mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
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:
Generated
+259
-2
@@ -8,13 +8,16 @@
|
|||||||
"name": "certctl-dashboard",
|
"name": "certctl-dashboard",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.19",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@headlessui/react": "^2.2.10",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
"recharts": "^3.8.0"
|
"recharts": "^3.8.0",
|
||||||
|
"sonner": "^2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
@@ -873,6 +876,59 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@fontsource-variable/inter": {
|
||||||
"version": "5.2.8",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
||||||
@@ -905,6 +961,41 @@
|
|||||||
"@shikijs/vscode-textmate": "^10.0.2"
|
"@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": {
|
"node_modules/@ibm-cloud/openapi-ruleset": {
|
||||||
"version": "1.33.9",
|
"version": "1.33.9",
|
||||||
"resolved": "https://registry.npmjs.org/@ibm-cloud/openapi-ruleset/-/openapi-ruleset-1.33.9.tgz",
|
"resolved": "https://registry.npmjs.org/@ibm-cloud/openapi-ruleset/-/openapi-ruleset-1.33.9.tgz",
|
||||||
@@ -939,6 +1030,33 @@
|
|||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -1717,6 +1835,44 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.11.2",
|
"version": "2.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
@@ -2443,6 +2599,15 @@
|
|||||||
"node": "^12.20 || >=14.13"
|
"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": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.20",
|
"version": "5.90.20",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||||
@@ -2469,6 +2634,33 @@
|
|||||||
"react": "^18 || ^19"
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -3023,6 +3215,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"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": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
@@ -6894,6 +7098,27 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
@@ -6969,6 +7194,23 @@
|
|||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -7544,6 +7786,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -7796,6 +8048,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
@@ -8016,7 +8274,6 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/typed-array-buffer": {
|
"node_modules/typed-array-buffer": {
|
||||||
|
|||||||
+4
-1
@@ -14,13 +14,16 @@
|
|||||||
"generate": "orval --config ./orval.config.ts"
|
"generate": "orval --config ./orval.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.19",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@headlessui/react": "^2.2.10",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
"recharts": "^3.8.0"
|
"recharts": "^3.8.0",
|
||||||
|
"sonner": "^2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface Column<T> {
|
interface Column<T> {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -28,6 +30,14 @@ interface DataTableProps<T> {
|
|||||||
data: T[];
|
data: T[];
|
||||||
onRowClick?: (item: T) => void;
|
onRowClick?: (item: T) => void;
|
||||||
emptyMessage?: string;
|
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;
|
isLoading?: boolean;
|
||||||
keyField?: string;
|
keyField?: string;
|
||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
@@ -36,7 +46,7 @@ interface DataTableProps<T> {
|
|||||||
pagination?: PaginationProps;
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16 text-ink-muted">
|
<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) {
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-center py-16 text-ink-faint">
|
<div className="flex items-center justify-center py-16 text-ink-faint">
|
||||||
{emptyMessage || 'No data found'}
|
{emptyMessage || 'No data found'}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import StatusBadge from './StatusBadge';
|
import StatusBadge, { statusDisplay, titleCase } from './StatusBadge';
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// D-1 master — StatusBadge enum-coverage contract
|
// 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');
|
expect(container.querySelector('span')!.className).toContain('badge-warning');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unknown statuses fall through to neutral. The string is still
|
// Unknown statuses fall through to neutral. The label is humanised
|
||||||
// displayed verbatim so an operator can see "what is this?" rather
|
// via the titleCase() helper (UX-H5) so the operator sees readable
|
||||||
// than nothing at all.
|
// text rather than the raw enum key — "Some future status" instead
|
||||||
it('unknown status string renders as neutral but preserves the label text', () => {
|
// of "SomeFutureStatus".
|
||||||
|
it('unknown status string renders as neutral with titleCase fallback', () => {
|
||||||
const { container } = render(<StatusBadge status="SomeFutureStatus" />);
|
const { container } = render(<StatusBadge status="SomeFutureStatus" />);
|
||||||
const span = container.querySelector('span');
|
const span = container.querySelector('span');
|
||||||
expect(span!.className).toBe('badge badge-neutral');
|
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('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,16 @@
|
|||||||
// the Go side; StatusBadge.test.tsx walks every value and will go red
|
// the Go side; StatusBadge.test.tsx walks every value and will go red
|
||||||
// before users see a default-grey "what is happening?" badge.
|
// 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,
|
// D-1 master closure (cat-d-359e92c20cbf, cat-d-9f4c8e4a91f1,
|
||||||
// cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback,
|
// cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback,
|
||||||
// cat-f-ae0d06b6588f) fixed the pre-master drift:
|
// cat-f-ae0d06b6588f) fixed the pre-master drift:
|
||||||
@@ -74,7 +84,73 @@ const statusStyles: Record<string, string> = {
|
|||||||
unknown: 'badge-neutral',
|
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 }) {
|
export default function StatusBadge({ status }: { status: string }) {
|
||||||
const cls = statusStyles[status] || 'badge-neutral';
|
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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,6 +58,10 @@ import SessionsPage from './pages/auth/SessionsPage';
|
|||||||
import BreakglassPage from './pages/auth/BreakglassPage';
|
import BreakglassPage from './pages/auth/BreakglassPage';
|
||||||
// Audit 2026-05-10 MED-11 closure — federated-user admin page.
|
// Audit 2026-05-10 MED-11 closure — federated-user admin page.
|
||||||
import UsersPage from './pages/auth/UsersPage';
|
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';
|
import './index.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -74,6 +78,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Toaster />
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AuthGate>
|
<AuthGate>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client';
|
import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -7,6 +8,7 @@ import DataTable from '../components/DataTable';
|
|||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { AgentGroup } from '../api/types';
|
import type { AgentGroup } from '../api/types';
|
||||||
|
|
||||||
@@ -254,6 +256,7 @@ export default function AgentGroupsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState<AgentGroup | null>(null);
|
const [editingGroup, setEditingGroup] = useState<AgentGroup | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<AgentGroup | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['agent-groups'],
|
queryKey: ['agent-groups'],
|
||||||
@@ -263,6 +266,8 @@ export default function AgentGroupsPage() {
|
|||||||
const deleteMutation = useTrackedMutation({
|
const deleteMutation = useTrackedMutation({
|
||||||
mutationFn: deleteAgentGroup,
|
mutationFn: deleteAgentGroup,
|
||||||
invalidates: [['agent-groups']],
|
invalidates: [['agent-groups']],
|
||||||
|
onSuccess: () => toast.success('Agent group deleted'),
|
||||||
|
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useTrackedMutation({
|
const createMutation = useTrackedMutation({
|
||||||
@@ -337,7 +342,7 @@ export default function AgentGroupsPage() {
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -385,6 +390,22 @@ export default function AgentGroupsPage() {
|
|||||||
isLoading={updateMutation.isPending}
|
isLoading={updateMutation.isPending}
|
||||||
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
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 { 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 { REVOCATION_REASONS } from '../api/types';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
import { useAuth } from '../components/AuthProvider';
|
import { useAuth } from '../components/AuthProvider';
|
||||||
import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils';
|
import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils';
|
||||||
import type { Job, CRLCacheRow } from '../api/types';
|
import type { Job, CRLCacheRow } from '../api/types';
|
||||||
@@ -422,6 +424,7 @@ export default function CertificateDetailPage() {
|
|||||||
const [showExport, setShowExport] = useState(false);
|
const [showExport, setShowExport] = useState(false);
|
||||||
const [pkcs12Password, setPkcs12Password] = useState('');
|
const [pkcs12Password, setPkcs12Password] = useState('');
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||||
|
|
||||||
const { data: cert, isLoading, error, refetch } = useQuery({
|
const { data: cert, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['certificate', id],
|
queryKey: ['certificate', id],
|
||||||
@@ -466,8 +469,10 @@ export default function CertificateDetailPage() {
|
|||||||
mutationFn: () => archiveCertificate(id!),
|
mutationFn: () => archiveCertificate(id!),
|
||||||
invalidates: [['certificates']],
|
invalidates: [['certificates']],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
toast.success('Certificate archived');
|
||||||
navigate('/certificates');
|
navigate('/certificates');
|
||||||
},
|
},
|
||||||
|
onError: (err: Error) => toast.error(`Archive failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const revokeMutation = useTrackedMutation({
|
const revokeMutation = useTrackedMutation({
|
||||||
@@ -490,7 +495,7 @@ export default function CertificateDetailPage() {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
|
toast.error(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -509,7 +514,7 @@ export default function CertificateDetailPage() {
|
|||||||
setShowExport(false);
|
setShowExport(false);
|
||||||
setPkcs12Password('');
|
setPkcs12Password('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
|
toast.error(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -600,7 +605,7 @@ export default function CertificateDetailPage() {
|
|||||||
)}
|
)}
|
||||||
{!isArchived && (
|
{!isArchived && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (confirm('Archive this certificate? This cannot be undone.')) archiveMutation.mutate(); }}
|
onClick={() => setConfirmArchive(true)}
|
||||||
disabled={archiveMutation.isPending}
|
disabled={archiveMutation.isPending}
|
||||||
className="btn btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
|
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>
|
||||||
</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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { useListParams } from '../hooks/useListParams';
|
import { useListParams } from '../hooks/useListParams';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -511,9 +513,29 @@ export default function CertificatesPage() {
|
|||||||
total: result.total_matched,
|
total: result.total_matched,
|
||||||
running: false,
|
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.
|
// surface as a "0 of N" terminal state — no retries.
|
||||||
setBulkRenewProgress({ done: 0, total: ids.length, running: false });
|
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'] });
|
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
@@ -566,8 +588,20 @@ export default function CertificatesPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bulk Action Bar */}
|
{/* Bulk Action Bar — UX-L5 (Phase 1): Headless UI <Transition>
|
||||||
{hasSelection && (
|
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">
|
<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>
|
<span className="text-sm text-brand-600 font-medium">{selectedArray.length} selected</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -593,7 +627,7 @@ export default function CertificatesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Transition>
|
||||||
|
|
||||||
{/* Bulk Renewal Success */}
|
{/* Bulk Renewal Success */}
|
||||||
{bulkRenewProgress && !bulkRenewProgress.running && (
|
{bulkRenewProgress && !bulkRenewProgress.running && (
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getOwners, getTeams, deleteOwner, createOwner, updateOwner } from '../api/client';
|
import { getOwners, getTeams, deleteOwner, createOwner, updateOwner } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { Owner, Team } from '../api/types';
|
import type { Owner, Team } from '../api/types';
|
||||||
|
|
||||||
@@ -211,10 +213,13 @@ export default function OwnersPage() {
|
|||||||
queryFn: () => getTeams(),
|
queryFn: () => getTeams(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<Owner | null>(null);
|
||||||
|
|
||||||
const deleteMutation = useTrackedMutation({
|
const deleteMutation = useTrackedMutation({
|
||||||
mutationFn: deleteOwner,
|
mutationFn: deleteOwner,
|
||||||
invalidates: [['owners']],
|
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({
|
const createMutation = useTrackedMutation({
|
||||||
@@ -279,7 +284,7 @@ export default function OwnersPage() {
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -329,6 +334,22 @@ export default function OwnersPage() {
|
|||||||
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
||||||
teamsData={teamsData}
|
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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import {
|
import {
|
||||||
getRenewalPolicies,
|
getRenewalPolicies,
|
||||||
@@ -206,7 +207,8 @@ export default function RenewalPoliciesPage() {
|
|||||||
// alert so the operator sees "this policy is still attached to N
|
// alert so the operator sees "this policy is still attached to N
|
||||||
// certificates" and can re-target those certs to another policy
|
// certificates" and can re-target those certs to another policy
|
||||||
// before deleting.
|
// 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>[] = [
|
const columns: Column<RenewalPolicy>[] = [
|
||||||
|
|||||||
@@ -93,23 +93,37 @@ describe('TargetsPage — T-1 page coverage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Delete confirm flow calls deleteTarget(id)', async () => {
|
it('Delete confirm flow calls deleteTarget(id)', async () => {
|
||||||
const origConfirm = globalThis.confirm;
|
// Phase 1 UX-H2 closure: Delete now opens a ConfirmDialog primitive
|
||||||
globalThis.confirm = vi.fn(() => true);
|
// (Headless UI) rather than firing window.confirm(). The new flow:
|
||||||
try {
|
// 1. operator clicks the row's "Delete" button → sets state
|
||||||
renderWithQuery(<TargetsPage />);
|
// that opens the dialog
|
||||||
await waitFor(() => {
|
// 2. ConfirmDialog mounts with title "Delete deployment target"
|
||||||
expect(screen.getByText('IIS Web01')).toBeInTheDocument();
|
// 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' });
|
const rowDeleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
|
||||||
fireEvent.click(deleteButtons[0]!);
|
fireEvent.click(rowDeleteButtons[0]!);
|
||||||
|
|
||||||
await waitFor(() => {
|
// Wait for the dialog title to mount (Headless UI Transition).
|
||||||
expect(client.deleteTarget).toHaveBeenCalled();
|
await waitFor(() => {
|
||||||
});
|
expect(screen.getByText('Delete deployment target')).toBeInTheDocument();
|
||||||
expect(vi.mocked(client.deleteTarget).mock.calls[0]?.[0]).toBe('tgt-iis-prod');
|
});
|
||||||
} finally {
|
|
||||||
globalThis.confirm = origConfirm;
|
// 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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getTargets, createTarget, deleteTarget, getAgents } from '../api/client';
|
import { getTargets, createTarget, deleteTarget, getAgents } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -8,6 +9,7 @@ import DataTable from '../components/DataTable';
|
|||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { Target } from '../api/types';
|
import type { Target } from '../api/types';
|
||||||
|
|
||||||
@@ -403,6 +405,7 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
|||||||
export default function TargetsPage() {
|
export default function TargetsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<Target | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['targets'],
|
queryKey: ['targets'],
|
||||||
@@ -412,6 +415,8 @@ export default function TargetsPage() {
|
|||||||
const deleteMutation = useTrackedMutation({
|
const deleteMutation = useTrackedMutation({
|
||||||
mutationFn: deleteTarget,
|
mutationFn: deleteTarget,
|
||||||
invalidates: [['targets']],
|
invalidates: [['targets']],
|
||||||
|
onSuccess: () => toast.success('Target deleted'),
|
||||||
|
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns: Column<Target>[] = [
|
const columns: Column<Target>[] = [
|
||||||
@@ -462,7 +467,7 @@ export default function TargetsPage() {
|
|||||||
label: '',
|
label: '',
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
<button
|
<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"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getTeams, deleteTeam, createTeam, updateTeam } from '../api/client';
|
import { getTeams, deleteTeam, createTeam, updateTeam } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -156,7 +157,8 @@ export default function TeamsPage() {
|
|||||||
const deleteMutation = useTrackedMutation({
|
const deleteMutation = useTrackedMutation({
|
||||||
mutationFn: deleteTeam,
|
mutationFn: deleteTeam,
|
||||||
invalidates: [['teams']],
|
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({
|
const createMutation = useTrackedMutation({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
authListKeys,
|
authListKeys,
|
||||||
authListRoles,
|
authListRoles,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 1 Phase 10 — KeysPage.
|
// Bundle 1 Phase 10 — KeysPage.
|
||||||
@@ -44,20 +46,33 @@ export default function KeysPage() {
|
|||||||
const [assignTarget, setAssignTarget] = useState<AuthKeyEntry | null>(null);
|
const [assignTarget, setAssignTarget] = useState<AuthKeyEntry | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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 canAssign = me.hasPerm('auth.role.assign') || me.isAdmin();
|
||||||
const canRevoke = 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 (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);
|
setBusy(true);
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
await authRevokeKeyRole(entry.actor_id, roleID);
|
await authRevokeKeyRole(entry.actor_id, roleID);
|
||||||
|
toast.success(`Revoked ${roleID} from ${entry.actor_id}`);
|
||||||
qc.invalidateQueries({ queryKey: ['auth', 'keys'] });
|
qc.invalidateQueries({ queryKey: ['auth', 'keys'] });
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setBusy(false);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
authGetRole,
|
authGetRole,
|
||||||
authListPermissions,
|
authListPermissions,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 1 Phase 10 — RoleDetailPage.
|
// Bundle 1 Phase 10 — RoleDetailPage.
|
||||||
@@ -65,6 +67,8 @@ export default function RoleDetailPage() {
|
|||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
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 canEdit = me.hasPerm('auth.role.edit') || me.isAdmin();
|
||||||
const canDelete = me.hasPerm('auth.role.delete') || 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 { role, permissions } = detailQuery.data;
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = () => {
|
||||||
if (!window.confirm(`Delete role ${role.name}? This cannot be undone.`)) return;
|
setConfirmDelete(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const performDelete = async () => {
|
||||||
|
setConfirmDelete(false);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
await authDeleteRole(role.id);
|
await authDeleteRole(role.id);
|
||||||
|
toast.success(`Role ${role.name} deleted`);
|
||||||
navigate('/auth/roles');
|
navigate('/auth/roles');
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setSubmitting(false);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user