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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user