mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:31:36 +00:00
e761ae40a4
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.
165 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|