From a0aab6571f5725cbcf03dff36cc38e9dae50c1de Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 14:52:16 +0200 Subject: [PATCH 1/5] docs(specs): session-expiry soft redirect + auth feedback (#48) --- .../2026-06-08-session-expiry-ux-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md diff --git a/docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md b/docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md new file mode 100644 index 0000000..47a0dbe --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md @@ -0,0 +1,194 @@ +# 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 `` +during the `useMe` fetch (not the blank `
` 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) +1. **Soft redirect + return** (chosen over an in-place re-auth modal): router-navigate (no full reload) + to `/login?reason=expired&from=`; login shows the reason and returns the user to `from`. 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.) +2. **Navigate-bridge module** (chosen over exporting the `router` singleton or a custom DOM event): a + module holds a settable `navigate` ref that a one-line component registers. This is testable with the + memory-router harness (tests call `setNavigate(mock)` directly); importing the singleton `router` + would be untestable because `renderApp` never mounts it. + +## Components + +### `api/auth-redirect.ts` (rewrite) +```ts +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) +```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; +} +``` + +### `app.tsx` (wrap routes in a pathless layout) +Add a pathless layout route around **all** existing routes whose element mounts the bridge plus an +``, so the bridge is always present (covers `/login` and the authed area): +```tsx +function RootLayout() { + return ( + <> + + + + ); +} +``` +```tsx +createRoutesFromElements( + }> + } /> + {/* …existing RequireAuth / AppShell subtree unchanged… */} + } /> + , +) +``` + +### `auth/login-page.tsx` (reason banner + return-to + empty-field guard) +- `const [params] = useSearchParams();` +- Reason banner when `params.get("reason") === "expired"`: render `t("auth.sessionExpired")` (a + non-error info note, distinct from the existing `role="alert"` invalid-credentials message). +- On success, navigate to `safeFrom(params.get("from"))` instead of the hardcoded `/objects`: +```ts +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) +```tsx +const location = useLocation(); +// … +if (!user) { + const from = encodeURIComponent(location.pathname + location.search); + return ; +} +``` +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: +``. 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=", {replace})` (no reload) → `LoginPage` reads `reason`/`from`, shows the banner → on success +`navigate(safeFrom(from), {replace})`. Deep-link-while-unauthed → `RequireAuth` → `/login?from=` → +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): `navigateFn` is null → hard `window.location.assign` + to `/login?reason=expired&from=…` (graceful fallback; reason/return still preserved). +- **Open redirect:** `safeFrom` accepts 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): with `setNavigate(mock)` and a stubbed + `window.location` (pathname `/objects/abc`), `redirectToLogin()` calls `mock` with + `"/login?reason=expired&from=%2Fobjects%2Fabc"` and `{replace:true}`; with `setNavigate(null)` it calls + `window.location.assign` with the same target; when `pathname === "/login"` it does nothing. +- **`auth/login-page` test:** rendering at `/login?reason=expired` shows the `auth.sessionExpired` text; + a successful login at `/login?from=%2Fobjects%2F123` navigates to `/objects/123`; a bad `from` + (`//evil.com`) falls back to `/objects`; the submit button is disabled until both fields are non-empty. +- **`auth/require-auth` test:** when `useMe` resolves to no user, it renders a redirect to + `/login?from=…` carrying the attempted path. +- **`shell/user-menu` test:** the Sign out item is disabled while `logout.isPending`. +- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; en/sv parity (the #60 + parity test guards the new key); no codename; no new dependency. + +## Acceptance criteria +1. A 401 from an API call performs a **router** navigation (no full page reload) to + `/login?reason=expired&from=`; the React app/query cache are not torn down. +2. The login page shows a "session expired" message when `reason=expired`, and on success returns the + user to a **validated** `from` path (defaulting to `/objects`); protocol-relative/absolute `from` + values are rejected. +3. `RequireAuth` redirects unauthenticated users to `/login?from=` so deep links return + after login. +4. Login submit is disabled while either field is empty; the Sign out item shows a disabled/pending state + while logging out. +5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; 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. From 76f65a95ddf5f29e432bbe8db7f5dbc7569dae37 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 14:56:44 +0200 Subject: [PATCH 2/5] =?UTF-8?q?docs(plans):=20session-expiry=20soft=20redi?= =?UTF-8?q?rect=20=E2=80=94=203-task=20plan=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-08-session-expiry-ux.md | 448 ++++++++++++++++++ .../2026-06-08-session-expiry-ux-design.md | 29 +- 2 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-08-session-expiry-ux.md diff --git a/docs/superpowers/plans/2026-06-08-session-expiry-ux.md b/docs/superpowers/plans/2026-06-08-session-expiry-ux.md new file mode 100644 index 0000000..0a3565b --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-session-expiry-ux.md @@ -0,0 +1,448 @@ +# 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 +

} /> + object detail} /> + + ); +} +``` +Add these tests (keep the two existing ones): +```tsx +test("shows the session-expired notice when reason=expired", async () => { + renderApp(tree(), { route: "/login?reason=expired" }); + expect(await screen.findByText(/session expired/i)).toBeInTheDocument(); +}); + +test("returns to the from path on success", async () => { + renderApp(tree(), { route: "/login?from=%2Fobjects%2F123" }); + await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + expect(await screen.findByText("object detail")).toBeInTheDocument(); +}); + +test("rejects an off-site from and falls back to /objects", async () => { + renderApp(tree(), { route: "/login?from=%2F%2Fevil.com" }); + await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + expect(await screen.findByText("objects landing")).toBeInTheDocument(); +}); + +test("disables submit until both fields are filled", async () => { + renderApp(tree(), { route: "/login" }); + const button = screen.getByRole("button", { name: /sign in/i }); + expect(button).toBeDisabled(); + await userEvent.type(screen.getByLabelText(/email/i), "a@b.se"); + expect(button).toBeDisabled(); + await userEvent.type(screen.getByLabelText(/password/i), "pw"); + expect(button).toBeEnabled(); +}); +``` +(The existing "successful login navigates to /objects" test has no `from`, so `safeFrom(null)` → `/objects` keeps it green. The default MSW `/api/admin/login` handler returns 204 for `editor@example.com` / `pw-editor-123`.) + +- [ ] **Step 4: Run the login + i18n tests (vitest ONCE):** +```bash +cd web && pnpm vitest run src/auth/login-page.test.tsx src/i18n +``` +Expected: all green (login-page tests + the i18n parity test covering the 2 new keys). + +- [ ] **Step 5: Commit** +```bash +git add web/src/auth/login-page.tsx web/src/auth/login-page.test.tsx web/src/i18n/en.json web/src/i18n/sv.json +git commit -m "feat(web): login reason banner + return-to + empty-field guard (#48)" +``` + +--- + +# Task 3: RequireAuth return-to + logout pending + full gate + +**Files:** +- Modify: `web/src/auth/require-auth.tsx` +- Modify: `web/src/auth/require-auth.test.tsx` +- Modify: `web/src/shell/user-menu.tsx` +- Modify: `web/src/shell/user-menu.test.tsx` + +- [ ] **Step 1: Update `web/src/auth/require-auth.tsx`** to capture the attempted path: +```tsx +import { Navigate, Outlet, useLocation } from "react-router-dom"; + +import { useMe } from "../api/queries"; +import { AppShellSkeleton } from "@/components/ui/skeletons"; + +export function RequireAuth() { + const { data: user, isLoading } = useMe(); + const location = useLocation(); + + if (isLoading) return ; + + if (!user) { + const from = encodeURIComponent(location.pathname + location.search); + return ; + } + + return ; +} +``` + +- [ ] **Step 2: Update `web/src/auth/require-auth.test.tsx`** so the login stub echoes the search string, and assert `from` is carried. Replace the `tree()` and the redirect test: +```tsx +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { expect, test } from "vitest"; +import { Route, Routes, useLocation } from "react-router-dom"; + +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { RequireAuth } from "./require-auth"; + +function LoginStub() { + const location = useLocation(); + return
login page {location.search}
; +} + +function tree() { + return ( + + } /> + }> + secret objects} /> + + + ); +} + +test("renders children when authenticated", async () => { + renderApp(tree(), { route: "/objects" }); + expect(await screen.findByText("secret objects")).toBeInTheDocument(); +}); + +test("redirects unauthenticated users to /login carrying the attempted path", async () => { + server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 }))); + renderApp(tree(), { route: "/objects" }); + await waitFor(() => expect(screen.getByText(/from=%2Fobjects/)).toBeInTheDocument()); +}); +``` + +- [ ] **Step 3: Update `web/src/shell/user-menu.tsx`** to show a logout-pending state. Change only the ``: +```tsx + + {logout.isPending ? t("auth.signingOut") : t("auth.signOut")} + +``` +(Everything else in the file is unchanged. `onSignOut` already navigates to `/login` on success, which unmounts the menu since `useMe` becomes null.) + +- [ ] **Step 4: Add a pending-state test to `web/src/shell/user-menu.test.tsx`.** Add `delay` to the msw import and append a test: +```tsx +import { delay, http, HttpResponse } from "msw"; +``` +```tsx +test("shows a pending state on Sign out while logging out", async () => { + server.use( + http.post("/api/admin/logout", async () => { + await delay(50); + return new HttpResponse(null, { status: 204 }); + }), + ); + + renderApp(); + + const trigger = await screen.findByRole("button", { name: /editor@example.com/ }); + await userEvent.click(trigger); + + const menu = within(document.body); + await userEvent.click(await menu.findByText("Sign out")); + + expect(await menu.findByText(/signing out/i)).toBeInTheDocument(); +}); +``` +(The existing "signs out" test still passes: `closeOnClick={false}` keeps the item, the POST still fires, `loggedOut` flips true.) + +- [ ] **Step 5: FULL FRONTEND GATE (run tests EXACTLY ONCE):** +```bash +cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors +``` +All green. Report test totals, largest chunk (gz), and the `check:colors` line. + +- [ ] **Step 6: Codename + status:** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?" +git status --short +``` +Expected: no codename matches (`codename-exit=1`). + +- [ ] **Step 7: Manual smoke (recommended).** With the stack + server + `pnpm dev` up: open an object edit, let the session expire (or delete the session cookie) and trigger a save → you land on `/login` **without a full reload**, see "Your session expired…", and after re-login return to the same record. Deep-link to a route while logged out → login → returns there. Empty login form → Sign in disabled. Click Sign out → brief "Signing out…". + +- [ ] **Step 8: Commit** +```bash +git add web/src/auth/require-auth.tsx web/src/auth/require-auth.test.tsx web/src/shell/user-menu.tsx web/src/shell/user-menu.test.tsx +git commit -m "feat(web): return-to-destination on auth redirect; logout pending state (#48)" +``` + +--- + +## Self-Review (completed) + +**Spec coverage:** navigate bridge + soft redirect with reason/from (T1 — AC1); login reason banner + +validated return-to + empty-field guard (T2 — AC2, AC4 first half); `RequireAuth` return-to (T3 S1–S2 — +AC3); logout pending state (T3 S3–S4 — AC4 second half); i18n 2 keys + parity (T2 S1); full gate + +codename (T3 S5–S6 — AC5). All 5 acceptance criteria mapped. ✓ + +**Placeholder scan:** every code step shows complete code; tests have concrete assertions and exact +encoded URLs (`%2Fobjects%2Fabc%3Fx%3D1`, `%2F%2Fevil.com`); `vi.stubGlobal("location", …)` is the +established stub. No TODO/TBD. ✓ + +**Type consistency:** `setNavigate(fn: NavigateFn | null)` defined in T1 and called with React Router's +`navigate` (compatible signature `(to, {replace})`) in `NavigationBridge`, and with `null` in cleanup; +`redirectToLogin()` signature unchanged so `client.ts` needs no edit; `safeFrom(raw: string | null)` +consumed only in `login-page.tsx`. i18n keys `auth.sessionExpired` / `auth.signingOut` added in T2 and +`auth.signingOut` consumed in T3. ✓ + +## Notes +- No new dependency. `ui/menu.tsx` is **not** modified (Base UI `MenuItem` already exposes `closeOnClick` + + `disabled`). en/sv parity preserved (2 new keys, guarded by the #60 parity test). +- `client.ts` is intentionally untouched — only `redirectToLogin`'s behaviour changes. +- The in-place re-auth modal (full field-level preservation) is a deferred follow-up per the spec. diff --git a/docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md b/docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md index 47a0dbe..bd280e4 100644 --- a/docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md +++ b/docs/superpowers/specs/2026-06-08-session-expiry-ux-design.md @@ -135,12 +135,22 @@ if (!user) { 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: -``. Error already surfaces via the global toast. +Keep the menu open during logout and show a pending state on the item (Base UI `MenuItem` supports both +`closeOnClick` and `disabled`): +```tsx + + {logout.isPending ? t("auth.signingOut") : t("auth.signOut")} + +``` +On success the mutation sets `me` to null and navigates to `/login`, so `UserMenu` unmounts (it returns +`null` when `!me`). Logout **error** already surfaces via the global toast (`useLogout` has no +`suppressErrorToast`). `closeOnClick={false}` also prevents a double-submit (the item is disabled while +pending). -### i18n (en + sv parity — 1 new key) +### i18n (en + sv parity — 2 new keys) - `auth.sessionExpired` = "Your session expired — please sign in again." / "Din session har gått ut — logga in igen." +- `auth.signingOut` = "Signing out…" / "Loggar ut…" ## Data flow API 401 → `client.ts` middleware → `redirectToLogin()` → registered `navigate("/login?reason=expired& @@ -170,9 +180,10 @@ same return-to. The `NavigationBridge` keeps `setNavigate` current for the lifet (`//evil.com`) falls back to `/objects`; the submit button is disabled until both fields are non-empty. - **`auth/require-auth` test:** when `useMe` resolves to no user, it renders a redirect to `/login?from=…` carrying the attempted path. -- **`shell/user-menu` test:** the Sign out item is disabled while `logout.isPending`. +- **`shell/user-menu` test:** with a delayed logout handler, after clicking Sign out the item shows the + `auth.signingOut` pending text (the menu stays open via `closeOnClick={false}`). - **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; en/sv parity (the #60 - parity test guards the new key); no codename; no new dependency. + parity test guards the 2 new keys); no codename; no new dependency. ## Acceptance criteria 1. A 401 from an API call performs a **router** navigation (no full page reload) to @@ -182,10 +193,10 @@ same return-to. The `NavigationBridge` keeps `setNavigate` current for the lifet values are rejected. 3. `RequireAuth` redirects unauthenticated users to `/login?from=` so deep links return after login. -4. Login submit is disabled while either field is empty; the Sign out item shows a disabled/pending state - while logging out. -5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity (1 new - key); no codename; no new dependency. +4. Login submit is disabled while either field is empty; the Sign out item shows a pending state + (`auth.signingOut`, disabled) while logging out. +5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity (2 new + keys); 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 From 3c59f47f81b76ad10fcfd1119d3e0bd68c201f3b Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 14:58:25 +0200 Subject: [PATCH 3/5] feat(web): soft-redirect to login on 401 via a navigate bridge (#48) --- web/src/api/auth-redirect.test.ts | 46 +++++++++++++++++++++++++++++ web/src/api/auth-redirect.ts | 24 ++++++++++++--- web/src/app.tsx | 16 ++++++++-- web/src/shell/navigation-bridge.tsx | 16 ++++++++++ 4 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 web/src/api/auth-redirect.test.ts create mode 100644 web/src/shell/navigation-bridge.tsx diff --git a/web/src/api/auth-redirect.test.ts b/web/src/api/auth-redirect.test.ts new file mode 100644 index 0000000..c51d130 --- /dev/null +++ b/web/src/api/auth-redirect.test.ts @@ -0,0 +1,46 @@ +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(); +}); diff --git a/web/src/api/auth-redirect.ts b/web/src/api/auth-redirect.ts index 269a4bf..77f52c5 100644 --- a/web/src/api/auth-redirect.ts +++ b/web/src/api/auth-redirect.ts @@ -1,7 +1,23 @@ -/** Hard-navigate to login. Isolated so it can be spied/mocked in tests and swapped - * for a router navigation if needed. */ +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 { - if (window.location.pathname !== "/login") { - window.location.assign("/login"); + 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); } } diff --git a/web/src/app.tsx b/web/src/app.tsx index 907a7cc..5f227b1 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from "react"; -import { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom"; +import { createBrowserRouter, createRoutesFromElements, Navigate, Outlet, Route, RouterProvider } from "react-router-dom"; +import { NavigationBridge } from "./shell/navigation-bridge"; import { RequireAuth } from "./auth/require-auth"; import { LoginPage } from "./auth/login-page"; import { AppShell } from "./shell/app-shell"; @@ -26,9 +27,18 @@ const FieldsPage = lazy(() => import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })), ); +function RootLayout() { + return ( + <> + + + + ); +} + const router = createBrowserRouter( createRoutesFromElements( - <> + }> } /> }> }> @@ -73,7 +83,7 @@ const router = createBrowserRouter( } /> - , + , ), ); diff --git a/web/src/shell/navigation-bridge.tsx b/web/src/shell/navigation-bridge.tsx new file mode 100644 index 0000000..f920826 --- /dev/null +++ b/web/src/shell/navigation-bridge.tsx @@ -0,0 +1,16 @@ +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; +} From ec6e90ef5bc468fb8739b98c5f14464857db72cd Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 15:02:55 +0200 Subject: [PATCH 4/5] feat(web): login reason banner + return-to + empty-field guard (#48) --- web/src/auth/login-page.test.tsx | 32 ++++++++++++++++++++++++++++++++ web/src/auth/login-page.tsx | 18 +++++++++++++++--- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/web/src/auth/login-page.test.tsx b/web/src/auth/login-page.test.tsx index dd1ad84..0cf8a3c 100644 --- a/web/src/auth/login-page.test.tsx +++ b/web/src/auth/login-page.test.tsx @@ -12,6 +12,7 @@ function tree() { } /> objects landing} /> + object detail} /> ); } @@ -34,3 +35,34 @@ test("invalid credentials show an inline error", async () => { expect(screen.getByText(/invalid email or password/i)).toBeInTheDocument(), ); }); + +test("shows the session-expired notice when reason=expired", async () => { + renderApp(tree(), { route: "/login?reason=expired" }); + expect(await screen.findByText(/session expired/i)).toBeInTheDocument(); +}); + +test("returns to the from path on success", async () => { + renderApp(tree(), { route: "/login?from=%2Fobjects%2F123" }); + await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + expect(await screen.findByText("object detail")).toBeInTheDocument(); +}); + +test("rejects an off-site from and falls back to /objects", async () => { + renderApp(tree(), { route: "/login?from=%2F%2Fevil.com" }); + await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + expect(await screen.findByText("objects landing")).toBeInTheDocument(); +}); + +test("disables submit until both fields are filled", async () => { + renderApp(tree(), { route: "/login" }); + const button = screen.getByRole("button", { name: /sign in/i }); + expect(button).toBeDisabled(); + await userEvent.type(screen.getByLabelText(/email/i), "a@b.se"); + expect(button).toBeDisabled(); + await userEvent.type(screen.getByLabelText(/password/i), "pw"); + expect(button).toBeEnabled(); +}); diff --git a/web/src/auth/login-page.tsx b/web/src/auth/login-page.tsx index 3de4927..1eedfeb 100644 --- a/web/src/auth/login-page.tsx +++ b/web/src/auth/login-page.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type FormEvent } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useLogin } from "../api/queries"; @@ -8,10 +8,19 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +/** 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"; +} + export function LoginPage() { const { t } = useTranslation(); const { app_name } = useConfig(); const navigate = useNavigate(); + const [params] = useSearchParams(); + const sessionExpired = params.get("reason") === "expired"; const login = useLogin(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -24,7 +33,7 @@ export function LoginPage() { event.preventDefault(); login.mutate( { email, password }, - { onSuccess: () => navigate("/objects", { replace: true }) }, + { onSuccess: () => navigate(safeFrom(params.get("from")), { replace: true }) }, ); }; @@ -38,6 +47,9 @@ export function LoginPage() {

