feat(frontend): sprint 2 — simulations UI + MITRE picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user