Adds a small test harness (src/test/testUtils.tsx):
- renderWithProviders mounts a fresh QueryClient (no retries, no cache)
+ MemoryRouter so screens using useNavigate / <Link> don't crash.
- installFetchMock(responses[]) replaces globalThis.fetch with a typed
sequence of canned responses and records call URLs + init.
Specs (10 cases, all green):
LoginPage.test.tsx
- happy path: submit posts to /api/v1/auth/login with credentials:'include',
correct JSON body shape (username/password).
- 401 surfaces "Identifiants invalides" and does NOT leak the backend
detail string.
- empty submit is intercepted by HTML5 `required` — no fetch fires.
EngagementsPage.test.tsx
- loading row renders while /engagements is in flight.
- empty state renders on 200 [].
- error state + Retry button render on 500.
- populated table renders the snake_case fields correctly (name,
client_name, c2_type uppercased).
EngagementCreateDialog.test.tsx
- client-side validation: empty name blocks submission, no fetch fires.
- 422 Pydantic error on the `name` field maps to the inline message
next to the input.
- 201 success triggers onClose() and POSTs to /api/v1/engagements.
LoginPage
- RT mode now POSTs /api/v1/auth/login with controlled username/password
fields. Success seeds the session cache via queryClient.setQueryData and
navigates to /engagements. 401 surfaces as the generic
"Identifiants invalides" — no echo of the backend detail (avoids
user enumeration leaks).
- SOC mode kept visually for masthead continuity but disabled with a
"sprint 2" placeholder pointing at the deferred
POST /api/v1/auth/soc/session endpoint.
- Removed the sprint-0 mock role-picker.
EngagementsPage
- MOCK_ENGAGEMENTS dropped. useQuery against fetchEngagements (handles
both bare-array and { items: [] } envelope shapes — backend has not
pinned this yet).
- Distinct loading / empty / error states. Error row surfaces an HTTP
code and a Retry button. Empty state offers the create dialog.
- Column shape aligned with the real Engagement schema (snake_case:
name, client_name, c2_type, start_date, end_date). Dropped mock-only
columns (operators, socAnalysts) — those land when backend exposes
/engagements/:id/members and /engagements/:id/soc-sessions counts.
engagementsApi.ts
- fetchEngagements + createEngagement, both bound to /api/v1/engagements.
- ENGAGEMENTS_QUERY_KEY exported so the dialog can invalidate without
re-knowing the key.
EngagementCreateDialog (frontend-design skill — new non-trivial component)
- "Arm engagement" mission-control dialog. Backdrop is a graphite dim
with a faint scanline overlay (no soft blur) — reads as "cockpit
paused while you issue a command", not as a SaaS modal.
- Surface --surface-3 with corner-marks and an amber hairline accent
under the title; underline-style inputs that light amber on focus;
label-system uppercase microtypography throughout.
- Esc + outside-click close (suspended while the mutation is in flight).
- Rudimentary tab focus trap.
- 422 Pydantic errors map per-field via the last loc segment;
401/5xx surface as a generic top-of-form alert.
- On 201 invalidates ['engagements'] and closes.
Foundations for the sprint 1 backend wiring. No UI behavior change beyond
the loading state in AppShell, but everything below the wire is now real:
- vite.config.ts adds `server.proxy['/api']` → http://localhost:5000
(overridable via VITE_DEV_API_TARGET). In prod Caddy routes /api → backend
on the same origin, so the same `/api/v1/...` paths work without changes.
- src/types/api.ts hand-rolled against the backend Pydantic schemas.
User / Engagement / EngagementCreate / Login / ApiError / ApiValidationError.
Should be regenerated from OpenAPI once backend exposes it.
- src/lib/api.ts: thin fetch wrapper. Always credentials:'include' so the
HttpOnly session cookie travels. 4xx/5xx normalize into ApiClientError
with typed `body` (ApiError | ApiValidationError | null). No retry loop —
that's TanStack Query's policy.
- src/session/sessionApi.ts: 1:1 functions for /auth/me, /auth/login,
/auth/logout. fetchMe maps 401 → null so "unauthenticated" is data, not
an error.
- src/session/useSession.ts: now a TanStack Query hook against
SESSION_QUERY_KEY (`['session']`). Returns { user, isLoading, isError,
signOut, isSigningOut }. Cookie is the source of truth, server is the
resolver, query is the cache.
- Drop sessionStorage mock layer entirely: src/mocks/session.ts,
src/session/SessionContext.{tsx,context.ts}, src/routing/Root.tsx all
removed. No more provider tree — QueryClientProvider in App.tsx is the
only global state container.
- AppShell renders a "resolving session" state during /auth/me's first
flight so users with a valid cookie don't see a /login flash on direct
navigation to a protected URL.
- StatusRail gains an optional `sessionState="resolving"` slot used by
the loading shell.
- Sidebar's Sign-out wires POST /auth/logout, invalidates the session
cache, and always navigates to /login regardless of the call outcome
(a failed logout still expires the local cache so users aren't stuck
on a broken cookie).
- types/roles.ts loses SessionUser (replaced by api.ts User which is the
authoritative shape).
Production image for the frontend dist.
Stage 1 (build): node:22-alpine, `npm ci --ignore-scripts` from the
committed lockfile, `npm run build`. Output lands in /app/dist.
Stage 2 (runtime): docker.io/nginxinc/nginx-unprivileged:alpine.
- Upstream-maintained variant that runs as the nginx user (uid 101)
out of the box. /var/cache/nginx and /var/run/nginx are pre-owned,
no chown gymnastics needed in our layer. Vanilla nginx:alpine fails
at startup as non-root because client_temp mkdir is denied.
- Listens on 8080 (non-privileged port, matches the unprivileged
variant convention).
- nginx.conf serves /usr/share/nginx/html with SPA `try_files`
fallback for client-side routing, long-cache headers on
/assets/ (Vite hashed bundles), a plaintext /healthz endpoint
for Caddy / Prometheus blackbox, and server_tokens off.
.dockerignore excludes node_modules, dist, .vite, coverage,
playwright-report, .env*, .git, editor dirs. Keeps .env.example.
Smoke local validated with `podman build -t mimic-frontend:smoke .`
and `podman run -p 127.0.0.1:18080:8080`:
/healthz -> 200 "ok"
/ -> 200 index.html (508 B)
/spa/x -> 200 (SPA fallback)
/assets -> Cache-Control: max-age=31536000, public, immutable
M1 — Single SessionProvider via nested router.
The previous router had two route entries with `path: '/'`
(Navigate, AppShell) plus a separate `/login` entry, each wrapped in
its own RootLayout. That instantiated SessionProvider three times,
forking state the moment session writes diverged across siblings.
Replaced by one Root route with SessionProvider + <Outlet />, and
index/login/AppShell-children nested underneath. RootLayout (the
per-tree wrapper) is now obsolete and deleted; the new Root component
lives in src/routing/Root.tsx (addresses NIT N4 as a side effect).
M2 — Typo: "pollign" → "polling" in LiveCockpitPage masthead.
M3 — Replace asymmetric `?? 'rt_operator'` / `?? 'soc_analyst'`
fallbacks in LiveCockpitPage with an explicit `if (!user) return null;`
guard placed after all hooks (rules-of-hooks). AppShell already
redirects unauthenticated visitors to /login, so the guard documents
the invariant rather than introducing one.
NITs N1-N3, N5-N7 recorded in tasks/todo.md as sprint 1+ follow-ups.
- Role enum (rt_operator, rt_lead, soc_analyst) aligned with spec §3 / F11.
Frontend predicates (isRT, isLead, isSOC) drive layout only — backend
remains the source of truth for permissions (D-008).
- SessionContext split into Provider (TSX) and hook (useSession) to satisfy
react-refresh/only-export-components.
- AppShell composes StatusRail (link health, active run, UTC clock, build) +
Sidebar (role-conditional nav with keyboard shortcut hints) + Outlet.
Unauthenticated visitors redirect to /login.
- StatusRail uses pulsing status-dot pattern and label-system micro-typo
(uppercase 10px, 0.08em tracking) to evoke an instrument-panel header.
- Router (createBrowserRouter): /login outside the shell, all app routes
nested inside the shell. RootLayout extracted to its own file for
fast-refresh compliance.
Routes (sprint 0, flat):
/login LoginPage
/engagements EngagementsPage
/library TtpLibraryPage (RT only — gated client-side, will
be re-enforced by backend RBAC)
/scenarios ScenarioComposerPage (RT only)
/runs LiveCockpitPage
/reports ReportPage
/audit AuditPage (lead RT only)
Sub-routes under /engagements/:eid land in sprint 1+ when real scoping
arrives.