{app_name}

+ {sessionExpired && ( +

{t("auth.sessionExpired")}

+ )}
)} - diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index a8a70bf..02a74a1 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -1,7 +1,7 @@ { "common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading", "filter": "Filter…", "noMatches": "No matches", "language": "Language", "skipToContent": "Skip to content" }, "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar" }, - "auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" }, + "auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server", "sessionExpired": "Your session expired — please sign in again.", "signingOut": "Signing out…" }, "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" }, "fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility" }, "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 59d9c0c..f0ffe52 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -1,7 +1,7 @@ { "common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar", "filter": "Filtrera…", "noMatches": "Inga träffar", "language": "Språk", "skipToContent": "Hoppa till innehåll" }, "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet" }, - "auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" }, + "auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern", "sessionExpired": "Din session har gått ut — logga in igen.", "signingOut": "Loggar ut…" }, "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" }, "fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet" }, "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, From af3f1a5367f215680b5592c8e118812465e30f5c Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 15:06:50 +0200 Subject: [PATCH 5/5] feat(web): return-to-destination on auth redirect; logout pending state (#48) --- web/src/auth/require-auth.test.tsx | 13 +++++++++---- web/src/auth/require-auth.tsx | 8 ++++++-- web/src/shell/user-menu.test.tsx | 21 ++++++++++++++++++++- web/src/shell/user-menu.tsx | 4 +++- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/web/src/auth/require-auth.test.tsx b/web/src/auth/require-auth.test.tsx index 643e9cc..d0faa1a 100644 --- a/web/src/auth/require-auth.test.tsx +++ b/web/src/auth/require-auth.test.tsx @@ -1,16 +1,21 @@ import { screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { expect, test } from "vitest"; -import { Route, Routes } from "react-router-dom"; +import { Route, Routes, useLocation } from "react-router-dom"; import { server } from "../test/server"; import { renderApp } from "../test/render"; import { RequireAuth } from "./require-auth"; +function LoginStub() { + const location = useLocation(); + return
login page {location.search}
; +} + function tree() { return ( - login page
} /> + } /> }> secret objects
} />
@@ -23,8 +28,8 @@ test("renders children when authenticated", async () => { expect(await screen.findByText("secret objects")).toBeInTheDocument(); }); -test("redirects to /login when unauthenticated", async () => { +test("redirects unauthenticated users to /login carrying the attempted path", async () => { server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 }))); renderApp(tree(), { route: "/objects" }); - await waitFor(() => expect(screen.getByText("login page")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText(/from=%2Fobjects/)).toBeInTheDocument()); }); diff --git a/web/src/auth/require-auth.tsx b/web/src/auth/require-auth.tsx index 23876f3..7adb927 100644 --- a/web/src/auth/require-auth.tsx +++ b/web/src/auth/require-auth.tsx @@ -1,14 +1,18 @@ -import { Navigate, Outlet } from "react-router-dom"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; import { useMe } from "../api/queries"; import { AppShellSkeleton } from "@/components/ui/skeletons"; export function RequireAuth() { const { data: user, isLoading } = useMe(); + const location = useLocation(); if (isLoading) return ; - if (!user) return ; + if (!user) { + const from = encodeURIComponent(location.pathname + location.search); + return ; + } return ; } diff --git a/web/src/shell/user-menu.test.tsx b/web/src/shell/user-menu.test.tsx index 70085b9..784c478 100644 --- a/web/src/shell/user-menu.test.tsx +++ b/web/src/shell/user-menu.test.tsx @@ -1,7 +1,7 @@ import { expect, test } from "vitest"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { http, HttpResponse } from "msw"; +import { delay, http, HttpResponse } from "msw"; import { server } from "../test/server"; import { renderApp } from "../test/render"; import { UserMenu } from "./user-menu"; @@ -33,3 +33,22 @@ test("opens the menu showing email + role and signs out", async () => { await userEvent.click(signOut); await waitFor(() => expect(loggedOut).toBe(true)); }); + +test("shows a pending state on Sign out while logging out", async () => { + server.use( + http.post("/api/admin/logout", async () => { + await delay(50); + return new HttpResponse(null, { status: 204 }); + }), + ); + + renderApp(); + + const trigger = await screen.findByRole("button", { name: /editor@example.com/ }); + await userEvent.click(trigger); + + const menu = within(document.body); + await userEvent.click(await menu.findByText("Sign out")); + + expect(await menu.findByText(/signing out/i)).toBeInTheDocument(); +}); diff --git a/web/src/shell/user-menu.tsx b/web/src/shell/user-menu.tsx index a8779be..643feb1 100644 --- a/web/src/shell/user-menu.tsx +++ b/web/src/shell/user-menu.tsx @@ -35,7 +35,9 @@ export function UserMenu() {
{me.role}
- {t("auth.signOut")} + + {logout.isPending ? t("auth.signingOut") : t("auth.signOut")} + );