import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } 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 { Modal } from '@/components/ui/Modal'; import { SectionHeader } from '@/components/ui/SectionHeader'; import { Tag } from '@/components/ui/Tag'; import { TextField } from '@/components/ui/TextField'; import { ApiError, apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'; import { useAuth } from '@/lib/auth'; import { MISSION_STATUS_ACCENT, MISSION_STATUS_LABEL, missionKeys, type AddScenariosPayload, type MemberPayload, type Mission, type MissionRoleHint, type MissionStatus, type SetMembersPayload, type TransitionPayload, type UpdateMissionPayload, } from '@/lib/missions'; import type { ScenarioTemplate, ScenarioTemplateListResponse, } from '@/lib/templates'; import { templateKeys } from '@/lib/templates'; const TABS = ['tests', 'members', 'synthesis', 'export'] as const; type Tab = (typeof TABS)[number]; const ALLOWED_TRANSITIONS: Record = { draft: ['in_progress', 'archived'], in_progress: ['completed', 'archived'], completed: ['archived'], archived: [], }; const TRANSITION_BUTTON_ACCENT: Record = { draft: 'cyan', in_progress: 'orange', completed: 'green', archived: 'teal', }; interface RosterUser { id: string; email: string; display_name: string | null; } interface RosterResponse { items: RosterUser[]; } interface MemberSelection { user_id: string; role_hint: MissionRoleHint; } function useMission(id: string) { return useQuery({ queryKey: missionKeys.detail(id), queryFn: () => apiGet(`/missions/${id}`), enabled: !!id, }); } function useScenarioCatalogue(enabled: boolean) { return useQuery({ queryKey: templateKeys.scenarios(''), queryFn: () => apiGet('/scenario-templates?limit=500'), enabled, }); } function useRoster(enabled: boolean) { return useQuery({ queryKey: ['users', 'roster'], queryFn: () => apiGet('/users/roster'), enabled, }); } 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 ?? ''; } // --------------------------------------------------------------------------- // // Metadata edit modal // // --------------------------------------------------------------------------- // interface MetaEditModalProps { mission: Mission; open: boolean; onClose: () => void; } function MetaEditModal({ mission, open, onClose }: MetaEditModalProps) { const qc = useQueryClient(); const [name, setName] = useState(mission.name); const [client, setClient] = useState(mission.client_target ?? ''); const [dateStart, setDateStart] = useState(mission.date_start ?? ''); const [dateEnd, setDateEnd] = useState(mission.date_end ?? ''); const [description, setDescription] = useState(mission.description_md ?? ''); // Reset form whenever the modal opens with a (potentially newer) mission. useEffect(() => { if (!open) return; setName(mission.name); setClient(mission.client_target ?? ''); setDateStart(mission.date_start ?? ''); setDateEnd(mission.date_end ?? ''); setDescription(mission.description_md ?? ''); }, [open, mission]); const update = useMutation({ mutationFn: (body: UpdateMissionPayload) => apiPut(`/missions/${mission.id}`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) }); qc.invalidateQueries({ queryKey: missionKeys.listPrefix() }); onClose(); }, }); const apiErr = update.error instanceof ApiError ? update.error : null; const nameInvalid = name.trim().length === 0; const datesInvalid = dateStart && dateEnd && dateEnd < dateStart; function submit() { update.mutate({ name: name.trim(), client_target: client.trim() || null, date_start: dateStart || null, date_end: dateEnd || null, description_md: description.trim() || null, }); } return (
{apiErr && {apiErr.message}}
setName(e.target.value)} data-testid="meta-edit-name" /> setClient(e.target.value)} data-testid="meta-edit-client" /> setDateStart(e.target.value)} data-testid="meta-edit-date-start" /> setDateEnd(e.target.value)} data-testid="meta-edit-date-end" />
{datesInvalid && (

End date must be on or after start date.

)}
); } // --------------------------------------------------------------------------- // // Add-scenarios modal // // --------------------------------------------------------------------------- // interface AddScenariosModalProps { mission: Mission; open: boolean; onClose: () => void; } function AddScenariosModal({ mission, open, onClose }: AddScenariosModalProps) { const qc = useQueryClient(); const [selected, setSelected] = useState([]); const catalogue = useScenarioCatalogue(open); useEffect(() => { if (open) setSelected([]); }, [open]); const add = useMutation({ mutationFn: (body: AddScenariosPayload) => apiPost(`/missions/${mission.id}/scenarios`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) }); qc.invalidateQueries({ queryKey: missionKeys.listPrefix() }); onClose(); }, }); const apiErr = add.error instanceof ApiError ? add.error : null; function toggle(id: string) { setSelected((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], ); } function submit() { add.mutate({ scenario_template_ids: selected }); } const totalTestsToAdd = useMemo(() => { if (!catalogue.data) return 0; const by_id = new Map( catalogue.data.items.map((sc) => [sc.id, sc] as const), ); return selected.reduce((acc, id) => acc + (by_id.get(id)?.tests_count ?? 0), 0); }, [selected, catalogue.data]); return (
{apiErr && {apiErr.message}} {catalogue.isError && Failed to load scenarios.} {catalogue.isLoading && (

Loading…

)}

