feat(m4): frontend MitreTagPicker + /mitre showcase page

- lib/mitre.ts: shared types (MitreTactic, Technique, Subtechnique, MitreTag
  kind/id/external_id/name) + TanStack query keys.
- components/MitreTagPicker.tsx: three-column controlled picker (tactic →
  technique → subtechnique), multi-select with chip-removal, autocomplete on
  each column, ARIA labels for screen readers. Returns MitreTag[] via
  value/onChange — drop-in for M5 template forms.
- pages/MitrePage.tsx: status card (version, source URL, last_sync), admin-
  gated Trigger Sync button with success/error alerts, picker showcase, JSON
  preview of the current selection.
- Layout adds MITRE nav link for any logged-in user; App.tsx adds the
  /mitre route under RequireAuth. HomePage roadmap bumped to next: M5
  templates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-12 13:54:15 +02:00
parent 872f3c046a
commit 8a1dd58c83
6 changed files with 465 additions and 4 deletions

View File

@@ -36,6 +36,7 @@ export function Layout() {
<>
{navItem('/', 'Home')}
{navItem('/profile', 'Profile')}
{navItem('/mitre', 'MITRE')}
{state.user.is_admin && (
<>
{navItem('/admin/users', 'Users')}
@@ -68,7 +69,7 @@ export function Layout() {
<Outlet />
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · design system from tasks/design.md
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · design system from tasks/design.md
</footer>
</div>
</div>

View File

@@ -0,0 +1,272 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { apiGet } from '@/lib/api';
import {
mitreKeys,
type MitreSubtechnique,
type MitreTactic,
type MitreTag,
type MitreTechnique,
type Paginated,
} from '@/lib/mitre';
import { cn } from '@/lib/cn';
interface MitreTagPickerProps {
/** Already-selected tags. The parent owns the state. */
value: MitreTag[];
/** Called whenever the selection changes (replace semantics). */
onChange: (next: MitreTag[]) => void;
/** Hide the search box(es). Useful for compact embed in a sidebar. */
compact?: boolean;
className?: string;
}
function useTactics(q: string) {
return useQuery({
queryKey: mitreKeys.tactics(q),
queryFn: () =>
apiGet<Paginated<MitreTactic>>(
`/mitre/tactics${q ? `?q=${encodeURIComponent(q)}` : ''}`,
),
});
}
function useTechniques(tactic: string | null, q: string) {
return useQuery({
enabled: tactic !== null,
queryKey: mitreKeys.techniques(tactic ?? '', q),
queryFn: () => {
const params = new URLSearchParams();
if (tactic) params.set('tactic', tactic);
if (q) params.set('q', q);
return apiGet<Paginated<MitreTechnique>>(
`/mitre/techniques${params.toString() ? `?${params}` : ''}`,
);
},
});
}
function useSubtechniques(technique: string | null, q: string) {
return useQuery({
enabled: technique !== null,
queryKey: mitreKeys.subtechniques(technique ?? '', q),
queryFn: () => {
const params = new URLSearchParams();
if (technique) params.set('technique', technique);
if (q) params.set('q', q);
return apiGet<Paginated<MitreSubtechnique>>(
`/mitre/subtechniques${params.toString() ? `?${params}` : ''}`,
);
},
});
}
/**
* Three-column picker — Tactic > Technique > Sub-technique — with multi-select.
* Selected tags accumulate in the chips at the top.
*/
export function MitreTagPicker({ value, onChange, compact, className }: MitreTagPickerProps) {
const [activeTactic, setActiveTactic] = useState<string | null>(null);
const [activeTechnique, setActiveTechnique] = useState<string | null>(null);
const [qTactic, setQTactic] = useState('');
const [qTechnique, setQTechnique] = useState('');
const [qSub, setQSub] = useState('');
const tactics = useTactics(qTactic);
const techniques = useTechniques(activeTactic, qTechnique);
const subtechniques = useSubtechniques(activeTechnique, qSub);
const selectedKey = useMemo(() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)), [value]);
function toggle(tag: MitreTag) {
const key = `${tag.kind}:${tag.external_id}`;
if (selectedKey.has(key)) {
onChange(value.filter((t) => `${t.kind}:${t.external_id}` !== key));
} else {
onChange([...value, tag]);
}
}
function colorForKind(kind: 'tactic' | 'technique' | 'subtechnique') {
return kind === 'tactic' ? 'cyan' : kind === 'technique' ? 'orange' : 'purple';
}
return (
<div className={cn('rounded-lg border border-border bg-bg-card p-4', className)} data-testid="mitre-tag-picker">
{value.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-1" data-testid="mitre-selected">
{value.map((t) => (
<button
key={`${t.kind}:${t.external_id}`}
type="button"
onClick={() => toggle(t)}
className="inline-flex items-center"
aria-label={`Remove ${t.external_id}`}
>
<Tag accent={colorForKind(t.kind)}>
{t.external_id} · {t.name}
</Tag>
</button>
))}
</div>
)}
<div className="grid gap-3 md:grid-cols-3">
{/* Tactics column */}
<div>
{!compact && (
<TextField
label="Tactic search"
value={qTactic}
onChange={(e) => setQTactic(e.target.value)}
placeholder="e.g. Credential"
/>
)}
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-tactics-column">
{tactics.isLoading && <p className="text-2xs text-text-dim">Loading</p>}
{tactics.data?.items.map((t) => {
const active = activeTactic === t.external_id;
const selected = selectedKey.has(`tactic:${t.external_id}`);
return (
<div
key={t.id}
className={cn(
'group flex items-center gap-2 rounded border px-2 py-1 cursor-pointer',
active ? 'border-cyan' : 'border-transparent hover:border-cyan',
)}
onClick={() => {
setActiveTactic(t.external_id);
setActiveTechnique(null);
}}
data-testid={`mitre-tactic-${t.external_id}`}
>
<input
type="checkbox"
checked={selected}
onClick={(e) => e.stopPropagation()}
onChange={() =>
toggle({ kind: 'tactic', id: t.id, external_id: t.external_id, name: t.name })
}
aria-label={`Select ${t.external_id}`}
/>
<span className="font-mono text-2xs text-cyan">{t.external_id}</span>
<span className="text-2xs">{t.name}</span>
</div>
);
})}
</div>
</div>
{/* Techniques column */}
<div>
{!compact && (
<TextField
label="Technique search"
value={qTechnique}
onChange={(e) => setQTechnique(e.target.value)}
placeholder="e.g. T1059"
/>
)}
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-techniques-column">
{activeTactic === null && (
<p className="text-2xs text-text-dim">Select a tactic to list its techniques.</p>
)}
{techniques.isLoading && <p className="text-2xs text-text-dim">Loading</p>}
{techniques.data?.items.map((t) => {
const active = activeTechnique === t.external_id;
const selected = selectedKey.has(`technique:${t.external_id}`);
return (
<div
key={t.id}
className={cn(
'group flex items-center gap-2 rounded border px-2 py-1 cursor-pointer',
active ? 'border-orange' : 'border-transparent hover:border-orange',
)}
onClick={() => setActiveTechnique(t.external_id)}
data-testid={`mitre-technique-${t.external_id}`}
>
<input
type="checkbox"
checked={selected}
onClick={(e) => e.stopPropagation()}
onChange={() =>
toggle({
kind: 'technique',
id: t.id,
external_id: t.external_id,
name: t.name,
})
}
aria-label={`Select ${t.external_id}`}
/>
<span className="font-mono text-2xs text-orange">{t.external_id}</span>
<span className="text-2xs">{t.name}</span>
</div>
);
})}
{techniques.data && techniques.data.items.length === 0 && activeTactic && (
<p className="text-2xs text-text-dim">No techniques for this tactic.</p>
)}
</div>
</div>
{/* Sub-techniques column */}
<div>
{!compact && (
<TextField
label="Sub-technique search"
value={qSub}
onChange={(e) => setQSub(e.target.value)}
placeholder="e.g. Powershell"
/>
)}
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-subtechniques-column">
{activeTechnique === null && (
<p className="text-2xs text-text-dim">Select a technique to list its sub-techniques.</p>
)}
{subtechniques.isLoading && <p className="text-2xs text-text-dim">Loading</p>}
{subtechniques.data?.items.map((sb) => {
const selected = selectedKey.has(`subtechnique:${sb.external_id}`);
return (
<div
key={sb.id}
className="flex items-center gap-2 rounded border border-transparent hover:border-purple px-2 py-1 cursor-pointer"
onClick={() =>
toggle({
kind: 'subtechnique',
id: sb.id,
external_id: sb.external_id,
name: sb.name,
})
}
data-testid={`mitre-subtechnique-${sb.external_id}`}
>
<input
type="checkbox"
checked={selected}
readOnly
aria-label={`Select ${sb.external_id}`}
/>
<span className="font-mono text-2xs text-purple">{sb.external_id}</span>
<span className="text-2xs">{sb.name}</span>
</div>
);
})}
{subtechniques.data && subtechniques.data.items.length === 0 && activeTechnique && (
<p className="text-2xs text-text-dim">No sub-techniques.</p>
)}
</div>
</div>
</div>
{(tactics.isError || techniques.isError || subtechniques.isError) && (
<Alert accent="red" className="mt-3">
Failed to load MITRE data has `make seed-mitre` been run?
</Alert>
)}
</div>
);
}