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('rt'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [errorMsg, setErrorMsg] = useState(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 d’utilisateur et mot de passe requis.'); return; } loginMutation.mutate({ username, password }); }; return (
setMode('rt')} tone="rt" label="RT — operator" hint="username + password" /> setMode('soc')} tone="soc" label="SOC — analyst" hint="session token (sprint 2)" />

{mode === 'rt' ? 'Operator sign-in' : 'SOC session'}

{mode === 'rt' ? 'RT' : 'SOC'}
{mode === 'rt' ? (
) : ( )} {errorMsg && mode === 'rt' && (

{errorMsg}

)}

{mode === 'rt' ? 'OIDC Keycloak — v2' : 'Token delivered out-of-band'}

mimic.rt.local · session cookie HttpOnly · audit log live

); } interface ModeTabProps { active: boolean; onClick: () => void; tone: 'rt' | 'soc'; label: string; hint: string; } function ModeTab({ active, onClick, tone, label, hint }: ModeTabProps) { return ( ); } function SocPlaceholder() { return (

SOC session sign-in lands in sprint 2 once the backend exposes
POST /api/v1/auth/soc/session.
For now, use the RT path with a local account.

); } 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 (
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, }} />
); }