Files
certctl/web/src/components/Breadcrumbs.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

165 lines
5.5 KiB
TypeScript

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Breadcrumbs — Phase 3 closure for UX-M5 (zero breadcrumb component,
// zero navigate(-1), 3-deep routes like issuers/:id/hierarchy have no
// wayfinding).
//
// Implementation note: the audit prompt suggested useMatches() + per-
// route handle.crumb. That requires React Router v6's data-router
// (createBrowserRouter), but the certctl app currently uses the JSX
// <BrowserRouter> form. Migrating the router config is its own
// phase-sized effort with non-trivial blast radius (every Route
// element, every test's MemoryRouter wrapper). Instead, this version
// uses useLocation() to read the current pathname + walks the
// segments, mapping each one to a label via the static
// pathSegmentLabels lookup below. Limitations: only the top-level +
// detail-route segments get a label (anything matching /:id/.../ at a
// depth > 2 falls back to the literal segment). Sufficient for the
// 3-deep routes the audit flagged (e.g. /issuers/:id/hierarchy);
// upgrading to data-router-driven crumbs is a future task once the
// router migration ships.
import { Link, useLocation } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
// pathSegmentLabels — map first-segment URL keys to human labels.
// Add entries here as new top-level routes land. Lookup is exact-
// match on the first path segment; subsequent segments are heuristics
// (see crumbsFor below).
const pathSegmentLabels: Record<string, string> = {
certificates: 'Certificates',
issuers: 'Issuers',
agents: 'Agents',
targets: 'Targets',
jobs: 'Jobs',
notifications: 'Notifications',
policies: 'Policies',
'renewal-policies': 'Renewal Policies',
profiles: 'Profiles',
owners: 'Owners',
teams: 'Teams',
'agent-groups': 'Agent Groups',
audit: 'Audit Trail',
'short-lived': 'Short-Lived',
fleet: 'Fleet Overview',
discovery: 'Discovery',
'network-scans': 'Network Scans',
'health-monitor': 'Health Monitor',
digest: 'Digest',
observability: 'Observability',
scep: 'SCEP Admin',
est: 'EST Admin',
auth: 'Access',
};
// Auth-subtree subsegments (e.g. /auth/oidc/providers).
const authSubsegmentLabels: Record<string, string> = {
oidc: 'OIDC',
providers: 'Providers',
sessions: 'Sessions',
users: 'Users',
roles: 'Roles',
keys: 'API Keys',
approvals: 'Approvals',
breakglass: 'Break-glass',
settings: 'Auth Settings',
};
interface Crumb {
pathname: string;
label: string;
isLast: boolean;
}
function crumbsFor(pathname: string): Crumb[] {
// Dashboard root produces no breadcrumb trail — the title alone
// suffices.
if (pathname === '/' || pathname === '') return [];
const segments = pathname.split('/').filter(Boolean);
if (segments.length === 0) return [];
// The Dashboard ("Home") crumb is always the first hop.
const out: Crumb[] = [{ pathname: '/', label: 'Home', isLast: false }];
// First segment — top-level route.
const first = segments[0]!;
const firstLabel = pathSegmentLabels[first] ?? first;
out.push({
pathname: '/' + first,
label: firstLabel,
isLast: segments.length === 1,
});
// Subsequent segments — heuristics:
// - /auth/<sub>[/...] uses authSubsegmentLabels for each piece
// - any other segment that looks like an :id (starts with a
// known prefix or is hex/random) becomes "Detail"
// - terminal /hierarchy on /issuers/:id/hierarchy → "Hierarchy"
let acc = '/' + first;
for (let i = 1; i < segments.length; i++) {
const seg = segments[i]!;
acc += '/' + seg;
let label: string;
if (first === 'auth') {
label = authSubsegmentLabels[seg] ?? seg;
} else if (seg === 'hierarchy') {
label = 'Hierarchy';
} else if (looksLikeID(seg)) {
label = 'Detail';
} else {
label = seg;
}
out.push({ pathname: acc, label, isLast: i === segments.length - 1 });
}
return out;
}
/** ID-shape heuristic — certctl IDs look like cert-001, iss-vault, t-iis-prod. */
function looksLikeID(s: string): boolean {
// Anything with a hyphen is treated as an ID for breadcrumb purposes.
// Hyphenated segments that aren't IDs (renewal-policies, agent-groups,
// network-scans, health-monitor, short-lived) are top-level routes
// resolved by pathSegmentLabels BEFORE this heuristic fires.
return s.includes('-') || /^[a-f0-9]{8,}$/i.test(s);
}
export default function Breadcrumbs() {
const { pathname } = useLocation();
const crumbs = crumbsFor(pathname);
if (crumbs.length === 0) return null;
return (
<nav aria-label="Breadcrumb" className="mb-1">
<ol className="flex items-center gap-1 text-xs text-ink-muted">
{crumbs.map((c, i) => (
<li key={c.pathname} className="flex items-center gap-1">
{i > 0 && (
<ChevronRight
className="w-3 h-3 text-ink-faint shrink-0"
strokeWidth={1.5}
aria-hidden="true"
/>
)}
{c.isLast ? (
<span aria-current="page" className="text-ink font-medium">
{c.label}
</span>
) : (
<Link
to={c.pathname}
className="hover:text-brand-500 hover:underline transition-colors"
>
{c.label}
</Link>
)}
</li>
))}
</ol>
</nav>
);
}