feat: sprint 4 — UI polish + dark mode + workflow tightening + process hygiene #7
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.27.0"
|
"react-router-dom": "^6.27.0"
|
||||||
@@ -5083,6 +5084,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.27.0"
|
"react-router-dom": "^6.27.0"
|
||||||
|
|||||||
@@ -78,11 +78,17 @@ export interface MitreTactic {
|
|||||||
techniques: MitreMatrixTechnique[];
|
techniques: MitreMatrixTechnique[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MitreTacticRef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Simulation {
|
export interface Simulation {
|
||||||
id: number;
|
id: number;
|
||||||
engagement_id: number;
|
engagement_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
techniques: MitreTechnique[];
|
techniques: MitreTechnique[];
|
||||||
|
tactics: MitreTacticRef[];
|
||||||
description: string | null;
|
description: string | null;
|
||||||
commands: string | null;
|
commands: string | null;
|
||||||
prerequisites: string | null;
|
prerequisites: string | null;
|
||||||
@@ -105,6 +111,7 @@ export interface SimulationCreateInput {
|
|||||||
export interface SimulationPatchInput {
|
export interface SimulationPatchInput {
|
||||||
name?: string;
|
name?: string;
|
||||||
technique_ids?: string[];
|
technique_ids?: string[];
|
||||||
|
tactic_ids?: string[];
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
commands?: string | null;
|
commands?: string | null;
|
||||||
prerequisites?: string | null;
|
prerequisites?: string | null;
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
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 {
|
export function Layout(): JSX.Element {
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, isAdmin, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { theme, cycleTheme } = useTheme();
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
@@ -17,7 +29,7 @@ export function Layout(): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-full flex flex-col bg-canvas">
|
<div className="min-h-full flex flex-col bg-canvas">
|
||||||
{/* utility-strip — ink slab, fine print */}
|
{/* 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">
|
<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>
|
<span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span>
|
||||||
{user ? (
|
{user ? (
|
||||||
@@ -26,6 +38,15 @@ export function Layout(): JSX.Element {
|
|||||||
{user.role}
|
{user.role}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[14px]">{user.username}</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
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">
|
<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">
|
<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="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>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-md">
|
<nav className="flex items-center gap-md">
|
||||||
@@ -81,7 +102,7 @@ export function Layout(): JSX.Element {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* footer — ink slab close */}
|
{/* 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">
|
<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.
|
Mimic — Internal Purple Team tooling. Authorized engagements only.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,24 +3,32 @@ import { LoadingState } from './LoadingState';
|
|||||||
import { ErrorState } from './ErrorState';
|
import { ErrorState } from './ErrorState';
|
||||||
import { extractApiError } from '@/api/client';
|
import { extractApiError } from '@/api/client';
|
||||||
import { useMitreMatrix } from '@/hooks/useMitre';
|
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 {
|
interface MitreMatrixModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
initialSelection: MitreTechnique[];
|
initialTechniques: MitreTechnique[];
|
||||||
onApply: (selection: MitreTechnique[]) => void;
|
initialTactics: MitreTacticRef[];
|
||||||
|
onApply: (selection: MatrixSelection) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function techniqueInTactic(
|
function countSelected(
|
||||||
tacticTechniques: { id: string; subtechniques: { id: string }[] }[],
|
techniques: { id: string; subtechniques: { id: string }[] }[],
|
||||||
selection: Set<string>,
|
techMap: Set<string>,
|
||||||
|
tacticId: string,
|
||||||
|
tacticMap: Set<string>,
|
||||||
): number {
|
): number {
|
||||||
let count = 0;
|
let count = tacticMap.has(tacticId) ? 1 : 0;
|
||||||
for (const t of tacticTechniques) {
|
for (const t of techniques) {
|
||||||
if (selection.has(t.id)) count++;
|
if (techMap.has(t.id)) count++;
|
||||||
for (const s of t.subtechniques) {
|
for (const s of t.subtechniques) {
|
||||||
if (selection.has(s.id)) count++;
|
if (techMap.has(s.id)) count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
@@ -28,15 +36,18 @@ function techniqueInTactic(
|
|||||||
|
|
||||||
export function MitreMatrixModal({
|
export function MitreMatrixModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
initialSelection,
|
initialTechniques,
|
||||||
|
initialTactics,
|
||||||
onApply,
|
onApply,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: MitreMatrixModalProps): JSX.Element | null {
|
}: MitreMatrixModalProps): JSX.Element | null {
|
||||||
const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen);
|
const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen);
|
||||||
|
|
||||||
// Selected IDs → Map id → {id, name} for reconstruct
|
const [selectedTechMap, setSelectedTechMap] = useState<Map<string, { id: string; name: string }>>(
|
||||||
const [selectedMap, setSelectedMap] = useState<Map<string, { id: string; name: string }>>(
|
() => new Map(initialTechniques.map((t) => [t.id, { id: t.id, name: t.name }])),
|
||||||
() => new Map(initialSelection.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 [expandedTechniques, setExpandedTechniques] = useState<Set<string>>(new Set());
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -44,24 +55,21 @@ export function MitreMatrixModal({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Reset local state when modal opens with new initialSelection
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
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());
|
setExpandedTechniques(new Set());
|
||||||
setSearch('');
|
setSearch('');
|
||||||
}
|
}
|
||||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Focus search input on open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// Small delay lets the DOM render before focus
|
|
||||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Escape closes modal
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -87,28 +95,26 @@ export function MitreMatrixModal({
|
|||||||
const first = focusables[0];
|
const first = focusables[0];
|
||||||
const last = focusables[focusables.length - 1];
|
const last = focusables[focusables.length - 1];
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
if (document.activeElement === first) {
|
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||||
e.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (document.activeElement === last) {
|
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||||
e.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const toggleTechnique = (id: string, name: string) => {
|
const toggleTechnique = (id: string, name: string) => {
|
||||||
setSelectedMap((prev) => {
|
setSelectedTechMap((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
if (next.has(id)) {
|
if (next.has(id)) next.delete(id); else next.set(id, { id, name });
|
||||||
next.delete(id);
|
return next;
|
||||||
} else {
|
});
|
||||||
next.set(id, { id, name });
|
};
|
||||||
}
|
|
||||||
|
const toggleTactic = (tacticId: string) => {
|
||||||
|
setSelectedTacticSet((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(tacticId)) next.delete(tacticId); else next.add(tacticId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -116,18 +122,13 @@ export function MitreMatrixModal({
|
|||||||
const toggleExpand = (id: string) => {
|
const toggleExpand = (id: string) => {
|
||||||
setExpandedTechniques((prev) => {
|
setExpandedTechniques((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) {
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
next.delete(id);
|
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchLower = search.toLowerCase().trim();
|
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>();
|
const autoExpanded = new Set<string>();
|
||||||
if (searchLower && matrix) {
|
if (searchLower && matrix) {
|
||||||
for (const tactic of matrix) {
|
for (const tactic of matrix) {
|
||||||
@@ -141,40 +142,41 @@ export function MitreMatrixModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
// Reconstruct MitreTechnique[] from selected IDs.
|
const techniques: MitreTechnique[] = Array.from(selectedTechMap.values()).map((t) => ({
|
||||||
// tactics are not available here; parent will use what it has or send []
|
|
||||||
const selection: MitreTechnique[] = Array.from(selectedMap.values()).map((t) => ({
|
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
tactics: [],
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<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
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="matrix-modal-title"
|
aria-labelledby="matrix-modal-title"
|
||||||
className="relative bg-canvas rounded-xl shadow-elevated max-w-[95vw] max-h-[85vh] overflow-hidden flex flex-col"
|
className="relative bg-canvas rounded-xl shadow-floating max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col"
|
||||||
style={{ width: '1200px' }}
|
style={{ width: '1400px' }}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-xl py-md border-b border-hairline flex-shrink-0">
|
<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
|
MITRE ATT&CK Matrix
|
||||||
</h2>
|
</h2>
|
||||||
<input
|
<input
|
||||||
@@ -183,25 +185,31 @@ export function MitreMatrixModal({
|
|||||||
placeholder="Filter techniques…"
|
placeholder="Filter techniques…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="text-input w-64"
|
className="text-input w-56 h-9 text-[14px]"
|
||||||
aria-label="Filter techniques"
|
aria-label="Filter techniques"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body — overflow-y-auto, NO overflow-x */}
|
||||||
<div className="flex-1 overflow-auto px-xl py-md">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-md py-md">
|
||||||
{isLoading && <LoadingState label="Loading MITRE matrix…" />}
|
{isLoading && <LoadingState label="Loading MITRE matrix…" />}
|
||||||
{isError && (
|
{isError && (
|
||||||
<ErrorState
|
<ErrorState message={extractApiError(error, 'Could not load MITRE matrix')} />
|
||||||
message={extractApiError(error, 'Could not load MITRE matrix')}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{!isLoading && !isError && 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) => {
|
{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) => {
|
const visibleTechniques = tactic.techniques.filter((tech) => {
|
||||||
if (!searchLower) return true;
|
if (!searchLower) return true;
|
||||||
const techMatch =
|
const techMatch =
|
||||||
@@ -215,35 +223,41 @@ export function MitreMatrixModal({
|
|||||||
return techMatch || subMatch;
|
return techMatch || subMatch;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (visibleTechniques.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={tactic.tactic_id} className="flex flex-col min-w-0">
|
||||||
key={tactic.tactic_id}
|
{/* Tactic header — clickable to toggle tactic selection */}
|
||||||
className="flex-shrink-0"
|
<button
|
||||||
style={{ width: '220px' }}
|
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'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* Tactic header */}
|
<div className={`text-[10px] uppercase tracking-[0.6px] font-semibold leading-tight truncate ${
|
||||||
<div className="bg-cloud rounded-t-md px-sm py-xs border border-hairline border-b-0">
|
tacticSelected ? 'text-white' : 'text-graphite'
|
||||||
<div className="text-[11px] uppercase tracking-[0.5px] text-graphite font-medium leading-none">
|
}`}>
|
||||||
{tactic.tactic_name}
|
{tactic.tactic_name}
|
||||||
</div>
|
</div>
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<div className="text-[11px] text-primary-deep font-medium mt-xxs">
|
<div className={`text-[10px] font-medium leading-none mt-[2px] ${
|
||||||
{selectedCount} selected
|
tacticSelected ? 'text-white/80' : 'text-primary-deep'
|
||||||
|
}`}>
|
||||||
|
{selectedCount} sel.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{/* Techniques */}
|
{/* 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) => {
|
{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 isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id);
|
||||||
const hasSubtechniques = tech.subtechniques.length > 0;
|
const hasSubtechniques = tech.subtechniques.length > 0;
|
||||||
const isLast = techIdx === visibleTechniques.length - 1;
|
const isLast = techIdx === visibleTechniques.length - 1;
|
||||||
|
|
||||||
// Filter subtechniques when searching
|
|
||||||
const visibleSubs = searchLower
|
const visibleSubs = searchLower
|
||||||
? tech.subtechniques.filter(
|
? tech.subtechniques.filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
@@ -254,68 +268,67 @@ export function MitreMatrixModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={tech.id} className={!isLast ? 'border-b border-hairline' : ''}>
|
<div key={tech.id} className={!isLast ? 'border-b border-hairline' : ''}>
|
||||||
{/* Technique row */}
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-start px-sm py-xs text-[13px] ${
|
className={`flex items-start px-xs py-xxs text-[11px] ${
|
||||||
isSelected ? 'bg-primary text-canvas' : 'bg-canvas text-ink hover:bg-cloud'
|
isSelected ? 'bg-primary' : 'bg-canvas hover:bg-cloud'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Chevron — expand/collapse, does NOT toggle selection */}
|
|
||||||
{hasSubtechniques ? (
|
{hasSubtechniques ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={isExpanded ? `Collapse ${tech.id}` : `Expand ${tech.id}`}
|
aria-label={isExpanded ? `Collapse ${tech.id}` : `Expand ${tech.id}`}
|
||||||
onClick={() => toggleExpand(tech.id)}
|
onClick={() => toggleExpand(tech.id)}
|
||||||
className={`mr-xxs flex-shrink-0 text-[11px] w-4 leading-none mt-[1px] ${
|
className={`mr-[2px] flex-shrink-0 text-[9px] w-3 leading-none mt-[1px] ${
|
||||||
isSelected ? 'text-canvas' : 'text-graphite'
|
isSelected ? 'text-white' : 'text-graphite'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isExpanded ? '▾' : '▸'}
|
{isExpanded ? '▾' : '▸'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="mr-xxs w-4 flex-shrink-0" />
|
<span className="mr-[2px] w-3 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Label — click toggles selection */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleTechnique(tech.id, tech.name)}
|
onClick={() => toggleTechnique(tech.id, tech.name)}
|
||||||
className={`text-left leading-snug flex-1 min-w-0 ${
|
title={`${tech.id} — ${tech.name}`}
|
||||||
isSelected ? 'text-canvas' : 'text-ink'
|
className={`text-left leading-tight flex-1 min-w-0 ${
|
||||||
|
isSelected ? 'text-white' : 'text-ink'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{tech.id}</span>
|
<span className="font-semibold block truncate">{tech.id}</span>
|
||||||
<br />
|
<span className={`block truncate text-[10px] ${isSelected ? 'text-white/80' : 'text-charcoal'}`}>
|
||||||
<span className={isSelected ? 'text-canvas/80' : 'text-charcoal'}>
|
|
||||||
{tech.name}
|
{tech.name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtechniques — shown when expanded */}
|
|
||||||
{isExpanded &&
|
{isExpanded &&
|
||||||
visibleSubs.map((sub) => {
|
visibleSubs.map((sub) => {
|
||||||
const isSubSelected = selectedMap.has(sub.id);
|
const isSubSelected = selectedTechMap.has(sub.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={sub.id}
|
key={sub.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleTechnique(sub.id, sub.name)}
|
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
|
isSubSelected
|
||||||
? 'bg-primary-soft text-primary-deep'
|
? 'bg-primary-soft text-primary-deep'
|
||||||
: 'bg-cloud text-charcoal hover:bg-fog'
|
: 'bg-cloud text-charcoal hover:bg-fog'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{sub.id}</span>
|
<span className="font-semibold block truncate">{sub.id}</span>
|
||||||
{' — '}
|
<span className="block truncate">{sub.name}</span>
|
||||||
{sub.name}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{visibleTechniques.length === 0 && searchLower && (
|
||||||
|
<div className="px-xs py-xxs text-[10px] text-graphite italic">No match</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -333,11 +346,11 @@ export function MitreMatrixModal({
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
disabled={isLoading || isError || (totalSelected === 0 && initialSelection.length === 0)}
|
disabled={isLoading || isError || (totalSelected === 0 && !hasInitial)}
|
||||||
>
|
>
|
||||||
{totalSelected === 0
|
{totalSelected === 0
|
||||||
? 'Clear all'
|
? 'Clear all'
|
||||||
: `Apply ${totalSelected} technique${totalSelected !== 1 ? 's' : ''}`}
|
: `Apply ${totalSelected} item${totalSelected !== 1 ? 's' : ''}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
technique: MitreTechnique;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TacticTagProps {
|
||||||
|
tactic: MitreTacticRef;
|
||||||
|
onRemove: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technique chip — soft blue, id only, name in title
|
||||||
export function MitreTechniqueTag({
|
export function MitreTechniqueTag({
|
||||||
technique,
|
technique,
|
||||||
onRemove,
|
onRemove,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: MitreTechniqueTagProps): JSX.Element {
|
}: TechniqueTagProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-testid="mitre-technique-tag"
|
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>
|
{technique.id}
|
||||||
<span className="text-primary-deep opacity-75"> — {technique.name}</span>
|
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Remove ${technique.id}`}
|
aria-label={`Remove ${technique.id}`}
|
||||||
onClick={onRemove}
|
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>
|
</button>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Grid2x2 } from 'lucide-react';
|
||||||
import { extractApiError } from '@/api/client';
|
import { extractApiError } from '@/api/client';
|
||||||
import type { MitreTechnique } from '@/api/types';
|
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||||
import { useUpdateSimulation } from '@/hooks/useSimulations';
|
import { useUpdateSimulation } from '@/hooks/useSimulations';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { MitreTechniqueTag } from './MitreTechniqueTag';
|
import { MitreTechniqueTag, MitreTacticTag } from './MitreTechniqueTag';
|
||||||
import { MitreTechniquePicker } from './MitreTechniquePicker';
|
import { MitreTechniquePicker } from './MitreTechniquePicker';
|
||||||
import { MitreMatrixModal } from './MitreMatrixModal';
|
import { MitreMatrixModal } from './MitreMatrixModal';
|
||||||
|
import type { MatrixSelection } from './MitreMatrixModal';
|
||||||
|
|
||||||
interface MitreTechniquesFieldProps {
|
interface MitreTechniquesFieldProps {
|
||||||
value: MitreTechnique[];
|
value: MitreTechnique[];
|
||||||
|
tactics: MitreTacticRef[];
|
||||||
simulationId: number;
|
simulationId: number;
|
||||||
engagementId: number;
|
engagementId: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -16,6 +19,7 @@ interface MitreTechniquesFieldProps {
|
|||||||
|
|
||||||
export function MitreTechniquesField({
|
export function MitreTechniquesField({
|
||||||
value,
|
value,
|
||||||
|
tactics,
|
||||||
simulationId,
|
simulationId,
|
||||||
engagementId,
|
engagementId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -26,10 +30,11 @@ export function MitreTechniquesField({
|
|||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const updateMutation = useUpdateSimulation(simulationId, engagementId);
|
const updateMutation = useUpdateSimulation(simulationId, engagementId);
|
||||||
|
|
||||||
const save = async (techniques: MitreTechnique[]) => {
|
const save = async (techniques: MitreTechnique[], nextTactics: MitreTacticRef[]) => {
|
||||||
try {
|
try {
|
||||||
await updateMutation.mutateAsync({
|
await updateMutation.mutateAsync({
|
||||||
technique_ids: techniques.map((t) => t.id),
|
technique_ids: techniques.map((t) => t.id),
|
||||||
|
tactic_ids: nextTactics.map((t) => t.id),
|
||||||
});
|
});
|
||||||
push('Techniques updated', 'success');
|
push('Techniques updated', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -37,96 +42,92 @@ export function MitreTechniquesField({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = (id: string) => {
|
const handleRemoveTechnique = (id: string) => {
|
||||||
const next = value.filter((t) => t.id !== id);
|
void save(value.filter((t) => t.id !== id), tactics);
|
||||||
void save(next);
|
};
|
||||||
|
|
||||||
|
const handleRemoveTactic = (id: string) => {
|
||||||
|
void save(value, tactics.filter((t) => t.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (technique: MitreTechnique) => {
|
const handleSelect = (technique: MitreTechnique) => {
|
||||||
// Dedup: no-op if already present
|
|
||||||
if (value.some((t) => t.id === technique.id)) return;
|
if (value.some((t) => t.id === technique.id)) return;
|
||||||
const next = [...value, technique];
|
void save([...value, technique], tactics);
|
||||||
void save(next);
|
setShowPicker(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMatrixApply = (selection: MitreTechnique[]) => {
|
const handleMatrixApply = ({ techniques, tactics: newTactics }: MatrixSelection) => {
|
||||||
setShowMatrix(false);
|
setShowMatrix(false);
|
||||||
// Merge: preserve existing tactics on items already in value, fill from selection otherwise.
|
const merged = techniques.map((s) => {
|
||||||
// The backend re-enriches tactics at serialize time, so the exact tactics here don't matter.
|
|
||||||
const merged = selection.map((s) => {
|
|
||||||
const existing = value.find((v) => v.id === s.id);
|
const existing = value.find((v) => v.id === s.id);
|
||||||
return existing ?? s;
|
return existing ?? s;
|
||||||
});
|
});
|
||||||
void save(merged);
|
void save(merged, newTactics);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPending = updateMutation.isPending;
|
const isPending = updateMutation.isPending;
|
||||||
|
const isEmpty = value.length === 0 && tactics.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-sm">
|
<div className="flex flex-col gap-sm">
|
||||||
{/* Tag list */}
|
{/* Chips area */}
|
||||||
{value.length === 0 ? (
|
{isEmpty ? (
|
||||||
<p className="text-[14px] text-graphite">
|
<p className="text-[13px] text-graphite">No techniques selected</p>
|
||||||
No techniques selected — use the matrix or the quick search to add.
|
|
||||||
</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) => (
|
{value.map((t) => (
|
||||||
<MitreTechniqueTag
|
<MitreTechniqueTag
|
||||||
key={t.id}
|
key={t.id}
|
||||||
technique={t}
|
technique={t}
|
||||||
onRemove={() => handleRemove(t.id)}
|
onRemove={() => handleRemoveTechnique(t.id)}
|
||||||
disabled={disabled || isPending}
|
disabled={disabled || isPending}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons — hidden in read-only mode */}
|
{/* Input row — hidden in read-only mode */}
|
||||||
{!disabled && (
|
{!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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-outline"
|
className="text-input h-9 text-[13px] text-graphite text-left cursor-text w-full"
|
||||||
onClick={() => {
|
onClick={() => setShowPicker(true)}
|
||||||
setShowPicker(false);
|
|
||||||
setShowMatrix(true);
|
|
||||||
}}
|
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
Add technique
|
Search technique (e.g. T1059)…
|
||||||
</button>
|
</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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Grid2x2 size={16} />
|
||||||
|
</button>
|
||||||
|
{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
|
<MitreMatrixModal
|
||||||
isOpen={showMatrix}
|
isOpen={showMatrix}
|
||||||
initialSelection={value}
|
initialTechniques={value}
|
||||||
|
initialTactics={tactics}
|
||||||
onApply={handleMatrixApply}
|
onApply={handleMatrixApply}
|
||||||
onCancel={() => setShowMatrix(false)}
|
onCancel={() => setShowMatrix(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -96,11 +96,15 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-xl py-md text-charcoal text-[14px]">
|
<td className="px-xl py-md text-charcoal text-[14px]">
|
||||||
{sim.techniques.length === 0
|
{(() => {
|
||||||
? '—'
|
const items = [
|
||||||
: sim.techniques.length === 1
|
...(sim.tactics ?? []).map((t) => t.id),
|
||||||
? sim.techniques[0].id
|
...sim.techniques.map((t) => t.id),
|
||||||
: `${sim.techniques[0].id} +${sim.techniques.length - 1}`}
|
];
|
||||||
|
if (items.length === 0) return '—';
|
||||||
|
if (items.length === 1) return items[0];
|
||||||
|
return `${items[0]} +${items.length - 1}`;
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-xl py-md">
|
<td className="px-xl py-md">
|
||||||
<SimulationStatusBadge status={sim.status} />
|
<SimulationStatusBadge status={sim.status} />
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export function useUpdateSimulation(id: number, engagementId: number) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
||||||
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
||||||
|
qc.invalidateQueries({ queryKey: ['engagements', engagementId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['engagements'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,8 @@ export function useTransitionSimulation(id: number, engagementId: number) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
||||||
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
||||||
|
qc.invalidateQueries({ queryKey: ['engagements', engagementId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['engagements'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
59
frontend/src/hooks/useTheme.ts
Normal file
59
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'mimic-theme';
|
||||||
|
|
||||||
|
function resolveTheme(theme: Theme): 'light' | 'dark' {
|
||||||
|
if (theme === 'system') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
const resolved = resolveTheme(theme);
|
||||||
|
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredTheme(): Theme {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable
|
||||||
|
}
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(readStoredTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// Track system preference changes when theme === 'system'
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== 'system') return;
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = () => applyTheme('system');
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const setTheme = useCallback((next: Theme) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setThemeState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cycleTheme = useCallback(() => {
|
||||||
|
setTheme(theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light');
|
||||||
|
}, [theme, setTheme]);
|
||||||
|
|
||||||
|
return { theme, setTheme, cycleTheme };
|
||||||
|
}
|
||||||
@@ -24,9 +24,9 @@ export function EngagementsListPage(): JSX.Element {
|
|||||||
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
|
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync(eng.id);
|
await deleteMutation.mutateAsync(eng.id);
|
||||||
push('Engagement supprimé', 'success');
|
push('Engagement deleted', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push(extractApiError(err, 'Suppression impossible'), 'error');
|
push(extractApiError(err, 'Could not delete engagement'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export function EngagementsListPage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
{canEditEngagements ? (
|
{canEditEngagements ? (
|
||||||
<Link to="/engagements/new" className="btn-primary">
|
<Link to="/engagements/new" className="btn-primary">
|
||||||
New engagement
|
+ New
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
@@ -59,7 +59,7 @@ export function EngagementsListPage(): JSX.Element {
|
|||||||
action={
|
action={
|
||||||
canEditEngagements ? (
|
canEditEngagements ? (
|
||||||
<Link to="/engagements/new" className="btn-primary">
|
<Link to="/engagements/new" className="btn-primary">
|
||||||
Create engagement
|
+ New engagement
|
||||||
</Link>
|
</Link>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react';
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { Save, RotateCcw } from 'lucide-react';
|
||||||
import { extractApiError } from '@/api/client';
|
import { extractApiError } from '@/api/client';
|
||||||
import type { SimulationPatchInput } from '@/api/types';
|
import type { SimulationPatchInput } from '@/api/types';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
@@ -105,30 +106,28 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
const simulation = detail.data;
|
const simulation = detail.data;
|
||||||
const status = simulation?.status;
|
const status = simulation?.status;
|
||||||
|
|
||||||
// Role-based field locking
|
// US-18: Done = fully read-only, Reopen only
|
||||||
|
const isDone = status === 'done';
|
||||||
|
|
||||||
const canEditRT = isAdmin || isRedteam;
|
const canEditRT = isAdmin || isRedteam;
|
||||||
// SOC can only edit when status is review_required or done
|
|
||||||
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
|
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
|
||||||
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
|
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
|
||||||
|
|
||||||
const canSaveSoc = socCanEdit || canEditEngagements;
|
const canSaveSoc = !isDone && (socCanEdit || canEditEngagements);
|
||||||
const rtDisabled = !canEditRT;
|
const rtDisabled = !canEditRT || isDone;
|
||||||
const socDisabled = !canEditEngagements && !socCanEdit;
|
const socDisabled = isDone || (!canEditEngagements && !socCanEdit);
|
||||||
|
|
||||||
// Transition buttons visibility
|
|
||||||
const showMarkReview =
|
const showMarkReview =
|
||||||
canEditEngagements && (status === 'pending' || status === 'in_progress');
|
!isDone && canEditEngagements && (status === 'pending' || status === 'in_progress');
|
||||||
const showClose =
|
const showClose =
|
||||||
(canEditEngagements || isSoc) && status === 'review_required';
|
!isDone && (canEditEngagements || isSoc) && status === 'review_required';
|
||||||
|
const showReopen = isDone && (isAdmin || isRedteam || isSoc);
|
||||||
|
|
||||||
const onSubmitNew = async (e: FormEvent) => {
|
const onSubmitNew = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setNameError(null);
|
setNameError(null);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
if (!rt.name.trim()) {
|
if (!rt.name.trim()) { setNameError('Name is required'); return; }
|
||||||
setNameError('Name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
|
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
|
||||||
push('Simulation created', 'success');
|
push('Simulation created', 'success');
|
||||||
@@ -142,10 +141,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setNameError(null);
|
setNameError(null);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
if (!rt.name.trim()) {
|
if (!rt.name.trim()) { setNameError('Name is required'); return; }
|
||||||
setNameError('Name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const patch: SimulationPatchInput = {
|
const patch: SimulationPatchInput = {
|
||||||
name: rt.name.trim(),
|
name: rt.name.trim(),
|
||||||
description: rt.description.trim() || null,
|
description: rt.description.trim() || null,
|
||||||
@@ -197,6 +193,15 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onReopen = async () => {
|
||||||
|
try {
|
||||||
|
await transitionMutation.mutateAsync('review_required');
|
||||||
|
push('Simulation reopened', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
push(extractApiError(err, 'Transition failed'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
try {
|
try {
|
||||||
@@ -208,7 +213,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// New simulation form (minimal)
|
// New simulation form
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const submitting = createMutation.isPending;
|
const submitting = createMutation.isPending;
|
||||||
return (
|
return (
|
||||||
@@ -232,9 +237,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{submitError ? (
|
{submitError ? (
|
||||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
||||||
{submitError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-md pt-sm">
|
<div className="flex items-center gap-md pt-sm">
|
||||||
@@ -250,7 +253,6 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit form
|
|
||||||
const submitting =
|
const submitting =
|
||||||
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
|
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
|
||||||
|
|
||||||
@@ -275,7 +277,17 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* SOC banner — shown when soc user visits pending/in_progress */}
|
{/* Done banner */}
|
||||||
|
{isDone && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="rounded-xl px-xl py-md bg-cloud border border-hairline text-[14px] text-charcoal"
|
||||||
|
>
|
||||||
|
This simulation is <strong>done</strong> and read-only. Use Reopen to make changes.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SOC banner */}
|
||||||
{socBlocked && (
|
{socBlocked && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
@@ -289,7 +301,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
{/* Red Team card */}
|
{/* Red Team card */}
|
||||||
<form
|
<form
|
||||||
id="rt-form"
|
id="rt-form"
|
||||||
onSubmit={canEditRT ? onSaveRT : (e) => e.preventDefault()}
|
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
|
||||||
noValidate
|
noValidate
|
||||||
className="card-product flex flex-col gap-md"
|
className="card-product flex flex-col gap-md"
|
||||||
>
|
>
|
||||||
@@ -307,9 +319,10 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="flex flex-col gap-xs">
|
<div className="flex flex-col gap-xs">
|
||||||
<span className="text-[14px] font-medium text-ink">MITRE Techniques</span>
|
<span className="text-[14px] font-medium text-ink">MITRE Techniques & Tactics</span>
|
||||||
<MitreTechniquesField
|
<MitreTechniquesField
|
||||||
value={simulation?.techniques ?? []}
|
value={simulation?.techniques ?? []}
|
||||||
|
tactics={simulation?.tactics ?? []}
|
||||||
simulationId={simulationId as number}
|
simulationId={simulationId as number}
|
||||||
engagementId={engagementId as number}
|
engagementId={engagementId as number}
|
||||||
disabled={rtDisabled}
|
disabled={rtDisabled}
|
||||||
@@ -326,11 +339,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Commands" htmlFor="sim-commands" hint="One command per line">
|
||||||
label="Commands"
|
|
||||||
htmlFor="sim-commands"
|
|
||||||
hint="One command per line"
|
|
||||||
>
|
|
||||||
<TextArea
|
<TextArea
|
||||||
id="sim-commands"
|
id="sim-commands"
|
||||||
name="commands"
|
name="commands"
|
||||||
@@ -422,24 +431,38 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
disabled={socDisabled}
|
disabled={socDisabled}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{submitError ? (
|
{submitError ? (
|
||||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
||||||
{submitError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Unified sticky action bar */}
|
{/* Unified sticky action bar */}
|
||||||
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
||||||
{canEditRT && (
|
{/* Done state: Reopen only */}
|
||||||
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
{showReopen && (
|
||||||
{updateMutation.isPending ? 'Saving…' : 'Save Red Team'}
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-outline"
|
||||||
|
onClick={onReopen}
|
||||||
|
disabled={transitionMutation.isPending}
|
||||||
|
data-testid="reopen-btn"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} aria-hidden />
|
||||||
|
Reopen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canSaveSoc && (
|
|
||||||
|
{/* Normal state buttons */}
|
||||||
|
{!isDone && canEditRT && (
|
||||||
|
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||||
|
<Save size={14} aria-hidden />
|
||||||
|
{updateMutation.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isDone && canSaveSoc && (
|
||||||
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||||
|
<Save size={14} aria-hidden />
|
||||||
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
|
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -463,7 +486,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEditEngagements && simulationId && (
|
{!isDone && canEditEngagements && simulationId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-text-link text-bloom-deep ml-auto"
|
className="btn-text-link text-bloom-deep ml-auto"
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
|
|
||||||
<section className="card-product flex flex-col gap-md">
|
<section className="card-product flex flex-col gap-md">
|
||||||
<h2 className="text-[20px] font-medium">Create account</h2>
|
<h2 className="text-[20px] font-medium">Create account</h2>
|
||||||
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-start">
|
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-end">
|
||||||
<FormField label="Username" htmlFor="new-username" required>
|
<FormField label="Username" htmlFor="new-username" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="new-username"
|
id="new-username"
|
||||||
@@ -137,7 +137,7 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
options={ROLE_OPTIONS}
|
options={ROLE_OPTIONS}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="self-end">
|
<div>
|
||||||
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
|
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
|
||||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,41 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
/* Light mode — default */
|
||||||
|
:root {
|
||||||
|
--color-canvas: #ffffff;
|
||||||
|
--color-paper: #ffffff;
|
||||||
|
--color-cloud: #f7f7f7;
|
||||||
|
--color-fog: #e8e8e8;
|
||||||
|
--color-steel: #c2c2c2;
|
||||||
|
--color-hairline: #e8e8e8;
|
||||||
|
--color-ink: #1a1a1a;
|
||||||
|
--color-ink-soft: #292929;
|
||||||
|
--color-ink-deep: #000000;
|
||||||
|
--color-ink-on: #ffffff;
|
||||||
|
--color-charcoal: #3d3d3d;
|
||||||
|
--color-graphite: #636363;
|
||||||
|
|
||||||
|
/* DESIGN.md: body line-height 1.4 when substituting Inter */
|
||||||
|
font-size: 16.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
.dark {
|
||||||
|
--color-canvas: #111827;
|
||||||
|
--color-paper: #1f2937;
|
||||||
|
--color-cloud: #1f2937;
|
||||||
|
--color-fog: #374151;
|
||||||
|
--color-steel: #4b5563;
|
||||||
|
--color-hairline: #374151;
|
||||||
|
--color-ink: #f9fafb;
|
||||||
|
--color-ink-soft: #e5e7eb;
|
||||||
|
--color-ink-deep: #ffffff;
|
||||||
|
--color-ink-on: #111827;
|
||||||
|
--color-charcoal: #d1d5db;
|
||||||
|
--color-graphite: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
@@ -13,15 +48,9 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-canvas text-ink font-sans antialiased;
|
@apply bg-canvas text-ink font-sans antialiased;
|
||||||
/* DESIGN.md: body line-height 1.4 when substituting Inter */
|
|
||||||
font-feature-settings: 'cv11', 'ss01';
|
font-feature-settings: 'cv11', 'ss01';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compensate for Inter being slightly narrower than Forma DJR Micro (~3%) */
|
|
||||||
:root {
|
|
||||||
font-size: 16.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
@@ -39,7 +68,7 @@
|
|||||||
* Buttons stay sharp (rounded-md = 4px); cards stay soft (rounded-xl = 16px).
|
* Buttons stay sharp (rounded-md = 4px); cards stay soft (rounded-xl = 16px).
|
||||||
*/
|
*/
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply inline-flex items-center justify-center bg-primary text-ink-on uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
@apply inline-flex items-center justify-center gap-xs bg-primary text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||||
}
|
}
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
@apply bg-primary-deep;
|
@apply bg-primary-deep;
|
||||||
@@ -49,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-ink {
|
.btn-ink {
|
||||||
@apply inline-flex items-center justify-center bg-ink text-ink-on uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
@apply inline-flex items-center justify-center gap-xs bg-ink text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||||
}
|
}
|
||||||
.btn-ink:hover {
|
.btn-ink:hover {
|
||||||
@apply bg-ink-soft;
|
@apply bg-ink-soft;
|
||||||
@@ -59,21 +88,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
@apply inline-flex items-center justify-center bg-canvas text-primary border border-primary uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
@apply inline-flex items-center justify-center gap-xs bg-canvas text-primary border border-primary uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||||
}
|
}
|
||||||
.btn-outline:hover {
|
.btn-outline:hover {
|
||||||
@apply bg-primary-soft;
|
@apply bg-primary-soft;
|
||||||
}
|
}
|
||||||
|
.btn-outline:disabled {
|
||||||
|
@apply border-steel text-steel cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-outline-ink {
|
.btn-outline-ink {
|
||||||
@apply inline-flex items-center justify-center bg-canvas text-ink border border-ink uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
@apply inline-flex items-center justify-center gap-xs bg-canvas text-ink border border-ink uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||||
}
|
}
|
||||||
.btn-outline-ink:hover {
|
.btn-outline-ink:hover {
|
||||||
@apply bg-cloud;
|
@apply bg-cloud;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-text-link {
|
.btn-text-link {
|
||||||
@apply inline-flex items-center text-primary font-medium text-[16px] leading-[1.38] underline-offset-2 hover:underline;
|
@apply inline-flex items-center gap-xxs text-primary font-medium text-[16px] leading-[1.38] underline-offset-2 hover:underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-input {
|
.text-input {
|
||||||
@@ -85,7 +117,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-pill-ink {
|
.badge-pill-ink {
|
||||||
@apply inline-flex items-center bg-ink text-ink-on rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
|
@apply inline-flex items-center bg-ink text-white rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-pill-outline {
|
.badge-pill-outline {
|
||||||
|
|||||||
@@ -3,36 +3,38 @@ import type { Config } from 'tailwindcss';
|
|||||||
/**
|
/**
|
||||||
* Tokens mirror DESIGN.md.
|
* Tokens mirror DESIGN.md.
|
||||||
* Forma DJR Micro substitut: Inter (bundled locally via @fontsource-variable/inter).
|
* Forma DJR Micro substitut: Inter (bundled locally via @fontsource-variable/inter).
|
||||||
|
* Dark mode: class-based, toggled by adding 'dark' to <html>.
|
||||||
*/
|
*/
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
// Brand & Accent
|
// Brand & Accent — primary stays fixed (HP Electric Blue never inverts)
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: '#024ad8',
|
DEFAULT: '#024ad8',
|
||||||
bright: '#296ef9',
|
bright: '#296ef9',
|
||||||
deep: '#0e3191',
|
deep: '#0e3191',
|
||||||
soft: '#c9e0fc',
|
soft: '#c9e0fc',
|
||||||
},
|
},
|
||||||
// Surface
|
// Surface — backed by CSS vars so dark mode works via .dark class
|
||||||
canvas: '#ffffff',
|
canvas: 'var(--color-canvas)',
|
||||||
paper: '#ffffff',
|
paper: 'var(--color-paper)',
|
||||||
cloud: '#f7f7f7',
|
cloud: 'var(--color-cloud)',
|
||||||
fog: '#e8e8e8',
|
fog: 'var(--color-fog)',
|
||||||
steel: '#c2c2c2',
|
steel: 'var(--color-steel)',
|
||||||
hairline: '#e8e8e8',
|
hairline: 'var(--color-hairline)',
|
||||||
// Text
|
// Text — also CSS vars
|
||||||
ink: {
|
ink: {
|
||||||
DEFAULT: '#1a1a1a',
|
DEFAULT: 'var(--color-ink)',
|
||||||
deep: '#000000',
|
deep: 'var(--color-ink-deep)',
|
||||||
soft: '#292929',
|
soft: 'var(--color-ink-soft)',
|
||||||
on: '#ffffff',
|
on: 'var(--color-ink-on)',
|
||||||
},
|
},
|
||||||
charcoal: '#3d3d3d',
|
charcoal: 'var(--color-charcoal)',
|
||||||
graphite: '#636363',
|
graphite: 'var(--color-graphite)',
|
||||||
// Semantic / decorative
|
// Semantic / decorative — fixed (not themeable)
|
||||||
bloom: {
|
bloom: {
|
||||||
coral: '#ff5050',
|
coral: '#ff5050',
|
||||||
rose: '#f9d4d2',
|
rose: '#f9d4d2',
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const SELECTION: MitreTechnique[] = [
|
|||||||
{ id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] },
|
{ id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const NO_TACTICS: never[] = [];
|
||||||
|
|
||||||
describe('MitreMatrixModal', () => {
|
describe('MitreMatrixModal', () => {
|
||||||
let mock: MockAdapter;
|
let mock: MockAdapter;
|
||||||
|
|
||||||
@@ -58,14 +60,26 @@ describe('MitreMatrixModal', () => {
|
|||||||
|
|
||||||
it('returns null when isOpen=false', () => {
|
it('returns null when isOpen=false', () => {
|
||||||
const { container } = renderWithProviders(
|
const { container } = renderWithProviders(
|
||||||
<MitreMatrixModal isOpen={false} initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
<MitreMatrixModal
|
||||||
|
isOpen={false}
|
||||||
|
initialTechniques={[]}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders dialog with tactic columns when open', async () => {
|
it('renders dialog with tactic columns when open', async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={[]}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Initial Access')).toBeInTheDocument();
|
expect(screen.getByText('Initial Access')).toBeInTheDocument();
|
||||||
@@ -75,7 +89,13 @@ describe('MitreMatrixModal', () => {
|
|||||||
|
|
||||||
it('renders techniques for each tactic', async () => {
|
it('renders techniques for each tactic', async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={[]}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('T1078')).toBeInTheDocument();
|
expect(screen.getByText('T1078')).toBeInTheDocument();
|
||||||
@@ -88,12 +108,17 @@ describe('MitreMatrixModal', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={onApply} onCancel={vi.fn()} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={[]}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={onApply}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => screen.getByText('T1078'));
|
await waitFor(() => screen.getByText('T1078'));
|
||||||
|
|
||||||
// Click the label button for T1078 to select it
|
|
||||||
const t1078Btn = screen.getAllByRole('button').find(
|
const t1078Btn = screen.getAllByRole('button').find(
|
||||||
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
|
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
|
||||||
);
|
);
|
||||||
@@ -102,7 +127,9 @@ describe('MitreMatrixModal', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: /Apply/i }));
|
await user.click(screen.getByRole('button', { name: /Apply/i }));
|
||||||
|
|
||||||
expect(onApply).toHaveBeenCalledWith(
|
expect(onApply).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining([expect.objectContaining({ id: 'T1078' })]),
|
expect.objectContaining({
|
||||||
|
techniques: expect.arrayContaining([expect.objectContaining({ id: 'T1078' })]),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,7 +139,13 @@ describe('MitreMatrixModal', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={onApply} onCancel={onCancel} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={[]}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={onApply}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||||
@@ -126,7 +159,13 @@ describe('MitreMatrixModal', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={onCancel} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={[]}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={vi.fn()}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.keyboard('{Escape}');
|
await user.keyboard('{Escape}');
|
||||||
@@ -134,21 +173,31 @@ describe('MitreMatrixModal', () => {
|
|||||||
expect(onCancel).toHaveBeenCalled();
|
expect(onCancel).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows initial selection as selected', async () => {
|
it('shows initial technique selection as selected (count in header)', async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={vi.fn()} onCancel={vi.fn()} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={SELECTION}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => screen.getByText('T1078'));
|
await waitFor(() => screen.getByText('T1078'));
|
||||||
|
expect(screen.getByText(/1 sel\./i)).toBeInTheDocument();
|
||||||
// T1078 should show selected count in tactic header
|
|
||||||
expect(screen.getByText('1 selected')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('search filter narrows visible techniques', async () => {
|
it('search filter narrows visible techniques', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={[]}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => screen.getByText('T1078'));
|
await waitFor(() => screen.getByText('T1078'));
|
||||||
@@ -156,7 +205,6 @@ describe('MitreMatrixModal', () => {
|
|||||||
const searchInput = screen.getByPlaceholderText(/Filter techniques/i);
|
const searchInput = screen.getByPlaceholderText(/Filter techniques/i);
|
||||||
await user.type(searchInput, 'T1059');
|
await user.type(searchInput, 'T1059');
|
||||||
|
|
||||||
// T1059 column should be visible, T1078 should not
|
|
||||||
expect(screen.queryByText('T1078')).toBeNull();
|
expect(screen.queryByText('T1078')).toBeNull();
|
||||||
expect(screen.getByText('T1059')).toBeInTheDocument();
|
expect(screen.getByText('T1059')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -164,64 +212,84 @@ describe('MitreMatrixModal', () => {
|
|||||||
it('chevron expands subtechniques', async () => {
|
it('chevron expands subtechniques', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={[]}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => screen.getByText('T1078'));
|
await waitFor(() => screen.getByText('T1078'));
|
||||||
|
|
||||||
// Subtechniques should not be visible initially
|
|
||||||
expect(screen.queryByText(/Default Accounts/)).toBeNull();
|
expect(screen.queryByText(/Default Accounts/)).toBeNull();
|
||||||
|
|
||||||
// Click the expand chevron for T1078
|
|
||||||
const expandBtn = screen.getByRole('button', { name: /Expand T1078/i });
|
const expandBtn = screen.getByRole('button', { name: /Expand T1078/i });
|
||||||
await user.click(expandBtn);
|
await user.click(expandBtn);
|
||||||
|
|
||||||
expect(screen.getByText(/Default Accounts/)).toBeInTheDocument();
|
expect(screen.getByText(/Default Accounts/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Apply button shows technique count', async () => {
|
it('Apply button shows item count when techniques selected', async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={vi.fn()} onCancel={vi.fn()} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={SELECTION}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: /Apply 1 technique/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Apply 1 item/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Apply button is disabled when no techniques selected and no initial selection', async () => {
|
it('Apply button is disabled when nothing selected and no initial selection', async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={[]}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => screen.getByText('T1078'));
|
await waitFor(() => screen.getByText('T1078'));
|
||||||
|
|
||||||
// Label is "Clear all" when totalSelected === 0, but it's disabled when initialSelection is also empty
|
|
||||||
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
||||||
expect(applyBtn).toBeDisabled();
|
expect(applyBtn).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Apply button shows "Clear all" and stays enabled when initial selection is deselected', async () => {
|
it('Apply button shows "Clear all" and is enabled when initial selection is deselected', async () => {
|
||||||
const onApply = vi.fn();
|
const onApply = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={onApply} onCancel={vi.fn()} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={SELECTION}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={onApply}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => screen.getByText('T1078'));
|
await waitFor(() => screen.getByText('T1078'));
|
||||||
|
|
||||||
// Deselect T1078 (it was pre-selected)
|
|
||||||
const t1078Btn = screen.getAllByRole('button').find(
|
const t1078Btn = screen.getAllByRole('button').find(
|
||||||
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
|
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
|
||||||
);
|
);
|
||||||
await user.click(t1078Btn!);
|
await user.click(t1078Btn!);
|
||||||
|
|
||||||
// Button should show "Clear all" and be enabled (user explicitly clearing the list)
|
|
||||||
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
||||||
expect(applyBtn).not.toBeDisabled();
|
expect(applyBtn).not.toBeDisabled();
|
||||||
await user.click(applyBtn);
|
await user.click(applyBtn);
|
||||||
expect(onApply).toHaveBeenCalledWith([]);
|
expect(onApply).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ techniques: [], tactics: [] }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('backdrop click calls onCancel', async () => {
|
it('backdrop click calls onCancel', async () => {
|
||||||
@@ -229,10 +297,15 @@ describe('MitreMatrixModal', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={onCancel} />,
|
<MitreMatrixModal
|
||||||
|
isOpen
|
||||||
|
initialTechniques={[]}
|
||||||
|
initialTactics={NO_TACTICS}
|
||||||
|
onApply={vi.fn()}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Click the backdrop (the fixed inset div behind the modal)
|
|
||||||
const backdrop = document.querySelector('.bg-ink\\/60') as HTMLElement;
|
const backdrop = document.querySelector('.bg-ink\\/60') as HTMLElement;
|
||||||
if (backdrop) await user.click(backdrop);
|
if (backdrop) await user.click(backdrop);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { MitreTechniqueTag } from '@/components/MitreTechniqueTag';
|
import { MitreTechniqueTag, MitreTacticTag } from '@/components/MitreTechniqueTag';
|
||||||
import { renderWithProviders } from './utils';
|
import { renderWithProviders } from './utils';
|
||||||
|
import type { MitreTacticRef } from '@/api/types';
|
||||||
|
|
||||||
const TECHNIQUE = { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] };
|
const TECHNIQUE = { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] };
|
||||||
|
const TACTIC: MitreTacticRef = { id: 'TA0007', name: 'Discovery' };
|
||||||
|
|
||||||
describe('MitreTechniqueTag', () => {
|
describe('MitreTechniqueTag', () => {
|
||||||
it('renders id and name', () => {
|
it('renders id and name in title attribute (AC-22.2)', () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} />,
|
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('T1059')).toBeInTheDocument();
|
expect(screen.getByText('T1059')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Command and Scripting Interpreter/)).toBeInTheDocument();
|
// Name is in title= only, not as visible text
|
||||||
|
expect(screen.getByTitle(/Command and Scripting Interpreter/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Command and Scripting Interpreter/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows remove button when not disabled', () => {
|
it('shows remove button when not disabled', () => {
|
||||||
@@ -39,3 +43,37 @@ describe('MitreTechniqueTag', () => {
|
|||||||
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull();
|
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MitreTacticTag', () => {
|
||||||
|
it('renders tactic id with title containing name', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('TA0007')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle(/Discovery/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows remove button when not disabled', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: /Remove TA0007/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking × calls onRemove', async () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<MitreTacticTag tactic={TACTIC} onRemove={onRemove} />,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: /Remove TA0007/i }));
|
||||||
|
expect(onRemove).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides remove button when disabled', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} disabled />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import MockAdapter from 'axios-mock-adapter';
|
|||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
|
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
|
||||||
import { renderWithProviders } from './utils';
|
import { renderWithProviders } from './utils';
|
||||||
import type { MitreTechnique } from '@/api/types';
|
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||||
|
|
||||||
const T1059: MitreTechnique = { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] };
|
const T1059: MitreTechnique = { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] };
|
||||||
const T1078: MitreTechnique = { id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] };
|
const T1078: MitreTechnique = { id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] };
|
||||||
|
const TA0007: MitreTacticRef = { id: 'TA0007', name: 'Discovery' };
|
||||||
|
|
||||||
vi.mock('@/hooks/useAuth', () => ({
|
vi.mock('@/hooks/useAuth', () => ({
|
||||||
useAuth: () => ({
|
useAuth: () => ({
|
||||||
@@ -23,6 +24,15 @@ vi.mock('@/hooks/useAuth', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const SIM_RESPONSE = {
|
||||||
|
id: 7, engagement_id: 42, name: 'test', techniques: [], tactics: [],
|
||||||
|
description: null, commands: null, prerequisites: null,
|
||||||
|
executed_at: null, execution_result: null, log_source: null,
|
||||||
|
logs: null, soc_comment: null, incident_number: null,
|
||||||
|
status: 'pending', created_at: '2026-01-01', updated_at: null,
|
||||||
|
created_by: { id: 1, username: 'alice' },
|
||||||
|
};
|
||||||
|
|
||||||
describe('MitreTechniquesField', () => {
|
describe('MitreTechniquesField', () => {
|
||||||
let mock: MockAdapter;
|
let mock: MockAdapter;
|
||||||
|
|
||||||
@@ -34,61 +44,54 @@ describe('MitreTechniquesField', () => {
|
|||||||
mock.restore();
|
mock.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows empty state message when no techniques', () => {
|
it('shows empty state message when no techniques or tactics', () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
|
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/No techniques selected/i)).toBeInTheDocument();
|
expect(screen.getByText(/No techniques selected/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders tags for each technique', () => {
|
it('renders technique tags for each technique', () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreTechniquesField value={[T1059, T1078]} simulationId={7} engagementId={42} />,
|
<MitreTechniquesField value={[T1059, T1078]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||||
);
|
);
|
||||||
expect(screen.getAllByTestId('mitre-technique-tag')).toHaveLength(2);
|
expect(screen.getAllByTestId('mitre-technique-tag')).toHaveLength(2);
|
||||||
expect(screen.getByText('T1059')).toBeInTheDocument();
|
expect(screen.getByTitle(/T1059/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('T1078')).toBeInTheDocument();
|
expect(screen.getByTitle(/T1078/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Add technique and Quick search buttons when not disabled', () => {
|
it('renders tactic chips alongside technique chips', () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
|
<MitreTechniquesField value={[T1059]} tactics={[TA0007]} simulationId={7} engagementId={42} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole('button', { name: /Add technique/i })).toBeInTheDocument();
|
expect(screen.getAllByTestId('mitre-tactic-tag')).toHaveLength(1);
|
||||||
expect(screen.getByRole('button', { name: /Quick search/i })).toBeInTheDocument();
|
expect(screen.getByTitle(/TA0007/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides action buttons when disabled', () => {
|
it('shows search input and matrix icon when not disabled', () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreTechniquesField value={[T1059]} simulationId={7} engagementId={42} disabled />,
|
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||||
);
|
);
|
||||||
expect(screen.queryByRole('button', { name: /Add technique/i })).toBeNull();
|
expect(screen.getByRole('button', { name: /Open MITRE matrix/i })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /Quick search/i })).toBeNull();
|
// The search placeholder button
|
||||||
|
expect(screen.getByRole('button', { name: /Search technique/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('× button on tag calls PATCH with technique removed', async () => {
|
it('hides input row when disabled', () => {
|
||||||
mock.onPatch('/simulations/7').reply(200, {
|
renderWithProviders(
|
||||||
id: 7, engagement_id: 42, name: 'test', techniques: [],
|
<MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} disabled />,
|
||||||
description: null, commands: null, prerequisites: null,
|
);
|
||||||
executed_at: null, execution_result: null, log_source: null,
|
expect(screen.queryByRole('button', { name: /Open MITRE matrix/i })).toBeNull();
|
||||||
logs: null, soc_comment: null, incident_number: null,
|
|
||||||
status: 'pending', created_at: '2026-01-01', updated_at: null,
|
|
||||||
created_by: { id: 1, username: 'alice' },
|
|
||||||
});
|
});
|
||||||
// also mock GET simulations list for invalidation
|
|
||||||
|
it('× button on technique tag calls PATCH with technique removed', async () => {
|
||||||
|
mock.onPatch('/simulations/7').reply(200, SIM_RESPONSE);
|
||||||
mock.onGet('/engagements/42/simulations').reply(200, []);
|
mock.onGet('/engagements/42/simulations').reply(200, []);
|
||||||
mock.onGet('/simulations/7').reply(200, {
|
mock.onGet('/simulations/7').reply(200, SIM_RESPONSE);
|
||||||
id: 7, engagement_id: 42, name: 'test', techniques: [],
|
|
||||||
description: null, commands: null, prerequisites: null,
|
|
||||||
executed_at: null, execution_result: null, log_source: null,
|
|
||||||
logs: null, soc_comment: null, incident_number: null,
|
|
||||||
status: 'pending', created_at: '2026-01-01', updated_at: null,
|
|
||||||
created_by: { id: 1, username: 'alice' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreTechniquesField value={[T1059, T1078]} simulationId={7} engagementId={42} />,
|
<MitreTechniquesField value={[T1059, T1078]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeBtn = screen.getByRole('button', { name: /Remove T1059/i });
|
const removeBtn = screen.getByRole('button', { name: /Remove T1059/i });
|
||||||
@@ -98,15 +101,37 @@ describe('MitreTechniquesField', () => {
|
|||||||
expect(mock.history.patch.length).toBe(1);
|
expect(mock.history.patch.length).toBe(1);
|
||||||
const body = JSON.parse(mock.history.patch[0].data as string);
|
const body = JSON.parse(mock.history.patch[0].data as string);
|
||||||
expect(body.technique_ids).toEqual(['T1078']);
|
expect(body.technique_ids).toEqual(['T1078']);
|
||||||
|
expect(body.tactic_ids).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Quick search toggle shows picker input', async () => {
|
it('× button on tactic tag calls PATCH with tactic removed', async () => {
|
||||||
|
mock.onPatch('/simulations/7').reply(200, SIM_RESPONSE);
|
||||||
|
mock.onGet('/engagements/42/simulations').reply(200, []);
|
||||||
|
mock.onGet('/simulations/7').reply(200, SIM_RESPONSE);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
|
<MitreTechniquesField value={[T1059]} tactics={[TA0007]} simulationId={7} engagementId={42} />,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole('button', { name: /Quick search/i }));
|
|
||||||
|
const removeBtn = screen.getByRole('button', { name: /Remove TA0007/i });
|
||||||
|
await user.click(removeBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mock.history.patch.length).toBe(1);
|
||||||
|
const body = JSON.parse(mock.history.patch[0].data as string);
|
||||||
|
expect(body.tactic_ids).toEqual([]);
|
||||||
|
expect(body.technique_ids).toEqual(['T1059']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking search placeholder shows combobox input', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: /Search technique/i }));
|
||||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,35 +139,29 @@ describe('MitreTechniquesField', () => {
|
|||||||
mock.onGet('/mitre/techniques').reply(200, [T1059]);
|
mock.onGet('/mitre/techniques').reply(200, [T1059]);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreTechniquesField value={[T1059]} simulationId={7} engagementId={42} />,
|
<MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Open the quick-search picker
|
await user.click(screen.getByRole('button', { name: /Search technique/i }));
|
||||||
await user.click(screen.getByRole('button', { name: /Quick search/i }));
|
|
||||||
const combobox = screen.getByRole('combobox');
|
const combobox = screen.getByRole('combobox');
|
||||||
expect(combobox).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Type to trigger the search (debounce is 200ms but fake timers not needed — mock responds immediately)
|
|
||||||
await user.type(combobox, 'T1059');
|
await user.type(combobox, 'T1059');
|
||||||
|
|
||||||
// Wait for the option to appear in the listbox
|
|
||||||
const option = await screen.findByRole('option', { name: /T1059/i });
|
const option = await screen.findByRole('option', { name: /T1059/i });
|
||||||
expect(option).toBeInTheDocument();
|
expect(option).toBeInTheDocument();
|
||||||
|
|
||||||
// Select it via pointerDown (mirrors the component's onPointerDown handler)
|
|
||||||
await user.pointer({ target: option, keys: '[MouseLeft>]' });
|
await user.pointer({ target: option, keys: '[MouseLeft>]' });
|
||||||
|
|
||||||
// Dedup guard should have fired — no PATCH should have been sent
|
|
||||||
expect(mock.history.patch.length).toBe(0);
|
expect(mock.history.patch.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens matrix modal when Add technique is clicked', async () => {
|
it('opens matrix modal when matrix icon is clicked', async () => {
|
||||||
mock.onGet('/mitre/matrix').reply(200, []);
|
mock.onGet('/mitre/matrix').reply(200, []);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
|
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole('button', { name: /Add technique/i }));
|
await user.click(screen.getByRole('button', { name: /Open MITRE matrix/i }));
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const BASE_SIM: Simulation = {
|
|||||||
engagement_id: 42,
|
engagement_id: 42,
|
||||||
name: 'Recon test',
|
name: 'Recon test',
|
||||||
techniques: [],
|
techniques: [],
|
||||||
|
tactics: [],
|
||||||
description: 'Some description',
|
description: 'Some description',
|
||||||
commands: 'whoami\nipconfig',
|
commands: 'whoami\nipconfig',
|
||||||
prerequisites: null,
|
prerequisites: null,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const SIMULATIONS: Simulation[] = [
|
|||||||
engagement_id: 42,
|
engagement_id: 42,
|
||||||
name: 'Lateral movement test',
|
name: 'Lateral movement test',
|
||||||
techniques: [{ id: 'T1021', name: 'Remote Services', tactics: ['lateral-movement'] }],
|
techniques: [{ id: 'T1021', name: 'Remote Services', tactics: ['lateral-movement'] }],
|
||||||
|
tactics: [],
|
||||||
description: null,
|
description: null,
|
||||||
commands: null,
|
commands: null,
|
||||||
prerequisites: null,
|
prerequisites: null,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ L'évolution est tracée dans CHANGELOG.md § Changed sprint 4.
|
|||||||
- [ ] AC-21.1 : modèle `Simulation` gagne un champ `tactic_ids` (colonne JSON, liste de strings TA-id, défaut `[]`). Séparé de `techniques`.
|
- [ ] AC-21.1 : modèle `Simulation` gagne un champ `tactic_ids` (colonne JSON, liste de strings TA-id, défaut `[]`). Séparé de `techniques`.
|
||||||
- [ ] AC-21.2 : migration Alembic `0004_simulation_tactic_ids.py` — ADD COLUMN `tactic_ids` (JSON, NOT NULL, default `[]`). Pas besoin de batch pour ADD COLUMN (SQLite natif). Aucun backfill (default suffit).
|
- [ ] AC-21.2 : migration Alembic `0004_simulation_tactic_ids.py` — ADD COLUMN `tactic_ids` (JSON, NOT NULL, default `[]`). Pas besoin de batch pour ADD COLUMN (SQLite natif). Aucun backfill (default suffit).
|
||||||
- [ ] AC-21.3 : sérialisation Simulation expose `tactics: [{id, name}]` enrichi à partir de `tactic_ids` (id snapshot + name dérivé du bundle MITRE au runtime, comme pour `techniques`).
|
- [ ] AC-21.3 : sérialisation Simulation expose `tactics: [{id, name}]` enrichi à partir de `tactic_ids` (id snapshot + name dérivé du bundle MITRE au runtime, comme pour `techniques`).
|
||||||
- [ ] AC-21.4 : `PATCH /api/simulations/<sid>` accepte `{tactic_ids: ["TA0007", ...]}`. Validation : chaque ID doit exister (préfixe `TA`, présent dans `_TACTIC_ORDER`). Dedup serveur. ID inconnu → 400. Bundle non chargé → 503.
|
- [ ] AC-21.4 : `PATCH /api/simulations/<sid>` accepte `{tactic_ids: ["TA0007", ...]}`. Validation : chaque ID doit exister dans `_TACTIC_IDS` (mapping TA-id → short-name, cf §2 Service MITRE). Dedup serveur. ID inconnu → 400. Bundle non chargé → 503.
|
||||||
- [ ] AC-21.5 : `tactic_ids` est ajouté au gate SOC : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`. SOC envoie → 403. Auto-transition se déclenche aussi si `tactic_ids` non vide.
|
- [ ] AC-21.5 : `tactic_ids` est ajouté au gate SOC : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`. SOC envoie → 403. Auto-transition se déclenche aussi si `tactic_ids` non vide.
|
||||||
- [ ] AC-21.6 : `MitreMatrixModal` — le header de chaque colonne tactique devient cliquable (toggle de la tactique elle-même). État visuel distinct des techniques sélectionnées. Compteur passe à `N+M selected` (techniques + tactique).
|
- [ ] AC-21.6 : `MitreMatrixModal` — le header de chaque colonne tactique devient cliquable (toggle de la tactique elle-même). État visuel distinct des techniques sélectionnées. Compteur passe à `N+M selected` (techniques + tactique).
|
||||||
- [ ] AC-21.7 : `MitreTechniquesField` — tactiques sélectionnées affichées comme chips distincts (style différencié : `bg-primary text-canvas` au lieu de `bg-primary-soft text-primary-deep`). × pour retirer. Auto-save sur add/remove.
|
- [ ] AC-21.7 : `MitreTechniquesField` — tactiques sélectionnées affichées comme chips distincts (style différencié : `bg-primary text-canvas` au lieu de `bg-primary-soft text-primary-deep`). × pour retirer. Auto-save sur add/remove.
|
||||||
@@ -142,17 +142,53 @@ tactic_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list
|
|||||||
**Serializer** : `serialize_simulation(sim)` ajoute `tactics: [{id, name}]` enrichi runtime.
|
**Serializer** : `serialize_simulation(sim)` ajoute `tactics: [{id, name}]` enrichi runtime.
|
||||||
|
|
||||||
**Service MITRE** :
|
**Service MITRE** :
|
||||||
- Nouvelle fonction `lookup_tactic(tactic_id)` → `{id, name}` ou None.
|
- Sprint 3 a indexé les tactiques par **short-name** (`"initial-access"`, `"execution"`, `...`) dans `_TACTIC_ORDER` et `TACTIC_NAMES`. La SPEC et le plan sprint 4 utilisent la notation **TA-id** (`"TA0001"`, `"TA0007"`, etc.). Il faut un mapping TA-id → short-name pour valider/résoudre les `tactic_ids` reçus.
|
||||||
- Nouvelle fonction `get_tactic_name(tactic_id)` → name string ou fallback id.
|
- Ajouter une constante module-level (12 entrées hardcodées, MITRE standard stable — attention, les TA-ids ne sont PAS séquentiels) :
|
||||||
- `_TACTIC_ORDER` / `_TACTIC_NAMES` réutilisés.
|
```python
|
||||||
|
_TACTIC_IDS: dict[str, str] = {
|
||||||
|
"TA0001": "initial-access",
|
||||||
|
"TA0002": "execution",
|
||||||
|
"TA0003": "persistence",
|
||||||
|
"TA0004": "privilege-escalation",
|
||||||
|
"TA0005": "defense-evasion",
|
||||||
|
"TA0006": "credential-access",
|
||||||
|
"TA0007": "discovery",
|
||||||
|
"TA0008": "lateral-movement",
|
||||||
|
"TA0009": "collection",
|
||||||
|
"TA0011": "command-and-control",
|
||||||
|
"TA0010": "exfiltration",
|
||||||
|
"TA0040": "impact",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Nouvelle fonction `lookup_tactic(tactic_id: str) -> dict | None` :
|
||||||
|
```python
|
||||||
|
short = _TACTIC_IDS.get(tactic_id)
|
||||||
|
if short is None:
|
||||||
|
return None
|
||||||
|
return {"id": tactic_id, "name": TACTIC_NAMES[short]}
|
||||||
|
```
|
||||||
|
- Nouvelle fonction `get_tactic_name(tactic_id: str) -> str | None` : pareil mais retourne juste le name.
|
||||||
|
- Validation `tactic_ids` dans `simulation_workflow.py` : un id absent de `_TACTIC_IDS` → 400 `{"error": "unknown tactic id: <id>"}`.
|
||||||
|
|
||||||
**Service workflow `simulation_workflow.py`** — modifications :
|
**Service workflow `simulation_workflow.py`** — modifications :
|
||||||
1. **Guard `done` (AC-18.1)** : tout en haut de `apply_patch`, AVANT le check RBAC, si `simulation.status == "done"` → 409 `{error: "simulation is done — reopen first"}`.
|
1. **Guard `done` (AC-18.1)** : tout en haut de `apply_patch`, AVANT le check RBAC, si `simulation.status == "done"` → 409 `{error: "simulation is done — reopen first"}`. Vaut pour TOUS les rôles, admin compris.
|
||||||
2. **SOC gate étendu** : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`.
|
2. **SOC gate étendu** : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`.
|
||||||
3. **Validation `tactic_ids`** upfront (similaire à `technique_ids`) : tous les IDs validés contre le bundle, dedup `dict.fromkeys`. Bundle non chargé → 503.
|
3. **Validation `tactic_ids`** upfront (similaire à `technique_ids`) : tous les IDs validés contre le bundle, dedup `dict.fromkeys`. Bundle non chargé → 503.
|
||||||
4. **Auto-transition** : ajouter le check `len(payload["tactic_ids"]) > 0` au calcul `auto_trigger`.
|
4. **Auto-transition** : ajouter le check `len(payload["tactic_ids"]) > 0` au calcul `auto_trigger`.
|
||||||
5. **Transition `done → review_required` (AC-18.2)** : ajouter ce cas au state machine. Autorisé admin + redteam + soc. Update `updated_at`. Autres transitions depuis `done` → 409.
|
5. **Transition `done → review_required` (AC-18.2)** — **implémentation précise** : le dict `_ALLOWED_TRANSITIONS` actuel est keyé par target status et a déjà une entrée `"review_required"` avec from={pending, in_progress} et roles={admin, redteam}. On NE peut PAS ajouter une 2e entrée avec la même clé. À la place, dans `transition()`, AVANT le lookup dict, ajoute un cas spécial qui suit les patterns existants du fichier :
|
||||||
6. **Hook engagement auto-status (AC-19.1)** : après une transition de simu vers `in_progress` (auto OU manual), appeler une fonction `_maybe_activate_engagement(simulation)` qui, si `simulation.engagement.status == "planned"`, set `engagement.status = "active"` et add à la session (commit en même temps que la simu).
|
```python
|
||||||
|
# transition() returns tuple[Any, int] | None — None on success, error tuple otherwise.
|
||||||
|
# Existing functions use datetime.now(UTC) (timezone-aware, not deprecated utcnow).
|
||||||
|
# Enum values are UPPERCASE: SimulationStatus.DONE, SimulationStatus.REVIEW_REQUIRED.
|
||||||
|
if to_status == "review_required" and simulation.status == SimulationStatus.DONE:
|
||||||
|
simulation.status = SimulationStatus.REVIEW_REQUIRED
|
||||||
|
simulation.updated_at = datetime.now(UTC)
|
||||||
|
db.session.commit()
|
||||||
|
return None
|
||||||
|
# ... reste de la fonction inchangée (dict lookup pour les autres cas)
|
||||||
|
```
|
||||||
|
Pas de check explicite du rôle ici — `@login_required` upstream + l'enum User limité à admin/redteam/soc rendent la défense superflue (KISS). Autres transitions depuis `done` (vers `pending`, `in_progress`, `done` lui-même) → 409 via le dict lookup qui ne les couvre pas.
|
||||||
|
6. **Hook engagement auto-status (AC-19.1)** : après une transition de simu vers `in_progress` (auto OU manual), appeler une fonction `_maybe_activate_engagement(simulation)` qui, si `simulation.engagement.status == "planned"`, set `engagement.status = "active"` et `db.session.add(engagement)`. **NE PAS appeler `db.session.commit()` dans le helper** — le caller (`api/simulations.py:update_simulation`) gère le commit final, sinon double-commit.
|
||||||
|
|
||||||
**API `simulations.py`** :
|
**API `simulations.py`** :
|
||||||
- PATCH : le check status==done est fait dans `apply_patch` (voir au-dessus).
|
- PATCH : le check status==done est fait dans `apply_patch` (voir au-dessus).
|
||||||
@@ -200,6 +236,7 @@ Paths absolus dans le summary final. Si le dev server n'a pas pu tourner, dis-le
|
|||||||
|
|
||||||
**US-19 — Engagement auto-status (côté UI)**
|
**US-19 — Engagement auto-status (côté UI)**
|
||||||
- `useUpdateSimulation` et `useTransitionSimulation` : ajouter `["engagement", eid]` et `["engagements"]` aux invalidations après mutation réussie. Pas d'autre changement visuel.
|
- `useUpdateSimulation` et `useTransitionSimulation` : ajouter `["engagement", eid]` et `["engagements"]` aux invalidations après mutation réussie. Pas d'autre changement visuel.
|
||||||
|
- **Note (spec-reviewer Pass 3)** : `eid` n'est pas directement disponible dans la signature des hooks (qui prennent `sid`). Solution : lire `engagement_id` depuis la response simulation (le backend l'expose toujours, cf serialize_simulation sprint 2) OU le passer en arg supplémentaire au hook si plus propre. Pas un trou plan, juste à anticiper.
|
||||||
|
|
||||||
**US-20 — Matrice MITRE attack.mitre.org look**
|
**US-20 — Matrice MITRE attack.mitre.org look**
|
||||||
- `MitreMatrixModal.tsx` overhaul :
|
- `MitreMatrixModal.tsx` overhaul :
|
||||||
@@ -213,7 +250,8 @@ Paths absolus dans le summary final. Si le dev server n'a pas pu tourner, dis-le
|
|||||||
**US-21 — Tactic selection**
|
**US-21 — Tactic selection**
|
||||||
- `MitreMatrixModal.tsx` : header de tactique cliquable (toggle). État visuel distinct.
|
- `MitreMatrixModal.tsx` : header de tactique cliquable (toggle). État visuel distinct.
|
||||||
- Apply renvoie `{techniques, tactics}` au parent.
|
- Apply renvoie `{techniques, tactics}` au parent.
|
||||||
- `MitreTechniquesField.tsx` : tactic chips style différencié `bg-primary text-canvas`. Auto-save tactic_ids.
|
- `MitreTechniquesField.tsx` : tactic chips style différencié `bg-primary text-canvas`. Auto-save.
|
||||||
|
- **PATCH combiné (spec-reviewer fix #4)** : Apply depuis la matrice → UN SEUL PATCH `{technique_ids: [...], tactic_ids: [...]}` (les 2 listes ensemble). Pas 2 PATCH séquentiels (risque de race + risque que le 2nd appel hit le guard done). Remove via × sur un tag → un PATCH avec la liste mise à jour (seulement la dimension qui change : `technique_ids` ou `tactic_ids`). Quick Search select → 1 PATCH `{technique_ids: [...]}` (le picker n'ajoute que des techniques). Toutes les mutations passent par `useUpdateSimulation` en un appel atomique.
|
||||||
|
|
||||||
**US-22 — Refonte input MITRE**
|
**US-22 — Refonte input MITRE**
|
||||||
- `MitreTechniquesField.tsx` :
|
- `MitreTechniquesField.tsx` :
|
||||||
@@ -221,6 +259,7 @@ Paths absolus dans le summary final. Si le dev server n'a pas pu tourner, dis-le
|
|||||||
- Plus de boutons textuels "Add Technique" / "Quick Search".
|
- Plus de boutons textuels "Add Technique" / "Quick Search".
|
||||||
- Chips compacts (T-id ou TA-id seul, name en `title=`).
|
- Chips compacts (T-id ou TA-id seul, name en `title=`).
|
||||||
- Empty state minimal.
|
- Empty state minimal.
|
||||||
|
- **`SimulationFormPage.tsx` — call site update (spec-reviewer fix #4)** : la signature de `MitreTechniquesField` change de `value: MitreTechnique[]` (sprint 3) à `value: {techniques: MitreTechnique[], tactics: MitreTactic[]}`. La page doit passer `value={{techniques: sim.techniques, tactics: sim.tactics}}` (le champ `sim.tactics` vient du nouveau serializer backend). TypeScript catch le miss mais flag-le explicitement pour ne pas l'oublier.
|
||||||
|
|
||||||
**US-23 — Dark mode**
|
**US-23 — Dark mode**
|
||||||
- `Layout.tsx` : toggle theme dans la topbar. Hook `useTheme()` (localStorage + media query). 3 états avec cycle.
|
- `Layout.tsx` : toggle theme dans la topbar. Hook `useTheme()` (localStorage + media query). 3 états avec cycle.
|
||||||
@@ -267,6 +306,8 @@ US-24/25 non e2e (process / repo files). Couverture par dogfood (la PR sprint 4
|
|||||||
|
|
||||||
Adapter les sprint 2/3 e2e si l'audit boutons (AC-17.2) renomme certains labels.
|
Adapter les sprint 2/3 e2e si l'audit boutons (AC-17.2) renomme certains labels.
|
||||||
|
|
||||||
|
**Spec-reviewer INFO B** : AC-22.2 change le format des chips de "T1059 — Command and Scripting Interpreter" (sprint 3) à juste "T1059" (avec name dans `title=`). Les e2e sprint 3 (notamment `us14-techniques-tags.spec.ts`) qui assertent le format complet doivent être mis à jour. Pas seulement les labels boutons.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Décisions arrêtées
|
## 6. Décisions arrêtées
|
||||||
|
|||||||
Reference in New Issue
Block a user