diff --git a/src/components/AdminShell.tsx b/src/components/AdminShell.tsx index bb27423..3f4d8b1 100644 --- a/src/components/AdminShell.tsx +++ b/src/components/AdminShell.tsx @@ -15,6 +15,7 @@ import { isExternalIdentity } from "~/lib/admin-auth"; import { clearAdminSession, hasAdminSession, setAdminSession } from "~/lib/admin-session"; import { normalizeAllowedModules } from "~/lib/admin/module-access"; import { sessionExpired, clearSessionExpired, installSessionExpiryWatcher } from "~/lib/session-expired"; +import { installSessionRefreshLoop } from "~/lib/session-refresh"; type Tab = { href: string; label: string; exact?: boolean }; type SearchResult = { id: string; title: string; subtitle: string; href: string }; @@ -491,6 +492,7 @@ export default function AdminShell(props: { children: JSX.Element }) { onMount(() => { installSessionExpiryWatcher(); + installSessionRefreshLoop(); const savedTheme = ( typeof localStorage !== "undefined" ? localStorage.getItem("nxtgauge_admin_theme") : null diff --git a/src/lib/session-refresh.ts b/src/lib/session-refresh.ts new file mode 100644 index 0000000..1875305 --- /dev/null +++ b/src/lib/session-refresh.ts @@ -0,0 +1,51 @@ +// Sliding session window for the admin panel: the access token is only valid for +// 15 minutes (see crates/auth/src/jwt.rs), matching the backend's employee_sessions +// expiry policy. While the admin is active we silently exchange the refresh-token +// cookie for a new access token before the old one expires; once idle for 15 +// minutes we stop refreshing and let the token expire naturally, so the existing +// session-expired dialog (src/lib/session-expired.ts) kicks in on the next request. +const IDLE_LIMIT_MS = 15 * 60 * 1000; +const CHECK_INTERVAL_MS = 3 * 60 * 1000; +const ACTIVITY_EVENTS = ['mousedown', 'keydown', 'scroll', 'touchstart'] as const; + +let lastActivityAt = Date.now(); +let installed = false; + +function markActivity(): void { + lastActivityAt = Date.now(); +} + +async function refreshAccessToken(): Promise { + try { + const res = await fetch('/api/admin/auth/refresh', { + method: 'POST', + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) return; + const data = await res.json().catch(() => null); + if (data?.access_token && typeof sessionStorage !== 'undefined') { + sessionStorage.setItem('nxtgauge_admin_access_token', data.access_token); + } + } catch { + // Network hiccup — the next successful check will catch up. If the token + // does expire in the meantime, the session-expired dialog handles it. + } +} + +/** Idempotent — safe to call from every AdminShell mount. */ +export function installSessionRefreshLoop(): void { + if (installed || typeof window === 'undefined') return; + installed = true; + + markActivity(); + for (const evt of ACTIVITY_EVENTS) { + window.addEventListener(evt, markActivity, { passive: true }); + } + + setInterval(() => { + if (Date.now() - lastActivityAt < IDLE_LIMIT_MS) { + void refreshAccessToken(); + } + }, CHECK_INTERVAL_MS); +}