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
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:
parent
5df2d6c07e
commit
ca2c5a4ef6
2 changed files with 92 additions and 0 deletions
|
|
@ -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={
|
||||||
|
|
|
||||||
42
src/lib/session-expired.ts
Normal file
42
src/lib/session-expired.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue