feat(tests): Save as Template button on test detail page
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:
kitos
2026-05-29 12:57:29 +02:00
parent 193c48d031
commit 9310652944

View File

@@ -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>
);
}