From ef081c8c28dfbcdaad54090b0abb5bcdd615e4b3 Mon Sep 17 00:00:00 2001 From: ux-frontend Date: Thu, 21 May 2026 20:31:01 +0200 Subject: [PATCH] feat(frontend): role-aware app shell + routing skeleton (F0.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- frontend/src/components/shell/AppShell.tsx | 43 +++++++ frontend/src/components/shell/Sidebar.tsx | 120 ++++++++++++++++++ frontend/src/components/shell/StatusRail.tsx | 85 +++++++++++++ frontend/src/components/shell/useClock.ts | 15 +++ frontend/src/router.tsx | 58 +++++++++ frontend/src/router/RootLayout.tsx | 10 ++ .../src/session/SessionContext.context.ts | 10 ++ frontend/src/session/SessionContext.tsx | 25 ++++ frontend/src/session/useSession.ts | 10 ++ frontend/src/types/roles.ts | 25 ++++ 10 files changed, 401 insertions(+) create mode 100644 frontend/src/components/shell/AppShell.tsx create mode 100644 frontend/src/components/shell/Sidebar.tsx create mode 100644 frontend/src/components/shell/StatusRail.tsx create mode 100644 frontend/src/components/shell/useClock.ts create mode 100644 frontend/src/router.tsx create mode 100644 frontend/src/router/RootLayout.tsx create mode 100644 frontend/src/session/SessionContext.context.ts create mode 100644 frontend/src/session/SessionContext.tsx create mode 100644 frontend/src/session/useSession.ts create mode 100644 frontend/src/types/roles.ts diff --git a/frontend/src/components/shell/AppShell.tsx b/frontend/src/components/shell/AppShell.tsx new file mode 100644 index 0000000..4763b1a --- /dev/null +++ b/frontend/src/components/shell/AppShell.tsx @@ -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 ; + } + + return ( +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/shell/Sidebar.tsx b/frontend/src/components/shell/Sidebar.tsx new file mode 100644 index 0000000..ef5ef46 --- /dev/null +++ b/frontend/src/components/shell/Sidebar.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/shell/StatusRail.tsx b/frontend/src/components/shell/StatusRail.tsx new file mode 100644 index 0000000..b60fff2 --- /dev/null +++ b/frontend/src/components/shell/StatusRail.tsx @@ -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 = { + up: 'LINK · UP', + degraded: 'LINK · DEG', + down: 'LINK · DOWN', +}; + +const LINK_DOT: Record = { + up: 'text-link-up', + degraded: 'text-link-degraded', + down: 'text-link-down', +}; + +export function StatusRail({ + linkState = 'up', + activeRunId, + activeRunState = 'idle', + clock, +}: StatusRailProps) { + return ( +
+ + + {LINK_LABEL[linkState]} + + +
+ ); +} diff --git a/frontend/src/components/shell/useClock.ts b/frontend/src/components/shell/useClock.ts new file mode 100644 index 0000000..291ac5e --- /dev/null +++ b/frontend/src/components/shell/useClock.ts @@ -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; +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx new file mode 100644 index 0000000..f3a7754 --- /dev/null +++ b/frontend/src/router.tsx @@ -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: ( + + + + ), + }, + { + path: '/login', + element: ( + + + + ), + }, + { + path: '/', + element: ( + + + + ), + children: [ + { path: 'engagements', element: }, + { path: 'library', element: }, + { path: 'scenarios', element: }, + { path: 'runs', element: }, + { path: 'reports', element: }, + { path: 'audit', element: }, + ], + }, + { + path: '*', + element: ( + + + + ), + }, +]); diff --git a/frontend/src/router/RootLayout.tsx b/frontend/src/router/RootLayout.tsx new file mode 100644 index 0000000..bca8088 --- /dev/null +++ b/frontend/src/router/RootLayout.tsx @@ -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 {children}; +} diff --git a/frontend/src/session/SessionContext.context.ts b/frontend/src/session/SessionContext.context.ts new file mode 100644 index 0000000..8536bf0 --- /dev/null +++ b/frontend/src/session/SessionContext.context.ts @@ -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(null); diff --git a/frontend/src/session/SessionContext.tsx b/frontend/src/session/SessionContext.tsx new file mode 100644 index 0000000..53cc684 --- /dev/null +++ b/frontend/src/session/SessionContext.tsx @@ -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(() => readMockSession()); + + const signIn = useCallback((next: SessionUser) => { + writeMockSession(next); + setUser(next); + }, []); + + const signOut = useCallback(() => { + clearMockSession(); + setUser(null); + }, []); + + const value = useMemo( + () => ({ user, signIn, signOut }), + [user, signIn, signOut], + ); + + return {children}; +} diff --git a/frontend/src/session/useSession.ts b/frontend/src/session/useSession.ts new file mode 100644 index 0000000..79e0f31 --- /dev/null +++ b/frontend/src/session/useSession.ts @@ -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 '); + } + return ctx; +} diff --git a/frontend/src/types/roles.ts b/frontend/src/types/roles.ts new file mode 100644 index 0000000..bae3fdd --- /dev/null +++ b/frontend/src/types/roles.ts @@ -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 = { + 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';