feat(frontend): role-aware app shell + routing skeleton (F0.4)

- Role enum (rt_operator, rt_lead, soc_analyst) aligned with spec §3 / F11.
  Frontend predicates (isRT, isLead, isSOC) drive layout only — backend
  remains the source of truth for permissions (D-008).
- SessionContext split into Provider (TSX) and hook (useSession) to satisfy
  react-refresh/only-export-components.
- AppShell composes StatusRail (link health, active run, UTC clock, build) +
  Sidebar (role-conditional nav with keyboard shortcut hints) + Outlet.
  Unauthenticated visitors redirect to /login.
- StatusRail uses pulsing status-dot pattern and label-system micro-typo
  (uppercase 10px, 0.08em tracking) to evoke an instrument-panel header.
- Router (createBrowserRouter): /login outside the shell, all app routes
  nested inside the shell. RootLayout extracted to its own file for
  fast-refresh compliance.

Routes (sprint 0, flat):
  /login                 LoginPage
  /engagements           EngagementsPage
  /library               TtpLibraryPage (RT only — gated client-side, will
                                          be re-enforced by backend RBAC)
  /scenarios             ScenarioComposerPage (RT only)
  /runs                  LiveCockpitPage
  /reports               ReportPage
  /audit                 AuditPage (lead RT only)

