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 MarkdownText from "../components/MarkdownText";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Loader2, AlertCircle, ArrowLeft } from "lucide-react";
|
import { Loader2, AlertCircle, ArrowLeft, BookOpen, X, CheckCircle } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getTestById,
|
getTestById,
|
||||||
@@ -30,6 +30,7 @@ import ValidationModal from "../components/test-detail/ValidationModal";
|
|||||||
import ConfirmDialog from "../components/ConfirmDialog";
|
import ConfirmDialog from "../components/ConfirmDialog";
|
||||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||||
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
||||||
|
import { createTemplate } from "../api/test-templates";
|
||||||
|
|
||||||
// ── Page Component ─────────────────────────────────────────────────
|
// ── Page Component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -361,6 +362,12 @@ export default function TestDetailPage() {
|
|||||||
test.state === "blue_evaluating" &&
|
test.state === "blue_evaluating" &&
|
||||||
(role === "blue_tech" || role === "blue_lead" || role === "admin");
|
(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 ─────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -507,6 +514,18 @@ export default function TestDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Retest Chain */}
|
{/* 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 notification */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<div
|
<div
|
||||||
@@ -610,3 +641,190 @@ export default function TestDetailPage() {
|
|||||||
</div>
|
</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