From 7d99a4852bb13197021fd9ba2ae71a6f3c03a4f5 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:27:42 +0800 Subject: [PATCH] feat: implement authentication and health check providers with hooks for user management --- apps/frontend/app/hooks/ResponseHelper.tsx | 4 +- apps/frontend/app/hooks/ensureLoggedIn.tsx | 48 ++++++++++++++++ .../app/providers/ApiHealthProvider.tsx | 56 +++++++++++++++++++ apps/frontend/app/providers/ApiProvider.tsx | 1 - apps/frontend/app/providers/AuthProvider.tsx | 47 ++++++++++++++++ apps/frontend/app/root.tsx | 10 +++- apps/frontend/app/routes/home.tsx | 2 + 7 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 apps/frontend/app/hooks/ensureLoggedIn.tsx create mode 100644 apps/frontend/app/providers/ApiHealthProvider.tsx create mode 100644 apps/frontend/app/providers/AuthProvider.tsx diff --git a/apps/frontend/app/hooks/ResponseHelper.tsx b/apps/frontend/app/hooks/ResponseHelper.tsx index 517a3ac..be1560a 100644 --- a/apps/frontend/app/hooks/ResponseHelper.tsx +++ b/apps/frontend/app/hooks/ResponseHelper.tsx @@ -104,7 +104,9 @@ const defaultResponseErrorHandler = return false; }; -export function useResponseErrorHandler() { +export function useResponseErrorHandler(): { + defaultResponseErrorHandler: ReturnType; +} { const navigate = useNavigate(); const location = useLocation(); return { defaultResponseErrorHandler: defaultResponseErrorHandler(navigate, location) }; diff --git a/apps/frontend/app/hooks/ensureLoggedIn.tsx b/apps/frontend/app/hooks/ensureLoggedIn.tsx new file mode 100644 index 0000000..e23a4ab --- /dev/null +++ b/apps/frontend/app/hooks/ensureLoggedIn.tsx @@ -0,0 +1,48 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router'; +import { useAuth } from '../providers/AuthProvider'; +import { useApi } from '../providers/ApiProvider'; +import { useQuery } from '@tanstack/react-query'; +import { useResponseErrorHandler } from './ResponseHelper'; + +export type EnsureLoggedInResult = { + checking: boolean; + loggedIn: boolean; +}; + +export function useEnsureLoggedIn(): EnsureLoggedInResult { + const { user, setUser } = useAuth(); + const navigate = useNavigate(); + const { tanstackApiClient } = useApi(); + const { defaultResponseErrorHandler } = useResponseErrorHandler(); + + const { queryOptions: currentUserQuery } = tanstackApiClient.get('/api/user/me'); + const { isFetched, isPending } = useQuery({ + ...currentUserQuery, + queryFn: async (...args) => { + try { + const data = await currentUserQuery.queryFn!(...args); + setUser({ + id: data.id, + name: data.username, + }); + return data; + } catch (error) { + if (defaultResponseErrorHandler(error)) return {} as never; + throw error; + } + }, + }); + + useEffect(() => { + if (user) { + navigate('/', { replace: true }); + return; + } + }, [user, setUser, navigate]); + + return { + checking: isPending, + loggedIn: isFetched && !!user, + }; +} diff --git a/apps/frontend/app/providers/ApiHealthProvider.tsx b/apps/frontend/app/providers/ApiHealthProvider.tsx new file mode 100644 index 0000000..225735c --- /dev/null +++ b/apps/frontend/app/providers/ApiHealthProvider.tsx @@ -0,0 +1,56 @@ +import { useNavigate } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { createContext, use, type PropsWithChildren } from 'react'; +import { useApi } from './ApiProvider'; +import { useResponseErrorHandler } from '../hooks/ResponseHelper'; +import type { Schemas } from '../generated/api-client/api-client'; + +export type HealthStatus = Schemas.HealthInfo; + +export type ApiHealthProviderProps = PropsWithChildren; +export type ApiHealthContextType = { + healthStatus: HealthStatus | undefined; +}; + +const ApiHealthContext = createContext(null); + +export const ApiHealthProvider: React.FC = ({ children }) => { + const navigate = useNavigate(); + const { tanstackApiClient } = useApi(); + const { defaultResponseErrorHandler } = useResponseErrorHandler(); + + const { queryOptions: healthInfoQuery } = tanstackApiClient.get('/api/health/info'); + const { data } = useQuery({ + ...healthInfoQuery, + queryFn: async (...args) => { + try { + const data = await healthInfoQuery.queryFn!(...args); + if (!data.is_initialized) { + navigate('/init'); + } + return data; + } catch (error) { + if (defaultResponseErrorHandler(error)) return {} as never; + throw error; + } + }, + }); + + return ( + + {children} + + ); +}; + +export const useApiHealth = (): ApiHealthContextType => { + const context = use(ApiHealthContext); + if (!context) { + throw new Error('useApiHealth must be used within an ApiHealthProvider'); + } + return context; +}; diff --git a/apps/frontend/app/providers/ApiProvider.tsx b/apps/frontend/app/providers/ApiProvider.tsx index 542e8f8..d178119 100644 --- a/apps/frontend/app/providers/ApiProvider.tsx +++ b/apps/frontend/app/providers/ApiProvider.tsx @@ -2,7 +2,6 @@ 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 ApiContextType = { diff --git a/apps/frontend/app/providers/AuthProvider.tsx b/apps/frontend/app/providers/AuthProvider.tsx new file mode 100644 index 0000000..4a7fea7 --- /dev/null +++ b/apps/frontend/app/providers/AuthProvider.tsx @@ -0,0 +1,47 @@ +import { createContext, use, useCallback, useState, type PropsWithChildren } from 'react'; + +export type User = { + id: string; + name: string; +}; + +export type AuthProviderProps = PropsWithChildren; +export type AuthContextType = { + setUser: (user: User) => void; + logOut: () => void; + user: User | null; +}; + +const AuthContext = createContext(null); + +export const AuthProvider: React.FC = ({ children }) => { + const [user, setUserState] = useState(null); + + const setUser = useCallback((user: User) => { + setUserState(user); + }, []); + + const logout = useCallback(() => { + setUserState(null); + }, []); + + return ( + + {children} + + ); +}; + +export function useAuth() { + const context = use(AuthContext); + if (!context) { + throw new Error('useAuth must be used within a AuthProvider'); + } + return context; +} diff --git a/apps/frontend/app/root.tsx b/apps/frontend/app/root.tsx index 57f86ec..cf027bb 100644 --- a/apps/frontend/app/root.tsx +++ b/apps/frontend/app/root.tsx @@ -6,6 +6,8 @@ import AppTheme from './components/theme'; import { ApiProvider } from './providers/ApiProvider'; import { LayoutProvider } from './providers/LayoutProvider'; import { Tooltip } from 'radix-ui'; +import { AuthProvider } from './providers/AuthProvider'; +import { ApiHealthProvider } from './providers/ApiHealthProvider'; export const links: Route.LinksFunction = () => []; @@ -17,6 +19,8 @@ export function Layout({ children }: { children: React.ReactNode }) { + {/* Required for react-toastify */} +