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:
447
frontend/src/screens/engagements/EngagementCreateDialog.tsx
Normal file
447
frontend/src/screens/engagements/EngagementCreateDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,47 +42,73 @@ 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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<LoadingRow />
|
||||||
|
) : query.isError ? (
|
||||||
|
<ErrorRow error={query.error} onRetry={() => void query.refetch()} />
|
||||||
|
) : engagements.length === 0 ? (
|
||||||
|
<EmptyRow onCreate={() => setCreateOpen(true)} />
|
||||||
|
) : (
|
||||||
|
<EngagementsTable engagements={engagements} />
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{createOpen && <EngagementCreateDialog onClose={() => setCreateOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EngagementsTable({ engagements }: { engagements: Engagement[] }) {
|
||||||
|
return (
|
||||||
<table className="w-full" style={{ fontSize: 12.5 }}>
|
<table className="w-full" style={{ fontSize: 12.5 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-fg-subtle">
|
<tr className="text-fg-subtle">
|
||||||
<Th>Codename</Th>
|
<Th>Name</Th>
|
||||||
<Th>Client</Th>
|
<Th>Client</Th>
|
||||||
<Th>Status</Th>
|
<Th>Status</Th>
|
||||||
<Th>C2</Th>
|
<Th>C2</Th>
|
||||||
<Th align="right">Operators</Th>
|
|
||||||
<Th align="right">SOC</Th>
|
|
||||||
<Th>Window</Th>
|
<Th>Window</Th>
|
||||||
<Th />
|
<Th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{MOCK_ENGAGEMENTS.map((eng, idx) => (
|
{engagements.map((eng, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={eng.id}
|
key={eng.id}
|
||||||
style={{
|
style={{
|
||||||
borderTop: idx === 0 ? '1px solid var(--line-default)' : '1px solid var(--line-faint)',
|
borderTop:
|
||||||
|
idx === 0 ? '1px solid var(--line-default)' : '1px solid var(--line-faint)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Td>
|
<Td>
|
||||||
<div className="font-display text-fg-default" style={{ letterSpacing: '0.06em' }}>
|
<div
|
||||||
{eng.codename}
|
className="font-display text-fg-default"
|
||||||
|
style={{ letterSpacing: '0.06em' }}
|
||||||
|
>
|
||||||
|
{eng.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-fg-faint" style={{ fontSize: '10.5px' }}>
|
<div className="font-mono text-fg-faint" style={{ fontSize: '10.5px' }}>
|
||||||
{eng.id}
|
{eng.id}
|
||||||
</div>
|
</div>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>{eng.client}</Td>
|
<Td>{eng.client_name ?? <span className="text-fg-faint">—</span>}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<Pill tone={STATUS_TONE[eng.status]}>
|
<Pill tone={STATUS_TONE[eng.status]}>
|
||||||
<span className="status-dot" style={{ color: 'currentColor' }} />
|
<span className="status-dot" style={{ color: 'currentColor' }} />
|
||||||
@@ -75,18 +116,20 @@ export function EngagementsPage() {
|
|||||||
</Pill>
|
</Pill>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<span className="font-mono tabular">{eng.c2Type.toUpperCase()}</span>
|
{eng.c2_type ? (
|
||||||
</Td>
|
<span className="font-mono tabular">{eng.c2_type.toUpperCase()}</span>
|
||||||
<Td align="right">
|
) : (
|
||||||
<span className="font-mono tabular">{eng.operators}</span>
|
<span className="text-fg-faint">—</span>
|
||||||
</Td>
|
)}
|
||||||
<Td align="right">
|
|
||||||
<span className="font-mono tabular">{eng.socAnalysts}</span>
|
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
|
{eng.start_date || eng.end_date ? (
|
||||||
<span className="font-mono tabular text-fg-muted">
|
<span className="font-mono tabular text-fg-muted">
|
||||||
{eng.startDate} → {eng.endDate}
|
{eng.start_date ?? '—'} → {eng.end_date ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-fg-faint">—</span>
|
||||||
|
)}
|
||||||
</Td>
|
</Td>
|
||||||
<Td align="right">
|
<Td align="right">
|
||||||
<Link to="/runs">
|
<Link to="/runs">
|
||||||
@@ -99,7 +142,51 @@ export function EngagementsPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Panel>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
21
frontend/src/screens/engagements/engagementsApi.ts
Normal file
21
frontend/src/screens/engagements/engagementsApi.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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 d’utilisateur 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">
|
|
||||||
<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 />
|
<br />
|
||||||
Scope: <span className="text-fg-muted">single engagement, read-only on telemetry, write on detection coting.</span>
|
<code style={{ fontSize: '10.5px' }}>POST /api/v1/auth/soc/session</code>.
|
||||||
|
<br />
|
||||||
|
For now, use the RT path with a local account.
|
||||||
</p>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user