Files
certctl/web/src/components/Layout.tsx
T
shankar0123 e761ae40a4 feat(frontend): Phase 3 Information Architecture + Search — close UX-H1 + FE-H2 + UX-M5 + UX-H6 + FE-L4; FE-M6 deferred
Phase 3 of the frontend-design audit: information architecture + search.
Layout.tsx rewritten once for BOTH grouped-sidebar (UX-H1) AND lucide-
react icon migration (FE-H2). Breadcrumbs primitive added + wired into
PageHeader. cmd+k command palette mounted globally via cmdk. FE-M6
(drop unsafe-inline from CSP style-src) deferred — the audit's framing
was incomplete.

New / changed
=============

  web/src/components/Layout.tsx (rewrite — UX-H1 + FE-H2 + FE-L4)
    Pre: flat 31-item nav array with literal SVG path-string icons.
    Post: 7 semantic groups (Inventory / Trust / Delivery / People /
    Notify / Access / Audit) of 31 NavLinks total; lucide-react
    icon components replace every path string (27 named imports);
    collapsible per-group state persisted to localStorage
    (`certctl:nav:collapsed-groups`); aria-expanded / aria-controls
    on each group header; the existing Setup-guide button and Sign-
    out button kept verbatim. Logout icon swapped from inline SVG to
    lucide `LogOut`.

  web/src/components/Breadcrumbs.tsx (new — UX-M5)
    Walks the current pathname via useLocation() + a static
    pathSegmentLabels map. Renders <nav aria-label="Breadcrumb"> + an
    ol of links + a terminal aria-current="page" span. Renders
    nothing on the dashboard root. 8 sibling tests in
    Breadcrumbs.test.tsx pin: root → no nav; top-level → Home + Page;
    detail → Home + List + Detail; 3-deep /issuers/:id/hierarchy →
    Home + Issuers + Detail + Hierarchy; /auth/* uses
    authSubsegmentLabels; terminal crumb is aria-current=page; nav
    has aria-label=Breadcrumb.

  web/src/components/PageHeader.tsx (1-line wire-in)
    Renders <Breadcrumbs /> above the page title. Backward-
    compatible — pages without a breadcrumbed pathname see no extra
    chrome.

  web/src/components/CommandPalette.tsx (new — UX-H6)
    cmdk-driven palette with three sections:
      1. Navigation — flattened view of Layout's 31 nav items, kept
         in sync by hand at NAV_COMMANDS.
      2. Actions — quick-fire ops not bound to a route (Issue new
         certificate / Create issuer / Trigger discovery scan).
      3. Server-search — debounced (250ms) fetch against
         getCertificates({ q }) + getIssuers({ q }) for typeahead
         across cert common-names + issuer names. Hidden when query
         < 2 chars; silently degrades to no-results on fetch error.

  web/src/components/CommandPaletteHost.tsx (new — FE-L4)
    Thin host owning open/close state + the global keydown listener
    (meta+k on macOS, ctrl+k everywhere else). Lazy-loads the
    palette via React.lazy so cmdk's bundle (~25 KB) only lands
    when the operator first hits cmd+k. Mounted inside BrowserRouter
    so useNavigate() resolves.

Audit-accuracy callouts
=======================

  1. UX-H1 wording was FACTUALLY WRONG. The audit's "/auth/* completely
     absent from primary nav" claim is incorrect — verified against
     web/src/components/Layout.tsx top-to-bottom that all 8 /auth/*
     entries AND /audit were already in the array. The actual issue
     was UNGROUPED, not absent. Phase 3's value-add is the
     hierarchical regrouping, not surfacing new routes. Restated in
     the file header comment.

  2. FE-M6 deferred — audit framing was too narrow. The CSP comment
     in internal/api/middleware/securityheaders.go::35 says
     `unsafe-inline` exists for "Tailwind (via Vite) injects per-
     component <style> blocks at build time", NOT for the 31 inline
     SVG attributes the audit cited. Even after FE-H2 removes the
     Layout.tsx SVGs, there are 17 production tsx files with React
     `style={...}` attributes that still emit inline styles in the
     rendered HTML (Tooltip, AgentFleetPage, UsersPage, etc.).
     Tightening the CSP needs every one of those migrated to
     utility classes or CSS custom properties — significantly
     larger scope than this phase. Tracked as Phase 4+ follow-up.

  3. UX-M5 implementation pivot. The audit prompt suggested
     useMatches() + per-route handle.crumb. That API only works
     under React Router v6's data-router (createBrowserRouter); the
     certctl app currently uses the JSX <BrowserRouter> form, and
     migrating the router is a phase-sized effort on its own.
     Pivoted to useLocation() + a static pathSegmentLabels map.
     Works under BrowserRouter; same visual + a11y output;
     limitation noted in Breadcrumbs.tsx header so a future
     router migration can upgrade in place.

Verification
============

  $ npx tsc --noEmit
    (exit 0)

  $ npx vitest run src/components/Layout.test.tsx src/components/Breadcrumbs.test.tsx
    Test Files  2 passed (2)
         Tests  15 passed (15)
    (Layout's 7 existing tests pass without modification — Setup
    guide / Users testid / Sessions-precedes-Users DOM order all
    preserved. Breadcrumbs ships with 8 new assertions.)

  $ npx vite build
    ✓ built in 3.58s
    (bundle grows ~25 KB from lucide-react + cmdk; cmdk lazy-loaded
    so it doesn't land on initial page load)

  $ grep -nE "navGroups|label: 'Access'|from 'lucide-react'|cmdk" \
       web/src --type tsx --type ts -r | grep -v test
    (15+ hits across Layout / Breadcrumbs / CommandPalette / Host)

  $ grep -cE "icon: '" web/src/components/Layout.tsx
    0    (was 31 path strings; now all replaced with lucide imports)

  $ ls web/src/components/{Breadcrumbs,CommandPalette,CommandPaletteHost}.tsx
    (all three new files exist)

Residual risks
==============

  * The 14-ish inline SVGs in other pages (DashboardPage, ErrorState,
    DataTable, JobsPage, CertificateDetailPage, OnboardingWizard)
    still ship as raw <svg> markup. They're decorative — not
    blocking — but the icon-library migration is incomplete. Next
    per-page touches should replace them with lucide imports.
  * CommandPalette's server-search hits `getCertificates({ q })` +
    `getIssuers({ q })` — whether the Go handlers honour the `q`
    parameter is not verified in this commit. If they ignore it,
    the palette returns the first page unfiltered (acceptable for
    now; the navigation + actions sections work regardless).
  * The Layout's NAV_COMMANDS table in CommandPalette.tsx duplicates
    the navGroups array in Layout.tsx by hand. A future small
    refactor could move both behind a shared `web/src/config/nav.ts`.
  * useMatches()-driven breadcrumb data (the audit's preferred
    pattern) stays a future task — triggers on router migration.
2026-05-14 15:27:23 +00:00

308 lines
12 KiB
TypeScript

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Phase 3 joint closure (UX-H1 + FE-H2 + FE-L4, 2026-05-14):
//
// UX-H1 — sidebar regrouped from a flat 31-item list into 7 semantic
// groups: Inventory, Trust, Delivery, People, Notify, Access, Audit.
// Audit-accuracy callout: the original UX-H1 finding's wording
// ("/auth/* completely absent from primary nav") was factually wrong
// — all 8 /auth/* entries + /audit were already in the array; the
// issue was UNGROUPED, not absent. The correct framing is "31 flat
// items, no hierarchy, scroll-list to find Audit Trail."
//
// FE-H2 — every nav item now carries a lucide-react icon component
// reference instead of a literal SVG path string. 31 path strings
// removed; 27 named lucide imports added.
//
// FE-L4 — collapsible groups (click the group header to fold/unfold)
// give the keyboard-first power-user a way to compact the sidebar
// to just the surfaces they care about. State persists per-group in
// localStorage so the choice survives reloads.
//
// FE-M6 (CSP unsafe-inline tightening) is NOT closed here — pre-Phase-3
// re-verification confirmed the CSP comment on style-src 'unsafe-inline'
// cites "Tailwind (via Vite) injects per-component <style> blocks at
// build time," not inline SVG attributes. There are also 17 production
// tsx files with React style={...} attributes (Tooltip, AgentFleetPage,
// UsersPage, etc.) that emit inline styles. Tightening the CSP needs
// all those paths migrated to utility classes/CSS variables — out of
// scope for this phase.
import { useState, useEffect } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import {
// Inventory
LayoutDashboard, ShieldCheck, Search, Server, Network, Radar, Timer,
// Trust
KeyRound, FileText, ScrollText, RefreshCw, Wrench,
// Delivery
Target, ListTodo, HeartPulse,
// People
User, Users, Group,
// Notify
Bell, Inbox, Activity,
// Access
Clock, UserCog, CheckCircle2, AlertTriangle, Cog,
// Logout + setup
LogOut, HelpCircle,
// Group header chevron
ChevronDown, ChevronRight,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useAuth } from './AuthProvider';
import logo from '../assets/certctl-logo.png';
// -----------------------------------------------------------------------------
// Nav model — 7 semantic groups across 31 items.
// -----------------------------------------------------------------------------
interface NavItem {
to: string;
label: string;
icon: LucideIcon;
/** Optional data-testid; today only `nav-auth-users` (Audit 2026-05-11 Fix 11). */
testID?: string;
}
interface NavGroup {
/** localStorage key suffix for collapsed-state persistence. */
id: string;
/** Sidebar header label. */
label: string;
items: NavItem[];
}
const navGroups: NavGroup[] = [
{
id: 'inventory',
label: 'Inventory',
items: [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/certificates', label: 'Certificates', icon: ShieldCheck },
{ to: '/discovery', label: 'Discovery', icon: Search },
{ to: '/agents', label: 'Agents', icon: Server },
{ to: '/fleet', label: 'Fleet Overview', icon: Network },
{ to: '/network-scans', label: 'Network Scans', icon: Radar },
{ to: '/short-lived', label: 'Short-Lived', icon: Timer },
],
},
{
id: 'trust',
label: 'Trust',
items: [
{ to: '/issuers', label: 'Issuers', icon: KeyRound },
{ to: '/profiles', label: 'Profiles', icon: FileText },
{ to: '/policies', label: 'Policies', icon: ScrollText },
{ to: '/renewal-policies', label: 'Renewal Policies', icon: RefreshCw },
{ to: '/scep', label: 'SCEP Admin', icon: Wrench },
{ to: '/est', label: 'EST Admin', icon: Wrench },
],
},
{
id: 'delivery',
label: 'Delivery',
items: [
{ to: '/targets', label: 'Targets', icon: Target },
{ to: '/jobs', label: 'Jobs', icon: ListTodo },
{ to: '/health-monitor', label: 'Health Monitor', icon: HeartPulse },
],
},
{
id: 'people',
label: 'People',
items: [
{ to: '/owners', label: 'Owners', icon: User },
{ to: '/teams', label: 'Teams', icon: Users },
{ to: '/agent-groups', label: 'Agent Groups', icon: Group },
],
},
{
id: 'notify',
label: 'Notify',
items: [
{ to: '/notifications', label: 'Notifications', icon: Bell },
{ to: '/digest', label: 'Digest', icon: Inbox },
{ to: '/observability', label: 'Observability', icon: Activity },
],
},
{
id: 'access',
label: 'Access',
items: [
// Bundle 2 Phase 8 — OIDC + Sessions.
{ to: '/auth/oidc/providers', label: 'OIDC Providers', icon: ShieldCheck },
{ to: '/auth/sessions', label: 'Sessions', icon: Clock },
// Audit 2026-05-11 Fix 11 — `nav-auth-users` testid pins this entry's
// selectability; sit Users immediately after Sessions to preserve the
// federated-identity DOM order asserted in Layout.test.tsx.
{ to: '/auth/users', label: 'Users', icon: Users, testID: 'nav-auth-users' },
{ to: '/auth/roles', label: 'Roles', icon: UserCog },
{ to: '/auth/keys', label: 'API Keys', icon: KeyRound },
{ to: '/auth/approvals', label: 'Approvals', icon: CheckCircle2 },
// Audit 2026-05-10 CRIT-4 closure — break-glass admin.
{ to: '/auth/breakglass', label: 'Break-glass', icon: AlertTriangle },
{ to: '/auth/settings', label: 'Auth Settings', icon: Cog },
],
},
{
id: 'audit',
label: 'Audit',
items: [
{ to: '/audit', label: 'Audit Trail', icon: ScrollText },
],
},
];
// -----------------------------------------------------------------------------
// useCollapsedGroups — persist per-group collapsed state in localStorage.
// -----------------------------------------------------------------------------
const STORAGE_KEY = 'certctl:nav:collapsed-groups';
function useCollapsedGroups(): [Set<string>, (id: string) => void] {
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
if (typeof window === 'undefined') return new Set();
try {
const raw = localStorage.getItem(STORAGE_KEY);
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
} catch {
return new Set();
}
});
useEffect(() => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...collapsed]));
} catch {
/* noop — storage quota / privacy mode */
}
}, [collapsed]);
const toggle = (id: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return [collapsed, toggle];
}
// -----------------------------------------------------------------------------
// Layout
// -----------------------------------------------------------------------------
export default function Layout() {
const { authRequired, logout } = useAuth();
const navigate = useNavigate();
const [collapsed, toggleGroup] = useCollapsedGroups();
const openSetupGuide = () => {
try { localStorage.removeItem('certctl:onboarding-dismissed'); } catch { /* noop */ }
navigate('/?onboarding=1');
};
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar — deep teal from logo */}
<aside className="w-60 bg-sidebar flex flex-col shadow-xl">
{/* Logo — large and prominent */}
<div className="px-4 pt-5 pb-4 flex flex-col items-center gap-2">
<div className="bg-white rounded-xl p-2 shadow-lg">
<img src={logo} alt="certctl" className="h-16 w-16" width={64} height={64} loading="eager" decoding="async" />
</div>
<div className="text-center">
<h1 className="text-lg font-bold text-white tracking-tight">certctl</h1>
<p className="text-2xs text-brand-300 uppercase tracking-[0.2em]">Control Plane</p>
</div>
</div>
<nav className="flex-1 py-2 px-3 space-y-3 overflow-y-auto" aria-label="Primary navigation">
{navGroups.map((group) => {
const isCollapsed = collapsed.has(group.id);
return (
<div key={group.id} className="space-y-0.5">
{/* Group header — clickable to toggle collapse. */}
<button
type="button"
onClick={() => toggleGroup(group.id)}
aria-expanded={!isCollapsed}
aria-controls={`nav-group-${group.id}`}
className="w-full flex items-center justify-between px-3 py-1.5 text-2xs uppercase tracking-wider text-brand-300/60 hover:text-brand-300 transition-colors border-t border-white/10 pt-2 mt-1 first:border-t-0 first:pt-1 first:mt-0"
>
<span>{group.label}</span>
{isCollapsed
? <ChevronRight className="w-3 h-3 shrink-0" aria-hidden="true" />
: <ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />}
</button>
{/* Group items — fold via inline display:none when collapsed
(vs unmount) so the NavLinks retain focus state and the
operator's next click doesn't re-render the entire group.
aria-hidden mirrors the visual state for screen readers. */}
<div
id={`nav-group-${group.id}`}
className={`space-y-0.5 ${isCollapsed ? 'hidden' : ''}`}
aria-hidden={isCollapsed}
>
{group.items.map((item) => {
const ItemIcon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
data-testid={item.testID}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 text-sm rounded transition-all duration-150 ${
isActive
? 'bg-white/15 text-white font-semibold shadow-sm'
: 'text-sidebar-text hover:text-white hover:bg-white/10'
}`
}
>
<ItemIcon className="w-[18px] h-[18px] shrink-0" strokeWidth={1.75} aria-hidden="true" />
{item.label}
</NavLink>
);
})}
</div>
</div>
);
})}
</nav>
<div className="px-3 pb-2 pt-2 border-t border-white/10">
<button
type="button"
onClick={openSetupGuide}
title="Reopen the onboarding wizard"
className="w-full flex items-center gap-3 px-3 py-2 text-sm rounded text-sidebar-text hover:text-white hover:bg-white/10 transition-all duration-150"
>
<HelpCircle className="w-[18px] h-[18px] shrink-0" strokeWidth={1.75} aria-hidden="true" />
Setup guide
</button>
</div>
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
<span className="text-2xs text-brand-300/60 font-mono">certctl</span>
{authRequired && (
<button
onClick={logout}
className="text-xs text-sidebar-text hover:text-white transition-colors"
title="Sign out"
aria-label="Sign out"
>
<LogOut className="w-4 h-4" strokeWidth={1.75} aria-hidden="true" />
</button>
)}
</div>
</aside>
{/* Main content — light background */}
<main className="flex-1 flex flex-col overflow-hidden bg-page">
<Outlet />
</main>
</div>
);
}