Files
mimic-big/frontend/src/screens/engagements/EngagementCreateDialog.tsx
ux-frontend 140a34b81e
Some checks failed
ci / backend (lint + typecheck + unit tests) (push) Failing after 0s
ci / frontend (lint + typecheck + build + unit tests) (push) Failing after 0s
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 after dd5c508.
- 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.
2026-05-23 11:14:32 +02:00

518 lines
15 KiB
TypeScript

import {
useEffect,
useId,
useMemo,
useRef,
useState,
type FormEvent,
type KeyboardEvent,
type ReactNode,
type Ref,
} from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/Button';
import { ApiClientError } from '@/lib/api';
import type { ApiError, C2Type, PydanticErrorItem } from '@/types/api';
import { createEngagement, ENGAGEMENTS_QUERY_KEY } from './engagementsApi';
interface EngagementCreateDialogProps {
onClose: () => void;
}
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.
*
* Visual grammar:
* - Backdrop: graphite dim + faint scanline texture, no blur. Reads as
* "the cockpit is paused while you issue a command", not a sleek
* SaaS overlay.
* - Surface: --surface-3 (one level above panels), corner-mark utility
* at the four corners, hairline divider beneath the masthead.
* - Inputs: label-system uppercase + underline that lights amber on
* focus. No rounded boxes; the form should read as a console.
* - Submit: primary amber Button — the same accent used for RT-only
* actions throughout the app, so the action lineage is obvious.
*
* 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 [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);
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createEngagement,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ENGAGEMENTS_QUERY_KEY });
onClose();
},
onError: (err) => {
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.');
},
});
const isPending = mutation.isPending;
useEffect(() => {
firstFieldRef.current?.focus();
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape' && !isPending) {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isPending, onClose]);
const handleSurfaceKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Tab' || !surfaceRef.current) return;
const focusables = surfaceRef.current.querySelectorAll<HTMLElement>(
'input, textarea, select, button, [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (!first || !last) return;
const active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setFieldErrors({});
setTopError(null);
if (!clientName.trim()) {
setFieldErrors({ client_name: 'Client requis.' });
return;
}
mutation.mutate({
client_name: clientName.trim(),
description: description.trim() || null,
c2_type: c2Type,
});
};
const draftId = useMemo(() => generateDraftId(), []);
return (
<div
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget && !isPending) onClose();
}}
style={{
position: 'fixed',
inset: 0,
zIndex: 50,
background:
'radial-gradient(circle at 50% 35%, oklch(7.4% 0.012 247 / 0.55), oklch(5.8% 0.012 247 / 0.82) 60%)',
backgroundColor: 'oklch(5.8% 0.012 247 / 0.78)',
}}
>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'repeating-linear-gradient(0deg, transparent 0, transparent 2px, oklch(100% 0 0 / 0.012) 2px, oklch(100% 0 0 / 0.012) 3px)',
pointerEvents: 'none',
}}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
ref={surfaceRef}
onKeyDown={handleSurfaceKeyDown}
className="corner-mark"
style={{
position: 'absolute',
top: '14%',
left: '50%',
transform: 'translateX(-50%)',
width: 'min(520px, calc(100vw - 32px))',
backgroundColor: 'var(--surface-3)',
border: '1px solid var(--line-strong)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-pop)',
animation: 'dialog-in 140ms var(--ease-mech) both',
}}
>
<div
className="flex items-center justify-between gap-3 px-5 py-3 border-b"
style={{ borderColor: 'var(--line-default)' }}
>
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="status-dot pulsing"
style={{ color: 'var(--accent-rt)' }}
/>
<h2
id={titleId}
className="label-system"
style={{ color: 'var(--accent-rt)', letterSpacing: '0.18em' }}
>
ARM · NEW ENGAGEMENT
</h2>
</div>
<span
className="font-mono tabular text-fg-faint"
style={{ fontSize: '10.5px' }}
>
{draftId}
</span>
</div>
<div
aria-hidden="true"
style={{
height: 1,
background:
'linear-gradient(90deg, transparent 0%, var(--accent-rt) 50%, transparent 100%)',
opacity: 0.55,
}}
/>
<form onSubmit={handleSubmit} className="px-5 py-4 space-y-4" noValidate>
{topError && (
<div
role="alert"
className="label-system"
style={{
color: 'var(--state-failed)',
border: '1px solid var(--state-failed)',
padding: '6px 10px',
borderRadius: 'var(--radius-sm)',
}}
>
{topError}
</div>
)}
<ConsoleField
label="Client name"
required
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}
onChange={setDescription}
error={fieldErrors.description}
disabled={isPending}
placeholder="Scope notes, ROE pointers, post-mission expectations."
/>
<div
className="flex items-center justify-between gap-3 pt-3 border-t"
style={{ borderColor: 'var(--line-default)' }}
>
<span className="label-system text-fg-faint">
{isPending ? '// transmitting …' : '// awaiting confirmation'}
</span>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClose}
disabled={isPending}
>
Cancel
</Button>
<Button type="submit" variant="primary" size="sm" disabled={isPending}>
{isPending ? 'Arming …' : 'Arm engagement →'}
</Button>
</div>
</div>
</form>
</div>
<style>{`
@keyframes dialog-in {
0% { opacity: 0; transform: translate(-50%, calc(-50% + 4px)) translateY(8px); }
100% { opacity: 1; transform: translateX(-50%) translateY(0); }
}
`}</style>
</div>
);
}
function mapValidationErrors(details: PydanticErrorItem[]): FieldErrors {
const out: FieldErrors = {};
for (const item of details) {
const last = item.loc[item.loc.length - 1];
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');
return `DRAFT-${t.getUTCFullYear().toString().slice(-2)}${pad(t.getUTCMonth() + 1)}${pad(t.getUTCDate())}-${pad(t.getUTCHours())}${pad(t.getUTCMinutes())}`;
}
interface ConsoleFieldProps {
label: string;
value: string;
onChange: (next: string) => void;
required?: boolean;
disabled?: boolean;
placeholder?: string;
error?: string;
mono?: boolean;
}
function ConsoleField({
label,
value,
onChange,
required,
disabled,
placeholder,
error,
mono,
ref,
}: ConsoleFieldProps & { ref?: Ref<HTMLInputElement> }): ReactNode {
const id = useId();
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block label-system flex items-center justify-between">
<span>{label}</span>
{required && (
<span style={{ color: 'var(--accent-rt)' }} aria-hidden="true">
· required
</span>
)}
</label>
<input
id={id}
ref={ref}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={placeholder}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${id}-err` : undefined}
className={mono ? 'font-mono tabular' : 'font-sans'}
style={{
width: '100%',
height: 30,
padding: '0 0 4px 0',
backgroundColor: 'transparent',
color: 'var(--fg-default)',
border: 'none',
borderBottom: `1px solid ${error ? 'var(--state-failed)' : 'var(--line-strong)'}`,
borderRadius: 0,
fontSize: 13,
outline: 'none',
}}
onFocus={(e) => {
if (!error) {
e.currentTarget.style.borderBottomColor = 'var(--accent-rt)';
}
}}
onBlur={(e) => {
if (!error) {
e.currentTarget.style.borderBottomColor = 'var(--line-strong)';
}
}}
/>
{error && (
<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>
)}
</div>
);
}
interface ConsoleTextareaProps {
label: string;
value: string;
onChange: (next: string) => void;
disabled?: boolean;
placeholder?: string;
error?: string;
}
function ConsoleTextarea({
label,
value,
onChange,
disabled,
placeholder,
error,
}: ConsoleTextareaProps): ReactNode {
const id = useId();
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block label-system">
{label}
</label>
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={placeholder}
rows={4}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${id}-err` : undefined}
className="font-sans"
style={{
width: '100%',
padding: '8px 10px',
backgroundColor: 'var(--surface-inset)',
color: 'var(--fg-default)',
border: `1px solid ${error ? 'var(--state-failed)' : 'var(--line-default)'}`,
borderRadius: 'var(--radius-sm)',
fontSize: 12.5,
lineHeight: 1.55,
resize: 'vertical',
outline: 'none',
}}
/>
{error && (
<p id={`${id}-err`} className="label-system" style={{ color: 'var(--state-failed)' }}>
{error}
</p>
)}
</div>
);
}