feat(m5): admin SPA pages for the template catalogue
- AdminTestsPage with filters (q, tactic, opsec, tag), modal-based CRUD, markdown textareas for procedure/result/detection, embedded MitreTagPicker for tagging. - AdminScenariosPage with @dnd-kit/sortable drag-and-drop on the ordered test list, two-step save (PATCH metadata + PUT tests), catalogue picker excluding soft-deleted items. - lib/templates.ts typed client + queryKey factory. - MarkdownField helper (textarea with markdown hint label). - Layout adds Tests + Scenarios admin nav links; App.tsx routes both behind RequireAdmin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
434
frontend/src/pages/AdminScenariosPage.tsx
Normal file
434
frontend/src/pages/AdminScenariosPage.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Alert } from '@/components/ui/Alert';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import { TextField } from '@/components/ui/TextField';
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
apiGet,
|
||||
apiPatch,
|
||||
apiPost,
|
||||
apiPut,
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
templateKeys,
|
||||
type CreateScenarioPayload,
|
||||
type ScenarioTemplate,
|
||||
type ScenarioTemplateListResponse,
|
||||
type TestTemplate,
|
||||
type TestTemplateListResponse,
|
||||
} from '@/lib/templates';
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
description: string;
|
||||
test_ids: string[];
|
||||
}
|
||||
|
||||
function blankForm(): FormState {
|
||||
return { name: '', description: '', test_ids: [] };
|
||||
}
|
||||
|
||||
function toForm(sc: ScenarioTemplate): FormState {
|
||||
return {
|
||||
name: sc.name,
|
||||
description: sc.description ?? '',
|
||||
test_ids: sc.tests.map((t) => t.test_template_id),
|
||||
};
|
||||
}
|
||||
|
||||
function useScenarios(q: string) {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.scenarios(q),
|
||||
queryFn: () =>
|
||||
apiGet<ScenarioTemplateListResponse>(
|
||||
`/scenario-templates${q ? `?q=${encodeURIComponent(q)}` : ''}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function useTestCatalogue() {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.tests({}),
|
||||
queryFn: () => apiGet<TestTemplateListResponse>('/test-templates?limit=500'),
|
||||
});
|
||||
}
|
||||
|
||||
interface SortableTestRowProps {
|
||||
id: string;
|
||||
index: number;
|
||||
name: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function SortableTestRow({ id, index, name, onRemove }: SortableTestRowProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-bg-card px-3 py-2"
|
||||
data-testid={`scenario-test-row-${id}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab font-mono text-text-dim hover:text-text-bright active:cursor-grabbing"
|
||||
aria-label={`Drag ${name}`}
|
||||
data-testid={`drag-handle-${id}`}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<span className="font-mono text-2xs text-text-dim w-6">{String(index + 1).padStart(2, '0')}</span>
|
||||
<span className="font-mono text-xs text-text-bright flex-1">{name}</span>
|
||||
<Button variant="ghost" accent="rose" onClick={onRemove} aria-label={`Remove ${name}`}>
|
||||
✕
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminScenariosPage() {
|
||||
const qc = useQueryClient();
|
||||
const [q, setQ] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<ScenarioTemplate | null>(null);
|
||||
const [form, setForm] = useState<FormState>(blankForm());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const scenarios = useScenarios(q);
|
||||
const catalogue = useTestCatalogue();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (payload: CreateScenarioPayload) =>
|
||||
apiPost<ScenarioTemplate>('/scenario-templates', payload),
|
||||
onSuccess: async () => {
|
||||
setCreating(false);
|
||||
setForm(blankForm());
|
||||
setError(null);
|
||||
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
|
||||
},
|
||||
onError: (e) => setError(humanError(e)),
|
||||
});
|
||||
|
||||
const updateMeta = useMutation({
|
||||
mutationFn: ({ id, name, description }: { id: string; name: string; description: string | null }) =>
|
||||
apiPatch<ScenarioTemplate>(`/scenario-templates/${id}`, { name, description }),
|
||||
});
|
||||
|
||||
const setTests = useMutation({
|
||||
mutationFn: ({ id, test_template_ids }: { id: string; test_template_ids: string[] }) =>
|
||||
apiPut<ScenarioTemplate>(`/scenario-templates/${id}/tests`, { test_template_ids }),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => apiDelete<{ ok: boolean }>(`/scenario-templates/${id}`),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
|
||||
},
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
setForm(blankForm());
|
||||
setError(null);
|
||||
setCreating(true);
|
||||
}
|
||||
|
||||
function openEdit(sc: ScenarioTemplate) {
|
||||
setForm(toForm(sc));
|
||||
setError(null);
|
||||
setEditing(sc);
|
||||
}
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
setForm((f) => {
|
||||
const from = f.test_ids.indexOf(String(active.id));
|
||||
const to = f.test_ids.indexOf(String(over.id));
|
||||
if (from < 0 || to < 0) return f;
|
||||
return { ...f, test_ids: arrayMove(f.test_ids, from, to) };
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setError(null);
|
||||
if (!form.name.trim()) {
|
||||
setError('Name is required.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editing) {
|
||||
// Two-step: metadata first, then ordered tests.
|
||||
await updateMeta.mutateAsync({
|
||||
id: editing.id,
|
||||
name: form.name.trim(),
|
||||
description: form.description || null,
|
||||
});
|
||||
await setTests.mutateAsync({ id: editing.id, test_template_ids: form.test_ids });
|
||||
} else {
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
description: form.description || null,
|
||||
test_template_ids: form.test_ids,
|
||||
});
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
|
||||
setEditing(null);
|
||||
setCreating(false);
|
||||
} catch (e) {
|
||||
setError(humanError(e));
|
||||
}
|
||||
}
|
||||
|
||||
const isModalOpen = creating || editing !== null;
|
||||
|
||||
const testNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
catalogue.data?.items.forEach((t) => map.set(t.id, t.name));
|
||||
return map;
|
||||
}, [catalogue.data]);
|
||||
|
||||
// Tests available for inclusion (excluding already-picked + soft-deleted).
|
||||
const availableTests = useMemo<TestTemplate[]>(() => {
|
||||
if (!catalogue.data) return [];
|
||||
const picked = new Set(form.test_ids);
|
||||
return catalogue.data.items.filter((t) => !picked.has(t.id) && !t.deleted_at);
|
||||
}, [catalogue.data, form.test_ids]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
prefix="Admin"
|
||||
highlight="Scenarios"
|
||||
accent="purple"
|
||||
description="Ordered playbooks composed from the test catalogue. Drag rows to reorder; the order is the execution sequence."
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex flex-wrap items-end gap-3">
|
||||
<TextField
|
||||
label="Search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="name or description"
|
||||
data-testid="scenarios-search"
|
||||
/>
|
||||
<Button accent="purple" onClick={openCreate} data-testid="create-scenario" className="ml-auto">
|
||||
+ New scenario
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{scenarios.isError && <Alert accent="red">Failed to load scenarios.</Alert>}
|
||||
|
||||
<div className="grid gap-3" data-testid="scenarios-list">
|
||||
{scenarios.isLoading && <p className="font-mono text-xs text-text-dim">Loading…</p>}
|
||||
{scenarios.data?.items.map((sc) => (
|
||||
<Card
|
||||
key={sc.id}
|
||||
accent="purple"
|
||||
title={sc.name}
|
||||
sub={sc.description ?? '—'}
|
||||
data-testid={`scenario-row-${sc.id}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag accent="purple">{sc.tests_count} test{sc.tests_count === 1 ? '' : 's'}</Tag>
|
||||
{sc.tests.slice(0, 4).map((t) => (
|
||||
<Tag key={`${sc.id}:${t.position}`} accent={t.test_template_deleted ? 'rose' : 'cyan'}>
|
||||
{t.position + 1}. {t.test_template_name}
|
||||
</Tag>
|
||||
))}
|
||||
{sc.tests.length > 4 && (
|
||||
<Tag accent="yellow">+{sc.tests.length - 4} more</Tag>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button accent="purple" onClick={() => openEdit(sc)} data-testid={`edit-scenario-${sc.id}`}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
accent="rose"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Soft-delete "${sc.name}"?`)) remove.mutate(sc.id);
|
||||
}}
|
||||
data-testid={`delete-scenario-${sc.id}`}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{scenarios.data && scenarios.data.items.length === 0 && !scenarios.isLoading && (
|
||||
<p className="font-mono text-2xs text-text-dim">No scenarios yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
title={editing ? `Scenario · ${editing.name}` : 'New scenario template'}
|
||||
accent="purple"
|
||||
onClose={() => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
testid="scenario-template-modal"
|
||||
>
|
||||
{error && <Alert accent="red" className="mb-3">{error}</Alert>}
|
||||
<div className="grid gap-3">
|
||||
<TextField
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
data-testid="form-scenario-name"
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
data-testid="form-scenario-description"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-2">
|
||||
Tests in order ({form.test_ids.length})
|
||||
</p>
|
||||
{form.test_ids.length === 0 ? (
|
||||
<p className="font-mono text-2xs text-text-dim mb-2">No test picked yet.</p>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={form.test_ids} strategy={verticalListSortingStrategy}>
|
||||
<ol className="grid gap-2 mb-3" data-testid="scenario-tests-ordered">
|
||||
{form.test_ids.map((id, idx) => (
|
||||
<SortableTestRow
|
||||
key={id}
|
||||
id={id}
|
||||
index={idx}
|
||||
name={testNameById.get(id) ?? '<missing>'}
|
||||
onRemove={() =>
|
||||
setForm((f) => ({ ...f, test_ids: f.test_ids.filter((t) => t !== id) }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<CataloguePicker
|
||||
tests={availableTests}
|
||||
onAdd={(id) => setForm((f) => ({ ...f, test_ids: [...f.test_ids, id] }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
accent="purple"
|
||||
onClick={submit}
|
||||
disabled={create.isPending || updateMeta.isPending || setTests.isPending}
|
||||
data-testid="form-scenario-submit"
|
||||
>
|
||||
{editing ? 'Save' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CataloguePickerProps {
|
||||
tests: TestTemplate[];
|
||||
onAdd: (id: string) => void;
|
||||
}
|
||||
|
||||
function CataloguePicker({ tests, onAdd }: CataloguePickerProps) {
|
||||
const [q, setQ] = useState('');
|
||||
const filtered = useMemo(() => {
|
||||
const norm = q.trim().toLowerCase();
|
||||
if (!norm) return tests.slice(0, 50);
|
||||
return tests.filter((t) => t.name.toLowerCase().includes(norm)).slice(0, 50);
|
||||
}, [tests, q]);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-bg-card p-3">
|
||||
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-2">
|
||||
Add a test from the catalogue
|
||||
</p>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search catalogue…"
|
||||
className="mb-2 w-full rounded-md border border-border bg-bg-base px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim focus:border-cyan focus:outline-none"
|
||||
data-testid="scenario-catalogue-search"
|
||||
/>
|
||||
<ul className="max-h-48 overflow-auto grid gap-1" data-testid="scenario-catalogue-list">
|
||||
{filtered.map((t) => (
|
||||
<li key={t.id} className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdd(t.id)}
|
||||
className="flex-1 text-left rounded-sm px-2 py-1 font-mono text-xs text-text-bright hover:bg-bg-base"
|
||||
data-testid={`catalogue-add-${t.id}`}
|
||||
>
|
||||
+ {t.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<li className="font-mono text-2xs text-text-dim">No matching test in the catalogue.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function humanError(e: unknown): string {
|
||||
if (e instanceof ApiError) {
|
||||
const p = e.payload as { error?: string; message?: string } | null;
|
||||
return p?.message ?? p?.error ?? `HTTP ${e.status}`;
|
||||
}
|
||||
return e instanceof Error ? e.message : 'Unexpected error';
|
||||
}
|
||||
403
frontend/src/pages/AdminTestsPage.tsx
Normal file
403
frontend/src/pages/AdminTestsPage.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { MarkdownField } from '@/components/MarkdownField';
|
||||
import { MitreTagPicker } from '@/components/MitreTagPicker';
|
||||
import { Alert } from '@/components/ui/Alert';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import { TextField } from '@/components/ui/TextField';
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPut,
|
||||
} from '@/lib/api';
|
||||
import { type MitreTag, type MitreTagKind } from '@/lib/mitre';
|
||||
import {
|
||||
buildTestQueryString,
|
||||
templateKeys,
|
||||
type CreateTestTemplatePayload,
|
||||
type OpsecLevel,
|
||||
type TestTemplate,
|
||||
type TestTemplateFilters,
|
||||
type TestTemplateListResponse,
|
||||
} from '@/lib/templates';
|
||||
|
||||
const OPSEC_LEVELS: OpsecLevel[] = ['low', 'medium', 'high'];
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
description: string;
|
||||
objective: string;
|
||||
procedure_md: string;
|
||||
prerequisites_md: string;
|
||||
expected_result_red_md: string;
|
||||
expected_detection_blue_md: string;
|
||||
opsec_level: OpsecLevel;
|
||||
tags: string;
|
||||
expected_iocs: string;
|
||||
mitre_tags: MitreTag[];
|
||||
}
|
||||
|
||||
function blankForm(): FormState {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
objective: '',
|
||||
procedure_md: '',
|
||||
prerequisites_md: '',
|
||||
expected_result_red_md: '',
|
||||
expected_detection_blue_md: '',
|
||||
opsec_level: 'medium',
|
||||
tags: '',
|
||||
expected_iocs: '',
|
||||
mitre_tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
function toForm(t: TestTemplate): FormState {
|
||||
return {
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
objective: t.objective ?? '',
|
||||
procedure_md: t.procedure_md ?? '',
|
||||
prerequisites_md: t.prerequisites_md ?? '',
|
||||
expected_result_red_md: t.expected_result_red_md ?? '',
|
||||
expected_detection_blue_md: t.expected_detection_blue_md ?? '',
|
||||
opsec_level: t.opsec_level,
|
||||
tags: t.tags.join(', '),
|
||||
expected_iocs: t.expected_iocs.join(', '),
|
||||
mitre_tags: t.mitre_tags.map((tag) => ({
|
||||
kind: tag.kind as MitreTagKind,
|
||||
id: tag.external_id,
|
||||
external_id: tag.external_id,
|
||||
name: tag.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function csvToList(s: string): string[] {
|
||||
return s.split(',').map((x) => x.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function toPayload(form: FormState): CreateTestTemplatePayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
description: form.description || null,
|
||||
objective: form.objective || null,
|
||||
procedure_md: form.procedure_md || null,
|
||||
prerequisites_md: form.prerequisites_md || null,
|
||||
expected_result_red_md: form.expected_result_red_md || null,
|
||||
expected_detection_blue_md: form.expected_detection_blue_md || null,
|
||||
opsec_level: form.opsec_level,
|
||||
tags: csvToList(form.tags),
|
||||
expected_iocs: csvToList(form.expected_iocs),
|
||||
mitre_tags: form.mitre_tags.map((t) => ({ kind: t.kind, external_id: t.external_id })),
|
||||
};
|
||||
}
|
||||
|
||||
function useTestTemplates(filters: TestTemplateFilters) {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.tests(filters),
|
||||
queryFn: () =>
|
||||
apiGet<TestTemplateListResponse>(`/test-templates${buildTestQueryString(filters)}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function AdminTestsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [filters, setFilters] = useState<TestTemplateFilters>({});
|
||||
const [editing, setEditing] = useState<TestTemplate | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(blankForm());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const tests = useTestTemplates(filters);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (payload: CreateTestTemplatePayload) =>
|
||||
apiPost<TestTemplate>('/test-templates', payload),
|
||||
onSuccess: async () => {
|
||||
setCreating(false);
|
||||
setForm(blankForm());
|
||||
setError(null);
|
||||
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
|
||||
},
|
||||
onError: (e) => setError(humanError(e)),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: CreateTestTemplatePayload }) =>
|
||||
apiPut<TestTemplate>(`/test-templates/${id}`, payload),
|
||||
onSuccess: async () => {
|
||||
setEditing(null);
|
||||
setError(null);
|
||||
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
|
||||
},
|
||||
onError: (e) => setError(humanError(e)),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => apiDelete<{ ok: boolean }>(`/test-templates/${id}`),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
|
||||
},
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
setForm(blankForm());
|
||||
setError(null);
|
||||
setCreating(true);
|
||||
}
|
||||
|
||||
function openEdit(t: TestTemplate) {
|
||||
setForm(toForm(t));
|
||||
setError(null);
|
||||
setEditing(t);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const payload = toPayload(form);
|
||||
if (!payload.name) {
|
||||
setError('Name is required.');
|
||||
return;
|
||||
}
|
||||
if (editing) update.mutate({ id: editing.id, payload });
|
||||
else create.mutate(payload);
|
||||
}
|
||||
|
||||
const isModalOpen = creating || editing !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
prefix="Admin"
|
||||
highlight="Tests"
|
||||
accent="orange"
|
||||
description="Reusable test units. Each test belongs to a scenario at instantiation time, but the catalogue lives independently."
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex flex-wrap items-end gap-3" data-testid="tests-filters">
|
||||
<TextField
|
||||
label="Search"
|
||||
placeholder="name or description"
|
||||
value={filters.q ?? ''}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, q: e.target.value || undefined }))}
|
||||
data-testid="filter-q"
|
||||
/>
|
||||
<TextField
|
||||
label="Tactic external_id"
|
||||
placeholder="TA0006"
|
||||
value={filters.tactic ?? ''}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, tactic: e.target.value || undefined }))}
|
||||
data-testid="filter-tactic"
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
|
||||
OPSEC
|
||||
</span>
|
||||
<select
|
||||
value={filters.opsec ?? ''}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({ ...f, opsec: (e.target.value as OpsecLevel | '') || undefined }))
|
||||
}
|
||||
className="mt-1 rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
|
||||
data-testid="filter-opsec"
|
||||
>
|
||||
<option value="">— all —</option>
|
||||
{OPSEC_LEVELS.map((lv) => (
|
||||
<option key={lv} value={lv}>{lv}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<TextField
|
||||
label="Free tag"
|
||||
placeholder="phish"
|
||||
value={filters.tag ?? ''}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, tag: e.target.value || undefined }))}
|
||||
data-testid="filter-tag"
|
||||
/>
|
||||
<Button accent="orange" onClick={openCreate} data-testid="create-test" className="ml-auto">
|
||||
+ New test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tests.isError && <Alert accent="red">Failed to load tests.</Alert>}
|
||||
|
||||
<div className="grid gap-3" data-testid="tests-list">
|
||||
{tests.isLoading && <p className="font-mono text-xs text-text-dim">Loading…</p>}
|
||||
{tests.data?.items.map((t) => (
|
||||
<Card
|
||||
key={t.id}
|
||||
accent="orange"
|
||||
title={t.name}
|
||||
sub={t.description ?? '—'}
|
||||
data-testid={`test-row-${t.id}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag accent={t.opsec_level === 'high' ? 'red' : t.opsec_level === 'low' ? 'green' : 'yellow'}>
|
||||
opsec: {t.opsec_level}
|
||||
</Tag>
|
||||
{t.mitre_tags.map((tag) => (
|
||||
<Tag
|
||||
key={`${tag.kind}:${tag.external_id}`}
|
||||
accent={tag.kind === 'tactic' ? 'cyan' : tag.kind === 'technique' ? 'orange' : 'purple'}
|
||||
>
|
||||
{tag.external_id}
|
||||
</Tag>
|
||||
))}
|
||||
{t.tags.map((tg) => (
|
||||
<Tag key={tg} accent="cyan">#{tg}</Tag>
|
||||
))}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button accent="orange" onClick={() => openEdit(t)} data-testid={`edit-test-${t.id}`}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
accent="rose"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Soft-delete "${t.name}"?`)) remove.mutate(t.id);
|
||||
}}
|
||||
data-testid={`delete-test-${t.id}`}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{tests.data && tests.data.items.length === 0 && !tests.isLoading && (
|
||||
<p className="font-mono text-2xs text-text-dim">No tests match the current filters.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
title={editing ? `Test · ${editing.name}` : 'New test template'}
|
||||
accent="orange"
|
||||
onClose={() => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
testid="test-template-modal"
|
||||
>
|
||||
{error && <Alert accent="red" className="mb-3">{error}</Alert>}
|
||||
<div className="grid gap-3">
|
||||
<TextField
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
data-testid="form-name"
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
data-testid="form-description"
|
||||
/>
|
||||
<TextField
|
||||
label="Objective (1-liner)"
|
||||
value={form.objective}
|
||||
onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))}
|
||||
/>
|
||||
<MarkdownField
|
||||
label="Procedure"
|
||||
value={form.procedure_md}
|
||||
onChange={(v) => setForm((f) => ({ ...f, procedure_md: v }))}
|
||||
data-testid="form-procedure"
|
||||
hint="Step-by-step playbook. Markdown supported."
|
||||
/>
|
||||
<MarkdownField
|
||||
label="Prerequisites"
|
||||
value={form.prerequisites_md}
|
||||
onChange={(v) => setForm((f) => ({ ...f, prerequisites_md: v }))}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<MarkdownField
|
||||
label="Red — expected result"
|
||||
value={form.expected_result_red_md}
|
||||
onChange={(v) => setForm((f) => ({ ...f, expected_result_red_md: v }))}
|
||||
rows={4}
|
||||
/>
|
||||
<MarkdownField
|
||||
label="Blue — expected detection"
|
||||
value={form.expected_detection_blue_md}
|
||||
onChange={(v) => setForm((f) => ({ ...f, expected_detection_blue_md: v }))}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
|
||||
OPSEC level
|
||||
</span>
|
||||
<select
|
||||
value={form.opsec_level}
|
||||
onChange={(e) => setForm((f) => ({ ...f, opsec_level: e.target.value as OpsecLevel }))}
|
||||
className="mt-1 rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
|
||||
data-testid="form-opsec"
|
||||
>
|
||||
{OPSEC_LEVELS.map((lv) => (
|
||||
<option key={lv} value={lv}>{lv}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<TextField
|
||||
label="Free tags (comma-separated)"
|
||||
value={form.tags}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
|
||||
data-testid="form-tags"
|
||||
hint="e.g. phish, persistence, quick-win"
|
||||
/>
|
||||
<TextField
|
||||
label="Expected IOCs (comma-separated)"
|
||||
value={form.expected_iocs}
|
||||
onChange={(e) => setForm((f) => ({ ...f, expected_iocs: e.target.value }))}
|
||||
hint="Indicators the blue team should look for"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-1">
|
||||
MITRE ATT&CK tags
|
||||
</p>
|
||||
<MitreTagPicker
|
||||
value={form.mitre_tags}
|
||||
onChange={(next) => setForm((f) => ({ ...f, mitre_tags: next }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
accent="orange"
|
||||
onClick={submit}
|
||||
disabled={create.isPending || update.isPending}
|
||||
data-testid="form-submit"
|
||||
>
|
||||
{editing ? 'Save' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function humanError(e: unknown): string {
|
||||
if (e instanceof ApiError) {
|
||||
const p = e.payload as { error?: string; message?: string } | null;
|
||||
return p?.message ?? p?.error ?? `HTTP ${e.status}`;
|
||||
}
|
||||
return e instanceof Error ? e.message : 'Unexpected error';
|
||||
}
|
||||
Reference in New Issue
Block a user