feat(frontend): wire LoginPage + EngagementsPage + create dialog to backend

LoginPage
- RT mode now POSTs /api/v1/auth/login with controlled username/password
  fields. Success seeds the session cache via queryClient.setQueryData and
  navigates to /engagements. 401 surfaces as the generic
  "Identifiants invalides" — no echo of the backend detail (avoids
  user enumeration leaks).
- SOC mode kept visually for masthead continuity but disabled with a
  "sprint 2" placeholder pointing at the deferred
  POST /api/v1/auth/soc/session endpoint.
- Removed the sprint-0 mock role-picker.

EngagementsPage
- MOCK_ENGAGEMENTS dropped. useQuery against fetchEngagements (handles
  both bare-array and { items: [] } envelope shapes — backend has not
  pinned this yet).
- Distinct loading / empty / error states. Error row surfaces an HTTP
  code and a Retry button. Empty state offers the create dialog.
- Column shape aligned with the real Engagement schema (snake_case:
  name, client_name, c2_type, start_date, end_date). Dropped mock-only
  columns (operators, socAnalysts) — those land when backend exposes
  /engagements/:id/members and /engagements/:id/soc-sessions counts.

engagementsApi.ts
- fetchEngagements + createEngagement, both bound to /api/v1/engagements.
- ENGAGEMENTS_QUERY_KEY exported so the dialog can invalidate without
  re-knowing the key.

EngagementCreateDialog (frontend-design skill — new non-trivial component)
- "Arm engagement" mission-control dialog. Backdrop is a graphite dim
  with a faint scanline overlay (no soft blur) — reads as "cockpit
  paused while you issue a command", not as a SaaS modal.
- Surface --surface-3 with corner-marks and an amber hairline accent
  under the title; underline-style inputs that light amber on focus;
  label-system uppercase microtypography throughout.
- Esc + outside-click close (suspended while the mutation is in flight).
- Rudimentary tab focus trap.
- 422 Pydantic errors map per-field via the last loc segment;
  401/5xx surface as a generic top-of-form alert.
- On 201 invalidates ['engagements'] and closes.
This commit is contained in:
ux-frontend
2026-05-23 04:26:48 +02:00
committed by knacky
parent f6d4e43e4c
commit 20fbcdf1f8
4 changed files with 748 additions and 181 deletions

View File

