'use client'; import * as React from 'react'; import { apiClient, authInterceptor, fetchRefreshToken } from '@/lib/api/client'; import { clearSessionCookie, setSessionCookie } from '@/lib/auth/session-cookie'; import { decodeJwt, type JwtPayload } from '@/lib/auth/jwt'; interface LoginResponse { access_token: string; } type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; interface AuthContextValue { status: AuthStatus; user: JwtPayload | null; loginMaster: (email: string, senha: string) => Promise; loginTenant: (email: string, senha: string) => Promise; logout: () => Promise; } const AuthContext = React.createContext(null); /** * Fonte única de sessão do app (Etapa 13). Access token só existe em * memória (`tokenRef`/estado do React) — nunca em `localStorage`. O * refresh token real vive só no cookie httpOnly do backend; este * Provider nunca o lê nem o guarda, só dispara `POST /auth/refresh` (que * o browser já anexa o cookie automaticamente) e reage ao resultado. */ export function AuthProvider({ children }: { children: React.ReactNode }) { const tokenRef = React.useRef(null); const [status, setStatus] = React.useState('loading'); const [user, setUser] = React.useState(null); // Deduplica chamadas concorrentes a `refreshSession` (ver comentário abaixo). const refreshPromiseRef = React.useRef | null>(null); const applyToken = React.useCallback((token: string | null) => { tokenRef.current = token; setUser(token ? decodeJwt(token) : null); setStatus(token ? 'authenticated' : 'unauthenticated'); }, []); /** * Duas fontes independentes podem chamar isto quase ao mesmo tempo logo * após um reload de página protegida: o efeito de montagem deste * Provider (recupera a sessão) e `authInterceptor.onUnauthorized` * (disparado por um 401 de uma request que saiu antes do token estar * pronto). Sem deduplicar, a 2ª chamada reenvia o mesmo cookie de * refresh que a 1ª já rotacionou — o backend trata isso como reuso de * token revogado e revoga a família inteira (Etapa 2), derrubando a * sessão que a 1ª chamada acabou de validar. Compartilhar a mesma * promise entre chamadas concorrentes evita a corrida. */ const refreshSession = React.useCallback((): Promise => { if (refreshPromiseRef.current) return refreshPromiseRef.current; const promise = (async () => { try { const result = await fetchRefreshToken(); if (!result) { applyToken(null); clearSessionCookie(); return false; } applyToken(result.access_token); return true; } finally { refreshPromiseRef.current = null; } })(); refreshPromiseRef.current = promise; return promise; }, [applyToken]); // Gancho deixado pronto na Etapa 11: liga o client HTTP à sessão atual. React.useEffect(() => { authInterceptor.getAccessToken = () => tokenRef.current; authInterceptor.onUnauthorized = refreshSession; }, [refreshSession]); // Recupera a sessão em memória após um reload de página (o access token // não sobrevive a isso de propósito — só o cookie httpOnly sobrevive). React.useEffect(() => { void refreshSession(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const loginMaster = React.useCallback( async (email: string, senha: string) => { const tokens = await apiClient.post('/auth/master/login', { email, senha }); applyToken(tokens.access_token); setSessionCookie('master'); }, [applyToken], ); const loginTenant = React.useCallback( async (email: string, senha: string) => { const tokens = await apiClient.post('/auth/tenant/login', { email, senha }); applyToken(tokens.access_token); setSessionCookie('tenant'); }, [applyToken], ); const logout = React.useCallback(async () => { try { await apiClient.post('/auth/logout', {}); } catch { // mesmo se a chamada falhar (ex: rede), a sessão local é limpa de qualquer forma } applyToken(null); clearSessionCookie(); }, [applyToken]); const value = React.useMemo( () => ({ status, user, loginMaster, loginTenant, logout }), [status, user, loginMaster, loginTenant, logout], ); return {children}; } export function useAuth(): AuthContextValue { const ctx = React.useContext(AuthContext); if (!ctx) throw new Error('useAuth precisa estar dentro de '); return ctx; }