merge: session-expiry soft redirect + auth feedback (#48)
CI / web (push) Has been cancelled
CI / web (push) Has been cancelled
This commit is contained in:
@@ -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=<path>`. 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: `<Route path="/login">`, `<Route element={<RequireAuth/>}>…</Route>`, `<Route path="*">`. 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 `<Routes>` 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` → `<AppShellSkeleton/>`; `!user` → `<Navigate to="/login" replace/>`. Test: `require-auth.test.tsx`.
|
||||
- `user-menu.tsx`: `useLogout()`, `onSignOut` navigates to `/login` on success; `<MenuItem onClick={onSignOut}>{t("auth.signOut")}</MenuItem>`; 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 (
|
||||
<>
|
||||
<NavigationBridge />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
Wrap the existing top-level fragment children in a pathless `<Route element={<RootLayout />}>` — i.e. change `createRoutesFromElements(<> … </>)` so the outer element is `<Route element={<RootLayout />}> … </Route>` containing the existing `/login`, `RequireAuth`, and `*` routes unchanged:
|
||||
```tsx
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route element={<RootLayout />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
{/* …AppShell subtree exactly as before… */}
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/objects" replace />} />
|
||||
</Route>,
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **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 `<form>`, above the email field (after the `<h1>`):
|
||||
```tsx
|
||||
{sessionExpired && (
|
||||
<p className="text-sm text-muted-foreground">{t("auth.sessionExpired")}</p>
|
||||
)}
|
||||
```
|
||||
Change the submit button disabled condition:
|
||||
```tsx
|
||||
<Button type="submit" className="w-full" disabled={login.isPending || !email.trim() || !password}>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `web/src/auth/login-page.test.tsx`.** Extend `tree()` with an `:id` destination and add tests. Replace the file's `tree()` and append the new tests:
|
||||
```tsx
|
||||
function tree() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/objects" element={<div>objects landing</div>} />
|
||||
<Route path="/objects/:id" element={<div>object detail</div>} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
```
|
||||
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 <AppShellSkeleton />;
|
||||
|
||||
if (!user) {
|
||||
const from = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?from=${from}`} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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 <div>login page {location.search}</div>;
|
||||
}
|
||||
|
||||
function tree() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginStub />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/objects" element={<div>secret objects</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
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 `<MenuItem>`:
|
||||
```tsx
|
||||
<MenuItem closeOnClick={false} disabled={logout.isPending} onClick={onSignOut}>
|
||||
{logout.isPending ? t("auth.signingOut") : t("auth.signOut")}
|
||||
</MenuItem>
|
||||
```
|
||||
(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(<UserMenu />);
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,205 @@
|
||||
# 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)
|
||||
1. **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 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
|
||||
`<Outlet/>`, so the bridge is always present (covers `/login` and the authed area):
|
||||
```tsx
|
||||
function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
<NavigationBridge />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
```tsx
|
||||
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"`: 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 <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)
|
||||
Keep the menu open during logout and show a pending state on the item (Base UI `MenuItem` supports both
|
||||
`closeOnClick` and `disabled`):
|
||||
```tsx
|
||||
<MenuItem closeOnClick={false} disabled={logout.isPending} onClick={onSignOut}>
|
||||
{logout.isPending ? t("auth.signingOut") : t("auth.signOut")}
|
||||
</MenuItem>
|
||||
```
|
||||
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 — 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&
|
||||
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): `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:** 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 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
|
||||
`/login?reason=expired&from=<attempted path>`; 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=<attempted path>` so deep links return
|
||||
after login.
|
||||
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
|
||||
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.
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -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 (
|
||||
<>
|
||||
<NavigationBridge />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<>
|
||||
<Route element={<RootLayout />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppShell />}>
|
||||
@@ -73,7 +83,7 @@ const router = createBrowserRouter(
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/objects" replace />} />
|
||||
</>,
|
||||
</Route>,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ function tree() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/objects" element={<div>objects landing</div>} />
|
||||
<Route path="/objects/:id" element={<div>object detail</div>} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<form onSubmit={onSubmit} className="w-full max-w-sm space-y-4">
|
||||
<h1 className="text-2xl font-semibold">{app_name}</h1>
|
||||
{sessionExpired && (
|
||||
<p className="text-sm text-muted-foreground">{t("auth.sessionExpired")}</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||
<Input
|
||||
@@ -63,7 +75,7 @@ export function LoginPage() {
|
||||
{t(errorKey)}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={login.isPending}>
|
||||
<Button type="submit" className="w-full" disabled={login.isPending || !email.trim() || !password}>
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -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 <div>login page {location.search}</div>;
|
||||
}
|
||||
|
||||
function tree() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>login page</div>} />
|
||||
<Route path="/login" element={<LoginStub />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/objects" element={<div>secret objects</div>} />
|
||||
</Route>
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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 <AppShellSkeleton />;
|
||||
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (!user) {
|
||||
const from = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?from=${from}`} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(<UserMenu />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -35,7 +35,9 @@ export function UserMenu() {
|
||||
<div className="text-xs text-muted-foreground">{me.role}</div>
|
||||
</div>
|
||||
<MenuSeparator />
|
||||
<MenuItem onClick={onSignOut}>{t("auth.signOut")}</MenuItem>
|
||||
<MenuItem closeOnClick={false} disabled={logout.isPending} onClick={onSignOut}>
|
||||
{logout.isPending ? t("auth.signingOut") : t("auth.signOut")}
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user