From 2910aea6b2340c3ffc6afdafae826e5ac19b9861 Mon Sep 17 00:00:00 2001 From: kitos Date: Fri, 29 May 2026 09:10:03 +0200 Subject: [PATCH] feat(campaigns): add 'From Template' tab in Add Test modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal now has two tabs: - 'From Template' (default): searchable/filterable template catalog → select template → customise name/platform/procedure/tool → 'Create & Add to Campaign' (two-step: POST /tests/from-template then POST /campaigns/{id}/tests) - 'Existing Test': previous behaviour — add an already-created test Both tabs share an added-count footer badge. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/AddTestToCampaignModal.tsx | 607 +++++++++++++++--- 1 file changed, 503 insertions(+), 104 deletions(-) diff --git a/frontend/src/components/AddTestToCampaignModal.tsx b/frontend/src/components/AddTestToCampaignModal.tsx index 2dc6df7..99b516e 100644 --- a/frontend/src/components/AddTestToCampaignModal.tsx +++ b/frontend/src/components/AddTestToCampaignModal.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { X, @@ -7,10 +7,18 @@ import { Loader2, CheckCircle, FlaskConical, + BookOpen, + ArrowLeft, + ChevronRight, + Filter, } from "lucide-react"; import { getTests } from "../api/tests"; import { addTestToCampaign } from "../api/campaigns"; -import type { Test, TestState } from "../types/models"; +import { getTemplates, getTemplateById } from "../api/test-templates"; +import { createTestFromTemplate } from "../api/tests"; +import type { Test, TestState, TestTemplateSummary } from "../types/models"; + +/* ── helpers ─────────────────────────────────────────────────────── */ const stateBadge: Record = { draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", @@ -21,6 +29,24 @@ const stateBadge: Record = { rejected: "bg-red-900/50 text-red-400 border-red-500/30", }; +const SEVERITY_COLORS: Record = { + critical: "text-red-400 border-red-500/30 bg-red-500/10", + high: "text-orange-400 border-orange-500/30 bg-orange-500/10", + medium: "text-yellow-400 border-yellow-500/30 bg-yellow-500/10", + low: "text-blue-400 border-blue-500/30 bg-blue-500/10", +}; + +const SOURCE_LABELS: Record = { + atomic_red_team: "Atomic Red Team", + mitre: "MITRE", + custom: "Custom", +}; + +type Tab = "existing" | "template"; +type TemplateStep = "list" | "form"; + +/* ── props ───────────────────────────────────────────────────────── */ + interface AddTestToCampaignModalProps { campaignId: string; existingTestIds: string[]; @@ -29,6 +55,8 @@ interface AddTestToCampaignModalProps { onSuccess: () => void; } +/* ── component ───────────────────────────────────────────────────── */ + export default function AddTestToCampaignModal({ campaignId, existingTestIds, @@ -37,53 +65,167 @@ export default function AddTestToCampaignModal({ onSuccess, }: AddTestToCampaignModalProps) { const queryClient = useQueryClient(); - const [searchText, setSearchText] = useState(""); + + // ── shared state ──────────────────────────────────────────────── + const [tab, setTab] = useState("template"); + const [addedCount, setAddedCount] = useState(0); + + // ── existing-test tab ─────────────────────────────────────────── + const [existingSearch, setExistingSearch] = useState(""); const [addedIds, setAddedIds] = useState>(new Set()); - const { data: allTests, isLoading } = useQuery({ + // ── template tab ──────────────────────────────────────────────── + const [templateStep, setTemplateStep] = useState("list"); + const [templateSearch, setTemplateSearch] = useState(""); + const [filterPlatform, setFilterPlatform] = useState(""); + const [filterSeverity, setFilterSeverity] = useState(""); + const [selectedTemplateId, setSelectedTemplateId] = useState(null); + + // form fields (pre-filled from selected template) + const [formName, setFormName] = useState(""); + const [formDescription, setFormDescription] = useState(""); + const [formPlatform, setFormPlatform] = useState(""); + const [formProcedure, setFormProcedure] = useState(""); + const [formTool, setFormTool] = useState(""); + + // ── reset when closed ──────────────────────────────────────────── + useEffect(() => { + if (!open) { + setTab("template"); + setExistingSearch(""); + setAddedIds(new Set()); + setAddedCount(0); + setTemplateStep("list"); + setTemplateSearch(""); + setFilterPlatform(""); + setFilterSeverity(""); + setSelectedTemplateId(null); + } + }, [open]); + + // ── queries ────────────────────────────────────────────────────── + + const { data: allTests, isLoading: testsLoading } = useQuery({ queryKey: ["tests", "for-campaign-picker"], queryFn: () => getTests({ limit: 200 }), - enabled: open, + enabled: open && tab === "existing", }); - const filteredTests = useMemo(() => { - if (!allTests) return []; - const alreadyIn = new Set([...existingTestIds, ...addedIds]); - let results = allTests.filter((t) => !alreadyIn.has(t.id)); + const { data: templates, isLoading: templatesLoading } = useQuery({ + queryKey: ["templates", "picker", filterPlatform, filterSeverity, templateSearch], + queryFn: () => + getTemplates({ + search: templateSearch || undefined, + platform: filterPlatform || undefined, + severity: filterSeverity || undefined, + limit: 100, + }), + enabled: open && tab === "template", + staleTime: 60_000, + }); - if (searchText.trim()) { - const q = searchText.toLowerCase(); - results = results.filter( - (t) => - t.name.toLowerCase().includes(q) || - (t.technique_mitre_id && t.technique_mitre_id.toLowerCase().includes(q)) || - (t.technique_name && t.technique_name.toLowerCase().includes(q)) - ); + const { data: fullTemplate, isLoading: fullTemplateLoading } = useQuery({ + queryKey: ["template-detail", selectedTemplateId], + queryFn: () => getTemplateById(selectedTemplateId!), + enabled: !!selectedTemplateId, + }); + + // Pre-fill form when full template loads + useEffect(() => { + if (fullTemplate) { + setFormName(fullTemplate.name); + setFormDescription(fullTemplate.description || ""); + setFormPlatform(fullTemplate.platform || ""); + setFormProcedure(fullTemplate.attack_procedure || ""); + setFormTool(fullTemplate.tool_suggested || ""); } + }, [fullTemplate]); - return results; - }, [allTests, searchText, existingTestIds, addedIds]); + // ── mutations ───────────────────────────────────────────────────── - const addMutation = useMutation({ + /** Add an existing test directly to the campaign */ + const addExistingMutation = useMutation({ mutationFn: (testId: string) => addTestToCampaign(campaignId, { test_id: testId }), onSuccess: (_data, testId) => { setAddedIds((prev) => new Set(prev).add(testId)); + setAddedCount((n) => n + 1); queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] }); onSuccess(); }, }); + /** Create test from template, then add to campaign */ + const createAndAddMutation = useMutation({ + mutationFn: async () => { + if (!fullTemplate) throw new Error("No template loaded"); + const test = await createTestFromTemplate( + selectedTemplateId!, + fullTemplate.mitre_technique_id, + { + name: formName.trim() || undefined, + description: formDescription.trim() || undefined, + platform: formPlatform.trim() || undefined, + procedure_text: formProcedure.trim() || undefined, + tool_used: formTool.trim() || undefined, + }, + ); + await addTestToCampaign(campaignId, { test_id: test.id }); + return test; + }, + onSuccess: () => { + setAddedCount((n) => n + 1); + queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] }); + onSuccess(); + // Back to template list, ready to add another + setTemplateStep("list"); + setSelectedTemplateId(null); + }, + }); + + // ── derived data ────────────────────────────────────────────────── + + const filteredExisting = useMemo(() => { + if (!allTests) return []; + const alreadyIn = new Set([...existingTestIds, ...addedIds]); + let list = allTests.filter((t) => !alreadyIn.has(t.id)); + if (existingSearch.trim()) { + const q = existingSearch.toLowerCase(); + list = list.filter( + (t) => + t.name.toLowerCase().includes(q) || + (t.technique_mitre_id && t.technique_mitre_id.toLowerCase().includes(q)) || + (t.technique_name && t.technique_name.toLowerCase().includes(q)), + ); + } + return list; + }, [allTests, existingSearch, existingTestIds, addedIds]); + + const canSubmitForm = + formName.trim().length > 0 && !!fullTemplate && !createAndAddMutation.isPending; + if (!open) return null; + /* ── render ──────────────────────────────────────────────────── */ + return (
-
- {/* Header */} +
+ + {/* ── Header ── */}
-

- Add Tests to Campaign -

+ {templateStep === "form" ? ( + + ) : ( +

Add Test to Campaign

+ )}
- {/* Search */} -
-
- - setSearchText(e.target.value)} - placeholder="Search tests by name or technique..." - autoFocus - className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-9 pr-3 py-2.5 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" - /> + {/* ── Tab bar (only on list step) ── */} + {templateStep === "list" && ( +
+ {(["template", "existing"] as Tab[]).map((t) => ( + + ))}
-
+ )} - {/* Test list */} -
- {isLoading ? ( -
- -
- ) : filteredTests.length === 0 ? ( -
- -

- {searchText - ? "No tests match your search." - : "All available tests are already in this campaign."} -

-
- ) : ( -
- {filteredTests.map((test: Test) => ( -
-
-
- - {test.name} - - + + {/* ═══ TEMPLATE TAB — LIST ══════════════════════════════ */} + {tab === "template" && templateStep === "list" && ( + <> + {/* Filters */} +
+
+ + setTemplateSearch(e.target.value)} + placeholder="Search templates by name or technique…" + className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-9 pr-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" + /> +
+
+
+ +
+ + +
+
+ + {/* Template list */} +
+ {templatesLoading ? ( +
+ +
+ ) : !templates?.length ? ( +
+ +

No templates match your filters.

+
+ ) : ( +
+ {templates.map((tmpl: TestTemplateSummary) => ( + + ))} +
+ )} +
+ + )} + + {/* ═══ TEMPLATE TAB — FORM ══════════════════════════════ */} + {tab === "template" && templateStep === "form" && ( +
+ {fullTemplateLoading ? ( +
+ +
+ ) : ( + <> + {/* Template info banner */} + {fullTemplate && ( +
+ Template: {fullTemplate.name} + + {fullTemplate.mitre_technique_id} -
-
- {test.technique_mitre_id && ( - - {test.technique_mitre_id} + {fullTemplate.source && ( + + {SOURCE_LABELS[fullTemplate.source] ?? fullTemplate.source} )} - {test.technique_name && ( - {test.technique_name} - )} - {test.platform && ( - {test.platform} - )}
+ )} + + {/* Name */} +
+ + setFormName(e.target.value)} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" + placeholder="Test name" + />
- -
- ))} + + {/* Platform */} +
+ + setFormPlatform(e.target.value)} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" + placeholder="e.g. windows, linux, macos" + /> +
+ + {/* Description */} +
+ +