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:
43
frontend/src/components/shell/AppShell.tsx
Normal file
43
frontend/src/components/shell/AppShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
frontend/src/components/shell/Sidebar.tsx
Normal file
120
frontend/src/components/shell/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/components/shell/StatusRail.tsx
Normal file
85
frontend/src/components/shell/StatusRail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/shell/useClock.ts
Normal file
15
frontend/src/components/shell/useClock.ts
Normal 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
58
frontend/src/router.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]);
|
||||||
10
frontend/src/router/RootLayout.tsx
Normal file
10
frontend/src/router/RootLayout.tsx
Normal 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>;
|
||||||
|
}
|
||||||
10
frontend/src/session/SessionContext.context.ts
Normal file
10
frontend/src/session/SessionContext.context.ts
Normal 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);
|
||||||
25
frontend/src/session/SessionContext.tsx
Normal file
25
frontend/src/session/SessionContext.tsx
Normal 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>;
|
||||||
|
}
|
||||||
10
frontend/src/session/useSession.ts
Normal file
10
frontend/src/session/useSession.ts
Normal 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;
|
||||||
|
}
|
||||||
25
frontend/src/types/roles.ts
Normal file
25
frontend/src/types/roles.ts
Normal 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';
|
||||||
Reference in New Issue
Block a user