From 6aa0078fd35311271068da7b74265b9ecf97c35d Mon Sep 17 00:00:00 2001 From: ux-frontend Date: Sat, 23 May 2026 04:26:28 +0200 Subject: [PATCH] feat(frontend): wire session to real /auth/me + drop sessionStorage mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- frontend/src/components/shell/AppShell.tsx | 32 +++++-- frontend/src/components/shell/Sidebar.tsx | 49 +++++++--- frontend/src/components/shell/StatusRail.tsx | 10 ++ frontend/src/lib/api.ts | 93 +++++++++++++++++++ frontend/src/mocks/session.ts | 60 ------------ frontend/src/router.tsx | 41 ++++---- frontend/src/routing/Root.tsx | 16 ---- .../src/session/SessionContext.context.ts | 10 -- frontend/src/session/SessionContext.tsx | 25 ----- frontend/src/session/sessionApi.ts | 32 +++++++ frontend/src/session/useSession.ts | 58 ++++++++++-- frontend/src/types/api.ts | 68 ++++++++++++++ frontend/src/types/roles.ts | 8 -- frontend/vite.config.ts | 9 ++ 14 files changed, 340 insertions(+), 171 deletions(-) create mode 100644 frontend/src/lib/api.ts delete mode 100644 frontend/src/mocks/session.ts delete mode 100644 frontend/src/routing/Root.tsx delete mode 100644 frontend/src/session/SessionContext.context.ts delete mode 100644 frontend/src/session/SessionContext.tsx create mode 100644 frontend/src/session/sessionApi.ts create mode 100644 frontend/src/types/api.ts diff --git a/frontend/src/components/shell/AppShell.tsx b/frontend/src/components/shell/AppShell.tsx index 4763b1a..56842e6 100644 --- a/frontend/src/components/shell/AppShell.tsx +++ b/frontend/src/components/shell/AppShell.tsx @@ -11,20 +11,38 @@ import { useClock } from './useClock'; * ┌──────────────────────────────────────────────────────────────┐ * │ StatusRail (link health · active run · UTC clock · build) │ * ├──────────┬───────────────────────────────────────────────────┤ - * │ │ │ * │ Sidebar │ Outlet (current screen) │ - * │ │ │ * └──────────┴───────────────────────────────────────────────────┘ * - * Unauthenticated visitors are redirected to /login. Inside the shell, the - * sidebar and rail expose only what the current session's role can see — - * this is layout, not enforcement: the API remains the source of truth on - * permissions (D-008 / F11). + * Session resolution flow: + * 1. useSession queries /api/v1/auth/me with the cookie that travels + * automatically on every fetch. + * 2. While the query is in flight, the shell renders a minimal masthead + * with a "resolving session" pill — avoids a /login flash for users + * who land on a protected URL with a valid cookie. + * 3. Resolved null → redirect to /login. Resolved User → render the + * shell. Errors fall through to the same null path (treat hard + * backend errors as unauthenticated for routing purposes, the + * LoginPage surfaces the underlying message). + * + * Backend RBAC remains authoritative — the role enum here only drives + * layout (which nav items appear). */ export function AppShell() { - const { user } = useSession(); + const { user, isLoading } = useSession(); const clock = useClock(); + if (isLoading) { + return ( +
+ +
+ resolving session … +
+
+ ); + } + if (!user) { return ; } diff --git a/frontend/src/components/shell/Sidebar.tsx b/frontend/src/components/shell/Sidebar.tsx index ef5ef46..643ec34 100644 --- a/frontend/src/components/shell/Sidebar.tsx +++ b/frontend/src/components/shell/Sidebar.tsx @@ -1,7 +1,8 @@ -import { NavLink } from 'react-router-dom'; +import { NavLink, useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { clsx } from 'clsx'; import { Logo } from '@/components/brand/Logo'; -import { useSession } from '@/session/useSession'; +import { useSession, SESSION_QUERY_KEY } from '@/session/useSession'; import { ROLE_LABELS, isRT, isLead } from '@/types/roles'; interface NavItem { @@ -21,22 +22,36 @@ const NAV_ITEMS: NavItem[] = [ ]; export function Sidebar() { - const { user, signOut } = useSession(); + const { user, signOut, isSigningOut } = useSession(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + if (!user) return null; const canRT = isRT(user.role); const canLead = isLead(user.role); const visible = NAV_ITEMS.filter((item) => item.show({ canRT, canLead })); + // Logout is a one-shot side effect that always lands on /login regardless + // of whether the backend call succeeded — a failed logout still expires + // the local session cache so the user is not stuck on a broken cookie. + const handleSignOut = () => { + void (async () => { + try { + await signOut(); + } catch { + queryClient.setQueryData(SESSION_QUERY_KEY, null); + } + void navigate('/login', { replace: true }); + })(); + }; + return ( ); diff --git a/frontend/src/components/shell/StatusRail.tsx b/frontend/src/components/shell/StatusRail.tsx index b60fff2..ea0df38 100644 --- a/frontend/src/components/shell/StatusRail.tsx +++ b/frontend/src/components/shell/StatusRail.tsx @@ -18,6 +18,8 @@ interface StatusRailProps { activeRunId?: string; activeRunState?: 'running' | 'paused' | 'aborting' | 'idle'; clock: string; + /** Optional session resolution indicator, shown while /auth/me is in flight. */ + sessionState?: 'resolving'; } const LINK_LABEL: Record = { @@ -37,6 +39,7 @@ export function StatusRail({ activeRunId, activeRunState = 'idle', clock, + sessionState, }: StatusRailProps) { return (
+ {sessionState === 'resolving' && ( + + + SESSION · RESOLVING + + )} + {clock} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..1cbdd26 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,93 @@ +/** + * Thin fetch wrapper for the Mimic backend. + * + * Responsibilities: + * - Inject `credentials: 'include'` so the session cookie travels with + * every call (the cookie itself is HttpOnly + Secure, set by the backend). + * - Set JSON Content-Type on bodied requests and parse JSON responses. + * - Normalize 4xx/5xx into a typed `ApiClientError` so callers can branch + * on `error.status` and `error.detail` without duplicating parsing. + * + * Deliberate non-features: + * - No retry loop. TanStack Query owns that policy. + * - No CSRF token: same-origin in prod (Caddy), same-origin via Vite proxy + * in dev. SameSite=Lax cookie is enough for our threat model (no + * cross-site form posts in scope). + */ + +import type { ApiError, ApiValidationError } from '@/types/api'; + +const DEFAULT_BASE = '/api/v1'; + +export class ApiClientError extends Error { + status: number; + body: ApiError | ApiValidationError | null; + + constructor(status: number, message: string, body: ApiError | ApiValidationError | null) { + super(message); + this.name = 'ApiClientError'; + this.status = status; + this.body = body; + } +} + +interface ApiRequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + signal?: AbortSignal; +} + +async function parseBody(response: Response): Promise { + if (response.status === 204) return null; + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +function bodyAsApiError(body: unknown): ApiError | ApiValidationError | null { + if (typeof body !== 'object' || body === null) return null; + if (Array.isArray((body as { detail?: unknown }).detail)) { + return body as ApiValidationError; + } + if (typeof (body as { detail?: unknown }).detail === 'string') { + return body as ApiError; + } + return null; +} + +export async function apiFetch(path: string, opts: ApiRequestOptions = {}): Promise { + const url = path.startsWith('http') ? path : `${DEFAULT_BASE}${path}`; + const method = opts.method ?? 'GET'; + const headers: Record = { + Accept: 'application/json', + }; + let body: BodyInit | undefined; + if (opts.body !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(opts.body); + } + + const response = await fetch(url, { + method, + headers, + body, + credentials: 'include', + signal: opts.signal, + }); + + const parsed = await parseBody(response); + + if (!response.ok) { + throw new ApiClientError( + response.status, + `${method} ${url} → ${response.status.toString()}`, + bodyAsApiError(parsed), + ); + } + + return parsed as T; +} diff --git a/frontend/src/mocks/session.ts b/frontend/src/mocks/session.ts deleted file mode 100644 index 74dc68d..0000000 --- a/frontend/src/mocks/session.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { SessionUser } from '@/types/roles'; - -/** - * Sprint 0 mock — no backend yet. The session is selected from /login - * and persisted in sessionStorage so route navigations preserve role. - * Real auth lands later (D-003: local user/password v1, Keycloak OIDC v2). - */ - -const STORAGE_KEY = 'mimic.mock.session'; - -export const MOCK_SESSIONS: Record = { - rt_operator: { - id: 'usr_001', - displayName: 'M. Dubreuil', - role: 'rt_operator', - engagementId: 'eng_42', - engagementName: 'Démo Client X', - }, - rt_lead: { - id: 'usr_002', - displayName: 'A. Verlhac', - role: 'rt_lead', - engagementId: 'eng_42', - engagementName: 'Démo Client X', - }, - soc_analyst: { - id: 'usr_soc_07', - displayName: 'SOC · session #07', - role: 'soc_analyst', - engagementId: 'eng_42', - engagementName: 'Démo Client X', - }, -}; - -export function readMockSession(): SessionUser | null { - try { - const raw = sessionStorage.getItem(STORAGE_KEY); - if (!raw) return null; - const parsed: unknown = JSON.parse(raw); - if ( - typeof parsed === 'object' && - parsed !== null && - 'role' in parsed && - typeof (parsed as SessionUser).role === 'string' - ) { - return parsed as SessionUser; - } - return null; - } catch { - return null; - } -} - -export function writeMockSession(user: SessionUser): void { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user)); -} - -export function clearMockSession(): void { - sessionStorage.removeItem(STORAGE_KEY); -} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 14acf40..6fdc6cc 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,5 +1,4 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'; -import { Root } from '@/routing/Root'; import { AppShell } from '@/components/shell/AppShell'; import { LoginPage } from '@/screens/login/LoginPage'; import { EngagementsPage } from '@/screens/engagements/EngagementsPage'; @@ -10,35 +9,31 @@ import { TtpLibraryPage } from '@/screens/library/TtpLibraryPage'; import { AuditPage } from '@/screens/audit/AuditPage'; /** - * Routes mirror spec §9 (UI Web) with sprint 0 placeholders. + * Routes mirror spec §9 (UI Web). * - * The Root route mounts SessionProvider once for the entire tree. All - * top-level paths (login, app shell, fallback) are children of that - * single Root so they share one session state — no provider forking - * between routes. + * Session state lives in TanStack Query (key `SESSION_QUERY_KEY`), mounted + * once in App.tsx via QueryClientProvider — no per-route provider needed. + * + * AppShell is the gate for authenticated routes: it reads useSession() and + * redirects to /login on null user. The login route lives outside the + * shell so it stays reachable when unauthenticated. * * Once real engagement scoping lands, sub-routes nest under - * /engagements/:eid (spec §9). Sprint 0 keeps URLs flat so the - * wireframes are reachable directly. + * /engagements/:eid (spec §9). Sprint 1 keeps URLs flat. */ export const router = createBrowserRouter([ + { index: true, element: }, + { path: '/login', element: }, { - element: , + element: , children: [ - { index: true, element: }, - { path: 'login', element: }, - { - element: , - children: [ - { path: 'engagements', element: }, - { path: 'library', element: }, - { path: 'scenarios', element: }, - { path: 'runs', element: }, - { path: 'reports', element: }, - { path: 'audit', element: }, - ], - }, - { path: '*', element: }, + { path: '/engagements', element: }, + { path: '/library', element: }, + { path: '/scenarios', element: }, + { path: '/runs', element: }, + { path: '/reports', element: }, + { path: '/audit', element: }, ], }, + { path: '*', element: }, ]); diff --git a/frontend/src/routing/Root.tsx b/frontend/src/routing/Root.tsx deleted file mode 100644 index ec978f8..0000000 --- a/frontend/src/routing/Root.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Outlet } from 'react-router-dom'; -import { SessionProvider } from '@/session/SessionContext'; - -/** - * Root route element. Mounts SessionProvider once for the entire app so - * every nested route — login, app shell, fallback — shares one session - * state. Kept in its own file so router.tsx exports only the router - * config (fast-refresh friendly). - */ -export function Root() { - return ( - - - - ); -} diff --git a/frontend/src/session/SessionContext.context.ts b/frontend/src/session/SessionContext.context.ts deleted file mode 100644 index 8536bf0..0000000 --- a/frontend/src/session/SessionContext.context.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createContext } from 'react'; -import type { SessionUser } from '@/types/roles'; - -export interface SessionContextValue { - user: SessionUser | null; - signIn: (user: SessionUser) => void; - signOut: () => void; -} - -export const SessionContext = createContext(null); diff --git a/frontend/src/session/SessionContext.tsx b/frontend/src/session/SessionContext.tsx deleted file mode 100644 index 53cc684..0000000 --- a/frontend/src/session/SessionContext.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useCallback, useMemo, useState, type ReactNode } from 'react'; -import type { SessionUser } from '@/types/roles'; -import { clearMockSession, readMockSession, writeMockSession } from '@/mocks/session'; -import { SessionContext, type SessionContextValue } from './SessionContext.context'; - -export function SessionProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(() => readMockSession()); - - const signIn = useCallback((next: SessionUser) => { - writeMockSession(next); - setUser(next); - }, []); - - const signOut = useCallback(() => { - clearMockSession(); - setUser(null); - }, []); - - const value = useMemo( - () => ({ user, signIn, signOut }), - [user, signIn, signOut], - ); - - return {children}; -} diff --git a/frontend/src/session/sessionApi.ts b/frontend/src/session/sessionApi.ts new file mode 100644 index 0000000..d2fdb1c --- /dev/null +++ b/frontend/src/session/sessionApi.ts @@ -0,0 +1,32 @@ +import { ApiClientError, apiFetch } from '@/lib/api'; +import type { LoginRequest, User } from '@/types/api'; + +/** + * Network layer for session-scoped endpoints. + * + * Each function maps 1:1 to a backend endpoint and is the only place that + * knows the path. Components never call `apiFetch` directly for these — + * they go through TanStack Query against these helpers. + */ + +/** + * GET /api/v1/auth/me — returns the current user if a valid session cookie + * is present. Maps the 401 case to `null` rather than re-throwing so the + * caller can treat "unauthenticated" as data, not as an error. + */ +export async function fetchMe(signal?: AbortSignal): Promise { + try { + return await apiFetch('/auth/me', { signal }); + } catch (err) { + if (err instanceof ApiClientError && err.status === 401) return null; + throw err; + } +} + +export async function login(payload: LoginRequest): Promise { + return apiFetch('/auth/login', { method: 'POST', body: payload }); +} + +export async function logout(): Promise { + await apiFetch('/auth/logout', { method: 'POST' }); +} diff --git a/frontend/src/session/useSession.ts b/frontend/src/session/useSession.ts index 79e0f31..9566bf5 100644 --- a/frontend/src/session/useSession.ts +++ b/frontend/src/session/useSession.ts @@ -1,10 +1,52 @@ -import { useContext } from 'react'; -import { SessionContext } from './SessionContext.context'; +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; +import type { User } from '@/types/api'; +import { fetchMe, logout as logoutRequest } from './sessionApi'; -export function useSession() { - const ctx = useContext(SessionContext); - if (!ctx) { - throw new Error('useSession must be used inside '); - } - return ctx; +export const SESSION_QUERY_KEY = ['session'] as const; + +interface UseSessionResult { + user: User | null; + isLoading: boolean; + isError: boolean; + signOut: () => Promise; + isSigningOut: boolean; +} + +/** + * Single source of truth for the current session. + * + * Backed by TanStack Query against /api/v1/auth/me. Components never read + * from sessionStorage or any local state — the cookie is the source, the + * server is the resolver, the query is the cache. + * + * After successful login the LoginPage calls `queryClient.setQueryData` on + * `SESSION_QUERY_KEY` with the returned User, which propagates to every + * consumer instantly. + */ +export function useSession(): UseSessionResult { + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: SESSION_QUERY_KEY, + queryFn: ({ signal }) => fetchMe(signal), + staleTime: 60_000, + retry: false, + }); + + const logoutMutation = useMutation({ + mutationFn: logoutRequest, + onSuccess: () => { + queryClient.setQueryData(SESSION_QUERY_KEY, null); + }, + }); + + return { + user: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + signOut: async () => { + await logoutMutation.mutateAsync(); + }, + isSigningOut: logoutMutation.isPending, + }; } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..a94ec45 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,68 @@ +/** + * Shared API contract types. + * + * Hand-rolled for sprint 1 against the backend's Pydantic schemas. Once the + * backend exposes OpenAPI, this file should be regenerated rather than + * maintained by hand. + * + * The role enum mirrors the existing frontend/src/types/roles.ts so the + * backend payload drops straight into the session state. + */ + +import type { Role } from './roles'; + +export interface User { + id: string; + username: string; + display_name: string; + role: Role; + /** Optional engagement context. Present once the user is scoped to one. */ + engagement_id?: string; + engagement_name?: string; +} + +export interface LoginRequest { + username: string; + password: string; +} + +export type C2Type = 'mythic' | 'home'; +export type EngagementStatus = 'planning' | 'active' | 'reporting' | 'archived'; + +export interface Engagement { + id: string; + name: string; + client_name: string | null; + description: string | null; + status: EngagementStatus; + c2_type: C2Type | null; + start_date: string | null; + end_date: string | null; + created_at: string; +} + +export interface EngagementCreate { + name: string; + client_name?: string; + description?: string; +} + +/** + * Validation error shape returned by the backend on 422. + * Pydantic v2 style: `detail` is an array of field errors. + */ +export interface ApiValidationError { + detail: Array<{ + loc: Array; + msg: string; + type: string; + }>; +} + +/** + * Generic error shape for 4xx that are not validation errors + * (401 invalid credentials, 403 forbidden, 404 not found, …). + */ +export interface ApiError { + detail: string; +} diff --git a/frontend/src/types/roles.ts b/frontend/src/types/roles.ts index bae3fdd..88e8ede 100644 --- a/frontend/src/types/roles.ts +++ b/frontend/src/types/roles.ts @@ -5,14 +5,6 @@ */ export type Role = 'rt_operator' | 'rt_lead' | 'soc_analyst'; -export interface SessionUser { - id: string; - displayName: string; - role: Role; - engagementId: string; - engagementName: string; -} - export const ROLE_LABELS: Record = { rt_operator: 'RT Operator', rt_lead: 'RT Lead', diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 96ea71e..63a510d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import path from 'node:path'; +const API_TARGET = process.env.VITE_DEV_API_TARGET ?? 'http://localhost:5000'; + export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -13,5 +15,12 @@ export default defineConfig({ server: { port: 5173, strictPort: false, + proxy: { + '/api': { + target: API_TARGET, + changeOrigin: true, + secure: false, + }, + }, }, });