import React from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { authorizedFetch, clearTokens, isAuthError, loadToken, registerAuthFailureHandler, storePersonalAccessToken, } from './tokens'; import { invalidateTenantApiCache } from '../api'; export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; export interface TenantProfile { id: number; tenant_id: number; role?: string | null; name?: string; slug?: string; email?: string | null; features?: Record; [key: string]: unknown; } interface AuthContextValue { status: AuthStatus; user: TenantProfile | null; abilities: string[]; hasAbility: (ability: string) => boolean; refreshProfile: () => Promise; logout: (options?: { redirect?: string }) => Promise; applyToken: (token: string, abilities: string[]) => Promise; } const AuthContext = React.createContext(undefined); function getCsrfToken(): string | undefined { const meta = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null; return meta?.content; } async function exchangeSessionForToken(): Promise<{ token: string; abilities: string[] } | null> { const csrf = getCsrfToken(); try { const response = await fetch('/api/v1/tenant-auth/exchange', { method: 'POST', headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', ...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}), }, credentials: 'same-origin', }); if (response.status === 204) { return null; } if (!response.ok) { return null; } const data = (await response.json()) as { token: string; abilities?: string[] }; if (!data?.token) { return null; } return { token: data.token, abilities: Array.isArray(data.abilities) ? data.abilities : [], }; } catch (error) { if (import.meta.env.DEV) { console.warn('[Auth] Session exchange failed', error); } return null; } } export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [status, setStatus] = React.useState('loading'); const [user, setUser] = React.useState(null); const [abilities, setAbilities] = React.useState([]); const queryClient = useQueryClient(); const profileQueryKey = React.useMemo(() => ['tenantProfile'], []); const handleAuthFailure = React.useCallback(() => { clearTokens(); invalidateTenantApiCache(); queryClient.removeQueries({ queryKey: profileQueryKey }); setUser(null); setAbilities([]); setStatus('unauthenticated'); }, [profileQueryKey, queryClient]); React.useEffect(() => { const unsubscribe = registerAuthFailureHandler(handleAuthFailure); return unsubscribe; }, [handleAuthFailure]); const refreshProfile = React.useCallback(async () => { setStatus('loading'); try { const data = await queryClient.fetchQuery({ queryKey: profileQueryKey, queryFn: async () => { const response = await authorizedFetch('/api/v1/tenant-auth/me', { method: 'GET', }); if (!response.ok) { throw new Error('Failed to fetch tenant profile'); } return (await response.json()) as { user: TenantProfile | null; tenant?: Record | null; abilities: string[]; }; }, staleTime: 1000 * 60 * 5, retry: 1, }); const composed: TenantProfile | null = data.user && data.tenant ? { ...data.user, ...data.tenant } : data.user; setUser(composed ?? null); setAbilities(Array.isArray(data?.abilities) ? data.abilities : []); setStatus('authenticated'); } catch (error) { console.error('[Auth] Failed to refresh profile', error); if (isAuthError(error)) { handleAuthFailure(); } else { setStatus('unauthenticated'); } throw error; } }, [handleAuthFailure, profileQueryKey, queryClient]); const applyToken = React.useCallback(async (token: string, tokenAbilities: string[]) => { storePersonalAccessToken(token, tokenAbilities); setAbilities(tokenAbilities); await refreshProfile(); }, [refreshProfile]); React.useEffect(() => { let cancelled = false; const bootstrap = async () => { const stored = loadToken(); if (stored) { try { await refreshProfile(); return; } catch (error) { if (import.meta.env.DEV) { console.warn('[Auth] Stored token bootstrap failed', error); } } } const exchanged = await exchangeSessionForToken(); if (cancelled) { return; } if (exchanged) { await applyToken(exchanged.token, exchanged.abilities); return; } handleAuthFailure(); }; bootstrap().catch((error) => { if (import.meta.env.DEV) { console.error('[Auth] Failed to bootstrap authentication', error); } handleAuthFailure(); }); return () => { cancelled = true; }; }, [applyToken, handleAuthFailure, refreshProfile]); const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => { try { await authorizedFetch('/api/v1/tenant-auth/logout', { method: 'POST', }); } catch (error) { if (import.meta.env.DEV) { console.warn('[Auth] API logout failed', error); } } finally { try { const csrf = getCsrfToken(); await fetch('/logout', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', ...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}), }, credentials: 'same-origin', }); } catch (error) { if (import.meta.env.DEV) { console.warn('[Auth] Session logout failed', error); } } handleAuthFailure(); if (redirect) { window.location.href = redirect; } } }, [handleAuthFailure]); const hasAbility = React.useCallback((ability: string) => abilities.includes(ability), [abilities]); const value = React.useMemo( () => ({ status, user, abilities, hasAbility, refreshProfile, logout, applyToken }), [status, user, abilities, hasAbility, refreshProfile, logout, applyToken] ); return {children}; }; export function useAuth(): AuthContextValue { const context = React.useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; }