feat(admin): extend session on activity, expire on 15 min idle
All checks were successful
build-and-release / build (push) Successful in 1m33s

Pairs with the backend's new POST /api/admin/auth/refresh (nxtgauge-backend-rust).
While the admin is active (mouse/keyboard/scroll/touch), silently exchange
the refresh-token cookie for a new access token every 3 minutes so the
15-minute access token never actually expires mid-work. Once idle for 15
minutes, the refresh loop stops on its own and the token expires naturally,
triggering the existing session-expired dialog - "logged out on inactivity,
not on a fixed timer" as intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-19 00:14:39 +05:30
parent ca2c5a4ef6
commit 0e08cf9d6b
2 changed files with 53 additions and 0 deletions

View file

@ -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

View file

@ -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<void> {
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);
}