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.
This commit is contained in:
shankar0123
2026-05-14 15:27:23 +00:00
parent 1daae5d709
commit e761ae40a4
9 changed files with 1446 additions and 74 deletions
+596 -1
View File
@@ -13,6 +13,8 @@
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@headlessui/react": "^2.2.10", "@headlessui/react": "^2.2.10",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"cmdk": "^1.1.1",
"lucide-react": "^1.16.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.30.3", "react-router-dom": "^6.30.3",
@@ -1835,6 +1837,447 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@react-aria/focus": { "node_modules/@react-aria/focus": {
"version": "3.22.0", "version": "3.22.0",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz",
@@ -2895,7 +3338,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@@ -3668,6 +4111,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4061,6 +4520,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -4723,6 +5188,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-proto": { "node_modules/get-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -6028,6 +6502,15 @@
"node": "20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/lucide-react": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lunr": { "node_modules/lunr": {
"version": "2.3.9", "version": "2.3.9",
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
@@ -7162,6 +7645,53 @@
} }
} }
}, },
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": { "node_modules/react-router": {
"version": "6.30.3", "version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
@@ -7211,6 +7741,28 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
} }
}, },
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -8525,6 +9077,49 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+2
View File
@@ -19,6 +19,8 @@
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@headlessui/react": "^2.2.10", "@headlessui/react": "^2.2.10",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"cmdk": "^1.1.1",
"lucide-react": "^1.16.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.30.3", "react-router-dom": "^6.30.3",
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Breadcrumbs tests — Phase 3 UX-M5 closure.
// Verifies the useLocation()-driven segment-walker:
// (a) root path "/" → no crumbs rendered (no empty <nav>)
// (b) top-level paths → Home + that page
// (c) detail paths → Home + List + Detail
// (d) deeply-nested /issuers/:id/hierarchy → Home + Issuers + Detail + Hierarchy
// (e) /auth/ subtree → uses authSubsegmentLabels
// (f) terminal crumb has aria-current="page" and is plain text;
// intermediate crumbs are <Link>s
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Breadcrumbs from './Breadcrumbs';
function renderAt(pathname: string) {
return render(
<MemoryRouter initialEntries={[pathname]}>
<Breadcrumbs />
</MemoryRouter>,
);
}
describe('Breadcrumbs', () => {
it('renders nothing for the dashboard root', () => {
const { container } = renderAt('/');
expect(container.querySelector('nav')).toBeNull();
});
it('renders Home + Certificates for /certificates', () => {
renderAt('/certificates');
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('Certificates')).toBeInTheDocument();
const items = document.querySelectorAll('nav[aria-label="Breadcrumb"] ol > li');
expect(items.length).toBe(2);
});
it('renders Home + Certificates + Detail for /certificates/cert-001', () => {
renderAt('/certificates/cert-001');
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('Certificates')).toBeInTheDocument();
expect(screen.getByText('Detail')).toBeInTheDocument();
});
it('walks /issuers/:id/hierarchy down to the Hierarchy leaf', () => {
renderAt('/issuers/iss-vault/hierarchy');
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('Issuers')).toBeInTheDocument();
expect(screen.getByText('Detail')).toBeInTheDocument();
expect(screen.getByText('Hierarchy')).toBeInTheDocument();
// Hierarchy is the terminal crumb — plain text, aria-current.
const hierarchy = screen.getByText('Hierarchy');
expect(hierarchy.tagName).toBe('SPAN');
expect(hierarchy).toHaveAttribute('aria-current', 'page');
});
it('uses authSubsegmentLabels for /auth/* paths', () => {
renderAt('/auth/oidc/providers');
expect(screen.getByText('Access')).toBeInTheDocument();
expect(screen.getByText('OIDC')).toBeInTheDocument();
expect(screen.getByText('Providers')).toBeInTheDocument();
});
it("renders the last crumb as aria-current='page' plain text", () => {
renderAt('/certificates/cert-001');
const detail = screen.getByText('Detail');
expect(detail.tagName).toBe('SPAN');
expect(detail).toHaveAttribute('aria-current', 'page');
});
it('renders intermediate crumbs as <Link> elements pointing at their pathname', () => {
renderAt('/certificates/cert-001');
const home = screen.getByText('Home');
const homeAnchor = home.closest('a');
expect(homeAnchor).not.toBeNull();
expect(homeAnchor!.getAttribute('href')).toBe('/');
const certs = screen.getByText('Certificates');
const certsAnchor = certs.closest('a');
expect(certsAnchor).not.toBeNull();
expect(certsAnchor!.getAttribute('href')).toBe('/certificates');
});
it('exposes nav[aria-label="Breadcrumb"] for screen readers', () => {
renderAt('/issuers');
expect(
screen.getByRole('navigation', { name: 'Breadcrumb' }),
).toBeInTheDocument();
});
});
+164
View File
@@ -0,0 +1,164 @@
// 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>
);
}
+287
View File
@@ -0,0 +1,287 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// CommandPalette — Phase 3 closure for UX-H6 (no cmd+k palette, no
// <input type="search">, no global keyboard-shortcut surface) and
// FE-L4 (rolls under UX-H6 per the audit's framing).
//
// Built on `cmdk`. Three sections:
//
// 1. Navigation — every route surfaced in Layout.tsx's navGroups.
// Operator types "audit", picks the matching row, navigates to
// /audit. Reproduces a sidebar without the scroll.
// 2. Actions — quick-fire operations that aren't routes: "Issue
// new certificate" (navigates to / + ?onboarding=1), "Create
// issuer", "Trigger discovery scan". Each action is a callback
// that closes the palette.
// 3. Server-search — debounced fetch against /api/v1/certificates?q=
// + /api/v1/issuers?q= for typeahead across cert names + issuer
// names. Results stream into the same cmdk list under a "Search
// results" heading; clicking jumps to that record's detail page.
//
// Global keydown listener (meta+k on macOS, ctrl+k everywhere else)
// is wired in web/src/main.tsx — the palette itself is render-only
// and reads `open` from a prop.
import { Command } from 'cmdk';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
LayoutDashboard, ShieldCheck, Search, Server, Network, Radar, Timer,
KeyRound, FileText, ScrollText, RefreshCw, Wrench,
Target, ListTodo, HeartPulse,
User, Users, Group,
Bell, Inbox, Activity,
Clock, UserCog, CheckCircle2, AlertTriangle, Cog,
Plus, Zap,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { getCertificates, getIssuers } from '../api/client';
import type { Certificate, Issuer } from '../api/types';
export interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface NavCommand {
to: string;
label: string;
group: string;
icon: LucideIcon;
}
// NAV_COMMANDS — flattened view of Layout.tsx's navGroups, kept in
// sync by hand. (DRY-ing this against the Layout would require an
// extra module just to share the table; the audit notes future work
// could collapse them.)
const NAV_COMMANDS: NavCommand[] = [
// Inventory
{ to: '/', label: 'Dashboard', group: 'Inventory', icon: LayoutDashboard },
{ to: '/certificates', label: 'Certificates', group: 'Inventory', icon: ShieldCheck },
{ to: '/discovery', label: 'Discovery', group: 'Inventory', icon: Search },
{ to: '/agents', label: 'Agents', group: 'Inventory', icon: Server },
{ to: '/fleet', label: 'Fleet Overview', group: 'Inventory', icon: Network },
{ to: '/network-scans', label: 'Network Scans', group: 'Inventory', icon: Radar },
{ to: '/short-lived', label: 'Short-Lived', group: 'Inventory', icon: Timer },
// Trust
{ to: '/issuers', label: 'Issuers', group: 'Trust', icon: KeyRound },
{ to: '/profiles', label: 'Profiles', group: 'Trust', icon: FileText },
{ to: '/policies', label: 'Policies', group: 'Trust', icon: ScrollText },
{ to: '/renewal-policies', label: 'Renewal Policies', group: 'Trust', icon: RefreshCw },
{ to: '/scep', label: 'SCEP Admin', group: 'Trust', icon: Wrench },
{ to: '/est', label: 'EST Admin', group: 'Trust', icon: Wrench },
// Delivery
{ to: '/targets', label: 'Targets', group: 'Delivery', icon: Target },
{ to: '/jobs', label: 'Jobs', group: 'Delivery', icon: ListTodo },
{ to: '/health-monitor', label: 'Health Monitor', group: 'Delivery', icon: HeartPulse },
// People
{ to: '/owners', label: 'Owners', group: 'People', icon: User },
{ to: '/teams', label: 'Teams', group: 'People', icon: Users },
{ to: '/agent-groups', label: 'Agent Groups', group: 'People', icon: Group },
// Notify
{ to: '/notifications', label: 'Notifications', group: 'Notify', icon: Bell },
{ to: '/digest', label: 'Digest', group: 'Notify', icon: Inbox },
{ to: '/observability', label: 'Observability', group: 'Notify', icon: Activity },
// Access
{ to: '/auth/oidc/providers', label: 'OIDC Providers', group: 'Access', icon: ShieldCheck },
{ to: '/auth/sessions', label: 'Sessions', group: 'Access', icon: Clock },
{ to: '/auth/users', label: 'Users', group: 'Access', icon: Users },
{ to: '/auth/roles', label: 'Roles', group: 'Access', icon: UserCog },
{ to: '/auth/keys', label: 'API Keys', group: 'Access', icon: KeyRound },
{ to: '/auth/approvals', label: 'Approvals', group: 'Access', icon: CheckCircle2 },
{ to: '/auth/breakglass', label: 'Break-glass', group: 'Access', icon: AlertTriangle },
{ to: '/auth/settings', label: 'Auth Settings', group: 'Access', icon: Cog },
// Audit
{ to: '/audit', label: 'Audit Trail', group: 'Audit', icon: ScrollText },
];
interface SearchResult {
type: 'certificate' | 'issuer';
id: string;
label: string;
to: string;
}
/**
* useDebouncedValue — small hook to throttle the server-search query
* so we don't fire a fetch on every keystroke.
*/
function useDebouncedValue<T>(value: T, ms: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return debounced;
}
export default function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 250);
const [serverResults, setServerResults] = useState<SearchResult[]>([]);
// Server-search on debounced input. Empty / <2-char queries skip
// the fetch (too many results to be useful + load on the API).
useEffect(() => {
if (!open || debouncedQuery.length < 2) {
setServerResults([]);
return;
}
let cancelled = false;
(async () => {
try {
const [certsResp, issuersResp] = await Promise.all([
getCertificates({ q: debouncedQuery, per_page: '8' }),
getIssuers({ q: debouncedQuery, per_page: '8' }),
]);
if (cancelled) return;
const certs: SearchResult[] = (certsResp?.data ?? []).map((c: Certificate) => ({
type: 'certificate',
id: c.id,
label: c.common_name || c.id,
to: `/certificates/${c.id}`,
}));
const issuers: SearchResult[] = (issuersResp?.data ?? []).map((i: Issuer) => ({
type: 'issuer',
id: i.id,
label: i.name || i.id,
to: `/issuers/${i.id}`,
}));
setServerResults([...certs, ...issuers]);
} catch {
// Silent — keep whatever's already in the list.
if (!cancelled) setServerResults([]);
}
})();
return () => { cancelled = true; };
}, [debouncedQuery, open]);
// Reset query each time the palette opens — fresh state per session.
useEffect(() => {
if (open) setQuery('');
}, [open]);
const navByGroup = useMemo(() => {
const m = new Map<string, NavCommand[]>();
for (const n of NAV_COMMANDS) {
if (!m.has(n.group)) m.set(n.group, []);
m.get(n.group)!.push(n);
}
return m;
}, []);
const go = (to: string) => {
onOpenChange(false);
navigate(to);
};
if (!open) return null;
return (
<Command.Dialog
open={open}
onOpenChange={onOpenChange}
label="Global command palette"
className="fixed inset-0 z-50 flex items-start justify-center pt-24"
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40"
aria-hidden="true"
onClick={() => onOpenChange(false)}
/>
{/* Panel */}
<div className="relative w-full max-w-xl bg-surface border border-surface-border rounded-lg shadow-2xl overflow-hidden">
<Command.Input
autoFocus
value={query}
onValueChange={setQuery}
placeholder="Type a page name, action, or search certs / issuers…"
className="w-full px-4 py-3 text-sm text-ink bg-transparent border-b border-surface-border focus:outline-none placeholder:text-ink-faint"
/>
<Command.List className="max-h-96 overflow-y-auto py-1">
<Command.Empty className="px-4 py-6 text-center text-sm text-ink-faint">
No matches try a different term.
</Command.Empty>
{/* Navigation — every sidebar item, grouped */}
{Array.from(navByGroup.entries()).map(([groupName, items]) => (
<Command.Group key={groupName} heading={groupName}>
{items.map((item) => {
const I = item.icon;
return (
<Command.Item
key={item.to}
value={`${groupName} ${item.label}`}
onSelect={() => go(item.to)}
className="px-4 py-2 text-sm text-ink cursor-pointer flex items-center gap-3 data-[selected=true]:bg-brand-50 data-[selected=true]:text-brand-700"
>
<I className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />
<span>{item.label}</span>
</Command.Item>
);
})}
</Command.Group>
))}
{/* Actions — quick-fire operations that aren't routes */}
<Command.Group heading="Actions">
<Command.Item
value="action issue new certificate"
onSelect={() => go('/?onboarding=1')}
className="px-4 py-2 text-sm text-ink cursor-pointer flex items-center gap-3 data-[selected=true]:bg-brand-50 data-[selected=true]:text-brand-700"
>
<Plus className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />
<span>Issue new certificate (Setup guide)</span>
</Command.Item>
<Command.Item
value="action create issuer"
onSelect={() => go('/issuers')}
className="px-4 py-2 text-sm text-ink cursor-pointer flex items-center gap-3 data-[selected=true]:bg-brand-50 data-[selected=true]:text-brand-700"
>
<KeyRound className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />
<span>Create issuer</span>
</Command.Item>
<Command.Item
value="action trigger discovery scan"
onSelect={() => go('/network-scans')}
className="px-4 py-2 text-sm text-ink cursor-pointer flex items-center gap-3 data-[selected=true]:bg-brand-50 data-[selected=true]:text-brand-700"
>
<Zap className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />
<span>Trigger discovery scan</span>
</Command.Item>
</Command.Group>
{/* Server search — only render the heading if we have hits */}
{serverResults.length > 0 && (
<Command.Group heading="Search results">
{serverResults.map((r) => (
<Command.Item
key={`${r.type}-${r.id}`}
value={`search ${r.label} ${r.id}`}
onSelect={() => go(r.to)}
className="px-4 py-2 text-sm text-ink cursor-pointer flex items-center gap-3 data-[selected=true]:bg-brand-50 data-[selected=true]:text-brand-700"
>
{r.type === 'certificate'
? <ShieldCheck className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />
: <KeyRound className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />}
<span className="flex-1">{r.label}</span>
<span className="text-xs text-ink-faint capitalize">{r.type}</span>
</Command.Item>
))}
</Command.Group>
)}
</Command.List>
{/* Footer hint */}
<div className="px-4 py-2 border-t border-surface-border text-xs text-ink-faint flex items-center justify-between">
<span> navigate · select · esc close</span>
<span><kbd className="px-1 py-0.5 text-2xs bg-surface-muted border border-surface-border rounded">K</kbd></span>
</div>
</div>
</Command.Dialog>
);
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// CommandPaletteHost — Phase 3 closure: thin wrapper around
// CommandPalette that owns the open/close state + the global
// keyboard listener (meta+k on mac, ctrl+k everywhere else).
//
// Lives at the React tree root (mounted alongside Toaster in
// main.tsx) so the keydown handler is registered once + survives
// page navigations. The handler is intentionally scoped to the
// component lifecycle so HMR + React StrictMode double-mount don't
// leave orphaned listeners.
import { useEffect, useState, lazy, Suspense } from 'react';
// Lazy-load the palette so cmdk's bundle (~25 KB) doesn't land on
// the initial page load — only fetched once the operator hits cmd+k.
const CommandPalette = lazy(() => import('./CommandPalette'));
export default function CommandPaletteHost() {
const [open, setOpen] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// metaKey on macOS, ctrlKey on Windows / Linux.
const isCmdK = e.key === 'k' && (e.metaKey || e.ctrlKey);
if (isCmdK) {
e.preventDefault();
setOpen((prev) => !prev);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
// Only mount the palette tree when first-needed — avoids fetching
// cmdk's bundle on every page load.
if (!open) return null;
return (
<Suspense fallback={null}>
<CommandPalette open={open} onOpenChange={setOpen} />
</Suspense>
);
}
+230 -59
View File
@@ -1,62 +1,201 @@
// 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 { 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 { useAuth } from './AuthProvider';
import logo from '../assets/certctl-logo.png'; import logo from '../assets/certctl-logo.png';
const nav = [ // -----------------------------------------------------------------------------
{ to: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' }, // Nav model — 7 semantic groups across 31 items.
{ to: '/certificates', label: 'Certificates', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' }, // -----------------------------------------------------------------------------
{ to: '/agents', label: 'Agents', icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2' }, interface NavItem {
{ to: '/fleet', label: 'Fleet Overview', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z' }, to: string;
{ to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' }, label: string;
{ to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' }, icon: LucideIcon;
{ to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' }, /** Optional data-testid; today only `nav-auth-users` (Audit 2026-05-11 Fix 11). */
{ to: '/renewal-policies', label: 'Renewal Policies', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' }, testID?: string;
{ to: '/profiles', label: 'Profiles', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' }, }
{ to: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' }, interface NavGroup {
{ to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, /** localStorage key suffix for collapsed-state persistence. */
{ to: '/owners', label: 'Owners', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' }, id: string;
{ to: '/teams', label: 'Teams', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' }, /** Sidebar header label. */
{ to: '/agent-groups', label: 'Agent Groups', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10 M9 3v2m6-2v2' }, label: string;
{ to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' }, items: NavItem[];
{ to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' },
{ to: '/health-monitor', label: 'Health Monitor', icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z' },
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ to: '/scep', label: 'SCEP Admin', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
{ to: '/est', label: 'EST Admin', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
// Bundle 1 Phase 10 — RBAC management (Roles / Keys / Settings).
// Bundle 2 Phase 8 — OIDC + Sessions.
{ to: '/auth/oidc/providers', label: 'OIDC Providers', icon: 'M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4' },
{ to: '/auth/sessions', label: 'Sessions', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
// Audit 2026-05-11 Fix 11 — UsersPage sidebar entry (MED-11 discoverability).
// The MED-11 closure wired UsersPage but no nav entry; operators had to know
// the URL /auth/users to reach the federated-user-management surface. This
// entry sits adjacent to Sessions because the two share the same mental
// model (federated identity admin). UsersPage handles its own 403 state for
// callers without auth.user.read so we don't need to gate the nav entry;
// every other entry in this array uses the same unconditional pattern.
{ to: '/auth/users', label: 'Users', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', testID: 'nav-auth-users' },
{ to: '/auth/roles', label: 'Roles', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
{ to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
{ to: '/auth/approvals', label: 'Approvals', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
// Audit 2026-05-10 CRIT-4 closure — break-glass admin surface.
{ to: '/auth/breakglass', label: 'Break-glass', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
{ to: '/auth/settings', label: 'Auth Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
];
function Icon({ d }: { d: string }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
</svg>
);
} }
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() { export default function Layout() {
const { authRequired, logout } = useAuth(); const { authRequired, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [collapsed, toggleGroup] = useCollapsedGroups();
const openSetupGuide = () => { const openSetupGuide = () => {
try { localStorage.removeItem('certctl:onboarding-dismissed'); } catch { /* noop */ } try { localStorage.removeItem('certctl:onboarding-dismissed'); } catch { /* noop */ }
@@ -78,13 +217,41 @@ export default function Layout() {
</div> </div>
</div> </div>
<nav className="flex-1 py-2 px-3 space-y-0.5 overflow-y-auto"> <nav className="flex-1 py-2 px-3 space-y-3 overflow-y-auto" aria-label="Primary navigation">
{nav.map(item => ( {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 <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/'} end={item.to === '/'}
data-testid={'testID' in item ? item.testID : undefined} data-testid={item.testID}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 text-sm rounded transition-all duration-150 ${ `flex items-center gap-3 px-3 py-2 text-sm rounded transition-all duration-150 ${
isActive isActive
@@ -93,10 +260,15 @@ export default function Layout() {
}` }`
} }
> >
<Icon d={item.icon} /> <ItemIcon className="w-[18px] h-[18px] shrink-0" strokeWidth={1.75} aria-hidden="true" />
{item.label} {item.label}
</NavLink> </NavLink>
))} );
})}
</div>
</div>
);
})}
</nav> </nav>
<div className="px-3 pb-2 pt-2 border-t border-white/10"> <div className="px-3 pb-2 pt-2 border-t border-white/10">
@@ -106,7 +278,7 @@ export default function Layout() {
title="Reopen the onboarding wizard" 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" 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"
> >
<Icon d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /> <HelpCircle className="w-[18px] h-[18px] shrink-0" strokeWidth={1.75} aria-hidden="true" />
Setup guide Setup guide
</button> </button>
</div> </div>
@@ -118,10 +290,9 @@ export default function Layout() {
onClick={logout} onClick={logout}
className="text-xs text-sidebar-text hover:text-white transition-colors" className="text-xs text-sidebar-text hover:text-white transition-colors"
title="Sign out" title="Sign out"
aria-label="Sign out"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <LogOut className="w-4 h-4" strokeWidth={1.75} aria-hidden="true" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
</button> </button>
)} )}
</div> </div>
+10
View File
@@ -1,3 +1,5 @@
import Breadcrumbs from './Breadcrumbs';
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: string;
subtitle?: string; subtitle?: string;
@@ -8,6 +10,14 @@ export default function PageHeader({ title, subtitle, action }: PageHeaderProps)
return ( return (
<div className="flex items-center justify-between px-6 py-4 border-b border-surface-border bg-surface"> <div className="flex items-center justify-between px-6 py-4 border-b border-surface-border bg-surface">
<div> <div>
{/* Phase 3 UX-M5 closure: breadcrumb trail derived from
useLocation() + the static pathSegmentLabels map in
Breadcrumbs.tsx (see that file's header comment for why
we pivoted away from the useMatches() + handle.crumb
pattern the audit prompt suggested). Renders nothing on
the dashboard root — backward-compatible with every
existing PageHeader consumer. */}
<Breadcrumbs />
<h2 className="text-lg font-semibold text-ink">{title}</h2> <h2 className="text-lg font-semibold text-ink">{title}</h2>
{subtitle && <p className="text-sm text-ink-muted mt-0.5">{subtitle}</p>} {subtitle && <p className="text-sm text-ink-muted mt-0.5">{subtitle}</p>}
</div> </div>
+6
View File
@@ -62,6 +62,11 @@ import UsersPage from './pages/auth/UsersPage';
// the root so any component can `import { toast } from "sonner"` and // the root so any component can `import { toast } from "sonner"` and
// call toast.success / toast.error without provider plumbing. // call toast.success / toast.error without provider plumbing.
import Toaster from './components/Toaster'; import Toaster from './components/Toaster';
// Phase 3 closure (UX-H6 + FE-L4): cmd+k command palette mounted at
// the root. The hook + listener live in CommandPaletteHost so the
// keydown binding stays scoped to the React tree (auto-cleanup on
// HMR + StrictMode).
import CommandPaletteHost from './components/CommandPaletteHost';
import { STALE_TIME, GC_TIME } from './api/queryConstants'; import { STALE_TIME, GC_TIME } from './api/queryConstants';
import './index.css'; import './index.css';
@@ -99,6 +104,7 @@ createRoot(document.getElementById('root')!).render(
<AuthProvider> <AuthProvider>
<AuthGate> <AuthGate>
<BrowserRouter> <BrowserRouter>
<CommandPaletteHost />
<Routes> <Routes>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />