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 { clearAdminSession, hasAdminSession, setAdminSession } from "~/lib/admin-session";
|
||||
import { normalizeAllowedModules } from "~/lib/admin/module-access";
|
||||
import { sessionExpired, clearSessionExpired, installSessionExpiryWatcher } from "~/lib/session-expired";
|
||||
|
||||
type Tab = { href: string; label: string; exact?: boolean };
|
||||
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(() => {
|
||||
installSessionExpiryWatcher();
|
||||
|
||||
const savedTheme = (
|
||||
typeof localStorage !== "undefined" ? localStorage.getItem("nxtgauge_admin_theme") : null
|
||||
) as "light" | "dark" | null;
|
||||
|
|
@ -667,6 +682,41 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
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
|
||||
when={checkedSession()}
|
||||
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