feat(frontend): wireframes for 5 MVP screens + audit (F0.3)

Mock-data wireframes covering spec §5 / §9 surface area. All read from
src/mocks/fixtures.ts — no backend wiring yet. Each screen is built from
the design-system primitives (Panel, Pill, Button, label-system, status-dot)
and adheres to the instrumentation-grade visual grammar.

Screens:
- /login              LoginPage — RT vs SOC mode switch (segmented), role-tinted.
                       RT form picks rt_operator / rt_lead at sign-in (mock only).
                       SOC form takes a session token (out-of-band, D-006).
                       Left rail carries mission brief + platform telemetry.
- /engagements        EngagementsPage — mission roster table (codename, client,
                       status, c2_type, operators, SOC count, window).
- /runs               LiveCockpitPage — the cornerstone screen. 3-column layout:
                       steps timeline | step detail (resolved command,
                       output, evidence, detection) | side rail
                       (DetectionPanel for SOC; EvidencePanel +
                       DetectionPanel readonly + CleanupPanel for RT).
                       Control bar (F6 pause/skip/retry/abort) is lead-RT-only.
                       Stats header: steps done, detected/partial/missed counts.
- /scenarios          ScenarioComposerPage — 3-column composer:
                       filterable TTP library | ordered steps with delays
                       | inspector (params from params_schema_json, target
                       host list, jinja2 cleanup template preview).
                       c2_type locked at scenario level (D-F3 / H33).
- /library            TtpLibraryPage — catalog table with stealth-variant
                       flagging, source provenance (custom/atr/mission),
                       payload_type chip, tags. Import journal / ATR buttons.
- /reports            ReportPage — restricted MITRE matrix (techniques
                       played only, H29), narration timeline, integrity
                       hash footer (SHA-256, H19/H24/F9). PDF/JSON/MD
                       export buttons.
- /audit              AuditPage — append-only journal viewer (lead RT only,
                       F13). Tabular timestamp/actor/role/action/resource.

UX guardrails baked in:
- SOC analysts never see RT-only controls (conditional rendering, not just
  disabled state). UI layer mirrors backend RBAC but does not replace it.
- Layout density and dark-first palette tuned for long purple sessions
  (sober contrast, no flash, status colors carry information without
  being shouted).
- Live cockpit reserves a clear visual slot for cleanup-failed alerts
  (R-T5) — currently a Pill, real alert UX lands when the WebSocket is
  wired in sprint 1+.
This commit is contained in:
ux-frontend
2026-05-21 20:31:24 +02:00
parent ef081c8c28
commit 12bc33469c
9 changed files with 1654 additions and 0 deletions

View File

@@ -0,0 +1,300 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
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';
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 0 mock: no validation. Picking a role assumes that role's mock
* session and lands the user inside the shell.
*/
export function LoginPage() {
const [mode, setMode] = useState<Mode>('rt');
const [pickedRtRole, setPickedRtRole] = useState<'rt_operator' | 'rt_lead'>('rt_lead');
const { signIn } = useSession();
const navigate = useNavigate();
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 });
};
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={{
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">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>
</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>
{/* 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'}
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"
/>
</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' ? (
<RtForm picked={pickedRtRole} onPick={setPickedRtRole} />
) : (
<SocForm />
)}
<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>
</div>
</form>
<p
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
</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 RtForm({
picked,
onPick,
}: {
picked: 'rt_operator' | 'rt_lead';
onPick: (r: 'rt_operator' | 'rt_lead') => void;
}) {
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>
);
}
interface FieldProps {
label: string;
placeholder?: string;
type?: string;
autoComplete?: string;
spellCheck?: boolean;
mono?: boolean;
}
function Field({ label, placeholder, type = 'text', autoComplete, spellCheck, mono }: FieldProps) {
const id = label.toLowerCase().replace(/\s+/g, '-');
return (
<div>
<label htmlFor={id} className="block label-system mb-1.5">
{label}
</label>
<input
id={id}
name={id}
type={type}
placeholder={placeholder}
autoComplete={autoComplete}
spellCheck={spellCheck}
className={mono ? 'font-mono' : '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>
);
}
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>
);
}