Sub-routes under /engagements/:eid land in sprint 1+ when real scoping
arrives.
This commit is contained in:
ux-frontend
2026-05-21 20:31:01 +02:00
parent 1562478a54
commit ef081c8c28
10 changed files with 401 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import { Outlet, Navigate } from 'react-router-dom';
import { useSession } from '@/session/useSession';
import { Sidebar } from './Sidebar';
import { StatusRail } from './StatusRail';
import { useClock } from './useClock';
/**
* Role-aware layout shell.
*
* Composition (instrument-panel inspired):
* ┌──────────────────────────────────────────────────────────────┐
* │ StatusRail (link health · active run · UTC clock · build) │
* ├──────────┬───────────────────────────────────────────────────┤
* │ │ │
* │ Sidebar │ Outlet (current screen) │
* │ │ │
* └──────────┴───────────────────────────────────────────────────┘
*
* Unauthenticated visitors are redirected to /login. Inside the shell, the
* sidebar and rail expose only what the current session's role can see —
* this is layout, not enforcement: the API remains the source of truth on
* permissions (D-008 / F11).
*/
export function AppShell() {
const { user } = useSession();
const clock = useClock();
if (!user) {
return <Navigate to="/login" replace />;
}
return (
<div className="h-screen flex flex-col" style={{ backgroundColor: 'var(--surface-0)' }}>
<StatusRail clock={clock} />
<div className="flex-1 flex min-h-0">
<Sidebar />
<main className="flex-1 min-w-0 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { NavLink } from 'react-router-dom';
import { clsx } from 'clsx';
import { Logo } from '@/components/brand/Logo';
import { useSession } from '@/session/useSession';
import { ROLE_LABELS, isRT, isLead } from '@/types/roles';
interface NavItem {
to: string;
label: string;
shortcut: string;
show: (args: { canRT: boolean; canLead: boolean }) => boolean;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/engagements', label: 'Engagements', shortcut: 'E', show: () => true },
{ to: '/library', label: 'TTP Library', shortcut: 'L', show: ({ canRT }) => canRT },
{ to: '/scenarios', label: 'Scenarios', shortcut: 'S', show: ({ canRT }) => canRT },
{ to: '/runs', label: 'Live Runs', shortcut: 'R', show: () => true },
{ to: '/reports', label: 'Reports', shortcut: 'P', show: () => true },
{ to: '/audit', label: 'Audit Log', shortcut: 'A', show: ({ canLead }) => canLead },
];
export function Sidebar() {
const { user, signOut } = useSession();
if (!user) return null;
const canRT = isRT(user.role);
const canLead = isLead(user.role);
const visible = NAV_ITEMS.filter((item) => item.show({ canRT, canLead }));
return (
<aside
className="flex flex-col h-full w-56 border-r"
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
>
<div
className="px-3 py-4 border-b"
style={{ borderColor: 'var(--line-default)' }}
>
<Logo build="0.1.0" />
</div>
<nav className="flex-1 py-3" aria-label="Primary">
<div className="px-3 mb-2 label-system">Navigation</div>
<ul className="space-y-px">
{visible.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) =>
clsx(
'group flex items-center justify-between px-3 py-1.5 mx-2',
'text-[12.5px] transition-colors',
isActive
? 'text-fg-default bg-surface-3'
: 'text-fg-muted hover:text-fg-default hover:bg-surface-2',
)
}
style={{ borderRadius: 'var(--radius-sm)' }}
>
{({ isActive }) => (
<>
<span className="inline-flex items-center gap-2">
<span
aria-hidden="true"
className={clsx(
'inline-block w-1 h-3.5',
isActive ? 'bg-accent-rt' : 'bg-transparent',
)}
style={{ borderRadius: 'var(--radius-xs)' }}
/>
{item.label}
</span>
<kbd
className="font-mono text-fg-faint tabular"
style={{ fontSize: '9.5px' }}
>
{item.shortcut}
</kbd>
</>
)}
</NavLink>
</li>
))}
</ul>
</nav>
<div
className="px-3 py-3 border-t space-y-2"
style={{ borderColor: 'var(--line-default)' }}
>
<div className="label-system">Session</div>
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="truncate text-fg-default" style={{ fontSize: '12px' }}>
{user.displayName}
</div>
<div
className="label-system mt-0.5"
style={{ color: isRT(user.role) ? 'var(--accent-rt)' : 'var(--accent-soc)' }}
>
{ROLE_LABELS[user.role]}
</div>
</div>
<button
type="button"
onClick={signOut}
className="label-system text-fg-faint hover:text-fg-default px-2 py-1"
style={{ border: '1px solid var(--line-default)', borderRadius: 'var(--radius-sm)' }}
>
Sign out
</button>
</div>
<div className="font-mono tabular text-fg-faint truncate" style={{ fontSize: '10px' }}>
ENG · {user.engagementName}
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,85 @@
import { clsx } from 'clsx';
/**
* Top status rail — the constant "instrument panel header strip".
* Renders link health, run state, time-of-day, build id.
*
* Purpose:
* - Persistent context across every screen, mimicking the masthead of a
* SDR ground-station UI (one line of telemetry visible at all times).
* - In MVP+, when a run is active, the run-state pill becomes the link
* to the live cockpit so the lead RT can always jump back to it.
*/
type LinkState = 'up' | 'degraded' | 'down';
interface StatusRailProps {
linkState?: LinkState;
activeRunId?: string;
activeRunState?: 'running' | 'paused' | 'aborting' | 'idle';
clock: string;
}
const LINK_LABEL: Record<LinkState, string> = {
up: 'LINK · UP',
degraded: 'LINK · DEG',
down: 'LINK · DOWN',
};
const LINK_DOT: Record<LinkState, string> = {
up: 'text-link-up',
degraded: 'text-link-degraded',
down: 'text-link-down',
};
export function StatusRail({
linkState = 'up',
activeRunId,
activeRunState = 'idle',
clock,
}: StatusRailProps) {
return (
<div
className="flex items-center gap-6 px-4 h-7 border-b text-fg-subtle"
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
role="status"
aria-label="System telemetry"
>
<span className="inline-flex items-center gap-2">
<span className={clsx('status-dot', LINK_DOT[linkState], linkState === 'up' && 'pulsing')} />
<span className="label-system tabular">{LINK_LABEL[linkState]}</span>
</span>
<span
aria-hidden="true"
style={{ width: 1, height: 12, background: 'var(--line-default)' }}
/>
{activeRunId ? (
<span className="inline-flex items-center gap-2">
<span
className={clsx(
'status-dot',
activeRunState === 'running' && 'text-state-running pulsing',
activeRunState === 'paused' && 'text-state-paused',
activeRunState === 'aborting' && 'text-state-failed',
activeRunState === 'idle' && 'text-fg-faint',
)}
/>
<span className="label-system tabular">
RUN · {activeRunId.toUpperCase()} · {activeRunState.toUpperCase()}
</span>
</span>
) : (
<span className="label-system text-fg-faint">RUN · </span>
)}
<span className="flex-1" />
<span className="font-mono tabular text-fg-muted" style={{ fontSize: '11px' }}>
{clock}
</span>
<span className="label-system text-fg-faint">v0.1.0-sprint0</span>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';
function format(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`;
}
export function useClock(): string {
const [now, setNow] = useState(() => format(new Date()));
useEffect(() => {
const id = window.setInterval(() => setNow(format(new Date())), 1000);
return () => window.clearInterval(id);
}, []);
return now;
}

58
frontend/src/router.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { RootLayout } from '@/router/RootLayout';
import { AppShell } from '@/components/shell/AppShell';
import { LoginPage } from '@/screens/login/LoginPage';
import { EngagementsPage } from '@/screens/engagements/EngagementsPage';
import { LiveCockpitPage } from '@/screens/cockpit/LiveCockpitPage';
import { ScenarioComposerPage } from '@/screens/composer/ScenarioComposerPage';
import { ReportPage } from '@/screens/report/ReportPage';
import { TtpLibraryPage } from '@/screens/library/TtpLibraryPage';
import { AuditPage } from '@/screens/audit/AuditPage';
/**
* Routes mirror spec §9 (UI Web) with sprint 0 placeholders.
* Once real engagement scoping lands, sub-routes nest under /engagements/:eid.
* Sprint 0 keeps the URLs flat so the wireframes are reachable directly.
*/
export const router = createBrowserRouter([
{
path: '/',
element: (
<RootLayout>
<Navigate to="/login" replace />
</RootLayout>
),
},
{
path: '/login',
element: (
<RootLayout>
<LoginPage />
</RootLayout>
),
},
{
path: '/',
element: (
<RootLayout>
<AppShell />
</RootLayout>
),
children: [
{ path: 'engagements', element: <EngagementsPage /> },
{ path: 'library', element: <TtpLibraryPage /> },
{ path: 'scenarios', element: <ScenarioComposerPage /> },
{ path: 'runs', element: <LiveCockpitPage /> },
{ path: 'reports', element: <ReportPage /> },
{ path: 'audit', element: <AuditPage /> },
],
},
{
path: '*',
element: (
<RootLayout>
<Navigate to="/login" replace />
</RootLayout>
),
},
]);

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import { SessionProvider } from '@/session/SessionContext';
/**
* Wraps every top-level route with the session provider. Kept in its own
* file so the router config can stay export-pure (fast-refresh friendly).
*/
export function RootLayout({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -0,0 +1,10 @@
import { createContext } from 'react';
import type { SessionUser } from '@/types/roles';
export interface SessionContextValue {
user: SessionUser | null;
signIn: (user: SessionUser) => void;
signOut: () => void;
}
export const SessionContext = createContext<SessionContextValue | null>(null);

View File

@@ -0,0 +1,25 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react';
import type { SessionUser } from '@/types/roles';
import { clearMockSession, readMockSession, writeMockSession } from '@/mocks/session';
import { SessionContext, type SessionContextValue } from './SessionContext.context';
export function SessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SessionUser | null>(() => readMockSession());
const signIn = useCallback((next: SessionUser) => {
writeMockSession(next);
setUser(next);
}, []);
const signOut = useCallback(() => {
clearMockSession();
setUser(null);
}, []);
const value = useMemo<SessionContextValue>(
() => ({ user, signIn, signOut }),
[user, signIn, signOut],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { SessionContext } from './SessionContext.context';
export function useSession() {
const ctx = useContext(SessionContext);
if (!ctx) {
throw new Error('useSession must be used inside <SessionProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,25 @@
/**
* Spec §3 / F11 — three roles, enforced by the backend RBAC matrix.
* Frontend uses this enum only to drive layout/conditional rendering;
* the backend remains the source of truth on what is actually permitted.
*/
export type Role = 'rt_operator' | 'rt_lead' | 'soc_analyst';
export interface SessionUser {
id: string;
displayName: string;
role: Role;
engagementId: string;
engagementName: string;
}
export const ROLE_LABELS: Record<Role, string> = {
rt_operator: 'RT Operator',
rt_lead: 'RT Lead',
soc_analyst: 'SOC Analyst',
};
/** Convenience predicates kept in one place so all screens read identically. */
export const isRT = (role: Role): boolean => role === 'rt_operator' || role === 'rt_lead';
export const isLead = (role: Role): boolean => role === 'rt_lead';
export const isSOC = (role: Role): boolean => role === 'soc_analyst';