feat(frontend): sprint 4 — dark mode + matrix overhaul + tactic selection + done read-only + UI polish
US-17: fix duplicate "Create engagement" button, icon conventions (Save/RotateCcw/Grid2x2), UsersAdminPage form baseline alignment US-18: done status fully read-only + Reopen button (done → review_required) for all roles US-19: invalidate engagement queries on simulation PATCH/transition for auto-status propagation US-20: MitreMatrixModal rewritten — CSS grid 12-column layout, no horizontal scroll, attack.mitre.org compact look US-21: tactic header clickable in matrix, tactic chips (MitreTacticTag) in field, single atomic PATCH with technique_ids + tactic_ids US-22: MitreTechniquesField chips-only area + inline search input + matrix icon button; chips show ID-only (name in title=) US-23: useTheme hook — 3-state light/dark/system, CSS variables, Tailwind darkMode class, localStorage persistence 92/92 tests passing, typecheck and lint clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,25 @@
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import type { Theme } from '@/hooks/useTheme';
|
||||
|
||||
function ThemeIcon({ theme }: { theme: Theme }) {
|
||||
if (theme === 'light') return <Sun size={16} aria-hidden />;
|
||||
if (theme === 'dark') return <Moon size={16} aria-hidden />;
|
||||
return <Monitor size={16} aria-hidden />;
|
||||
}
|
||||
|
||||
function themeLabel(theme: Theme): string {
|
||||
if (theme === 'light') return 'Light';
|
||||
if (theme === 'dark') return 'Dark';
|
||||
return 'System';
|
||||
}
|
||||
|
||||
/**
|
||||
* Top utility strip (ink) + main nav (canvas).
|
||||
* Mirrors DESIGN.md utility-strip + nav-bar-top pattern, scaled to internal app.
|
||||
*/
|
||||
export function Layout(): JSX.Element {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { theme, cycleTheme } = useTheme();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
@@ -17,7 +29,7 @@ export function Layout(): JSX.Element {
|
||||
return (
|
||||
<div className="min-h-full flex flex-col bg-canvas">
|
||||
{/* utility-strip — ink slab, fine print */}
|
||||
<div className="bg-ink text-ink-on text-[14px] h-9 flex items-center">
|
||||
<div className="bg-ink text-white text-[14px] h-9 flex items-center">
|
||||
<div className="mx-auto w-full max-w-page px-xl flex items-center justify-between">
|
||||
<span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span>
|
||||
{user ? (
|
||||
@@ -26,6 +38,15 @@ export function Layout(): JSX.Element {
|
||||
{user.role}
|
||||
</span>
|
||||
<span className="text-[14px]">{user.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycleTheme}
|
||||
aria-label={`Theme: ${themeLabel(theme)} — click to cycle`}
|
||||
className="flex items-center gap-xxs text-[12px] text-steel hover:text-white transition-colors"
|
||||
>
|
||||
<ThemeIcon theme={theme} />
|
||||
<span className="uppercase tracking-[0.5px]">{themeLabel(theme)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
@@ -43,7 +64,7 @@ export function Layout(): JSX.Element {
|
||||
<div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between">
|
||||
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home">
|
||||
<span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden />
|
||||
<span className="text-[20px] font-medium tracking-tight">Mimic</span>
|
||||
<span className="text-[20px] font-medium tracking-tight text-ink">Mimic</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-md">
|
||||
@@ -81,7 +102,7 @@ export function Layout(): JSX.Element {
|
||||
</main>
|
||||
|
||||
{/* footer — ink slab close */}
|
||||
<footer className="bg-ink text-ink-on">
|
||||
<footer className="bg-ink text-white">
|
||||
<div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-steel">
|
||||
Mimic — Internal Purple Team tooling. Authorized engagements only.
|
||||
</div>
|
||||
|
||||
@@ -3,24 +3,32 @@ import { LoadingState } from './LoadingState';
|
||||
import { ErrorState } from './ErrorState';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useMitreMatrix } from '@/hooks/useMitre';
|
||||
import type { MitreTechnique } from '@/api/types';
|
||||
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||
|
||||
export interface MatrixSelection {
|
||||
techniques: MitreTechnique[];
|
||||
tactics: MitreTacticRef[];
|
||||
}
|
||||
|
||||
interface MitreMatrixModalProps {
|
||||
isOpen: boolean;
|
||||
initialSelection: MitreTechnique[];
|
||||
onApply: (selection: MitreTechnique[]) => void;
|
||||
initialTechniques: MitreTechnique[];
|
||||
initialTactics: MitreTacticRef[];
|
||||
onApply: (selection: MatrixSelection) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function techniqueInTactic(
|
||||
tacticTechniques: { id: string; subtechniques: { id: string }[] }[],
|
||||
selection: Set<string>,
|
||||
function countSelected(
|
||||
techniques: { id: string; subtechniques: { id: string }[] }[],
|
||||
techMap: Set<string>,
|
||||
tacticId: string,
|
||||
tacticMap: Set<string>,
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const t of tacticTechniques) {
|
||||
if (selection.has(t.id)) count++;
|
||||
let count = tacticMap.has(tacticId) ? 1 : 0;
|
||||
for (const t of techniques) {
|
||||
if (techMap.has(t.id)) count++;
|
||||
for (const s of t.subtechniques) {
|
||||
if (selection.has(s.id)) count++;
|
||||
if (techMap.has(s.id)) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
@@ -28,15 +36,18 @@ function techniqueInTactic(
|
||||
|
||||
export function MitreMatrixModal({
|
||||
isOpen,
|
||||
initialSelection,
|
||||
initialTechniques,
|
||||
initialTactics,
|
||||
onApply,
|
||||
onCancel,
|
||||
}: MitreMatrixModalProps): JSX.Element | null {
|
||||
const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen);
|
||||
|
||||
// Selected IDs → Map id → {id, name} for reconstruct
|
||||
const [selectedMap, setSelectedMap] = useState<Map<string, { id: string; name: string }>>(
|
||||
() => new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }])),
|
||||
const [selectedTechMap, setSelectedTechMap] = useState<Map<string, { id: string; name: string }>>(
|
||||
() => new Map(initialTechniques.map((t) => [t.id, { id: t.id, name: t.name }])),
|
||||
);
|
||||
const [selectedTacticSet, setSelectedTacticSet] = useState<Set<string>>(
|
||||
() => new Set(initialTactics.map((t) => t.id)),
|
||||
);
|
||||
const [expandedTechniques, setExpandedTechniques] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -44,24 +55,21 @@ export function MitreMatrixModal({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset local state when modal opens with new initialSelection
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedMap(new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }])));
|
||||
setSelectedTechMap(new Map(initialTechniques.map((t) => [t.id, { id: t.id, name: t.name }])));
|
||||
setSelectedTacticSet(new Set(initialTactics.map((t) => t.id)));
|
||||
setExpandedTechniques(new Set());
|
||||
setSearch('');
|
||||
}
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Focus search input on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Small delay lets the DOM render before focus
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Escape closes modal
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -87,28 +95,26 @@ export function MitreMatrixModal({
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const toggleTechnique = (id: string, name: string) => {
|
||||
setSelectedMap((prev) => {
|
||||
setSelectedTechMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.set(id, { id, name });
|
||||
}
|
||||
if (next.has(id)) next.delete(id); else next.set(id, { id, name });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTactic = (tacticId: string) => {
|
||||
setSelectedTacticSet((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(tacticId)) next.delete(tacticId); else next.add(tacticId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -116,18 +122,13 @@ export function MitreMatrixModal({
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedTechniques((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const searchLower = search.toLowerCase().trim();
|
||||
|
||||
// Figure out which technique IDs should be auto-expanded due to a sub-technique match
|
||||
const autoExpanded = new Set<string>();
|
||||
if (searchLower && matrix) {
|
||||
for (const tactic of matrix) {
|
||||
@@ -141,40 +142,41 @@ export function MitreMatrixModal({
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
// Reconstruct MitreTechnique[] from selected IDs.
|
||||
// tactics are not available here; parent will use what it has or send []
|
||||
const selection: MitreTechnique[] = Array.from(selectedMap.values()).map((t) => ({
|
||||
const techniques: MitreTechnique[] = Array.from(selectedTechMap.values()).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tactics: [],
|
||||
}));
|
||||
onApply(selection);
|
||||
// Reconstruct tactic refs from matrix data
|
||||
const tactics: MitreTacticRef[] = matrix
|
||||
? matrix
|
||||
.filter((t) => selectedTacticSet.has(t.tactic_id))
|
||||
.map((t) => ({ id: t.tactic_id, name: t.tactic_name }))
|
||||
: Array.from(selectedTacticSet).map((id) => ({ id, name: id }));
|
||||
onApply({ techniques, tactics });
|
||||
};
|
||||
|
||||
const totalSelected = selectedMap.size;
|
||||
const totalTechSelected = selectedTechMap.size;
|
||||
const totalTacticSelected = selectedTacticSet.size;
|
||||
const totalSelected = totalTechSelected + totalTacticSelected;
|
||||
const hasInitial = initialTechniques.length + initialTactics.length > 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-ink/60"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/60" onClick={onCancel} aria-hidden="true" />
|
||||
|
||||
{/* Modal container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="matrix-modal-title"
|
||||
className="relative bg-canvas rounded-xl shadow-elevated max-w-[95vw] max-h-[85vh] overflow-hidden flex flex-col"
|
||||
style={{ width: '1200px' }}
|
||||
className="relative bg-canvas rounded-xl shadow-floating max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col"
|
||||
style={{ width: '1400px' }}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-xl py-md border-b border-hairline flex-shrink-0">
|
||||
<h2 id="matrix-modal-title" className="text-[20px] font-medium text-ink">
|
||||
<h2 id="matrix-modal-title" className="text-[18px] font-medium text-ink">
|
||||
MITRE ATT&CK Matrix
|
||||
</h2>
|
||||
<input
|
||||
@@ -183,25 +185,31 @@ export function MitreMatrixModal({
|
||||
placeholder="Filter techniques…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="text-input w-64"
|
||||
className="text-input w-56 h-9 text-[14px]"
|
||||
aria-label="Filter techniques"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto px-xl py-md">
|
||||
{/* Body — overflow-y-auto, NO overflow-x */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-md py-md">
|
||||
{isLoading && <LoadingState label="Loading MITRE matrix…" />}
|
||||
{isError && (
|
||||
<ErrorState
|
||||
message={extractApiError(error, 'Could not load MITRE matrix')}
|
||||
/>
|
||||
<ErrorState message={extractApiError(error, 'Could not load MITRE matrix')} />
|
||||
)}
|
||||
{!isLoading && !isError && matrix && (
|
||||
<div className="flex gap-sm" style={{ minWidth: 'max-content' }}>
|
||||
<div
|
||||
className="grid gap-xxs"
|
||||
style={{ gridTemplateColumns: `repeat(${matrix.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{matrix.map((tactic) => {
|
||||
const selectedCount = techniqueInTactic(tactic.techniques, new Set(selectedMap.keys()));
|
||||
const tacticSelected = selectedTacticSet.has(tactic.tactic_id);
|
||||
const selectedCount = countSelected(
|
||||
tactic.techniques,
|
||||
new Set(selectedTechMap.keys()),
|
||||
tactic.tactic_id,
|
||||
selectedTacticSet,
|
||||
);
|
||||
|
||||
// Filter techniques for this tactic
|
||||
const visibleTechniques = tactic.techniques.filter((tech) => {
|
||||
if (!searchLower) return true;
|
||||
const techMatch =
|
||||
@@ -215,35 +223,41 @@ export function MitreMatrixModal({
|
||||
return techMatch || subMatch;
|
||||
});
|
||||
|
||||
if (visibleTechniques.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tactic.tactic_id}
|
||||
className="flex-shrink-0"
|
||||
style={{ width: '220px' }}
|
||||
>
|
||||
{/* Tactic header */}
|
||||
<div className="bg-cloud rounded-t-md px-sm py-xs border border-hairline border-b-0">
|
||||
<div className="text-[11px] uppercase tracking-[0.5px] text-graphite font-medium leading-none">
|
||||
<div key={tactic.tactic_id} className="flex flex-col min-w-0">
|
||||
{/* Tactic header — clickable to toggle tactic selection */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTactic(tactic.tactic_id)}
|
||||
title={`${tactic.tactic_name} (${tactic.tactic_id}) — click to tag this tactic`}
|
||||
className={`w-full text-left px-xs py-xxs rounded-t-sm border border-b-0 border-hairline transition-colors ${
|
||||
tacticSelected
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-cloud hover:bg-fog'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-[10px] uppercase tracking-[0.6px] font-semibold leading-tight truncate ${
|
||||
tacticSelected ? 'text-white' : 'text-graphite'
|
||||
}`}>
|
||||
{tactic.tactic_name}
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<div className="text-[11px] text-primary-deep font-medium mt-xxs">
|
||||
{selectedCount} selected
|
||||
<div className={`text-[10px] font-medium leading-none mt-[2px] ${
|
||||
tacticSelected ? 'text-white/80' : 'text-primary-deep'
|
||||
}`}>
|
||||
{selectedCount} sel.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Techniques */}
|
||||
<div className="border border-hairline rounded-b-md overflow-hidden">
|
||||
<div className="border border-hairline rounded-b-sm overflow-hidden flex flex-col">
|
||||
{visibleTechniques.map((tech, techIdx) => {
|
||||
const isSelected = selectedMap.has(tech.id);
|
||||
const isSelected = selectedTechMap.has(tech.id);
|
||||
const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id);
|
||||
const hasSubtechniques = tech.subtechniques.length > 0;
|
||||
const isLast = techIdx === visibleTechniques.length - 1;
|
||||
|
||||
// Filter subtechniques when searching
|
||||
const visibleSubs = searchLower
|
||||
? tech.subtechniques.filter(
|
||||
(s) =>
|
||||
@@ -254,68 +268,67 @@ export function MitreMatrixModal({
|
||||
|
||||
return (
|
||||
<div key={tech.id} className={!isLast ? 'border-b border-hairline' : ''}>
|
||||
{/* Technique row */}
|
||||
<div
|
||||
className={`flex items-start px-sm py-xs text-[13px] ${
|
||||
isSelected ? 'bg-primary text-canvas' : 'bg-canvas text-ink hover:bg-cloud'
|
||||
className={`flex items-start px-xs py-xxs text-[11px] ${
|
||||
isSelected ? 'bg-primary' : 'bg-canvas hover:bg-cloud'
|
||||
}`}
|
||||
>
|
||||
{/* Chevron — expand/collapse, does NOT toggle selection */}
|
||||
{hasSubtechniques ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? `Collapse ${tech.id}` : `Expand ${tech.id}`}
|
||||
onClick={() => toggleExpand(tech.id)}
|
||||
className={`mr-xxs flex-shrink-0 text-[11px] w-4 leading-none mt-[1px] ${
|
||||
isSelected ? 'text-canvas' : 'text-graphite'
|
||||
className={`mr-[2px] flex-shrink-0 text-[9px] w-3 leading-none mt-[1px] ${
|
||||
isSelected ? 'text-white' : 'text-graphite'
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? '▾' : '▸'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="mr-xxs w-4 flex-shrink-0" />
|
||||
<span className="mr-[2px] w-3 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Label — click toggles selection */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTechnique(tech.id, tech.name)}
|
||||
className={`text-left leading-snug flex-1 min-w-0 ${
|
||||
isSelected ? 'text-canvas' : 'text-ink'
|
||||
title={`${tech.id} — ${tech.name}`}
|
||||
className={`text-left leading-tight flex-1 min-w-0 ${
|
||||
isSelected ? 'text-white' : 'text-ink'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{tech.id}</span>
|
||||
<br />
|
||||
<span className={isSelected ? 'text-canvas/80' : 'text-charcoal'}>
|
||||
<span className="font-semibold block truncate">{tech.id}</span>
|
||||
<span className={`block truncate text-[10px] ${isSelected ? 'text-white/80' : 'text-charcoal'}`}>
|
||||
{tech.name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtechniques — shown when expanded */}
|
||||
{isExpanded &&
|
||||
visibleSubs.map((sub) => {
|
||||
const isSubSelected = selectedMap.has(sub.id);
|
||||
const isSubSelected = selectedTechMap.has(sub.id);
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
onClick={() => toggleTechnique(sub.id, sub.name)}
|
||||
className={`w-full text-left pl-md pr-sm py-xxs text-[12px] border-t border-hairline leading-snug ${
|
||||
title={`${sub.id} — ${sub.name}`}
|
||||
className={`w-full text-left pl-[14px] pr-xs py-[2px] text-[10px] border-t border-hairline leading-tight ${
|
||||
isSubSelected
|
||||
? 'bg-primary-soft text-primary-deep'
|
||||
: 'bg-cloud text-charcoal hover:bg-fog'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{sub.id}</span>
|
||||
{' — '}
|
||||
{sub.name}
|
||||
<span className="font-semibold block truncate">{sub.id}</span>
|
||||
<span className="block truncate">{sub.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleTechniques.length === 0 && searchLower && (
|
||||
<div className="px-xs py-xxs text-[10px] text-graphite italic">No match</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -333,11 +346,11 @@ export function MitreMatrixModal({
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleApply}
|
||||
disabled={isLoading || isError || (totalSelected === 0 && initialSelection.length === 0)}
|
||||
disabled={isLoading || isError || (totalSelected === 0 && !hasInitial)}
|
||||
>
|
||||
{totalSelected === 0
|
||||
? 'Clear all'
|
||||
: `Apply ${totalSelected} technique${totalSelected !== 1 ? 's' : ''}`}
|
||||
: `Apply ${totalSelected} item${totalSelected !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,63 @@
|
||||
import type { MitreTechnique } from '@/api/types';
|
||||
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||
|
||||
interface MitreTechniqueTagProps {
|
||||
interface TechniqueTagProps {
|
||||
technique: MitreTechnique;
|
||||
onRemove: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TacticTagProps {
|
||||
tactic: MitreTacticRef;
|
||||
onRemove: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Technique chip — soft blue, id only, name in title
|
||||
export function MitreTechniqueTag({
|
||||
technique,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
}: MitreTechniqueTagProps): JSX.Element {
|
||||
}: TechniqueTagProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
data-testid="mitre-technique-tag"
|
||||
className="inline-flex items-center gap-xxs bg-primary-soft text-primary-deep rounded-full px-md py-xxs text-[14px]"
|
||||
title={`${technique.id} — ${technique.name}`}
|
||||
className="inline-flex items-center gap-xxs bg-primary-soft text-primary-deep rounded-full px-sm py-xxs text-[13px] font-medium"
|
||||
>
|
||||
<span className="font-medium">{technique.id}</span>
|
||||
<span className="text-primary-deep opacity-75"> — {technique.name}</span>
|
||||
{technique.id}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${technique.id}`}
|
||||
onClick={onRemove}
|
||||
className="ml-xxs text-primary-deep opacity-60 hover:opacity-100 leading-none"
|
||||
className="text-primary-deep opacity-60 hover:opacity-100 leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Tactic chip — primary blue filled, id only, name in title
|
||||
export function MitreTacticTag({
|
||||
tactic,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
}: TacticTagProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
data-testid="mitre-tactic-tag"
|
||||
title={`${tactic.id} — ${tactic.name}`}
|
||||
className="inline-flex items-center gap-xxs bg-primary text-white rounded-full px-sm py-xxs text-[13px] font-medium"
|
||||
>
|
||||
{tactic.id}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${tactic.id}`}
|
||||
onClick={onRemove}
|
||||
className="text-white opacity-60 hover:opacity-100 leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { Grid2x2 } from 'lucide-react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { MitreTechnique } from '@/api/types';
|
||||
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||
import { useUpdateSimulation } from '@/hooks/useSimulations';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { MitreTechniqueTag } from './MitreTechniqueTag';
|
||||
import { MitreTechniqueTag, MitreTacticTag } from './MitreTechniqueTag';
|
||||
import { MitreTechniquePicker } from './MitreTechniquePicker';
|
||||
import { MitreMatrixModal } from './MitreMatrixModal';
|
||||
import type { MatrixSelection } from './MitreMatrixModal';
|
||||
|
||||
interface MitreTechniquesFieldProps {
|
||||
value: MitreTechnique[];
|
||||
tactics: MitreTacticRef[];
|
||||
simulationId: number;
|
||||
engagementId: number;
|
||||
disabled?: boolean;
|
||||
@@ -16,6 +19,7 @@ interface MitreTechniquesFieldProps {
|
||||
|
||||
export function MitreTechniquesField({
|
||||
value,
|
||||
tactics,
|
||||
simulationId,
|
||||
engagementId,
|
||||
disabled = false,
|
||||
@@ -26,10 +30,11 @@ export function MitreTechniquesField({
|
||||
const { push } = useToast();
|
||||
const updateMutation = useUpdateSimulation(simulationId, engagementId);
|
||||
|
||||
const save = async (techniques: MitreTechnique[]) => {
|
||||
const save = async (techniques: MitreTechnique[], nextTactics: MitreTacticRef[]) => {
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
technique_ids: techniques.map((t) => t.id),
|
||||
tactic_ids: nextTactics.map((t) => t.id),
|
||||
});
|
||||
push('Techniques updated', 'success');
|
||||
} catch (err) {
|
||||
@@ -37,96 +42,92 @@ export function MitreTechniquesField({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
const next = value.filter((t) => t.id !== id);
|
||||
void save(next);
|
||||
const handleRemoveTechnique = (id: string) => {
|
||||
void save(value.filter((t) => t.id !== id), tactics);
|
||||
};
|
||||
|
||||
const handleRemoveTactic = (id: string) => {
|
||||
void save(value, tactics.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
const handleSelect = (technique: MitreTechnique) => {
|
||||
// Dedup: no-op if already present
|
||||
if (value.some((t) => t.id === technique.id)) return;
|
||||
const next = [...value, technique];
|
||||
void save(next);
|
||||
void save([...value, technique], tactics);
|
||||
setShowPicker(false);
|
||||
};
|
||||
|
||||
const handleMatrixApply = (selection: MitreTechnique[]) => {
|
||||
const handleMatrixApply = ({ techniques, tactics: newTactics }: MatrixSelection) => {
|
||||
setShowMatrix(false);
|
||||
// Merge: preserve existing tactics on items already in value, fill from selection otherwise.
|
||||
// The backend re-enriches tactics at serialize time, so the exact tactics here don't matter.
|
||||
const merged = selection.map((s) => {
|
||||
const merged = techniques.map((s) => {
|
||||
const existing = value.find((v) => v.id === s.id);
|
||||
return existing ?? s;
|
||||
});
|
||||
void save(merged);
|
||||
void save(merged, newTactics);
|
||||
};
|
||||
|
||||
const isPending = updateMutation.isPending;
|
||||
const isEmpty = value.length === 0 && tactics.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-sm">
|
||||
{/* Tag list */}
|
||||
{value.length === 0 ? (
|
||||
<p className="text-[14px] text-graphite">
|
||||
No techniques selected — use the matrix or the quick search to add.
|
||||
</p>
|
||||
{/* Chips area */}
|
||||
{isEmpty ? (
|
||||
<p className="text-[13px] text-graphite">No techniques selected</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-sm" data-testid="techniques-tag-list">
|
||||
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
|
||||
{tactics.map((t) => (
|
||||
<MitreTacticTag
|
||||
key={t.id}
|
||||
tactic={t}
|
||||
onRemove={() => handleRemoveTactic(t.id)}
|
||||
disabled={disabled || isPending}
|
||||
/>
|
||||
))}
|
||||
{value.map((t) => (
|
||||
<MitreTechniqueTag
|
||||
key={t.id}
|
||||
technique={t}
|
||||
onRemove={() => handleRemove(t.id)}
|
||||
onRemove={() => handleRemoveTechnique(t.id)}
|
||||
disabled={disabled || isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons — hidden in read-only mode */}
|
||||
{/* Input row — hidden in read-only mode */}
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-sm">
|
||||
<div className="flex items-center gap-xs">
|
||||
<div className="flex-1 max-w-sm">
|
||||
{showPicker ? (
|
||||
<MitreTechniquePicker onSelect={handleSelect} disabled={isPending} />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-input h-9 text-[13px] text-graphite text-left cursor-text w-full"
|
||||
onClick={() => setShowPicker(true)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Search technique (e.g. T1059)…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={() => {
|
||||
setShowPicker(false);
|
||||
setShowMatrix(true);
|
||||
}}
|
||||
aria-label="Open MITRE matrix"
|
||||
onClick={() => { setShowPicker(false); setShowMatrix(true); }}
|
||||
disabled={isPending}
|
||||
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-md border border-steel text-graphite hover:text-ink hover:border-ink transition-colors"
|
||||
>
|
||||
Add technique
|
||||
<Grid2x2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink"
|
||||
onClick={() => setShowPicker((v) => !v)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Quick search
|
||||
</button>
|
||||
{isPending && (
|
||||
<span className="text-[13px] text-graphite">Saving…</span>
|
||||
)}
|
||||
{isPending && <span className="text-[12px] text-graphite">Saving…</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Quick Search picker */}
|
||||
{showPicker && !disabled && (
|
||||
<div className="max-w-md">
|
||||
<MitreTechniquePicker
|
||||
onSelect={(technique) => {
|
||||
handleSelect(technique);
|
||||
setShowPicker(false);
|
||||
}}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Matrix modal */}
|
||||
<MitreMatrixModal
|
||||
isOpen={showMatrix}
|
||||
initialSelection={value}
|
||||
initialTechniques={value}
|
||||
initialTactics={tactics}
|
||||
onApply={handleMatrixApply}
|
||||
onCancel={() => setShowMatrix(false)}
|
||||
/>
|
||||
|
||||
@@ -96,11 +96,15 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal text-[14px]">
|
||||
{sim.techniques.length === 0
|
||||
? '—'
|
||||
: sim.techniques.length === 1
|
||||
? sim.techniques[0].id
|
||||
: `${sim.techniques[0].id} +${sim.techniques.length - 1}`}
|
||||
{(() => {
|
||||
const items = [
|
||||
...(sim.tactics ?? []).map((t) => t.id),
|
||||
...sim.techniques.map((t) => t.id),
|
||||
];
|
||||
if (items.length === 0) return '—';
|
||||
if (items.length === 1) return items[0];
|
||||
return `${items[0]} +${items.length - 1}`;
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-xl py-md">
|
||||
<SimulationStatusBadge status={sim.status} />
|
||||
|
||||
Reference in New Issue
Block a user