feat(campaigns): add 'From Template' tab in Add Test modal
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
@@ -7,10 +7,18 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
|
BookOpen,
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTests } from "../api/tests";
|
import { getTests } from "../api/tests";
|
||||||
import { addTestToCampaign } from "../api/campaigns";
|
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<TestState, string> = {
|
const stateBadge: Record<TestState, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
@@ -21,6 +29,24 @@ const stateBadge: Record<TestState, string> = {
|
|||||||
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SEVERITY_COLORS: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
atomic_red_team: "Atomic Red Team",
|
||||||
|
mitre: "MITRE",
|
||||||
|
custom: "Custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Tab = "existing" | "template";
|
||||||
|
type TemplateStep = "list" | "form";
|
||||||
|
|
||||||
|
/* ── props ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface AddTestToCampaignModalProps {
|
interface AddTestToCampaignModalProps {
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
existingTestIds: string[];
|
existingTestIds: string[];
|
||||||
@@ -29,6 +55,8 @@ interface AddTestToCampaignModalProps {
|
|||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── component ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export default function AddTestToCampaignModal({
|
export default function AddTestToCampaignModal({
|
||||||
campaignId,
|
campaignId,
|
||||||
existingTestIds,
|
existingTestIds,
|
||||||
@@ -37,53 +65,167 @@ export default function AddTestToCampaignModal({
|
|||||||
onSuccess,
|
onSuccess,
|
||||||
}: AddTestToCampaignModalProps) {
|
}: AddTestToCampaignModalProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchText, setSearchText] = useState("");
|
|
||||||
|
// ── shared state ────────────────────────────────────────────────
|
||||||
|
const [tab, setTab] = useState<Tab>("template");
|
||||||
|
const [addedCount, setAddedCount] = useState(0);
|
||||||
|
|
||||||
|
// ── existing-test tab ───────────────────────────────────────────
|
||||||
|
const [existingSearch, setExistingSearch] = useState("");
|
||||||
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
|
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const { data: allTests, isLoading } = useQuery({
|
// ── template tab ────────────────────────────────────────────────
|
||||||
|
const [templateStep, setTemplateStep] = useState<TemplateStep>("list");
|
||||||
|
const [templateSearch, setTemplateSearch] = useState("");
|
||||||
|
const [filterPlatform, setFilterPlatform] = useState("");
|
||||||
|
const [filterSeverity, setFilterSeverity] = useState("");
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(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"],
|
queryKey: ["tests", "for-campaign-picker"],
|
||||||
queryFn: () => getTests({ limit: 200 }),
|
queryFn: () => getTests({ limit: 200 }),
|
||||||
enabled: open,
|
enabled: open && tab === "existing",
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTests = useMemo(() => {
|
const { data: templates, isLoading: templatesLoading } = useQuery({
|
||||||
if (!allTests) return [];
|
queryKey: ["templates", "picker", filterPlatform, filterSeverity, templateSearch],
|
||||||
const alreadyIn = new Set([...existingTestIds, ...addedIds]);
|
queryFn: () =>
|
||||||
let results = allTests.filter((t) => !alreadyIn.has(t.id));
|
getTemplates({
|
||||||
|
search: templateSearch || undefined,
|
||||||
|
platform: filterPlatform || undefined,
|
||||||
|
severity: filterSeverity || undefined,
|
||||||
|
limit: 100,
|
||||||
|
}),
|
||||||
|
enabled: open && tab === "template",
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
if (searchText.trim()) {
|
const { data: fullTemplate, isLoading: fullTemplateLoading } = useQuery({
|
||||||
const q = searchText.toLowerCase();
|
queryKey: ["template-detail", selectedTemplateId],
|
||||||
results = results.filter(
|
queryFn: () => getTemplateById(selectedTemplateId!),
|
||||||
(t) =>
|
enabled: !!selectedTemplateId,
|
||||||
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))
|
// 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;
|
// ── mutations ─────────────────────────────────────────────────────
|
||||||
}, [allTests, searchText, existingTestIds, addedIds]);
|
|
||||||
|
|
||||||
const addMutation = useMutation({
|
/** Add an existing test directly to the campaign */
|
||||||
|
const addExistingMutation = useMutation({
|
||||||
mutationFn: (testId: string) =>
|
mutationFn: (testId: string) =>
|
||||||
addTestToCampaign(campaignId, { test_id: testId }),
|
addTestToCampaign(campaignId, { test_id: testId }),
|
||||||
onSuccess: (_data, testId) => {
|
onSuccess: (_data, testId) => {
|
||||||
setAddedIds((prev) => new Set(prev).add(testId));
|
setAddedIds((prev) => new Set(prev).add(testId));
|
||||||
|
setAddedCount((n) => n + 1);
|
||||||
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||||||
onSuccess();
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
|
/* ── render ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-2xl rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
|
<div className="flex w-full max-w-2xl flex-col rounded-xl border border-gray-700 bg-gray-900 shadow-2xl"
|
||||||
{/* Header */}
|
style={{ maxHeight: "90vh" }}>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-white">
|
{templateStep === "form" ? (
|
||||||
Add Tests to Campaign
|
<button
|
||||||
</h2>
|
onClick={() => { setTemplateStep("list"); setSelectedTemplateId(null); }}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to templates
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<h2 className="text-lg font-semibold text-white">Add Test to Campaign</h2>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
|
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
|
||||||
@@ -92,104 +234,361 @@ export default function AddTestToCampaignModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* ── Tab bar (only on list step) ── */}
|
||||||
<div className="border-b border-gray-800 px-6 py-3">
|
{templateStep === "list" && (
|
||||||
<div className="relative">
|
<div className="flex border-b border-gray-800">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
{(["template", "existing"] as Tab[]).map((t) => (
|
||||||
<input
|
<button
|
||||||
type="text"
|
key={t}
|
||||||
value={searchText}
|
onClick={() => setTab(t)}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium transition-colors border-b-2 ${
|
||||||
placeholder="Search tests by name or technique..."
|
tab === t
|
||||||
autoFocus
|
? "border-cyan-500 text-cyan-400"
|
||||||
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"
|
: "border-transparent text-gray-500 hover:text-gray-300"
|
||||||
/>
|
}`}
|
||||||
|
>
|
||||||
|
{t === "template" ? (
|
||||||
|
<><BookOpen className="h-4 w-4" /> From Template</>
|
||||||
|
) : (
|
||||||
|
<><FlaskConical className="h-4 w-4" /> Existing Test</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Test list */}
|
{/* ── Body ── */}
|
||||||
<div className="max-h-[400px] overflow-y-auto px-6 py-3">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
{/* ═══ TEMPLATE TAB — LIST ══════════════════════════════ */}
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
{tab === "template" && templateStep === "list" && (
|
||||||
</div>
|
<>
|
||||||
) : filteredTests.length === 0 ? (
|
{/* Filters */}
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
<div className="border-b border-gray-800 px-5 py-3 space-y-2">
|
||||||
<FlaskConical className="mb-2 h-8 w-8 text-gray-600" />
|
<div className="relative">
|
||||||
<p className="text-sm">
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
{searchText
|
<input
|
||||||
? "No tests match your search."
|
type="text"
|
||||||
: "All available tests are already in this campaign."}
|
value={templateSearch}
|
||||||
</p>
|
onChange={(e) => setTemplateSearch(e.target.value)}
|
||||||
</div>
|
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"
|
||||||
<div className="space-y-1">
|
/>
|
||||||
{filteredTests.map((test: Test) => (
|
</div>
|
||||||
<div
|
<div className="flex gap-2">
|
||||||
key={test.id}
|
<div className="flex items-center gap-1.5 text-gray-500">
|
||||||
className="flex items-center justify-between rounded-lg border border-gray-800 px-4 py-3 hover:border-gray-700 hover:bg-gray-800/50 transition-colors"
|
<Filter className="h-3.5 w-3.5" />
|
||||||
>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<select
|
||||||
<div className="flex items-center gap-2">
|
value={filterPlatform}
|
||||||
<span className="text-sm font-medium text-gray-200 truncate">
|
onChange={(e) => setFilterPlatform(e.target.value)}
|
||||||
{test.name}
|
className="rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1.5 text-xs text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||||
</span>
|
>
|
||||||
<span
|
<option value="">All platforms</option>
|
||||||
className={`inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium ${
|
<option value="windows">Windows</option>
|
||||||
stateBadge[test.state]
|
<option value="linux">Linux</option>
|
||||||
}`}
|
<option value="macos">macOS</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterSeverity}
|
||||||
|
onChange={(e) => setFilterSeverity(e.target.value)}
|
||||||
|
className="rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1.5 text-xs text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">All severities</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template list */}
|
||||||
|
<div className="px-5 py-3">
|
||||||
|
{templatesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
) : !templates?.length ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||||
|
<BookOpen className="mb-2 h-8 w-8 text-gray-700" />
|
||||||
|
<p className="text-sm">No templates match your filters.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{templates.map((tmpl: TestTemplateSummary) => (
|
||||||
|
<button
|
||||||
|
key={tmpl.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTemplateId(tmpl.id);
|
||||||
|
setTemplateStep("form");
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-between rounded-lg border border-gray-800 px-4 py-3 text-left hover:border-gray-700 hover:bg-gray-800/50 transition-colors"
|
||||||
>
|
>
|
||||||
{test.state.replace(/_/g, " ")}
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium text-gray-200 truncate">
|
||||||
|
{tmpl.name}
|
||||||
|
</span>
|
||||||
|
{tmpl.severity && (
|
||||||
|
<span className={`inline-flex rounded-full border px-1.5 py-0.5 text-[10px] font-medium capitalize ${SEVERITY_COLORS[tmpl.severity] ?? ""}`}>
|
||||||
|
{tmpl.severity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
|
||||||
|
<span className="font-mono text-cyan-400/70">
|
||||||
|
{tmpl.mitre_technique_id}
|
||||||
|
</span>
|
||||||
|
{tmpl.platform && (
|
||||||
|
<span className="capitalize">{tmpl.platform}</span>
|
||||||
|
)}
|
||||||
|
{tmpl.source && (
|
||||||
|
<span>{SOURCE_LABELS[tmpl.source] ?? tmpl.source}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-gray-600 ml-2" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ TEMPLATE TAB — FORM ══════════════════════════════ */}
|
||||||
|
{tab === "template" && templateStep === "form" && (
|
||||||
|
<div className="px-6 py-5 space-y-4">
|
||||||
|
{fullTemplateLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Template info banner */}
|
||||||
|
{fullTemplate && (
|
||||||
|
<div className="rounded-lg border border-cyan-500/20 bg-cyan-900/10 px-4 py-3 text-xs text-cyan-400">
|
||||||
|
Template: <strong>{fullTemplate.name}</strong>
|
||||||
|
<span className="ml-2 font-mono text-gray-500">
|
||||||
|
{fullTemplate.mitre_technique_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{fullTemplate.source && (
|
||||||
<div className="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
|
<span className="ml-2 rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-gray-400">
|
||||||
{test.technique_mitre_id && (
|
{SOURCE_LABELS[fullTemplate.source] ?? fullTemplate.source}
|
||||||
<span className="font-mono text-cyan-400/70">
|
|
||||||
{test.technique_mitre_id}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{test.technique_name && (
|
|
||||||
<span className="truncate">{test.technique_name}</span>
|
|
||||||
)}
|
|
||||||
{test.platform && (
|
|
||||||
<span className="capitalize">{test.platform}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
Test Name <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => addMutation.mutate(test.id)}
|
{/* Platform */}
|
||||||
disabled={addMutation.isPending && addMutation.variables === test.id}
|
<div>
|
||||||
className="ml-3 flex shrink-0 items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-xs font-medium text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50 transition-colors"
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Platform</label>
|
||||||
>
|
<input
|
||||||
{addMutation.isPending && addMutation.variables === test.id ? (
|
value={formPlatform}
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
onChange={(e) => 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"
|
||||||
<Plus className="h-3.5 w-3.5" />
|
placeholder="e.g. windows, linux, macos"
|
||||||
)}
|
/>
|
||||||
Add
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
{/* Description */}
|
||||||
))}
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={formDescription}
|
||||||
|
onChange={(e) => setFormDescription(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="Optional description…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attack Procedure */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
Attack Procedure
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formProcedure}
|
||||||
|
onChange={(e) => setFormProcedure(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
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={formTool}
|
||||||
|
onChange={(e) => setFormTool(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 — read-only reference */}
|
||||||
|
{fullTemplate?.expected_detection && (
|
||||||
|
<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">(reference — Blue Team)</span>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3">
|
||||||
|
<p className="whitespace-pre-wrap font-mono text-xs text-gray-400">
|
||||||
|
{fullTemplate.expected_detection}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{createAndAddMutation.isError && (
|
||||||
|
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
|
||||||
|
{(createAndAddMutation.error as Error)?.message || "Failed to create test"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ═══ EXISTING TESTS TAB ═══════════════════════════════ */}
|
||||||
|
{tab === "existing" && (
|
||||||
|
<>
|
||||||
|
<div className="border-b border-gray-800 px-6 py-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={existingSearch}
|
||||||
|
onChange={(e) => setExistingSearch(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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-3">
|
||||||
|
{testsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
) : filteredExisting.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||||
|
<FlaskConical className="mb-2 h-8 w-8 text-gray-600" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{existingSearch
|
||||||
|
? "No tests match your search."
|
||||||
|
: "No available tests to add."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredExisting.map((test: Test) => (
|
||||||
|
<div
|
||||||
|
key={test.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-gray-800 px-4 py-3 hover:border-gray-700 hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-200 truncate">
|
||||||
|
{test.name}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium ${stateBadge[test.state]}`}>
|
||||||
|
{test.state.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
|
||||||
|
{test.technique_mitre_id && (
|
||||||
|
<span className="font-mono text-cyan-400/70">{test.technique_mitre_id}</span>
|
||||||
|
)}
|
||||||
|
{test.technique_name && (
|
||||||
|
<span className="truncate">{test.technique_name}</span>
|
||||||
|
)}
|
||||||
|
{test.platform && <span className="capitalize">{test.platform}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => addExistingMutation.mutate(test.id)}
|
||||||
|
disabled={addExistingMutation.isPending && addExistingMutation.variables === test.id}
|
||||||
|
className="ml-3 flex shrink-0 items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-xs font-medium text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{addExistingMutation.isPending && addExistingMutation.variables === test.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* ── Footer ── */}
|
||||||
<div className="flex items-center justify-between border-t border-gray-800 px-6 py-4">
|
<div className="flex items-center justify-between border-t border-gray-800 px-6 py-4">
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{addedIds.size > 0 && (
|
{addedCount > 0 && (
|
||||||
<span className="flex items-center gap-1 text-green-400">
|
<span className="flex items-center gap-1 text-green-400">
|
||||||
<CheckCircle className="h-3.5 w-3.5" />
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
{addedIds.size} test{addedIds.size !== 1 ? "s" : ""} added
|
{addedCount} test{addedCount !== 1 ? "s" : ""} added
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={onClose}
|
{/* Template form submit */}
|
||||||
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 transition-colors"
|
{tab === "template" && templateStep === "form" ? (
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
Done
|
<button
|
||||||
</button>
|
onClick={() => { setTemplateStep("list"); setSelectedTemplateId(null); }}
|
||||||
|
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => createAndAddMutation.mutate()}
|
||||||
|
disabled={!canSubmitForm}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{createAndAddMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Create & Add to Campaign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user