HR-ATS-Portal/frontend/src/lib/tokenStore.js

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()
})
}