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"; 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 generationRef = useRef(0); const userRef = useRef(undefined); const authQueueRef = useRef>(Promise.resolve()); const refreshPromiseRef = useRef | undefined>(undefined); // Cookie-writing requests share ordering, including across tabs with Web Locks. 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(async () => "locks" in navigator ? navigator.locks.request("wealth-ledger-refresh", run) : 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 applyAuthentication = useCallback( (authentication: Authentication) => { const changedIdentity = userRef.current && userRef.current.id !== authentication.user.id; setSignedOut(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); }, [queryClient], ); const clearSession = useCallback(() => { generationRef.current += 1; userRef.current = undefined; setUser(undefined); accessTokenRef.current = undefined; queryClient.clear(); }, [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 } = await apiClient.POST("/api/v1/auth/refresh"); if (started !== generationRef.current) return false; if (!data) { clearSession(); return false; } applyAuthentication(data); return true; } catch { if (started === generationRef.current) clearSession(); return false; } // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. }).finally(() => { if (refreshPromiseRef.current === pending) refreshPromiseRef.current = undefined; }); refreshPromiseRef.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 = 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, }); }); 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), signedOut, user, }; return authContextValue; } export { useAuthProviderVm };