/** * 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({ 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 {children} }