import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { apiClient, configureAuthClient, type paths } from "@/shared/api"; import { type User } from "./auth-context"; // A 5xx or network error means the server is unreachable, not that the session ended. const HTTP_SERVER_ERROR = 500; type Authentication = paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"]; type Credentials = paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"]; async function request( path: "/api/v1/auth/login" | "/api/v1/auth/register", email: string, password: string, ): Promise { const { data, error } = await apiClient.POST(path, { body: { email, password }, } satisfies { body: Credentials }); // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. if (error) throw error; return data; } function useAuthProviderVm() { const queryClient = useQueryClient(); const [user, setUser] = useState(undefined); const accessTokenRef = useRef(undefined); const [ready, setReady] = useState(false); const [signedOut, setSignedOut] = useState(false); const [unavailable, setUnavailable] = useState(false); const unavailableRef = useRef(false); const generationRef = useRef(0); const userRef = useRef(undefined); const authQueueRef = useRef>(Promise.resolve()); const refreshPromiseRef = useRef | undefined>(undefined); // Order cookie-writing requests within this tab. Other tabs may reauthenticate. const serialize = useCallback((run: () => Promise): Promise => { // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. const next = authQueueRef.current.then(run); // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. authQueueRef.current = next.catch(() => {}); return next; }, []); const markUnavailable = useCallback((isUnavailable: boolean) => { unavailableRef.current = isUnavailable; setUnavailable(isUnavailable); }, []); const applyAuthentication = useCallback( (authentication: Authentication) => { const changedIdentity = userRef.current && userRef.current.id !== authentication.user.id; setSignedOut(false); markUnavailable(false); accessTokenRef.current = authentication.accessToken; if (changedIdentity) { generationRef.current += 1; queryClient.removeQueries({ type: "inactive" }); queryClient.getMutationCache().clear(); void queryClient.resetQueries(); } userRef.current = authentication.user; setUser(authentication.user); }, [markUnavailable, queryClient], ); const clearSession = useCallback(() => { markUnavailable(false); generationRef.current += 1; userRef.current = undefined; setUser(undefined); accessTokenRef.current = undefined; queryClient.clear(); }, [markUnavailable, queryClient]); const refresh = useCallback((): Promise => { if (refreshPromiseRef.current) return refreshPromiseRef.current; const started = generationRef.current; const pending = serialize(async () => { if (started !== generationRef.current) return false; try { const { data, response } = await apiClient.POST("/api/v1/auth/refresh"); if (started !== generationRef.current) return false; if (!data) { if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true); else clearSession(); return false; } applyAuthentication(data); return true; } catch { if (started === generationRef.current) markUnavailable(true); return false; } // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. }).finally(() => { refreshPromiseRef.current = undefined; }); refreshPromiseRef.current = pending; return pending; }, [applyAuthentication, clearSession, markUnavailable, serialize]); useEffect(() => { void refresh().finally(() => setReady(true)); }, [refresh]); async function authenticate( path: "/api/v1/auth/login" | "/api/v1/auth/register", email: string, password: string, ) { clearSession(); const started = generationRef.current; await serialize(async () => { const authentication = await request(path, email, password); if (started === generationRef.current) applyAuthentication(authentication); }); } async function logout() { setSignedOut(true); clearSession(); await serialize(async () => { await apiClient.POST("/api/v1/auth/logout"); }); } useEffect(() => { configureAuthClient({ accessToken: () => accessTokenRef.current, clear: clearSession, generation: () => generationRef.current, refresh, unavailable: () => unavailableRef.current, }); // `user` tracks access-token changes, which configureAuthClient publishes as session state. }, [clearSession, refresh, user]); useEffect(() => () => configureAuthClient(), []); const authContextValue = { login: (email: string, password: string) => authenticate("/api/v1/auth/login", email, password), logout, ready, register: (email: string, password: string) => authenticate("/api/v1/auth/register", email, password), retry: () => void refresh(), signedOut, unavailable, user, }; return authContextValue; } export { useAuthProviderVm };