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>
2026-05-12 13:54:15 +02:00
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
import { useState } from 'react';
|
|
|
|
|
|
|
|
|
|
import { MitreTagPicker } from '@/components/MitreTagPicker';
|
|
|
|
|
import { Alert } from '@/components/ui/Alert';
|
|
|
|
|
import { Button } from '@/components/ui/Button';
|
|
|
|
|
import { Card } from '@/components/ui/Card';
|
|
|
|
|
import { SectionHeader } from '@/components/ui/SectionHeader';
|
|
|
|
|
import { Tag } from '@/components/ui/Tag';
|
|
|
|
|
import { ApiError, apiGet, apiPost } from '@/lib/api';
|
|
|
|
|
import { useAuth } from '@/lib/auth';
|
|
|
|
|
import { mitreKeys, type MitreStatus, type MitreTag } from '@/lib/mitre';
|
|
|
|
|
|
|
|
|
|
export function MitrePage() {
|
|
|
|
|
const { state } = useAuth();
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
const [selected, setSelected] = useState<MitreTag[]>([]);
|
|
|
|
|
const [syncResult, setSyncResult] = useState<string | null>(null);
|
|
|
|
|
const [syncError, setSyncError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const status = useQuery({
|
|
|
|
|
queryKey: mitreKeys.status,
|
|
|
|
|
queryFn: () => apiGet<MitreStatus>('/mitre/status'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sync = useMutation({
|
|
|
|
|
mutationFn: () => apiPost<Record<string, unknown>>('/mitre/sync'),
|
|
|
|
|
onMutate: () => {
|
|
|
|
|
setSyncResult(null);
|
|
|
|
|
setSyncError(null);
|
|
|
|
|
},
|
|
|
|
|
onSuccess: async (res) => {
|
|
|
|
|
const counts = `${res.tactics_upserted} tactics, ${res.techniques_upserted} techniques, ${res.subtechniques_upserted} subtechniques`;
|
|
|
|
|
setSyncResult(`Sync completed in ${(res as { duration_ms: number }).duration_ms / 1000}s — ${counts}.`);
|
|
|
|
|
await qc.invalidateQueries({ queryKey: ['mitre'] });
|
|
|
|
|
},
|
|
|
|
|
onError: (e) => {
|
|
|
|
|
if (e instanceof ApiError) {
|
|
|
|
|
const p = e.payload as { error?: string; message?: string } | null;
|
|
|
|
|
setSyncError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
|
|
|
|
|
} else {
|
|
|
|
|
setSyncError(e instanceof Error ? e.message : 'Sync failed');
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<SectionHeader
|
|
|
|
|
prefix="MITRE"
|
|
|
|
|
highlight="ATT&CK"
|
|
|
|
|
accent="cyan"
|
|
|
|
|
description="Reference catalogue: tactics, techniques, sub-techniques. Tag picker reusable by tests and missions."
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 mb-6 [grid-template-columns:repeat(auto-fill,minmax(360px,1fr))]">
|
|
|
|
|
<Card accent="cyan" title="Source" sub={status.data?.version ? `version ${status.data.version}` : '—'}>
|
|
|
|
|
<p>
|
|
|
|
|
URL:{' '}
|
|
|
|
|
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs break-all">
|
|
|
|
|
{status.data?.source_url ?? status.data?.default_url ?? '—'}
|
|
|
|
|
</code>
|
|
|
|
|
</p>
|
|
|
|
|
<p className="mt-2">
|
|
|
|
|
Last sync:{' '}
|
|
|
|
|
<code
|
|
|
|
|
className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs"
|
|
|
|
|
data-testid="mitre-last-sync"
|
|
|
|
|
>
|
|
|
|
|
{status.data?.last_sync ?? 'never'}
|
|
|
|
|
</code>
|
|
|
|
|
</p>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{state.user?.is_admin && (
|
|
|
|
|
<Card accent="orange" title="Sync" sub="re-pull the bundle from the configured source">
|
|
|
|
|
<p className="text-2xs text-text-dim mb-3">
|
|
|
|
|
The seed CLI runs the same logic; trigger from here only if the URL is reachable from the api container.
|
|
|
|
|
</p>
|
|
|
|
|
<Button
|
|
|
|
|
accent="orange"
|
|
|
|
|
onClick={() => sync.mutate()}
|
|
|
|
|
disabled={sync.isPending}
|
|
|
|
|
data-testid="mitre-sync"
|
|
|
|
|
>
|
|
|
|
|
{sync.isPending ? 'Syncing…' : 'Trigger MITRE sync'}
|
|
|
|
|
</Button>
|
|
|
|
|
{syncResult && <Alert accent="green" className="mt-3">{syncResult}</Alert>}
|
|
|
|
|
{syncError && <Alert accent="red" className="mt-3">{syncError}</Alert>}
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<SectionHeader prefix="Tag" highlight="Picker" accent="orange" />
|
refactor(m4): full-bleed matrix + word-only line breaks
Two follow-up tweaks per user feedback ("wrap sur les mots, agrandit le
cadre"):
- Full-bleed wrapper: the matrix breaks out of the page's max-w-page (1400px)
constraint via `margin: 0 calc(50% - 50vw)` + `width: 100vw`, mirroring the
60px page padding internally. On wide viewports the picker now uses the
ENTIRE viewport width, so column widths grow proportionally — names that
used to wrap on 3 lines now fit on 1-2.
- Word-only wrapping: replaced `break-words` (overflow-wrap: break-word,
which falls back to mid-word breaks) with `break-normal hyphens-none`
(overflow-wrap: normal + word-break: normal). Cells break only at word
boundaries; if a single word is longer than the cell it overflows
visually rather than splitting `Aut\nhentication`-style. The grid is
configured `minmax(7rem, 1fr)` so the minimum column is wide enough for
every single word in MITRE v19 names, and stretches with available space.
- Spec §F2 rewritten as a bullet contract locking in: full-bleed, 15 cols
minmax(7rem, 1fr), word-only wrap, font sans 12px / count 10px, headers/
cells show name-only with external_id on hover + chips. Future spec-reviewer
passes can grade against this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:53:51 +02:00
|
|
|
{/* Full-bleed the matrix beyond max-w-page so it uses the full viewport
|
|
|
|
|
* width. `calc(50% - 50vw)` is the canonical CSS recipe: the element's
|
|
|
|
|
* left edge slides back to viewport x=0 regardless of how big the
|
|
|
|
|
* outer max-w-page container is. `px-[60px]` mirrors the page padding
|
|
|
|
|
* so cells don't touch the viewport edge. */}
|
|
|
|
|
<div
|
|
|
|
|
className="px-[60px]"
|
|
|
|
|
style={{
|
|
|
|
|
marginLeft: 'calc(50% - 50vw)',
|
|
|
|
|
marginRight: 'calc(50% - 50vw)',
|
|
|
|
|
width: '100vw',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<MitreTagPicker value={selected} onChange={setSelected} />
|
|
|
|
|
</div>
|
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>
2026-05-12 13:54:15 +02:00
|
|
|
|
|
|
|
|
{selected.length > 0 && (
|
|
|
|
|
<Card accent="purple" className="mt-6" title="Selected (preview payload)">
|
|
|
|
|
<pre className="text-2xs text-text-bright whitespace-pre-wrap" data-testid="mitre-selected-json">
|
|
|
|
|
{JSON.stringify(selected, null, 2)}
|
|
|
|
|
</pre>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{selected.length === 0 && (
|
|
|
|
|
<p className="mt-3 font-mono text-2xs text-text-dim">
|
|
|
|
|
Pick a tactic on the left, then a technique, then optionally a sub-technique. Selections accumulate.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
<Tag accent="cyan">Tactic</Tag>
|
|
|
|
|
<Tag accent="orange">Technique</Tag>
|
|
|
|
|
<Tag accent="purple">Sub-technique</Tag>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|