import { useQueryClient } from "@tanstack/react-query"; import type { FC, PropsWithChildren } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { apiClient, configureAuthClient, type paths } from "@/shared/api"; import { AuthContext, type User } from "./auth-context"; 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; } const AuthProvider: FC = (props) => { const { children } = props; const queryClient = useQueryClient(); const [user, setUser] = useState(null); const [accessToken, setAccessToken] = useState(null); const accessTokenRef = useRef(null); const [ready, setReady] = useState(false); const generation = useRef(0); const userRef = useRef(null); const authQueue = useRef>(Promise.resolve()); const refreshPromise = useRef | null>(null); // Cookie-writing requests share ordering, including across tabs with Web Locks. const serialize = useCallback((run: () => Promise): Promise => { const next = authQueue.current.then(async () => "locks" in navigator ? navigator.locks.request("wealth-ledger-refresh", run) : run(), ); authQueue.current = next.catch(() => undefined); return next; }, []); const applyAuthentication = useCallback( (authentication: Authentication) => { const changedIdentity = userRef.current && userRef.current.id !== authentication.user.id; accessTokenRef.current = authentication.accessToken; if (changedIdentity) { generation.current += 1; queryClient.removeQueries({ type: "inactive" }); queryClient.getMutationCache().clear(); void queryClient.resetQueries(); } userRef.current = authentication.user; setUser(authentication.user); setAccessToken(authentication.accessToken); }, [queryClient], ); const clearSession = useCallback(() => { generation.current += 1; userRef.current = null; setUser(null); setAccessToken(null); accessTokenRef.current = null; queryClient.clear(); }, [queryClient]); const refresh = useCallback((): Promise => { if (refreshPromise.current) return refreshPromise.current; const started = generation.current; const pending = serialize(async () => { if (started !== generation.current) return false; try { const { data } = await apiClient.POST("/api/v1/auth/refresh"); if (started !== generation.current) return false; if (!data) { clearSession(); return false; } applyAuthentication(data); return true; } catch { if (started === generation.current) clearSession(); return false; } }).finally(() => { if (refreshPromise.current === pending) refreshPromise.current = null; }); refreshPromise.current = pending; return pending; }, [applyAuthentication, clearSession, 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 = generation.current; await serialize(async () => { const authentication = await request(path, email, password); if (started === generation.current) applyAuthentication(authentication); }); } async function logout() { clearSession(); await serialize(async () => { await apiClient.POST("/api/v1/auth/logout"); }); } useEffect(() => { configureAuthClient({ accessToken: () => accessTokenRef.current, generation: () => generation.current, refresh, clear: clearSession, }); }); useEffect(() => () => configureAuthClient(null), []); return ( authenticate("/api/v1/auth/login", email, password), register: (email, password) => authenticate("/api/v1/auth/register", email, password), logout, refresh, }} > {children} ); }; export { AuthProvider };