feat: Phase 8 - Frontend main views (T-026 to T-031)
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.
This commit is contained in:
258
frontend/src/components/TestForm.tsx
Normal file
258
frontend/src/components/TestForm.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user