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
parent 6aa0078fd3
commit 2eefd11019
4 changed files with 748 additions and 181 deletions

View File

@@ -1,42 +1,68 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Logo } from '@/components/brand/Logo';
import { Button } from '@/components/ui/Button';
import { Pill } from '@/components/ui/Pill';
import { useSession } from '@/session/useSession';
import { MOCK_SESSIONS } from '@/mocks/session';
import type { Role } from '@/types/roles';
import { ApiClientError } from '@/lib/api';
import { login } from '@/session/sessionApi';
import { SESSION_QUERY_KEY } from '@/session/useSession';
type Mode = 'rt' | 'soc';
/**
* Login screen — two distinct paths.
*
* RT operators authenticate via username/password (D-003 v1, OIDC v2).
* SOC analysts use a one-shot token (D-006: bcrypt-hashed soc_session,
* clear value delivered out-of-band by the lead RT).
* Sprint 1 wires the RT operator path against POST /api/v1/auth/login.
* The SOC analyst path stays visible (so the masthead doesn't change) but
* is deferred to sprint 2 when the backend exposes /auth/soc/session.
*
* Sprint 0 mock: no validation. Picking a role assumes that role's mock
* session and lands the user inside the shell.
* On success the server sets an HttpOnly session cookie; the response body
* is the User. We seed the TanStack Query cache directly so the next render
* pass already has the user, then navigate to /engagements (the post-login
* landing for RT). No localStorage, no client-side credential persistence.
*
* Error policy: we never echo the backend message verbatim (could leak
* "user not found" vs "wrong password"). 401 → generic "Identifiants
* invalides". 4xx/5xx other → generic "Connexion impossible".
*/
export function LoginPage() {
const [mode, setMode] = useState<Mode>('rt');
const [pickedRtRole, setPickedRtRole] = useState<'rt_operator' | 'rt_lead'>('rt_lead');
const { signIn } = useSession();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
queryClient.setQueryData(SESSION_QUERY_KEY, user);
setErrorMsg(null);
void navigate('/engagements', { replace: true });
},
onError: (err) => {
if (err instanceof ApiClientError && err.status === 401) {
setErrorMsg('Identifiants invalides.');
} else {
setErrorMsg('Connexion impossible. Réessayez dans un instant.');
}
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const role: Role = mode === 'soc' ? 'soc_analyst' : pickedRtRole;
const user = MOCK_SESSIONS[role];
if (!user) return;
signIn(user);
void navigate(mode === 'soc' ? '/runs' : '/engagements', { replace: true });
if (mode !== 'rt') return;
if (!username || !password) {
setErrorMsg('Nom dutilisateur et mot de passe requis.');
return;
}
loginMutation.mutate({ username, password });
};
return (
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--surface-0)' }}>
{/* Left rail — masthead, identity, telemetry of the platform itself */}
<aside
className="hidden lg:flex flex-col justify-between w-[420px] border-r p-10"
style={{
@@ -61,18 +87,14 @@ export function LoginPage() {
</div>
<div className="space-y-3 font-mono tabular text-fg-faint" style={{ fontSize: '11px' }}>
<div className="flex gap-3">
<span className="w-24 text-fg-subtle">spec</span>
<span>ready-with-prereqs · frozen 2026-05-19</span>
</div>
<div className="flex gap-3">
<span className="w-24 text-fg-subtle">blockers</span>
<span>PR1 · PR2 · PR3 (graphic charter)</span>
</div>
<div className="flex gap-3">
<span className="w-24 text-fg-subtle">deploy</span>
<span>RT infra · Caddy + TLS · OPSEC handled by RP</span>
</div>
<div className="flex gap-3">
<span className="w-24 text-fg-subtle">auth</span>
<span>local user/password v1 · OIDC v2</span>
</div>
</div>
</div>
@@ -82,10 +104,8 @@ export function LoginPage() {
</div>
</aside>
{/* Right column — the auth form, instrument-panel inset card */}
<div className="flex-1 flex items-center justify-center px-8 py-16">
<div className="w-full max-w-[420px]">
{/* Mode switch — segmented, role-tinted */}
<div className="flex items-center gap-1 mb-6" role="tablist" aria-label="Login mode">
<ModeTab
active={mode === 'rt'}
@@ -99,7 +119,7 @@ export function LoginPage() {
onClick={() => setMode('soc')}
tone="soc"
label="SOC — analyst"
hint="session token"
hint="session token (sprint 2)"
/>
</div>
@@ -114,24 +134,64 @@ export function LoginPage() {
}}
>
<div className="flex items-center justify-between mb-4">
<h1 className="font-display text-fg-default" style={{ fontSize: '15px', letterSpacing: '0.05em' }}>
<h1
className="font-display text-fg-default"
style={{ fontSize: '15px', letterSpacing: '0.05em' }}
>
{mode === 'rt' ? 'Operator sign-in' : 'SOC session'}
</h1>
<Pill tone={mode}>{mode === 'rt' ? 'RT' : 'SOC'}</Pill>
</div>
{mode === 'rt' ? (
<RtForm picked={pickedRtRole} onPick={setPickedRtRole} />
<div className="space-y-4">
<Field
label="Username"
name="username"
autoComplete="username"
value={username}
onChange={setUsername}
disabled={loginMutation.isPending}
/>
<Field
label="Password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={setPassword}
disabled={loginMutation.isPending}
/>
</div>
) : (
<SocForm />
<SocPlaceholder />
)}
{errorMsg && mode === 'rt' && (
<p
role="alert"
className="mt-4 label-system"
style={{
color: 'var(--state-failed)',
border: '1px solid var(--state-failed)',
padding: '6px 10px',
borderRadius: 'var(--radius-sm)',
}}
>
{errorMsg}
</p>
)}
<div className="mt-6 flex items-center justify-between gap-3">
<p className="label-system text-fg-faint">
{mode === 'rt' ? 'OIDC Keycloak — v2' : 'Token delivered out-of-band'}
</p>
<Button type="submit" variant="primary">
{mode === 'rt' ? 'Enter Mimic →' : 'Open session →'}
<Button
type="submit"
variant="primary"
disabled={mode !== 'rt' || loginMutation.isPending}
>
{loginMutation.isPending ? 'Authenticating …' : 'Enter Mimic →'}
</Button>
</div>
</form>
@@ -140,7 +200,7 @@ export function LoginPage() {
className="mt-6 font-mono text-fg-faint text-center"
style={{ fontSize: '10.5px', letterSpacing: '0.08em' }}
>
mimic.rt.local · session ssrf-protected · audit log live
mimic.rt.local · session cookie HttpOnly · audit log live
</p>
</div>
</div>
@@ -178,7 +238,13 @@ function ModeTab({ active, onClick, tone, label, hint }: ModeTabProps) {
>
<div
className="label-system"
style={{ color: active ? (tone === 'rt' ? 'var(--accent-rt)' : 'var(--accent-soc)') : 'var(--fg-subtle)' }}
style={{
color: active
? tone === 'rt'
? 'var(--accent-rt)'
: 'var(--accent-soc)'
: 'var(--fg-subtle)',
}}
>
{label}
</div>
@@ -189,75 +255,47 @@ function ModeTab({ active, onClick, tone, label, hint }: ModeTabProps) {
);
}
function RtForm({
picked,
onPick,
}: {
picked: 'rt_operator' | 'rt_lead';
onPick: (r: 'rt_operator' | 'rt_lead') => void;
}) {
function SocPlaceholder() {
return (
<div className="space-y-4">
<Field label="Username" placeholder="m.dubreuil" autoComplete="username" />
<Field label="Password" type="password" placeholder="••••••••••" autoComplete="current-password" />
<fieldset className="space-y-2">
<legend className="label-system">Mock role (sprint 0 only)</legend>
<div className="flex gap-2">
<RolePick active={picked === 'rt_operator'} onClick={() => onPick('rt_operator')}>
RT Operator
</RolePick>
<RolePick active={picked === 'rt_lead'} onClick={() => onPick('rt_lead')}>
RT Lead
</RolePick>
</div>
</fieldset>
</div>
);
}
function SocForm() {
return (
<div className="space-y-4">
<Field
label="Session token"
placeholder="msoc_xxxxx-xxxx-xxxx-xxxx"
autoComplete="off"
spellCheck={false}
mono
/>
<p className="font-mono text-fg-faint" style={{ fontSize: '10.5px', lineHeight: 1.5 }}>
Your token was delivered out-of-band by the lead RT.
<br />
Scope: <span className="text-fg-muted">single engagement, read-only on telemetry, write on detection coting.</span>
</p>
</div>
<p
className="font-mono text-fg-faint"
style={{ fontSize: '11px', lineHeight: 1.55 }}
>
SOC session sign-in lands in sprint 2 once the backend exposes
<br />
<code style={{ fontSize: '10.5px' }}>POST /api/v1/auth/soc/session</code>.
<br />
For now, use the RT path with a local account.
</p>
);
}
interface FieldProps {
label: string;
placeholder?: string;
name: string;
type?: string;
autoComplete?: string;
spellCheck?: boolean;
mono?: boolean;
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}
function Field({ label, placeholder, type = 'text', autoComplete, spellCheck, mono }: FieldProps) {
const id = label.toLowerCase().replace(/\s+/g, '-');
function Field({ label, name, type = 'text', autoComplete, value, onChange, disabled }: FieldProps) {
return (
<div>
<label htmlFor={id} className="block label-system mb-1.5">
<label htmlFor={name} className="block label-system mb-1.5">
{label}
</label>
<input
id={id}
name={id}
id={name}
name={name}
type={type}
placeholder={placeholder}
autoComplete={autoComplete}
spellCheck={spellCheck}
className={mono ? 'font-mono' : 'font-sans'}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required
className="font-sans"
style={{
width: '100%',
height: 32,
@@ -272,29 +310,3 @@ function Field({ label, placeholder, type = 'text', autoComplete, spellCheck, mo
</div>
);
}
function RolePick({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex-1 label-system px-3 py-2 transition-colors"
style={{
background: active ? 'oklch(74.0% 0.165 68 / 0.10)' : 'transparent',
color: active ? 'var(--accent-rt)' : 'var(--fg-muted)',
border: `1px solid ${active ? 'var(--accent-rt-muted)' : 'var(--line-default)'}`,
borderRadius: 'var(--radius-sm)',
}}
>
{children}
</button>
);
}