feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown

- 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 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-28 06:36:10 +02:00
parent 1f327e9aa8
commit 90fc5bab6c
13 changed files with 1289 additions and 9 deletions

View File

@@ -0,0 +1,35 @@
import { apiClient } from './client';
import type {
SimulationTemplate,
SimulationTemplateCreateInput,
SimulationTemplatePatchInput,
} from './types';
export async function listTemplates(): Promise<SimulationTemplate[]> {
const { data } = await apiClient.get<SimulationTemplate[]>('/simulation-templates');
return data;
}
export async function getTemplate(id: number): Promise<SimulationTemplate> {
const { data } = await apiClient.get<SimulationTemplate>(`/simulation-templates/${id}`);
return data;
}
export async function createTemplate(
input: SimulationTemplateCreateInput,
): Promise<SimulationTemplate> {
const { data } = await apiClient.post<SimulationTemplate>('/simulation-templates', input);
return data;
}
export async function updateTemplate(
id: number,
patch: SimulationTemplatePatchInput,
): Promise<SimulationTemplate> {
const { data } = await apiClient.patch<SimulationTemplate>(`/simulation-templates/${id}`, patch);
return data;
}
export async function deleteTemplate(id: number): Promise<void> {
await apiClient.delete(`/simulation-templates/${id}`);
}

View File

@@ -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 {