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

@@ -0,0 +1,447 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type FormEvent,
type KeyboardEvent,
type ReactNode,
type Ref,
} from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/Button';
import { ApiClientError } from '@/lib/api';
import type { ApiValidationError } from '@/types/api';
import { createEngagement, ENGAGEMENTS_QUERY_KEY } from './engagementsApi';
interface EngagementCreateDialogProps {
onClose: () => void;
}
type FieldErrors = Partial<Record<'name' | 'client_name' | 'description', string>>;
/**
* "Arm engagement" dialog.
*
* Visual grammar:
* - Backdrop: graphite dim + faint scanline texture, no blur. Reads as
* "the cockpit is paused while you issue a command", not a sleek
* SaaS overlay.
* - Surface: --surface-3 (one level above panels), corner-mark utility
* at the four corners, hairline divider beneath the masthead.
* - Inputs: label-system uppercase + underline that lights amber on
* focus. No rounded boxes; the form should read as a console.
* - Submit: primary amber Button — the same accent used for RT-only
* actions throughout the app, so the action lineage is obvious.
*
* Behavior:
* - Esc and outside click close (unless the mutation is in flight).
* - Backend 422 Pydantic errors are mapped to per-field inline messages.
* - Other 4xx/5xx surface as a generic top-of-form alert.
*/
export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps) {
const titleId = useId();
const surfaceRef = useRef<HTMLDivElement>(null);
const firstFieldRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState('');
const [clientName, setClientName] = useState('');
const [description, setDescription] = useState('');
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [topError, setTopError] = useState<string | null>(null);
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createEngagement,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ENGAGEMENTS_QUERY_KEY });
onClose();
},
onError: (err) => {
if (err instanceof ApiClientError && err.status === 422 && err.body) {
setFieldErrors(mapValidationErrors(err.body as ApiValidationError));
setTopError(null);
return;
}
if (err instanceof ApiClientError && err.status === 401) {
setTopError('Session expirée. Reconnectez-vous.');
return;
}
setTopError('Création impossible. Réessayez dans un instant.');
},
});
const isPending = mutation.isPending;
// Focus the first field on open. ESC closes unless a request is in flight.
useEffect(() => {
firstFieldRef.current?.focus();
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape' && !isPending) {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isPending, onClose]);
// Rudimentary focus trap: cycle Tab/Shift+Tab within the dialog.
const handleSurfaceKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Tab' || !surfaceRef.current) return;
const focusables = surfaceRef.current.querySelectorAll<HTMLElement>(
'input, textarea, button, [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (!first || !last) return;
const active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setFieldErrors({});
setTopError(null);
if (!name.trim()) {
setFieldErrors({ name: 'Nom requis.' });
return;
}
mutation.mutate({
name: name.trim(),
client_name: clientName.trim() || undefined,
description: description.trim() || undefined,
});
};
const draftId = useMemo(() => generateDraftId(), []);
return (
<div
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget && !isPending) onClose();
}}
style={{
position: 'fixed',
inset: 0,
zIndex: 50,
background:
'radial-gradient(circle at 50% 35%, oklch(7.4% 0.012 247 / 0.55), oklch(5.8% 0.012 247 / 0.82) 60%)',
backgroundColor: 'oklch(5.8% 0.012 247 / 0.78)',
}}
>
{/* Faint scanline texture overlay — reads as "instrument feed paused" */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'repeating-linear-gradient(0deg, transparent 0, transparent 2px, oklch(100% 0 0 / 0.012) 2px, oklch(100% 0 0 / 0.012) 3px)',
pointerEvents: 'none',
}}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
ref={surfaceRef}
onKeyDown={handleSurfaceKeyDown}
className="corner-mark"
style={{
position: 'absolute',
top: '14%',
left: '50%',
transform: 'translateX(-50%)',
width: 'min(520px, calc(100vw - 32px))',
backgroundColor: 'var(--surface-3)',
border: '1px solid var(--line-strong)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-pop)',
animation: 'dialog-in 140ms var(--ease-mech) both',
}}
>
{/* Masthead */}
<div
className="flex items-center justify-between gap-3 px-5 py-3 border-b"
style={{ borderColor: 'var(--line-default)' }}
>
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="status-dot pulsing"
style={{ color: 'var(--accent-rt)' }}
/>
<h2
id={titleId}
className="label-system"
style={{ color: 'var(--accent-rt)', letterSpacing: '0.18em' }}
>
ARM · NEW ENGAGEMENT
</h2>
</div>
<span
className="font-mono tabular text-fg-faint"
style={{ fontSize: '10.5px' }}
>
{draftId}
</span>
</div>
{/* Amber hairline accent */}
<div
aria-hidden="true"
style={{
height: 1,
background:
'linear-gradient(90deg, transparent 0%, var(--accent-rt) 50%, transparent 100%)',
opacity: 0.55,
}}
/>
<form onSubmit={handleSubmit} className="px-5 py-4 space-y-4" noValidate>
{topError && (
<div
role="alert"
className="label-system"
style={{
color: 'var(--state-failed)',
border: '1px solid var(--state-failed)',
padding: '6px 10px',
borderRadius: 'var(--radius-sm)',
}}
>
{topError}
</div>
)}
<ConsoleField
label="Engagement name"
required
value={name}
onChange={setName}
error={fieldErrors.name}
disabled={isPending}
placeholder="OPERATION RUSTED ANCHOR"
ref={firstFieldRef}
mono
/>
<ConsoleField
label="Client"
value={clientName}
onChange={setClientName}
error={fieldErrors.client_name}
disabled={isPending}
placeholder="Démo Client X"
/>
<ConsoleTextarea
label="Brief"
value={description}
onChange={setDescription}
error={fieldErrors.description}
disabled={isPending}
placeholder="Scope notes, ROE pointers, post-mission expectations."
/>
<div
className="flex items-center justify-between gap-3 pt-3 border-t"
style={{ borderColor: 'var(--line-default)' }}
>
<span className="label-system text-fg-faint">
{isPending ? '// transmitting …' : '// awaiting confirmation'}
</span>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClose}
disabled={isPending}
>
Cancel
</Button>
<Button type="submit" variant="primary" size="sm" disabled={isPending}>
{isPending ? 'Arming …' : 'Arm engagement →'}
</Button>
</div>
</div>
</form>
</div>
{/* keyframes inlined so the component is self-contained */}
<style>{`
@keyframes dialog-in {
0% { opacity: 0; transform: translate(-50%, calc(-50% + 4px)) translateY(8px); }
100% { opacity: 1; transform: translateX(-50%) translateY(0); }
}
`}</style>
</div>
);
}
function mapValidationErrors(body: ApiValidationError): FieldErrors {
const out: FieldErrors = {};
for (const item of body.detail) {
const last = item.loc[item.loc.length - 1];
if (last === 'name' || last === 'client_name' || last === 'description') {
out[last] = item.msg;
}
}
return out;
}
function generateDraftId(): string {
const t = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `DRAFT-${t.getUTCFullYear().toString().slice(-2)}${pad(t.getUTCMonth() + 1)}${pad(t.getUTCDate())}-${pad(t.getUTCHours())}${pad(t.getUTCMinutes())}`;
}
interface ConsoleFieldProps {
label: string;
value: string;
onChange: (next: string) => void;
required?: boolean;
disabled?: boolean;
placeholder?: string;
error?: string;
mono?: boolean;
}
function ConsoleField({
label,
value,
onChange,
required,
disabled,
placeholder,
error,
mono,
ref,
}: ConsoleFieldProps & { ref?: Ref<HTMLInputElement> }): ReactNode {
const id = useId();
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block label-system flex items-center justify-between">
<span>{label}</span>
{required && (
<span style={{ color: 'var(--accent-rt)' }} aria-hidden="true">
· required
</span>
)}
</label>
<input
id={id}
ref={ref}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={placeholder}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${id}-err` : undefined}
className={mono ? 'font-mono tabular' : 'font-sans'}
style={{
width: '100%',
height: 30,
padding: '0 0 4px 0',
backgroundColor: 'transparent',
color: 'var(--fg-default)',
border: 'none',
borderBottom: `1px solid ${error ? 'var(--state-failed)' : 'var(--line-strong)'}`,
borderRadius: 0,
fontSize: 13,
outline: 'none',
}}
onFocus={(e) => {
if (!error) {
e.currentTarget.style.borderBottomColor = 'var(--accent-rt)';
}
}}
onBlur={(e) => {
if (!error) {
e.currentTarget.style.borderBottomColor = 'var(--line-strong)';
}
}}
/>
{error && (
<p
id={`${id}-err`}
className="label-system"
style={{ color: 'var(--state-failed)' }}
>
{error}
</p>
)}
</div>
);
}
interface ConsoleTextareaProps {
label: string;
value: string;
onChange: (next: string) => void;
disabled?: boolean;
placeholder?: string;
error?: string;
}
function ConsoleTextarea({
label,
value,
onChange,
disabled,
placeholder,
error,
}: ConsoleTextareaProps): ReactNode {
const id = useId();
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block label-system">
{label}
</label>
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={placeholder}
rows={4}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${id}-err` : undefined}
className="font-sans"
style={{
width: '100%',
padding: '8px 10px',
backgroundColor: 'var(--surface-inset)',
color: 'var(--fg-default)',
border: `1px solid ${error ? 'var(--state-failed)' : 'var(--line-default)'}`,
borderRadius: 'var(--radius-sm)',
fontSize: 12.5,
lineHeight: 1.55,
resize: 'vertical',
outline: 'none',
}}
/>
{error && (
<p
id={`${id}-err`}
className="label-system"
style={{ color: 'var(--state-failed)' }}
>
{error}
</p>
)}
</div>
);
}

