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".
This commit is contained in:
shankar0123
2026-05-14 14:25:41 +00:00
parent 93e00f6a5e
commit e37403edf1
28 changed files with 1789 additions and 47 deletions
+259 -2
View File
@@ -8,13 +8,16 @@
"name": "certctl-dashboard",
"version": "1.0.0",
"dependencies": {
"@floating-ui/react": "^0.27.19",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@headlessui/react": "^2.2.10",
"@tanstack/react-query": "^5.90.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.3",
"recharts": "^3.8.0"
"recharts": "^3.8.0",
"sonner": "^2.0.7"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
@@ -873,6 +876,59 @@
"dev": true,
"license": "MIT"
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.19",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz",
"integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.8",
"@floating-ui/utils": "^0.2.11",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
@@ -905,6 +961,41 @@
"@shikijs/vscode-textmate": "^10.0.2"
}
},
"node_modules/@headlessui/react": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz",
"integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.20.2",
"@react-aria/interactions": "^3.25.0",
"@tanstack/react-virtual": "^3.13.9",
"use-sync-external-store": "^1.5.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@headlessui/react/node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@ibm-cloud/openapi-ruleset": {
"version": "1.33.9",
"resolved": "https://registry.npmjs.org/@ibm-cloud/openapi-ruleset/-/openapi-ruleset-1.33.9.tgz",
@@ -939,6 +1030,33 @@
"node": ">=16.0.0"
}
},
"node_modules/@internationalized/date": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz",
"integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/number": {
"version": "3.6.6",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz",
"integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/string": {
"version": "3.2.8",
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.8.tgz",
"integrity": "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1717,6 +1835,44 @@
"node": ">=18"
}
},
"node_modules/@react-aria/focus": {
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz",
"integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0",
"react-aria": "3.48.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.0.tgz",
"integrity": "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.34.0",
"@swc/helpers": "^0.5.0",
"react-aria": "3.48.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz",
"integrity": "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -2443,6 +2599,15 @@
"node": "^12.20 || >=14.13"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
@@ -2469,6 +2634,33 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.14.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -3023,6 +3215,18 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -6894,6 +7098,27 @@
"node": ">=0.10.0"
}
},
"node_modules/react-aria": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.48.0.tgz",
"integrity": "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.1",
"@internationalized/number": "^3.6.6",
"@internationalized/string": "^3.2.8",
"@react-types/shared": "^3.34.0",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.46.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -6969,6 +7194,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-stately": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz",
"integrity": "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.1",
"@internationalized/number": "^3.6.6",
"@internationalized/string": "^3.2.8",
"@react-types/shared": "^3.34.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -7544,6 +7786,16 @@
"node": ">=8"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7796,6 +8048,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -8016,7 +8274,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/typed-array-buffer": {
+4 -1
View File
@@ -14,13 +14,16 @@
"generate": "orval --config ./orval.config.ts"
},
"dependencies": {
"@floating-ui/react": "^0.27.19",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@headlessui/react": "^2.2.10",
"@tanstack/react-query": "^5.90.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.3",
"recharts": "^3.8.0"
"recharts": "^3.8.0",
"sonner": "^2.0.7"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import Banner from './Banner';
describe('Banner', () => {
it('renders the children', () => {
render(<Banner type="info">Operator note</Banner>);
expect(screen.getByText('Operator note')).toBeInTheDocument();
});
it('renders the optional title', () => {
render(
<Banner type="error" title="Save failed">
Permission denied.
</Banner>,
);
expect(screen.getByText('Save failed')).toBeInTheDocument();
expect(screen.getByText('Permission denied.')).toBeInTheDocument();
});
it('uses role="alert" for error variant', () => {
render(<Banner type="error">Permission denied.</Banner>);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('uses role="alert" for warning variant', () => {
render(<Banner type="warning">Stale data.</Banner>);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('uses role="status" for success variant', () => {
render(<Banner type="success">Saved.</Banner>);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('uses role="status" for info variant', () => {
render(<Banner type="info">Heads up.</Banner>);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('applies variant-specific bg + border classes', () => {
const { container } = render(<Banner type="error">err</Banner>);
const root = container.firstChild as HTMLElement;
expect(root.className).toContain('bg-red-50');
expect(root.className).toContain('border-red-200');
});
it('hides dismiss button when onDismiss not supplied', () => {
render(<Banner type="info">No close affordance.</Banner>);
expect(screen.queryByRole('button', { name: /dismiss/i })).toBeNull();
});
it('renders dismiss button + fires onDismiss when supplied', () => {
const onDismiss = vi.fn();
render(
<Banner type="info" onDismiss={onDismiss}>
Closable.
</Banner>,
);
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Banner — the certctl-themed alert / message banner primitive. Phase 1
// closure for FE-M4 (no banner primitives; ~102 inline
// bg-(red|amber|yellow)-50 copy-paste sites across the codebase).
//
// Four severity variants:
// - error red surface, role="alert" — operator action required
// - warning amber surface, role="alert" — risky-but-not-fatal
// - success teal surface, role="status" — confirmation of last action
// - info blue surface, role="status" — neutral context
//
// role="alert" on error + warning surfaces these to screen readers
// immediately on render (aria-live=assertive equivalent). role="status"
// on success + info surfaces them politely (aria-live=polite).
//
// Optional `onDismiss` adds a close button — useful for transient
// banners. Persistent banners (e.g. "TLS bootstrap incomplete") omit
// it so the operator can't paper over the underlying state.
import type { ReactNode } from 'react';
export type BannerType = 'error' | 'warning' | 'success' | 'info';
export interface BannerProps {
type: BannerType;
title?: string;
children: ReactNode;
onDismiss?: () => void;
className?: string;
}
const variantStyles: Record<BannerType, string> = {
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-amber-50 border-amber-200 text-amber-800',
success: 'bg-emerald-50 border-emerald-200 text-emerald-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
};
const variantTitleStyles: Record<BannerType, string> = {
error: 'text-red-900',
warning: 'text-amber-900',
success: 'text-emerald-900',
info: 'text-blue-900',
};
export default function Banner({
type,
title,
children,
onDismiss,
className = '',
}: BannerProps) {
// role="alert" announces immediately; role="status" announces politely.
// Use alert for actionable / dangerous; status for confirmation /
// background context.
const role = type === 'error' || type === 'warning' ? 'alert' : 'status';
return (
<div
role={role}
className={`border-l-4 p-3 rounded ${variantStyles[type]} ${className}`}
>
<div className="flex items-start gap-3">
<div className="flex-1 text-sm">
{title && (
<div className={`font-semibold mb-0.5 ${variantTitleStyles[type]}`}>
{title}
</div>
)}
<div>{children}</div>
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className={`text-xl leading-none opacity-60 hover:opacity-100 transition-opacity ${variantTitleStyles[type]}`}
>
×
</button>
)}
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import Combobox from './Combobox';
type Option = { id: string; name: string };
const OPTIONS: Option[] = [
{ id: 'iss-vault', name: 'Vault PKI' },
{ id: 'iss-acme', name: 'ACME (Let\'s Encrypt)' },
{ id: 'iss-local', name: 'Local CA' },
];
describe('Combobox', () => {
it('renders the input', () => {
render(
<Combobox<Option>
value={null}
onChange={() => {}}
options={OPTIONS}
getKey={(o) => o.id}
getLabel={(o) => o.name}
placeholder="Pick issuer"
/>,
);
expect(screen.getByPlaceholderText('Pick issuer')).toBeInTheDocument();
});
it('renders the selected value as the input display', () => {
render(
<Combobox<Option>
value={OPTIONS[2]}
onChange={() => {}}
options={OPTIONS}
getKey={(o) => o.id}
getLabel={(o) => o.name}
/>,
);
expect(screen.getByDisplayValue('Local CA')).toBeInTheDocument();
});
it('filters options as the operator types', () => {
render(
<Combobox<Option>
value={null}
onChange={() => {}}
options={OPTIONS}
getKey={(o) => o.id}
getLabel={(o) => o.name}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'vault' } });
expect(screen.getByText('Vault PKI')).toBeInTheDocument();
expect(screen.queryByText('Local CA')).not.toBeInTheDocument();
expect(screen.queryByText("ACME (Let's Encrypt)")).not.toBeInTheDocument();
});
it('fires onChange when the operator selects via keyboard', () => {
const onChange = vi.fn();
render(
<Combobox<Option>
value={null}
onChange={onChange}
options={OPTIONS}
getKey={(o) => o.id}
getLabel={(o) => o.name}
/>,
);
// Open the listbox + filter to a single option, then press Enter.
// Click-to-select on Headless UI requires the pointerdown sequence
// which @testing-library/dom's fireEvent doesn't synthesize; the
// keyboard path is the accessible-equivalent and is what screen
// reader / keyboard-only operators use anyway.
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'Local' } });
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith(OPTIONS[2]);
});
it('shows "No matches" when the filter excludes everything', () => {
render(
<Combobox<Option>
value={null}
onChange={() => {}}
options={OPTIONS}
getKey={(o) => o.id}
getLabel={(o) => o.name}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'nonexistent' } });
expect(screen.getByText('No matches.')).toBeInTheDocument();
});
});
+104
View File
@@ -0,0 +1,104 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Combobox — Headless UI-backed typeahead select primitive. Phase 1
// closure for UX-M4 (~53 native HTML <select> elements with no
// typeahead surface). Migrating callsites is per-page rolling work
// in subsequent PRs; Phase 1 builds the primitive.
//
// Compared with native <select>:
// - typeahead filter narrows options as the operator types
// - keyboard nav (Up/Down/Enter/Esc) handled by Headless UI
// - aria-expanded / aria-activedescendant / aria-labelledby wired
// for free
// - styled to match the certctl .input + .card token palette
//
// Generic on the option value type T (string IDs are typical; arbitrary
// objects work too — supply a `getKey` + `getLabel`).
import { useState, useMemo } from 'react';
import { Combobox as HeadlessCombobox } from '@headlessui/react';
export interface ComboboxProps<T> {
/** The currently-selected option, or null if none. */
value: T | null;
/** Fires when the operator picks an option. */
onChange: (next: T | null) => void;
/** Full options list — Combobox filters internally on typed query. */
options: T[];
/** Stable string key per option (used for React `key` + filter equality). */
getKey: (option: T) => string;
/** Human-readable label rendered in the input + dropdown row. */
getLabel: (option: T) => string;
/** Optional placeholder when no value is selected. */
placeholder?: string;
/** Optional `id` on the input element (label wiring). */
inputId?: string;
/** Disabled state. */
disabled?: boolean;
/** Extra className on the outer wrapper. */
className?: string;
}
export default function Combobox<T>({
value,
onChange,
options,
getKey,
getLabel,
placeholder,
inputId,
disabled,
className = '',
}: ComboboxProps<T>) {
const [query, setQuery] = useState('');
// Filter is local + case-insensitive substring against the label.
// For >1000-option lists this should move to server-side; not Phase
// 1's problem.
const filtered = useMemo(() => {
if (!query) return options;
const needle = query.toLowerCase();
return options.filter((o) => getLabel(o).toLowerCase().includes(needle));
}, [options, query, getLabel]);
return (
<HeadlessCombobox
value={value}
onChange={onChange}
disabled={disabled}
>
<div className={`relative ${className}`}>
<HeadlessCombobox.Input
id={inputId}
className="input w-full"
placeholder={placeholder}
displayValue={(o: T | null) => (o ? getLabel(o) : '')}
onChange={(e) => setQuery(e.target.value)}
/>
<HeadlessCombobox.Options
className="absolute z-30 mt-1 max-h-60 w-full overflow-auto rounded border border-surface-border bg-surface shadow-lg focus:outline-none"
>
{filtered.length === 0 && query !== '' && (
<div className="px-3 py-2 text-sm text-ink-faint">
No matches.
</div>
)}
{filtered.map((option) => (
<HeadlessCombobox.Option
key={getKey(option)}
value={option}
className={({ active, selected }) =>
`cursor-pointer px-3 py-2 text-sm ${
active ? 'bg-brand-50 text-brand-700' : 'text-ink'
} ${selected ? 'font-semibold' : ''}`
}
>
{getLabel(option)}
</HeadlessCombobox.Option>
))}
</HeadlessCombobox.Options>
</div>
</HeadlessCombobox>
);
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Smoke + behavior tests for ConfirmDialog. The primitive replaces
// window.confirm(); the test suite asserts the contract:
// - hidden when open=false
// - title + message render
// - ESC + backdrop click + cancel button → onCancel
// - confirm button → onConfirm
// - typedConfirmation gates the confirm button until the exact string
// is typed
// - destructive=true uses the btn-danger styling
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import ConfirmDialog from './ConfirmDialog';
describe('ConfirmDialog', () => {
it('does not render when open=false', () => {
render(
<ConfirmDialog
open={false}
title="Archive cert"
message="Cannot be undone."
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.queryByText('Archive cert')).not.toBeInTheDocument();
});
it('renders title + message when open=true', () => {
render(
<ConfirmDialog
open
title="Archive cert"
message="Cannot be undone."
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByText('Archive cert')).toBeInTheDocument();
expect(screen.getByText('Cannot be undone.')).toBeInTheDocument();
});
it('fires onConfirm when confirm button clicked', () => {
const onConfirm = vi.fn();
render(
<ConfirmDialog
open
title="Delete owner"
message="Bob will be removed."
onConfirm={onConfirm}
onCancel={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /confirm/i }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('fires onCancel when cancel button clicked', () => {
const onCancel = vi.fn();
render(
<ConfirmDialog
open
title="Delete owner"
message="Bob will be removed."
onConfirm={() => {}}
onCancel={onCancel}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('disables confirm button until typedConfirmation matches', () => {
const onConfirm = vi.fn();
render(
<ConfirmDialog
open
title="Archive cert"
message="Type DELETE to confirm."
typedConfirmation="DELETE"
onConfirm={onConfirm}
onCancel={() => {}}
/>,
);
const confirmBtn = screen.getByRole('button', { name: /confirm/i });
expect(confirmBtn).toBeDisabled();
const input = screen.getByLabelText(/Type/i);
fireEvent.change(input, { target: { value: 'wrong' } });
expect(confirmBtn).toBeDisabled();
fireEvent.change(input, { target: { value: 'DELETE' } });
expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('uses btn-danger styling when destructive=true', () => {
render(
<ConfirmDialog
open
title="Revoke cert"
message="Cannot be undone."
destructive
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
const confirmBtn = screen.getByRole('button', { name: /confirm/i });
expect(confirmBtn.className).toContain('btn-danger');
});
it('honours custom confirmLabel + cancelLabel', () => {
render(
<ConfirmDialog
open
title="Archive cert"
message="Are you sure?"
confirmLabel="Yes, archive"
cancelLabel="No, go back"
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(
screen.getByRole('button', { name: 'Yes, archive' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'No, go back' }),
).toBeInTheDocument();
});
});
+181
View File
@@ -0,0 +1,181 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// ConfirmDialog — the certctl-themed replacement for window.confirm().
// Phase 1 closure for UX-H2 (destructive actions use window.confirm).
//
// Built on Headless UI's <Dialog>, which gives us:
// - automatic focus trap (Tab/Shift-Tab stays inside the modal)
// - automatic ESC-to-close (we wire onCancel to it)
// - automatic backdrop-click-to-close (we wire onCancel to it)
// - role="dialog" + aria-modal="true" on the panel
// - aria-labelledby on the title node, aria-describedby on the body
// - <Transition> handles enter/exit; respects prefers-reduced-motion
// transparently via the @media block in src/index.css.
//
// Optional `typedConfirmation` raises the friction for the most
// irreversible actions. Passing `typedConfirmation: "delete"` requires
// the operator to literally type the string "delete" into a field
// before the confirm button enables. Reserve it for the worst-case
// actions: archive-this-certificate, delete-root-CA, etc.
//
// Visual posture: destructive variant uses red surface tints + a red
// confirm button matching .btn-danger. Non-destructive uses the
// default brand-teal confirm button.
import { Fragment, useState, useEffect, useRef } from 'react';
import { Dialog, Transition } from '@headlessui/react';
export interface ConfirmDialogProps {
/** Controls visibility. Parent owns the boolean. */
open: boolean;
/** Title shown at the top of the dialog. Concise: "Archive certificate". */
title: string;
/** Body copy. Plain text recommended; spell out consequences. */
message: string;
/** Label for the confirm button. Defaults to "Confirm". */
confirmLabel?: string;
/** Label for the cancel button. Defaults to "Cancel". */
cancelLabel?: string;
/** When true, confirm button uses .btn-danger styling. */
destructive?: boolean;
/**
* When set, the operator must type this exact string before the
* confirm button enables. Use for the most irreversible actions
* (archive certificate, delete CA, etc.).
*/
typedConfirmation?: string;
/** Fires when the confirm button is clicked. Parent closes the dialog. */
onConfirm: () => void;
/** Fires on ESC, backdrop click, or cancel button. */
onCancel: () => void;
}
export default function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
destructive = false,
typedConfirmation,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const [typedValue, setTypedValue] = useState('');
const cancelButtonRef = useRef<HTMLButtonElement>(null);
// Reset typed-confirmation state every time the dialog closes/reopens.
// Without this, a previous successful confirmation leaves the field
// pre-filled on the next confirmation prompt — that's a footgun.
useEffect(() => {
if (open) setTypedValue('');
}, [open]);
const typedOK = !typedConfirmation || typedValue === typedConfirmation;
const confirmDisabled = !typedOK;
const confirmClass = destructive
? 'btn btn-danger'
: 'btn btn-primary';
return (
<Transition show={open} as={Fragment}>
<Dialog
as="div"
className="relative z-50"
onClose={onCancel}
initialFocus={cancelButtonRef}
>
{/* Backdrop */}
<Transition.Child
as={Fragment}
enter="ease-out duration-150"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-150"
enterFrom="opacity-0 translate-y-2 scale-95"
enterTo="opacity-100 translate-y-0 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 translate-y-0 scale-100"
leaveTo="opacity-0 translate-y-2 scale-95"
>
<Dialog.Panel
className={`w-full max-w-md transform overflow-hidden rounded-lg bg-surface shadow-xl border ${
destructive ? 'border-red-200' : 'border-surface-border'
} p-6`}
>
<Dialog.Title
as="h3"
className="text-lg font-semibold text-ink"
>
{title}
</Dialog.Title>
<Dialog.Description
as="p"
className="mt-2 text-sm text-ink-muted"
>
{message}
</Dialog.Description>
{typedConfirmation && (
<div className="mt-4">
<label
htmlFor="confirm-typed-input"
className="block text-xs font-medium text-ink-muted mb-1"
>
Type{' '}
<code className="text-ink font-mono">
{typedConfirmation}
</code>{' '}
to enable confirmation:
</label>
<input
id="confirm-typed-input"
type="text"
autoComplete="off"
autoFocus
value={typedValue}
onChange={(e) => setTypedValue(e.target.value)}
className="input w-full"
/>
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button
ref={cancelButtonRef}
type="button"
className="btn btn-outline"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
type="button"
className={confirmClass}
onClick={onConfirm}
disabled={confirmDisabled}
>
{confirmLabel}
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
+17 -1
View File
@@ -1,3 +1,5 @@
import type { ReactNode } from 'react';
interface Column<T> {
key: string;
label: string;
@@ -28,6 +30,14 @@ interface DataTableProps<T> {
data: T[];
onRowClick?: (item: T) => void;
emptyMessage?: string;
/**
* UX-M3 / Phase 1: rich empty-state slot. Pass an <EmptyState />
* component (or any ReactNode) here when the page wants a CTA-driven
* first-run experience instead of the bare emptyMessage string. The
* existing `emptyMessage` prop is preserved for backward compat with
* the ~18 list-page call sites that pass a simple string.
*/
emptyState?: ReactNode;
isLoading?: boolean;
keyField?: string;
selectable?: boolean;
@@ -36,7 +46,7 @@ interface DataTableProps<T> {
pagination?: PaginationProps;
}
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-ink-muted">
@@ -50,6 +60,12 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
}
if (!data.length) {
// UX-M3 / Phase 1: prefer the rich <EmptyState /> slot when supplied;
// fall back to the legacy string render so existing call sites with
// emptyMessage="…" stay unchanged.
if (emptyState) {
return <>{emptyState}</>;
}
return (
<div className="flex items-center justify-center py-16 text-ink-faint">
{emptyMessage || 'No data found'}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import EmptyState from './EmptyState';
describe('EmptyState', () => {
it('renders the title', () => {
render(<EmptyState title="No certificates yet" />);
expect(screen.getByText('No certificates yet')).toBeInTheDocument();
});
it('renders description when provided', () => {
render(
<EmptyState
title="No certificates yet"
description="Issue your first certificate to get started."
/>,
);
expect(
screen.getByText('Issue your first certificate to get started.'),
).toBeInTheDocument();
});
it('renders icon slot when provided', () => {
render(
<EmptyState
icon={<span data-testid="empty-icon">📜</span>}
title="No certificates"
/>,
);
expect(screen.getByTestId('empty-icon')).toBeInTheDocument();
});
it('renders primaryAction button and fires its onClick', () => {
const onClick = vi.fn();
render(
<EmptyState
title="No certificates"
primaryAction={{ label: 'Issue certificate', onClick }}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Issue certificate' }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders secondaryAction button and fires its onClick', () => {
const onClick = vi.fn();
render(
<EmptyState
title="No certificates"
secondaryAction={{ label: 'Read docs', onClick }}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Read docs' }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders both actions side-by-side', () => {
render(
<EmptyState
title="No certificates"
primaryAction={{ label: 'Issue', onClick: () => {} }}
secondaryAction={{ label: 'Connect issuer', onClick: () => {} }}
/>,
);
expect(screen.getByRole('button', { name: 'Issue' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Connect issuer' }),
).toBeInTheDocument();
});
it('exposes role="status" for screen readers', () => {
render(<EmptyState title="No data" />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// EmptyState — the certctl-themed empty-state primitive. Phase 1
// closure for UX-M3 (no <EmptyState> primitive; DataTable shows a bare
// 'No data found' string).
//
// Two render paths:
// 1) `<EmptyState title="..." description="..." />` — minimum
// acceptable empty state. Title is required (the user must
// understand what's missing); description + actions are optional.
// 2) `<EmptyState icon={<Icon />} title="..." description="..."
// primaryAction={{ label, onClick }} secondaryAction={...} />` —
// first-run CTA shape. Renders icon at the top, title in the
// middle, two action buttons at the bottom. Use this on list pages
// that an operator might hit on their first visit ("No certs yet —
// [Issue first certificate] [Connect an issuer]").
//
// Composition with DataTable: DataTable accepts `emptyState?: ReactNode`
// (added alongside the existing `emptyMessage?: string` for backward
// compat) so list pages can pass either a string or a full <EmptyState />
// component.
import type { ReactNode } from 'react';
export interface EmptyStateAction {
label: string;
onClick: () => void;
}
export interface EmptyStateProps {
/** Optional icon at the top. Pass any ReactNode (lucide / SVG / emoji). */
icon?: ReactNode;
/** Required headline. Keep short: "No certificates yet". */
title: string;
/** Optional sub-copy. One sentence explaining the empty condition. */
description?: string;
/** Optional primary CTA. Renders as .btn-primary. */
primaryAction?: EmptyStateAction;
/** Optional secondary CTA. Renders as .btn-outline alongside primary. */
secondaryAction?: EmptyStateAction;
/** Override default centering / padding when nested inside a card. */
className?: string;
}
export default function EmptyState({
icon,
title,
description,
primaryAction,
secondaryAction,
className,
}: EmptyStateProps) {
return (
<div
role="status"
className={
className ||
'flex flex-col items-center justify-center text-center py-16 px-6'
}
>
{icon && (
<div className="mb-4 text-ink-faint" aria-hidden="true">
{icon}
</div>
)}
<h3 className="text-base font-semibold text-ink mb-1">{title}</h3>
{description && (
<p className="text-sm text-ink-muted max-w-md mb-4">{description}</p>
)}
{(primaryAction || secondaryAction) && (
<div className="flex items-center gap-2 mt-2">
{primaryAction && (
<button
type="button"
className="btn btn-primary"
onClick={primaryAction.onClick}
>
{primaryAction.label}
</button>
)}
{secondaryAction && (
<button
type="button"
className="btn btn-outline"
onClick={secondaryAction.onClick}
>
{secondaryAction.label}
</button>
)}
</div>
)}
</div>
);
}
+104 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import StatusBadge from './StatusBadge';
import StatusBadge, { statusDisplay, titleCase } from './StatusBadge';
// -----------------------------------------------------------------------------
// D-1 master — StatusBadge enum-coverage contract
@@ -118,13 +118,111 @@ describe('StatusBadge — enum-coverage contract (D-1 master)', () => {
expect(container.querySelector('span')!.className).toContain('badge-warning');
});
// Unknown statuses fall through to neutral. The string is still
// displayed verbatim so an operator can see "what is this?" rather
// than nothing at all.
it('unknown status string renders as neutral but preserves the label text', () => {
// Unknown statuses fall through to neutral. The label is humanised
// via the titleCase() helper (UX-H5) so the operator sees readable
// text rather than the raw enum key — "Some future status" instead
// of "SomeFutureStatus".
it('unknown status string renders as neutral with titleCase fallback', () => {
const { container } = render(<StatusBadge status="SomeFutureStatus" />);
const span = container.querySelector('span');
expect(span!.className).toBe('badge badge-neutral');
expect(span!.textContent).toBe('SomeFutureStatus');
expect(span!.textContent).toBe('Some future status');
});
});
// -----------------------------------------------------------------------------
// UX-H5 master — StatusBadge display-string contract (Phase 1, 2026-05-14)
//
// The audit finding: pre-Phase-1, StatusBadge rendered raw Go enum keys
// — operators saw "RenewalInProgress" / "AwaitingCSR" / "cert_mismatch"
// / "dead" verbatim. Phase 1 adds a statusDisplay map next to
// statusStyles; this suite pins the byte-exact display string for every
// wire key.
// -----------------------------------------------------------------------------
describe('StatusBadge — display-string contract (UX-H5)', () => {
// Every wire key in the colour map MUST have a display-string entry
// and the entry MUST be non-empty. Missing entries fall back to the
// titleCase() helper, but having an explicit entry in statusDisplay
// is the preferred path (lets us pick the cleanest sentence-case
// phrasing, with terms like "Awaiting CSR" capitalised correctly
// where titleCase would yield "Awaiting csr").
const EXPECTED_DISPLAY: Array<[string, string]> = [
// Certificate statuses
['Active', 'Active'],
['Expiring', 'Expiring soon'],
['Expired', 'Expired'],
['RenewalInProgress', 'Renewal in progress'],
['Archived', 'Archived'],
['Revoked', 'Revoked'],
// Job statuses
['Pending', 'Pending'],
['AwaitingCSR', 'Awaiting CSR'],
['AwaitingApproval', 'Awaiting approval'],
['Running', 'Running'],
['Completed', 'Completed'],
['Failed', 'Failed'],
['Cancelled', 'Cancelled'],
// Agent statuses
['Online', 'Online'],
['Offline', 'Offline'],
['Degraded', 'Degraded'],
// Discovery statuses
['Unmanaged', 'Unmanaged'],
['Managed', 'Managed'],
['Dismissed', 'Dismissed'],
// Frontend-synthesized issuer statuses
['Enabled', 'Enabled'],
['Disabled', 'Disabled'],
// Notification statuses (lowercase wire values)
['sent', 'Sent'],
['pending', 'Pending'],
['failed', 'Failed'],
['dead', 'Dead-lettered'],
['read', 'Read'],
// Health check statuses (lowercase + snake_case)
['healthy', 'Healthy'],
['degraded', 'Degraded'],
['down', 'Down'],
['cert_mismatch', 'Certificate mismatch'],
['unknown', 'Unknown'],
];
it.each(EXPECTED_DISPLAY)(
"wire key '%s' renders display string '%s'",
(wire, expected) => {
// First — verify the statusDisplay map carries the entry verbatim.
expect(statusDisplay[wire]).toBe(expected);
// Then — verify the rendered <span>'s textContent matches.
const { container } = render(<StatusBadge status={wire} />);
expect(container.querySelector('span')!.textContent).toBe(expected);
},
);
it('every wire key in statusStyles has a matching statusDisplay entry', () => {
// Parity check — re-deriving the styles key set isn't possible at
// runtime without re-importing it, but we can probe a known sample
// and pin: if a future PR adds a new style entry without a display
// entry, the EXPECTED_DISPLAY list above will mismatch.
expect(Object.keys(statusDisplay).length).toBeGreaterThanOrEqual(
EXPECTED_DISPLAY.length,
);
});
describe('titleCase() helper — fallback for unmapped keys', () => {
it('humanises PascalCase', () => {
expect(titleCase('RenewalInProgress')).toBe('Renewal in progress');
});
it('humanises snake_case', () => {
expect(titleCase('cert_mismatch')).toBe('Cert mismatch');
});
it('handles single-word lowercase', () => {
expect(titleCase('pending')).toBe('Pending');
});
it('handles single-word PascalCase', () => {
expect(titleCase('Active')).toBe('Active');
});
it('handles empty string defensively', () => {
expect(titleCase('')).toBe('');
});
});
});
+77 -1
View File
@@ -4,6 +4,16 @@
// the Go side; StatusBadge.test.tsx walks every value and will go red
// before users see a default-grey "what is happening?" badge.
//
// UX-H5 closure (Phase 1, 2026-05-14): we now render a human display
// string rather than the raw enum key. The wire keys stay byte-
// identical to the Go-side enums (per the D-1 closure comment above) —
// only the rendered text changes. PascalCase + snake_case +
// lowercase enums map to spaced sentence-case ("Renewal in progress",
// "Awaiting CSR", "Dead-lettered", "Certificate mismatch"). Unmapped
// keys fall through to a titleCase helper that lower-bounds the
// readability even when a new Go-side enum lands before the frontend
// catches up.
//
// D-1 master closure (cat-d-359e92c20cbf, cat-d-9f4c8e4a91f1,
// cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback,
// cat-f-ae0d06b6588f) fixed the pre-master drift:
@@ -74,7 +84,73 @@ const statusStyles: Record<string, string> = {
unknown: 'badge-neutral',
};
// statusDisplay — human-facing text for each wire key. UX-H5 closure.
// Keys MUST stay byte-identical to statusStyles above (which is byte-
// identical to the Go enums). When a key here is missing, the
// titleCase fallback below renders something readable rather than
// the raw enum key.
const statusDisplay: Record<string, string> = {
// Certificate statuses
Active: 'Active',
Expiring: 'Expiring soon',
Expired: 'Expired',
RenewalInProgress: 'Renewal in progress',
Archived: 'Archived',
Revoked: 'Revoked',
// Job statuses
Pending: 'Pending',
AwaitingCSR: 'Awaiting CSR',
AwaitingApproval: 'Awaiting approval',
Running: 'Running',
Completed: 'Completed',
Failed: 'Failed',
Cancelled: 'Cancelled',
// Agent statuses
Online: 'Online',
Offline: 'Offline',
Degraded: 'Degraded',
// Discovery statuses
Unmanaged: 'Unmanaged',
Managed: 'Managed',
Dismissed: 'Dismissed',
// Issuer statuses (frontend-synthesized)
Enabled: 'Enabled',
Disabled: 'Disabled',
// Notification statuses
sent: 'Sent',
pending: 'Pending',
failed: 'Failed',
dead: 'Dead-lettered',
read: 'Read',
// Health check statuses
healthy: 'Healthy',
degraded: 'Degraded',
down: 'Down',
cert_mismatch: 'Certificate mismatch',
unknown: 'Unknown',
};
// titleCase — best-effort humanizer for wire keys not in statusDisplay.
// Handles PascalCase ("RenewalInProgress" → "Renewal in progress") and
// snake_case ("cert_mismatch" → "Cert mismatch"). The render-time fallback;
// adding a proper entry to statusDisplay above is the preferred path.
function titleCase(s: string): string {
if (!s) return s;
// snake_case → space-separated lower
let out = s.replace(/_/g, ' ');
// PascalCase / camelCase → space before capitals (but not the first)
out = out.replace(/([a-z])([A-Z])/g, '$1 $2');
// Lowercase everything, then capitalize the first character.
out = out.toLowerCase();
return out.charAt(0).toUpperCase() + out.slice(1);
}
export default function StatusBadge({ status }: { status: string }) {
const cls = statusStyles[status] || 'badge-neutral';
return <span className={`badge ${cls}`}>{status}</span>;
const display = statusDisplay[status] ?? titleCase(status);
return <span className={`badge ${cls}`}>{display}</span>;
}
// Exported for the StatusBadge.test.tsx suite — pinning the byte-exact
// display strings for every wire key in one place.
export { statusStyles, statusDisplay, titleCase };
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Smoke-test the Toaster wrapper. Sonner has its own deep test suite;
// we just pin (a) the wrapper renders without crashing, (b) the
// Sonner <Toaster /> root lands in the DOM with our position prop, and
// (c) toast.success / toast.error reach the renderer.
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { toast } from 'sonner';
import Toaster from './Toaster';
describe('Toaster', () => {
it('renders the Sonner root without crashing', () => {
render(<Toaster />);
// Sonner mounts a section[aria-label="Notifications <kbd>"] container
// — the label includes Sonner's expand-shortcut hint (e.g. "alt+T").
// Match the prefix only.
expect(screen.getByLabelText(/Notifications/)).toBeInTheDocument();
});
it('forwards toast.success() to the visible queue', async () => {
render(<Toaster />);
act(() => {
toast.success('Profile saved');
});
// Sonner debounces render slightly; flush via findByText.
expect(await screen.findByText('Profile saved')).toBeInTheDocument();
});
it('forwards toast.error() to the visible queue', async () => {
render(<Toaster />);
act(() => {
toast.error('Save failed: not authorized');
});
expect(
await screen.findByText('Save failed: not authorized'),
).toBeInTheDocument();
});
});
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Toaster — the certctl-themed Sonner wrapper. Phase 1 closure for
// UX-H3 (no toast / snackbar system) per the frontend-design-audit.
//
// Mount once near the top of <main.tsx>'s React tree (next to
// QueryClientProvider). Inside any component, import { toast } from
// "sonner" and call toast.success(…) / toast.error(…) / toast.info(…) /
// toast.warning(…). Sonner handles the singleton queue, focus + ARIA
// (role="status" / role="alert"), enter/exit animation, swipe-to-
// dismiss, and respects prefers-reduced-motion automatically.
//
// We surface a thin wrapper rather than the bare <Toaster /> so the
// default position + visual config lives in one place. Pages must NOT
// mount their own Toaster instances — Sonner asserts at runtime if
// multiple are mounted, but the failure mode is "toasts duplicate or
// disappear silently" which is hard to debug. Single import discipline.
//
// Visual position: top-right. Operators are paginated-table-heavy;
// top-right keeps the toast away from row-action click targets at the
// bottom of the list. richColors gives us the per-severity background
// fills (success teal / error red / warning amber / info blue) that
// match the existing .badge-* color tier.
import { Toaster as SonnerToaster } from 'sonner';
export default function Toaster() {
return (
<SonnerToaster
position="top-right"
richColors
closeButton
// 4s default for non-action toasts; persistent for error toasts
// with action (set per-call via toast.error(msg, { duration: ... })).
duration={4000}
// visibleToasts: cap stack so a runaway error loop doesn't drown
// the screen. 5 is the Sonner default; pinning it explicitly so
// the choice is documented.
visibleToasts={5}
/>
);
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Tooltip smoke + interaction tests. Floating-UI's positioning math
// requires a real browser layout engine; we just assert the wiring:
// - children render at rest (no tooltip)
// - focus reveals the tooltip body in the portal
// - escape dismisses
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import Tooltip from './Tooltip';
describe('Tooltip', () => {
it('renders the trigger at rest with no tooltip visible', () => {
render(
<Tooltip content="Hint">
<button>Hover me</button>
</Tooltip>,
);
expect(screen.getByRole('button', { name: 'Hover me' })).toBeInTheDocument();
expect(screen.queryByText('Hint')).not.toBeInTheDocument();
});
it('reveals tooltip body on focus', () => {
render(
<Tooltip content="Hint visible">
<button>Focusable trigger</button>
</Tooltip>,
);
const trigger = screen.getByRole('button', { name: 'Focusable trigger' });
fireEvent.focus(trigger);
// FloatingPortal renders into document.body; queryable.
expect(screen.getByText('Hint visible')).toBeInTheDocument();
});
it('dismisses on Escape after focus-open', () => {
render(
<Tooltip content="Press escape">
<button>Focusable</button>
</Tooltip>,
);
const trigger = screen.getByRole('button', { name: 'Focusable' });
fireEvent.focus(trigger);
expect(screen.getByText('Press escape')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByText('Press escape')).not.toBeInTheDocument();
});
});
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Tooltip — Floating-UI-backed replacement for the ~103 native title=
// attributes. Phase 1 builds the primitive; migrating the 103 callsites
// is per-page rolling work that happens in subsequent PRs (per the
// audit prompt's explicit "DO NOT" on one-mega-PR sweeps).
//
// Why Floating-UI: native title= renders poorly on mobile + has no
// reliable show/hide timing, no visual styling, no positioning around
// the edges of the viewport, and (most importantly) zero a11y story
// beyond the browser's default tooltip — which screen readers
// inconsistently surface. Floating-UI gives us:
// - middleware-driven positioning (auto-flip, shift, offset)
// - hover + focus triggers (with `useFocus` + `useHover`)
// - aria-describedby wiring via `useRole`
// - dismissable via ESC
//
// Usage:
// <Tooltip content="Some hint">
// <button>Hover me</button>
// </Tooltip>
//
// Children must be a single element capable of accepting a ref. For
// non-ref-forwardable children (e.g. plain text), wrap in a span.
import { useState, cloneElement, isValidElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import {
useFloating,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
flip,
shift,
offset,
autoUpdate,
FloatingPortal,
} from '@floating-ui/react';
export interface TooltipProps {
/** Tooltip body — usually a short string; ReactNode is allowed for icons. */
content: ReactNode;
/** Single child element that receives the ref + ARIA wiring. */
children: ReactElement;
/** Preferred placement; Floating-UI will auto-flip if viewport-clamped. */
placement?: 'top' | 'right' | 'bottom' | 'left';
/** Pixel offset between the trigger and the tooltip. Default 6. */
offsetPx?: number;
}
export default function Tooltip({
content,
children,
placement = 'top',
offsetPx = 6,
}: TooltipProps) {
const [open, setOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement,
middleware: [offset(offsetPx), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
const hover = useHover(context, { move: false, delay: { open: 200, close: 0 } });
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
if (!isValidElement(children)) {
// Defensive: render the child verbatim; Tooltip wiring is skipped.
// Console-warn so the misuse is visible during dev.
if (typeof console !== 'undefined') {
console.warn(
'<Tooltip> requires a single React element child; got:',
children,
);
}
return <>{children}</>;
}
// Merge the ref + interaction props onto the child. cloneElement keeps
// the original child's type + own props; we layer ours on top.
const triggerProps = getReferenceProps();
const child = cloneElement(
children as ReactElement<Record<string, unknown>>,
{
ref: refs.setReference,
...triggerProps,
},
);
return (
<>
{child}
{open && content && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className="z-50 max-w-xs rounded bg-ink/95 text-white text-xs px-2 py-1 shadow-lg pointer-events-none"
>
{content}
</div>
</FloatingPortal>
)}
</>
);
}
+5
View File
@@ -58,6 +58,10 @@ import SessionsPage from './pages/auth/SessionsPage';
import BreakglassPage from './pages/auth/BreakglassPage';
// Audit 2026-05-10 MED-11 closure — federated-user admin page.
import UsersPage from './pages/auth/UsersPage';
// Phase 1 closure (UX-H3): toast / snackbar system. Mounted once near
// the root so any component can `import { toast } from "sonner"` and
// call toast.success / toast.error without provider plumbing.
import Toaster from './components/Toaster';
import './index.css';
const queryClient = new QueryClient({
@@ -74,6 +78,7 @@ createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<Toaster />
<AuthProvider>
<AuthGate>
<BrowserRouter>
+22 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -7,6 +8,7 @@ import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import ConfirmDialog from '../components/ConfirmDialog';
import { formatDateTime } from '../api/utils';
import type { AgentGroup } from '../api/types';
@@ -254,6 +256,7 @@ export default function AgentGroupsPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [editingGroup, setEditingGroup] = useState<AgentGroup | null>(null);
const [confirmDelete, setConfirmDelete] = useState<AgentGroup | null>(null);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['agent-groups'],
@@ -263,6 +266,8 @@ export default function AgentGroupsPage() {
const deleteMutation = useTrackedMutation({
mutationFn: deleteAgentGroup,
invalidates: [['agent-groups']],
onSuccess: () => toast.success('Agent group deleted'),
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
});
const createMutation = useTrackedMutation({
@@ -337,7 +342,7 @@ export default function AgentGroupsPage() {
Edit
</button>
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete group ${g.name}?`)) deleteMutation.mutate(g.id); }}
onClick={(e) => { e.stopPropagation(); setConfirmDelete(g); }}
className="text-xs text-red-600 hover:text-red-700 transition-colors"
>
Delete
@@ -385,6 +390,22 @@ export default function AgentGroupsPage() {
isLoading={updateMutation.isPending}
error={updateMutation.error ? (updateMutation.error as Error).message : null}
/>
<ConfirmDialog
open={confirmDelete !== null}
title="Delete agent group"
message={
confirmDelete
? `Delete group ${confirmDelete.name}? This will remove the group definition; agents currently in the group will fall back to default assignment.`
: ''
}
confirmLabel="Delete"
destructive
onConfirm={() => {
if (confirmDelete) deleteMutation.mutate(confirmDelete.id);
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
</>
);
}
+25 -3
View File
@@ -1,12 +1,14 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getRenewalPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12, getOCSPStatus, fetchCRL, getAdminCRLCache } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import ConfirmDialog from '../components/ConfirmDialog';
import { useAuth } from '../components/AuthProvider';
import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils';
import type { Job, CRLCacheRow } from '../api/types';
@@ -422,6 +424,7 @@ export default function CertificateDetailPage() {
const [showExport, setShowExport] = useState(false);
const [pkcs12Password, setPkcs12Password] = useState('');
const [exporting, setExporting] = useState(false);
const [confirmArchive, setConfirmArchive] = useState(false);
const { data: cert, isLoading, error, refetch } = useQuery({
queryKey: ['certificate', id],
@@ -466,8 +469,10 @@ export default function CertificateDetailPage() {
mutationFn: () => archiveCertificate(id!),
invalidates: [['certificates']],
onSuccess: () => {
toast.success('Certificate archived');
navigate('/certificates');
},
onError: (err: Error) => toast.error(`Archive failed: ${err.message}`),
});
const revokeMutation = useTrackedMutation({
@@ -490,7 +495,7 @@ export default function CertificateDetailPage() {
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
toast.error(`Export failed: ${err instanceof Error ? err.message : err}`);
} finally {
setExporting(false);
}
@@ -509,7 +514,7 @@ export default function CertificateDetailPage() {
setShowExport(false);
setPkcs12Password('');
} catch (err) {
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
toast.error(`Export failed: ${err instanceof Error ? err.message : err}`);
} finally {
setExporting(false);
}
@@ -600,7 +605,7 @@ export default function CertificateDetailPage() {
)}
{!isArchived && (
<button
onClick={() => { if (confirm('Archive this certificate? This cannot be undone.')) archiveMutation.mutate(); }}
onClick={() => setConfirmArchive(true)}
disabled={archiveMutation.isPending}
className="btn btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
>
@@ -931,6 +936,23 @@ export default function CertificateDetailPage() {
</div>
</div>
)}
{/* UX-H2 / UX-H3 closure — archive is the most-irreversible
single-cert action. Gate behind a typed-confirmation prompt
so the operator cannot fat-finger through the dialog. */}
<ConfirmDialog
open={confirmArchive}
title="Archive this certificate"
message={`This action cannot be undone. The certificate (${cert?.common_name || id}) will be moved to the archive bucket and removed from the active inventory. Active deployments + renewal policies referencing it will be skipped.`}
confirmLabel="Archive"
cancelLabel="Cancel"
destructive
typedConfirmation="archive"
onConfirm={() => {
archiveMutation.mutate();
setConfirmArchive(false);
}}
onCancel={() => setConfirmArchive(false)}
/>
</>
);
}
+39 -5
View File
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { Fragment, useState } from 'react';
import { Transition } from '@headlessui/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { useListParams } from '../hooks/useListParams';
import { useNavigate } from 'react-router-dom';
@@ -511,9 +513,29 @@ export default function CertificatesPage() {
total: result.total_matched,
running: false,
});
} catch {
// UX-L5 closure (Phase 1): post-action toast with a "View jobs"
// action that deep-links to the Jobs page filtered to the
// certificate IDs we just renewed. The audit's missing
// "what just happened" affordance — operators can now jump
// straight to the resulting jobs.
if (result.total_enqueued > 0) {
toast.success(
`Triggered renewal for ${result.total_enqueued} certificate${result.total_enqueued > 1 ? 's' : ''}`,
{
action: {
label: `View ${result.total_enqueued} jobs`,
onClick: () =>
navigate(`/jobs?certificate_ids=${ids.join(',')}`),
},
duration: 8000,
},
);
}
} catch (err) {
// surface as a "0 of N" terminal state — no retries.
setBulkRenewProgress({ done: 0, total: ids.length, running: false });
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Bulk renewal failed: ${msg}`);
}
queryClient.invalidateQueries({ queryKey: ['certificates'] });
setSelectedIds(new Set());
@@ -566,8 +588,20 @@ export default function CertificatesPage() {
}
/>
{/* Bulk Action Bar */}
{hasSelection && (
{/* Bulk Action Bar — UX-L5 (Phase 1): Headless UI <Transition>
wraps the slide-in/out so the bar doesn't snap when selection
flips. Transition respects prefers-reduced-motion via the
global @media block in index.css. */}
<Transition
show={hasSelection}
as={Fragment}
enter="transition-all duration-200 ease-out"
enterFrom="opacity-0 -translate-y-2"
enterTo="opacity-100 translate-y-0"
leave="transition-all duration-150 ease-in"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-2"
>
<div className="px-6 py-3 bg-brand-50 border-b border-brand-200 flex items-center justify-between">
<span className="text-sm text-brand-600 font-medium">{selectedArray.length} selected</span>
<div className="flex gap-2">
@@ -593,7 +627,7 @@ export default function CertificatesPage() {
</button>
</div>
</div>
)}
</Transition>
{/* Bulk Renewal Success */}
{bulkRenewProgress && !bulkRenewProgress.running && (
+23 -2
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { getOwners, getTeams, deleteOwner, createOwner, updateOwner } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import ConfirmDialog from '../components/ConfirmDialog';
import { formatDateTime } from '../api/utils';
import type { Owner, Team } from '../api/types';
@@ -211,10 +213,13 @@ export default function OwnersPage() {
queryFn: () => getTeams(),
});
const [confirmDelete, setConfirmDelete] = useState<Owner | null>(null);
const deleteMutation = useTrackedMutation({
mutationFn: deleteOwner,
invalidates: [['owners']],
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
onSuccess: () => toast.success('Owner deleted'),
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
});
const createMutation = useTrackedMutation({
@@ -279,7 +284,7 @@ export default function OwnersPage() {
Edit
</button>
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete owner ${o.name}?`)) deleteMutation.mutate(o.id); }}
onClick={(e) => { e.stopPropagation(); setConfirmDelete(o); }}
className="text-xs text-red-600 hover:text-red-700 transition-colors"
>
Delete
@@ -329,6 +334,22 @@ export default function OwnersPage() {
error={updateMutation.error ? (updateMutation.error as Error).message : null}
teamsData={teamsData}
/>
<ConfirmDialog
open={confirmDelete !== null}
title="Delete owner"
message={
confirmDelete
? `Delete owner ${confirmDelete.name}? This action cannot be undone.`
: ''
}
confirmLabel="Delete"
destructive
onConfirm={() => {
if (confirmDelete) deleteMutation.mutate(confirmDelete.id);
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
</>
);
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import {
getRenewalPolicies,
@@ -206,7 +207,8 @@ export default function RenewalPoliciesPage() {
// alert so the operator sees "this policy is still attached to N
// certificates" and can re-target those certs to another policy
// before deleting.
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
onSuccess: () => toast.success('Renewal policy deleted'),
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
});
const columns: Column<RenewalPolicy>[] = [
+22 -8
View File
@@ -93,23 +93,37 @@ describe('TargetsPage — T-1 page coverage', () => {
});
it('Delete confirm flow calls deleteTarget(id)', async () => {
const origConfirm = globalThis.confirm;
globalThis.confirm = vi.fn(() => true);
try {
// Phase 1 UX-H2 closure: Delete now opens a ConfirmDialog primitive
// (Headless UI) rather than firing window.confirm(). The new flow:
// 1. operator clicks the row's "Delete" button → sets state
// that opens the dialog
// 2. ConfirmDialog mounts with title "Delete deployment target"
// 3. operator clicks the dialog's destructive "Delete" button
// 4. deleteTarget(id) fires
renderWithQuery(<TargetsPage />);
await waitFor(() => {
expect(screen.getByText('IIS Web01')).toBeInTheDocument();
});
const deleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
fireEvent.click(deleteButtons[0]!);
const rowDeleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
fireEvent.click(rowDeleteButtons[0]!);
// Wait for the dialog title to mount (Headless UI Transition).
await waitFor(() => {
expect(screen.getByText('Delete deployment target')).toBeInTheDocument();
});
// Click the dialog's destructive-styled confirm button (.btn-danger).
const allDeleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
const dialogDeleteBtn = allDeleteButtons.find((b) =>
b.className.includes('btn-danger'),
);
expect(dialogDeleteBtn).toBeDefined();
fireEvent.click(dialogDeleteBtn!);
await waitFor(() => {
expect(client.deleteTarget).toHaveBeenCalled();
});
expect(vi.mocked(client.deleteTarget).mock.calls[0]?.[0]).toBe('tgt-iis-prod');
} finally {
globalThis.confirm = origConfirm;
}
});
});
+22 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { getTargets, createTarget, deleteTarget, getAgents } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -8,6 +9,7 @@ import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import ConfirmDialog from '../components/ConfirmDialog';
import { formatDateTime } from '../api/utils';
import type { Target } from '../api/types';
@@ -403,6 +405,7 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
export default function TargetsPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<Target | null>(null);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['targets'],
@@ -412,6 +415,8 @@ export default function TargetsPage() {
const deleteMutation = useTrackedMutation({
mutationFn: deleteTarget,
invalidates: [['targets']],
onSuccess: () => toast.success('Target deleted'),
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
});
const columns: Column<Target>[] = [
@@ -462,7 +467,7 @@ export default function TargetsPage() {
label: '',
render: (t) => (
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete target ${t.name}?`)) deleteMutation.mutate(t.id); }}
onClick={(e) => { e.stopPropagation(); setConfirmDelete(t); }}
className="text-xs text-red-600 hover:text-red-700 transition-colors"
>
Delete
@@ -498,6 +503,22 @@ export default function TargetsPage() {
}}
/>
)}
<ConfirmDialog
open={confirmDelete !== null}
title="Delete deployment target"
message={
confirmDelete
? `Delete target ${confirmDelete.name}? Active deployments referencing this target will fail until reconfigured.`
: ''
}
confirmLabel="Delete"
destructive
onConfirm={() => {
if (confirmDelete) deleteMutation.mutate(confirmDelete.id);
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
</>
);
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { getTeams, deleteTeam, createTeam, updateTeam } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -156,7 +157,8 @@ export default function TeamsPage() {
const deleteMutation = useTrackedMutation({
mutationFn: deleteTeam,
invalidates: [['teams']],
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
onSuccess: () => toast.success('Team deleted'),
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
});
const createMutation = useTrackedMutation({
+31 -3
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
authListKeys,
authListRoles,
@@ -11,6 +12,7 @@ import {
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
import ConfirmDialog from '../../components/ConfirmDialog';
// =============================================================================
// Bundle 1 Phase 10 — KeysPage.
@@ -44,20 +46,33 @@ export default function KeysPage() {
const [assignTarget, setAssignTarget] = useState<AuthKeyEntry | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// UX-H2 closure — replace window.confirm() with ConfirmDialog.
const [confirmRevoke, setConfirmRevoke] = useState<
{ entry: AuthKeyEntry; roleID: string } | null
>(null);
const canAssign = me.hasPerm('auth.role.assign') || me.isAdmin();
const canRevoke = me.hasPerm('auth.role.assign') || me.isAdmin();
const handleRevoke = async (entry: AuthKeyEntry, roleID: string) => {
const handleRevoke = (entry: AuthKeyEntry, roleID: string) => {
if (entry.actor_id === DEMO_ANON) return;
if (!window.confirm(`Revoke ${roleID} from ${entry.actor_id}?`)) return;
setConfirmRevoke({ entry, roleID });
};
const performRevoke = async () => {
if (!confirmRevoke) return;
const { entry, roleID } = confirmRevoke;
setConfirmRevoke(null);
setBusy(true);
setActionError(null);
try {
await authRevokeKeyRole(entry.actor_id, roleID);
toast.success(`Revoked ${roleID} from ${entry.actor_id}`);
qc.invalidateQueries({ queryKey: ['auth', 'keys'] });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
const msg = err instanceof Error ? err.message : String(err);
setActionError(msg);
toast.error(`Revoke failed: ${msg}`);
} finally {
setBusy(false);
}
@@ -173,6 +188,19 @@ export default function KeysPage() {
}}
/>
)}
<ConfirmDialog
open={confirmRevoke !== null}
title="Revoke role grant"
message={
confirmRevoke
? `Revoke ${confirmRevoke.roleID} from ${confirmRevoke.entry.actor_id}? The actor will lose every permission scoped to that role on the next request.`
: ''
}
confirmLabel="Revoke"
destructive
onConfirm={performRevoke}
onCancel={() => setConfirmRevoke(null)}
/>
</div>
);
}
+23 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
authGetRole,
authListPermissions,
@@ -13,6 +14,7 @@ import {
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
import ConfirmDialog from '../../components/ConfirmDialog';
// =============================================================================
// Bundle 1 Phase 10 — RoleDetailPage.
@@ -65,6 +67,8 @@ export default function RoleDetailPage() {
const [editOpen, setEditOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
// UX-H2 closure — replace window.confirm with ConfirmDialog.
const [confirmDelete, setConfirmDelete] = useState(false);
const canEdit = me.hasPerm('auth.role.edit') || me.isAdmin();
const canDelete = me.hasPerm('auth.role.delete') || me.isAdmin();
@@ -83,15 +87,22 @@ export default function RoleDetailPage() {
const { role, permissions } = detailQuery.data;
const handleDelete = async () => {
if (!window.confirm(`Delete role ${role.name}? This cannot be undone.`)) return;
const handleDelete = () => {
setConfirmDelete(true);
};
const performDelete = async () => {
setConfirmDelete(false);
setSubmitting(true);
setActionError(null);
try {
await authDeleteRole(role.id);
toast.success(`Role ${role.name} deleted`);
navigate('/auth/roles');
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
const msg = err instanceof Error ? err.message : String(err);
setActionError(msg);
toast.error(`Delete failed: ${msg}`);
} finally {
setSubmitting(false);
}
@@ -260,6 +271,15 @@ export default function RoleDetailPage() {
}}
/>
)}
<ConfirmDialog
open={confirmDelete}
title="Delete role"
message={`Delete role ${role.name}? Every actor currently holding this role grant will lose those permissions. This cannot be undone.`}
confirmLabel="Delete role"
destructive
onConfirm={performDelete}
onCancel={() => setConfirmDelete(false)}
/>
</div>
);
}