Implement all main frontend views for the MITRE ATT&CK coverage platform: - T-026: Dashboard with coverage summary cards and tactic breakdown table - T-027: Interactive ATT&CK matrix with filtering by status, tactic, platform - T-028: Technique detail page with tests, intel items, and review actions - T-029: Test creation form with technique selector and validation - T-030: Test detail page with drag and drop evidence upload and download - T-031: System admin panel with MITRE sync and intel scan controls New components: CoverageSummaryCard, TacticCoverageChart, AttackMatrix, TechniqueCell, TestForm, EvidenceUpload, EvidenceList New API modules: metrics.ts, techniques.ts, tests.ts, evidence.ts, system.ts All views use TanStack Query for data fetching with proper loading and error states. Role-based UI controls for admin/lead actions.
259 lines
8.4 KiB
TypeScript
259 lines
8.4 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Loader2 } from "lucide-react";
|
|
import { getTechniques, type TechniqueSummary } from "../api/techniques";
|
|
import type { TestResult } from "../types/models";
|
|
|
|
export interface TestFormData {
|
|
technique_id: string;
|
|
name: string;
|
|
description: string;
|
|
platform: string;
|
|
procedure_text: string;
|
|
tool_used: string;
|
|
result?: TestResult;
|
|
}
|
|
|
|
interface TestFormProps {
|
|
initialData?: Partial<TestFormData>;
|
|
preselectedTechniqueId?: string;
|
|
onSubmit: (data: TestFormData) => void;
|
|
isSubmitting: boolean;
|
|
showResult?: boolean;
|
|
}
|
|
|
|
const PLATFORMS = [
|
|
{ value: "", label: "Select platform" },
|
|
{ value: "windows", label: "Windows" },
|
|
{ value: "linux", label: "Linux" },
|
|
{ value: "macos", label: "macOS" },
|
|
{ value: "cloud", label: "Cloud" },
|
|
{ value: "network", label: "Network" },
|
|
];
|
|
|
|
const RESULTS: { value: TestResult | ""; label: string }[] = [
|
|
{ value: "", label: "Select result (optional)" },
|
|
{ value: "detected", label: "Detected" },
|
|
{ value: "not_detected", label: "Not Detected" },
|
|
{ value: "partially_detected", label: "Partially Detected" },
|
|
];
|
|
|
|
export default function TestForm({
|
|
initialData,
|
|
preselectedTechniqueId,
|
|
onSubmit,
|
|
isSubmitting,
|
|
showResult = false,
|
|
}: TestFormProps) {
|
|
const [formData, setFormData] = useState<TestFormData>({
|
|
technique_id: preselectedTechniqueId || initialData?.technique_id || "",
|
|
name: initialData?.name || "",
|
|
description: initialData?.description || "",
|
|
platform: initialData?.platform || "",
|
|
procedure_text: initialData?.procedure_text || "",
|
|
tool_used: initialData?.tool_used || "",
|
|
result: initialData?.result,
|
|
});
|
|
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
const { data: techniques, isLoading: techniquesLoading } = useQuery({
|
|
queryKey: ["techniques"],
|
|
queryFn: () => getTechniques(),
|
|
});
|
|
|
|
// Update technique_id when preselected changes
|
|
useEffect(() => {
|
|
if (preselectedTechniqueId) {
|
|
setFormData((prev) => ({ ...prev, technique_id: preselectedTechniqueId }));
|
|
}
|
|
}, [preselectedTechniqueId]);
|
|
|
|
const handleChange = (
|
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
|
) => {
|
|
const { name, value } = e.target;
|
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
// Clear error when user starts typing
|
|
if (errors[name]) {
|
|
setErrors((prev) => ({ ...prev, [name]: "" }));
|
|
}
|
|
};
|
|
|
|
const validate = (): boolean => {
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
if (!formData.technique_id) {
|
|
newErrors.technique_id = "Technique is required";
|
|
}
|
|
if (!formData.name.trim()) {
|
|
newErrors.name = "Name is required";
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (validate()) {
|
|
onSubmit({
|
|
...formData,
|
|
result: formData.result || undefined,
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Technique Selector */}
|
|
<div>
|
|
<label htmlFor="technique_id" className="block text-sm font-medium text-gray-300">
|
|
Technique *
|
|
</label>
|
|
<select
|
|
id="technique_id"
|
|
name="technique_id"
|
|
value={formData.technique_id}
|
|
onChange={handleChange}
|
|
disabled={!!preselectedTechniqueId || techniquesLoading}
|
|
className={`mt-1 block w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-cyan-500 ${
|
|
errors.technique_id ? "border-red-500" : "border-gray-700"
|
|
} ${preselectedTechniqueId ? "opacity-70" : ""}`}
|
|
>
|
|
<option value="">Select a technique</option>
|
|
{techniques?.map((tech: TechniqueSummary) => (
|
|
<option key={tech.id} value={tech.id}>
|
|
{tech.mitre_id} - {tech.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{errors.technique_id && (
|
|
<p className="mt-1 text-sm text-red-400">{errors.technique_id}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<div>
|
|
<label htmlFor="name" className="block text-sm font-medium text-gray-300">
|
|
Name *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="name"
|
|
name="name"
|
|
value={formData.name}
|
|
onChange={handleChange}
|
|
placeholder="Enter test name"
|
|
className={`mt-1 block w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 ${
|
|
errors.name ? "border-red-500" : "border-gray-700"
|
|
}`}
|
|
/>
|
|
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name}</p>}
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label htmlFor="description" className="block text-sm font-medium text-gray-300">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
id="description"
|
|
name="description"
|
|
value={formData.description}
|
|
onChange={handleChange}
|
|
rows={3}
|
|
placeholder="Describe the test objective and scope"
|
|
className="mt-1 block w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Platform */}
|
|
<div>
|
|
<label htmlFor="platform" className="block text-sm font-medium text-gray-300">
|
|
Platform
|
|
</label>
|
|
<select
|
|
id="platform"
|
|
name="platform"
|
|
value={formData.platform}
|
|
onChange={handleChange}
|
|
className="mt-1 block w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
|
>
|
|
{PLATFORMS.map((p) => (
|
|
<option key={p.value} value={p.value}>
|
|
{p.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Procedure */}
|
|
<div>
|
|
<label htmlFor="procedure_text" className="block text-sm font-medium text-gray-300">
|
|
Procedure
|
|
</label>
|
|
<textarea
|
|
id="procedure_text"
|
|
name="procedure_text"
|
|
value={formData.procedure_text}
|
|
onChange={handleChange}
|
|
rows={5}
|
|
placeholder="Step-by-step procedure for executing this test"
|
|
className="mt-1 block 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:outline-none focus:ring-2 focus:ring-cyan-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Tool Used */}
|
|
<div>
|
|
<label htmlFor="tool_used" className="block text-sm font-medium text-gray-300">
|
|
Tool Used
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="tool_used"
|
|
name="tool_used"
|
|
value={formData.tool_used}
|
|
onChange={handleChange}
|
|
placeholder="e.g., Atomic Red Team, Cobalt Strike, etc."
|
|
className="mt-1 block w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Result (optional, for editing) */}
|
|
{showResult && (
|
|
<div>
|
|
<label htmlFor="result" className="block text-sm font-medium text-gray-300">
|
|
Result
|
|
</label>
|
|
<select
|
|
id="result"
|
|
name="result"
|
|
value={formData.result || ""}
|
|
onChange={handleChange}
|
|
className="mt-1 block w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
|
>
|
|
{RESULTS.map((r) => (
|
|
<option key={r.value} value={r.value}>
|
|
{r.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Submit Button */}
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-6 py-2.5 font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
|
|
>
|
|
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{isSubmitting ? "Creating..." : "Create Test"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|