mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:51:30 +00:00
c95685f8abb6826d3a46a0c15010669d7b1ccf8e
12 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
622c19cafe |
feat(web): close TEST-H3 — install Storybook 10 + wire scripts + dropt tsconfig exclude
Closes frontend-design-audit finding TEST-H3 (High):
Zero Storybook — 9 production components live without isolated
rendering or designer-handoff surface
Phase 8 originally shipped the scaffold (.storybook/main.ts +
preview.ts + 8 *.stories.tsx files) but couldn't land the deps:
• Storybook 8.6 peer-capped at Vite 6, project ships Vite 8
(Phase 4 manualChunks rewrite). Hotfix #9 ripped the deps.
• The .storybook/main.ts header speculated "Storybook 9 supports
Vite 7+8" — that was wrong. Verified at install time today:
Storybook 9.1.20's peer range is Vite 5/6/7. ERESOLVE'd again.
• Storybook 10.4.0 is the first release with explicit Vite 8 in
its peer range (^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0). Installed
cleanly via `npm install --save-dev`.
═══════════════════════════ CHANGES ═══════════════════════════════
package.json + package-lock.json:
• storybook ^10.4.0
• @storybook/react-vite ^10.4.0
• @storybook/addon-a11y ^10.4.0
All resolve without --legacy-peer-deps. 93 packages added.
Scripts: `npm run storybook` (dev server on :6006) and
`npm run storybook:build` (→ .storybook-static).
tsconfig.json:
Dropped the `src/**/*.stories.tsx` + `src/**/*.stories.ts`
exclusions. Storybook 10's @storybook/react types are stable;
the 8 committed story files typecheck cleanly inside the main
`npm run build` step. Phase 8's "stories excluded so build stays
green in the meantime" caveat is now retired.
web/src/components/Banner.stories.tsx:
Fixed stale prop name: stories used `severity: 'error'` but the
Banner primitive's prop is `type: 'error'` (BannerType union).
4-line edit, replace_all on `severity:` → `type:`. The Banner
component never had a `severity` prop — the story was authored
against a different draft of the API. Typecheck now passes.
web/.storybook/main.ts:
Replaced the "deps not installed" header block with a
version-selection history block documenting the 8 → 9 → 10
trail so the next operator who upgrades Vite doesn't re-walk
the same wall.
.gitignore:
Added `web/.storybook-static/` (Storybook build output, like
web/dist/).
═══════════════════════════ VERIFICATION ═══════════════════════════
• npm install — exit 0, 93 packages, no peer warnings, no
ERESOLVE.
• npx tsc --noEmit — exit 0 with stories included (was running
excluded; now they're in the typecheck graph).
• npx storybook build — built in 3.09s, 17 chunks emitted to
.storybook-static. All 8 stories rendered without errors.
• npx vitest run src/components — 16 files / 161 tests pass
(no regression from Storybook install / story-file fix).
• npx vite build — production build green in 3.35s.
• CI guards: no-raw-table 17/17, no-unbound-label 134/134,
no-raw-toLocaleString clean.
Operator follow-ups (none blocking):
• `npm run storybook` locally opens the dev server with hot-
reload + addon-a11y panel.
• `npm run storybook:build` for an immutable static deploy
(e.g. cert-ctl.io/storybook).
• New components SHOULD ship a sibling *.stories.tsx going
forward; can wire a CI guard if desired (fe-component-has-
story.sh — scaffold mentioned in the audit's executable
prompt for Phase 8 TEST-H3 but deferred).
Ground-truth: origin/master tip
|
||
|
|
c9f932be65 |
feat(frontend): Phase 5 Accessibility + Forms — close FE-H3 + UX-H4 primitive + FE-M1 primitive + axe-core gate
Closes the Phase 5 batch from cowork/frontend-design-audit.html: ships
the joint UX-H4 + FE-M1 lever (FormField primitive + react-hook-form +
zod schemas) and the FE-H3 fix (Headless UI Dialog focus trap on the 3
inline-managed modals), with an axe-core regression test + CI guard to
prevent UX-H4 regressions.
═════════════════════════ AUDIT VERIFICATION ═════════════════════════
Confirmed live against the repo before implementing:
• Q1 labels / htmlFor / input-id = 139 / 6 / 0
(audit said 138 / 6 / 0 — labels +1, otherwise accurate)
• Q2 no form library installed
(no react-hook-form, formik, @tanstack/react-form, final-form)
• Q3 3 inline-managed dialog sites confirmed:
SCEPAdminPage.tsx:272, AgentsPage.tsx:314, ESTAdminPage.tsx:281
• Q4 audit's top-6 list was OFF — actual top form-heaviest pages
by useState count are: OIDCProviderDetailPage 21, AgentGroupsPage
18, CertificatesPage 17, CertificateDetailPage 14, BreakglassPage
13, ProfilesPage 13 — NOT the audit-suggested OnboardingWizard 5
(now split in Phase 4) / OIDCProvidersPage 8 / IssuersPage 11 /
ProfilesPage 13 / TargetsPage 9 / ApprovalsPage 5. Audit's
intuition skipped the higher-useState pages.
• Q5 jest-dom imported in src/test/setup.ts — axe-core landed
cleanly
═════════════════════════════ CLOSURES ═══════════════════════════════
UX-H4 (label/input binding) — FormField primitive shipped
• web/src/components/FormField.tsx wraps a <label> + an input child
and auto-generates a stable id via React 18's useId(); cloneElement
threads that id onto BOTH the <label htmlFor> AND the child's id
prop so the WCAG 1.3.1 binding holds by construction. Supports
`required` (asterisk + aria-required), `description` (wires
aria-describedby), `error` (aria-invalid + role=alert + extends
aria-describedby). 7 tests pin the contract.
FE-M1 (no form library) — react-hook-form + @hookform/resolvers + zod
• Added react-hook-form 7.75, @hookform/resolvers 5.2, zod 4.4 as
runtime deps; @axe-core/react, jest-axe, @types/jest-axe as devDeps
• Representative migration of CreateTeamModalInline (inside
onboarding/CertificateStep — operator's first-run experience)
from 3-useState + manual handlers to useForm + zodResolver +
FormField. Schema at pages/onboarding/team.schema.ts.
• Per the audit's "top-6 only, primitive is the lever" rule, the
other 5 audit-suggested pages migrate organically as feature
work touches them — documented as Phase 5 follow-up. The
FormField primitive is the leverage point; per-page migrations
are mechanical applications.
FE-H3 (no focus trap on modal pages)
• New ModalDialog primitive at web/src/components/ModalDialog.tsx —
Headless UI Dialog wrapper for arbitrary-content modals
(complements ConfirmDialog which is confirm-only). Auto-emits
role=dialog + aria-modal + aria-labelledby + ESC-to-close +
backdrop-click-to-close + focus trap.
• All 3 inline-managed modal sites migrated:
• SCEPAdminPage ConfirmReloadModal
• ESTAdminPage ConfirmReloadModal (data-testid preserved)
• AgentsPage RetireAgentModal (3-mode: confirm / blocked / error
— title + footer change per mode; body slot stays the same)
• 37/37 existing modal-page tests stay green — no behavior change
visible to the test suite, only the focus-trap + ESC handling.
UX-H4 regression gate
• web/src/test/a11y.test.tsx runs axe-core (not jest-axe — its
`toHaveNoViolations` matcher uses jest's expect API which can't
plug into Vitest's expect.extend; fails with "expectAssertion.call
is not a function"). Direct axe.run + assert violations.length===0
gives the same gate with a readable failure message.
• Scope: primitives, not page sweeps. Primitives carry the risk
surface; pages compose them. 5 tests covering FormField (with +
without description/error), Skeleton (all 4 variants),
ModalDialog, Breadcrumbs. ~400ms total.
• Skeleton.table's empty <th> cells are decorative shimmers inside
a role=status + aria-busy=true tree — axe-core's
`empty-table-header` rule doesn't model aria-busy gating, so it
is suppressed for the Skeleton variant scan with a clear comment.
• scripts/ci-guards/no-unbound-label.sh — fails CI if a new <label>
without htmlFor lands. Baseline-driven (132 today) so the existing
backlog doesn't block CI; every migration to FormField drops the
baseline. `--strict` mode rejects any unbound label once the
backlog clears.
═══════════════════════════ VERIFICATION ═════════════════════════════
• npx tsc --noEmit — exits 0
• New tests: FormField 7/7, ModalDialog 6/6, a11y 5/5 = 18/18 new
• Component suite: 14 files / 150/150 green
• Page suite (representative subset run): 16 files in first run
(timeout truncated final summary) + 10 files / 48/48 in second
run — all green
• OnboardingWizard 4/4 (the migrated CreateTeamModalInline test
case is the second one — `+ New team opens the inline modal,
calls createTeam, invalidates the cache, and auto-selects the
new team`)
• SCEPAdminPage 20/20, ESTAdminPage 14/14, AgentsPage 3/3 — all
37 modal-page tests stay green after ModalDialog migration
• npm run build ✓ in 3.27s
• CI guard: bash scripts/ci-guards/no-unbound-label.sh — passes at
baseline 132 (current unbound count matches; failure mode is
only on increase). --strict path will fail until backlog clears.
═══════════════════════════ RESIDUAL RISK ════════════════════════════
• RHF migration risk: zod resolver's input/output type mismatch
bit me once during this work (description: z.string().optional()
gave Input: string|undefined vs Output: string after .default()).
Both sides typed as string + defaultValues providing empty string
fixes it; documented in team.schema.ts. Pattern applies to every
future Zod schema with optional-but-empty-string fields.
• The audit's "top-6" page list is stale (Phase 4 split
OnboardingWizard; useState ranks shifted). Future RHF migrations
should re-derive the priority list against live useState counts,
not the audit's stamped names.
• DataTable per-row React.memo (PERF-M1 follow-up from Phase 4)
remains deferred — orthogonal to Phase 5 scope.
|
||
|
|
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.
|
||
|
|
e37403edf1 |
feat(frontend): Phase 1 Foundation Primitives + Toast System — close UX-H2/H3/H5 + UX-M2/M3/M4/L5 + FE-M4
Frontend design remediation, Phase 1 (Foundation Primitives + Toast).
Builds the six reusable UI primitives every later phase consumes;
migrates the audit-enumerated destructive-action callsites; humanises
the StatusBadge wire keys; and wraps the bulk-action bar in a
Transition with a post-action toast affordance.
Six new primitives + their .test.tsx siblings
=============================================
web/src/components/Toaster.tsx — Sonner wrapper, mounted
once at the root next to
QueryClientProvider. Pages
import { toast } from
"sonner" directly.
web/src/components/ConfirmDialog.tsx — Headless UI Dialog primitive
with optional typed-
confirmation friction for
the most-irreversible actions
(archive-certificate uses
typedConfirmation="archive").
web/src/components/Tooltip.tsx — Floating-UI tooltip with
hover + focus triggers,
aria-describedby wiring,
ESC-to-dismiss. Migrations
of the 103 native title=
sites stay in subsequent
per-page PRs per the audit
prompt's explicit "DO NOT"
on one-mega-PR sweeps.
web/src/components/EmptyState.tsx — Empty-state primitive with
optional icon / title /
description / primary +
secondary CTAs. DataTable
adds a new emptyState slot
(legacy emptyMessage string
prop preserved for backward
compat).
web/src/components/Combobox.tsx — Headless UI typeahead-
select primitive. Migrations
of the 53 native <select>
sites stay in subsequent
per-page PRs.
web/src/components/Banner.tsx — Severity-variant alert
banner with role="alert" on
error/warning, role="status"
on success/info. Migrating
the ~102 inline
bg-(red|amber|yellow)-50
sites stays as page-touch
rolling work.
Each primitive ships with a sibling .test.tsx asserting the
behavioural contract — render at rest, fire callbacks, ARIA wiring,
keyboard nav, variant styling. Total new test count: 109 assertions
across 7 files (6 primitives + extended StatusBadge).
UX-H5 closure — StatusBadge display strings
============================================
web/src/components/StatusBadge.tsx gets a statusDisplay map paired
with the existing statusStyles map. Wire keys stay byte-identical
to the Go enums per the D-1 closure comment block — only the
rendered text changes. PascalCase + snake_case + lowercase enums
now render as spaced sentence-case:
"RenewalInProgress" → "Renewal in progress"
"AwaitingCSR" → "Awaiting CSR"
"cert_mismatch" → "Certificate mismatch"
"dead" → "Dead-lettered"
Unmapped keys flow through a titleCase() helper that humanises
PascalCase / snake_case to lower-bound readability.
StatusBadge.test.tsx extends to 75 assertions: 38 D-1 + 5 dead-key
+ 31 UX-H5 display-string + 5 titleCase + 1 parity. All wire-keys
pinned byte-exact.
UX-H2 closure — window.confirm sites migrated to ConfirmDialog
==============================================================
Audit said 8 destructive-action sites. Live count was 24 across
17 files — the audit missed 11 files (auth/SessionsPage,
auth/UsersPage, auth/GroupMappingsPage, auth/OIDCProvidersPage,
auth/OIDCProviderDetailPage, auth/RolesPage, TeamsPage,
PoliciesPage, IssuersPage, ProfilesPage, RenewalPoliciesPage).
Phase 1 migrates the 7 audit-enumerated destructive sites in the
6 priority files:
- CertificateDetailPage archive (typedConfirmation="archive" —
most-irreversible action gets the
strongest friction)
- OwnersPage delete owner
- TargetsPage delete target
- AgentGroupsPage delete agent group
- auth/KeysPage revoke role grant
- auth/RoleDetailPage delete role
The remaining 11 confirm sites in audit-missed files stay open
and ship as a Phase 1 follow-up (mechanical pattern repeat — same
Edit shape × ~11 files).
UX-H3 closure — alert() → toast.error, top mutations wired
===========================================================
All 5 alert() sites migrated to toast.error:
- OwnersPage / CertificateDetailPage × 2 / TeamsPage /
RenewalPoliciesPage
Eight high-traffic mutations now fire toast.success on resolve +
toast.error on failure: deleteOwner, deleteTarget, deleteAgentGroup,
deleteTeam, deleteRenewalPolicy, archiveCertificate,
authRevokeKeyRole, authDeleteRole. The bulk-renew flow on
CertificatesPage gets a toast with a "View N jobs" action button
that deep-links to /jobs?certificate_ids=… (paired UX-L5 work).
Toaster mounted at web/src/main.tsx next to QueryClientProvider —
single import discipline. Sonner asserts at runtime if multiple
toasters are mounted; centralising the position + duration config
in Toaster.tsx avoids the mistake.
UX-M3 closure — DataTable empty-state slot
==========================================
web/src/components/DataTable.tsx gains an optional emptyState
ReactNode prop. The existing emptyMessage string prop is
preserved for backward compat — every ~18 list-page call site
that passes emptyMessage="…" keeps working unchanged. New CTAs:
pages pass <EmptyState ... /> for first-run experiences. Wiring
EmptyState on the top-5 list pages (Certificates, Issuers,
Targets, Owners, Agents) is per-page rolling work — primitive
+ slot ship in Phase 1; CTAs follow.
UX-L5 closure — Bulk-action bar transition + post-action toast
==============================================================
web/src/pages/CertificatesPage.tsx wraps the bulk-action bar
conditional render in Headless UI <Transition>. Slide-in/out
(200ms enter, 150ms leave, -translate-y-2 → 0). The
prefers-reduced-motion respect comes for free from the global
@media block landed in Phase 0.
Post-renewal toast.success fires with an action button "View N
jobs" that navigate()s to /jobs filtered to the certificate_ids
we just renewed. Closes the audit's "what just happened" gap.
Audit-accuracy callouts
=======================
* UX-H2 undercount — live 24 sites vs audit's 8. Phase 1 closes
the 7 audit-enumerated destructive confirms across 6 priority
files. The remaining 11 sites in audit-missed files stay open
for follow-up.
* UX-M2 title= count — live 103 (matches audit). Tooltip
primitive built; per-page migrations explicitly deferred per
the prompt's "DO NOT" sweep rule.
* UX-M4 native <select> sites — Combobox primitive built;
callsite migrations deferred to per-page rolling PRs.
* FE-M4 inline bg-(red|amber|yellow)-50 — Banner primitive
built; callsite migrations deferred to page-touch work.
Verification
============
$ npx tsc --noEmit
(exit 0, no type errors)
$ npx vitest run src/components/{Toaster,ConfirmDialog,EmptyState,Banner,Tooltip,Combobox}.test.tsx src/components/StatusBadge.test.tsx
Test Files 7 passed (7)
Tests 109 passed (109)
$ npx vitest run src/pages/{OwnersPage,AgentGroupsPage,TargetsPage,CertificatesPage,CertificateDetailPage,TeamsPage,RenewalPoliciesPage}.test.tsx src/pages/auth/{KeysPage,RoleDetailPage}.test.tsx
Test Files 9 passed (9)
Tests 52 passed (52)
(TargetsPage.test.tsx updated — the existing Delete confirm
test stubbed window.confirm; new test clicks the dialog's
destructive Delete button.)
$ npx vite build
✓ built in 2.89s
dist/assets/index-DZ1ZcRdP.js 1,110.61 kB (was 1,028.66 kB)
+82 KB / +26 KB gzipped from sonner + @headlessui + @floating-ui.
Bundle code-splitting is a separate phase (FE-M5).
Residual risks + follow-ups
============================
* 11 remaining window.confirm sites in audit-missed files. Phase 1
follow-up commit will sweep them with the same ConfirmDialog
pattern — mechanical work.
* The discard-unsaved-changes confirm in EditRoleModal (and 2
sibling modal sub-components) stays as window.confirm; treated
as a UX safety guardrail rather than a destructive-action
confirmation. Migrating to ConfirmDialog is fine but not
audit-priority.
* Tooltip + Combobox + Banner callsite migrations are explicit
per-page rolling work for subsequent phases — primitives
landed; per the audit prompt's "DO NOT" rule the migrations
don't sweep here.
* Optimistic-update wiring on the 5 priority mutations
(mark-notification-read, dismiss-discovery, archive-cert,
claim-discovered-cert, role-assignment) is staged for Phase 2
TQ-M3 per the prompt's explicit "DO NOT add new mutations to
the optimistic-update list beyond the 5 priority ones".
|
||
|
|
93e00f6a5e |
fix(frontend): Phase 0 Hygiene Day — close 11 of 12 frontend-audit findings
Frontend design remediation, Phase 0 (Hygiene Day). Eleven low-risk
audit findings closed in one PR. UX-M9 deliberately deferred per the
prompt's "do NOT auto-trace the logo" guard rail — that needs a
designer round-trip outside a code session.
Findings closed (mapped by source ID)
=====================================
FE-H1 Half-wired dark mode removed.
web/index.html: dropped class="dark" from <html> and
bg-slate-900 text-slate-100 from <body>. Replaced with
bg-page text-ink (matching the live light-mode palette).
web/tailwind.config.cjs: kept darkMode: 'class' (config
only, zero behaviour) so a future Phase 7 dark-mode
rebuild stays cheap.
FE-H4 Self-hosted fonts (closes PERF-H3 as a side-effect).
web/package.json: added @fontsource-variable/inter +
@fontsource/jetbrains-mono (^5.2.8 both).
web/src/main.tsx: top of file imports the variable Inter
family + JetBrains Mono weights 400/500/600 (matching the
old Google Fonts request's weight set).
web/src/index.css: removed the @import url(
'https://fonts.googleapis.com/...') that lived on line 1.
Body font-family updated to "Inter Variable", "Inter",
system-ui, ... (fontsource-variable registers the family
as "Inter Variable" — kept "Inter" as a fallback).
Vite bundles the .woff2 files into dist/assets/ on build:
verified inter-latin-wght-normal-*.woff2 (48 kB) +
the JetBrains weights all land in the build output.
Net effect: cold load makes ZERO third-party requests.
FE-L2 StatusBadge.tsx.bak removed.
Audit claim "tracked in git" was stale — the file was
already excluded by .gitignore:46 (*.bak). Closure was
a plain `rm`, not `git rm`. (Audit accuracy note above.)
FE-L3 brand-900 removed from web/tailwind.config.cjs.
Verified 0 callers in web/src via
`grep -rEc "brand-$w\b" web/src --include='*.tsx'`.
Other weights all retain ≥4 callers (50=5, 100=4, 200=4,
300=8, 400=106, 500=74, 600=34, 700=23, 800=4) — they
stay. Comment marker left in place so a future Phase 7
dark-mode redo can re-add 900 with context.
UX-M6 text-ink-faint contrast bumped from #94a3b8 (3.0:1
against bg-page #f0f4f8, fails WCAG AA) to #64748b
(4.6:1, passes AA). To preserve the three-tier ink
hierarchy, ink.muted darkens from #64748b to #475569
(6.9:1, passes AA Large). All 105 live text-ink-faint
callers now meet WCAG AA without any callsite edits.
UX-M9 DEFERRED. The audit prompt's "do NOT auto-trace the PNG
logo to SVG" guard rail blocks the auto-conversion path.
Logo (886x864 PNG, 773 kB) remains shipped to dist/assets/
unchanged. Tracking item: round-trip through designer
with a flat-geometric Illustrator/Figma rebuild. Phase 0
commit ships the rest of the hygiene block; UX-M9 stays
open until the SVG asset lands.
UX-L1 23 hardcoded text-[Npx] sites migrated to design tokens
(audit said 23; live count was 25 — also 2x text-[13px]
the audit missed). web/tailwind.config.cjs added the
`2xs: 0.625rem` (10px) rung so the 7x text-[10px] sites
migrate losslessly. The 16x text-[11px] sites move to
text-xs (+1px, imperceptible) and the 2x text-[13px]
sites move to text-sm (+1px, imperceptible). Six files
touched: Layout.tsx, NetworkScanPage.tsx, SCEPAdminPage.tsx,
DiscoveryPage.tsx, ESTAdminPage.tsx, auth/SessionsPage.tsx.
Post-migration: zero `text-[Npx]` callers in web/src.
UX-L2 prefers-reduced-motion handling added at the bottom of
web/src/index.css. Caps animation-duration +
transition-duration at 0.01ms when the OS reduce-motion
flag is set. Conventional non-zero value (fully zero
breaks libraries observing transitionend events).
UX-L3 Print stylesheet added to web/src/index.css. Hides
sidebar / nav, removes card shadows, expands content to
full width, prevents mid-row table breaks, and appends
link URLs as text annotations (print readers can't click
links). Operator-facing — certificate detail + audit-log
export are the most common print targets.
UX-L4 DataTable.tsx <th>s now carry scope="col". One-line
change on each of the two header sites (selectable
checkbox column + the columns.map iteration). Closes the
accessibility-tree screen-reader gap.
PERF-H2 The only production <img> site (Layout.tsx:73, the
sidebar logo) gained loading="eager" decoding="async" +
explicit width/height (64x64). eager (not lazy) because
the logo is the LCP candidate above the fold. Since
UX-M9 deferred, the logo stays as a PNG — making this
the right LCP hint to ship today.
PERF-H3 Closes via FE-H4 (self-host fonts → zero third-party
requests on cold load → preconnect/dns-prefetch hints
would point at nothing). web/index.html stays free of
preconnect lines.
Verification
============
$ git status --short
(only the 13 expected files modified)
$ cd web && npx tsc --noEmit
(exit 0, no type errors)
$ cd web && npx vitest run
Test Files 54 passed (54)
Tests 583 passed (583)
(all green; ran via `timeout 35 npx vitest run`)
$ cd web && npx vite build
✓ built in 2.70s
dist/assets/index-Da_kGcIu.css 75.54 kB (was 39.50 kB
pre-Phase-0 — +36 kB from the inlined @fontsource @font-face
declarations + the new @media print + @media reduced-motion
blocks; offset by the elimination of all third-party font
requests + the FOIT on cold load)
dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 48.25 kB
dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 21.16 kB
(... + the rest of the weight variants and unicode-range subsets)
$ grep -rohE "text-\[[0-9]+px\]" web/src --include='*.tsx'
(zero matches — all 25 inline-pixel sites migrated)
$ grep -rEc "brand-900" web/src --include='*.tsx'
(zero callers)
$ grep -nE "scope=\"col\"" web/src/components/DataTable.tsx
86, 96 (both <th> sites carry scope="col")
$ grep -nE "loading=|decoding=" web/src/components/Layout.tsx
73 (logo <img> has both attrs + width/height)
$ grep -nE "prefers-reduced-motion|@media print" web/src/index.css
74, 92 (both blocks present)
$ ls web/src/components/StatusBadge.tsx.bak
(file not found — deleted)
Audit-accuracy notes
====================
* FE-L2 stale: the .bak file was NOT tracked in git (gitignored via
.gitignore:46 *.bak). The audit's "tracked in git" claim was wrong.
Closure path adjusted: `rm` instead of `git rm`.
* UX-L1 undercount: audit reported 23 inline-pixel sites; live count
was 25 (16x 11px + 7x 10px + 2x 13px). All 25 migrated.
* UX-M9 not closed: audit prompt's "do NOT auto-trace" guard rail
blocks closure in this code session. Tracking item for the
designer/Phase-1 follow-up.
Residual risks
==============
* Logo PNG (773 kB) still ships as-is until the designer round-trip
produces a hand-built SVG. Vite cache-busts the asset hash so
cold loads cost the same one-shot 773 kB; warm loads hit the
browser cache.
* Removing brand-900 may surface in a future dark-mode rebuild
(Phase 7) that wants a deeper teal floor. Easy re-add — comment
marker left in tailwind.config.cjs at the deletion site.
* The +1px nudges on text-[11px] -> text-xs and text-[13px] ->
text-sm are theoretically visible but practically imperceptible.
Any future visual-regression suite will catch genuine differences.
|
||
|
|
cd3205a66d |
fix(deps): pin lodash >= 4.18.0 to close Dependabot #18 + #19 (CVE-2026-4800)
Dependabot opened two High-severity alerts on lodash 4.17.23 arriving transitively via orval 7.x → @stoplight/spectral-* → lodash 4.17.23: #19 — CVE-2026-4800 / GHSA-r5fr-rjxr-66jc: _.template imports key names → Function() constructor sink → arbitrary-code execution at template compile time #18 — Prototype pollution via array path bypass in _.unset / _.omit Both alerts are tagged "Development dependency" by Dependabot — lodash is only pulled by orval (the Phase 5 API client codegen) and doesn't reach the production-served bundle. The risk is build- time RCE during `npm run generate` against untrusted input or a polluted Object.prototype. Worth fixing regardless. Fix: add `"lodash": ">=4.18.0"` to the existing `overrides` block in web/package.json. Force npm to dedupe every transitive lodash edge onto the top-level 4.18.1 already resolved at the root. Pre-fix lockfile state (web/package-lock.json): node_modules/lodash → 4.18.1 node_modules/@stoplight/spectral-functions/node_modules/lodash → 4.17.23 node_modules/@stoplight/spectral-rulesets/node_modules/lodash → 4.17.23 Post-fix: node_modules/lodash → 4.18.1 (the two nested copies are gone — deduplicated under the override) Verification: cd web npm install --package-lock-only --no-audit node -e "const lock = require('./package-lock.json'); for (const [k,v] of Object.entries(lock.packages||{})) if (k.includes('lodash') && !k.includes('lodash.')) console.log(k, v.version)" → node_modules/lodash 4.18.1 (only one entry) npm audit → found 0 vulnerabilities Lockfile delta is -14 / +0 (the two nested 4.17.23 copies removed, no new entries needed since 4.18.1 was already resolved at the root). The `"lodash": "^4.17.21"` / `~4.17.21` requirements declared by @stoplight/spectral-functions, spectral-rulesets, and orval itself are still satisfied — `^4.17.21` accepts 4.18.x, and the override forces every consumer to the same dedup'd version. Lockfile-regen pattern lesson: per the standing rule from the post-Phase-2 + post-Phase-5 lockfile-drift hotfixes, every commit that edits web/package.json MUST regenerate web/package-lock.json in the same commit via `npm install --package-lock-only --no-audit`. This commit follows that rule. Closes: https://github.com/certctl-io/certctl/security/dependabot/19 https://github.com/certctl-io/certctl/security/dependabot/18 |
||
|
|
888e10cba0 |
fix(ci): close two CI regressions from Phase 3 + Phase 5
Phase 3 added @playwright/test@^1.49.0 to web/package.json and
Phase 5 added orval@^7.0.0, both without regenerating
web/package-lock.json. CI's npm ci in both the Frontend Build job
and the Dockerfile frontend stage failed:
npm error Missing: @playwright/test@1.60.0 from lock file
npm error Missing: orval ... from lock file
Regenerate web/package-lock.json with:
cd web && npm install --package-lock-only --no-audit
(+6990 / -1893 lines — orval pulls a deep transitive graph). No
node_modules download required; lockfile-only mode keeps the
operation light. Verified clean with 'npm ci --dry-run' (612
packages would install).
Phase 2's SEC-H3 fail-closed branch (CERTCTL_DEMO_MODE_ACK_TS
required when CERTCTL_DEMO_MODE_ACK=true) broke four pre-existing
tests in internal/config/config_test.go that set DemoModeAck=true
without setting DemoModeAckTS:
TestValidate_AuthTypeNone_NonLoopback_AckPasses (l.722)
TestValidate_Bundle2_PlaceholderAuthSecret_DemoAckExempt (l.1799)
TestValidate_Bundle2_PlaceholderEncryptionKey_DemoAckExempt (l.1832)
TestValidate_Bundle2_CORSWildcard_DemoAckExempt (l.1879)
Each test now sets DemoModeAckTS alongside DemoModeAck=true:
DemoModeAckTS: strconv.FormatInt(time.Now().Unix(), 10)
strconv + time were already imported in config_test.go. Verified
locally: 'go test ./internal/config/... -count=1' passes clean
(0.700s), gofmt clean, go vet clean.
Root cause was the sandbox 'disk-full' constraint that forced
deferring npm install to the operator's workstation — but CI runs
npm ci before any workstation operation. Lockfile-only regen
(this commit) is the right fix; works in low-disk environments
because no node_modules download happens.
|
||
|
|
17455d2ea2 |
deps(web): pin picomatch to >=4.0.4 via npm override; clears 4 dependabot alerts
Dependabot flagged four picomatch vulnerabilities in web/package-lock.json: #8 GHSA-?, ReDoS via extglob quantifiers #9 GHSA-?, ReDoS via extglob quantifiers (related to #8) #10 CVE-2026-33672 / GHSA-3v7f-55p6-f55p, method injection via POSIX character classes (related; affecting < 2.3.2) #11 CVE-2026-33672 / GHSA-3v7f-55p6-f55p, method injection via POSIX character classes — same advisory as #10, separate Dependabot row because it surfaces against a second copy of picomatch in the dep tree All four close on the same fix: every resolved picomatch instance must be >= 4.0.4 (or >= 3.0.2, or >= 2.3.2 — the patch shipped on all three release lines). Pre-fix the lockfile carried at least two vulnerable copies: node_modules/picomatch v2.3.1 (vuln) node_modules/vitest/node_modules/picomatch v4.0.3 (vuln for #11) node_modules/vite/node_modules/picomatch v4.0.4 (ok) node_modules/tinyglobby/node_modules/picomatch v4.0.4 (ok) Reachability check before fixing: - picomatch is a build-time glob-matching tool (used by tailwindcss → readdirp/anymatch/micromatch chain, plus by vite + vitest internals). - All instances in our tree are dev=true. None are bundled into the React production output (web/dist/assets/*.js) — that's just the React SPA, no node_modules at runtime. - The CVE only affects code that processes UNTRUSTED glob patterns. Our build pipeline only globs operator-controlled file patterns (TSX source files, Tailwind 'content' globs). Not network-reachable. So the CVE was not reachable from any shipped certctl artefact. Fix anyway because the alerts are noise. Fix mechanism: add an npm 'overrides' entry pinning picomatch to ^4.0.4 across all consumers. npm collapses every transitive picomatch resolution to the override, so the lockfile shrinks from 4 picomatch entries to 1, all on v4.0.4 (patched). Verification: npm install --package-lock-only → up to date, 0 vuln npm audit → found 0 vulnerabilities Diff: 2 files, 7 insertions / 43 deletions (net negative — the override de-duplicates the picomatch tree). Closes: GHSA-3v7f-55p6-f55p, CVE-2026-33672 (alerts #10, #11) + the two related ReDoS picomatch alerts (#8, #9) |
||
|
|
9bfbac0f97 |
deps(web): upgrade vite ^8.0.0 → ^8.0.10 (3 Dependabot alerts)
Closes Dependabot alerts #12 (CVE — arbitrary file read via Vite dev server WebSocket), #13 (CVE-2026-39364 — server.fs.deny bypassed with ?raw / ?import&raw / ?import&url&inline query suffixes), and #14 (path traversal in optimized-deps .map handling). All three live in the vite DEV server only — vite build (production output) is unaffected. All three share the same advisory range '>= 8.0.0, <= 8.0.4' → fixed in 8.0.5; npm picked the latest 8.x patch (8.0.10). Real-world exposure for certctl was low: web/package.json's 'dev: vite' script has no --host flag, so the default binding is localhost (127.0.0.1). Devs who manually run 'vite --host' for cross-machine testing were exposed to the same-LAN attack vector; this closes it. Manifest change: bumped the constraint from '^8.0.0' to '^8.0.10' to document the security floor in package.json itself (the caret already permitted 8.0.10, but pinning the floor higher prevents an accidental downgrade if a future 'npm install' somehow re-resolves to a vulnerable 8.0.0-8.0.4). Lockfile change: 17 packages removed + 18 changed — mostly transitive vite-internal modules (rolldown, oxc-* etc.) that shifted around between 8.0.0 and 8.0.10. Verified locally: - 'npm install vite@^8.0.5 --save-dev' completed cleanly. - 'vite build' produces the same web/dist/ output (668 modules transformed, 35.30 kB CSS / 918.04 kB JS — same shape as pre- upgrade). - vitest run wasn't completed in the sandbox (test runner hung in the disk-pressure environment); CI will run it on push. Engineering history: this is a cross-cutting deps bump that lives outside the ACME-Server-N phase plan. |
||
|
|
ee75f149ae |
feat: M14 — Observability (dashboard charts, agent fleet, stats API, metrics, structured logging, rollback)
Backend: StatsService with 5 aggregation methods, JSON metrics endpoint, slog-based structured logging middleware. Stats API: dashboard summary, certificates-by-status, expiration timeline, job trends, issuance rate. 23 new backend tests. Frontend: Recharts-powered dashboard with 4 charts (status pie, expiration heatmap, job trends line, issuance bar), agent fleet overview page with OS/arch grouping and version breakdown, deployment rollback buttons on version history. 7 new frontend tests. 78 API endpoints, 744+ total tests (658 Go + 86 Vitest). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
73c6bd1416 |
feat: add frontend action buttons, fix notification auth bug, add 53 Vitest tests
Bug fix: - markNotificationRead was using raw fetch() without auth headers, bypassing the shared client's Authorization header. Moved to api/client.ts to use fetchJSON with proper auth. New action buttons: - CertificatesPage: "New Certificate" modal with form fields - CertificateDetailPage: "Deploy" button with target selector modal, "Archive" button with confirmation - IssuersPage: "Test Connection" and "Delete" per-row actions - TargetsPage: "Delete" per-row action - PoliciesPage: "Enable/Disable" toggle and "Delete" per-row actions New API client functions: - updateCertificate, archiveCertificate, registerAgent, createPolicy, updatePolicy, deletePolicy, getPolicyViolations, createIssuer, testIssuerConnection, deleteIssuer, createTarget, deleteTarget, markNotificationRead Frontend tests (53 tests, 2 files): - client.test.ts: 35 tests covering all API endpoints, auth headers, 401 handling, error parsing, HTTP methods, request bodies - utils.test.ts: 18 tests covering formatDate, formatDateTime, timeAgo, daysUntil, expiryColor CI: Added "Run Frontend Tests" step to frontend-build job Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
9e6756d02f |
Implement M5: hardening, input validation, and Vite+React+TS dashboard
Backend hardening: - Fix 6 nginx.go non-constant format string build errors - Add validation.go with hostname, PEM, and enum validators - Apply input validation to all POST/PUT handlers (certificates, agents, CSR, policies, teams, owners, targets, issuers) - Fix unchecked JSON decode in TriggerDeployment handler Frontend (Vite + React + TypeScript): - Migrate from single-file SPA to proper build pipeline - 7 pages: Dashboard, Certificates (list+detail), Agents, Jobs, Notifications, Policies, Audit Trail - TanStack Query for server state with auto-refetch intervals - Certificate detail with version history and renewal trigger - Job cancellation, status/type filtering, expiry countdowns - Reusable components: DataTable, StatusBadge, ErrorState, PageHeader - Dark theme with Tailwind CSS, sidebar nav via React Router Server integration: - Go server serves web/dist/ (Vite output) with SPA fallback - Falls back to web/index.html for legacy mode - .gitignore updated for web/node_modules/ and web/dist/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |