mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 01:09:27 +00:00
e761ae40a4
Phase 3 of the frontend-design audit: information architecture + search.
Layout.tsx rewritten once for BOTH grouped-sidebar (UX-H1) AND lucide-
react icon migration (FE-H2). Breadcrumbs primitive added + wired into
PageHeader. cmd+k command palette mounted globally via cmdk. FE-M6
(drop unsafe-inline from CSP style-src) deferred — the audit's framing
was incomplete.
New / changed
=============
web/src/components/Layout.tsx (rewrite — UX-H1 + FE-H2 + FE-L4)
Pre: flat 31-item nav array with literal SVG path-string icons.
Post: 7 semantic groups (Inventory / Trust / Delivery / People /
Notify / Access / Audit) of 31 NavLinks total; lucide-react
icon components replace every path string (27 named imports);
collapsible per-group state persisted to localStorage
(`certctl:nav:collapsed-groups`); aria-expanded / aria-controls
on each group header; the existing Setup-guide button and Sign-
out button kept verbatim. Logout icon swapped from inline SVG to
lucide `LogOut`.
web/src/components/Breadcrumbs.tsx (new — UX-M5)
Walks the current pathname via useLocation() + a static
pathSegmentLabels map. Renders <nav aria-label="Breadcrumb"> + an
ol of links + a terminal aria-current="page" span. Renders
nothing on the dashboard root. 8 sibling tests in
Breadcrumbs.test.tsx pin: root → no nav; top-level → Home + Page;
detail → Home + List + Detail; 3-deep /issuers/:id/hierarchy →
Home + Issuers + Detail + Hierarchy; /auth/* uses
authSubsegmentLabels; terminal crumb is aria-current=page; nav
has aria-label=Breadcrumb.
web/src/components/PageHeader.tsx (1-line wire-in)
Renders <Breadcrumbs /> above the page title. Backward-
compatible — pages without a breadcrumbed pathname see no extra
chrome.
web/src/components/CommandPalette.tsx (new — UX-H6)
cmdk-driven palette with three sections:
1. Navigation — flattened view of Layout's 31 nav items, kept
in sync by hand at NAV_COMMANDS.
2. Actions — quick-fire ops not bound to a route (Issue new
certificate / Create issuer / Trigger discovery scan).
3. Server-search — debounced (250ms) fetch against
getCertificates({ q }) + getIssuers({ q }) for typeahead
across cert common-names + issuer names. Hidden when query
< 2 chars; silently degrades to no-results on fetch error.
web/src/components/CommandPaletteHost.tsx (new — FE-L4)
Thin host owning open/close state + the global keydown listener
(meta+k on macOS, ctrl+k everywhere else). Lazy-loads the
palette via React.lazy so cmdk's bundle (~25 KB) only lands
when the operator first hits cmd+k. Mounted inside BrowserRouter
so useNavigate() resolves.
Audit-accuracy callouts
=======================
1. UX-H1 wording was FACTUALLY WRONG. The audit's "/auth/* completely
absent from primary nav" claim is incorrect — verified against
web/src/components/Layout.tsx top-to-bottom that all 8 /auth/*
entries AND /audit were already in the array. The actual issue
was UNGROUPED, not absent. Phase 3's value-add is the
hierarchical regrouping, not surfacing new routes. Restated in
the file header comment.
2. FE-M6 deferred — audit framing was too narrow. The CSP comment
in internal/api/middleware/securityheaders.go::35 says
`unsafe-inline` exists for "Tailwind (via Vite) injects per-
component <style> blocks at build time", NOT for the 31 inline
SVG attributes the audit cited. Even after FE-H2 removes the
Layout.tsx SVGs, there are 17 production tsx files with React
`style={...}` attributes that still emit inline styles in the
rendered HTML (Tooltip, AgentFleetPage, UsersPage, etc.).
Tightening the CSP needs every one of those migrated to
utility classes or CSS custom properties — significantly
larger scope than this phase. Tracked as Phase 4+ follow-up.
3. UX-M5 implementation pivot. The audit prompt suggested
useMatches() + per-route handle.crumb. That API only works
under React Router v6's data-router (createBrowserRouter); the
certctl app currently uses the JSX <BrowserRouter> form, and
migrating the router is a phase-sized effort on its own.
Pivoted to useLocation() + a static pathSegmentLabels map.
Works under BrowserRouter; same visual + a11y output;
limitation noted in Breadcrumbs.tsx header so a future
router migration can upgrade in place.
Verification
============
$ npx tsc --noEmit
(exit 0)
$ npx vitest run src/components/Layout.test.tsx src/components/Breadcrumbs.test.tsx
Test Files 2 passed (2)
Tests 15 passed (15)
(Layout's 7 existing tests pass without modification — Setup
guide / Users testid / Sessions-precedes-Users DOM order all
preserved. Breadcrumbs ships with 8 new assertions.)
$ npx vite build
✓ built in 3.58s
(bundle grows ~25 KB from lucide-react + cmdk; cmdk lazy-loaded
so it doesn't land on initial page load)
$ grep -nE "navGroups|label: 'Access'|from 'lucide-react'|cmdk" \
web/src --type tsx --type ts -r | grep -v test
(15+ hits across Layout / Breadcrumbs / CommandPalette / Host)
$ grep -cE "icon: '" web/src/components/Layout.tsx
0 (was 31 path strings; now all replaced with lucide imports)
$ ls web/src/components/{Breadcrumbs,CommandPalette,CommandPaletteHost}.tsx
(all three new files exist)
Residual risks
==============
* The 14-ish inline SVGs in other pages (DashboardPage, ErrorState,
DataTable, JobsPage, CertificateDetailPage, OnboardingWizard)
still ship as raw <svg> markup. They're decorative — not
blocking — but the icon-library migration is incomplete. Next
per-page touches should replace them with lucide imports.
* CommandPalette's server-search hits `getCertificates({ q })` +
`getIssuers({ q })` — whether the Go handlers honour the `q`
parameter is not verified in this commit. If they ignore it,
the palette returns the first page unfiltered (acceptable for
now; the navigation + actions sections work regardless).
* The Layout's NAV_COMMANDS table in CommandPalette.tsx duplicates
the navGroups array in Layout.tsx by hand. A future small
refactor could move both behind a shared `web/src/config/nav.ts`.
* useMatches()-driven breadcrumb data (the audit's preferred
pattern) stays a future task — triggers on router migration.
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
// 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>
|
|
);
|
|
}
|