- {user.displayName}
+ {user.display_name}
- Sign out
+ {isSigningOut ? '…' : 'Sign out'}
-
- ENG · {user.engagementName}
-
+ {user.engagement_name && (
+
+ ENG · {user.engagement_name}
+
+ )}
);
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,
+ },
+ },
},
});