From 227256e0e0748844d09e1bf090b358acb5956b5d Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:33:34 +0800 Subject: [PATCH] feat: implement frontend login functionality with form handling and error management --- apps/frontend/app/app.css | 10 +- apps/frontend/app/components/Form/Button.tsx | 36 +++++ .../app/components/Form/TextField.tsx | 93 ++++++++++++ .../app/components/home/TablePlaceholder.tsx | 27 ++++ .../app/components/layout/SidebarContent.tsx | 89 +++++++++++ apps/frontend/app/components/layout/types.ts | 1 + apps/frontend/app/components/theme.tsx | 16 ++ apps/frontend/app/hooks/ResponseHelper.tsx | 110 ++++++++++++++ apps/frontend/app/providers/ApiProvider.tsx | 29 +++- apps/frontend/app/providers/FormProvider.tsx | 18 +++ .../frontend/app/providers/LayoutProvider.tsx | 38 +++++ apps/frontend/app/root.tsx | 13 +- apps/frontend/app/routes.ts | 6 +- apps/frontend/app/routes/auth/login.tsx | 139 ++++++++++++++++++ apps/frontend/app/routes/home.tsx | 74 +++++++++- apps/frontend/app/routes/layout.tsx | 88 +++++++++++ apps/frontend/app/vite-env.d.ts | 5 +- 17 files changed, 765 insertions(+), 27 deletions(-) create mode 100644 apps/frontend/app/components/Form/Button.tsx create mode 100644 apps/frontend/app/components/Form/TextField.tsx create mode 100644 apps/frontend/app/components/home/TablePlaceholder.tsx create mode 100644 apps/frontend/app/components/layout/SidebarContent.tsx create mode 100644 apps/frontend/app/components/layout/types.ts create mode 100644 apps/frontend/app/components/theme.tsx create mode 100644 apps/frontend/app/hooks/ResponseHelper.tsx create mode 100644 apps/frontend/app/providers/FormProvider.tsx create mode 100644 apps/frontend/app/providers/LayoutProvider.tsx create mode 100644 apps/frontend/app/routes/auth/login.tsx create mode 100644 apps/frontend/app/routes/layout.tsx diff --git a/apps/frontend/app/app.css b/apps/frontend/app/app.css index 99345d8..107983d 100644 --- a/apps/frontend/app/app.css +++ b/apps/frontend/app/app.css @@ -1,15 +1,9 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @theme { - --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; } html, body { - @apply bg-white dark:bg-gray-950; - - @media (prefers-color-scheme: dark) { - color-scheme: dark; - } } diff --git a/apps/frontend/app/components/Form/Button.tsx b/apps/frontend/app/components/Form/Button.tsx new file mode 100644 index 0000000..deab319 --- /dev/null +++ b/apps/frontend/app/components/Form/Button.tsx @@ -0,0 +1,36 @@ +export type SubmitButtonProps = { + loading?: boolean; + label?: + | { + default: string; + loading: string; + } + | string; +} & React.ButtonHTMLAttributes; + +export function SubmitButton({ loading, label, ...props }: SubmitButtonProps) { + return ( + + ); +} + +export function ResetButton(props: React.ButtonHTMLAttributes) { + return ( + + ); +} diff --git a/apps/frontend/app/components/Form/TextField.tsx b/apps/frontend/app/components/Form/TextField.tsx new file mode 100644 index 0000000..bc25e1e --- /dev/null +++ b/apps/frontend/app/components/Form/TextField.tsx @@ -0,0 +1,93 @@ +import type { AnyFieldMeta } from '@tanstack/react-form'; +import { LucideEye, LucideEyeClosed } from 'lucide-react'; +import { useCallback, useId, useState } from 'react'; + +export type TextFieldProps = { + label?: string; + value?: string; + onChange?: (e: React.ChangeEvent) => void; + labelProps?: React.LabelHTMLAttributes; + labelDivProps?: React.HTMLAttributes; +} & React.InputHTMLAttributes & { + type?: 'password'; + showPasswordToggle?: boolean; + }; + +export function TextField({ label, value, onChange, labelProps, labelDivProps, showPasswordToggle, ...rest }: TextFieldProps) { + const id = useId(); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const handlePasswordVisibilitySet = useCallback( + (e: React.MouseEvent | React.TouchEvent, visible: boolean) => { + if (rest.type !== 'password') return; + e.preventDefault(); + setIsPasswordVisible(() => visible); + }, + [rest.type] + ); + return ( + + ); +} + +export type TextFieldErrorMessageProps = AnyFieldMeta & { + errorMessage?: string; +}; + +export function TextFieldErrorMessage({ isValid, errors, errorMessage }: TextFieldErrorMessageProps) { + return ( + !isValid && ( +
+ {errorMessage ?? errors?.reduce((msg, err) => msg + err.message + ' ', '')} +
+ ) + ); +} diff --git a/apps/frontend/app/components/home/TablePlaceholder.tsx b/apps/frontend/app/components/home/TablePlaceholder.tsx new file mode 100644 index 0000000..50645b4 --- /dev/null +++ b/apps/frontend/app/components/home/TablePlaceholder.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Flex, Text, Button, Separator, Box, Badge } from '@radix-ui/themes'; + +export default function TablePlaceholder() { + return ( + + + Proxy Hosts + + + + {[1, 2, 3].map((i) => ( + + + + {`host-${i}.example.com`} + + + {`http://10.0.0.${i}:8080`} + + + Online + + ))} + + ); +} diff --git a/apps/frontend/app/components/layout/SidebarContent.tsx b/apps/frontend/app/components/layout/SidebarContent.tsx new file mode 100644 index 0000000..c5f7ae8 --- /dev/null +++ b/apps/frontend/app/components/layout/SidebarContent.tsx @@ -0,0 +1,89 @@ +import type React from 'react'; +import { Box, Button, Flex, Heading, Separator, Text } from '@radix-ui/themes'; +import type { NavItem } from './types'; +import { Home, Globe, ArrowRight, Lock, Settings, User } from 'lucide-react'; +import { useLayout } from '../../providers/LayoutProvider'; + +const navItems: { label: NavItem; icon: React.ReactNode }[] = [ + { label: 'Dashboard', icon: }, + { label: 'Proxy Hosts', icon: }, + { label: 'Redirection', icon: }, + { label: 'SSL', icon: }, + { label: 'Settings', icon: }, + { label: 'Profile', icon: }, +] as const; + +export function SidebarContent() { + const { activeTab, setActiveTab, setIsMobileMenuOpen } = useLayout(); + + return ( + + + + Y + + + YANPM + + + + + {navItems.map((item) => ( + + ))} + + + + + + + + + Admin User + + + admin@example.com + + + + + + ); +} + +export default SidebarContent; diff --git a/apps/frontend/app/components/layout/types.ts b/apps/frontend/app/components/layout/types.ts new file mode 100644 index 0000000..c2159ce --- /dev/null +++ b/apps/frontend/app/components/layout/types.ts @@ -0,0 +1 @@ +export type NavItem = 'Dashboard' | 'Proxy Hosts' | 'Redirection' | 'SSL' | 'Settings' | 'Profile'; diff --git a/apps/frontend/app/components/theme.tsx b/apps/frontend/app/components/theme.tsx new file mode 100644 index 0000000..4426a55 --- /dev/null +++ b/apps/frontend/app/components/theme.tsx @@ -0,0 +1,16 @@ +import type React from 'react'; +import { Theme } from '@radix-ui/themes'; + +export type AppThemeProps = { + children: React.ReactNode; +}; + +export function AppTheme({ children }: AppThemeProps) { + return ( + + {children} + + ); +} + +export default AppTheme; diff --git a/apps/frontend/app/hooks/ResponseHelper.tsx b/apps/frontend/app/hooks/ResponseHelper.tsx new file mode 100644 index 0000000..61aa295 --- /dev/null +++ b/apps/frontend/app/hooks/ResponseHelper.tsx @@ -0,0 +1,110 @@ +import { Text } from '@radix-ui/themes'; +import { AxiosError } from 'axios'; +import { useLocation, useNavigate } from 'react-router'; +import { toast } from 'react-toastify'; + +export enum ResponseErrorToastId { + NetworkError = 'network-error', +} + +export type DefaultResponseErrorHandlerOptions = { + disableUnauthorizedHandling?: boolean; + disableHandleUnexpectedErrors?: boolean; + disableIgnoreCanceledRequests?: boolean; +}; + +/** + * + * @param err error value + * @returns {boolean} true if the error was handled, false otherwise + */ +const defaultResponseErrorHandler = + (navigate: ReturnType, location: ReturnType) => + (err: unknown, options?: DefaultResponseErrorHandlerOptions): boolean => { + if (!(err instanceof AxiosError) && !options?.disableHandleUnexpectedErrors) { + toast.error( +
+ Unexpected Error: +
An unexpected error occurred. Please try again later. +
, + { + position: 'top-center', + autoClose: false, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: false, + progress: undefined, + theme: 'colored', + } + ); + return true; + } + + if (!(err instanceof AxiosError)) return false; + + if (err.message === 'canceled') { + // request was aborted, ignore but return true to indicate it was handled + return !options?.disableIgnoreCanceledRequests; + } + + if (err.message === 'Network Error') { + toast.error( +
+ Network Error: +
Unable to reach the server. Please check your internet connection and try again. +
, + { + toastId: ResponseErrorToastId.NetworkError, + position: 'top-center', + autoClose: false, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: false, + progress: undefined, + theme: 'colored', + } + ); + return true; + } + + // handle 401 Unauthorized globally + if (err.status === 401 && !options?.disableUnauthorizedHandling) { + // store current path for redirect after login + const currentPath = location.pathname + location.search; + const searchParam = new URLSearchParams(); + searchParam.set('redirect', currentPath); + searchParam.set('message', 'Session expired, please log in again'); + navigate(`/login?${searchParam.toString()}`); + return true; + } + + if (err.status === 403) { + toast.error( +
+ Forbidden: +
You do not have permission to perform this action. +
, + { + position: 'top-center', + autoClose: 5000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: false, + progress: undefined, + theme: 'colored', + } + ); + return true; + } + + return false; + }; + +export function useResponseErrorHandler() { + const navigate = useNavigate(); + const location = useLocation(); + return { defaultResponseErrorHandler: defaultResponseErrorHandler(navigate, location) }; +} diff --git a/apps/frontend/app/providers/ApiProvider.tsx b/apps/frontend/app/providers/ApiProvider.tsx index e8e1176..cf2838a 100644 --- a/apps/frontend/app/providers/ApiProvider.tsx +++ b/apps/frontend/app/providers/ApiProvider.tsx @@ -1,9 +1,10 @@ -import { createContext, use, useContext, type PropsWithChildren } from 'react'; +import { createContext, use, type PropsWithChildren } from 'react'; import { createTanstackApi, createApi } from '../lib/api'; import axios from 'axios'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useNavigate } from 'react-router'; -type ApiProviderProps = PropsWithChildren<{}>; +type ApiProviderProps = PropsWithChildren; type ApiContextType = { apiClient: ReturnType; tanstackApiClient: ReturnType; @@ -31,11 +32,33 @@ const queryClient = new QueryClient(); */ export const ApiProvider: React.FC = ({ children }) => { + const navigate = useNavigate(); const axiosInstance = axios.create({ withCredentials: true, }); + + const internalAxiosInstance = axios.create({ + withCredentials: true, + }); + + internalAxiosInstance.interceptors.response.use( + (response) => response, + (error) => { + // only handle 403 errors + if (!error.response) return Promise.reject(error); + + if (error.response.status === 401) { + // redirect to login page + return navigate('/login', {}); + } + + return Promise.reject(error); + } + ); + const apiClient = createApi(axiosInstance); - const tanstackApiClient = createTanstackApi(axiosInstance); + const tanstackApiClient = createTanstackApi(internalAxiosInstance); + return ( ; +type LayoutContextType = { + activeTab: NavItem; + setActiveTab: (tab: NavItem) => void; + isMobileMenuOpen: boolean; + setIsMobileMenuOpen: (open: boolean) => void; +}; + +const LayoutContext = createContext(null); + +export const LayoutProvider: React.FC = ({ children }) => { + const [activeTab, setActiveTab] = useState('Dashboard'); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + + return ( + + {children} + + ); +}; + +export function useLayout() { + const context = use(LayoutContext); + if (!context) { + throw new Error('useLayout must be used within a LayoutProvider'); + } + return context; +} diff --git a/apps/frontend/app/root.tsx b/apps/frontend/app/root.tsx index 2041ae0..a4d1e9f 100644 --- a/apps/frontend/app/root.tsx +++ b/apps/frontend/app/root.tsx @@ -1,8 +1,11 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; import type { Route } from './+types/root'; +import '@radix-ui/themes/styles.css'; import './app.css'; -import { Theme } from '@radix-ui/themes'; +import AppTheme from './components/theme'; import { ApiProvider } from './providers/ApiProvider'; +import { LayoutProvider } from './providers/LayoutProvider'; +// import { LayoutContainer } from './routes/layout'; export const links: Route.LinksFunction = () => []; @@ -26,11 +29,13 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { return ( - + - + + + - + ); } diff --git a/apps/frontend/app/routes.ts b/apps/frontend/app/routes.ts index 9411f45..3527a78 100644 --- a/apps/frontend/app/routes.ts +++ b/apps/frontend/app/routes.ts @@ -1,7 +1,9 @@ -import { type RouteConfig, index, route } from '@react-router/dev/routes'; +import { type RouteConfig, index, layout, route } from '@react-router/dev/routes'; export default [ - index('routes/home.tsx'), + route('login', 'routes/auth/login.tsx'), + layout('routes/layout.tsx', [index('routes/home.tsx')]), + // route('init', 'routes/init.tsx'), // catch-all 404 route route('*', 'routes/404.tsx'), ] satisfies RouteConfig; diff --git a/apps/frontend/app/routes/auth/login.tsx b/apps/frontend/app/routes/auth/login.tsx new file mode 100644 index 0000000..b2cd892 --- /dev/null +++ b/apps/frontend/app/routes/auth/login.tsx @@ -0,0 +1,139 @@ +import { Box, Container, Flex, Heading } from '@radix-ui/themes'; +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from 'react-router'; +import { Slide, toast, ToastContainer } from 'react-toastify'; +import * as v from 'valibot'; +import { useResponseErrorHandler } from '../../hooks/ResponseHelper'; +import { useApi } from '../../providers/ApiProvider'; +import { formHook } from '../../providers/FormProvider'; +import type { Route } from './+types/login'; + +const loginFormSchema = v.object({ + username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')), + password: v.pipe(v.string(), v.minLength(1, 'Password is required')), +}); + +// eslint-disable-next-line no-empty-pattern +export function meta({}: Route.MetaArgs): Route.MetaDescriptors { + return [{ title: 'Login | YANPM' }]; +} + +export default function LoginRoute() { + const navigate = useNavigate(); + const { tanstackApiClient } = useApi(); + const { defaultResponseErrorHandler } = useResponseErrorHandler(); + + const { mutateAsync: login, isPending } = useMutation({ + ...tanstackApiClient.mutation('post', '/api/auth/login').mutationOptions, + onSuccess: async () => { + navigate('/'); + }, + onError: (error) => { + if (defaultResponseErrorHandler(error)) return; + console.error('Login failed:', error); + }, + }); + + const form = formHook.useAppForm({ + defaultValues: { + username: '', + password: '', + }, + validators: { + onBlur: loginFormSchema, + onSubmit: loginFormSchema, + }, + + onSubmit: async ({ value }) => { + toast.dismiss(); + return await login({ body: { password: value.password, username: value.username } }); + }, + }); + + return ( + <> + + + + + Sign In + +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > + ( + <> + field.handleChange(e.target.value)} + /> + + + )} + /> + + ( + <> + field.handleChange(e.target.value)} + showPasswordToggle + /> + + + )} + /> + +
+ +
+ +
+
+
+ + + ); +} diff --git a/apps/frontend/app/routes/home.tsx b/apps/frontend/app/routes/home.tsx index 8a5c77b..8e15814 100644 --- a/apps/frontend/app/routes/home.tsx +++ b/apps/frontend/app/routes/home.tsx @@ -1,13 +1,73 @@ -import { Text } from '@radix-ui/themes'; +import { Box, Button, Card, Flex, Grid, Heading, Text } from '@radix-ui/themes'; import type { Route } from './+types/home'; -import { useContext } from 'react'; -import { useApi } from '../providers/ApiProvider'; -import { useQuery } from '@tanstack/react-query'; +import TablePlaceholder from '../components/home/TablePlaceholder'; +import { useLayout } from '../providers/LayoutProvider'; +// eslint-disable-next-line no-empty-pattern export function meta({}: Route.MetaArgs) { - return [{ title: 'YANPM' }, { name: 'description', content: 'Welcome to Yet Another Nginx Proxy Manager!' }]; + return [{ title: 'Proxy Host Demo | YANPM' }, { name: 'description', content: 'Demo of the unified navigation paradigm.' }]; } -export default function Home() { - return Welcome to Yet Another Nginx Proxy Manager!; +export default function ProxyHostDemo() { + const { activeTab } = useLayout(); + return ( + + + {activeTab} + + + This is the {activeTab.toLowerCase()} page demo. + + + + + + + Status Overview + + + Everything is running smoothly in your {activeTab.toLowerCase()} section. + + + + + + + + Recent Activity + + + No recent changes detected in the last 24 hours. + + + + + + + + Quick Actions + + + Common tasks related to {activeTab.toLowerCase()} are available here. + + + + + + + {activeTab === 'Proxy Hosts' && ( + + + + + + )} + + ); } diff --git a/apps/frontend/app/routes/layout.tsx b/apps/frontend/app/routes/layout.tsx new file mode 100644 index 0000000..0a9d90e --- /dev/null +++ b/apps/frontend/app/routes/layout.tsx @@ -0,0 +1,88 @@ +import { Flex, Box, Container, Dialog, Heading, IconButton, TextField } from '@radix-ui/themes'; +import SidebarContent from '../components/layout/SidebarContent'; +import { useLayout } from '../providers/LayoutProvider'; +import { Menu, Search, Bell } from 'lucide-react'; +import { Outlet } from 'react-router'; + +export default function LayoutContainer() { + const { activeTab, isMobileMenuOpen, setIsMobileMenuOpen } = useLayout(); + return ( + + {/* Desktop Sidebar */} + + + + + {/* Main Content Area */} + + {' '} + {/* Top Header (Mobile & Desktop) */} + + + + + + + + + + + + + + + {activeTab} + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/frontend/app/vite-env.d.ts b/apps/frontend/app/vite-env.d.ts index 32e46ee..3f3a649 100644 --- a/apps/frontend/app/vite-env.d.ts +++ b/apps/frontend/app/vite-env.d.ts @@ -1,7 +1,6 @@ interface ViteTypeOptions { - // By adding this line, you can make the type of ImportMetaEnv strict - // to disallow unknown keys. - // strictImportMetaEnv: unknown + // disallow unknown keys. + strictImportMetaEnv: unknown; } interface ImportMetaEnv {