View File

@@ -1,12 +1,15 @@
import type { ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Panel } from '@/components/ui/Panel'; import { Panel } from '@/components/ui/Panel';
import { Pill } from '@/components/ui/Pill'; import { Pill } from '@/components/ui/Pill';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { MOCK_ENGAGEMENTS } from '@/mocks/fixtures'; import { ApiClientError } from '@/lib/api';
import type { MockEngagement } from '@/mocks/fixtures'; 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', active: 'running',
reporting: 'soc', reporting: 'soc',
archived: 'pending', archived: 'pending',
@@ -14,12 +17,24 @@ const STATUS_TONE: Record<MockEngagement['status'], 'running' | 'soc' | 'success
}; };
export function EngagementsPage() { 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 ( return (
<div className="px-8 py-6 space-y-6 max-w-[1400px] mx-auto"> <div className="px-8 py-6 space-y-6 max-w-[1400px] mx-auto">
<header className="flex items-end justify-between"> <header className="flex items-end justify-between">
<div> <div>
<div className="label-system mb-1">// Engagements</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 Mission roster
</h1> </h1>
<p className="text-fg-muted mt-1" style={{ fontSize: '12.5px' }}> <p className="text-fg-muted mt-1" style={{ fontSize: '12.5px' }}>
@@ -27,79 +42,151 @@ export function EngagementsPage() {
runs, and reports. runs, and reports.
</p> </p>
</div> </div>
<Button variant="primary">+ New engagement</Button> <Button variant="primary" onClick={() => setCreateOpen(true)}>
+ New engagement
</Button>
</header> </header>
<Panel <Panel
title="Active and recent" title="Active and recent"
meta={ meta={
<span className="tabular"> <span className="tabular">
{MOCK_ENGAGEMENTS.length} entries · sorted by start date {query.isLoading
? 'loading …'
: query.isError
? 'error'
: `${String(engagements.length)} entries`}
</span> </span>
} }
> >
<table className="w-full" style={{ fontSize: 12.5 }}> {query.isLoading ? (
<thead> <LoadingRow />
<tr className="text-fg-subtle"> ) : query.isError ? (
<Th>Codename</Th> <ErrorRow error={query.error} onRetry={() => void query.refetch()} />
<Th>Client</Th> ) : engagements.length === 0 ? (
<Th>Status</Th> <EmptyRow onCreate={() => setCreateOpen(true)} />
<Th>C2</Th> ) : (
<Th align="right">Operators</Th> <EngagementsTable engagements={engagements} />
<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>
</Panel> </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> </div>
); );
} }

View File

@@ -0,0 +1,21 @@
import { apiFetch } from '@/lib/api';
import type { Engagement, EngagementCreate } from '@/types/api';
export const ENGAGEMENTS_QUERY_KEY = ['engagements'] as const;
/**
* GET /api/v1/engagements
*
* Backend may return either a bare array or a `{ items: [...] }` envelope.
* Sprint 1 unwraps both shapes so the UI doesn't have to care which one
* landed — the OpenAPI source of truth is on backend's roadmap.
*/
export async function fetchEngagements(signal?: AbortSignal): Promise<Engagement[]> {
const data = await apiFetch<Engagement[] | { items: Engagement[] }>('/engagements', { signal });
if (Array.isArray(data)) return data;
return data.items;
}
export async function createEngagement(payload: EngagementCreate): Promise<Engagement> {
return apiFetch<Engagement>('/engagements', { method: 'POST', body: payload });
}

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 { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Logo } from '@/components/brand/Logo'; import { Logo } from '@/components/brand/Logo';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Pill } from '@/components/ui/Pill'; import { Pill } from '@/components/ui/Pill';
import { useSession } from '@/session/useSession'; import { ApiClientError } from '@/lib/api';
import { MOCK_SESSIONS } from '@/mocks/session'; import { login } from '@/session/sessionApi';
import type { Role } from '@/types/roles'; import { SESSION_QUERY_KEY } from '@/session/useSession';
type Mode = 'rt' | 'soc'; type Mode = 'rt' | 'soc';
/** /**
* Login screen — two distinct paths. * Login screen — two distinct paths.
* *
* RT operators authenticate via username/password (D-003 v1, OIDC v2). * Sprint 1 wires the RT operator path against POST /api/v1/auth/login.
* SOC analysts use a one-shot token (D-006: bcrypt-hashed soc_session, * The SOC analyst path stays visible (so the masthead doesn't change) but
* clear value delivered out-of-band by the lead RT). * 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 * On success the server sets an HttpOnly session cookie; the response body
* session and lands the user inside the shell. * 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() { export function LoginPage() {
const [mode, setMode] = useState<Mode>('rt'); const [mode, setMode] = useState<Mode>('rt');
const [pickedRtRole, setPickedRtRole] = useState<'rt_operator' | 'rt_lead'>('rt_lead'); const [username, setUsername] = useState('');
const { signIn } = useSession(); const [password, setPassword] = useState('');
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const navigate = useNavigate(); 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) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
const role: Role = mode === 'soc' ? 'soc_analyst' : pickedRtRole; if (mode !== 'rt') return;
const user = MOCK_SESSIONS[role]; if (!username || !password) {
if (!user) return; setErrorMsg('Nom dutilisateur et mot de passe requis.');
signIn(user); return;
void navigate(mode === 'soc' ? '/runs' : '/engagements', { replace: true }); }
loginMutation.mutate({ username, password });
}; };
return ( return (
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--surface-0)' }}> <div className="min-h-screen flex" style={{ backgroundColor: 'var(--surface-0)' }}>
{/* Left rail — masthead, identity, telemetry of the platform itself */}
<aside <aside
className="hidden lg:flex flex-col justify-between w-[420px] border-r p-10" className="hidden lg:flex flex-col justify-between w-[420px] border-r p-10"
style={{ style={{
@@ -61,18 +87,14 @@ export function LoginPage() {
</div> </div>
<div className="space-y-3 font-mono tabular text-fg-faint" style={{ fontSize: '11px' }}> <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"> <div className="flex gap-3">
<span className="w-24 text-fg-subtle">deploy</span> <span className="w-24 text-fg-subtle">deploy</span>
<span>RT infra · Caddy + TLS · OPSEC handled by RP</span> <span>RT infra · Caddy + TLS · OPSEC handled by RP</span>
</div> </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>
</div> </div>
@@ -82,10 +104,8 @@ export function LoginPage() {
</div> </div>
</aside> </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="flex-1 flex items-center justify-center px-8 py-16">
<div className="w-full max-w-[420px]"> <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"> <div className="flex items-center gap-1 mb-6" role="tablist" aria-label="Login mode">
<ModeTab <ModeTab
active={mode === 'rt'} active={mode === 'rt'}
@@ -99,7 +119,7 @@ export function LoginPage() {
onClick={() => setMode('soc')} onClick={() => setMode('soc')}
tone="soc" tone="soc"
label="SOC — analyst" label="SOC — analyst"
hint="session token" hint="session token (sprint 2)"
/> />
</div> </div>
@@ -114,24 +134,64 @@ export function LoginPage() {
}} }}
> >
<div className="flex items-center justify-between mb-4"> <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'} {mode === 'rt' ? 'Operator sign-in' : 'SOC session'}
</h1> </h1>
<Pill tone={mode}>{mode === 'rt' ? 'RT' : 'SOC'}</Pill> <Pill tone={mode}>{mode === 'rt' ? 'RT' : 'SOC'}</Pill>
</div> </div>
{mode === 'rt' ? ( {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"> <div className="mt-6 flex items-center justify-between gap-3">
<p className="label-system text-fg-faint"> <p className="label-system text-fg-faint">
{mode === 'rt' ? 'OIDC Keycloak — v2' : 'Token delivered out-of-band'} {mode === 'rt' ? 'OIDC Keycloak — v2' : 'Token delivered out-of-band'}
</p> </p>
<Button type="submit" variant="primary"> <Button
{mode === 'rt' ? 'Enter Mimic →' : 'Open session →'} type="submit"
variant="primary"
disabled={mode !== 'rt' || loginMutation.isPending}
>
{loginMutation.isPending ? 'Authenticating …' : 'Enter Mimic →'}
</Button> </Button>
</div> </div>
</form> </form>
@@ -140,7 +200,7 @@ export function LoginPage() {
className="mt-6 font-mono text-fg-faint text-center" className="mt-6 font-mono text-fg-faint text-center"
style={{ fontSize: '10.5px', letterSpacing: '0.08em' }} 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> </p>
</div> </div>
</div> </div>
@@ -178,7 +238,13 @@ function ModeTab({ active, onClick, tone, label, hint }: ModeTabProps) {
> >
<div <div
className="label-system" 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} {label}
</div> </div>
@@ -189,75 +255,47 @@ function ModeTab({ active, onClick, tone, label, hint }: ModeTabProps) {
); );
} }
function RtForm({ function SocPlaceholder() {
picked,
onPick,
}: {
picked: 'rt_operator' | 'rt_lead';
onPick: (r: 'rt_operator' | 'rt_lead') => void;
}) {
return ( return (
<div className="space-y-4"> <p
<Field label="Username" placeholder="m.dubreuil" autoComplete="username" /> className="font-mono text-fg-faint"
<Field label="Password" type="password" placeholder="••••••••••" autoComplete="current-password" /> style={{ fontSize: '11px', lineHeight: 1.55 }}
<fieldset className="space-y-2"> >
<legend className="label-system">Mock role (sprint 0 only)</legend> SOC session sign-in lands in sprint 2 once the backend exposes
<div className="flex gap-2"> <br />
<RolePick active={picked === 'rt_operator'} onClick={() => onPick('rt_operator')}> <code style={{ fontSize: '10.5px' }}>POST /api/v1/auth/soc/session</code>.
RT Operator <br />
</RolePick> For now, use the RT path with a local account.
<RolePick active={picked === 'rt_lead'} onClick={() => onPick('rt_lead')}> </p>
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>
); );
} }
interface FieldProps { interface FieldProps {
label: string; label: string;
placeholder?: string; name: string;
type?: string; type?: string;
autoComplete?: string; autoComplete?: string;
spellCheck?: boolean; value: string;
mono?: boolean; onChange: (next: string) => void;
disabled?: boolean;
} }
function Field({ label, placeholder, type = 'text', autoComplete, spellCheck, mono }: FieldProps) { function Field({ label, name, type = 'text', autoComplete, value, onChange, disabled }: FieldProps) {
const id = label.toLowerCase().replace(/\s+/g, '-');
return ( return (
<div> <div>
<label htmlFor={id} className="block label-system mb-1.5"> <label htmlFor={name} className="block label-system mb-1.5">
{label} {label}
</label> </label>
<input <input
id={id} id={name}
name={id} name={name}
type={type} type={type}
placeholder={placeholder}
autoComplete={autoComplete} autoComplete={autoComplete}
spellCheck={spellCheck} value={value}
className={mono ? 'font-mono' : 'font-sans'} onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required
className="font-sans"
style={{ style={{
width: '100%', width: '100%',
height: 32, height: 32,
@@ -272,29 +310,3 @@ function Field({ label, placeholder, type = 'text', autoComplete, spellCheck, mo
</div> </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>
);
}