301 lines
9.6 KiB
TypeScript
301 lines
9.6 KiB
TypeScript
|
|
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 & 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&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>
|
||
|
|
);
|
||
|
|
}
|