d49c4aa009
T-119: TestCatalogPage with search, filters (source/platform/severity), template cards grid, and pagination T-120: TestFromTemplateForm modal with pre-filled fields from template, required field validation, and redirect on creation T-121: Integrate Available Test Templates section in TechniqueDetailPage with Run This Test buttons; fix missing testStateBadgeColors for new states Also: add backend entrypoint.sh for automatic Alembic migrations + seed on container startup, add curl to Dockerfile for healthcheck
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import {
|
|
Loader2,
|
|
FlaskConical,
|
|
X,
|
|
AlertCircle,
|
|
BookOpen,
|
|
} from "lucide-react";
|
|
import { getTemplateById } from "../api/test-templates";
|
|
import { createTestFromTemplate } from "../api/tests";
|
|
import type { TestTemplate } from "../types/models";
|
|
|
|
// ── Props ──────────────────────────────────────────────────────────
|
|
|
|
interface TestFromTemplateFormProps {
|
|
templateId: string;
|
|
/** If provided, pre-selects the technique (from TechniqueDetailPage). */
|
|
techniqueId?: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
// ── Component ──────────────────────────────────────────────────────
|
|
|
|
export default function TestFromTemplateForm({
|
|
templateId,
|
|
techniqueId: propTechniqueId,
|
|
onClose,
|
|
}: TestFromTemplateFormProps) {
|
|
const navigate = useNavigate();
|
|
|
|
// ── Load template details ──────────────────────────────────────
|
|
|
|
const {
|
|
data: template,
|
|
isLoading,
|
|
error,
|
|
} = useQuery({
|
|
queryKey: ["test-template", templateId],
|
|
queryFn: () => getTemplateById(templateId),
|
|
enabled: !!templateId,
|
|
});
|
|
|
|
// ── Form state (pre-filled from template) ──────────────────────
|
|
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [technique, setTechnique] = useState(propTechniqueId || "");
|
|
const [platform, setPlatform] = useState("");
|
|
const [procedureText, setProcedureText] = useState("");
|
|
const [toolUsed, setToolUsed] = useState("");
|
|
const [expectedDetection, setExpectedDetection] = useState("");
|
|
|
|
// Hydrate form when template loads
|
|
useEffect(() => {
|
|
if (template) {
|
|
setName(template.name || "");
|
|
setDescription(template.description || "");
|
|
setTechnique(propTechniqueId || template.mitre_technique_id || "");
|
|
setPlatform(template.platform || "");
|
|
setProcedureText(template.attack_procedure || "");
|
|
setToolUsed(template.tool_suggested || "");
|
|
setExpectedDetection(template.expected_detection || "");
|
|
}
|
|
}, [template, propTechniqueId]);
|
|
|
|
// ── Submit ─────────────────────────────────────────────────────
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: () => createTestFromTemplate(templateId, technique),
|
|
onSuccess: (test) => {
|
|
navigate(`/tests/${test.id}`);
|
|
},
|
|
});
|
|
|
|
const canSubmit = name.trim().length > 0 && technique.trim().length > 0;
|
|
|
|
// ── Render ─────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-gray-800 bg-gray-900 shadow-xl">
|
|
{/* 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">Create Test from 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 */}
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex flex-col items-center justify-center py-16">
|
|
<AlertCircle className="h-10 w-10 text-red-400" />
|
|
<p className="mt-2 text-sm text-red-400">Failed to load template</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-5 px-6 py-5">
|
|
{/* Template info banner */}
|
|
<div className="rounded-lg border border-cyan-500/20 bg-cyan-900/10 p-3">
|
|
<p className="text-xs text-cyan-400">
|
|
Pre-filled from template: <strong>{template?.name}</strong>
|
|
{template?.source && (
|
|
<span className="ml-2 rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-[10px] text-gray-400">
|
|
{template.source}
|
|
</span>
|
|
)}
|
|
</p>
|
|
</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={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 focus:ring-1 focus:ring-cyan-500"
|
|
placeholder="Test name"
|
|
/>
|
|
</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={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 focus:ring-1 focus:ring-cyan-500"
|
|
placeholder="Test description..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Technique ID */}
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
|
MITRE Technique ID <span className="text-red-400">*</span>
|
|
</label>
|
|
<input
|
|
value={technique}
|
|
onChange={(e) => setTechnique(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 focus:ring-1 focus:ring-cyan-500"
|
|
placeholder="e.g. T1059.001"
|
|
readOnly={!!propTechniqueId}
|
|
/>
|
|
{propTechniqueId && (
|
|
<p className="mt-1 text-xs text-gray-500">Pre-selected from technique page</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Platform */}
|
|
<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 focus:ring-1 focus:ring-cyan-500"
|
|
placeholder="e.g. windows, linux, macos"
|
|
/>
|
|
</div>
|
|
|
|
{/* Attack Procedure */}
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
|
Suggested Attack Procedure
|
|
</label>
|
|
<textarea
|
|
value={procedureText}
|
|
onChange={(e) => setProcedureText(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 focus:ring-1 focus:ring-cyan-500"
|
|
placeholder="Attack procedure..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Tool Suggested */}
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
|
Suggested Tool
|
|
</label>
|
|
<input
|
|
value={toolUsed}
|
|
onChange={(e) => setToolUsed(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 focus:ring-1 focus:ring-cyan-500"
|
|
placeholder="e.g. Atomic Red Team, Cobalt Strike"
|
|
/>
|
|
</div>
|
|
|
|
{/* Expected Detection (read-only reference for Blue Team) */}
|
|
<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">(read-only reference for 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-sm text-gray-400">
|
|
{expectedDetection || "No detection guidance provided in template."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{createMutation.isError && (
|
|
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
|
|
{(createMutation.error as Error)?.message || "Failed to create test"}
|
|
</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={() => createMutation.mutate()}
|
|
disabled={!canSubmit || createMutation.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"
|
|
>
|
|
{createMutation.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<FlaskConical className="h-4 w-4" />
|
|
)}
|
|
Create Test
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|