feature/m5-templates #2
@@ -13,6 +13,9 @@
|
|||||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\""
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/ibm-plex-sans": "^5.0.20",
|
"@fontsource/ibm-plex-sans": "^5.0.20",
|
||||||
"@fontsource/jetbrains-mono": "^5.0.20",
|
"@fontsource/jetbrains-mono": "^5.0.20",
|
||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { RequireAdmin } from '@/components/RequireAdmin';
|
|||||||
import { RequireAuth } from '@/components/RequireAuth';
|
import { RequireAuth } from '@/components/RequireAuth';
|
||||||
import { AdminGroupsPage } from '@/pages/AdminGroupsPage';
|
import { AdminGroupsPage } from '@/pages/AdminGroupsPage';
|
||||||
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage';
|
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage';
|
||||||
|
import { AdminScenariosPage } from '@/pages/AdminScenariosPage';
|
||||||
|
import { AdminTestsPage } from '@/pages/AdminTestsPage';
|
||||||
import { AdminUsersPage } from '@/pages/AdminUsersPage';
|
import { AdminUsersPage } from '@/pages/AdminUsersPage';
|
||||||
import { HomePage } from '@/pages/HomePage';
|
import { HomePage } from '@/pages/HomePage';
|
||||||
import { MitrePage } from '@/pages/MitrePage';
|
import { MitrePage } from '@/pages/MitrePage';
|
||||||
@@ -82,6 +84,22 @@ function App() {
|
|||||||
</RequireAdmin>
|
</RequireAdmin>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/tests"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<AdminTestsPage />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/scenarios"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<AdminScenariosPage />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function Layout() {
|
|||||||
{navItem('/admin/users', 'Users')}
|
{navItem('/admin/users', 'Users')}
|
||||||
{navItem('/admin/groups', 'Groups')}
|
{navItem('/admin/groups', 'Groups')}
|
||||||
{navItem('/admin/invitations', 'Invitations')}
|
{navItem('/admin/invitations', 'Invitations')}
|
||||||
|
{navItem('/admin/tests', 'Tests')}
|
||||||
|
{navItem('/admin/scenarios', 'Scenarios')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email">
|
<span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email">
|
||||||
@@ -69,7 +71,7 @@ export function Layout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
||||||
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
|
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
|
||||||
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · design system from tasks/design.md
|
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · M5 templates · design system from tasks/design.md
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
45
frontend/src/components/MarkdownField.tsx
Normal file
45
frontend/src/components/MarkdownField.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useId, type TextareaHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
interface MarkdownFieldProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'> {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
rows?: number;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown-content textarea. We deliberately keep it textarea-only (no fancy
|
||||||
|
* WYSIWYG editor) — markdown lives well in plain text and the saved blob is
|
||||||
|
* rendered to HTML at display time (M6/M7 mission pages). The label exposes
|
||||||
|
* "markdown" so the user knows the field accepts MD syntax.
|
||||||
|
*/
|
||||||
|
export function MarkdownField({ label, value, onChange, rows = 6, hint, id, className, ...rest }: MarkdownFieldProps) {
|
||||||
|
const fallbackId = useId();
|
||||||
|
const inputId = id ?? fallbackId;
|
||||||
|
return (
|
||||||
|
<div className="block">
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim"
|
||||||
|
>
|
||||||
|
{label} <span className="text-text-dim/60">· markdown</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={inputId}
|
||||||
|
rows={rows}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'mt-1 w-full rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim',
|
||||||
|
'focus:border-cyan focus:outline-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{hint && <p className="mt-1 font-mono text-2xs text-text-dim">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
frontend/src/lib/templates.ts
Normal file
136
frontend/src/lib/templates.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Shared types + query-key factory for the M5 template catalogue.
|
||||||
|
*
|
||||||
|
* Two resources: `test_templates` (atomic test units) and `scenario_templates`
|
||||||
|
* (ordered lists of tests). Both back the admin pages and feed the M6 mission
|
||||||
|
* wizard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MitreTagKind } from './mitre';
|
||||||
|
|
||||||
|
export type OpsecLevel = 'low' | 'medium' | 'high';
|
||||||
|
|
||||||
|
export interface MitreTagOut {
|
||||||
|
kind: MitreTagKind;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MitreTagInWire {
|
||||||
|
kind: MitreTagKind;
|
||||||
|
external_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
objective: string | null;
|
||||||
|
procedure_md: string | null;
|
||||||
|
prerequisites_md: string | null;
|
||||||
|
expected_result_red_md: string | null;
|
||||||
|
expected_detection_blue_md: string | null;
|
||||||
|
opsec_level: OpsecLevel;
|
||||||
|
tags: string[];
|
||||||
|
expected_iocs: string[];
|
||||||
|
mitre_tags: MitreTagOut[];
|
||||||
|
deleted_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestTemplateListResponse {
|
||||||
|
items: TestTemplate[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTestTemplatePayload {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
objective?: string | null;
|
||||||
|
procedure_md?: string | null;
|
||||||
|
prerequisites_md?: string | null;
|
||||||
|
expected_result_red_md?: string | null;
|
||||||
|
expected_detection_blue_md?: string | null;
|
||||||
|
opsec_level?: OpsecLevel;
|
||||||
|
tags?: string[];
|
||||||
|
expected_iocs?: string[];
|
||||||
|
mitre_tags?: MitreTagInWire[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateTestTemplatePayload = Partial<CreateTestTemplatePayload>;
|
||||||
|
|
||||||
|
export interface ScenarioTest {
|
||||||
|
position: number;
|
||||||
|
test_template_id: string;
|
||||||
|
test_template_name: string;
|
||||||
|
test_template_deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
tests: ScenarioTest[];
|
||||||
|
tests_count: number;
|
||||||
|
deleted_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioTemplateListResponse {
|
||||||
|
items: ScenarioTemplate[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScenarioPayload {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
test_template_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateScenarioPayload {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetScenarioTestsPayload {
|
||||||
|
test_template_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestTemplateFilters {
|
||||||
|
q?: string;
|
||||||
|
tactic?: string;
|
||||||
|
technique?: string;
|
||||||
|
subtechnique?: string;
|
||||||
|
opsec?: OpsecLevel | '';
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const templateKeys = {
|
||||||
|
// Test templates
|
||||||
|
tests: (filters?: TestTemplateFilters) =>
|
||||||
|
['templates', 'tests', filters ?? {}] as const,
|
||||||
|
test: (id: string) => ['templates', 'tests', id] as const,
|
||||||
|
// Scenario templates
|
||||||
|
scenarios: (q?: string) => ['templates', 'scenarios', q ?? ''] as const,
|
||||||
|
scenario: (id: string) => ['templates', 'scenarios', id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildTestQueryString(filters: TestTemplateFilters | undefined): string {
|
||||||
|
if (!filters) return '';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.q) params.set('q', filters.q);
|
||||||
|
if (filters.tactic) params.set('tactic', filters.tactic);
|
||||||
|
if (filters.technique) params.set('technique', filters.technique);
|
||||||
|
if (filters.subtechnique) params.set('subtechnique', filters.subtechnique);
|
||||||
|
if (filters.opsec) params.set('opsec', filters.opsec);
|
||||||
|
if (filters.tag) params.set('tag', filters.tag);
|
||||||
|
const s = params.toString();
|
||||||
|
return s ? `?${s}` : '';
|
||||||
|
}
|
||||||
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