Files
mimic-big/frontend/src/screens/login/LoginPage.tsx
ux-frontend 2eefd11019 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.
2026-05-23 04:26:48 +02:00

313 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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.
*
* 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.
*
* 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 [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();
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)' }}>
<aside
className="hidden lg:flex flex-col justify-between w-[420px] border-r p-10"
style={{
borderColor: 'var(--line-default)',
backgroundColor: 'var(--surface-1)',
}}
>
<div className="space-y-10">
<Logo build="0.1.0" />
<div className="space-y-4">
<div className="label-system">// Mission brief</div>
<p className="text-fg-default" style={{ fontSize: '15px', lineHeight: 1.55 }}>
Internal Breach &amp; Attack Simulation platform.
<br />
<span className="text-fg-muted">
Replays red-team TTPs against client infrastructure in white-glove coordination
with the SOC. Outputs a measurable MITRE ATT&amp;CK coverage report per
engagement.
</span>
</p>
</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">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>
<div className="space-y-2 font-mono text-fg-faint" style={{ fontSize: '10px' }}>
<div>// Mimic is for authorized engagements only.</div>
<div>// Access is logged, audited, and reviewed weekly.</div>
</div>
</aside>
<div className="flex-1 flex items-center justify-center px-8 py-16">
<div className="w-full max-w-[420px]">
<div className="flex items-center gap-1 mb-6" role="tablist" aria-label="Login mode">
<ModeTab
active={mode === 'rt'}
onClick={() => setMode('rt')}
tone="rt"
label="RT — operator"
hint="username + password"
/>
<ModeTab
active={mode === 'soc'}
onClick={() => setMode('soc')}
tone="soc"
label="SOC — analyst"
hint="session token (sprint 2)"
/>
</div>
<form
onSubmit={handleSubmit}
className="p-6 corner-mark"
style={{
backgroundColor: 'var(--surface-2)',
border: '1px solid var(--line-default)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-panel)',
}}
>
<div className="flex items-center justify-between mb-4">
<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' ? (
<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>
) : (
<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"
disabled={mode !== 'rt' || loginMutation.isPending}
>
{loginMutation.isPending ? 'Authenticating …' : 'Enter Mimic →'}
</Button>
</div>
</form>
<p
className="mt-6 font-mono text-fg-faint text-center"
style={{ fontSize: '10.5px', letterSpacing: '0.08em' }}
>
mimic.rt.local · session cookie HttpOnly · audit log live
</p>
</div>
</div>
</div>
);
}
interface ModeTabProps {
active: boolean;
onClick: () => void;
tone: 'rt' | 'soc';
label: string;
hint: string;
}
function ModeTab({ active, onClick, tone, label, hint }: ModeTabProps) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className="flex-1 text-left px-3 py-2 transition-colors"
style={{
backgroundColor: active ? 'var(--surface-2)' : 'transparent',
border: '1px solid',
borderColor: active
? tone === 'rt'
? 'var(--accent-rt-muted)'
: 'var(--accent-soc-muted)'
: 'var(--line-default)',
borderRadius: 'var(--radius-sm)',
boxShadow: active ? 'var(--shadow-panel)' : 'none',
}}
>
<div
className="label-system"
style={{
color: active
? tone === 'rt'
? 'var(--accent-rt)'
: 'var(--accent-soc)'
: 'var(--fg-subtle)',
}}
>
{label}
</div>
<div className="font-mono text-fg-faint mt-1" style={{ fontSize: '10px' }}>
{hint}
</div>
</button>
);
}
function SocPlaceholder() {
return (
<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;
name: string;
type?: string;
autoComplete?: string;
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}
function Field({ label, name, type = 'text', autoComplete, value, onChange, disabled }: FieldProps) {
return (
<div>
<label htmlFor={name} className="block label-system mb-1.5">
{label}
</label>
<input
id={name}
name={name}
type={type}
autoComplete={autoComplete}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required
className="font-sans"
style={{
width: '100%',
height: 32,
padding: '0 10px',
backgroundColor: 'var(--surface-inset)',
color: 'var(--fg-default)',
border: '1px solid var(--line-strong)',
borderRadius: 'var(--radius-sm)',
fontSize: 12.5,
}}
/>
</div>
);
}