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" */}
- {/* Masthead */}
- {/* Amber hairline accent */}
-
+
+
setC2Type(v)}
+ error={fieldErrors.c2_type}
+ disabled={isPending}
+ options={C2_OPTIONS}
+ />
+
- {/* keyframes inlined so the component is self-contained */}