From 90fc5bab6c90cf7119bb67c72b4827abc3d324c7 Mon Sep 17 00:00:00 2001 From: Knacky Date: Thu, 28 May 2026 06:36:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20sprint=205=20=E2=80=94=20temp?= =?UTF-8?q?lates=20CRUD=20pages=20+=20nav=20+=20picker=20modal=20+=20dropd?= =?UTF-8?q?own?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput, extend SimulationCreateInput with template_id - api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate - hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate, useDeleteTemplate (TanStack Query, invalidates ["templates"]) - TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated), New/Edit/Delete actions, loading/error/empty states - TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form with inline MITRE field (picker + matrix modal), ConfirmDialog for delete - TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6) - SimulationList: replace "New simulation" link with split-button dropdown (Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id) - Layout: "Templates" nav link (admin | redteam, before "Users") - App.tsx: /admin/templates routes gated roles=["admin","redteam"] - 26 new Vitest tests (118 total, 92 original preserved) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.tsx | 9 + frontend/src/api/templates.ts | 35 +++ frontend/src/api/types.ts | 32 +++ frontend/src/components/Layout.tsx | 14 +- frontend/src/components/SimulationList.tsx | 108 ++++++- .../src/components/TemplatePickerModal.tsx | 104 +++++++ frontend/src/hooks/useTemplates.ts | 62 ++++ frontend/src/pages/TemplateFormPage.tsx | 268 ++++++++++++++++++ frontend/src/pages/TemplatesListPage.tsx | 121 ++++++++ frontend/tests/SimulationList.test.tsx | 37 +++ frontend/tests/TemplateFormPage.test.tsx | 219 ++++++++++++++ frontend/tests/TemplatePickerModal.test.tsx | 151 ++++++++++ frontend/tests/TemplatesListPage.test.tsx | 138 +++++++++ 13 files changed, 1289 insertions(+), 9 deletions(-) create mode 100644 frontend/src/api/templates.ts create mode 100644 frontend/src/components/TemplatePickerModal.tsx create mode 100644 frontend/src/hooks/useTemplates.ts create mode 100644 frontend/src/pages/TemplateFormPage.tsx create mode 100644 frontend/src/pages/TemplatesListPage.tsx create mode 100644 frontend/tests/TemplateFormPage.test.tsx create mode 100644 frontend/tests/TemplatePickerModal.test.tsx create mode 100644 frontend/tests/TemplatesListPage.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd61472..980fb97 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,8 @@ import { EngagementFormPage } from '@/pages/EngagementFormPage'; import { EngagementDetailPage } from '@/pages/EngagementDetailPage'; import { UsersAdminPage } from '@/pages/UsersAdminPage'; import { SimulationFormPage } from '@/pages/SimulationFormPage'; +import { TemplatesListPage } from '@/pages/TemplatesListPage'; +import { TemplateFormPage } from '@/pages/TemplateFormPage'; /** * Router. Auth + role gates handled by . @@ -43,6 +45,13 @@ export function App(): JSX.Element { }> } /> + + {/* admin + redteam routes */} + }> + } /> + } /> + } /> + diff --git a/frontend/src/api/templates.ts b/frontend/src/api/templates.ts new file mode 100644 index 0000000..0bd3349 --- /dev/null +++ b/frontend/src/api/templates.ts @@ -0,0 +1,35 @@ +import { apiClient } from './client'; +import type { + SimulationTemplate, + SimulationTemplateCreateInput, + SimulationTemplatePatchInput, +} from './types'; + +export async function listTemplates(): Promise { + const { data } = await apiClient.get('/simulation-templates'); + return data; +} + +export async function getTemplate(id: number): Promise { + const { data } = await apiClient.get(`/simulation-templates/${id}`); + return data; +} + +export async function createTemplate( + input: SimulationTemplateCreateInput, +): Promise { + const { data } = await apiClient.post('/simulation-templates', input); + return data; +} + +export async function updateTemplate( + id: number, + patch: SimulationTemplatePatchInput, +): Promise { + const { data } = await apiClient.patch(`/simulation-templates/${id}`, patch); + return data; +} + +export async function deleteTemplate(id: number): Promise { + await apiClient.delete(`/simulation-templates/${id}`); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4a52217..3c977eb 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -104,8 +104,40 @@ export interface Simulation { created_by: { id: number; username: string }; } +export interface SimulationTemplate { + id: number; + name: string; + description: string | null; + commands: string | null; + prerequisites: string | null; + techniques: MitreTechnique[]; + tactics: MitreTacticRef[]; + created_at: string; + updated_at: string | null; + created_by: { id: number; username: string }; +} + +export interface SimulationTemplateCreateInput { + name: string; + description?: string | null; + commands?: string | null; + prerequisites?: string | null; + technique_ids?: string[]; + tactic_ids?: string[]; +} + +export interface SimulationTemplatePatchInput { + name?: string; + description?: string | null; + commands?: string | null; + prerequisites?: string | null; + technique_ids?: string[]; + tactic_ids?: string[]; +} + export interface SimulationCreateInput { name: string; + template_id?: number; } export interface SimulationPatchInput { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a193908..e12c4a7 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -17,7 +17,7 @@ function themeLabel(theme: Theme): string { } export function Layout(): JSX.Element { - const { user, isAdmin, logout } = useAuth(); + const { user, isAdmin, isRedteam, logout } = useAuth(); const navigate = useNavigate(); const { theme, cycleTheme } = useTheme(); @@ -78,6 +78,18 @@ export function Layout(): JSX.Element { > Engagements + {isAdmin || isRedteam ? ( + + `text-[16px] py-2 px-md ${ + isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal' + }` + } + > + Templates + + ) : null} {isAdmin ? ( (null); + const createMutation = useCreateSimulation(engagementId); + + const handleBlank = () => { + setOpen(false); + navigate(`/engagements/${engagementId}/simulations/new`); + }; + + const handleFromTemplate = () => { + setOpen(false); + setShowPicker(true); + }; + + const handleSelectTemplate = async (template: SimulationTemplate) => { + try { + const sim = await createMutation.mutateAsync({ name: template.name, template_id: template.id }); + setShowPicker(false); + push(`Created "${sim.name}" from template`, 'success'); + navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + } catch (err) { + push(extractApiError(err, 'Could not create simulation from template'), 'error'); + } + }; + + return ( +
+
+ + +
+ + {open ? ( +
+ + +
+ ) : null} + + {showPicker ? ( + setShowPicker(false)} + onInstantiated={(simId) => { + setShowPicker(false); + navigate(`/engagements/${engagementId}/simulations/${simId}/edit`); + }} + onSelectTemplate={handleSelectTemplate} + isPending={createMutation.isPending} + /> + ) : null} +
+ ); +} + export function SimulationList({ engagementId }: SimulationListProps): JSX.Element { const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId); const { canEditEngagements } = useAuth(); @@ -57,13 +155,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme

Simulations

{canEditEngagements ? ( - - New simulation - + ) : null}
diff --git a/frontend/src/components/TemplatePickerModal.tsx b/frontend/src/components/TemplatePickerModal.tsx new file mode 100644 index 0000000..ccb16a9 --- /dev/null +++ b/frontend/src/components/TemplatePickerModal.tsx @@ -0,0 +1,104 @@ +import { extractApiError } from '@/api/client'; +import type { SimulationTemplate } from '@/api/types'; +import { useTemplates } from '@/hooks/useTemplates'; +import { LoadingState } from './LoadingState'; +import { ErrorState } from './ErrorState'; +import { EmptyState } from './EmptyState'; + +interface TemplatePickerModalProps { + engagementId: number; + onClose: () => void; + onInstantiated: (simId: number) => void; + onSelectTemplate: (template: SimulationTemplate) => void; + isPending?: boolean; +} + +function mitreCount(t: SimulationTemplate): string { + const count = t.techniques.length + t.tactics.length; + return count === 0 ? '—' : String(count); +} + +export function TemplatePickerModal({ + onClose, + onSelectTemplate, + isPending = false, +}: TemplatePickerModalProps): JSX.Element { + const { data, isLoading, isError, error, refetch } = useTemplates(); + + return ( +
+ + ); +} diff --git a/frontend/src/hooks/useTemplates.ts b/frontend/src/hooks/useTemplates.ts new file mode 100644 index 0000000..c91c813 --- /dev/null +++ b/frontend/src/hooks/useTemplates.ts @@ -0,0 +1,62 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + createTemplate, + deleteTemplate, + getTemplate, + listTemplates, + updateTemplate, +} from '@/api/templates'; +import type { SimulationTemplateCreateInput, SimulationTemplatePatchInput } from '@/api/types'; + +function templatesKey() { + return ['templates'] as const; +} + +function templateKey(id: number) { + return ['templates', id] as const; +} + +export function useTemplates() { + return useQuery({ + queryKey: templatesKey(), + queryFn: listTemplates, + }); +} + +export function useTemplate(id: number | undefined) { + return useQuery({ + queryKey: id ? templateKey(id) : ['templates', 'none'], + queryFn: () => getTemplate(id as number), + enabled: typeof id === 'number' && !Number.isNaN(id), + }); +} + +export function useCreateTemplate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: SimulationTemplateCreateInput) => createTemplate(input), + onSuccess: () => qc.invalidateQueries({ queryKey: templatesKey() }), + }); +} + +export function useUpdateTemplate(id: number) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (patch: SimulationTemplatePatchInput) => updateTemplate(id, patch), + onSuccess: () => { + qc.invalidateQueries({ queryKey: templateKey(id) }); + qc.invalidateQueries({ queryKey: templatesKey() }); + }, + }); +} + +export function useDeleteTemplate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => deleteTemplate(id), + onSuccess: (_data, id) => { + qc.invalidateQueries({ queryKey: templateKey(id) }); + qc.invalidateQueries({ queryKey: templatesKey() }); + }, + }); +} diff --git a/frontend/src/pages/TemplateFormPage.tsx b/frontend/src/pages/TemplateFormPage.tsx new file mode 100644 index 0000000..4dd8ac3 --- /dev/null +++ b/frontend/src/pages/TemplateFormPage.tsx @@ -0,0 +1,268 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { Save, Grid2x2 } from 'lucide-react'; +import { extractApiError } from '@/api/client'; +import type { MitreTechnique, MitreTacticRef } from '@/api/types'; +import { useToast } from '@/hooks/useToast'; +import { useCreateTemplate, useDeleteTemplate, useTemplate, useUpdateTemplate } from '@/hooks/useTemplates'; +import { FormField, TextArea, TextInput } from '@/components/FormField'; +import { LoadingState } from '@/components/LoadingState'; +import { ErrorState } from '@/components/ErrorState'; +import { ConfirmDialog } from '@/components/ConfirmDialog'; +import { MitreTechniqueTag, MitreTacticTag } from '@/components/MitreTechniqueTag'; +import { MitreTechniquePicker } from '@/components/MitreTechniquePicker'; +import { MitreMatrixModal } from '@/components/MitreMatrixModal'; +import type { MatrixSelection } from '@/components/MitreMatrixModal'; + +interface FormState { + name: string; + description: string; + commands: string; + prerequisites: string; +} + +const EMPTY: FormState = { name: '', description: '', commands: '', prerequisites: '' }; + +export function TemplateFormPage(): JSX.Element { + const { id } = useParams<{ id: string }>(); + const templateId = id ? Number(id) : undefined; + const isNew = !templateId; + + const navigate = useNavigate(); + const { push } = useToast(); + + const existing = useTemplate(templateId); + const createMutation = useCreateTemplate(); + const updateMutation = useUpdateTemplate(templateId ?? 0); + const deleteMutation = useDeleteTemplate(); + + const [form, setForm] = useState(EMPTY); + const [techniques, setTechniques] = useState([]); + const [tactics, setTactics] = useState([]); + const [formError, setFormError] = useState(null); + const [showMatrix, setShowMatrix] = useState(false); + const [showPicker, setShowPicker] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + useEffect(() => { + if (existing.data) { + const t = existing.data; + setForm({ + name: t.name, + description: t.description ?? '', + commands: t.commands ?? '', + prerequisites: t.prerequisites ?? '', + }); + setTechniques(t.techniques); + setTactics(t.tactics); + } + }, [existing.data]); + + const isPending = createMutation.isPending || updateMutation.isPending; + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + setFormError(null); + if (!form.name.trim()) { + setFormError('Name is required'); + return; + } + const payload = { + name: form.name.trim(), + description: form.description.trim() || null, + commands: form.commands.trim() || null, + prerequisites: form.prerequisites.trim() || null, + technique_ids: techniques.map((t) => t.id), + tactic_ids: tactics.map((t) => t.id), + }; + try { + if (isNew) { + const created = await createMutation.mutateAsync(payload); + push('Template created', 'success'); + navigate(`/admin/templates/${created.id}/edit`, { replace: true }); + } else { + await updateMutation.mutateAsync(payload); + push('Template saved', 'success'); + } + } catch (err) { + setFormError(extractApiError(err, 'Could not save template')); + } + }; + + const onDelete = async () => { + if (!templateId) return; + try { + await deleteMutation.mutateAsync(templateId); + push('Template deleted', 'success'); + navigate('/admin/templates', { replace: true }); + } catch (err) { + push(extractApiError(err, 'Could not delete template'), 'error'); + } + setShowDeleteConfirm(false); + }; + + const handleMatrixApply = ({ techniques: newTech, tactics: newTac }: MatrixSelection) => { + setShowMatrix(false); + setTechniques(newTech); + setTactics(newTac); + }; + + const handlePickerSelect = (technique: MitreTechnique) => { + if (techniques.some((t) => t.id === technique.id)) return; + setTechniques((prev) => [...prev, technique]); + setShowPicker(false); + }; + + if (!isNew && existing.isLoading) return ; + if (!isNew && existing.isError) { + return ( + existing.refetch()} + /> + ); + } + + return ( +
+
+
+ + ← Back to templates + +

+ {isNew ? 'New template' : (existing.data?.name ?? 'Edit template')} +

+
+ {!isNew ? ( + + ) : null} +
+ +
+ + setForm({ ...form, name: e.target.value })} + disabled={isPending} + /> + + + +