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:
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