mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:31:36 +00:00
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:
Generated
+596
-1
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+244
-73
@@ -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,25 +217,58 @@ 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) => {
|
||||||
<NavLink
|
const isCollapsed = collapsed.has(group.id);
|
||||||
key={item.to}
|
return (
|
||||||
to={item.to}
|
<div key={group.id} className="space-y-0.5">
|
||||||
end={item.to === '/'}
|
{/* Group header — clickable to toggle collapse. */}
|
||||||
data-testid={'testID' in item ? item.testID : undefined}
|
<button
|
||||||
className={({ isActive }) =>
|
type="button"
|
||||||
`flex items-center gap-3 px-3 py-2 text-sm rounded transition-all duration-150 ${
|
onClick={() => toggleGroup(group.id)}
|
||||||
isActive
|
aria-expanded={!isCollapsed}
|
||||||
? 'bg-white/15 text-white font-semibold shadow-sm'
|
aria-controls={`nav-group-${group.id}`}
|
||||||
: 'text-sidebar-text hover:text-white hover:bg-white/10'
|
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
|
||||||
<Icon d={item.icon} />
|
? <ChevronRight className="w-3 h-3 shrink-0" aria-hidden="true" />
|
||||||
{item.label}
|
: <ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />}
|
||||||
</NavLink>
|
</button>
|
||||||
))}
|
{/* Group items — fold via inline display:none when collapsed
|
||||||
|
(vs unmount) so the NavLinks retain focus state and the
|
||||||
|
operator's next click doesn't re-render the entire group.
|
||||||
|
aria-hidden mirrors the visual state for screen readers. */}
|
||||||
|
<div
|
||||||
|
id={`nav-group-${group.id}`}
|
||||||
|
className={`space-y-0.5 ${isCollapsed ? 'hidden' : ''}`}
|
||||||
|
aria-hidden={isCollapsed}
|
||||||
|
>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const ItemIcon = item.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
data-testid={item.testID}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2 text-sm rounded transition-all duration-150 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-white/15 text-white font-semibold shadow-sm'
|
||||||
|
: 'text-sidebar-text hover:text-white hover:bg-white/10'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ItemIcon className="w-[18px] h-[18px] shrink-0" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user