feat(tests): Save as Template button on test detail page
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{canSaveAsTemplate && (
|
||||
<div className="mt-4 border-t border-gray-800 pt-4">
|
||||
<button
|
||||
onClick={() => setShowTemplateModal(true)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
|
||||
>
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
Save as Template
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Retest Chain */}
|
||||
@@ -595,6 +614,18 @@ export default function TestDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save as Template Modal */}
|
||||
{showTemplateModal && (
|
||||
<SaveAsTemplateModal
|
||||
test={test}
|
||||
onClose={() => setShowTemplateModal(false)}
|
||||
onSaved={() => {
|
||||
setShowTemplateModal(false);
|
||||
showToast("Template created successfully", "success");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast notification */}
|
||||
{toast && (
|
||||
<div
|
||||
@@ -610,3 +641,190 @@ export default function TestDetailPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-800 bg-gray-900 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-cyan-400" />
|
||||
<h3 className="text-lg font-semibold text-white">Save as Template</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* Technique info banner */}
|
||||
<div className="rounded-lg border border-cyan-500/20 bg-cyan-900/10 px-4 py-2.5 text-xs text-cyan-400">
|
||||
Technique:{" "}
|
||||
<span className="font-mono font-semibold">{test.technique_mitre_id}</span>
|
||||
{test.technique_name && (
|
||||
<span className="ml-2 text-gray-400">{test.technique_name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||
Template Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platform + Severity row */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">Platform</label>
|
||||
<input
|
||||
value={platform}
|
||||
onChange={(e) => 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…"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">Severity</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={(e) => setSeverity(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
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="What does this template test?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attack Procedure */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">Attack Procedure</label>
|
||||
<textarea
|
||||
value={attackProcedure}
|
||||
onChange={(e) => setAttackProcedure(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 font-mono text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||||
placeholder="Steps to execute the attack…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">Suggested Tool</label>
|
||||
<input
|
||||
value={toolSuggested}
|
||||
onChange={(e) => setToolSuggested(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. Atomic Red Team, Cobalt Strike"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expected Detection (new — not in the test) */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||
Expected Detection
|
||||
<span className="ml-2 text-xs text-gray-500">(guidance for Blue Team)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={expectedDetection}
|
||||
onChange={(e) => setExpectedDetection(e.target.value)}
|
||||
rows={3}
|
||||
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="What alerts or logs should Blue Team look for?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{saveMutation.isError && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
|
||||
{(saveMutation.error as Error)?.message || "Failed to create template"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 flex justify-end gap-3 border-t border-gray-800 bg-gray-900 px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!canSubmit || saveMutation.isPending}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
)}
|
||||
Save Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user