M2-T06: scaffold React SPA frontend with typed OpenAPI client
- Vite + React 18 + TypeScript + Mantine + TanStack Query + react-router-dom - typed client: openapi-typescript -> src/api/schema.d.ts (committed), openapi-fetch - fetch wrapper middleware: cookies, X-CSRF-Token on writes, 401 -> /login, non-401 errors carry parsed JSON body - SessionProvider/useSession (GET /api/session), ProtectedRoute skeleton - app shell (Mantine + router) with placeholder login/home/config pages + gear nav - dev proxy to FastAPI; vitest smoke test; frontend README - npm scripts: dev/build/preview/lint/typecheck/test/codegen
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* SessionProvider — fetches GET /api/session once on mount via TanStack Query.
|
||||
*
|
||||
* Contract (orchestrator-decisions.md §4, §11):
|
||||
* - 200 → authenticated; calls setCsrfToken(data.csrf_token) so write requests work.
|
||||
* - 401 → unauthenticated (not an error toast; normal state before login).
|
||||
* - Exposes { user, status } to descendants via useSession().
|
||||
*
|
||||
* Also registers the 401 → /login redirect with the API client middleware.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, type ReactNode } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import apiClient, { registerLoginRedirect } from '../api/client'
|
||||
import { setCsrfToken } from '../api/csrf'
|
||||
import type { components } from '../api/schema.d.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SessionUser = components['schemas']['SessionUser']
|
||||
|
||||
type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated'
|
||||
|
||||
interface SessionContextValue {
|
||||
user: SessionUser | null
|
||||
status: SessionStatus
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SessionContext = createContext<SessionContextValue>({
|
||||
user: null,
|
||||
status: 'loading',
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Access the current session from any descendant component. */
|
||||
export function useSession(): SessionContextValue {
|
||||
return useContext(SessionContext)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SessionProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: SessionProviderProps) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Register the 401 redirect callback with the API client once.
|
||||
useEffect(() => {
|
||||
registerLoginRedirect(() => {
|
||||
// Invalidate the session query so any subscriber re-fetches (→ unauthenticated).
|
||||
queryClient.invalidateQueries({ queryKey: ['session'] })
|
||||
navigate('/login', { replace: true })
|
||||
})
|
||||
}, [navigate, queryClient])
|
||||
|
||||
const { data, status, error } = useQuery({
|
||||
queryKey: ['session'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/session')
|
||||
// openapi-fetch returns { data, error, response }.
|
||||
// On 401 the middleware already navigates; here data will be undefined.
|
||||
return res.data ?? null
|
||||
},
|
||||
// Don't treat 401 as a React Query "error" — it's a normal unauthenticated state.
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
|
||||
// When we get session data, store the CSRF token.
|
||||
useEffect(() => {
|
||||
if (data?.csrf_token) {
|
||||
setCsrfToken(data.csrf_token)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
let sessionStatus: SessionStatus
|
||||
if (status === 'pending') {
|
||||
sessionStatus = 'loading'
|
||||
} else if (status === 'error' || data === null || !data) {
|
||||
// 401 returns null from our queryFn; any actual network error → unauthenticated.
|
||||
sessionStatus = 'unauthenticated'
|
||||
// Suppress unused variable warning for error in non-401 cases
|
||||
void error
|
||||
} else {
|
||||
sessionStatus = 'authenticated'
|
||||
}
|
||||
|
||||
const value: SessionContextValue = {
|
||||
user: data?.user ?? null,
|
||||
status: sessionStatus,
|
||||
}
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||
}
|
||||
Reference in New Issue
Block a user