From cb447f380342e534763adcb156ff2171593b81fa Mon Sep 17 00:00:00 2001 From: Kitos Date: Fri, 6 Feb 2026 16:21:14 +0100 Subject: [PATCH] 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. --- README.md | 35 +- frontend/src/App.tsx | 6 + frontend/src/api/evidence.ts | 30 ++ frontend/src/api/metrics.ts | 14 + frontend/src/api/system.ts | 41 ++ frontend/src/api/techniques.ts | 74 ++++ frontend/src/api/tests.ts | 67 ++++ frontend/src/components/AttackMatrix.tsx | 112 ++++++ .../src/components/CoverageSummaryCard.tsx | 38 ++ frontend/src/components/EvidenceList.tsx | 97 +++++ frontend/src/components/EvidenceUpload.tsx | 140 +++++++ .../src/components/TacticCoverageChart.tsx | 97 +++++ frontend/src/components/TechniqueCell.tsx | 78 ++++ frontend/src/components/TestForm.tsx | 258 ++++++++++++ frontend/src/pages/DashboardPage.tsx | 135 ++++++- frontend/src/pages/MatrixPage.tsx | 197 ++++++++++ frontend/src/pages/SystemPage.tsx | 368 +++++++++++++++++- frontend/src/pages/TechniqueDetailPage.tsx | 363 +++++++++++++++++ frontend/src/pages/TechniquesPage.tsx | 278 ++++++++++++- frontend/src/pages/TestCreatePage.tsx | 109 ++++++ frontend/src/pages/TestDetailPage.tsx | 355 +++++++++++++++++ frontend/src/pages/TestsPage.tsx | 227 ++++++++++- 22 files changed, 3092 insertions(+), 27 deletions(-) create mode 100644 frontend/src/api/evidence.ts create mode 100644 frontend/src/api/metrics.ts create mode 100644 frontend/src/api/system.ts create mode 100644 frontend/src/api/techniques.ts create mode 100644 frontend/src/api/tests.ts create mode 100644 frontend/src/components/AttackMatrix.tsx create mode 100644 frontend/src/components/CoverageSummaryCard.tsx create mode 100644 frontend/src/components/EvidenceList.tsx create mode 100644 frontend/src/components/EvidenceUpload.tsx create mode 100644 frontend/src/components/TacticCoverageChart.tsx create mode 100644 frontend/src/components/TechniqueCell.tsx create mode 100644 frontend/src/components/TestForm.tsx create mode 100644 frontend/src/pages/MatrixPage.tsx create mode 100644 frontend/src/pages/TechniqueDetailPage.tsx create mode 100644 frontend/src/pages/TestCreatePage.tsx create mode 100644 frontend/src/pages/TestDetailPage.tsx diff --git a/README.md b/README.md index 6f91c3d..0e87481 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Aegis is a comprehensive platform for tracking and managing security coverage ag - **Database**: PostgreSQL 15 - **Object Storage**: MinIO (S3-compatible) - **ORM**: SQLAlchemy with Alembic migrations -- **Frontend**: React 19 + TypeScript + Vite + Tailwind CSS v4 +- **Frontend**: React 19 + TypeScript + Vite + Tailwind CSS v4 + TanStack Query ## Quick Start @@ -208,19 +208,34 @@ Aegis/ ├── index.css # Tailwind CSS entry ├── api/ # Axios clients │ ├── client.ts # Base axios instance with JWT interceptor - │ └── auth.ts # login(), getMe() + │ ├── auth.ts # login(), getMe() + │ ├── metrics.ts # getCoverageSummary(), getCoverageByTactic() + │ ├── techniques.ts # getTechniques(), getTechniqueByMitreId() + │ ├── tests.ts # createTest(), validateTest(), rejectTest() + │ ├── evidence.ts # uploadEvidence(), getEvidence() + │ └── system.ts # triggerMitreSync(), triggerIntelScan() ├── context/ │ └── AuthContext.tsx # Auth state: user, login, logout, isLoading ├── components/ - │ ├── Layout.tsx # Sidebar + header + - │ ├── Sidebar.tsx # Nav links (role-aware) - │ └── ProtectedRoute.tsx + │ ├── Layout.tsx # Sidebar + header + + │ ├── Sidebar.tsx # Nav links (role-aware) + │ ├── ProtectedRoute.tsx # Auth route guard with role support + │ ├── CoverageSummaryCard.tsx # Metric card component + │ ├── TacticCoverageChart.tsx # Coverage breakdown table + │ ├── AttackMatrix.tsx # Interactive technique grid + │ ├── TechniqueCell.tsx # Individual technique cell in matrix + │ ├── TestForm.tsx # Reusable test creation/edit form + │ ├── EvidenceUpload.tsx # Drag & drop file upload + │ └── EvidenceList.tsx # Evidence file listing ├── pages/ - │ ├── LoginPage.tsx - │ ├── DashboardPage.tsx - │ ├── TechniquesPage.tsx - │ ├── TestsPage.tsx - │ └── SystemPage.tsx + │ ├── LoginPage.tsx # User authentication form + │ ├── DashboardPage.tsx # Coverage metrics dashboard with summary cards + │ ├── TechniquesPage.tsx # Interactive ATT&CK matrix view with filters + │ ├── TechniqueDetailPage.tsx # Individual technique detail with tests + │ ├── TestsPage.tsx # Tests overview and navigation + │ ├── TestCreatePage.tsx # Test creation form + │ ├── TestDetailPage.tsx # Test details with evidence upload + │ └── SystemPage.tsx # Admin panel for MITRE sync & intel scan ├── types/ │ └── models.ts # TS interfaces matching backend schemas ├── hooks/ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f0b4c6..2404916 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,10 @@ import { Routes, Route, Navigate } from "react-router-dom"; import LoginPage from "./pages/LoginPage"; import DashboardPage from "./pages/DashboardPage"; import TechniquesPage from "./pages/TechniquesPage"; +import TechniqueDetailPage from "./pages/TechniqueDetailPage"; import TestsPage from "./pages/TestsPage"; +import TestCreatePage from "./pages/TestCreatePage"; +import TestDetailPage from "./pages/TestDetailPage"; import SystemPage from "./pages/SystemPage"; import Layout from "./components/Layout"; import ProtectedRoute from "./components/ProtectedRoute"; @@ -23,7 +26,10 @@ export default function App() { > } /> } /> + } /> } /> + } /> + } /> { + const formData = new FormData(); + formData.append("file", file); + + const { data } = await client.post(`/tests/${testId}/evidence`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return data; +} + +/** Get evidence metadata with download URL. */ +export async function getEvidence(evidenceId: string): Promise { + const { data } = await client.get(`/evidence/${evidenceId}`); + return data; +} diff --git a/frontend/src/api/metrics.ts b/frontend/src/api/metrics.ts new file mode 100644 index 0000000..79e7bf7 --- /dev/null +++ b/frontend/src/api/metrics.ts @@ -0,0 +1,14 @@ +import client from "./client"; +import type { CoverageSummary, TacticCoverage } from "../types/models"; + +/** Fetch the global coverage summary. */ +export async function getCoverageSummary(): Promise { + const { data } = await client.get("/metrics/summary"); + return data; +} + +/** Fetch coverage breakdown by tactic. */ +export async function getCoverageByTactic(): Promise { + const { data } = await client.get("/metrics/by-tactic"); + return data; +} diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts new file mode 100644 index 0000000..b82ebdf --- /dev/null +++ b/frontend/src/api/system.ts @@ -0,0 +1,41 @@ +import client from "./client"; + +export interface SyncMitreResponse { + message: string; + new: number; + updated: number; +} + +export interface IntelScanResponse { + message: string; + new_items: number; +} + +export interface SchedulerJob { + id: string; + name: string; + next_run_time: string | null; +} + +export interface SchedulerStatusResponse { + running: boolean; + jobs: SchedulerJob[]; +} + +/** Manually trigger MITRE ATT&CK sync. */ +export async function triggerMitreSync(): Promise { + const { data } = await client.post("/system/sync-mitre"); + return data; +} + +/** Manually trigger threat intelligence scan. */ +export async function triggerIntelScan(): Promise { + const { data } = await client.post("/system/run-intel-scan"); + return data; +} + +/** Get scheduler status. */ +export async function getSchedulerStatus(): Promise { + const { data } = await client.get("/system/scheduler-status"); + return data; +} diff --git a/frontend/src/api/techniques.ts b/frontend/src/api/techniques.ts new file mode 100644 index 0000000..dba8fce --- /dev/null +++ b/frontend/src/api/techniques.ts @@ -0,0 +1,74 @@ +import client from "./client"; +import type { Technique, TechniqueStatus } from "../types/models"; + +/** Summary representation used in list endpoints. */ +export interface TechniqueSummary { + id: string; + mitre_id: string; + name: string; + tactic: string | null; + status_global: TechniqueStatus; + review_required?: boolean; +} + +/** Extended technique with tests for detail view. */ +export interface TechniqueWithTests extends Technique { + tests?: Array<{ + id: string; + technique_id: string; + name: string; + description: string | null; + platform: string | null; + procedure_text: string | null; + tool_used: string | null; + execution_date: string | null; + created_by: string | null; + result: "detected" | "not_detected" | "partially_detected" | null; + state: "draft" | "in_review" | "validated" | "rejected"; + validated_by: string | null; + validated_at: string | null; + created_at: string; + }>; + intel_items?: Array<{ + id: string; + technique_id: string | null; + url: string; + title: string | null; + source: string | null; + detected_at: string; + reviewed: boolean; + }>; +} + +export interface TechniqueFilters { + tactic?: string; + status?: TechniqueStatus; + review_required?: boolean; +} + +/** Fetch all techniques (summary). */ +export async function getTechniques(filters?: TechniqueFilters): Promise { + const params = new URLSearchParams(); + if (filters?.tactic) params.append("tactic", filters.tactic); + if (filters?.status) params.append("status", filters.status); + if (filters?.review_required !== undefined) { + params.append("review_required", String(filters.review_required)); + } + + const { data } = await client.get( + `/techniques${params.toString() ? `?${params}` : ""}` + ); + return data; +} + +/** Fetch a single technique by mitre_id (with tests). */ +export async function getTechniqueByMitreId(mitreId: string): Promise { + const { data } = await client.get(`/techniques/${mitreId}`); + return data; +} + +/** Mark a technique as reviewed. */ +export async function markTechniqueReviewed(mitreId: string): Promise { + const { data } = await client.patch(`/techniques/${mitreId}/review`); + return data; +} diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts new file mode 100644 index 0000000..f8184b2 --- /dev/null +++ b/frontend/src/api/tests.ts @@ -0,0 +1,67 @@ +import client from "./client"; +import type { Test, TestResult } from "../types/models"; + +export interface TestCreatePayload { + technique_id: string; + name: string; + description?: string; + platform?: string; + procedure_text?: string; + tool_used?: string; +} + +export interface TestUpdatePayload { + name?: string; + description?: string; + platform?: string; + procedure_text?: string; + tool_used?: string; + result?: TestResult; +} + +export interface TestValidatePayload { + result: TestResult; + comments?: string; +} + +export interface TestWithEvidences extends Test { + evidences?: Array<{ + id: string; + test_id: string; + file_name: string; + sha256_hash: string; + uploaded_by: string | null; + uploaded_at: string; + download_url?: string; + }>; +} + +/** Create a new test. */ +export async function createTest(payload: TestCreatePayload): Promise { + const { data } = await client.post("/tests", payload); + return data; +} + +/** Get test by ID with evidences. */ +export async function getTestById(testId: string): Promise { + const { data } = await client.get(`/tests/${testId}`); + return data; +} + +/** Update a test (only draft/rejected). */ +export async function updateTest(testId: string, payload: TestUpdatePayload): Promise { + const { data } = await client.patch(`/tests/${testId}`, payload); + return data; +} + +/** Validate a test. */ +export async function validateTest(testId: string, payload: TestValidatePayload): Promise { + const { data } = await client.post(`/tests/${testId}/validate`, payload); + return data; +} + +/** Reject a test. */ +export async function rejectTest(testId: string): Promise { + const { data } = await client.post(`/tests/${testId}/reject`); + return data; +} diff --git a/frontend/src/components/AttackMatrix.tsx b/frontend/src/components/AttackMatrix.tsx new file mode 100644 index 0000000..b2ab6c1 --- /dev/null +++ b/frontend/src/components/AttackMatrix.tsx @@ -0,0 +1,112 @@ +import { useMemo } from "react"; +import TechniqueCell from "./TechniqueCell"; +import type { TechniqueSummary } from "../api/techniques"; + +interface AttackMatrixProps { + techniques: TechniqueSummary[]; +} + +// MITRE ATT&CK Enterprise Tactics in order +const TACTIC_ORDER = [ + "reconnaissance", + "resource-development", + "initial-access", + "execution", + "persistence", + "privilege-escalation", + "defense-evasion", + "credential-access", + "discovery", + "lateral-movement", + "collection", + "command-and-control", + "exfiltration", + "impact", +]; + +const formatTacticName = (tactic: string): string => { + return tactic + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +}; + +export default function AttackMatrix({ techniques }: AttackMatrixProps) { + // Group techniques by tactic + const groupedByTactic = useMemo(() => { + const groups: Record = {}; + + for (const tech of techniques) { + // A technique can belong to multiple tactics (comma-separated) + const tactics = tech.tactic + ? tech.tactic.split(",").map((t) => t.trim().toLowerCase()) + : ["unknown"]; + + for (const tactic of tactics) { + if (!groups[tactic]) { + groups[tactic] = []; + } + groups[tactic].push(tech); + } + } + + // Sort techniques within each tactic by mitre_id + for (const tactic of Object.keys(groups)) { + groups[tactic].sort((a, b) => a.mitre_id.localeCompare(b.mitre_id)); + } + + return groups; + }, [techniques]); + + // Get ordered tactics that have techniques + const orderedTactics = useMemo(() => { + const tacticSet = new Set(Object.keys(groupedByTactic)); + const ordered = TACTIC_ORDER.filter((t) => tacticSet.has(t)); + // Add any unknown tactics at the end + const remaining = Array.from(tacticSet).filter((t) => !TACTIC_ORDER.includes(t)); + return [...ordered, ...remaining]; + }, [groupedByTactic]); + + if (techniques.length === 0) { + return ( +
+

