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:
Knacky
2026-05-27 20:06:01 +02:00
parent d5ab1fd26f
commit f5ea9d16af
21 changed files with 721 additions and 337 deletions

View File

@@ -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&amp;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>