feature/frontend-login #10

Merged
GW_MC merged 24 commits from feature/frontend-login into master 2025-12-20 19:01:07 +08:00
8 changed files with 287 additions and 160 deletions
Showing only changes of commit 0260a03e1b - Show all commits

View File

@@ -0,0 +1 @@
/* intentionally empty: used to stub react-toastify CSS in production builds */

View File

@@ -1,8 +1,11 @@
import { Text } from '@radix-ui/themes'; import { Text } from '@radix-ui/themes';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { useLocation, useNavigate } from 'react-router'; import { useLocation, useNavigate } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify/unstyled';
import { SearchParamKeys } from '../lib/constants'; import { SearchParamKeys } from '../lib/constants';
import { useQueryMessage } from './useQueryMessage';
import { QueryMessageCode, QueryMessageType } from '../lib/QueryMessages';
import { useCallback } from 'react';
export enum ResponseErrorToastId { export enum ResponseErrorToastId {
NetworkError = 'network-error', NetworkError = 'network-error',
@@ -19,95 +22,99 @@ export type DefaultResponseErrorHandlerOptions = {
* @param err error value * @param err error value
* @returns {boolean} true if the error was handled, false otherwise * @returns {boolean} true if the error was handled, false otherwise
*/ */
const defaultResponseErrorHandler =
(navigate: ReturnType<typeof useNavigate>, location: ReturnType<typeof useLocation>) =>
(err: unknown, options?: DefaultResponseErrorHandlerOptions): boolean => {
if (!(err instanceof AxiosError) && !options?.disableHandleUnexpectedErrors) {
toast.error(
<div>
<Text weight="bold">Unexpected Error:</Text>
<br /> An unexpected error occurred. Please try again later.
</div>,
{
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(
<div>
<Text weight="bold">Network Error:</Text>
<br /> Unable to reach the server. Please check your internet connection and try again.
</div>,
{
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(SearchParamKeys.Redirect, currentPath);
searchParam.set(SearchParamKeys.Message, 'Session expired, please log in again');
navigate(`/login?${searchParam.toString()}`);
return true;
}
if (err.status === 403) {
toast.error(
<div>
<Text weight="bold">Forbidden:</Text>
<br /> You do not have permission to perform this action.
</div>,
{
position: 'top-center',
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
}
);
return true;
}
return false;
};
export function useResponseErrorHandler(): { export function useResponseErrorHandler(): {
defaultResponseErrorHandler: ReturnType<typeof defaultResponseErrorHandler>; defaultResponseErrorHandler: typeof defaultResponseErrorHandler;
} { } {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
return { defaultResponseErrorHandler: defaultResponseErrorHandler(navigate, location) }; const { toSearchParamQueryMessage } = useQueryMessage();
const defaultResponseErrorHandler = useCallback(
(err: unknown, options?: DefaultResponseErrorHandlerOptions): boolean => {
if (!(err instanceof AxiosError) && !options?.disableHandleUnexpectedErrors) {
toast.error(
<div>
<Text weight="bold">Unexpected Error:</Text>
<br /> An unexpected error occurred. Please try again later.
</div>,
{
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(
<div>
<Text weight="bold">Network Error:</Text>
<br /> Unable to reach the server. Please check your internet connection and try again.
</div>,
{
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(SearchParamKeys.Redirect, currentPath);
searchParam.set(SearchParamKeys.Message, toSearchParamQueryMessage(QueryMessageCode.SessionExpired, QueryMessageType.Info));
navigate(`/login?${searchParam.toString()}`);
return true;
}
if (err.status === 403) {
toast.error(
<div>
<Text weight="bold">Forbidden:</Text>
<br /> You do not have permission to perform this action.
</div>,
{
position: 'top-center',
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
}
);
return true;
}
return false;
},
[location, navigate, toSearchParamQueryMessage]
);
return { defaultResponseErrorHandler };
} }

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { toast } from 'react-toastify/unstyled';
import { SearchParamKeys } from '../lib/constants';
import { CODE_TO_MESSAGE_MAP, QueryMessageCode, QueryMessageType } from '../lib/QueryMessages';
type QueryMessageString = `${QueryMessageCode}__${QueryMessageType}`;
export type QueryMessage = {
type: QueryMessageType;
code: QueryMessageCode;
message: ReactNode;
};
export type UseQueryMessageOptions = {
displayMessages?: boolean;
};
export type UseQueryMessageReturn = {
setQueryMessage: (messageCode: QueryMessageCode, messageType: QueryMessageType) => void;
clearQueryMessage: () => void;
toSearchParamQueryMessage: (message: QueryMessageCode, type: QueryMessageType) => QueryMessageString;
};
export function useQueryMessage(
{ displayMessages }: UseQueryMessageOptions = {
displayMessages: true,
}
): UseQueryMessageReturn {
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const messageStr = useRef<QueryMessageString | null>(null);
useEffect(() => {
// Reset messageStr when location changes to allow re-displaying the same message on navigation
messageStr.current = null;
}, [location.pathname]);
useEffect(() => {
const queryMessageStr = searchParams.get(SearchParamKeys.Message);
if (!(queryMessageStr && queryMessageStr !== messageStr.current)) return;
const [queryMessage, queryMessageString] = toQueryMessage(queryMessageStr) ?? [null, null];
if (!queryMessage) return;
messageStr.current = queryMessageString;
if (displayMessages) {
toast[queryMessage.type](queryMessage.code, {
position: 'top-center',
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
toastId: 'login-route-info-message',
});
}
}, [displayMessages, searchParams]);
const setQueryMessage = useCallback(
(messageCode: QueryMessageCode, messageType: QueryMessageType) => {
const queryMessageString: QueryMessageString = `${messageCode}__${messageType}`;
messageStr.current = queryMessageString;
setSearchParams((prev) => {
prev.set(SearchParamKeys.Message, queryMessageString);
return prev;
});
},
[setSearchParams]
);
const clearQueryMessage = useCallback(() => {
messageStr.current = null;
setSearchParams((prev) => {
prev.delete(SearchParamKeys.Message);
return prev;
});
}, [setSearchParams]);
const toSearchParamQueryMessage = useCallback((message: QueryMessageCode, type: QueryMessageType): QueryMessageString => {
return `${message}__${type}`;
}, []);
return {
setQueryMessage,
clearQueryMessage,
toSearchParamQueryMessage,
};
}
function isValidQueryMessageCode(code: string): code is QueryMessageCode {
return Object.values(QueryMessageCode).includes(code as QueryMessageCode);
}
function isValidQueryMessageType(type: string): type is QueryMessageType {
return Object.values(QueryMessageType).includes(type as QueryMessageType);
}
function toQueryMessage(value: string): [QueryMessage, QueryMessageString] | null {
const [code, type] = value.split('__');
if (!isValidQueryMessageCode(code) || !isValidQueryMessageType(type)) return null;
return [
{
code: code,
type: type,
message: CODE_TO_MESSAGE_MAP[code],
},
`${code}__${type}`,
];
}

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
export enum QueryMessageType {
Info = 'info',
Success = 'success',
Warning = 'warning',
Error = 'error',
}
export enum QueryMessageCode {
SessionExpired = 'SESSION_EXPIRED',
InitializationRequired = 'INITIALIZATION_REQUIRED',
InitializationSuccessful = 'INITIALIZATION_SUCCESSFUL',
}
export const CODE_TO_MESSAGE_MAP: Record<QueryMessageCode, ReactNode> = {
[QueryMessageCode.SessionExpired]: 'Your session has expired. Please log in again.',
[QueryMessageCode.InitializationRequired]: 'The application requires initialization. Please follow the setup instructions.',
[QueryMessageCode.InitializationSuccessful]: 'Initialization successful. Please log in.',
} as const;

View File

@@ -2,6 +2,11 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration }
import type { Route } from './+types/root'; import type { Route } from './+types/root';
import '@radix-ui/themes/styles.css'; import '@radix-ui/themes/styles.css';
import './app.css'; import './app.css';
// start: react-toastify special import
// ! MUST use unstyled version for dev server build, styled version for production build is handled in vite.config.ts
import { ToastContainer } from 'react-toastify/unstyled';
import 'react-toastify/ReactToastify.css';
// end: react-toastify special import
import AppTheme from './components/theme'; import AppTheme from './components/theme';
import { ApiProvider } from './providers/ApiProvider'; import { ApiProvider } from './providers/ApiProvider';
import { LayoutProvider } from './providers/LayoutProvider'; import { LayoutProvider } from './providers/LayoutProvider';
@@ -19,8 +24,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta /> <Meta />
<Links /> <Links />
{/* Required for react-toastify */}
<style />
</head> </head>
<body> <body>
{children} {children}
@@ -33,19 +36,23 @@ export function Layout({ children }: { children: React.ReactNode }) {
export default function App() { export default function App() {
return ( return (
<AppTheme> <>
<ApiProvider> <AppTheme>
<Tooltip.Provider delayDuration={250}> <ApiProvider>
<LayoutProvider> <Tooltip.Provider delayDuration={250}>
<ApiHealthProvider> <LayoutProvider>
<AuthProvider> <ApiHealthProvider>
<Outlet /> <AuthProvider>
</AuthProvider> <Outlet />
</ApiHealthProvider> </AuthProvider>
</LayoutProvider> </ApiHealthProvider>
</Tooltip.Provider> </LayoutProvider>
</ApiProvider> </Tooltip.Provider>
</AppTheme> </ApiProvider>
</AppTheme>
<ToastContainer />
</>
); );
} }

View File

@@ -1,15 +1,15 @@
import { Box, Container, Flex, Heading } from '@radix-ui/themes'; import { Box, Container, Flex, Heading } from '@radix-ui/themes';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useLocation, useNavigate } from 'react-router'; import { useLocation, useNavigate } from 'react-router';
import { Slide, toast, ToastContainer } from 'react-toastify'; import { toast } from 'react-toastify/unstyled';
import * as v from 'valibot'; import * as v from 'valibot';
import { useResponseErrorHandler } from '../../hooks/ResponseHelper'; import { useResponseErrorHandler } from '../../hooks/ResponseHelper';
import { useApi } from '../../providers/ApiProvider'; import { useApi } from '../../providers/ApiProvider';
import { formHook } from '../../providers/FormProvider'; import { formHook } from '../../providers/FormProvider';
import type { Route } from './+types/login'; import type { Route } from './+types/login';
import { SearchParamKeys } from '../../lib/constants'; import { SearchParamKeys } from '../../lib/constants';
import { useEffect, useState } from 'react';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { useQueryMessage } from '../../hooks/useQueryMessage';
const loginFormSchema = v.object({ const loginFormSchema = v.object({
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')), username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
@@ -27,7 +27,7 @@ export default function LoginRoute() {
const location = useLocation(); const location = useLocation();
const { tanstackApiClient } = useApi(); const { tanstackApiClient } = useApi();
const { defaultResponseErrorHandler } = useResponseErrorHandler(); const { defaultResponseErrorHandler } = useResponseErrorHandler();
const [previousSearchParamMessage, setPreviousSearchParamMessage] = useState<string>(''); useQueryMessage();
const { mutateAsync: login, isPending } = useMutation({ const { mutateAsync: login, isPending } = useMutation({
...tanstackApiClient.mutation('post', '/api/auth/login').mutationOptions, ...tanstackApiClient.mutation('post', '/api/auth/login').mutationOptions,
@@ -75,25 +75,6 @@ export default function LoginRoute() {
}, },
}); });
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const message = searchParams.get(SearchParamKeys.Message);
if (message && message !== previousSearchParamMessage) {
setPreviousSearchParamMessage(message);
toast.info(message, {
position: 'top-center',
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
progress: undefined,
theme: 'colored',
toastId: 'login-route-info-message',
});
}
}, [location.search]);
return ( return (
<> <>
<Flex align="center" justify="center" style={{ minHeight: 'calc(100vh - 64px)' }}> <Flex align="center" justify="center" style={{ minHeight: 'calc(100vh - 64px)' }}>
@@ -167,17 +148,6 @@ export default function LoginRoute() {
</Box> </Box>
</Container> </Container>
</Flex> </Flex>
<ToastContainer
position="top-center"
autoClose={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable={false}
theme="colored"
transition={Slide}
/>
</> </>
); );
} }

View File

@@ -1,13 +1,15 @@
import { Box, Container, Flex, Heading, Text } from '@radix-ui/themes'; import { Box, Container, Flex, Heading, Text } from '@radix-ui/themes';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { Slide, toast, ToastContainer } from 'react-toastify'; import { toast } from 'react-toastify/unstyled';
import * as v from 'valibot'; import * as v from 'valibot';
import { useResponseErrorHandler } from '../hooks/ResponseHelper'; import { useResponseErrorHandler } from '../hooks/ResponseHelper';
import { useApi } from '../providers/ApiProvider'; import { useApi } from '../providers/ApiProvider';
import { formHook } from '../providers/FormProvider'; import { formHook } from '../providers/FormProvider';
import { TooltipContentContainer } from '../components/info'; import { TooltipContentContainer } from '../components/info';
import { SearchParamKeys } from '../lib/constants'; import { SearchParamKeys } from '../lib/constants';
import { useQueryMessage } from '../hooks/useQueryMessage';
import { QueryMessageCode, QueryMessageType } from '../lib/QueryMessages';
const initFormSchema = v.object({ const initFormSchema = v.object({
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')), username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
@@ -19,12 +21,13 @@ export default function InitRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const { tanstackApiClient } = useApi(); const { tanstackApiClient } = useApi();
const { defaultResponseErrorHandler } = useResponseErrorHandler(); const { defaultResponseErrorHandler } = useResponseErrorHandler();
const { toSearchParamQueryMessage } = useQueryMessage();
const { mutateAsync: initAdmin, isPending } = useMutation({ const { mutateAsync: initAdmin, isPending } = useMutation({
...tanstackApiClient.mutation('post', '/api/auth/init_admin').mutationOptions, ...tanstackApiClient.mutation('post', '/api/auth/init_admin').mutationOptions,
onSuccess: async () => { onSuccess: async () => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
searchParams.set(SearchParamKeys.Message, 'Initialization successful. Please log in.'); searchParams.set(SearchParamKeys.Message, toSearchParamQueryMessage(QueryMessageCode.InitializationSuccessful, QueryMessageType.Success));
navigate(`/login?${searchParams.toString()}`); navigate(`/login?${searchParams.toString()}`);
}, },
onError: (error) => { onError: (error) => {
@@ -40,7 +43,7 @@ export default function InitRoute() {
try { try {
const data = await healthInfoQuery.queryFn!(...args); const data = await healthInfoQuery.queryFn!(...args);
if (data.is_initialized) { if (data.is_initialized) {
navigate('/'); navigate('/login', { replace: true });
return data; return data;
} }
return data; return data;
@@ -153,18 +156,6 @@ export default function InitRoute() {
</Box> </Box>
</Container> </Container>
</Flex> </Flex>
<ToastContainer
position="top-center"
autoClose={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable={false}
theme="colored"
transition={Slide}
/>
</> </>
); );
} }

View File

@@ -5,14 +5,34 @@ import tsconfigPaths from 'vite-tsconfig-paths';
// @ts-expect-error vite-plugin-eslint has no types // @ts-expect-error vite-plugin-eslint has no types
import eslint from 'vite-plugin-eslint'; import eslint from 'vite-plugin-eslint';
export default defineConfig({ export default defineConfig(({ command }) => {
plugins: [ const isBuild = command === 'build';
tailwindcss(),
reactRouter(), return {
tsconfigPaths(), plugins: [
eslint({ tailwindcss(),
failOnError: false, reactRouter(),
}), tsconfigPaths(),
], eslint({
appType: 'spa', failOnError: false,
}),
],
resolve: {
alias: isBuild
? [
{
// replace unstyled import with styled for SPA build
find: 'react-toastify/unstyled',
replacement: 'react-toastify',
},
{
// point to the empty CSS file to stub out the import during build, SPA build does not require extra CSS imports
find: 'react-toastify/ReactToastify.css',
replacement: '~/empty-toastify.css',
},
]
: [],
},
appType: 'spa',
};
}); });