diff --git a/frontend/src/components/shell/Sidebar.tsx b/frontend/src/components/shell/Sidebar.tsx index 643ec34..e955228 100644 --- a/frontend/src/components/shell/Sidebar.tsx +++ b/frontend/src/components/shell/Sidebar.tsx @@ -108,7 +108,7 @@ export function Sidebar() {
- {user.display_name} + {user.display_name ?? user.username}
- {user.engagement_name && ( -
- ENG · {user.engagement_name} -
- )} +
+ {user.username} +
); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1cbdd26..5cb04b6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,27 +3,27 @@ * * 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. + * every call (cookie is HttpOnly + Secure, set by the backend). + * - Send JSON on bodied requests and parse JSON responses. + * - Normalize 4xx/5xx into a typed `ApiClientError` so callers can + * branch on `error.status` and `error.body` without re-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). + * - No CSRF token: same-origin in prod (Caddy), same-origin via the + * Vite proxy in dev. SameSite=Lax cookie is enough — no cross-site + * form posts in scope. */ -import type { ApiError, ApiValidationError } from '@/types/api'; +import type { ApiError } from '@/types/api'; const DEFAULT_BASE = '/api/v1'; export class ApiClientError extends Error { status: number; - body: ApiError | ApiValidationError | null; + body: ApiError | null; - constructor(status: number, message: string, body: ApiError | ApiValidationError | null) { + constructor(status: number, message: string, body: ApiError | null) { super(message); this.name = 'ApiClientError'; this.status = status; @@ -48,13 +48,17 @@ async function parseBody(response: Response): Promise { } } -function bodyAsApiError(body: unknown): ApiError | ApiValidationError | null { +/** + * Try to coerce the response body into the `{error, message, details?}` + * envelope documented in api.md. Backends that haven't routed every error + * through that handler yet (raw `flask.abort(...)` HTML output, plain text, + * other shapes) return `null` so callers can fall back to a generic message. + */ +function bodyAsApiError(body: unknown): ApiError | 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; + const obj = body as Record; + if (typeof obj.error === 'string' && typeof obj.message === 'string') { + return obj as unknown as ApiError; } return null; } diff --git a/frontend/src/screens/engagements/EngagementCreateDialog.test.tsx b/frontend/src/screens/engagements/EngagementCreateDialog.test.tsx index 330034f..be6ec18 100644 --- a/frontend/src/screens/engagements/EngagementCreateDialog.test.tsx +++ b/frontend/src/screens/engagements/EngagementCreateDialog.test.tsx @@ -10,23 +10,25 @@ describe('EngagementCreateDialog', () => { fetchMock?.restore(); }); - it('rejects empty name client-side without calling the backend', () => { + it('rejects empty client name client-side without calling the backend', () => { fetchMock = installFetchMock([]); renderWithProviders(); fireEvent.click(screen.getByRole('button', { name: /arm engagement/i })); - expect(screen.getByText(/nom requis/i)).toBeInTheDocument(); + expect(screen.getByText(/client requis/i)).toBeInTheDocument(); expect(fetchMock.calls).toHaveLength(0); }); - it('maps 422 Pydantic errors to per-field messages', async () => { + it('maps 422 backend errors (details[].loc) to per-field messages', async () => { fetchMock = installFetchMock([ { status: 422, body: { - detail: [ + error: 'validation_error', + message: 'request failed', + details: [ { - loc: ['body', 'name'], - msg: 'String should have at least 3 characters', + loc: ['client_name'], + msg: 'String should have at least 1 character', type: 'string_too_short', }, ], @@ -35,11 +37,11 @@ describe('EngagementCreateDialog', () => { ]); renderWithProviders(); - fireEvent.change(screen.getByLabelText(/engagement name/i), { target: { value: 'AB' } }); + fireEvent.change(screen.getByLabelText(/client name/i), { target: { value: 'x' } }); fireEvent.click(screen.getByRole('button', { name: /arm engagement/i })); await waitFor(() => { - expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument(); + expect(screen.getByText(/at least 1 character/i)).toBeInTheDocument(); }); }); @@ -50,20 +52,18 @@ describe('EngagementCreateDialog', () => { status: 201, body: { id: 'eng_new', - name: 'OPERATION ZETA', - client_name: null, + client_name: 'OPERATION ZETA', description: null, - status: 'planning', - c2_type: null, + status: 'draft', + c2_type: 'mythic', start_date: null, end_date: null, - created_at: '2026-05-23T08:00:00Z', }, }, ]); renderWithProviders(); - fireEvent.change(screen.getByLabelText(/engagement name/i), { + fireEvent.change(screen.getByLabelText(/client name/i), { target: { value: 'OPERATION ZETA' }, }); fireEvent.click(screen.getByRole('button', { name: /arm engagement/i })); @@ -71,7 +71,23 @@ describe('EngagementCreateDialog', () => { await waitFor(() => { expect(onClose).toHaveBeenCalledTimes(1); }); - expect(fetchMock.calls[0]?.url).toBe('/api/v1/engagements'); + expect(fetchMock.calls[0]?.url).toBe('/api/v1/engagements/'); expect(fetchMock.calls[0]?.init?.method).toBe('POST'); }); + + it('surfaces a generic top-of-form error on 401', async () => { + fetchMock = installFetchMock([ + { + status: 401, + body: { error: 'not_authenticated', message: 'no active session' }, + }, + ]); + renderWithProviders(); + fireEvent.change(screen.getByLabelText(/client name/i), { + target: { value: 'Anyone' }, + }); + fireEvent.click(screen.getByRole('button', { name: /arm engagement/i })); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/session expirée/i); + }); }); diff --git a/frontend/src/screens/engagements/EngagementCreateDialog.tsx b/frontend/src/screens/engagements/EngagementCreateDialog.tsx index 3a0581b..8497bb4 100644 --- a/frontend/src/screens/engagements/EngagementCreateDialog.tsx +++ b/frontend/src/screens/engagements/EngagementCreateDialog.tsx @@ -12,14 +12,20 @@ import { import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/Button'; import { ApiClientError } from '@/lib/api'; -import type { ApiValidationError } from '@/types/api'; +import type { ApiError, C2Type, PydanticErrorItem } from '@/types/api'; import { createEngagement, ENGAGEMENTS_QUERY_KEY } from './engagementsApi'; interface EngagementCreateDialogProps { onClose: () => void; } -type FieldErrors = Partial>; +type FieldKey = 'client_name' | 'description' | 'c2_type'; +type FieldErrors = Partial>; + +const C2_OPTIONS: ReadonlyArray<{ value: C2Type; label: string }> = [ + { value: 'mythic', label: 'Mythic' }, + { value: 'home', label: 'Home (RT-internal)' }, +]; /** * "Arm engagement" dialog. @@ -35,19 +41,21 @@ type FieldErrors = Partial(null); const firstFieldRef = useRef(null); - const [name, setName] = useState(''); const [clientName, setClientName] = useState(''); const [description, setDescription] = useState(''); + const [c2Type, setC2Type] = useState('mythic'); const [fieldErrors, setFieldErrors] = useState({}); const [topError, setTopError] = useState(null); @@ -60,14 +68,24 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps) onClose(); }, onError: (err) => { - if (err instanceof ApiClientError && err.status === 422 && err.body) { - setFieldErrors(mapValidationErrors(err.body as ApiValidationError)); - setTopError(null); - return; - } - if (err instanceof ApiClientError && err.status === 401) { - setTopError('Session expirée. Reconnectez-vous.'); - return; + if (err instanceof ApiClientError) { + if (err.status === 422 && err.body?.details) { + setFieldErrors(mapValidationErrors(err.body.details)); + setTopError(null); + return; + } + if (err.status === 401) { + setTopError('Session expirée. Reconnectez-vous.'); + return; + } + if (err.status === 403) { + setTopError('Action interdite pour ce rôle.'); + return; + } + if (err.body?.message) { + setTopError(genericMessage(err.body)); + return; + } } setTopError('Création impossible. Réessayez dans un instant.'); }, @@ -75,7 +93,6 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps) const isPending = mutation.isPending; - // Focus the first field on open. ESC closes unless a request is in flight. useEffect(() => { firstFieldRef.current?.focus(); const onKeyDown = (e: globalThis.KeyboardEvent) => { @@ -88,11 +105,10 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps) return () => window.removeEventListener('keydown', onKeyDown); }, [isPending, onClose]); - // Rudimentary focus trap: cycle Tab/Shift+Tab within the dialog. const handleSurfaceKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Tab' || !surfaceRef.current) return; const focusables = surfaceRef.current.querySelectorAll( - 'input, textarea, button, [tabindex]:not([tabindex="-1"])', + 'input, textarea, select, button, [tabindex]:not([tabindex="-1"])', ); if (focusables.length === 0) return; const first = focusables[0]; @@ -112,14 +128,14 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps) e.preventDefault(); setFieldErrors({}); setTopError(null); - if (!name.trim()) { - setFieldErrors({ name: 'Nom requis.' }); + if (!clientName.trim()) { + setFieldErrors({ client_name: 'Client requis.' }); return; } mutation.mutate({ - name: name.trim(), - client_name: clientName.trim() || undefined, - description: description.trim() || undefined, + client_name: clientName.trim(), + description: description.trim() || null, + c2_type: c2Type, }); }; @@ -140,7 +156,6 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps) backgroundColor: 'oklch(5.8% 0.012 247 / 0.78)', }} > - {/* Faint scanline texture overlay — reads as "instrument feed paused" */}