Files
certctl/web/src/components/CommandPalette.tsx
T
shankar0123 e761ae40a4 feat(frontend): Phase 3 Information Architecture + Search — close UX-H1 + FE-H2 + UX-M5 + UX-H6 + FE-L4; FE-M6 deferred
Phase 3 of the frontend-design audit: information architecture + search.
Layout.tsx rewritten once for BOTH grouped-sidebar (UX-H1) AND lucide-
react icon migration (FE-H2). Breadcrumbs primitive added + wired into
PageHeader. cmd+k command palette mounted globally via cmdk. FE-M6
(drop unsafe-inline from CSP style-src) deferred — the audit's framing
was incomplete.

New / changed
=============

  web/src/components/Layout.tsx (rewrite — UX-H1 + FE-H2 + FE-L4)
    Pre: flat 31-item nav array with literal SVG path-string icons.
    Post: 7 semantic groups (Inventory / Trust / Delivery / People /
    Notify / Access / Audit) of 31 NavLinks total; lucide-react
    icon components replace every path string (27 named imports);
    collapsible per-group state persisted to localStorage
    (`certctl:nav:collapsed-groups`); aria-expanded / aria-controls
    on each group header; the existing Setup-guide button and Sign-
    out button kept verbatim. Logout icon swapped from inline SVG to
    lucide `LogOut`.

  web/src/components/Breadcrumbs.tsx (new — UX-M5)
    Walks the current pathname via useLocation() + a static
    pathSegmentLabels map. Renders <nav aria-label="Breadcrumb"> + an
    ol of links + a terminal aria-current="page" span. Renders
    nothing on the dashboard root. 8 sibling tests in
    Breadcrumbs.test.tsx pin: root → no nav; top-level → Home + Page;
    detail → Home + List + Detail; 3-deep /issuers/:id/hierarchy →
    Home + Issuers + Detail + Hierarchy; /auth/* uses
    authSubsegmentLabels; terminal crumb is aria-current=page; nav
    has aria-label=Breadcrumb.

  web/src/components/PageHeader.tsx (1-line wire-in)
    Renders <Breadcrumbs /> above the page title. Backward-
    compatible — pages without a breadcrumbed pathname see no extra
    chrome.

  web/src/components/CommandPalette.tsx (new — UX-H6)
    cmdk-driven palette with three sections:
      1. Navigation — flattened view of Layout's 31 nav items, kept
         in sync by hand at NAV_COMMANDS.
      2. Actions — quick-fire ops not bound to a route (Issue new
         certificate / Create issuer / Trigger discovery scan).
      3. Server-search — debounced (250ms) fetch against
         getCertificates({ q }) + getIssuers({ q }) for typeahead
         across cert common-names + issuer names. Hidden when query
         < 2 chars; silently degrades to no-results on fetch error.

  web/src/components/CommandPaletteHost.tsx (new — FE-L4)
    Thin host owning open/close state + the global keydown listener
    (meta+k on macOS, ctrl+k everywhere else). Lazy-loads the
    palette via React.lazy so cmdk's bundle (~25 KB) only lands
    when the operator first hits cmd+k. Mounted inside BrowserRouter
    so useNavigate() resolves.

Audit-accuracy callouts
=======================

  1. UX-H1 wording was FACTUALLY WRONG. The audit's "/auth/* completely
     absent from primary nav" claim is incorrect — verified against
     web/src/components/Layout.tsx top-to-bottom that all 8 /auth/*
     entries AND /audit were already in the array. The actual issue
     was UNGROUPED, not absent. Phase 3's value-add is the
     hierarchical regrouping, not surfacing new routes. Restated in
     the file header comment.

  2. FE-M6 deferred — audit framing was too narrow. The CSP comment
     in internal/api/middleware/securityheaders.go::35 says
     `unsafe-inline` exists for "Tailwind (via Vite) injects per-
     component <style> blocks at build time", NOT for the 31 inline
     SVG attributes the audit cited. Even after FE-H2 removes the
     Layout.tsx SVGs, there are 17 production tsx files with React
     `style={...}` attributes that still emit inline styles in the
     rendered HTML (Tooltip, AgentFleetPage, UsersPage, etc.).
     Tightening the CSP needs every one of those migrated to
     utility classes or CSS custom properties — significantly
     larger scope than this phase. Tracked as Phase 4+ follow-up.

  3. UX-M5 implementation pivot. The audit prompt suggested
     useMatches() + per-route handle.crumb. That API only works
     under React Router v6's data-router (createBrowserRouter); the
     certctl app currently uses the JSX <BrowserRouter> form, and
     migrating the router is a phase-sized effort on its own.
     Pivoted to useLocation() + a static pathSegmentLabels map.
     Works under BrowserRouter; same visual + a11y output;
     limitation noted in Breadcrumbs.tsx header so a future
     router migration can upgrade in place.

Verification
============

  $ npx tsc --noEmit
    (exit 0)

  $ npx vitest run src/components/Layout.test.tsx src/components/Breadcrumbs.test.tsx
    Test Files  2 passed (2)
         Tests  15 passed (15)
    (Layout's 7 existing tests pass without modification — Setup
    guide / Users testid / Sessions-precedes-Users DOM order all
    preserved. Breadcrumbs ships with 8 new assertions.)

  $ npx vite build
    ✓ built in 3.58s
    (bundle grows ~25 KB from lucide-react + cmdk; cmdk lazy-loaded
    so it doesn't land on initial page load)

  $ grep -nE "navGroups|label: 'Access'|from 'lucide-react'|cmdk" \
       web/src --type tsx --type ts -r | grep -v test
    (15+ hits across Layout / Breadcrumbs / CommandPalette / Host)

  $ grep -cE "icon: '" web/src/components/Layout.tsx
    0    (was 31 path strings; now all replaced with lucide imports)

  $ ls web/src/components/{Breadcrumbs,CommandPalette,CommandPaletteHost}.tsx
    (all three new files exist)

Residual risks
==============

  * The 14-ish inline SVGs in other pages (DashboardPage, ErrorState,
    DataTable, JobsPage, CertificateDetailPage, OnboardingWizard)
    still ship as raw <svg> markup. They're decorative — not
    blocking — but the icon-library migration is incomplete. Next
    per-page touches should replace them with lucide imports.
  * CommandPalette's server-search hits `getCertificates({ q })` +
    `getIssuers({ q })` — whether the Go handlers honour the `q`
    parameter is not verified in this commit. If they ignore it,
    the palette returns the first page unfiltered (acceptable for
    now; the navigation + actions sections work regardless).
  * The Layout's NAV_COMMANDS table in CommandPalette.tsx duplicates
    the navGroups array in Layout.tsx by hand. A future small
    refactor could move both behind a shared `web/src/config/nav.ts`.
  * useMatches()-driven breadcrumb data (the audit's preferred
    pattern) stays a future task — triggers on router migration.
2026-05-14 15:27:23 +00:00

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>
);
}