No techniques found matching your filters

+
+ ); + } + + return ( +
+
+
+ {orderedTactics.map((tactic) => ( +
+ {/* Tactic header */} +
+

+ {formatTacticName(tactic)} +

+

+ {groupedByTactic[tactic]?.length || 0} techniques +

+
+ + {/* Technique cells */} +
+ {groupedByTactic[tactic]?.map((tech) => ( + + ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/CoverageSummaryCard.tsx b/frontend/src/components/CoverageSummaryCard.tsx new file mode 100644 index 0000000..b5279b7 --- /dev/null +++ b/frontend/src/components/CoverageSummaryCard.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; + +interface CoverageSummaryCardProps { + title: string; + value: number; + total?: number; + icon: ReactNode; + colorClass: string; + bgClass: string; +} + +export default function CoverageSummaryCard({ + title, + value, + total, + icon, + colorClass, + bgClass, +}: CoverageSummaryCardProps) { + const percentage = total && total > 0 ? ((value / total) * 100).toFixed(1) : null; + + return ( +
+
+
+

{title}

+

{value}

+ {percentage !== null && ( +

{percentage}% of total

+ )} +
+
+ {icon} +
+
+
+ ); +} diff --git a/frontend/src/components/EvidenceList.tsx b/frontend/src/components/EvidenceList.tsx new file mode 100644 index 0000000..ba484b9 --- /dev/null +++ b/frontend/src/components/EvidenceList.tsx @@ -0,0 +1,97 @@ +import { FileIcon, Download, ExternalLink, Copy, Check } from "lucide-react"; +import { useState } from "react"; + +interface Evidence { + id: string; + test_id: string; + file_name: string; + sha256_hash: string; + uploaded_by: string | null; + uploaded_at: string; + download_url?: string; +} + +interface EvidenceListProps { + evidences: Evidence[]; + onDownload: (evidenceId: string) => void; +} + +export default function EvidenceList({ evidences, onDownload }: EvidenceListProps) { + const [copiedHash, setCopiedHash] = useState(null); + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const copyHash = async (hash: string) => { + await navigator.clipboard.writeText(hash); + setCopiedHash(hash); + setTimeout(() => setCopiedHash(null), 2000); + }; + + if (evidences.length === 0) { + return ( +
+ +

No evidence files uploaded yet

+
+ ); + } + + return ( +
+ {evidences.map((evidence) => ( +
+
+
+
+ +
+
+

{evidence.file_name}

+

+ Uploaded {formatDate(evidence.uploaded_at)} +

+
+
+ +
+ + {/* SHA256 Hash */} +
+ SHA256: + + {evidence.sha256_hash} + + +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/EvidenceUpload.tsx b/frontend/src/components/EvidenceUpload.tsx new file mode 100644 index 0000000..8c77fc5 --- /dev/null +++ b/frontend/src/components/EvidenceUpload.tsx @@ -0,0 +1,140 @@ +import { useState, useCallback, useRef } from "react"; +import { Upload, Loader2, X, FileIcon } from "lucide-react"; + +interface EvidenceUploadProps { + onUpload: (file: File) => Promise; + isUploading: boolean; +} + +export default function EvidenceUpload({ onUpload, isUploading }: EvidenceUploadProps) { + const [isDragging, setIsDragging] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const fileInputRef = useRef(null); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) { + setSelectedFile(file); + } + }, []); + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setSelectedFile(file); + } + }; + + const handleUpload = async () => { + if (selectedFile) { + await onUpload(selectedFile); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + const clearSelection = () => { + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + }; + + return ( +
+ {/* Drop zone */} +
fileInputRef.current?.click()} + className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${ + isDragging + ? "border-cyan-500 bg-cyan-500/10" + : "border-gray-700 bg-gray-800/50 hover:border-gray-600 hover:bg-gray-800" + }`} + > + + +

+ {isDragging ? ( + "Drop file here" + ) : ( + <> + Drag and drop a file, or browse + + )} +

+

+ Screenshots, logs, pcap files, etc. +

+
+ + {/* Selected file preview */} + {selectedFile && ( +
+
+ +
+

{selectedFile.name}

+

{formatFileSize(selectedFile.size)}

+
+
+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/TacticCoverageChart.tsx b/frontend/src/components/TacticCoverageChart.tsx new file mode 100644 index 0000000..f9ddb07 --- /dev/null +++ b/frontend/src/components/TacticCoverageChart.tsx @@ -0,0 +1,97 @@ +import type { TacticCoverage } from "../types/models"; + +interface TacticCoverageChartProps { + data: TacticCoverage[]; +} + +export default function TacticCoverageChart({ data }: TacticCoverageChartProps) { + if (data.length === 0) { + return ( +
+

No tactic data available

+
+ ); + } + + return ( +
+

Coverage by Tactic

+
+ + + + + + + + + + + + + + + {data.map((tactic) => { + const coveragePercent = + tactic.total > 0 + ? ((tactic.validated + tactic.partial) / tactic.total) * 100 + : 0; + + return ( + + + + + + + + + + + ); + })} + +
TacticTotalValidatedPartialIn ProgressNot CoveredNot EvaluatedCoverage
+ + {tactic.tactic.replace(/-/g, " ")} + + {tactic.total} + 0 ? "text-green-400 font-medium" : "text-gray-600"}> + {tactic.validated} + + + 0 ? "text-yellow-400 font-medium" : "text-gray-600"}> + {tactic.partial} + + + 0 ? "text-blue-400 font-medium" : "text-gray-600"}> + {tactic.in_progress} + + + 0 ? "text-red-400 font-medium" : "text-gray-600"}> + {tactic.not_covered} + + + 0 ? "text-gray-400" : "text-gray-600"}> + {tactic.not_evaluated} + + +
+
+
+
+ + {coveragePercent.toFixed(0)}% + +
+
+
+
+ ); +} diff --git a/frontend/src/components/TechniqueCell.tsx b/frontend/src/components/TechniqueCell.tsx new file mode 100644 index 0000000..95158c1 --- /dev/null +++ b/frontend/src/components/TechniqueCell.tsx @@ -0,0 +1,78 @@ +import { useNavigate } from "react-router-dom"; +import { AlertTriangle } from "lucide-react"; +import type { TechniqueStatus } from "../types/models"; + +interface TechniqueCellProps { + mitreId: string; + name: string; + status: TechniqueStatus; + reviewRequired?: boolean; +} + +const statusColors: Record = { + validated: { + bg: "bg-green-900/40", + border: "border-green-500/50", + text: "text-green-400", + }, + partial: { + bg: "bg-yellow-900/40", + border: "border-yellow-500/50", + text: "text-yellow-400", + }, + in_progress: { + bg: "bg-blue-900/40", + border: "border-blue-500/50", + text: "text-blue-400", + }, + not_covered: { + bg: "bg-red-900/40", + border: "border-red-500/50", + text: "text-red-400", + }, + not_evaluated: { + bg: "bg-gray-800/40", + border: "border-gray-600/50", + text: "text-gray-400", + }, + review_required: { + bg: "bg-yellow-900/40", + border: "border-yellow-500/50", + text: "text-yellow-400", + }, +}; + +export default function TechniqueCell({ + mitreId, + name, + status, + reviewRequired = false, +}: TechniqueCellProps) { + const navigate = useNavigate(); + const colors = statusColors[status] || statusColors.not_evaluated; + + const handleClick = () => { + navigate(`/techniques/${mitreId}`); + }; + + return ( + + ); +} diff --git a/frontend/src/components/TestForm.tsx b/frontend/src/components/TestForm.tsx new file mode 100644 index 0000000..6cb8915 --- /dev/null +++ b/frontend/src/components/TestForm.tsx @@ -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; + 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({ + 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>({}); + + 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 + ) => { + 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 = {}; + + 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 ( +
+ {/* Technique Selector */} +
+ + + {errors.technique_id && ( +

{errors.technique_id}

+ )} +
+ + {/* Name */} +
+ + + {errors.name &&

{errors.name}

} +
+ + {/* Description */} +
+ +