9.5 KiB
Session-Expiry Soft Redirect + Auth Feedback — Design
Date: 2026-06-08 Status: Approved (brainstorming) — ready for implementation planning. Issue: #48 (Frontend UX: session-expiry handling loses in-progress work; auth feedback gaps).
Context
The session-expiry path is the most damaging daily failure mode for a long-session data-entry tool.
Today a 401 from any API call runs redirectToLogin() (web/src/api/auth-redirect.ts) →
window.location.assign("/login"), a full page reload: it tears down the React app and query
cache, gives the login page no reason ("session expired"), and after re-login drops the user on
/objects rather than where they were. Login also hardcodes navigate("/objects") and ignores the
attempted destination, and the Sign out control has no pending state.
Already fixed since the audit (verified): require-auth.tsx now renders <AppShellSkeleton />
during the useMe fetch (not the blank <div role="status"> the audit cited), so the "blank screen on
load" problem is gone and is out of scope. Logout error feedback also already exists: useLogout
carries no meta.suppressErrorToast, so the global MutationCache.onError (api/query-client.ts)
already shows an error toast on logout failure — the only remaining logout gap is a pending/disabled
state.
The crux: the 401 handler lives in an openapi-fetch Middleware (api/client.ts), which runs
outside React and therefore cannot call useNavigate. The app uses a React Router 7 data router
(createBrowserRouter, app.tsx). The test harness renderApp (src/test/render.tsx) mounts the
component under test at path:"*" in a fresh createMemoryRouter — it does not use the app's real
router singleton.
Decisions (from brainstorming)
- Soft redirect + return (chosen over an in-place re-auth modal): router-navigate (no full reload)
to
/login?reason=expired&from=<path>; login shows the reason and returns the user tofrom. The React app + query cache survive; an unsaved edit form's typed values are still lost, but the user lands back on the same record. (Full field-level preservation via an in-place modal is a deferred follow-up.) - Navigate-bridge module (chosen over exporting the
routersingleton or a custom DOM event): a module holds a settablenavigateref that a one-line component registers. This is testable with the memory-router harness (tests callsetNavigate(mock)directly); importing the singletonrouterwould be untestable becauserenderAppnever mounts it.
Components
api/auth-redirect.ts (rewrite)
type NavigateFn = (to: string, opts?: { replace?: boolean }) => void;
let navigateFn: NavigateFn | null = null;
/** Register/unregister the router's navigate fn (called by NavigationBridge). */
export function setNavigate(fn: NavigateFn | null): void {
navigateFn = fn;
}
/** Soft-redirect to login on a 401, preserving SPA state and the return path.
* Falls back to a hard navigation when no router navigate is registered yet
* (e.g. a 401 during the very first load). No-op when already on /login. */
export function redirectToLogin(): void {
const { pathname, search } = window.location;
if (pathname === "/login") return;
const from = encodeURIComponent(pathname + search);
const target = `/login?reason=expired&from=${from}`;
if (navigateFn) {
navigateFn(target, { replace: true });
} else {
window.location.assign(target);
}
}
api/client.ts is unchanged — the middleware still calls redirectToLogin(); only its behaviour changes.
shell/navigation-bridge.tsx (new)
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { setNavigate } from "../api/auth-redirect";
/** Bridges React Router's navigate to the non-React 401 handler. Renders nothing. */
export function NavigationBridge() {
const navigate = useNavigate();
useEffect(() => {
setNavigate(navigate);
return () => setNavigate(null);
}, [navigate]);
return null;
}
app.tsx (wrap routes in a pathless layout)
Add a pathless layout route around all existing routes whose element mounts the bridge plus an
<Outlet/>, so the bridge is always present (covers /login and the authed area):
function RootLayout() {
return (
<>
<NavigationBridge />
<Outlet />
</>
);
}
createRoutesFromElements(
<Route element={<RootLayout />}>
<Route path="/login" element={<LoginPage />} />
{/* …existing RequireAuth / AppShell subtree unchanged… */}
<Route path="*" element={<Navigate to="/objects" replace />} />
</Route>,
)
auth/login-page.tsx (reason banner + return-to + empty-field guard)
const [params] = useSearchParams();- Reason banner when
params.get("reason") === "expired": rendert("auth.sessionExpired")(a non-error info note, distinct from the existingrole="alert"invalid-credentials message). - On success, navigate to
safeFrom(params.get("from"))instead of the hardcoded/objects:
function safeFrom(raw: string | null): string {
if (!raw) return "/objects";
// single leading slash only — reject protocol-relative ("//host") / absolute URLs (open redirect)
return /^\/(?!\/)/.test(raw) ? raw : "/objects";
}
(raw from useSearchParams is already percent-decoded.)
- Disable submit on empty fields:
disabled={login.isPending || !email.trim() || !password}.
auth/require-auth.tsx (capture the attempted location)
const location = useLocation();
// …
if (!user) {
const from = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?from=${from}`} replace />;
}
No reason here — an unauthenticated deep-link is not an expiry; login simply returns the user to from.
shell/user-menu.tsx (logout pending state)
Disable the Sign out item while the mutation is in flight:
<MenuItem disabled={logout.isPending} onClick={onSignOut}>. Error already surfaces via the global toast.
i18n (en + sv parity — 1 new key)
auth.sessionExpired= "Your session expired — please sign in again." / "Din session har gått ut — logga in igen."
Data flow
API 401 → client.ts middleware → redirectToLogin() → registered navigate("/login?reason=expired& from=<path>", {replace}) (no reload) → LoginPage reads reason/from, shows the banner → on success
navigate(safeFrom(from), {replace}). Deep-link-while-unauthed → RequireAuth → /login?from=<path> →
same return-to. The NavigationBridge keeps setNavigate current for the lifetime of the app.
Error handling / edges
- Already on
/login:redirectToLogin()is a no-op (no redirect loop). - 401 before the bridge mounts (first paint):
navigateFnis null → hardwindow.location.assignto/login?reason=expired&from=…(graceful fallback; reason/return still preserved). - Open redirect:
safeFromaccepts only a single-leading-slash local path;//evil.com,https://…, and empty →/objects. - Login success with no
from: defaults to/objects(unchanged behaviour). - StrictMode double-invoke: the bridge effect is idempotent (sets the same fn; cleanup nulls it) — safe.
- Reason banner is informational, not
role="alert"(it is not an error and should not preempt the credential-error alert region).
Testing
api/auth-redirect.test.ts(unit, jsdom): withsetNavigate(mock)and a stubbedwindow.location(pathname/objects/abc),redirectToLogin()callsmockwith"/login?reason=expired&from=%2Fobjects%2Fabc"and{replace:true}; withsetNavigate(null)it callswindow.location.assignwith the same target; whenpathname === "/login"it does nothing.auth/login-pagetest: rendering at/login?reason=expiredshows theauth.sessionExpiredtext; a successful login at/login?from=%2Fobjects%2F123navigates to/objects/123; a badfrom(//evil.com) falls back to/objects; the submit button is disabled until both fields are non-empty.auth/require-authtest: whenuseMeresolves to no user, it renders a redirect to/login?from=…carrying the attempted path.shell/user-menutest: the Sign out item is disabled whilelogout.isPending.- Gate:
typecheck/lint/test/build/check:size/check:colorsgreen; en/sv parity (the #60 parity test guards the new key); no codename; no new dependency.
Acceptance criteria
- A 401 from an API call performs a router navigation (no full page reload) to
/login?reason=expired&from=<attempted path>; the React app/query cache are not torn down. - The login page shows a "session expired" message when
reason=expired, and on success returns the user to a validatedfrompath (defaulting to/objects); protocol-relative/absolutefromvalues are rejected. RequireAuthredirects unauthenticated users to/login?from=<attempted path>so deep links return after login.- Login submit is disabled while either field is empty; the Sign out item shows a disabled/pending state while logging out.
typecheck/lint/test/build/check:colorsgreen;check:sizereported; en/sv parity (1 new key); no codename; no new dependency.
Out of scope → follow-ups
- In-place re-auth modal that keeps the edit form mounted for full field-level preservation of in-progress work (deferred by decision; soft redirect ships first).
- Idle/expiry pre-warning (e.g. "your session will expire soon") and token/silent refresh.
- Broader auth-event audit surfacing on the client.