fix(frontend): align types + UI to backend contract (docs/api.md @ dd5c508)
Backend pushed the authoritative contract in docs/api.md and tightened the error envelope via a global HTTPException handler (dd5c508). This commit folds the frontend onto that contract — every drift flagged by the code-reviewer MAJOR is closed. Types (src/types/api.ts) - User: `id` → `user_id`; `display_name` is `string | null`; add `permissions: string[]` and `groups: string[]`; drop `engagement_id` and `engagement_name` (not part of CurrentUser). - Engagement: drop `name`, `client_name` is non-null `string`; status enum aligned to `draft | active | closed | archived`; `c2_type` is non-null `C2Type`; drop `created_at` (not in EngagementRead v1). - EngagementCreate body: `client_name` required, plus optional `description`, `c2_type`, `start_date`, `end_date`. No `name`. - Replace ApiError + ApiValidationError with a single uniform envelope: `{ error: string, message: string, details?: PydanticErrorItem[] }`, matching the new HTTPException handler. PydanticErrorItem is the per-field shape on 422 (`{ loc, msg, type }`). Fetch client (src/lib/api.ts) - `bodyAsApiError` now recognizes the uniform envelope by shape (error+message strings). Anything else returns null so callers fall back to a generic message — keeps us robust if the backend ever emits a non-JSON response. Engagements API (src/screens/engagements/engagementsApi.ts) - Drop the `{ items: [] }` envelope tolerance — backend serves a bare `Engagement[]`. - Hit `/engagements/` with trailing slash explicitly; backend now sets `strict_slashes=False` but staying consistent with docs/api.md. EngagementsPage - Status tone map switched to the new enum (`draft → pending`, `closed → soc`). - Drop "Name" column. `client_name` is the primary identifier; the description column replaces the now-meaningless name field. - `c2_type` is non-null, so no nullable rendering path. EngagementCreateDialog - Drop `name` field. New required field is `client_name`; add a `c2_type` select (default `mythic`); brief textarea stays optional. - `mapValidationErrors` now reads `body.details[*].loc` (last segment matches the form field) — direct alignment with the backend's new shape afterdd5c508. - 401 still surfaces "Session expirée"; 403 gains a dedicated message; other errors fall back to a capitalized backend `message` when available, then to a generic French string. Sidebar - Display fallback: `user.display_name ?? user.username` (now nullable). - Drop the `ENG · {engagement_name}` line; show `user.username` (the email) as the secondary identity instead. LoginPage - Field label "Username" → "Email or username" so RT users with email accounts find the field semantically obvious (per docs/api.md note on the username/email mapping). Tests (Vitest, 14 cases, all green) - Refreshed fixtures to the new shapes (no more `name`, no `created_at`, status `draft`, envelopes carry `error`+`message`). - New 422 test exercises the `details[*].loc` mapping shape. - New 401 test on the dialog covers the top-of-form alert path.
This commit is contained in:
@@ -108,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.display_name}
|
||||
{user.display_name ?? user.username}
|
||||
</div>
|
||||
<div
|
||||
className="label-system mt-0.5"
|
||||
@@ -127,14 +127,9 @@ export function Sidebar() {
|
||||
{isSigningOut ? '…' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
{user.engagement_name && (
|
||||
<div
|
||||
className="font-mono tabular text-fg-faint truncate"
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
ENG · {user.engagement_name}
|
||||
<div className="font-mono tabular text-fg-faint truncate" style={{ fontSize: '10px' }}>
|
||||
{user.username}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -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<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
if (typeof obj.error === 'string' && typeof obj.message === 'string') {
|
||||
return obj as unknown as ApiError;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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(<EngagementCreateDialog onClose={vi.fn()} />);
|
||||
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(<EngagementCreateDialog onClose={vi.fn()} />);
|
||||
|
||||
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(<EngagementCreateDialog onClose={onClose} />);
|
||||
|
||||
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(<EngagementCreateDialog onClose={vi.fn()} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<'name' | 'client_name' | 'description', string>>;
|
||||
type FieldKey = 'client_name' | 'description' | 'c2_type';
|
||||
type FieldErrors = Partial<Record<FieldKey, string>>;
|
||||
|
||||
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<Record<'name' | 'client_name' | 'description', string
|
||||
* - Submit: primary amber Button — the same accent used for RT-only
|
||||
* actions throughout the app, so the action lineage is obvious.
|
||||
*
|
||||
* Behavior:
|
||||
* - Esc and outside click close (unless the mutation is in flight).
|
||||
* - Backend 422 Pydantic errors are mapped to per-field inline messages.
|
||||
* - Other 4xx/5xx surface as a generic top-of-form alert.
|
||||
* Contract (api.md):
|
||||
* POST /api/v1/engagements/ with { client_name (required), description?,
|
||||
* c2_type? (default mythic), start_date?, end_date? }. Backend returns
|
||||
* the created Engagement on 201, the uniform { error, message, details? }
|
||||
* envelope on 422 / 4xx. Per-field details on 422 are matched via the
|
||||
* last segment of `loc`.
|
||||
*/
|
||||
export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps) {
|
||||
const titleId = useId();
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
const firstFieldRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [clientName, setClientName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [c2Type, setC2Type] = useState<C2Type>('mythic');
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [topError, setTopError] = useState<string | null>(null);
|
||||
|
||||
@@ -60,22 +68,31 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps)
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiClientError && err.status === 422 && err.body) {
|
||||
setFieldErrors(mapValidationErrors(err.body as ApiValidationError));
|
||||
if (err instanceof ApiClientError) {
|
||||
if (err.status === 422 && err.body?.details) {
|
||||
setFieldErrors(mapValidationErrors(err.body.details));
|
||||
setTopError(null);
|
||||
return;
|
||||
}
|
||||
if (err instanceof ApiClientError && err.status === 401) {
|
||||
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.');
|
||||
},
|
||||
});
|
||||
|
||||
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<HTMLDivElement>) => {
|
||||
if (e.key !== 'Tab' || !surfaceRef.current) return;
|
||||
const focusables = surfaceRef.current.querySelectorAll<HTMLElement>(
|
||||
'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" */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
@@ -172,7 +187,6 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps)
|
||||
animation: 'dialog-in 140ms var(--ease-mech) both',
|
||||
}}
|
||||
>
|
||||
{/* Masthead */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 px-5 py-3 border-b"
|
||||
style={{ borderColor: 'var(--line-default)' }}
|
||||
@@ -199,7 +213,6 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Amber hairline accent */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
@@ -227,24 +240,25 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps)
|
||||
)}
|
||||
|
||||
<ConsoleField
|
||||
label="Engagement name"
|
||||
label="Client name"
|
||||
required
|
||||
value={name}
|
||||
onChange={setName}
|
||||
error={fieldErrors.name}
|
||||
disabled={isPending}
|
||||
placeholder="OPERATION RUSTED ANCHOR"
|
||||
ref={firstFieldRef}
|
||||
mono
|
||||
/>
|
||||
<ConsoleField
|
||||
label="Client"
|
||||
value={clientName}
|
||||
onChange={setClientName}
|
||||
error={fieldErrors.client_name}
|
||||
disabled={isPending}
|
||||
placeholder="Démo Client X"
|
||||
ref={firstFieldRef}
|
||||
/>
|
||||
|
||||
<ConsoleSelect
|
||||
label="C2 backend"
|
||||
value={c2Type}
|
||||
onChange={(v) => setC2Type(v)}
|
||||
error={fieldErrors.c2_type}
|
||||
disabled={isPending}
|
||||
options={C2_OPTIONS}
|
||||
/>
|
||||
|
||||
<ConsoleTextarea
|
||||
label="Brief"
|
||||
value={description}
|
||||
@@ -279,7 +293,6 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps)
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* keyframes inlined so the component is self-contained */}
|
||||
<style>{`
|
||||
@keyframes dialog-in {
|
||||
0% { opacity: 0; transform: translate(-50%, calc(-50% + 4px)) translateY(8px); }
|
||||
@@ -290,17 +303,24 @@ export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps)
|
||||
);
|
||||
}
|
||||
|
||||
function mapValidationErrors(body: ApiValidationError): FieldErrors {
|
||||
function mapValidationErrors(details: PydanticErrorItem[]): FieldErrors {
|
||||
const out: FieldErrors = {};
|
||||
for (const item of body.detail) {
|
||||
for (const item of details) {
|
||||
const last = item.loc[item.loc.length - 1];
|
||||
if (last === 'name' || last === 'client_name' || last === 'description') {
|
||||
if (last === 'client_name' || last === 'description' || last === 'c2_type') {
|
||||
out[last] = item.msg;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function genericMessage(body: ApiError): string {
|
||||
// Capitalize first letter for display while keeping the message verbatim.
|
||||
const msg = body.message.trim();
|
||||
if (!msg) return 'Création impossible.';
|
||||
return msg.charAt(0).toUpperCase() + msg.slice(1);
|
||||
}
|
||||
|
||||
function generateDraftId(): string {
|
||||
const t = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
@@ -375,11 +395,65 @@ function ConsoleField({
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p
|
||||
id={`${id}-err`}
|
||||
className="label-system"
|
||||
style={{ color: 'var(--state-failed)' }}
|
||||
<p id={`${id}-err`} className="label-system" style={{ color: 'var(--state-failed)' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConsoleSelectProps<T extends string> {
|
||||
label: string;
|
||||
value: T;
|
||||
onChange: (next: T) => void;
|
||||
options: ReadonlyArray<{ value: T; label: string }>;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ConsoleSelect<T extends string>({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
error,
|
||||
}: ConsoleSelectProps<T>): ReactNode {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={id} className="block label-system">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as T)}
|
||||
disabled={disabled}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={error ? `${id}-err` : undefined}
|
||||
className="font-sans"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 30,
|
||||
padding: '0 8px',
|
||||
backgroundColor: 'var(--surface-inset)',
|
||||
color: 'var(--fg-default)',
|
||||
border: `1px solid ${error ? 'var(--state-failed)' : 'var(--line-strong)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12.5,
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p id={`${id}-err`} className="label-system" style={{ color: 'var(--state-failed)' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
@@ -434,11 +508,7 @@ function ConsoleTextarea({
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p
|
||||
id={`${id}-err`}
|
||||
className="label-system"
|
||||
style={{ color: 'var(--state-failed)' }}
|
||||
>
|
||||
<p id={`${id}-err`} className="label-system" style={{ color: 'var(--state-failed)' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,6 @@ describe('EngagementsPage', () => {
|
||||
|
||||
it('shows the loading row while the engagements query is pending', () => {
|
||||
fetchMock = installFetchMock([]); // never resolve in this test
|
||||
// Replace with a long-lived promise so the query sits in pending state.
|
||||
const pending: typeof fetch = () => new Promise(() => null);
|
||||
globalThis.fetch = pending;
|
||||
renderWithProviders(<EngagementsPage />);
|
||||
@@ -26,7 +25,9 @@ describe('EngagementsPage', () => {
|
||||
});
|
||||
|
||||
it('renders the error state on 500', async () => {
|
||||
fetchMock = installFetchMock([{ status: 500, body: { detail: 'internal_error' } }]);
|
||||
fetchMock = installFetchMock([
|
||||
{ status: 500, body: { error: 'internal_error', message: 'boom' } },
|
||||
]);
|
||||
renderWithProviders(<EngagementsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/fetch failed/i)).toBeInTheDocument();
|
||||
@@ -41,21 +42,19 @@ describe('EngagementsPage', () => {
|
||||
body: [
|
||||
{
|
||||
id: 'eng_1',
|
||||
name: 'OPERATION ALPHA',
|
||||
client_name: 'Acme',
|
||||
description: null,
|
||||
status: 'active',
|
||||
c2_type: 'mythic',
|
||||
start_date: '2026-05-20',
|
||||
end_date: '2026-05-30',
|
||||
created_at: '2026-05-20T10:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
renderWithProviders(<EngagementsPage />);
|
||||
await screen.findByText('OPERATION ALPHA');
|
||||
expect(screen.getByText('Acme')).toBeInTheDocument();
|
||||
await screen.findByText('Acme');
|
||||
expect(screen.getByText('MYTHIC')).toBeInTheDocument();
|
||||
expect(screen.getByText(/active/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,10 @@ import { ENGAGEMENTS_QUERY_KEY, fetchEngagements } from './engagementsApi';
|
||||
import { EngagementCreateDialog } from './EngagementCreateDialog';
|
||||
|
||||
const STATUS_TONE: Record<EngagementStatus, 'running' | 'soc' | 'success' | 'pending'> = {
|
||||
draft: 'pending',
|
||||
active: 'running',
|
||||
reporting: 'soc',
|
||||
closed: 'soc',
|
||||
archived: 'pending',
|
||||
planning: 'success',
|
||||
};
|
||||
|
||||
export function EngagementsPage() {
|
||||
@@ -80,11 +80,11 @@ function EngagementsTable({ engagements }: { engagements: Engagement[] }) {
|
||||
<table className="w-full" style={{ fontSize: 12.5 }}>
|
||||
<thead>
|
||||
<tr className="text-fg-subtle">
|
||||
<Th>Name</Th>
|
||||
<Th>Client</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>C2</Th>
|
||||
<Th>Window</Th>
|
||||
<Th>Description</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -102,13 +102,12 @@ function EngagementsTable({ engagements }: { engagements: Engagement[] }) {
|
||||
className="font-display text-fg-default"
|
||||
style={{ letterSpacing: '0.06em' }}
|
||||
>
|
||||
{eng.name}
|
||||
{eng.client_name}
|
||||
</div>
|
||||
<div className="font-mono text-fg-faint" style={{ fontSize: '10.5px' }}>
|
||||
{eng.id}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{eng.client_name ?? <span className="text-fg-faint">—</span>}</Td>
|
||||
<Td>
|
||||
<Pill tone={STATUS_TONE[eng.status]}>
|
||||
<span className="status-dot" style={{ color: 'currentColor' }} />
|
||||
@@ -116,11 +115,7 @@ function EngagementsTable({ engagements }: { engagements: Engagement[] }) {
|
||||
</Pill>
|
||||
</Td>
|
||||
<Td>
|
||||
{eng.c2_type ? (
|
||||
<span className="font-mono tabular">{eng.c2_type.toUpperCase()}</span>
|
||||
) : (
|
||||
<span className="text-fg-faint">—</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{eng.start_date || eng.end_date ? (
|
||||
@@ -131,6 +126,18 @@ function EngagementsTable({ engagements }: { engagements: Engagement[] }) {
|
||||
<span className="text-fg-faint">—</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{eng.description ? (
|
||||
<span
|
||||
className="text-fg-muted"
|
||||
style={{ display: 'inline-block', maxWidth: 280 }}
|
||||
>
|
||||
{eng.description}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-fg-faint">—</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Link to="/runs">
|
||||
<Button variant="ghost" size="sm">
|
||||
|
||||
@@ -4,18 +4,16 @@ import type { Engagement, EngagementCreate } from '@/types/api';
|
||||
export const ENGAGEMENTS_QUERY_KEY = ['engagements'] as const;
|
||||
|
||||
/**
|
||||
* GET /api/v1/engagements
|
||||
*
|
||||
* Backend may return either a bare array or a `{ items: [...] }` envelope.
|
||||
* Sprint 1 unwraps both shapes so the UI doesn't have to care which one
|
||||
* landed — the OpenAPI source of truth is on backend's roadmap.
|
||||
* Trailing slash matches the backend's blueprint URL prefix exactly. Hitting
|
||||
* `/engagements` (no slash) triggers a 308 redirect which some browsers drop
|
||||
* the session cookie on — we go direct.
|
||||
*/
|
||||
const ENGAGEMENTS_PATH = '/engagements/';
|
||||
|
||||
export async function fetchEngagements(signal?: AbortSignal): Promise<Engagement[]> {
|
||||
const data = await apiFetch<Engagement[] | { items: Engagement[] }>('/engagements', { signal });
|
||||
if (Array.isArray(data)) return data;
|
||||
return data.items;
|
||||
return apiFetch<Engagement[]>(ENGAGEMENTS_PATH, { signal });
|
||||
}
|
||||
|
||||
export async function createEngagement(payload: EngagementCreate): Promise<Engagement> {
|
||||
return apiFetch<Engagement>('/engagements', { method: 'POST', body: payload });
|
||||
return apiFetch<Engagement>(ENGAGEMENTS_PATH, { method: 'POST', body: payload });
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function LoginPage() {
|
||||
e.preventDefault();
|
||||
if (mode !== 'rt') return;
|
||||
if (!username || !password) {
|
||||
setErrorMsg('Nom d’utilisateur et mot de passe requis.');
|
||||
setErrorMsg('Identifiant et mot de passe requis.');
|
||||
return;
|
||||
}
|
||||
loginMutation.mutate({ username, password });
|
||||
@@ -146,7 +146,7 @@ export function LoginPage() {
|
||||
{mode === 'rt' ? (
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label="Username"
|
||||
label="Email or username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
/**
|
||||
* 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.
|
||||
* Hand-rolled against the backend Pydantic schemas as documented in
|
||||
* `docs/api.md` (sprint 1, feature/backend-auth-wiring @ dd321c2). 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.
|
||||
* Wire format is snake_case; the frontend keeps the same casing so there
|
||||
* is no camelCase adapter layer to drift.
|
||||
*/
|
||||
|
||||
import type { Role } from './roles';
|
||||
|
||||
/**
|
||||
* `CurrentUser` payload returned by /auth/login and /auth/me.
|
||||
*
|
||||
* `username` carries the email server-side (kept as "username" in the
|
||||
* HTTP contract so future identity sources can route through the same
|
||||
* endpoint). `permissions` is the canonical RBAC resolution from the
|
||||
* backend — the source of truth for action gating.
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
display_name: string | null;
|
||||
role: Role;
|
||||
/** Optional engagement context. Present once the user is scoped to one. */
|
||||
engagement_id?: string;
|
||||
engagement_name?: string;
|
||||
permissions: string[];
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
@@ -27,42 +35,50 @@ export interface LoginRequest {
|
||||
}
|
||||
|
||||
export type C2Type = 'mythic' | 'home';
|
||||
export type EngagementStatus = 'planning' | 'active' | 'reporting' | 'archived';
|
||||
export type EngagementStatus = 'draft' | 'active' | 'closed' | 'archived';
|
||||
|
||||
/**
|
||||
* Engagement read shape (list element and detail).
|
||||
*
|
||||
* The backend does not expose a separate `name` column — the
|
||||
* `client_name` is the primary identifier. The frontend treats
|
||||
* `client_name` as both the display label and the form's required field.
|
||||
*/
|
||||
export interface Engagement {
|
||||
id: string;
|
||||
name: string;
|
||||
client_name: string | null;
|
||||
client_name: string;
|
||||
description: string | null;
|
||||
status: EngagementStatus;
|
||||
c2_type: C2Type | null;
|
||||
c2_type: C2Type;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EngagementCreate {
|
||||
name: string;
|
||||
client_name?: string;
|
||||
description?: string;
|
||||
client_name: string;
|
||||
description?: string | null;
|
||||
c2_type?: C2Type;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error shape returned by the backend on 422.
|
||||
* Pydantic v2 style: `detail` is an array of field errors.
|
||||
* Uniform error envelope (docs/api.md §Conventions).
|
||||
*
|
||||
* `error` is a stable snake_case code, `message` is human-readable but
|
||||
* not localized. `details` carries Pydantic per-field errors on 422
|
||||
* once the backend's global HTTPException handler is in place (currently
|
||||
* Flask emits HTML on raw `abort(422, ...)` — pending backend ack on the
|
||||
* outstanding ping; the field is optional to absorb either shape).
|
||||
*/
|
||||
export interface ApiValidationError {
|
||||
detail: Array<{
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
message: string;
|
||||
details?: PydanticErrorItem[];
|
||||
}
|
||||
|
||||
export interface PydanticErrorItem {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user