diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c3949b..cd61472 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 . @@ -29,8 +30,15 @@ export function App(): JSX.Element { }> } /> } /> + } /> + {/* simulation edit — all authenticated roles, RBAC handled inside the page */} + } + /> + {/* admin-only routes */} }> } /> diff --git a/frontend/src/api/mitre.ts b/frontend/src/api/mitre.ts new file mode 100644 index 0000000..4750d62 --- /dev/null +++ b/frontend/src/api/mitre.ts @@ -0,0 +1,9 @@ +import { apiClient } from './client'; +import type { MitreTechnique } from './types'; + +export async function searchMitreTechniques(query: string): Promise { + const { data } = await apiClient.get('/mitre/techniques', { + params: { q: query }, + }); + return data; +} diff --git a/frontend/src/api/simulations.ts b/frontend/src/api/simulations.ts new file mode 100644 index 0000000..4b8cee7 --- /dev/null +++ b/frontend/src/api/simulations.ts @@ -0,0 +1,37 @@ +import { apiClient } from './client'; +import type { Simulation, SimulationCreateInput, SimulationPatchInput, SimulationStatus } from './types'; + +export async function listSimulations(engagementId: number): Promise { + const { data } = await apiClient.get(`/engagements/${engagementId}/simulations`); + return data; +} + +export async function createSimulation( + engagementId: number, + input: SimulationCreateInput, +): Promise { + const { data } = await apiClient.post(`/engagements/${engagementId}/simulations`, input); + return data; +} + +export async function getSimulation(id: number): Promise { + const { data } = await apiClient.get(`/simulations/${id}`); + return data; +} + +export async function updateSimulation(id: number, patch: SimulationPatchInput): Promise { + const { data } = await apiClient.patch(`/simulations/${id}`, patch); + return data; +} + +export async function deleteSimulation(id: number): Promise { + await apiClient.delete(`/simulations/${id}`); +} + +export async function transitionSimulation( + id: number, + to: Extract, +): Promise { + const { data } = await apiClient.post(`/simulations/${id}/transition`, { to }); + return data; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 668f960..d640768 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; +} diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..66af72b --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -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 ( +
+ + ); +} diff --git a/frontend/src/components/MitreTechniquePicker.tsx b/frontend/src/components/MitreTechniquePicker.tsx new file mode 100644 index 0000000..74ee798 --- /dev/null +++ b/frontend/src/components/MitreTechniquePicker.tsx @@ -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 | null>(null); + const containerRef = useRef(null); + const listRef = useRef(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) => { + 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 ( +
+ = 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 && ( +
+ {isFetching && ( +
Searching…
+ )} + + {isError && !isFetching && ( +
+ {extractApiError(error, 'MITRE search unavailable')} +
+ )} + + {!isFetching && !isError && items.length === 0 && query.trim().length > 0 && ( +
No results
+ )} + + {!isFetching && items.length > 0 && ( +
    + {items.map((item, i) => ( +
  • { + // Prevent input blur before we handle the click + e.preventDefault(); + selectItem(item); + }} + > + {item.id} + — {item.name} + {item.tactics.length > 0 && ( + ({item.tactics[0]}) + )} +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/SimulationList.tsx b/frontend/src/components/SimulationList.tsx new file mode 100644 index 0000000..2ea8b92 --- /dev/null +++ b/frontend/src/components/SimulationList.tsx @@ -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 ; + + if (isError) { + return ( + refetch()} + /> + ); + } + + if (!data || data.length === 0) { + return ( + + Nouvelle simulation + + ) : undefined + } + /> + ); + } + + return ( +
+
+

Simulations

+ {canEditEngagements ? ( + + Nouvelle simulation + + ) : null} +
+ +
+ + + + + + + + + + + {data.map((sim) => ( + + (window.location.href = `/engagements/${engagementId}/simulations/${sim.id}/edit`) + } + > + + + + + + ))} + +
NameMITREStatusExecuted at
+ e.stopPropagation()} + > + {sim.name} + + + {sim.mitre_technique_id ?? '—'} + + + + {formatDate(sim.executed_at)} +
+
+
+ ); +} diff --git a/frontend/src/components/SimulationStatusBadge.tsx b/frontend/src/components/SimulationStatusBadge.tsx new file mode 100644 index 0000000..c5ffb09 --- /dev/null +++ b/frontend/src/components/SimulationStatusBadge.tsx @@ -0,0 +1,28 @@ +import type { SimulationStatus } from '@/api/types'; + +const LABELS: Record = { + 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 = { + 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 ( + + {LABELS[status]} + + ); +} diff --git a/frontend/src/hooks/useMitre.ts b/frontend/src/hooks/useMitre.ts new file mode 100644 index 0000000..18d736f --- /dev/null +++ b/frontend/src/hooks/useMitre.ts @@ -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, + }); +} diff --git a/frontend/src/hooks/useSimulations.ts b/frontend/src/hooks/useSimulations.ts new file mode 100644 index 0000000..ab9eed5 --- /dev/null +++ b/frontend/src/hooks/useSimulations.ts @@ -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) => + transitionSimulation(id, to), + onSuccess: () => { + qc.invalidateQueries({ queryKey: simulationKey(id) }); + qc.invalidateQueries({ queryKey: simulationsKey(engagementId) }); + }, + }); +} diff --git a/frontend/src/pages/EngagementDetailPage.tsx b/frontend/src/pages/EngagementDetailPage.tsx index 2984bfb..cf44312 100644 --- a/frontend/src/pages/EngagementDetailPage.tsx +++ b/frontend/src/pages/EngagementDetailPage.tsx @@ -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 {
- {/* Sprint 2 placeholder per AC-4.9 */} -
-

Simulations

-

- Simulations à venir au Sprint 2 — tracking of red team tests and SOC detection coverage - will live here. -

+
+
); diff --git a/frontend/src/pages/SimulationFormPage.tsx b/frontend/src/pages/SimulationFormPage.tsx new file mode 100644 index 0000000..90f5be9 --- /dev/null +++ b/frontend/src/pages/SimulationFormPage.tsx @@ -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(EMPTY_RT); + const [soc, setSoc] = useState(EMPTY_SOC); + const [nameError, setNameError] = useState(null); + const [submitError, setSubmitError] = useState(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 ; + if (!isNew && detail.isError) { + return ( + 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 ( +
+
+ + ← Back to engagement + +

Nouvelle simulation

+
+ +
+ + setRt({ ...rt, name: e.target.value })} + required + /> + + + {submitError ? ( +
+ {submitError} +
+ ) : null} + +
+ + + Cancel + +
+
+
+ ); + } + + // Edit form + const submitting = + updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending; + + return ( +
+
+
+ + ← Back to engagement + +

{rt.name || simulation?.name}

+ {status ? ( +
+ + {simulation?.created_by && ( + + Created by {simulation.created_by.username} + + )} +
+ ) : null} +
+
+ + {/* SOC banner — shown when soc user visits pending/in_progress */} + {socBlocked && ( +
+ Simulation pas encore en revue — la redteam doit la marquer comme "Review required" avant + que vous puissiez intervenir. +
+ )} + + {/* Red Team card */} +
e.preventDefault()} + noValidate + className="card-product flex flex-col gap-md" + > +

Red Team

+ + + setRt({ ...rt, name: e.target.value })} + disabled={rtDisabled} + required + /> + + + + + setRt({ ...rt, mitre_technique_id: id, mitre_technique_name: name }) + } + disabled={rtDisabled} + /> + + + +