feat(frontend): wire session to real /auth/me + drop sessionStorage mock

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).
This commit is contained in:
ux-frontend
2026-05-23 04:26:28 +02:00
parent a8c5400f97
commit 6aa0078fd3
14 changed files with 340 additions and 171 deletions

View File

@@ -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 (
<div className="h-screen flex flex-col" style={{ backgroundColor: 'var(--surface-0)' }}>
<StatusRail clock={clock} sessionState="resolving" />
<div className="flex-1 flex items-center justify-center label-system text-fg-faint">
resolving session
</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}

View File

@@ -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 (
<aside
className="flex flex-col h-full w-56 border-r"
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
>
<div
className="px-3 py-4 border-b"
style={{ borderColor: 'var(--line-default)' }}
>
<div className="px-3 py-4 border-b" style={{ borderColor: 'var(--line-default)' }}>
<Logo build="0.1.0" />
</div>
@@ -93,7 +108,7 @@ export function Sidebar() {
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="truncate text-fg-default" style={{ fontSize: '12px' }}>
{user.displayName}
{user.display_name}
</div>
<div
className="label-system mt-0.5"
@@ -104,16 +119,22 @@ export function Sidebar() {
</div>
<button
type="button"
onClick={signOut}
className="label-system text-fg-faint hover:text-fg-default px-2 py-1"
onClick={handleSignOut}
disabled={isSigningOut}
className="label-system text-fg-faint hover:text-fg-default px-2 py-1 disabled:opacity-50"
style={{ border: '1px solid var(--line-default)', borderRadius: 'var(--radius-sm)' }}
>
Sign out
{isSigningOut ? '…' : 'Sign out'}
</button>
</div>
<div className="font-mono tabular text-fg-faint truncate" style={{ fontSize: '10px' }}>
ENG · {user.engagementName}
</div>
{user.engagement_name && (
<div
className="font-mono tabular text-fg-faint truncate"
style={{ fontSize: '10px' }}
>
ENG · {user.engagement_name}
</div>
)}
</div>
</aside>
);

View File

@@ -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<LinkState, string> = {
@@ -37,6 +39,7 @@ export function StatusRail({
activeRunId,
activeRunState = 'idle',
clock,
sessionState,
}: StatusRailProps) {
return (
<div
@@ -76,6 +79,13 @@ export function StatusRail({
<span className="flex-1" />
{sessionState === 'resolving' && (
<span className="inline-flex items-center gap-2">
<span className="status-dot text-fg-faint pulsing" />
<span className="label-system tabular text-fg-faint">SESSION · RESOLVING</span>
</span>
)}
<span className="font-mono tabular text-fg-muted" style={{ fontSize: '11px' }}>
{clock}
</span>

93
frontend/src/lib/api.ts Normal file
View File

@@ -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<unknown> {
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<T>(path: string, opts: ApiRequestOptions = {}): Promise<T> {
const url = path.startsWith('http') ? path : `${DEFAULT_BASE}${path}`;
const method = opts.method ?? 'GET';
const headers: Record<string, string> = {
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;
}

View File

@@ -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<string, SessionUser> = {
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);
}

View File

@@ -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: <Navigate to="/engagements" replace /> },
{ path: '/login', element: <LoginPage /> },
{
element: <Root />,
element: <AppShell />,
children: [
{ index: true, element: <Navigate to="/login" replace /> },
{ path: 'login', element: <LoginPage /> },
{
element: <AppShell />,
children: [
{ path: 'engagements', element: <EngagementsPage /> },
{ path: 'library', element: <TtpLibraryPage /> },
{ path: 'scenarios', element: <ScenarioComposerPage /> },
{ path: 'runs', element: <LiveCockpitPage /> },
{ path: 'reports', element: <ReportPage /> },
{ path: 'audit', element: <AuditPage /> },
],
},
{ path: '*', element: <Navigate to="/login" replace /> },
{ path: '/engagements', element: <EngagementsPage /> },
{ path: '/library', element: <TtpLibraryPage /> },
{ path: '/scenarios', element: <ScenarioComposerPage /> },
{ path: '/runs', element: <LiveCockpitPage /> },
{ path: '/reports', element: <ReportPage /> },
{ path: '/audit', element: <AuditPage /> },
],
},
{ path: '*', element: <Navigate to="/engagements" replace /> },
]);

View File

@@ -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 (
<SessionProvider>
<Outlet />
</SessionProvider>
);
}

View File

@@ -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<SessionContextValue | null>(null);

View File

@@ -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<SessionUser | null>(() => readMockSession());
const signIn = useCallback((next: SessionUser) => {
writeMockSession(next);
setUser(next);
}, []);
const signOut = useCallback(() => {
clearMockSession();
setUser(null);
}, []);
const value = useMemo<SessionContextValue>(
() => ({ user, signIn, signOut }),
[user, signIn, signOut],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}

View File

@@ -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<User | null> {
try {
return await apiFetch<User>('/auth/me', { signal });
} catch (err) {
if (err instanceof ApiClientError && err.status === 401) return null;
throw err;
}
}
export async function login(payload: LoginRequest): Promise<User> {
return apiFetch<User>('/auth/login', { method: 'POST', body: payload });
}
export async function logout(): Promise<void> {
await apiFetch<null>('/auth/logout', { method: 'POST' });
}

View File

@@ -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 <SessionProvider>');
}
return ctx;
export const SESSION_QUERY_KEY = ['session'] as const;
interface UseSessionResult {
user: User | null;
isLoading: boolean;
isError: boolean;
signOut: () => Promise<void>;
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<User | null>({
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,
};
}

68
frontend/src/types/api.ts Normal file
View File

@@ -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<string | number>;
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;
}

View File

@@ -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<Role, string> = {
rt_operator: 'RT Operator',
rt_lead: 'RT Lead',