1"use client"; 2 3/** 4 * Auth hooks. 5 * 6 * Startup hydration (S10 — the W1/W2/W3 rework): the access token lives in 7 * JS memory only (never localStorage — security-architecture.md §AuthN), so 8 * on mount the client re-issues it from the httpOnly `factory_refresh` 9 * cookie via a single-flight POST /auth/refresh, then fetches the profile 10 * with GET /auth/me (Bearer). The httpOnly cookie is the session source of 11 * truth; the middleware only checks its existence for redirects. 12 */ 13import { useCallback, useEffect, useRef } from "react"; 14import { useRouter } from "next/navigation"; 15import { useQuery, useQueryClient } from "@tanstack/react-query"; 16import { authApi, isStaleRefreshError, type LoginInput, type RegisterInput } from "@/lib/api"; 17import { authStore, hasSession, useAuthStore } from "@/lib/auth-store"; 18import { useOutboxStore } from "@/stores/offlineQueue"; 19import { toast } from "@/stores/ui-store"; 20 21/** Current user (null while initializing or signed out). */ 22export function useAuth() { 23 const user = useAuthStore((s) => s.user); 24 const initializing = useAuthStore((s) => s.initializing); 25 // Subscribe to the access token too: the bootstrap sets it WITHOUT setting 26 // the user, and only this subscription re-renders the component so the 27 // `enabled` below re-evaluates and fires /auth/me against the fresh token. 28 const accessToken = useAuthStore((s) => s.accessToken); 29 const hydrated = useRef(false); 30 31 const me = useQuery({ 32 queryKey: ["auth", "me"], 33 queryFn: () => authApi.me(), 34 // Runs once per tab when a session is hydrated (an access token is in 35 // memory). While no token is held, the bootstrap below refreshes the 36 // access token from the refresh cookie FIRST — /auth/me is Bearer-gated 37 // (W3) and has no token in its body, so it can never issue one. 38 enabled: !user && Boolean(accessToken), 39 retry: false, 40 staleTime: 5 * 60 * 1000, 41 gcTime: Infinity 42 }); 43 44 // One-time silent refresh: cookie → new access token → (enables) /me. 45 useEffect(() => { 46 if (hydrated.current) return; 47 hydrated.current = true; 48 if (hasSession()) return; // already holding a token (e.g. right after login) 49 let cancelled = false; 50 (async () => { 51 // `authApi.refresh` is the module-level single-flight primitive (api.ts 52 // `refreshAccessToken`): a StrictMode double-mount — or two components 53 // bootstrapping in the same document — share ONE in-flight 54 // POST /auth/refresh, so the tab can never rotate its refresh cookie 55 // twice in parallel. (The old bypass via mutatingApiFetch let two 56 // concurrent bootstraps both rotate; the loser then replayed the 57 // rotated-out cookie and tripped reuse detection → family kill.) 58 let ok = false; 59 try { 60 await authApi.refresh(); 61 ok = true; 62 } catch (err) { 63 if (isStaleRefreshError(err)) { 64 // STALE RETRY (security-architecture.md §1.2): a concurrent 65 // refresh — another tab, or a racing request — rotated the cookie 66 // between our read and the server's. No session was killed, and 67 // the browser jar already holds the rotated successor when that 68 // rotation's response was observed. Per-tab single-flight cannot 69 // dedupe ACROSS tabs, so retry exactly once: the second attempt 70 // presents the live successor and rotates it forward. A second 71 // stale answer (jar never advanced) or any other failure is 72 // terminal → treat as no session. 73 try { 74 await authApi.refresh(); 75 ok = true; 76 } catch { 77 ok = false; 78 } 79 } 80 } 81 if (!ok) { 82 // No refresh cookie (or dead) → no session. End the initial spin so 83 // the (app) layout redirects to /login instead of spinning forever. 84 if (!cancelled) authStore.setInitializing(false); 85 return; 86 } 87 if (cancelled) return; 88 // The refresh already stored the access token; the store update 89 // re-renders and re-evaluates `enabled` above, which fires /auth/me 90 // against the fresh Bearer token. 91 })(); 92 return () => { 93 cancelled = true; 94 }; 95 }, []); 96 97 useEffect(() => { 98 if (me.data) { 99 // /auth/me carries no token — keep the one the refresh flow stored. 100 authStore.setUser(me.data.user, authStore.accessToken); 101 } 102 if (me.error && !me.data) { 103 // /me failed (a 401 is transparent — doFetch refreshes + retries once 104 // before this lands) → the session is unviable: end the spin so the 105 // (app) layout redirects to /login instead of hanging on the loader. 106 authStore.setInitializing(false); 107 } 108 }, [me.data, me.error]); 109 110 return { 111 user, 112 initializing, 113 isAuthenticated: Boolean(user) 114 }; 115} 116 117/** POST /auth/login; stores tokens, returns the created session. */ 118export function useLogin() { 119 const router = useRouter(); 120 const queryClient = useQueryClient(); 121 122 const login = useCallback(async (input: LoginInput) => { 123 const result = await authApi.login(input); 124 authStore.setUser(result.user, result.accessToken); 125 return result; 126 }, []); 127 128 const logout = useCallback(async () => { 129 try { 130 // Bearer-gated: the API resolves the user from the token to scope the 131 // session revocation and clears the refresh cookie on the response. 132 await authApi.logout(); 133 } catch { 134 /* best-effort — we clear local state regardless */ 135 } 136 authStore.clear(); 137 // Drop the cached /me so the next login (possibly a different user) 138 // re-fetches instead of reading the stale profile. 139 queryClient.removeQueries({ queryKey: ["auth", "me"] }); 140 // Logout = an explicit abandon (R1 recorded rule): a signed-out user's 141 // queued offline mutations are discarded (the outbox is cleared, not 142 // carried into the next session). 143 const outbox = useOutboxStore.getState(); 144 if (outbox.pending + outbox.failed > 0) { 145 toast("info", "Your offline changes were discarded"); 146 void outbox.clear(); 147 } 148 router.replace("/login"); 149 }, [router, queryClient]); 150 151 return { login, logout }; 152} 153 154/** POST /auth/register; auto-logs the new user in. */ 155export function useRegister() { 156 const register = useCallback(async (input: RegisterInput) => { 157 const result = await authApi.register(input); 158 authStore.setUser(result.user, result.accessToken); 159 return result; 160 }, []); 161 162 return { register }; 163} 164