feat(admin): show a dialog when the session expires instead of failing silently
All checks were successful
build-and-release / build (push) Successful in 1m2s

Admin access tokens expire after 15 minutes with no refresh flow. Until now,
once the token expired, every subsequent action (approve, save, etc.) just
403/401'd with no visible feedback, or an error banner easy to miss inside a
modal — it looked like the button "didn't work."

Add a global window.fetch wrapper (installSessionExpiryWatcher, installed
once from AdminShell) that flips a shared signal whenever an authenticated
request comes back 401. AdminShell renders a "You have been logged out"
dialog on top of everything when that fires, with a button that clears the
stale session and sends the admin back to /login?from=<current path>.

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

View file

@ -14,6 +14,7 @@ import AdminSidebar from "./AdminSidebar";
import { isExternalIdentity } from "~/lib/admin-auth"; import { isExternalIdentity } from "~/lib/admin-auth";
import { clearAdminSession, hasAdminSession, setAdminSession } from "~/lib/admin-session"; import { clearAdminSession, hasAdminSession, setAdminSession } from "~/lib/admin-session";
import { normalizeAllowedModules } from "~/lib/admin/module-access"; import { normalizeAllowedModules } from "~/lib/admin/module-access";
import { sessionExpired, clearSessionExpired, installSessionExpiryWatcher } from "~/lib/session-expired";
type Tab = { href: string; label: string; exact?: boolean }; type Tab = { href: string; label: string; exact?: boolean };
type SearchResult = { id: string; title: string; subtitle: string; href: string }; type SearchResult = { id: string; title: string; subtitle: string; href: string };
@ -476,7 +477,21 @@ export default function AdminShell(props: { children: JSX.Element }) {
} }
}); });
const goToLoginAfterExpiry = () => {
clearSessionExpired();
if (typeof sessionStorage !== "undefined") {
sessionStorage.removeItem("nxtgauge_admin_access_token");
sessionStorage.removeItem("nxtgauge_admin_preview");
}
clearAdminSession();
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
replace: true,
});
};
onMount(() => { onMount(() => {
installSessionExpiryWatcher();
const savedTheme = ( const savedTheme = (
typeof localStorage !== "undefined" ? localStorage.getItem("nxtgauge_admin_theme") : null typeof localStorage !== "undefined" ? localStorage.getItem("nxtgauge_admin_theme") : null
) as "light" | "dark" | null; ) as "light" | "dark" | null;
@ -667,6 +682,41 @@ export default function AdminShell(props: { children: JSX.Element }) {
color: isDark() ? "#E5E7EB" : "#0D0D2A", color: isDark() ? "#E5E7EB" : "#0D0D2A",
}} }}
> >
<Show when={sessionExpired()}>
<div
style="position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(13,13,42,0.55);padding:16px"
role="alertdialog"
aria-modal="true"
aria-labelledby="session-expired-title"
>
<div
style={{
background: isDark() ? "#111827" : "white",
color: isDark() ? "#E5E7EB" : "#0D0D2A",
"border-radius": "12px",
padding: "24px",
"max-width": "380px",
width: "100%",
"box-shadow": "0 20px 40px rgba(0,0,0,0.25)",
}}
>
<h2 id="session-expired-title" style="font-size:16px;font-weight:700;margin:0 0 8px">
You have been logged out
</h2>
<p style="font-size:14px;color:#6B7280;margin:0 0 20px">
Your session has expired for security reasons. Please log in again to continue.
</p>
<button
type="button"
onClick={goToLoginAfterExpiry}
style="width:100%;padding:10px 16px;border-radius:8px;border:none;background:#FF5E13;color:white;font-size:14px;font-weight:600;cursor:pointer"
>
Log In Again
</button>
</div>
</div>
</Show>
<Show <Show
when={checkedSession()} when={checkedSession()}
fallback={ fallback={

View file

@ -0,0 +1,42 @@
import { createSignal } from 'solid-js';
// Shared across the admin app: set to true the moment any authenticated request
// comes back 401, so a single dialog (mounted in AdminShell) can tell the admin
// their session expired instead of pages failing silently one by one.
const [sessionExpired, setSessionExpired] = createSignal(false);
export { sessionExpired };
export function markSessionExpired(): void {
setSessionExpired(true);
}
export function clearSessionExpired(): void {
setSessionExpired(false);
}
let patched = false;
/**
* Wrap window.fetch once so any authenticated admin request that comes back 401
* flips the shared session-expired signal, regardless of which page/helper made
* the call. Idempotent safe to call from every AdminShell mount.
*/
export function installSessionExpiryWatcher(): void {
if (patched || typeof window === 'undefined') return;
patched = true;
const originalFetch = window.fetch.bind(window);
window.fetch = async (...args: Parameters<typeof fetch>) => {
const response = await originalFetch(...args);
if (response.status === 401) {
const hadToken =
typeof sessionStorage !== 'undefined' &&
Boolean(sessionStorage.getItem('nxtgauge_admin_access_token'));
// Only treat this as a session expiry if we were actually holding a token —
// otherwise this is just a normal pre-login 401 (e.g. a failed login attempt).
if (hadToken) markSessionExpired();
}
return response;
};
}