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>
This commit is contained in:
@@ -12,6 +12,9 @@ import { AdminUsersPage } from '@/pages/AdminUsersPage';
|
||||
import { HomePage } from '@/pages/HomePage';
|
||||
import { MitrePage } from '@/pages/MitrePage';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { MissionDetailPage } from '@/pages/MissionDetailPage';
|
||||
import { MissionsCreatePage } from '@/pages/MissionsCreatePage';
|
||||
import { MissionsListPage } from '@/pages/MissionsListPage';
|
||||
import { ProfilePage } from '@/pages/ProfilePage';
|
||||
import { RegisterPage } from '@/pages/RegisterPage';
|
||||
import { SetupPage } from '@/pages/SetupPage';
|
||||
@@ -60,6 +63,30 @@ function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/missions"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<MissionsListPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/missions/new"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<MissionsCreatePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/missions/:id"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<MissionDetailPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
|
||||
@@ -37,6 +37,9 @@ export function Layout() {
|
||||
{navItem('/', 'Home')}
|
||||
{navItem('/profile', 'Profile')}
|
||||
{navItem('/mitre', 'MITRE')}
|
||||
{(state.user.is_admin ||
|
||||
state.user.permissions.includes('mission.read')) &&
|
||||
navItem('/missions', 'Missions')}
|
||||
{state.user.is_admin && (
|
||||
<>
|
||||
{navItem('/admin/users', 'Users')}
|
||||
@@ -71,7 +74,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 · M4 mitre · M5 templates · design system from tasks/design.md
|
||||
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · M5 templates · M6 missions · design system from tasks/design.md
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
162
frontend/src/lib/missions.ts
Normal file
162
frontend/src/lib/missions.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Mission types + query-key factory.
|
||||
*
|
||||
* A mission is a *snapshot* of one or more scenario templates: the backend
|
||||
* copies template fields into mission_* tables at creation time, and template
|
||||
* edits after that point do not propagate. Types here mirror the server-side
|
||||
* dataclasses in `app/services/missions.py`.
|
||||
*/
|
||||
|
||||
export type MissionStatus = 'draft' | 'in_progress' | 'completed' | 'archived';
|
||||
export type MissionRoleHint = 'red' | 'blue';
|
||||
export type MissionTestState =
|
||||
| 'pending'
|
||||
| 'executed'
|
||||
| 'reviewed_by_blue'
|
||||
| 'skipped'
|
||||
| 'blocked';
|
||||
export type MissionVisibilityMode = 'whitebox' | 'titles_only' | 'executed_only';
|
||||
export type MissionMitreKind = 'tactic' | 'technique' | 'subtechnique';
|
||||
export type MissionOpsecLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface MissionMember {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
user_display_name: string | null;
|
||||
role_hint: MissionRoleHint;
|
||||
}
|
||||
|
||||
export interface MissionMitreTag {
|
||||
kind: MissionMitreKind;
|
||||
external_id: string;
|
||||
name: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface MissionTest {
|
||||
id: string;
|
||||
position: number;
|
||||
snapshot_name: string;
|
||||
snapshot_description: string | null;
|
||||
snapshot_objective: string | null;
|
||||
snapshot_procedure_md: string | null;
|
||||
snapshot_prerequisites_md: string | null;
|
||||
snapshot_expected_red_md: string | null;
|
||||
snapshot_expected_blue_md: string | null;
|
||||
snapshot_opsec_level: MissionOpsecLevel;
|
||||
snapshot_tags: string[];
|
||||
snapshot_expected_iocs: string[];
|
||||
state: MissionTestState;
|
||||
executed_at: string | null;
|
||||
executed_at_overridden: boolean;
|
||||
mitre_tags: MissionMitreTag[];
|
||||
source_test_template_id: string | null;
|
||||
}
|
||||
|
||||
export interface MissionScenario {
|
||||
id: string;
|
||||
position: number;
|
||||
snapshot_name: string;
|
||||
snapshot_description: string | null;
|
||||
tests: MissionTest[];
|
||||
source_scenario_template_id: string | null;
|
||||
}
|
||||
|
||||
export interface MissionListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
client_target: string | null;
|
||||
date_start: string | null;
|
||||
date_end: string | null;
|
||||
status: MissionStatus;
|
||||
description_md: string | null;
|
||||
visibility_mode: MissionVisibilityMode;
|
||||
scenarios_count: number;
|
||||
tests_count: number;
|
||||
members_count: number;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Mission extends MissionListItem {
|
||||
scenarios: MissionScenario[];
|
||||
members: MissionMember[];
|
||||
}
|
||||
|
||||
export interface MissionListResponse {
|
||||
items: MissionListItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface MissionFilters {
|
||||
q?: string;
|
||||
status?: MissionStatus | '';
|
||||
client?: string;
|
||||
}
|
||||
|
||||
export interface MemberPayload {
|
||||
user_id: string;
|
||||
role_hint: MissionRoleHint;
|
||||
}
|
||||
|
||||
export interface CreateMissionPayload {
|
||||
name: string;
|
||||
client_target?: string | null;
|
||||
date_start?: string | null;
|
||||
date_end?: string | null;
|
||||
description_md?: string | null;
|
||||
scenario_template_ids?: string[];
|
||||
members?: MemberPayload[];
|
||||
}
|
||||
|
||||
export interface UpdateMissionPayload {
|
||||
name?: string;
|
||||
client_target?: string | null;
|
||||
date_start?: string | null;
|
||||
date_end?: string | null;
|
||||
description_md?: string | null;
|
||||
}
|
||||
|
||||
export interface AddScenariosPayload {
|
||||
scenario_template_ids: string[];
|
||||
}
|
||||
|
||||
export interface SetMembersPayload {
|
||||
members: MemberPayload[];
|
||||
}
|
||||
|
||||
export interface TransitionPayload {
|
||||
status: MissionStatus;
|
||||
}
|
||||
|
||||
export const missionKeys = {
|
||||
list: (filters?: MissionFilters) => ['missions', 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => ['missions', 'detail', id] as const,
|
||||
};
|
||||
|
||||
export function buildMissionQueryString(filters: MissionFilters | undefined): string {
|
||||
if (!filters) return '';
|
||||
const params = new URLSearchParams();
|
||||
if (filters.q) params.set('q', filters.q);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.client) params.set('client', filters.client);
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export const MISSION_STATUS_ACCENT: Record<MissionStatus, 'cyan' | 'orange' | 'green' | 'teal'> = {
|
||||
draft: 'cyan',
|
||||
in_progress: 'orange',
|
||||
completed: 'green',
|
||||
archived: 'teal',
|
||||
};
|
||||
|
||||
export const MISSION_STATUS_LABEL: Record<MissionStatus, string> = {
|
||||
draft: 'Draft',
|
||||
in_progress: 'In Progress',
|
||||
completed: 'Completed',
|
||||
archived: 'Archived',
|
||||
};
|
||||
305
frontend/src/pages/MissionDetailPage.tsx
Normal file
305
frontend/src/pages/MissionDetailPage.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
404
frontend/src/pages/MissionsCreatePage.tsx
Normal file
404
frontend/src/pages/MissionsCreatePage.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { MarkdownField } from '@/components/MarkdownField';
|
||||
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 { TextField } from '@/components/ui/TextField';
|
||||
import { ApiError, apiGet, apiPost } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import {
|
||||
missionKeys,
|
||||
type CreateMissionPayload,
|
||||
type Mission,
|
||||
type MissionRoleHint,
|
||||
} from '@/lib/missions';
|
||||
import {
|
||||
templateKeys,
|
||||
type ScenarioTemplate,
|
||||
type ScenarioTemplateListResponse,
|
||||
} from '@/lib/templates';
|
||||
|
||||
interface RosterUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
}
|
||||
|
||||
interface RosterResponse {
|
||||
items: RosterUser[];
|
||||
}
|
||||
|
||||
interface MetaState {
|
||||
name: string;
|
||||
client_target: string;
|
||||
date_start: string;
|
||||
date_end: string;
|
||||
description_md: string;
|
||||
}
|
||||
|
||||
interface MemberSelection {
|
||||
user_id: string;
|
||||
role_hint: MissionRoleHint;
|
||||
}
|
||||
|
||||
const STEPS: Array<{ key: 'meta' | 'scenarios' | 'members'; label: string }> = [
|
||||
{ key: 'meta', label: 'Metadata' },
|
||||
{ key: 'scenarios', label: 'Scenarios' },
|
||||
{ key: 'members', label: 'Members' },
|
||||
];
|
||||
|
||||
function blankMeta(): MetaState {
|
||||
return {
|
||||
name: '',
|
||||
client_target: '',
|
||||
date_start: '',
|
||||
date_end: '',
|
||||
description_md: '',
|
||||
};
|
||||
}
|
||||
|
||||
function useScenarioCatalogue() {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.scenarios(''),
|
||||
queryFn: () =>
|
||||
apiGet<ScenarioTemplateListResponse>('/scenario-templates?limit=500'),
|
||||
});
|
||||
}
|
||||
|
||||
function useRoster() {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'roster'],
|
||||
queryFn: () => apiGet<RosterResponse>('/users/roster'),
|
||||
});
|
||||
}
|
||||
|
||||
export function MissionsCreatePage() {
|
||||
const { state } = useAuth();
|
||||
const me = state.user;
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [stepIdx, setStepIdx] = useState(0);
|
||||
const step = STEPS[stepIdx];
|
||||
|
||||
const [meta, setMeta] = useState<MetaState>(blankMeta);
|
||||
const [scenarioIds, setScenarioIds] = useState<string[]>([]);
|
||||
const [members, setMembers] = useState<MemberSelection[]>(() =>
|
||||
me && !me.is_admin
|
||||
? [{ user_id: me.id, role_hint: 'red' }]
|
||||
: [],
|
||||
);
|
||||
|
||||
const scenarios = useScenarioCatalogue();
|
||||
const roster = useRoster();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: CreateMissionPayload) => apiPost<Mission>('/missions', body),
|
||||
onSuccess: (created) => {
|
||||
qc.invalidateQueries({ queryKey: missionKeys.list() });
|
||||
navigate(`/missions/${created.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const apiErr =
|
||||
createMutation.error instanceof ApiError ? createMutation.error : null;
|
||||
|
||||
const metaInvalid = meta.name.trim().length === 0;
|
||||
const datesInvalid =
|
||||
meta.date_start &&
|
||||
meta.date_end &&
|
||||
meta.date_end < meta.date_start;
|
||||
|
||||
const scenarioById = useMemo(() => {
|
||||
const m = new Map<string, ScenarioTemplate>();
|
||||
for (const sc of scenarios.data?.items ?? []) m.set(sc.id, sc);
|
||||
return m;
|
||||
}, [scenarios.data]);
|
||||
|
||||
const next = () => setStepIdx((i) => Math.min(i + 1, STEPS.length - 1));
|
||||
const prev = () => setStepIdx((i) => Math.max(i - 1, 0));
|
||||
|
||||
function toggleScenario(id: string) {
|
||||
setScenarioIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
}
|
||||
|
||||
function setMemberRole(user_id: string, role_hint: MissionRoleHint) {
|
||||
setMembers((prev) =>
|
||||
prev.some((m) => m.user_id === user_id)
|
||||
? prev.map((m) => (m.user_id === user_id ? { ...m, role_hint } : m))
|
||||
: [...prev, { user_id, role_hint }],
|
||||
);
|
||||
}
|
||||
|
||||
function removeMember(user_id: string) {
|
||||
setMembers((prev) => prev.filter((m) => m.user_id !== user_id));
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const payload: CreateMissionPayload = {
|
||||
name: meta.name.trim(),
|
||||
client_target: meta.client_target.trim() || null,
|
||||
date_start: meta.date_start || null,
|
||||
date_end: meta.date_end || null,
|
||||
description_md: meta.description_md.trim() || null,
|
||||
scenario_template_ids: scenarioIds,
|
||||
members,
|
||||
};
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
|
||||
const totalSelectedTests = useMemo(
|
||||
() =>
|
||||
scenarioIds.reduce(
|
||||
(acc, id) => acc + (scenarioById.get(id)?.tests_count ?? 0),
|
||||
0,
|
||||
),
|
||||
[scenarioIds, scenarioById],
|
||||
);
|
||||
|
||||
return (
|
||||
<section data-testid="missions-create">
|
||||
<SectionHeader prefix="New" highlight="Mission" accent="cyan" />
|
||||
|
||||
<Card className="mb-6">
|
||||
<ol className="flex items-center gap-2" data-testid="missions-create-steps">
|
||||
{STEPS.map((s, i) => {
|
||||
const active = i === stepIdx;
|
||||
const done = i < stepIdx;
|
||||
const accent: 'cyan' | 'green' | 'teal' = active
|
||||
? 'cyan'
|
||||
: done
|
||||
? 'green'
|
||||
: 'teal';
|
||||
return (
|
||||
<li key={s.key} className="flex items-center gap-2">
|
||||
<Tag accent={accent}>{i + 1}. {s.label}</Tag>
|
||||
{i < STEPS.length - 1 && (
|
||||
<span className="text-text-dim font-mono">→</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Card>
|
||||
|
||||
{apiErr && (
|
||||
<div data-testid="missions-create-error" className="mb-4">
|
||||
<Alert accent="red">{apiErr.message}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.key === 'meta' && (
|
||||
<Card title="Metadata" sub="Identification and scope">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<TextField
|
||||
label="Name"
|
||||
required
|
||||
value={meta.name}
|
||||
onChange={(e) => setMeta((p) => ({ ...p, name: e.target.value }))}
|
||||
data-testid="meta-name"
|
||||
placeholder="purple-q2-2026"
|
||||
/>
|
||||
<TextField
|
||||
label="Client / target"
|
||||
value={meta.client_target}
|
||||
onChange={(e) =>
|
||||
setMeta((p) => ({ ...p, client_target: e.target.value }))
|
||||
}
|
||||
data-testid="meta-client"
|
||||
placeholder="Acme Corp"
|
||||
/>
|
||||
<TextField
|
||||
label="Start date"
|
||||
type="date"
|
||||
value={meta.date_start}
|
||||
onChange={(e) =>
|
||||
setMeta((p) => ({ ...p, date_start: e.target.value }))
|
||||
}
|
||||
data-testid="meta-date-start"
|
||||
/>
|
||||
<TextField
|
||||
label="End date"
|
||||
type="date"
|
||||
value={meta.date_end}
|
||||
onChange={(e) =>
|
||||
setMeta((p) => ({ ...p, date_end: e.target.value }))
|
||||
}
|
||||
data-testid="meta-date-end"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<MarkdownField
|
||||
label="ROE / Description"
|
||||
value={meta.description_md}
|
||||
onChange={(v) =>
|
||||
setMeta((p) => ({ ...p, description_md: v }))
|
||||
}
|
||||
data-testid="meta-description"
|
||||
/>
|
||||
</div>
|
||||
{datesInvalid && (
|
||||
<p className="mt-3 font-mono text-2xs text-red" data-testid="meta-date-error">
|
||||
End date must be on or after start date.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step.key === 'scenarios' && (
|
||||
<Card
|
||||
title="Scenarios"
|
||||
sub={`Select reusable scenarios — ${totalSelectedTests} tests will be snapshotted`}
|
||||
>
|
||||
{scenarios.isError && (
|
||||
<Alert accent="red">Failed to load scenarios.</Alert>
|
||||
)}
|
||||
{scenarios.isLoading && (
|
||||
<p className="font-mono text-xs text-text-dim">Loading…</p>
|
||||
)}
|
||||
<ul
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-2"
|
||||
data-testid="scenarios-picker"
|
||||
>
|
||||
{scenarios.data?.items.map((sc) => {
|
||||
const selected = scenarioIds.includes(sc.id);
|
||||
return (
|
||||
<li key={sc.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full rounded-md border ${
|
||||
selected ? 'border-cyan text-cyan' : 'border-border text-text'
|
||||
} bg-bg-card p-3 text-left font-mono text-xs hover:border-cyan`}
|
||||
onClick={() => toggleScenario(sc.id)}
|
||||
data-testid={`scenario-toggle-${sc.id}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-bright">{sc.name}</span>
|
||||
<Tag accent="purple">{sc.tests_count} tests</Tag>
|
||||
</div>
|
||||
{sc.description && (
|
||||
<p className="mt-1 text-text-dim">{sc.description}</p>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{scenarios.data && scenarios.data.items.length === 0 && (
|
||||
<p className="font-mono text-xs text-text-dim">
|
||||
No scenarios in the catalogue yet — create one in
|
||||
{' '}<a href="/admin/scenarios" className="text-cyan underline">Admin → Scenarios</a>{' '}
|
||||
first.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step.key === 'members' && (
|
||||
<Card title="Members" sub="Who works on this mission and on which side">
|
||||
{roster.isError && (
|
||||
<Alert accent="red">Failed to load roster.</Alert>
|
||||
)}
|
||||
{roster.isLoading && (
|
||||
<p className="font-mono text-xs text-text-dim">Loading users…</p>
|
||||
)}
|
||||
<ul
|
||||
className="flex flex-col gap-2"
|
||||
data-testid="members-picker"
|
||||
>
|
||||
{roster.data?.items.map((u) => {
|
||||
const selected = members.find((m) => m.user_id === u.id);
|
||||
return (
|
||||
<li
|
||||
key={u.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-bg-card p-3"
|
||||
data-testid={`member-row-${u.id}`}
|
||||
>
|
||||
<div>
|
||||
<p className="font-mono text-xs text-text-bright">
|
||||
{u.display_name ?? u.email}
|
||||
</p>
|
||||
{u.display_name && (
|
||||
<p className="font-mono text-2xs text-text-dim">{u.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
accent="red"
|
||||
variant={selected?.role_hint === 'red' ? 'solid' : 'outline'}
|
||||
onClick={() => setMemberRole(u.id, 'red')}
|
||||
data-testid={`member-${u.id}-red`}
|
||||
>
|
||||
Red
|
||||
</Button>
|
||||
<Button
|
||||
accent="cyan"
|
||||
variant={selected?.role_hint === 'blue' ? 'solid' : 'outline'}
|
||||
onClick={() => setMemberRole(u.id, 'blue')}
|
||||
data-testid={`member-${u.id}-blue`}
|
||||
>
|
||||
Blue
|
||||
</Button>
|
||||
{selected && (
|
||||
<Button
|
||||
accent="rose"
|
||||
variant="ghost"
|
||||
onClick={() => removeMember(u.id)}
|
||||
data-testid={`member-${u.id}-clear`}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button
|
||||
accent="teal"
|
||||
variant="ghost"
|
||||
onClick={prev}
|
||||
disabled={stepIdx === 0}
|
||||
data-testid="missions-create-prev"
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
{stepIdx < STEPS.length - 1 ? (
|
||||
<Button
|
||||
accent="cyan"
|
||||
onClick={next}
|
||||
disabled={step.key === 'meta' && (metaInvalid || !!datesInvalid)}
|
||||
data-testid="missions-create-next"
|
||||
>
|
||||
Next →
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
accent="green"
|
||||
onClick={submit}
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
metaInvalid ||
|
||||
!!datesInvalid
|
||||
}
|
||||
data-testid="missions-create-submit"
|
||||
>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create mission'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
167
frontend/src/pages/MissionsListPage.tsx
Normal file
167
frontend/src/pages/MissionsListPage.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } 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 { TextField } from '@/components/ui/TextField';
|
||||
import { ApiError, apiGet } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import {
|
||||
MISSION_STATUS_ACCENT,
|
||||
MISSION_STATUS_LABEL,
|
||||
buildMissionQueryString,
|
||||
missionKeys,
|
||||
type MissionFilters,
|
||||
type MissionListResponse,
|
||||
type MissionStatus,
|
||||
} from '@/lib/missions';
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: '' | MissionStatus; label: string }> = [
|
||||
{ value: '', label: 'All statuses' },
|
||||
{ value: 'draft', label: MISSION_STATUS_LABEL.draft },
|
||||
{ value: 'in_progress', label: MISSION_STATUS_LABEL.in_progress },
|
||||
{ value: 'completed', label: MISSION_STATUS_LABEL.completed },
|
||||
{ value: 'archived', label: MISSION_STATUS_LABEL.archived },
|
||||
];
|
||||
|
||||
function useMissions(filters: MissionFilters) {
|
||||
return useQuery({
|
||||
queryKey: missionKeys.list(filters),
|
||||
queryFn: () =>
|
||||
apiGet<MissionListResponse>(`/missions${buildMissionQueryString(filters)}`),
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateRange(start: string | null, end: string | null): string {
|
||||
if (!start && !end) return '—';
|
||||
if (start && end) return `${start} → ${end}`;
|
||||
return start ?? end ?? '—';
|
||||
}
|
||||
|
||||
export function MissionsListPage() {
|
||||
const { state } = useAuth();
|
||||
const canCreate =
|
||||
state.user?.is_admin || state.user?.permissions.includes('mission.create');
|
||||
|
||||
const [q, setQ] = useState('');
|
||||
const [status, setStatus] = useState<'' | MissionStatus>('');
|
||||
const [client, setClient] = useState('');
|
||||
|
||||
const filters = useMemo<MissionFilters>(
|
||||
() => ({
|
||||
q: q.trim() || undefined,
|
||||
status: status || undefined,
|
||||
client: client.trim() || undefined,
|
||||
}),
|
||||
[q, status, client],
|
||||
);
|
||||
|
||||
const { data, error, isLoading } = useMissions(filters);
|
||||
const apiErr = error instanceof ApiError ? error : null;
|
||||
|
||||
return (
|
||||
<section data-testid="missions-list">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<SectionHeader prefix="Plan" highlight="Missions" accent="cyan" />
|
||||
{canCreate && (
|
||||
<Link to="/missions/new" data-testid="missions-new-link">
|
||||
<Button accent="cyan">+ New mission</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<TextField
|
||||
label="Search"
|
||||
placeholder="name or description"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
data-testid="missions-filter-q"
|
||||
/>
|
||||
<TextField
|
||||
label="Client"
|
||||
placeholder="acme corp"
|
||||
value={client}
|
||||
onChange={(e) => setClient(e.target.value)}
|
||||
data-testid="missions-filter-client"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as '' | MissionStatus)}
|
||||
data-testid="missions-filter-status"
|
||||
className="bg-bg-card border border-border rounded px-3 py-2 font-mono text-xs text-text-bright"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{apiErr && (
|
||||
<div data-testid="missions-error">
|
||||
<Alert accent="red">{apiErr.message}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<p className="font-mono text-xs text-text-dim">Loading missions…</p>
|
||||
)}
|
||||
|
||||
{data && data.items.length === 0 && !isLoading && (
|
||||
<Card>
|
||||
<p className="font-mono text-xs text-text-dim" data-testid="missions-empty">
|
||||
No missions match the filters. {canCreate ? 'Create one to get started.' : ''}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2" data-testid="missions-grid">
|
||||
{data?.items.map((m) => {
|
||||
const accent = MISSION_STATUS_ACCENT[m.status];
|
||||
return (
|
||||
<Link key={m.id} to={`/missions/${m.id}`} data-testid={`mission-card-${m.id}`}>
|
||||
<Card
|
||||
accent={accent}
|
||||
title={m.name}
|
||||
sub={m.client_target ?? 'No client'}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag accent={accent}>{MISSION_STATUS_LABEL[m.status]}</Tag>
|
||||
<Tag accent="cyan">{m.scenarios_count} scenarios</Tag>
|
||||
<Tag accent="purple">{m.tests_count} tests</Tag>
|
||||
<Tag accent="teal">{m.members_count} members</Tag>
|
||||
</div>
|
||||
<p className="mt-3 font-mono text-2xs text-text-dim">
|
||||
{formatDateRange(m.date_start, m.date_end)}
|
||||
</p>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<p
|
||||
className="mt-4 font-mono text-2xs text-text-dim"
|
||||
data-testid="missions-total"
|
||||
>
|
||||
{data.total} mission{data.total === 1 ? '' : 's'} total
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user