Something went wrong. Try again.
Original-language reader for Kant, Aristotle & more: select any passage to gloss, translate, or hear it read aloud, with exact citations.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105import { useCallback, useEffect, useMemo, useState, type JSX, type ReactNode,} from "react"
import { AuthContext, STORAGE_KEY, loadToken, startLogin, type LightAuthValue,} from "@/auth/context"import { decodeJwt, isAdminClaims, isExpired } from "@/auth/jwt"
/** * Provides light-auth session state (Phase 8). Holds the broker JWT, keeps it in * localStorage (and in sync across tabs), and exposes login/logout + the decoded * display claims. The context, hooks, and the Convex adapter live in * `@/auth/context`; this file exports only the component. */export function AuthProvider({ children,}: { children: ReactNode}): JSX.Element { const [token, setTokenState] = useState<string | null>(() => loadToken())
const setToken = useCallback((next: string | null) => { if (next) localStorage.setItem(STORAGE_KEY, next) else localStorage.removeItem(STORAGE_KEY) setTokenState(next) }, [])
// Keep tabs in sync (login/logout in one tab reflects in the others). useEffect(() => { const onStorage = (e: StorageEvent) => { // Re-derive through loadToken (localStorage already holds the new value) // so a token pushed from another tab gets the same expiry/validity check // as the initial mount, instead of trusting e.newValue verbatim. if (e.key === STORAGE_KEY) setTokenState(loadToken()) } window.addEventListener("storage", onStorage) return () => { window.removeEventListener("storage", onStorage) } }, [])
const claims = useMemo(() => (token ? decodeJwt(token) : null), [token]) // A token still in state but past its exp is unusable — `fetchAccessToken` // already withholds it from Convex, so the UI must read as logged-out too, // else we advertise a session (and admin affordances) the server rejects. // Single source of truth for every derived field below. const usable = claims !== null && !isExpired(claims)
// Flip the UI to logged-out the moment the token expires while the tab stays // open (nothing else re-renders on the passage of wall-clock time). useEffect(() => { if (claims === null || typeof claims.exp !== "number") return const ms = claims.exp * 1000 - 30_000 - Date.now() // mirror isExpired's skew // setTimeout clamps delays past ~24.8d into 32 bits and would fire early; for // those (long-lived sessions) skip the timer — `usable` and the mount-time // loadToken() still log the reader out correctly. An already-expired token // (ms<=0) clears on the next tick (delay 0), never synchronously here. if (ms > 2_147_483_647) return const id = window.setTimeout(() => { setToken(null) }, Math.max(0, ms)) return () => { window.clearTimeout(id) } }, [claims, setToken])
const value = useMemo<LightAuthValue>( () => ({ token, isAuthenticated: usable, isAdmin: usable && isAdminClaims(claims), nickname: usable && typeof claims?.readerNickname === "string" ? claims.readerNickname : null, login: () => { startLogin("reader") }, adminLogin: () => { startLogin("admin") }, logout: () => { setToken(null) }, clearStaleSession: () => { setToken(null) }, setToken, }), [token, claims, usable, setToken] )
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>}