122 lines
3.5 KiB
JavaScript
122 lines
3.5 KiB
JavaScript
/* ============================================================
|
|
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()
|
|
})
|
|
}
|