# Session-Expiry Soft Redirect + Auth Feedback — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the full-page-reload 401 redirect with a router soft-redirect that preserves SPA state, carries a "session expired" reason + return-to path, and add the missing login/logout feedback. **Architecture:** A non-React navigate-bridge module (`auth-redirect.ts`) holds a settable `navigate` ref; a one-line `NavigationBridge` component (mounted at the router root) registers React Router's navigate into it. The openapi-fetch 401 middleware calls `redirectToLogin()`, which soft-navigates to `/login?reason=expired&from=`. The login page reads `reason`/`from` (validated against open redirects), `RequireAuth` captures the attempted path, and the user menu shows a logout-pending state. **Tech Stack:** React 19 + TS + pnpm, React Router 7 (data router), TanStack Query v5, react-i18next, Base UI menu, Vitest 4 (jsdom project) + RTL + MSW. Test runner: `pnpm test` (single pass). **Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; app source double-quote+semicolon; token classes only; `ui/` files use no-semicolon style (do not touch `ui/menu.tsx`). Run a single test pass. **Spec:** `docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md` **Key facts:** - `web/src/api/auth-redirect.ts` currently: `redirectToLogin()` → `window.location.assign("/login")` (guarded by `pathname !== "/login"`). Called from `web/src/api/client.ts` middleware on `response.status === 401`. Do **not** change `client.ts`. - `web/src/app.tsx` builds a data router via `createBrowserRouter(createRoutesFromElements(<>…))`. Top-level children: ``, `}>…`, ``. Imports `Navigate`, `Route`, `Outlet` is **not** yet imported (add it). - `web/src/test/render.tsx` `renderApp(ui, {route})` mounts `ui` at `path:"*"` in a `createMemoryRouter` — tests pass their own `` tree. jsdom project url is `http://localhost`. - `vi.stubGlobal("location", {...})` is the established way to stub `window.location` here (see `theme-switch.test.tsx` stubbing `matchMedia`); restore with `vi.unstubAllGlobals()`. - `login-page.tsx`: `useLogin()` mutation; success currently `navigate("/objects", {replace:true})`; submit `disabled={login.isPending}`; existing error alert uses `role="alert"`. Existing tests: `login-page.test.tsx` (`tree()` with `/login` + `/objects` routes). - `require-auth.tsx`: `useMe()`; `isLoading` → ``; `!user` → ``. Test: `require-auth.test.tsx`. - `user-menu.tsx`: `useLogout()`, `onSignOut` navigates to `/login` on success; `{t("auth.signOut")}`; returns `null` when `!me`. Base UI `MenuItem` supports `closeOnClick` + `disabled`. Test: `user-menu.test.tsx`. - i18n `auth` block keys: `email/password/signIn/signOut/invalid/networkError` in both `en.json` and `sv.json`. --- # Task 1: Navigate bridge + soft redirect **Files:** - Modify: `web/src/api/auth-redirect.ts` - Create: `web/src/api/auth-redirect.test.ts` - Create: `web/src/shell/navigation-bridge.tsx` - Modify: `web/src/app.tsx` - [ ] **Step 1: Rewrite `web/src/api/auth-redirect.ts`:** ```ts type NavigateFn = (to: string, opts?: { replace?: boolean }) => void; let navigateFn: NavigateFn | null = null; /** Register (or clear) 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); } } ``` - [ ] **Step 2: Write the failing test `web/src/api/auth-redirect.test.ts`:** ```ts import { afterEach, expect, test, vi } from "vitest"; import { redirectToLogin, setNavigate } from "./auth-redirect"; function stubLocation(pathname: string, search = "") { const assign = vi.fn(); vi.stubGlobal("location", { pathname, search, assign }); return assign; } afterEach(() => { setNavigate(null); vi.unstubAllGlobals(); }); test("uses the registered navigate to soft-redirect with reason + from", () => { const assign = stubLocation("/objects/abc", "?x=1"); const navigate = vi.fn(); setNavigate(navigate); redirectToLogin(); expect(navigate).toHaveBeenCalledWith( "/login?reason=expired&from=%2Fobjects%2Fabc%3Fx%3D1", { replace: true }, ); expect(assign).not.toHaveBeenCalled(); }); test("falls back to a hard navigation when no navigate is registered", () => { const assign = stubLocation("/objects/abc"); redirectToLogin(); expect(assign).toHaveBeenCalledWith("/login?reason=expired&from=%2Fobjects%2Fabc"); }); test("does nothing when already on /login", () => { stubLocation("/login"); const navigate = vi.fn(); setNavigate(navigate); redirectToLogin(); expect(navigate).not.toHaveBeenCalled(); }); ``` - [ ] **Step 3: Run the test — expect PASS** (the implementation in Step 1 already satisfies it): ```bash cd web && pnpm vitest run src/api/auth-redirect.test.ts ``` Expected: 3 passing. (If you wrote the test before the impl, it would fail on the missing `setNavigate` export — either order is fine; end state is green.) - [ ] **Step 4: Create `web/src/shell/navigation-bridge.tsx`:** ```tsx 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; } ``` - [ ] **Step 5: Wrap the routes in `web/src/app.tsx`** so the bridge is always mounted. Add `Outlet` to the `react-router-dom` import and import the bridge: ```tsx import { createBrowserRouter, createRoutesFromElements, Navigate, Outlet, Route, RouterProvider } from "react-router-dom"; ``` ```tsx import { NavigationBridge } from "./shell/navigation-bridge"; ``` Add this component above `const router = …`: ```tsx function RootLayout() { return ( <> ); } ``` Wrap the existing top-level fragment children in a pathless `}>` — i.e. change `createRoutesFromElements(<> … )` so the outer element is `}> … ` containing the existing `/login`, `RequireAuth`, and `*` routes unchanged: ```tsx const router = createBrowserRouter( createRoutesFromElements( }> } /> }> {/* …AppShell subtree exactly as before… */} } /> , ), ); ``` - [ ] **Step 6: Verify build + lint + existing app test (vitest ONCE for the touched files):** ```bash cd web && pnpm vitest run src/api/auth-redirect.test.ts src/app.test.tsx && pnpm typecheck && pnpm lint ``` Expected: PASS. `app.test.tsx` must stay green (the pathless wrapper is transparent — same rendered routes). - [ ] **Step 7: Commit** ```bash git add web/src/api/auth-redirect.ts web/src/api/auth-redirect.test.ts web/src/shell/navigation-bridge.tsx web/src/app.tsx git commit -m "feat(web): soft-redirect to login on 401 via a navigate bridge (#48)" ``` --- # Task 2: Login page — reason banner, return-to, empty-field guard **Files:** - Modify: `web/src/auth/login-page.tsx` - Modify: `web/src/auth/login-page.test.tsx` - Modify: `web/src/i18n/en.json`, `web/src/i18n/sv.json` - [ ] **Step 1: Add i18n keys** (both locales, parity). In `web/src/i18n/en.json` `auth` block add: ```json "sessionExpired": "Your session expired — please sign in again.", "signingOut": "Signing out…" ``` In `web/src/i18n/sv.json` `auth` block add: ```json "sessionExpired": "Din session har gått ut — logga in igen.", "signingOut": "Loggar ut…" ``` (Place them after the existing `networkError` entry; mind trailing commas. `signingOut` is consumed in Task 3 — add both now so the parity test stays green.) - [ ] **Step 2: Update `web/src/auth/login-page.tsx`.** Add `useSearchParams` to the import and a `safeFrom` helper; show the reason banner; route to `safeFrom` on success; guard the submit. Full changes: Import line: ```tsx import { useNavigate, useSearchParams } from "react-router-dom"; ``` Add a module-level helper (above `export function LoginPage`): ```tsx /** Accept only a single-leading-slash local path; reject protocol-relative * ("//host") and absolute URLs to avoid an open redirect. */ function safeFrom(raw: string | null): string { if (!raw) return "/objects"; return /^\/(?!\/)/.test(raw) ? raw : "/objects"; } ``` Inside the component, after `const navigate = useNavigate();`: ```tsx const [params] = useSearchParams(); const sessionExpired = params.get("reason") === "expired"; ``` Change the success navigation: ```tsx { onSuccess: () => navigate(safeFrom(params.get("from")), { replace: true }) }, ``` Add the banner just inside the `
`, above the email field (after the `

`): ```tsx {sessionExpired && (

{t("auth.sessionExpired")}

)} ``` Change the submit button disabled condition: ```tsx