fix(frontend): address M1-M3 polish from code-reviewer

M1 — Single SessionProvider via nested router.
  The previous router had two route entries with `path: '/'`
  (Navigate, AppShell) plus a separate `/login` entry, each wrapped in
  its own RootLayout. That instantiated SessionProvider three times,
  forking state the moment session writes diverged across siblings.
  Replaced by one Root route with SessionProvider + <Outlet />, and
  index/login/AppShell-children nested underneath. RootLayout (the
  per-tree wrapper) is now obsolete and deleted; the new Root component
  lives in src/routing/Root.tsx (addresses NIT N4 as a side effect).

M2 — Typo: "pollign" → "polling" in LiveCockpitPage masthead.

M3 — Replace asymmetric `?? 'rt_operator'` / `?? 'soc_analyst'`
  fallbacks in LiveCockpitPage with an explicit `if (!user) return null;`
  guard placed after all hooks (rules-of-hooks). AppShell already
  redirects unauthenticated visitors to /login, so the guard documents
  the invariant rather than introducing one.

NITs N1-N3, N5-N7 recorded in tasks/todo.md as sprint 1+ follow-ups.
This commit is contained in:
ux-frontend
2026-05-21 20:44:32 +02:00
parent 12bc33469c
commit b505a654f8
5 changed files with 76 additions and 56 deletions

View File

@@ -1,5 +1,5 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { RootLayout } from '@/router/RootLayout';
import { Root } from '@/routing/Root';
import { AppShell } from '@/components/shell/AppShell';
import { LoginPage } from '@/screens/login/LoginPage';
import { EngagementsPage } from '@/screens/engagements/EngagementsPage';
@@ -11,48 +11,34 @@ 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.
*
* The Root route mounts SessionProvider once for the entire tree. All
* top-level paths (login, app shell, fallback) are children of that
* single Root so they share one session state — no provider forking
* between routes.
*
* Once real engagement scoping lands, sub-routes nest under
* /engagements/:eid (spec §9). Sprint 0 keeps 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>
),
element: <Root />,
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 /> },
{ index: true, element: <Navigate to="/login" replace /> },
{ path: 'login', element: <LoginPage /> },
{
element: <AppShell />,
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: <Navigate to="/login" replace /> },
],
},
{
path: '*',
element: (
<RootLayout>
<Navigate to="/login" replace />
</RootLayout>
),
},
]);

View File

@@ -1,10 +0,0 @@
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,16 @@
import { Outlet } from 'react-router-dom';
import { SessionProvider } from '@/session/SessionContext';
/**
* Root route element. Mounts SessionProvider once for the entire app so
* every nested route — login, app shell, fallback — shares one session
* state. Kept in its own file so router.tsx exports only the router
* config (fast-refresh friendly).
*/
export function Root() {
return (
<SessionProvider>
<Outlet />
</SessionProvider>
);
}

View File

@@ -46,6 +46,11 @@ export function LiveCockpitPage() {
return { total, done, detected, missed, partial };
}, [steps]);
// AppShell guarantees a session before this screen mounts; the early
// return keeps that invariant explicit and removes ambiguous fallbacks
// when reading user.role below. Placed after all hooks per rules-of-hooks.
if (!user) return null;
return (
<div className="flex flex-col h-full">
{/* Run header + control bar */}
@@ -54,7 +59,7 @@ export function LiveCockpitPage() {
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
>
<div className="space-y-1">
<div className="label-system">// Live run · pollign 500 ms</div>
<div className="label-system">// Live run · polling 500 ms</div>
<h1 className="font-display text-fg-default" style={{ fontSize: '17px', letterSpacing: '0.05em' }}>
RUN-2026-05-21-A03 · Démo Client X
</h1>
@@ -74,7 +79,7 @@ export function LiveCockpitPage() {
<Stat label="Missed" value={`${stats.missed}`} tone="missed" />
</div>
{isLead(user?.role ?? 'rt_operator') && (
{isLead(user.role) && (
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost">
Pause
@@ -162,7 +167,7 @@ export function LiveCockpitPage() {
>
{selected && (
<>
{isSOC(user?.role ?? 'soc_analyst') ? (
{isSOC(user.role) ? (
<DetectionPanel step={selected} />
) : (
<>