/* ============================================================ tokenStore.js — the session record, its expiry math, and cross-tab sync. A module-level singleton rather than React state, because apiClient must read the access token from plain async code that has no hooks available. React binds to it through useSyncExternalStore (see auth/AuthProvider.jsx), which works because every write replaces `cached` wholesale — the snapshot identity only changes when the session actually changes. Storage key stays `tf-auth` so anyone already signed in stays signed in. ============================================================ */ const KEY = 'tf-auth' // Renew this far ahead of expiry. Covers clock drift between browser and server // and, more importantly, means a request never has to eat a guaranteed 401 first. export const CLOCK_SKEW_MS = 60_000 function readRaw() { try { return JSON.parse(localStorage.getItem(KEY)) || null } catch { return null } } let cached = readRaw() const subscribers = new Set() function emit() { subscribers.forEach((fn) => fn()) } function persist() { try { localStorage.setItem(KEY, JSON.stringify(cached)) } catch { /* quota or private mode — the in-memory session still works for this tab */ } } export function getSession() { return cached } export function getAccessToken() { return cached?.access_token ?? null } export function getRefreshToken() { return cached?.refresh_token ?? null } export function subscribe(fn) { subscribers.add(fn) return () => subscribers.delete(fn) } /** Re-read from storage. Another tab may have rotated the pair since we last looked. */ export function reloadFromStorage() { cached = readRaw() return cached } export function isExpiring(skew = CLOCK_SKEW_MS) { if (!cached?.access_token) return false // Sessions written before this field existed can't be reasoned about; let the // reactive 401 path handle those rather than refreshing on every request. if (!cached.expires_at) return false return Date.now() >= cached.expires_at - skew } /** * Store a login/signup/refresh response. * * The `data` MERGE is load-bearing. /users/login and /users/refresh return a * user record WITHOUT `permissions` — only GET /users/me resolves those * (backend/users/serializers.py: serialize_user's with_permissions defaults to * False). A naive assignment would therefore wipe the permission list on the * first background refresh, and every permission-gated nav item would vanish * mid-session. */ export function setSession(res) { cached = { access_token: res.access_token, refresh_token: res.refresh_token, expires_in: res.expires_in, expires_at: Date.now() + (res.expires_in ?? 1800) * 1000, data: { ...(cached?.data ?? {}), ...(res.data ?? {}) }, } persist() emit() return cached } /** Merge the GET /users/me payload — this is what puts `permissions` on the session. */ export function mergeUser(user) { if (!cached || !user) return cached cached = { ...cached, data: { ...cached.data, ...user } } persist() emit() return cached } export function clearSession() { cached = null try { localStorage.removeItem(KEY) } catch { /* ignore */ } emit() } // Cross-tab: sign-out and token rotation both propagate. `e.key === null` is a // storage.clear() from another tab. if (typeof window !== 'undefined') { window.addEventListener('storage', (e) => { if (e.key !== KEY && e.key !== null) return cached = readRaw() emit() }) }