110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
|
|
/**
|
||
|
|
* 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>
|
||
|
|
}
|