@@ -1,12 +1,15 @@
import type { ReactNode } from 'react';
import { useState, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Panel } from '@/components/ui/Panel';
import { Pill } from '@/components/ui/Pill';
import { Button } from '@/components/ui/Button';
import { MOCK_ENGAGEMENTS } from '@/mocks/fixtures';
import type { MockEngagement } from '@/mocks/fixtures';
import { ApiClientError } from '@/lib/api';
import type { Engagement, EngagementStatus } from '@/types/api';
import { ENGAGEMENTS_QUERY_KEY, fetchEngagements } from './engagementsApi';
import { EngagementCreateDialog } from './EngagementCreateDialog';
const STATUS_TONE: Record<MockEngagement['status'], 'running' | 'soc' | 'success' | 'pending'> = {
const STATUS_TONE: Record<EngagementStatus, 'running' | 'soc' | 'success' | 'pending'> = {
active: 'running',
reporting: 'soc',
archived: 'pending',
@@ -14,12 +17,24 @@ const STATUS_TONE: Record<MockEngagement['status'], 'running' | 'soc' | 'success
};
export function EngagementsPage() {
const [createOpen, setCreateOpen] = useState(false);
const query = useQuery<Engagement[]>({
queryKey: ENGAGEMENTS_QUERY_KEY,
queryFn: ({ signal }) => fetchEngagements(signal),
});
const engagements = query.data ?? [];
return (
<div className="px-8 py-6 space-y-6 max-w-[1400px] mx-auto">
<header className="flex items-end justify-between">
<div>
<div className="label-system mb-1">// Engagements</div>
<h1 className="font-display text-fg-default" style={{ fontSize: '22px', letterSpacing: '0.02em' }}>
<h1
className="font-display text-fg-default"
style={{ fontSize: '22px', letterSpacing: '0.02em' }}
>
Mission roster
</h1>
<p className="text-fg-muted mt-1" style={{ fontSize: '12.5px' }}>
@@ -27,79 +42,151 @@ export function EngagementsPage() {
runs, and reports.
</p>
</div>
<Button variant="primary">+ New engagement</Button>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
+ New engagement
</Button>
</header>
<Panel
title="Active and recent"
meta={
<span className="tabular">
{MOCK_ENGAGEMENTS.length} entries · sorted by start date
{query.isLoading
? 'loading …'
: query.isError
? 'error'
: `${String(engagements.length)} entries`}
</span>
}
>
<table className="w-full" style={{ fontSize: 12.5 }}>
<thead>
<tr className="text-fg-subtle">
<Th>Codename</Th>
<Th>Client</Th>
<Th>Status</Th>
<Th>C2</Th>
<Th align="right">Operators</Th>
<Th align="right">SOC</Th>
<Th>Window</Th>
<Th />
</tr>
</thead>
<tbody>
{MOCK_ENGAGEMENTS.map((eng, idx) => (
<tr
key={eng.id}
style={{
borderTop: idx === 0 ? '1px solid var(--line-default)' : '1px solid var(--line-faint)',
}}
>
<Td>
<div className="font-display text-fg-default" style={{ letterSpacing: '0.06em' }}>
{eng.codename}
</div>
<div className="font-mono text-fg-faint" style={{ fontSize: '10.5px' }}>
{eng.id}
</div>
</Td>
<Td>{eng.client}</Td>
<Td>
<Pill tone={STATUS_TONE[eng.status]}>
<span className="status-dot" style={{ color: 'currentColor' }} />
{eng.status}
</Pill>
</Td>
<Td>
<span className="font-mono tabular">{eng.c2Type.toUpperCase()}</span>
</Td>
<Td align="right">
<span className="font-mono tabular">{eng.operators}</span>
</Td>
<Td align="right">
<span className="font-mono tabular">{eng.socAnalysts}</span>
</Td>
<Td>
<span className="font-mono tabular text-fg-muted">
{eng.startDate} {eng.endDate}
</span>
</Td>
<Td align="right">
<Link to="/runs">
<Button variant="ghost" size="sm">
Enter
</Button>
</Link>
</Td>
</tr>
))}
</tbody>
</table>
{query.isLoading ? (
<LoadingRow />
) : query.isError ? (
<ErrorRow error={query.error} onRetry={() => void query.refetch()} />
) : engagements.length === 0 ? (
<EmptyRow onCreate={() => setCreateOpen(true)} />
) : (
<EngagementsTable engagements={engagements} />
)}
</Panel>
{createOpen && <EngagementCreateDialog onClose={() => setCreateOpen(false)} />}
</div>
);
}
function EngagementsTable({ engagements }: { engagements: Engagement[] }) {
return (
<table className="w-full" style={{ fontSize: 12.5 }}>
<thead>
<tr className="text-fg-subtle">
<Th>Name</Th>
<Th>Client</Th>
<Th>Status</Th>
<Th>C2</Th>
<Th>Window</Th>
<Th />
</tr>
</thead>
<tbody>
{engagements.map((eng, idx) => (
<tr
key={eng.id}
style={{
borderTop:
idx === 0 ? '1px solid var(--line-default)' : '1px solid var(--line-faint)',
}}
>
<Td>
<div
className="font-display text-fg-default"
style={{ letterSpacing: '0.06em' }}
>
{eng.name}
</div>
<div className="font-mono text-fg-faint" style={{ fontSize: '10.5px' }}>
{eng.id}
</div>
</Td>
<Td>{eng.client_name ?? <span className="text-fg-faint"></span>}</Td>
<Td>
<Pill tone={STATUS_TONE[eng.status]}>
<span className="status-dot" style={{ color: 'currentColor' }} />
{eng.status}
</Pill>
</Td>
<Td>
{eng.c2_type ? (
<span className="font-mono tabular">{eng.c2_type.toUpperCase()}</span>
) : (
<span className="text-fg-faint"></span>
)}
</Td>
<Td>
{eng.start_date || eng.end_date ? (
<span className="font-mono tabular text-fg-muted">
{eng.start_date ?? '—'} {eng.end_date ?? '—'}
</span>
) : (
<span className="text-fg-faint"></span>
)}
</Td>
<Td align="right">
<Link to="/runs">
<Button variant="ghost" size="sm">
Enter
</Button>
</Link>
</Td>
</tr>
))}
</tbody>
</table>
);
}
function LoadingRow() {
return (
<div className="px-4 py-12 flex items-center gap-3 text-fg-faint label-system">
<span className="status-dot text-fg-faint pulsing" />
<span>fetching engagements </span>
</div>
);
}
function ErrorRow({ error, onRetry }: { error: unknown; onRetry: () => void }) {
const message =
error instanceof ApiClientError
? `HTTP ${String(error.status)} · ${error.message}`
: 'Unable to reach the backend.';
return (
<div className="px-4 py-8 flex items-center justify-between gap-4">
<div>
<div className="label-system" style={{ color: 'var(--state-failed)' }}>
// Fetch failed
</div>
<div className="text-fg-muted mt-1" style={{ fontSize: 12.5 }}>
{message}
</div>
</div>
<Button variant="ghost" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}
function EmptyRow({ onCreate }: { onCreate: () => void }) {
return (
<div className="px-4 py-12 flex flex-col items-start gap-3">
<div className="label-system">// No engagements yet</div>
<p className="text-fg-muted" style={{ fontSize: 12.5 }}>
Create your first engagement to start composing scenarios and running them against client
infrastructure.
</p>
<Button variant="primary" size="sm" onClick={onCreate}>
+ New engagement
</Button>
</div>
);
}