feat(frontend): sprint 2 — simulations UI + MITRE picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { EngagementsListPage } from '@/pages/EngagementsListPage';
|
||||
import { EngagementFormPage } from '@/pages/EngagementFormPage';
|
||||
import { EngagementDetailPage } from '@/pages/EngagementDetailPage';
|
||||
import { UsersAdminPage } from '@/pages/UsersAdminPage';
|
||||
import { SimulationFormPage } from '@/pages/SimulationFormPage';
|
||||
|
||||
/**
|
||||
* Router. Auth + role gates handled by <ProtectedRoute />.
|
||||
@@ -29,8 +30,15 @@ export function App(): JSX.Element {
|
||||
<Route element={<ProtectedRoute roles={['admin', 'redteam']} />}>
|
||||
<Route path="/engagements/new" element={<EngagementFormPage />} />
|
||||
<Route path="/engagements/:id/edit" element={<EngagementFormPage />} />
|
||||
<Route path="/engagements/:eid/simulations/new" element={<SimulationFormPage />} />
|
||||
</Route>
|
||||
|
||||
{/* simulation edit — all authenticated roles, RBAC handled inside the page */}
|
||||
<Route
|
||||
path="/engagements/:eid/simulations/:sid/edit"
|
||||
element={<SimulationFormPage />}
|
||||
/>
|
||||
|
||||
{/* admin-only routes */}
|
||||
<Route element={<ProtectedRoute roles={['admin']} />}>
|
||||
<Route path="/admin/users" element={<UsersAdminPage />} />
|
||||
|
||||
9
frontend/src/api/mitre.ts
Normal file
9
frontend/src/api/mitre.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { apiClient } from './client';
|
||||
import type { MitreTechnique } from './types';
|
||||
|
||||
export async function searchMitreTechniques(query: string): Promise<MitreTechnique[]> {
|
||||
const { data } = await apiClient.get<MitreTechnique[]>('/mitre/techniques', {
|
||||
params: { q: query },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
37
frontend/src/api/simulations.ts
Normal file
37
frontend/src/api/simulations.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { apiClient } from './client';
|
||||
import type { Simulation, SimulationCreateInput, SimulationPatchInput, SimulationStatus } from './types';
|
||||
|
||||
export async function listSimulations(engagementId: number): Promise<Simulation[]> {
|
||||
const { data } = await apiClient.get<Simulation[]>(`/engagements/${engagementId}/simulations`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createSimulation(
|
||||
engagementId: number,
|
||||
input: SimulationCreateInput,
|
||||
): Promise<Simulation> {
|
||||
const { data } = await apiClient.post<Simulation>(`/engagements/${engagementId}/simulations`, input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getSimulation(id: number): Promise<Simulation> {
|
||||
const { data } = await apiClient.get<Simulation>(`/simulations/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSimulation(id: number, patch: SimulationPatchInput): Promise<Simulation> {
|
||||
const { data } = await apiClient.patch<Simulation>(`/simulations/${id}`, patch);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteSimulation(id: number): Promise<void> {
|
||||
await apiClient.delete(`/simulations/${id}`);
|
||||
}
|
||||
|
||||
export async function transitionSimulation(
|
||||
id: number,
|
||||
to: Extract<SimulationStatus, 'review_required' | 'done'>,
|
||||
): Promise<Simulation> {
|
||||
const { data } = await apiClient.post<Simulation>(`/simulations/${id}/transition`, { to });
|
||||
return data;
|
||||
}
|
||||
@@ -52,3 +52,51 @@ export interface UserPatchInput {
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type SimulationStatus = 'pending' | 'in_progress' | 'review_required' | 'done';
|
||||
|
||||
export interface MitreTechnique {
|
||||
id: string;
|
||||
name: string;
|
||||
tactics: string[];
|
||||
}
|
||||
|
||||
export interface Simulation {
|
||||
id: number;
|
||||
engagement_id: number;
|
||||
name: string;
|
||||
mitre_technique_id: string | null;
|
||||
mitre_technique_name: string | null;
|
||||
description: string | null;
|
||||
commands: string | null;
|
||||
prerequisites: string | null;
|
||||
executed_at: string | null;
|
||||
execution_result: string | null;
|
||||
log_source: string | null;
|
||||
logs: string | null;
|
||||
soc_comment: string | null;
|
||||
incident_number: string | null;
|
||||
status: SimulationStatus;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
created_by: { id: number; username: string };
|
||||
}
|
||||
|
||||
export interface SimulationCreateInput {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SimulationPatchInput {
|
||||
name?: string;
|
||||
mitre_technique_id?: string | null;
|
||||
mitre_technique_name?: string | null;
|
||||
description?: string | null;
|
||||
commands?: string | null;
|
||||
prerequisites?: string | null;
|
||||
executed_at?: string | null;
|
||||
execution_result?: string | null;
|
||||
log_source?: string | null;
|
||||
logs?: string | null;
|
||||
soc_comment?: string | null;
|
||||
incident_number?: string | null;
|
||||
}
|
||||
|
||||
48
frontend/src/components/ConfirmDialog.tsx
Normal file
48
frontend/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
destructive = false,
|
||||
}: ConfirmDialogProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div className="absolute inset-0 bg-ink/40" onClick={onCancel} aria-hidden="true" />
|
||||
<div className="relative card-product shadow-floating max-w-sm w-full mx-md flex flex-col gap-md">
|
||||
<h2 id="confirm-dialog-title" className="text-[20px] font-medium text-ink">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-[16px] text-charcoal">{description}</p>
|
||||
<div className="flex items-center gap-md pt-xs">
|
||||
<button
|
||||
type="button"
|
||||
className={destructive ? 'btn-ink' : 'btn-primary'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button type="button" className="btn-outline-ink" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
frontend/src/components/MitreTechniquePicker.tsx
Normal file
191
frontend/src/components/MitreTechniquePicker.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { MitreTechnique } from '@/api/types';
|
||||
import { useMitreSearch } from '@/hooks/useMitre';
|
||||
|
||||
interface MitreTechniquePickerProps {
|
||||
techniqueId: string | null;
|
||||
techniqueName: string | null;
|
||||
onChange: (id: string | null, name: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function formatOption(t: MitreTechnique): string {
|
||||
const tacticList = t.tactics.length > 0 ? ` (${t.tactics[0]})` : '';
|
||||
return `${t.id} — ${t.name}${tacticList}`;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
export function MitreTechniquePicker({
|
||||
techniqueId,
|
||||
techniqueName,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: MitreTechniquePickerProps): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState(
|
||||
techniqueId && techniqueName ? `${techniqueId} — ${techniqueName}` : '',
|
||||
);
|
||||
const [query, setQuery] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Sync display when selection changes from outside (initial load)
|
||||
useEffect(() => {
|
||||
if (techniqueId && techniqueName) {
|
||||
setInputValue(`${techniqueId} — ${techniqueName}`);
|
||||
} else if (!techniqueId) {
|
||||
setInputValue('');
|
||||
}
|
||||
}, [techniqueId, techniqueName]);
|
||||
|
||||
const { data: results, isFetching, isError, error } = useMitreSearch(query, open);
|
||||
|
||||
const items = results ?? [];
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInputValue(value);
|
||||
// Clear the selection when user starts typing
|
||||
onChange(null, null);
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setQuery(value);
|
||||
}, DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const selectItem = (item: MitreTechnique) => {
|
||||
setInputValue(formatOption(item));
|
||||
onChange(item.id, item.name);
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open || items.length === 0) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, items.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && items[activeIndex]) {
|
||||
selectItem(items[activeIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
if (activeIndex >= 0 && listRef.current) {
|
||||
const el = listRef.current.children[activeIndex] as HTMLElement | undefined;
|
||||
el?.scrollIntoView?.({ block: 'nearest' });
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown);
|
||||
}, []);
|
||||
|
||||
const listboxId = 'mitre-picker-listbox';
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<input
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined}
|
||||
aria-label="MITRE technique"
|
||||
className="text-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (!techniqueId) setOpen(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder="Search by ID or name (e.g. T1059)"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-20 w-full mt-xxs bg-canvas border border-steel rounded-md shadow-floating overflow-hidden">
|
||||
{isFetching && (
|
||||
<div className="px-md py-sm text-[14px] text-graphite">Searching…</div>
|
||||
)}
|
||||
|
||||
{isError && !isFetching && (
|
||||
<div className="px-md py-sm text-[14px] text-bloom-deep" role="alert">
|
||||
{extractApiError(error, 'MITRE search unavailable')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFetching && !isError && items.length === 0 && query.trim().length > 0 && (
|
||||
<div className="px-md py-sm text-[14px] text-graphite">No results</div>
|
||||
)}
|
||||
|
||||
{!isFetching && items.length > 0 && (
|
||||
<ul
|
||||
id={listboxId}
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label="MITRE techniques"
|
||||
className="max-h-64 overflow-y-auto"
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<li
|
||||
key={item.id}
|
||||
id={`mitre-option-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
className={`px-md py-sm text-[14px] cursor-pointer select-none ${
|
||||
i === activeIndex ? 'bg-primary-soft text-ink' : 'text-ink hover:bg-cloud'
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
// Prevent input blur before we handle the click
|
||||
e.preventDefault();
|
||||
selectItem(item);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">{item.id}</span>
|
||||
<span className="text-charcoal"> — {item.name}</span>
|
||||
{item.tactics.length > 0 && (
|
||||
<span className="text-graphite"> ({item.tactics[0]})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/SimulationList.tsx
Normal file
113
frontend/src/components/SimulationList.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useEngagementSimulations } from '@/hooks/useSimulations';
|
||||
import { LoadingState } from './LoadingState';
|
||||
import { ErrorState } from './ErrorState';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { SimulationStatusBadge } from './SimulationStatusBadge';
|
||||
|
||||
interface SimulationListProps {
|
||||
engagementId: number;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return value.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
|
||||
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
|
||||
const { canEditEngagements } = useAuth();
|
||||
|
||||
if (isLoading) return <LoadingState label="Loading simulations…" />;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(error, 'Could not load simulations')}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No simulations yet"
|
||||
description="Create the first simulation to start tracking red team tests."
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<Link
|
||||
to={`/engagements/${engagementId}/simulations/new`}
|
||||
className="btn-primary"
|
||||
data-testid="new-simulation-btn"
|
||||
>
|
||||
Nouvelle simulation
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[24px] font-medium text-ink">Simulations</h2>
|
||||
{canEditEngagements ? (
|
||||
<Link
|
||||
to={`/engagements/${engagementId}/simulations/new`}
|
||||
className="btn-primary"
|
||||
data-testid="new-simulation-btn"
|
||||
>
|
||||
Nouvelle simulation
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-product overflow-hidden p-0">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-xl py-md">Name</th>
|
||||
<th className="px-xl py-md">MITRE</th>
|
||||
<th className="px-xl py-md">Status</th>
|
||||
<th className="px-xl py-md">Executed at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((sim) => (
|
||||
<tr
|
||||
key={sim.id}
|
||||
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
|
||||
onClick={() =>
|
||||
(window.location.href = `/engagements/${engagementId}/simulations/${sim.id}/edit`)
|
||||
}
|
||||
>
|
||||
<td className="px-xl py-md">
|
||||
<Link
|
||||
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
|
||||
className="text-ink font-medium hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sim.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal text-[14px]">
|
||||
{sim.mitre_technique_id ?? '—'}
|
||||
</td>
|
||||
<td className="px-xl py-md">
|
||||
<SimulationStatusBadge status={sim.status} />
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal text-[14px]">
|
||||
{formatDate(sim.executed_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/SimulationStatusBadge.tsx
Normal file
28
frontend/src/components/SimulationStatusBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { SimulationStatus } from '@/api/types';
|
||||
|
||||
const LABELS: Record<SimulationStatus, string> = {
|
||||
pending: 'Pending',
|
||||
in_progress: 'In progress',
|
||||
review_required: 'Review required',
|
||||
done: 'Done',
|
||||
};
|
||||
|
||||
// pending=fog, in_progress=primary-soft, review_required=bloom-coral, done=storm-deep
|
||||
const STYLES: Record<SimulationStatus, string> = {
|
||||
pending: 'bg-fog text-charcoal border border-hairline',
|
||||
in_progress: 'bg-primary-soft text-primary-deep',
|
||||
review_required: 'bg-bloom-coral text-canvas',
|
||||
done: 'bg-storm-deep text-canvas',
|
||||
};
|
||||
|
||||
export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
|
||||
data-testid="simulation-status-badge"
|
||||
data-status={status}
|
||||
>
|
||||
{LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
11
frontend/src/hooks/useMitre.ts
Normal file
11
frontend/src/hooks/useMitre.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { searchMitreTechniques } from '@/api/mitre';
|
||||
|
||||
export function useMitreSearch(query: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['mitre', 'techniques', query],
|
||||
queryFn: () => searchMitreTechniques(query),
|
||||
enabled: enabled && query.trim().length > 0,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
76
frontend/src/hooks/useSimulations.ts
Normal file
76
frontend/src/hooks/useSimulations.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createSimulation,
|
||||
deleteSimulation,
|
||||
getSimulation,
|
||||
listSimulations,
|
||||
transitionSimulation,
|
||||
updateSimulation,
|
||||
} from '@/api/simulations';
|
||||
import type { SimulationCreateInput, SimulationPatchInput, SimulationStatus } from '@/api/types';
|
||||
|
||||
function simulationsKey(engagementId: number) {
|
||||
return ['engagements', engagementId, 'simulations'] as const;
|
||||
}
|
||||
|
||||
function simulationKey(id: number) {
|
||||
return ['simulations', id] as const;
|
||||
}
|
||||
|
||||
export function useEngagementSimulations(engagementId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: engagementId ? simulationsKey(engagementId) : ['simulations', 'none'],
|
||||
queryFn: () => listSimulations(engagementId as number),
|
||||
enabled: typeof engagementId === 'number' && !Number.isNaN(engagementId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSimulation(id: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: id ? simulationKey(id) : ['simulations', 'none'],
|
||||
queryFn: () => getSimulation(id as number),
|
||||
enabled: typeof id === 'number' && !Number.isNaN(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSimulation(engagementId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: SimulationCreateInput) => createSimulation(engagementId, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: simulationsKey(engagementId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSimulation(id: number, engagementId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch: SimulationPatchInput) => updateSimulation(id, patch),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
||||
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSimulation(engagementId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteSimulation(id),
|
||||
onSuccess: (_data, id) => {
|
||||
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
||||
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransitionSimulation(id: number, engagementId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (to: Extract<SimulationStatus, 'review_required' | 'done'>) =>
|
||||
transitionSimulation(id, to),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
||||
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useEngagement } from '@/hooks/useEngagements';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
import { SimulationList } from '@/components/SimulationList';
|
||||
|
||||
export function EngagementDetailPage(): JSX.Element {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -71,13 +72,8 @@ export function EngagementDetailPage(): JSX.Element {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sprint 2 placeholder per AC-4.9 */}
|
||||
<section className="bg-ink text-ink-on rounded-xl p-xxl">
|
||||
<h2 className="text-[32px] font-medium leading-none">Simulations</h2>
|
||||
<p className="text-[16px] mt-sm text-steel">
|
||||
Simulations à venir au Sprint 2 — tracking of red team tests and SOC detection coverage
|
||||
will live here.
|
||||
</p>
|
||||
<section>
|
||||
<SimulationList engagementId={eng.id} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
506
frontend/src/pages/SimulationFormPage.tsx
Normal file
506
frontend/src/pages/SimulationFormPage.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { SimulationPatchInput } from '@/api/types';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
useCreateSimulation,
|
||||
useDeleteSimulation,
|
||||
useSimulation,
|
||||
useTransitionSimulation,
|
||||
useUpdateSimulation,
|
||||
} from '@/hooks/useSimulations';
|
||||
import { FormField, TextArea, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
|
||||
|
||||
interface RedteamFormState {
|
||||
name: string;
|
||||
mitre_technique_id: string | null;
|
||||
mitre_technique_name: string | null;
|
||||
description: string;
|
||||
commands: string;
|
||||
prerequisites: string;
|
||||
executed_at: string;
|
||||
execution_result: string;
|
||||
}
|
||||
|
||||
interface SocFormState {
|
||||
log_source: string;
|
||||
logs: string;
|
||||
soc_comment: string;
|
||||
incident_number: string;
|
||||
}
|
||||
|
||||
const EMPTY_RT: RedteamFormState = {
|
||||
name: '',
|
||||
mitre_technique_id: null,
|
||||
mitre_technique_name: null,
|
||||
description: '',
|
||||
commands: '',
|
||||
prerequisites: '',
|
||||
executed_at: '',
|
||||
execution_result: '',
|
||||
};
|
||||
|
||||
const EMPTY_SOC: SocFormState = {
|
||||
log_source: '',
|
||||
logs: '',
|
||||
soc_comment: '',
|
||||
incident_number: '',
|
||||
};
|
||||
|
||||
export function SimulationFormPage(): JSX.Element {
|
||||
const { eid, sid } = useParams<{ eid: string; sid: string }>();
|
||||
const engagementId = eid ? Number(eid) : undefined;
|
||||
const simulationId = sid ? Number(sid) : undefined;
|
||||
const isNew = !simulationId;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { push } = useToast();
|
||||
const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth();
|
||||
|
||||
const detail = useSimulation(isNew ? undefined : simulationId);
|
||||
const createMutation = useCreateSimulation(engagementId ?? 0);
|
||||
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
|
||||
const deleteMutation = useDeleteSimulation(engagementId ?? 0);
|
||||
const transitionMutation = useTransitionSimulation(simulationId ?? 0, engagementId ?? 0);
|
||||
|
||||
const [rt, setRt] = useState<RedteamFormState>(EMPTY_RT);
|
||||
const [soc, setSoc] = useState<SocFormState>(EMPTY_SOC);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew && detail.data) {
|
||||
const s = detail.data;
|
||||
setRt({
|
||||
name: s.name,
|
||||
mitre_technique_id: s.mitre_technique_id,
|
||||
mitre_technique_name: s.mitre_technique_name,
|
||||
description: s.description ?? '',
|
||||
commands: s.commands ?? '',
|
||||
prerequisites: s.prerequisites ?? '',
|
||||
executed_at: s.executed_at ? s.executed_at.replace(' ', 'T').slice(0, 16) : '',
|
||||
execution_result: s.execution_result ?? '',
|
||||
});
|
||||
setSoc({
|
||||
log_source: s.log_source ?? '',
|
||||
logs: s.logs ?? '',
|
||||
soc_comment: s.soc_comment ?? '',
|
||||
incident_number: s.incident_number ?? '',
|
||||
});
|
||||
}
|
||||
}, [isNew, detail.data]);
|
||||
|
||||
if (!isNew && detail.isLoading) return <LoadingState label="Loading simulation…" />;
|
||||
if (!isNew && detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, 'Could not load simulation')}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const simulation = detail.data;
|
||||
const status = simulation?.status;
|
||||
|
||||
// Role-based field locking
|
||||
const canEditRT = isAdmin || isRedteam;
|
||||
// SOC can only edit when status is review_required or done
|
||||
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
|
||||
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
|
||||
|
||||
const rtDisabled = !canEditRT;
|
||||
const socDisabled = !canEditEngagements && !socCanEdit;
|
||||
|
||||
// Transition buttons visibility
|
||||
const showMarkReview =
|
||||
canEditEngagements && (status === 'pending' || status === 'in_progress');
|
||||
const showClose =
|
||||
(canEditEngagements || isSoc) && status === 'review_required';
|
||||
|
||||
const onSubmitNew = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setNameError(null);
|
||||
setSubmitError(null);
|
||||
if (!rt.name.trim()) {
|
||||
setNameError('Name is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
|
||||
push('Simulation créée', 'success');
|
||||
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not create simulation'));
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveRT = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setNameError(null);
|
||||
setSubmitError(null);
|
||||
if (!rt.name.trim()) {
|
||||
setNameError('Name is required');
|
||||
return;
|
||||
}
|
||||
const patch: SimulationPatchInput = {
|
||||
name: rt.name.trim(),
|
||||
mitre_technique_id: rt.mitre_technique_id ?? null,
|
||||
mitre_technique_name: rt.mitre_technique_name ?? null,
|
||||
description: rt.description.trim() || null,
|
||||
commands: rt.commands.trim() || null,
|
||||
prerequisites: rt.prerequisites.trim() || null,
|
||||
executed_at: rt.executed_at || null,
|
||||
execution_result: rt.execution_result.trim() || null,
|
||||
};
|
||||
try {
|
||||
await updateMutation.mutateAsync(patch);
|
||||
push('Simulation mise à jour', 'success');
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not update simulation'));
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveSOC = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitError(null);
|
||||
const patch: SimulationPatchInput = {
|
||||
log_source: soc.log_source.trim() || null,
|
||||
logs: soc.logs.trim() || null,
|
||||
soc_comment: soc.soc_comment.trim() || null,
|
||||
incident_number: soc.incident_number.trim() || null,
|
||||
};
|
||||
try {
|
||||
await updateMutation.mutateAsync(patch);
|
||||
push('Rapport SOC mis à jour', 'success');
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not update SOC fields'));
|
||||
}
|
||||
};
|
||||
|
||||
const onMarkReview = async () => {
|
||||
try {
|
||||
await transitionMutation.mutateAsync('review_required');
|
||||
push('Simulation marquée en revue', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Transition impossible'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = async () => {
|
||||
try {
|
||||
await transitionMutation.mutateAsync('done');
|
||||
push('Simulation clôturée', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Transition impossible'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
setShowDeleteConfirm(false);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(simulationId as number);
|
||||
push('Simulation supprimée', 'success');
|
||||
navigate(`/engagements/${engagementId}`);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Suppression impossible'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// New simulation form (minimal)
|
||||
if (isNew) {
|
||||
const submitting = createMutation.isPending;
|
||||
return (
|
||||
<div className="flex flex-col gap-xl max-w-2xl">
|
||||
<header>
|
||||
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
|
||||
← Back to engagement
|
||||
</Link>
|
||||
<h1 className="text-[44px] font-medium leading-none mt-sm">Nouvelle simulation</h1>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
|
||||
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
|
||||
<TextInput
|
||||
id="sim-name"
|
||||
name="name"
|
||||
value={rt.name}
|
||||
onChange={(e) => setRt({ ...rt, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{submitError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-md pt-sm">
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Creating…' : 'Create simulation'}
|
||||
</button>
|
||||
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit form
|
||||
const submitting =
|
||||
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl max-w-3xl">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<div className="flex flex-col gap-sm">
|
||||
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
|
||||
← Back to engagement
|
||||
</Link>
|
||||
<h1 className="text-[44px] font-medium leading-none">{rt.name || simulation?.name}</h1>
|
||||
{status ? (
|
||||
<div className="flex items-center gap-md">
|
||||
<SimulationStatusBadge status={status} />
|
||||
{simulation?.created_by && (
|
||||
<span className="text-[14px] text-graphite">
|
||||
Created by <span className="text-ink">{simulation.created_by.username}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* SOC banner — shown when soc user visits pending/in_progress */}
|
||||
{socBlocked && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="soc-blocked-banner"
|
||||
className="rounded-xl px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
|
||||
>
|
||||
Simulation pas encore en revue — la redteam doit la marquer comme "Review required" avant
|
||||
que vous puissiez intervenir.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Red Team card */}
|
||||
<form
|
||||
id="rt-form"
|
||||
onSubmit={canEditRT ? onSaveRT : (e) => e.preventDefault()}
|
||||
noValidate
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<h2 className="text-[20px] font-medium text-ink">Red Team</h2>
|
||||
|
||||
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
|
||||
<TextInput
|
||||
id="sim-name"
|
||||
name="name"
|
||||
value={rt.name}
|
||||
onChange={(e) => setRt({ ...rt, name: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="MITRE Technique" htmlFor="sim-mitre">
|
||||
<MitreTechniquePicker
|
||||
techniqueId={rt.mitre_technique_id}
|
||||
techniqueName={rt.mitre_technique_name}
|
||||
onChange={(id, name) =>
|
||||
setRt({ ...rt, mitre_technique_id: id, mitre_technique_name: name })
|
||||
}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" htmlFor="sim-description">
|
||||
<TextArea
|
||||
id="sim-description"
|
||||
name="description"
|
||||
value={rt.description}
|
||||
onChange={(e) => setRt({ ...rt, description: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Commands"
|
||||
htmlFor="sim-commands"
|
||||
hint="One command per line"
|
||||
>
|
||||
<TextArea
|
||||
id="sim-commands"
|
||||
name="commands"
|
||||
value={rt.commands}
|
||||
onChange={(e) => setRt({ ...rt, commands: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
className="min-h-[160px] font-mono text-[14px]"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Prerequisites" htmlFor="sim-prerequisites">
|
||||
<TextArea
|
||||
id="sim-prerequisites"
|
||||
name="prerequisites"
|
||||
value={rt.prerequisites}
|
||||
onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||
<FormField label="Executed at" htmlFor="sim-executed-at">
|
||||
<TextInput
|
||||
id="sim-executed-at"
|
||||
type="datetime-local"
|
||||
name="executed_at"
|
||||
value={rt.executed_at}
|
||||
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Execution result" htmlFor="sim-exec-result">
|
||||
<TextInput
|
||||
id="sim-exec-result"
|
||||
name="execution_result"
|
||||
value={rt.execution_result}
|
||||
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{canEditRT && (
|
||||
<div className="flex items-center gap-md pt-sm border-t border-hairline">
|
||||
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save Red Team'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* SOC card */}
|
||||
<form
|
||||
id="soc-form"
|
||||
onSubmit={socCanEdit ? onSaveSOC : canEditEngagements ? onSaveSOC : (e) => e.preventDefault()}
|
||||
noValidate
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<h2 className="text-[20px] font-medium text-ink">SOC</h2>
|
||||
|
||||
<FormField label="Log source" htmlFor="sim-log-source">
|
||||
<TextInput
|
||||
id="sim-log-source"
|
||||
name="log_source"
|
||||
value={soc.log_source}
|
||||
onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
|
||||
disabled={socDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Logs" htmlFor="sim-logs">
|
||||
<TextArea
|
||||
id="sim-logs"
|
||||
name="logs"
|
||||
value={soc.logs}
|
||||
onChange={(e) => setSoc({ ...soc, logs: e.target.value })}
|
||||
disabled={socDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="SOC comment" htmlFor="sim-soc-comment">
|
||||
<TextArea
|
||||
id="sim-soc-comment"
|
||||
name="soc_comment"
|
||||
value={soc.soc_comment}
|
||||
onChange={(e) => setSoc({ ...soc, soc_comment: e.target.value })}
|
||||
disabled={socDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Incident number" htmlFor="sim-incident">
|
||||
<TextInput
|
||||
id="sim-incident"
|
||||
name="incident_number"
|
||||
value={soc.incident_number}
|
||||
onChange={(e) => setSoc({ ...soc, incident_number: e.target.value })}
|
||||
disabled={socDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{(socCanEdit || canEditEngagements) && (
|
||||
<div className="flex items-center gap-md pt-sm border-t border-hairline">
|
||||
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{submitError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Workflow + delete footer */}
|
||||
<div className="flex items-center gap-md flex-wrap">
|
||||
{showMarkReview && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onMarkReview}
|
||||
disabled={transitionMutation.isPending}
|
||||
>
|
||||
Marquer en revue
|
||||
</button>
|
||||
)}
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onClose}
|
||||
disabled={transitionMutation.isPending}
|
||||
>
|
||||
Clôturer
|
||||
</button>
|
||||
)}
|
||||
{canEditEngagements && simulationId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmDialog
|
||||
title="Supprimer la simulation"
|
||||
description="Cette action est irréversible. La simulation sera définitivement supprimée."
|
||||
confirmLabel="Supprimer"
|
||||
cancelLabel="Annuler"
|
||||
destructive
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user