Files
Metamorph/frontend/src/pages/MissionDetailPage.tsx

306 lines
11 KiB
TypeScript
Raw Normal View History

feat(m6): missions + snapshot CRUD, membership visibility, status state machine Adds the mission layer that materialises template snapshots, plus the SPA list / 3-step wizard / detail page. Backend: - app/services/missions.py — create_mission snapshots scenarios, tests, MITRE tags in a 4-query write; list/get apply a non-admin membership filter that collapses to 404 (no existence leak); status state machine enforces draft → in_progress → completed → archived with archived as a sink; the non-admin creator is auto-added as role_hint='red' to retain visibility. - app/api/missions.py — 8 endpoints (list, get, create, update, add scenarios, set members, transition, soft-delete) with strict pydantic schemas. The transition endpoint splits the perm gate manually so archive requires mission.archive while other targets use mission.update. - app/api/users.py — new GET /users/roster returning (id, email, display_name) only, gated by user.read OR mission.create OR mission.update — lets non-admin wizard users see assignable peers without exposing the admin /users payload. - app/api/diag.py — /diag/reset truncates the mission_* tables before the template tables because the source_*_template_id FKs are ON DELETE SET NULL, which is cheaper to short-circuit by removing the children first. Frontend: - lib/missions.ts — typed client, queryKey factory, status accent map. - pages/MissionsListPage.tsx — list cards with status accent + filters (q, client, status). - pages/MissionsCreatePage.tsx — 3-step wizard (meta → scenarios → members) with member roster fed by /users/roster. - pages/MissionDetailPage.tsx — header + transition buttons (legal next states only) + Tests/Members/Synthesis/Export tabs. - Routes + nav entry (visible to anyone with mission.read or admin). Tests: - backend/tests/test_missions.py — 22 pytest covering snapshot fidelity, MITRE propagation, membership visibility, transition state machine, perm gating, member set replace, append scenarios, soft-delete, partial update, inverted-date rejection. - e2e/tests/m6-missions.spec.ts — 5 Playwright (snapshot freezing, non-admin visibility, status transitions + 409, SPA wizard end-to-end, list filter). Docs: - CHANGELOG, tasks/testing-m6.md, tasks/lessons.md (snapshot tradeoffs, membership=404 pattern, /diag/reset order, auto-creator add). - README + tasks/todo.md updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:07:32 +02:00
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>
);
}