From 9310652944d814eaca382a8c4921a2ea88e0b272 Mon Sep 17 00:00:00 2001 From: kitos Date: Fri, 29 May 2026 12:57:29 +0200 Subject: [PATCH] feat(tests): Save as Template button on test detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'Save as Template' button in the Details sidebar (visible to red_lead, blue_lead and admin only). Opens a modal pre-filled from the test's own fields: test.name → template name test.description → description test.platform → platform test.procedure_text → attack_procedure test.tool_used → tool_suggested test.technique_mitre_id → mitre_technique_id User can also set severity and write expected_detection (Blue Team guidance — not stored on tests). Calls POST /test-templates with source='custom' on submit. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/TestDetailPage.tsx | 220 +++++++++++++++++++++++++- 1 file changed, 219 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/TestDetailPage.tsx b/frontend/src/pages/TestDetailPage.tsx index 8582b39..b5895ab 100644 --- a/frontend/src/pages/TestDetailPage.tsx +++ b/frontend/src/pages/TestDetailPage.tsx @@ -2,7 +2,7 @@ import { useParams, useNavigate } from "react-router-dom"; import MarkdownText from "../components/MarkdownText"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useState, useEffect, useCallback } from "react"; -import { Loader2, AlertCircle, ArrowLeft } from "lucide-react"; +import { Loader2, AlertCircle, ArrowLeft, BookOpen, X, CheckCircle } from "lucide-react"; import { getTestById, @@ -30,6 +30,7 @@ import ValidationModal from "../components/test-detail/ValidationModal"; import ConfirmDialog from "../components/ConfirmDialog"; import JiraLinkPanel from "../components/JiraLinkPanel"; import TestPhaseTimeline from "../components/TestPhaseTimeline"; +import { createTemplate } from "../api/test-templates"; // ── Page Component ───────────────────────────────────────────────── @@ -361,6 +362,12 @@ export default function TestDetailPage() { test.state === "blue_evaluating" && (role === "blue_tech" || role === "blue_lead" || role === "admin"); + // Only leads and admins can create templates + const canSaveAsTemplate = + role === "red_lead" || role === "blue_lead" || role === "admin"; + + const [showTemplateModal, setShowTemplateModal] = useState(false); + // ── Render ───────────────────────────────────────────────────── return ( @@ -507,6 +514,18 @@ export default function TestDetailPage() { )} + + {canSaveAsTemplate && ( +
+ +
+ )} {/* Retest Chain */} @@ -595,6 +614,18 @@ export default function TestDetailPage() { /> )} + {/* Save as Template Modal */} + {showTemplateModal && ( + setShowTemplateModal(false)} + onSaved={() => { + setShowTemplateModal(false); + showToast("Template created successfully", "success"); + }} + /> + )} + {/* Toast notification */} {toast && (
); } + +/* ── SaveAsTemplateModal ─────────────────────────────────────────────── + Pre-fills template fields from an existing test. + Lets the user review / tweak before saving. + ────────────────────────────────────────────────────────────────── */ +function SaveAsTemplateModal({ + test, + onClose, + onSaved, +}: { + test: { name: string; description: string | null; platform: string | null; procedure_text: string | null; tool_used: string | null; technique_mitre_id: string | null; technique_name?: string | null }; + onClose: () => void; + onSaved: () => void; +}) { + const [name, setName] = useState(test.name); + const [description, setDescription] = useState(test.description ?? ""); + const [platform, setPlatform] = useState(test.platform ?? ""); + const [attackProcedure, setAttackProcedure] = useState(test.procedure_text ?? ""); + const [toolSuggested, setToolSuggested] = useState(test.tool_used ?? ""); + const [expectedDetection, setExpectedDetection] = useState(""); + const [severity, setSeverity] = useState("medium"); + + const saveMutation = useMutation({ + mutationFn: () => + createTemplate({ + mitre_technique_id: test.technique_mitre_id ?? "", + name: name.trim(), + description: description.trim() || undefined, + platform: platform.trim() || undefined, + attack_procedure: attackProcedure.trim() || undefined, + tool_suggested: toolSuggested.trim() || undefined, + expected_detection: expectedDetection.trim() || undefined, + severity: severity || undefined, + source: "custom", + }), + onSuccess: () => onSaved(), + }); + + const canSubmit = name.trim().length > 0 && !!test.technique_mitre_id; + + return ( +
+
+ {/* Header */} +
+
+ +

Save as Template

+
+ +
+ + {/* Body */} +
+ {/* Technique info banner */} +
+ Technique:{" "} + {test.technique_mitre_id} + {test.technique_name && ( + {test.technique_name} + )} +
+ + {/* Name */} +
+ + setName(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="Template name" + /> +
+ + {/* Platform + Severity row */} +
+
+ + setPlatform(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="windows, linux, macos…" + /> +
+
+ + +
+
+ + {/* Description */} +
+ +