306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
|
|
import { useState } from 'react';
|
||
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
||
|
|
|
||
|
|
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, apiDelete, apiGet, apiPost } from '@/lib/api';
|
||
|
|
import {
|
||
|
|
MISSION_STATUS_ACCENT,
|
||
|
|
MISSION_STATUS_LABEL,
|
||
|
|
missionKeys,
|
||
|
|
type Mission,
|
||
|
|
type MissionStatus,
|
||
|
|
type TransitionPayload,
|
||
|
|
} from '@/lib/missions';
|
||
|
|
|
||
|
|
const TABS = ['tests', 'members', 'synthesis', 'export'] as const;
|
||
|
|
type Tab = (typeof TABS)[number];
|
||
|
|
|
||
|
|
const ALLOWED_TRANSITIONS: Record<MissionStatus, MissionStatus[]> = {
|
||
|
|
draft: ['in_progress', 'archived'],
|
||
|
|
in_progress: ['completed', 'archived'],
|
||
|
|
completed: ['archived'],
|
||
|
|
archived: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
function useMission(id: string) {
|
||
|
|
return useQuery({
|
||
|
|
queryKey: missionKeys.detail(id),
|
||
|
|
queryFn: () => apiGet<Mission>(`/missions/${id}`),
|
||
|
|
enabled: !!id,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatDateRange(start: string | null, end: string | null): string {
|
||
|
|
if (!start && !end) return 'No dates set';
|
||
|
|
if (start && end) return `${start} → ${end}`;
|
||
|
|
return start ?? end ?? '';
|
||
|
|
}
|
||
|
|
|
||
|
|
export function MissionDetailPage() {
|
||
|
|
const params = useParams();
|
||
|
|
const missionId = params.id ?? '';
|
||
|
|
const navigate = useNavigate();
|
||
|
|
const qc = useQueryClient();
|
||
|
|
|
||
|
|
const [tab, setTab] = useState<Tab>('tests');
|
||
|
|
const detail = useMission(missionId);
|
||
|
|
|
||
|
|
const transition = useMutation({
|
||
|
|
mutationFn: (body: TransitionPayload) =>
|
||
|
|
apiPost<Mission>(`/missions/${missionId}/transition`, body),
|
||
|
|
onSuccess: () => {
|
||
|
|
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
|
||
|
|
qc.invalidateQueries({ queryKey: missionKeys.list() });
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const remove = useMutation({
|
||
|
|
mutationFn: () => apiDelete<{ ok: true }>(`/missions/${missionId}`),
|
||
|
|
onSuccess: () => {
|
||
|
|
qc.invalidateQueries({ queryKey: missionKeys.list() });
|
||
|
|
navigate('/missions');
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const apiErr = detail.error instanceof ApiError ? detail.error : null;
|
||
|
|
const m = detail.data;
|
||
|
|
|
||
|
|
if (apiErr) {
|
||
|
|
return (
|
||
|
|
<section>
|
||
|
|
<SectionHeader prefix="Mission" highlight="Not found" accent="rose" />
|
||
|
|
<Alert accent="rose">{apiErr.message}</Alert>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (!m) {
|
||
|
|
return <p className="font-mono text-xs text-text-dim">Loading mission…</p>;
|
||
|
|
}
|
||
|
|
|
||
|
|
const accent = MISSION_STATUS_ACCENT[m.status];
|
||
|
|
const allowedNext = ALLOWED_TRANSITIONS[m.status];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<section data-testid={`mission-detail-${m.id}`}>
|
||
|
|
<div className="flex items-baseline justify-between flex-wrap gap-3">
|
||
|
|
<SectionHeader prefix="Mission" highlight={m.name} accent={accent} />
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Tag accent={accent}>{MISSION_STATUS_LABEL[m.status]}</Tag>
|
||
|
|
{allowedNext.map((target) => (
|
||
|
|
<Button
|
||
|
|
key={target}
|
||
|
|
accent={target === 'archived' ? 'teal' : 'cyan'}
|
||
|
|
onClick={() => transition.mutate({ status: target })}
|
||
|
|
data-testid={`mission-transition-${target}`}
|
||
|
|
disabled={transition.isPending}
|
||
|
|
>
|
||
|
|
→ {MISSION_STATUS_LABEL[target]}
|
||
|
|
</Button>
|
||
|
|
))}
|
||
|
|
<Button
|
||
|
|
accent="rose"
|
||
|
|
variant="ghost"
|
||
|
|
onClick={() => {
|
||
|
|
if (
|
||
|
|
window.confirm(
|
||
|
|
`Soft-delete mission "${m.name}"? An admin can restore from the trash.`,
|
||
|
|
)
|
||
|
|
) {
|
||
|
|
remove.mutate();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
data-testid="mission-delete"
|
||
|
|
disabled={remove.isPending}
|
||
|
|
>
|
||
|
|
Delete
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Card className="mb-4">
|
||
|
|
<dl className="grid grid-cols-2 gap-3 md:grid-cols-4 font-mono text-2xs">
|
||
|
|
<div>
|
||
|
|
<dt className="text-text-dim uppercase tracking-wider2">Client</dt>
|
||
|
|
<dd className="text-text-bright">{m.client_target ?? '—'}</dd>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<dt className="text-text-dim uppercase tracking-wider2">Dates</dt>
|
||
|
|
<dd className="text-text-bright">
|
||
|
|
{formatDateRange(m.date_start, m.date_end)}
|
||
|
|
</dd>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<dt className="text-text-dim uppercase tracking-wider2">Scenarios</dt>
|
||
|
|
<dd className="text-text-bright">{m.scenarios_count}</dd>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<dt className="text-text-dim uppercase tracking-wider2">Tests</dt>
|
||
|
|
<dd className="text-text-bright">{m.tests_count}</dd>
|
||
|
|
</div>
|
||
|
|
</dl>
|
||
|
|
{m.description_md && (
|
||
|
|
<pre className="mt-3 whitespace-pre-wrap font-mono text-xs text-text">{m.description_md}</pre>
|
||
|
|
)}
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<nav className="flex gap-1 border-b border-border mb-4" aria-label="Mission tabs">
|
||
|
|
{TABS.map((t) => (
|
||
|
|
<button
|
||
|
|
key={t}
|
||
|
|
type="button"
|
||
|
|
onClick={() => setTab(t)}
|
||
|
|
data-testid={`mission-tab-${t}`}
|
||
|
|
className={`px-3 py-2 font-mono text-2xs uppercase tracking-wider2 ${
|
||
|
|
tab === t
|
||
|
|
? 'text-cyan border-b-2 border-cyan -mb-px'
|
||
|
|
: 'text-text-dim hover:text-text-bright'
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{t}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</nav>
|
||
|
|
|
||
|
|
{tab === 'tests' && (
|
||
|
|
<Card>
|
||
|
|
{m.scenarios.length === 0 ? (
|
||
|
|
<p className="font-mono text-xs text-text-dim">
|
||
|
|
No scenarios snapshotted yet.
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<div className="flex flex-col gap-4" data-testid="mission-scenarios">
|
||
|
|
{m.scenarios.map((sc) => (
|
||
|
|
<div
|
||
|
|
key={sc.id}
|
||
|
|
className="rounded-md border border-border bg-bg-card p-3"
|
||
|
|
data-testid={`mission-scenario-${sc.id}`}
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-2 mb-2">
|
||
|
|
<Tag accent="cyan">#{sc.position + 1}</Tag>
|
||
|
|
<p className="font-mono text-xs text-text-bright">
|
||
|
|
{sc.snapshot_name}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
{sc.snapshot_description && (
|
||
|
|
<p className="mb-2 font-mono text-2xs text-text-dim">
|
||
|
|
{sc.snapshot_description}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
<table className="w-full font-mono text-2xs">
|
||
|
|
<thead>
|
||
|
|
<tr className="text-text-dim uppercase tracking-wider2">
|
||
|
|
<th className="text-left py-1">#</th>
|
||
|
|
<th className="text-left py-1">Test</th>
|
||
|
|
<th className="text-left py-1">MITRE</th>
|
||
|
|
<th className="text-left py-1">OPSEC</th>
|
||
|
|
<th className="text-left py-1">State</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{sc.tests.map((t) => (
|
||
|
|
<tr
|
||
|
|
key={t.id}
|
||
|
|
className="border-t border-border/40"
|
||
|
|
data-testid={`mission-test-${t.id}`}
|
||
|
|
>
|
||
|
|
<td className="py-1 text-text-dim">{t.position + 1}</td>
|
||
|
|
<td className="py-1 text-text-bright">{t.snapshot_name}</td>
|
||
|
|
<td className="py-1">
|
||
|
|
<div className="flex flex-wrap gap-1">
|
||
|
|
{t.mitre_tags.map((tag) => (
|
||
|
|
<Tag
|
||
|
|
accent="cyan"
|
||
|
|
key={`${tag.kind}-${tag.external_id}`}
|
||
|
|
>
|
||
|
|
{tag.external_id}
|
||
|
|
</Tag>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td className="py-1 text-text">
|
||
|
|
{t.snapshot_opsec_level}
|
||
|
|
</td>
|
||
|
|
<td className="py-1">
|
||
|
|
<Tag
|
||
|
|
accent={
|
||
|
|
t.state === 'pending'
|
||
|
|
? 'teal'
|
||
|
|
: t.state === 'executed'
|
||
|
|
? 'orange'
|
||
|
|
: t.state === 'reviewed_by_blue'
|
||
|
|
? 'green'
|
||
|
|
: 'rose'
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{t.state}
|
||
|
|
</Tag>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{tab === 'members' && (
|
||
|
|
<Card>
|
||
|
|
{m.members.length === 0 ? (
|
||
|
|
<p className="font-mono text-xs text-text-dim">
|
||
|
|
No members assigned. An admin can add them via the API.
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<ul className="flex flex-col gap-2" data-testid="mission-members">
|
||
|
|
{m.members.map((mb) => (
|
||
|
|
<li
|
||
|
|
key={mb.user_id}
|
||
|
|
className="flex items-center justify-between rounded-md border border-border bg-bg-card p-3"
|
||
|
|
data-testid={`mission-member-${mb.user_id}`}
|
||
|
|
>
|
||
|
|
<div>
|
||
|
|
<p className="font-mono text-xs text-text-bright">
|
||
|
|
{mb.user_display_name ?? mb.user_email}
|
||
|
|
</p>
|
||
|
|
{mb.user_display_name && (
|
||
|
|
<p className="font-mono text-2xs text-text-dim">
|
||
|
|
{mb.user_email}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<Tag accent={mb.role_hint === 'red' ? 'red' : 'cyan'}>
|
||
|
|
{mb.role_hint}
|
||
|
|
</Tag>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{tab === 'synthesis' && (
|
||
|
|
<Card>
|
||
|
|
<p className="font-mono text-xs text-text-dim">
|
||
|
|
Reveal.js slide synthesis lands in M10.
|
||
|
|
</p>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{tab === 'export' && (
|
||
|
|
<Card>
|
||
|
|
<p className="font-mono text-xs text-text-dim">
|
||
|
|
JSON / CSV exports land in M11.
|
||
|
|
</p>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|