{selected.length} scenario{selected.length === 1 ? '' : 's'} ·{' '} {totalTestsToAdd} test{totalTestsToAdd === 1 ? '' : 's'} will be appended after the current {mission.scenarios_count}.

    {catalogue.data?.items.map((sc) => { const isSelected = selected.includes(sc.id); return (
  • ); })}
{catalogue.data && catalogue.data.items.length === 0 && (

No scenarios in the catalogue yet.

)}
); } // --------------------------------------------------------------------------- // // Edit-members modal // // --------------------------------------------------------------------------- // interface EditMembersModalProps { mission: Mission; open: boolean; onClose: () => void; } function EditMembersModal({ mission, open, onClose }: EditMembersModalProps) { const qc = useQueryClient(); const roster = useRoster(open); const [members, setMembers] = useState([]); useEffect(() => { if (!open) return; setMembers( mission.members.map((m) => ({ user_id: m.user_id, role_hint: m.role_hint, })), ); }, [open, mission]); const save = useMutation({ mutationFn: (body: SetMembersPayload) => apiPut(`/missions/${mission.id}/members`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) }); qc.invalidateQueries({ queryKey: missionKeys.listPrefix() }); onClose(); }, }); const apiErr = save.error instanceof ApiError ? save.error : null; function setRole(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 remove(user_id: string) { setMembers((prev) => prev.filter((m) => m.user_id !== user_id)); } function submit() { const payload: SetMembersPayload = { members: members.map( (m): MemberPayload => ({ user_id: m.user_id, role_hint: m.role_hint }), ), }; save.mutate(payload); } return (
{apiErr && {apiErr.message}} {roster.isError && Failed to load roster.} {roster.isLoading && (

Loading users…

)}
    {roster.data?.items.map((u) => { const selected = members.find((m) => m.user_id === u.id); return (
  • {u.display_name ?? u.email}

    {u.display_name && (

    {u.email}

    )}
    {selected && ( )}
  • ); })}
); } // --------------------------------------------------------------------------- // // Main page // // --------------------------------------------------------------------------- // export function MissionDetailPage() { const params = useParams(); const missionId = params.id ?? ''; const navigate = useNavigate(); const qc = useQueryClient(); const { state } = useAuth(); const canEdit = state.user?.is_admin || state.user?.permissions.includes('mission.update') || false; const [tab, setTab] = useState('tests'); const [editMeta, setEditMeta] = useState(false); const [addScenarios, setAddScenarios] = useState(false); const [editMembers, setEditMembers] = useState(false); const detail = useMission(missionId); const transition = useMutation({ mutationFn: (body: TransitionPayload) => apiPost(`/missions/${missionId}/transition`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) }); qc.invalidateQueries({ queryKey: missionKeys.listPrefix() }); }, }); const remove = useMutation({ mutationFn: () => apiDelete<{ ok: true }>(`/missions/${missionId}`), onSuccess: () => { qc.invalidateQueries({ queryKey: missionKeys.listPrefix() }); navigate('/missions'); }, }); const apiErr = detail.error instanceof ApiError ? detail.error : null; const m = detail.data; if (apiErr) { return (
{apiErr.message}
); } if (!m) { return

Loading mission…

; } const accent = MISSION_STATUS_ACCENT[m.status]; const allowedNext = ALLOWED_TRANSITIONS[m.status]; return (
{MISSION_STATUS_LABEL[m.status]} {canEdit && ( )} {allowedNext.map((target) => ( ))}
Client
{m.client_target ?? '—'}
Dates
{formatDateRange(m.date_start, m.date_end)}
Scenarios
{m.scenarios_count}
Tests
{m.tests_count}
{m.description_md && (
{m.description_md}
)}
{tab === 'tests' && (

Snapshots are frozen at append time — editing a source template does not propagate.

{canEdit && ( )}
{m.scenarios.length === 0 ? (

No scenarios snapshotted yet. {canEdit && ' Click "Add scenarios" to append one.'}

) : (
{m.scenarios.map((sc) => (
#{sc.position + 1}

{sc.snapshot_name}

{sc.snapshot_description && (

{sc.snapshot_description}

)} {sc.tests.map((t) => ( ))}
# Test MITRE OPSEC State
{t.position + 1} {t.snapshot_name}
{t.mitre_tags.map((tag) => ( {tag.external_id} ))}
{t.snapshot_opsec_level} {t.state}
))}
)}
)} {tab === 'members' && (

Members see this mission and (for reds) can author red-side fields on its tests in M7+.

{canEdit && ( )}
{m.members.length === 0 ? (

No members assigned. {canEdit && ' Click "Edit members" to add some.'}

) : (
    {m.members.map((mb) => (
  • {mb.user_display_name ?? mb.user_email}

    {mb.user_display_name && (

    {mb.user_email}

    )}
    {mb.role_hint}
  • ))}
)}
)} {tab === 'synthesis' && (

Reveal.js slide synthesis lands in M10.

)} {tab === 'export' && (

JSON / CSV exports land in M11.

)} setEditMeta(false)} /> setAddScenarios(false)} /> setEditMembers(false)} />
); }