import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import { AuthContext } from './AuthContext' import { makeCan } from './permissions' import { qk } from '../lib/queryKeys' import { setSessionExpiredHandler } from '../lib/apiClient' import { subscribe, getSession, setSession, mergeUser, clearSession } from '../lib/tokenStore' import * as authApi from '../api/auth' import * as usersApi from '../api/users' const emptySnapshot = () => null export default function AuthProvider({ children }) { const session = useSyncExternalStore(subscribe, getSession, emptySnapshot) const navigate = useNavigate() const qc = useQueryClient() // apiClient calls this when a refresh fails outright. Registered here so the // HTTP layer never has to know about the router. useEffect(() => { setSessionExpiredHandler(() => { clearSession() qc.clear() navigate('/auth/login?expired=1', { replace: true }) }) return () => setSessionExpiredHandler(() => {}) }, [navigate, qc]) // Permission bootstrap. This is a query rather than a useEffect fetch on // purpose: TanStack Query dedupes, so React 19 StrictMode's double mount // issues ONE request instead of two. const me = useQuery({ queryKey: qk.auth.me(), queryFn: () => usersApi.me().then((r) => r.data), enabled: Boolean(session?.access_token), staleTime: 5 * 60_000, retry: false, }) // Fold `permissions` into the stored session so a page reload has them before // /users/me resolves — otherwise the nav flickers on every refresh. useEffect(() => { if (me.data) mergeUser(me.data) }, [me.data]) const signIn = useCallback( async (email, password) => { const res = await authApi.login(email, password) setSession(res) // Refetch /users/me for the new user; the old one's permissions must not leak. await qc.invalidateQueries({ queryKey: qk.auth.me() }) return res }, [qc], ) const signOut = useCallback(() => { // Client-side only: the backend has no logout endpoint, no denylist and no // jti tracking, so the refresh token stays valid until its 7-day exp. clearSession() qc.clear() navigate('/auth/login', { replace: true }) }, [navigate, qc]) const user = me.data ?? session?.data ?? null const permissions = me.data?.permissions ?? session?.data?.permissions ?? null const status = !session?.access_token ? 'anonymous' : me.isError ? 'error' : me.data || permissions ? 'authenticated' : 'loading' const value = useMemo( () => ({ session, user, permissions, status, can: makeCan(permissions), isAuthenticated: status === 'authenticated', signIn, signOut, setSession, }), [session, user, permissions, status, signIn, signOut], ) return {children} }