import { type ReactNode, useEffect, useState } from "react"; import { AuthContext, type AuthContextValue } from "./AuthContext.ts"; import { type AuthResponse, deserializeAuthResponse, deserializePublicUser, } from "../model.ts"; import { API_URL } from "../config/api.ts"; function isTokenExpired(token: string): boolean { try { const payload = JSON.parse(atob(token.split(".")[1])); return typeof payload.exp === "number" && payload.exp * 1000 < Date.now(); } catch { return true; } } export function AuthProvider({ children }: { children: ReactNode }) { const [authResponse, setAuthResponse] = useState(() => { const stored = localStorage.getItem("authResponse"); if (!stored) return null; let parsed; try { parsed = deserializeAuthResponse(JSON.parse(stored)); } catch { localStorage.removeItem("authResponse"); return null; } if (isTokenExpired(parsed.token)) { localStorage.removeItem("authResponse"); return null; } // Sessions persisted before roles existed have no `role`; the server also // rejects their tokens, but discard them here so the UI never renders with // a roleless user. if (!parsed.user.role) { localStorage.removeItem("authResponse"); return null; } return parsed; }); // The `exp` pre-check above only catches expiry; it cannot detect a token the // server rejects for another reason (e.g. a changed GERBEUR_JWT_SECRET, which // leaves the signature invalid but the token unexpired). Ask the server — the // only authority on validity — and clear the session if it returns 401, so the // UI never shows a false "logged in" state. Re-check when the tab regains // focus to catch tokens invalidated by a server restart while we were away. const token = authResponse?.token; useEffect(() => { if (!token) return; const validate = async () => { try { const res = await fetch(`${API_URL}/api/users/me`, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", }); if (res.status === 401) { localStorage.removeItem("authResponse"); setAuthResponse(null); return; } // The stored user is a snapshot from login time; the role (and other // profile fields) can change server-side — e.g. an admin promoting this // user. Sync from the authoritative /me response so the UI reflects role // changes without forcing a logout/login cycle. if (res.ok) { const json = await res.json(); if (json?.success && json.data) { const freshUser = deserializePublicUser(json.data); setAuthResponse((prev) => { if (!prev || prev.user.role === freshUser.role) return prev; const updated = { ...prev, user: freshUser }; localStorage.setItem("authResponse", JSON.stringify(updated)); return updated; }); } } } catch { // Network error — don't log out an offline user; only an explicit 401 clears the session. } }; validate(); const onVisible = () => { if (document.visibilityState === "visible") validate(); }; globalThis.addEventListener("focus", validate); document.addEventListener("visibilitychange", onVisible); return () => { globalThis.removeEventListener("focus", validate); document.removeEventListener("visibilitychange", onVisible); }; }, [token]); const value: AuthContextValue = { authResponse, setAuthResponse }; return ( {children} ); }