diff --git a/web/package-lock.json b/web/package-lock.json index 15ce245..b2bedd9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,13 +8,16 @@ "name": "certctl-dashboard", "version": "1.0.0", "dependencies": { + "@floating-ui/react": "^0.27.19", "@fontsource-variable/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@headlessui/react": "^2.2.10", "@tanstack/react-query": "^5.90.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.3", - "recharts": "^3.8.0" + "recharts": "^3.8.0", + "sonner": "^2.0.7" }, "devDependencies": { "@playwright/test": "^1.49.0", @@ -873,6 +876,59 @@ "dev": true, "license": "MIT" }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@fontsource-variable/inter": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", @@ -905,6 +961,41 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, + "node_modules/@headlessui/react": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz", + "integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@headlessui/react/node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@ibm-cloud/openapi-ruleset": { "version": "1.33.9", "resolved": "https://registry.npmjs.org/@ibm-cloud/openapi-ruleset/-/openapi-ruleset-1.33.9.tgz", @@ -939,6 +1030,33 @@ "node": ">=16.0.0" } }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz", + "integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/string": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.8.tgz", + "integrity": "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1717,6 +1835,44 @@ "node": ">=18" } }, + "node_modules/@react-aria/focus": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz", + "integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.0.tgz", + "integrity": "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", + "integrity": "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -2443,6 +2599,15 @@ "node": "^12.20 || >=14.13" } }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tanstack/query-core": { "version": "5.90.20", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", @@ -2469,6 +2634,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3023,6 +3215,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -6894,6 +7098,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-aria": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.48.0.tgz", + "integrity": "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "aria-hidden": "^1.2.3", + "clsx": "^2.0.0", + "react-stately": "3.46.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -6969,6 +7194,23 @@ "react-dom": ">=16.8" } }, + "node_modules/react-stately": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz", + "integrity": "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7544,6 +7786,16 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7796,6 +8048,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -8016,7 +8274,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/typed-array-buffer": { diff --git a/web/package.json b/web/package.json index e54ab98..c2f3b63 100644 --- a/web/package.json +++ b/web/package.json @@ -14,13 +14,16 @@ "generate": "orval --config ./orval.config.ts" }, "dependencies": { + "@floating-ui/react": "^0.27.19", "@fontsource-variable/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@headlessui/react": "^2.2.10", "@tanstack/react-query": "^5.90.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.3", - "recharts": "^3.8.0" + "recharts": "^3.8.0", + "sonner": "^2.0.7" }, "devDependencies": { "@playwright/test": "^1.49.0", diff --git a/web/src/components/Banner.test.tsx b/web/src/components/Banner.test.tsx new file mode 100644 index 0000000..cbfa723 --- /dev/null +++ b/web/src/components/Banner.test.tsx @@ -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(Operator note); + expect(screen.getByText('Operator note')).toBeInTheDocument(); + }); + + it('renders the optional title', () => { + render( + + Permission denied. + , + ); + expect(screen.getByText('Save failed')).toBeInTheDocument(); + expect(screen.getByText('Permission denied.')).toBeInTheDocument(); + }); + + it('uses role="alert" for error variant', () => { + render(Permission denied.); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('uses role="alert" for warning variant', () => { + render(Stale data.); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('uses role="status" for success variant', () => { + render(Saved.); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('uses role="status" for info variant', () => { + render(Heads up.); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('applies variant-specific bg + border classes', () => { + const { container } = render(err); + 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(No close affordance.); + expect(screen.queryByRole('button', { name: /dismiss/i })).toBeNull(); + }); + + it('renders dismiss button + fires onDismiss when supplied', () => { + const onDismiss = vi.fn(); + render( + + Closable. + , + ); + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/components/Banner.tsx b/web/src/components/Banner.tsx new file mode 100644 index 0000000..233ff05 --- /dev/null +++ b/web/src/components/Banner.tsx @@ -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 = { + 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 = { + 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 ( +
+
+
+ {title && ( +
+ {title} +
+ )} +
{children}
+
+ {onDismiss && ( + + )} +
+
+ ); +} diff --git a/web/src/components/Combobox.test.tsx b/web/src/components/Combobox.test.tsx new file mode 100644 index 0000000..3a02d8c --- /dev/null +++ b/web/src/components/Combobox.test.tsx @@ -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( + + 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( + + 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( + + 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( + + 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( + + 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(); + }); +}); diff --git a/web/src/components/Combobox.tsx b/web/src/components/Combobox.tsx new file mode 100644 index 0000000..717ace4 --- /dev/null +++ b/web/src/components/Combobox.tsx @@ -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 : +// - 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 { + /** 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({ + value, + onChange, + options, + getKey, + getLabel, + placeholder, + inputId, + disabled, + className = '', +}: ComboboxProps) { + 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 ( + +
+ (o ? getLabel(o) : '')} + onChange={(e) => setQuery(e.target.value)} + /> + + {filtered.length === 0 && query !== '' && ( +
+ No matches. +
+ )} + {filtered.map((option) => ( + + `cursor-pointer px-3 py-2 text-sm ${ + active ? 'bg-brand-50 text-brand-700' : 'text-ink' + } ${selected ? 'font-semibold' : ''}` + } + > + {getLabel(option)} + + ))} +
+
+
+ ); +} diff --git a/web/src/components/ConfirmDialog.test.tsx b/web/src/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000..7b12057 --- /dev/null +++ b/web/src/components/ConfirmDialog.test.tsx @@ -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( + {}} + onCancel={() => {}} + />, + ); + expect(screen.queryByText('Archive cert')).not.toBeInTheDocument(); + }); + + it('renders title + message when open=true', () => { + render( + {}} + 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( + {}} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /confirm/i })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('fires onCancel when cancel button clicked', () => { + const onCancel = vi.fn(); + render( + {}} + 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( + {}} + />, + ); + 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( + {}} + onCancel={() => {}} + />, + ); + const confirmBtn = screen.getByRole('button', { name: /confirm/i }); + expect(confirmBtn.className).toContain('btn-danger'); + }); + + it('honours custom confirmLabel + cancelLabel', () => { + render( + {}} + onCancel={() => {}} + />, + ); + expect( + screen.getByRole('button', { name: 'Yes, archive' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'No, go back' }), + ).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..015574f --- /dev/null +++ b/web/src/components/ConfirmDialog.tsx @@ -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 , 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 +// - 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(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 ( + + + {/* Backdrop */} + + + + ); +} diff --git a/web/src/components/DataTable.tsx b/web/src/components/DataTable.tsx index f977563..e046aba 100644 --- a/web/src/components/DataTable.tsx +++ b/web/src/components/DataTable.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + interface Column { key: string; label: string; @@ -28,6 +30,14 @@ interface DataTableProps { data: T[]; onRowClick?: (item: T) => void; emptyMessage?: string; + /** + * UX-M3 / Phase 1: rich empty-state slot. Pass an + * component (or any ReactNode) here when the page wants a CTA-driven + * first-run experience instead of the bare emptyMessage string. The + * existing `emptyMessage` prop is preserved for backward compat with + * the ~18 list-page call sites that pass a simple string. + */ + emptyState?: ReactNode; isLoading?: boolean; keyField?: string; selectable?: boolean; @@ -36,7 +46,7 @@ interface DataTableProps { pagination?: PaginationProps; } -export default function DataTable({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps) { +export default function DataTable({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps) { if (isLoading) { return (
@@ -50,6 +60,12 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage, } if (!data.length) { + // UX-M3 / Phase 1: prefer the rich slot when supplied; + // fall back to the legacy string render so existing call sites with + // emptyMessage="…" stay unchanged. + if (emptyState) { + return <>{emptyState}; + } return (
{emptyMessage || 'No data found'} diff --git a/web/src/components/EmptyState.test.tsx b/web/src/components/EmptyState.test.tsx new file mode 100644 index 0000000..2a239e4 --- /dev/null +++ b/web/src/components/EmptyState.test.tsx @@ -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(); + expect(screen.getByText('No certificates yet')).toBeInTheDocument(); + }); + + it('renders description when provided', () => { + render( + , + ); + expect( + screen.getByText('Issue your first certificate to get started.'), + ).toBeInTheDocument(); + }); + + it('renders icon slot when provided', () => { + render( + 📜} + title="No certificates" + />, + ); + expect(screen.getByTestId('empty-icon')).toBeInTheDocument(); + }); + + it('renders primaryAction button and fires its onClick', () => { + const onClick = vi.fn(); + render( + , + ); + 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( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Read docs' })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders both actions side-by-side', () => { + render( + {} }} + 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(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/EmptyState.tsx b/web/src/components/EmptyState.tsx new file mode 100644 index 0000000..2ede628 --- /dev/null +++ b/web/src/components/EmptyState.tsx @@ -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 primitive; DataTable shows a bare +// 'No data found' string). +// +// Two render paths: +// 1) `` — minimum +// acceptable empty state. Title is required (the user must +// understand what's missing); description + actions are optional. +// 2) `} 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 +// 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 ( +
+ {icon && ( + + )} +

{title}

+ {description && ( +

{description}

+ )} + {(primaryAction || secondaryAction) && ( +
+ {primaryAction && ( + + )} + {secondaryAction && ( + + )} +
+ )} +
+ ); +} diff --git a/web/src/components/StatusBadge.test.tsx b/web/src/components/StatusBadge.test.tsx index c1bd975..19754d8 100644 --- a/web/src/components/StatusBadge.test.tsx +++ b/web/src/components/StatusBadge.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { render } from '@testing-library/react'; -import StatusBadge from './StatusBadge'; +import StatusBadge, { statusDisplay, titleCase } from './StatusBadge'; // ----------------------------------------------------------------------------- // D-1 master — StatusBadge enum-coverage contract @@ -118,13 +118,111 @@ describe('StatusBadge — enum-coverage contract (D-1 master)', () => { expect(container.querySelector('span')!.className).toContain('badge-warning'); }); - // Unknown statuses fall through to neutral. The string is still - // displayed verbatim so an operator can see "what is this?" rather - // than nothing at all. - it('unknown status string renders as neutral but preserves the label text', () => { + // Unknown statuses fall through to neutral. The label is humanised + // via the titleCase() helper (UX-H5) so the operator sees readable + // text rather than the raw enum key — "Some future status" instead + // of "SomeFutureStatus". + it('unknown status string renders as neutral with titleCase fallback', () => { const { container } = render(); const span = container.querySelector('span'); expect(span!.className).toBe('badge badge-neutral'); - expect(span!.textContent).toBe('SomeFutureStatus'); + expect(span!.textContent).toBe('Some future status'); + }); +}); + +// ----------------------------------------------------------------------------- +// UX-H5 master — StatusBadge display-string contract (Phase 1, 2026-05-14) +// +// The audit finding: pre-Phase-1, StatusBadge rendered raw Go enum keys +// — operators saw "RenewalInProgress" / "AwaitingCSR" / "cert_mismatch" +// / "dead" verbatim. Phase 1 adds a statusDisplay map next to +// statusStyles; this suite pins the byte-exact display string for every +// wire key. +// ----------------------------------------------------------------------------- +describe('StatusBadge — display-string contract (UX-H5)', () => { + // Every wire key in the colour map MUST have a display-string entry + // and the entry MUST be non-empty. Missing entries fall back to the + // titleCase() helper, but having an explicit entry in statusDisplay + // is the preferred path (lets us pick the cleanest sentence-case + // phrasing, with terms like "Awaiting CSR" capitalised correctly + // where titleCase would yield "Awaiting csr"). + const EXPECTED_DISPLAY: Array<[string, string]> = [ + // Certificate statuses + ['Active', 'Active'], + ['Expiring', 'Expiring soon'], + ['Expired', 'Expired'], + ['RenewalInProgress', 'Renewal in progress'], + ['Archived', 'Archived'], + ['Revoked', 'Revoked'], + // Job statuses + ['Pending', 'Pending'], + ['AwaitingCSR', 'Awaiting CSR'], + ['AwaitingApproval', 'Awaiting approval'], + ['Running', 'Running'], + ['Completed', 'Completed'], + ['Failed', 'Failed'], + ['Cancelled', 'Cancelled'], + // Agent statuses + ['Online', 'Online'], + ['Offline', 'Offline'], + ['Degraded', 'Degraded'], + // Discovery statuses + ['Unmanaged', 'Unmanaged'], + ['Managed', 'Managed'], + ['Dismissed', 'Dismissed'], + // Frontend-synthesized issuer statuses + ['Enabled', 'Enabled'], + ['Disabled', 'Disabled'], + // Notification statuses (lowercase wire values) + ['sent', 'Sent'], + ['pending', 'Pending'], + ['failed', 'Failed'], + ['dead', 'Dead-lettered'], + ['read', 'Read'], + // Health check statuses (lowercase + snake_case) + ['healthy', 'Healthy'], + ['degraded', 'Degraded'], + ['down', 'Down'], + ['cert_mismatch', 'Certificate mismatch'], + ['unknown', 'Unknown'], + ]; + + it.each(EXPECTED_DISPLAY)( + "wire key '%s' renders display string '%s'", + (wire, expected) => { + // First — verify the statusDisplay map carries the entry verbatim. + expect(statusDisplay[wire]).toBe(expected); + // Then — verify the rendered 's textContent matches. + const { container } = render(); + 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(''); + }); }); }); diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx index f080a7c..c724a47 100644 --- a/web/src/components/StatusBadge.tsx +++ b/web/src/components/StatusBadge.tsx @@ -4,6 +4,16 @@ // the Go side; StatusBadge.test.tsx walks every value and will go red // before users see a default-grey "what is happening?" badge. // +// UX-H5 closure (Phase 1, 2026-05-14): we now render a human display +// string rather than the raw enum key. The wire keys stay byte- +// identical to the Go-side enums (per the D-1 closure comment above) — +// only the rendered text changes. PascalCase + snake_case + +// lowercase enums map to spaced sentence-case ("Renewal in progress", +// "Awaiting CSR", "Dead-lettered", "Certificate mismatch"). Unmapped +// keys fall through to a titleCase helper that lower-bounds the +// readability even when a new Go-side enum lands before the frontend +// catches up. +// // D-1 master closure (cat-d-359e92c20cbf, cat-d-9f4c8e4a91f1, // cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback, // cat-f-ae0d06b6588f) fixed the pre-master drift: @@ -74,7 +84,73 @@ const statusStyles: Record = { 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 = { + // Certificate statuses + Active: 'Active', + Expiring: 'Expiring soon', + Expired: 'Expired', + RenewalInProgress: 'Renewal in progress', + Archived: 'Archived', + Revoked: 'Revoked', + // Job statuses + Pending: 'Pending', + AwaitingCSR: 'Awaiting CSR', + AwaitingApproval: 'Awaiting approval', + Running: 'Running', + Completed: 'Completed', + Failed: 'Failed', + Cancelled: 'Cancelled', + // Agent statuses + Online: 'Online', + Offline: 'Offline', + Degraded: 'Degraded', + // Discovery statuses + Unmanaged: 'Unmanaged', + Managed: 'Managed', + Dismissed: 'Dismissed', + // Issuer statuses (frontend-synthesized) + Enabled: 'Enabled', + Disabled: 'Disabled', + // Notification statuses + sent: 'Sent', + pending: 'Pending', + failed: 'Failed', + dead: 'Dead-lettered', + read: 'Read', + // Health check statuses + healthy: 'Healthy', + degraded: 'Degraded', + down: 'Down', + cert_mismatch: 'Certificate mismatch', + unknown: 'Unknown', +}; + +// titleCase — best-effort humanizer for wire keys not in statusDisplay. +// Handles PascalCase ("RenewalInProgress" → "Renewal in progress") and +// snake_case ("cert_mismatch" → "Cert mismatch"). The render-time fallback; +// adding a proper entry to statusDisplay above is the preferred path. +function titleCase(s: string): string { + if (!s) return s; + // snake_case → space-separated lower + let out = s.replace(/_/g, ' '); + // PascalCase / camelCase → space before capitals (but not the first) + out = out.replace(/([a-z])([A-Z])/g, '$1 $2'); + // Lowercase everything, then capitalize the first character. + out = out.toLowerCase(); + return out.charAt(0).toUpperCase() + out.slice(1); +} + export default function StatusBadge({ status }: { status: string }) { const cls = statusStyles[status] || 'badge-neutral'; - return {status}; + const display = statusDisplay[status] ?? titleCase(status); + return {display}; } + +// Exported for the StatusBadge.test.tsx suite — pinning the byte-exact +// display strings for every wire key in one place. +export { statusStyles, statusDisplay, titleCase }; diff --git a/web/src/components/Toaster.test.tsx b/web/src/components/Toaster.test.tsx new file mode 100644 index 0000000..89e45ae --- /dev/null +++ b/web/src/components/Toaster.test.tsx @@ -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 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(); + // Sonner mounts a section[aria-label="Notifications "] 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(); + 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(); + act(() => { + toast.error('Save failed: not authorized'); + }); + expect( + await screen.findByText('Save failed: not authorized'), + ).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/Toaster.tsx b/web/src/components/Toaster.tsx new file mode 100644 index 0000000..507ca4a --- /dev/null +++ b/web/src/components/Toaster.tsx @@ -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 '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 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 ( + + ); +} diff --git a/web/src/components/Tooltip.test.tsx b/web/src/components/Tooltip.test.tsx new file mode 100644 index 0000000..915f87a --- /dev/null +++ b/web/src/components/Tooltip.test.tsx @@ -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( + + + , + ); + expect(screen.getByRole('button', { name: 'Hover me' })).toBeInTheDocument(); + expect(screen.queryByText('Hint')).not.toBeInTheDocument(); + }); + + it('reveals tooltip body on focus', () => { + render( + + + , + ); + 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( + + + , + ); + 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(); + }); +}); diff --git a/web/src/components/Tooltip.tsx b/web/src/components/Tooltip.tsx new file mode 100644 index 0000000..81875a5 --- /dev/null +++ b/web/src/components/Tooltip.tsx @@ -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: +// +// +// +// +// 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( + ' 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>, + { + ref: refs.setReference, + ...triggerProps, + }, + ); + + return ( + <> + {child} + {open && content && ( + +
+ {content} +
+
+ )} + + ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 0317b3b..66fdf81 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -58,6 +58,10 @@ import SessionsPage from './pages/auth/SessionsPage'; import BreakglassPage from './pages/auth/BreakglassPage'; // Audit 2026-05-10 MED-11 closure — federated-user admin page. import UsersPage from './pages/auth/UsersPage'; +// Phase 1 closure (UX-H3): toast / snackbar system. Mounted once near +// the root so any component can `import { toast } from "sonner"` and +// call toast.success / toast.error without provider plumbing. +import Toaster from './components/Toaster'; import './index.css'; const queryClient = new QueryClient({ @@ -74,6 +78,7 @@ createRoot(document.getElementById('root')!).render( + diff --git a/web/src/pages/AgentGroupsPage.tsx b/web/src/pages/AgentGroupsPage.tsx index 574efb1..53a5342 100644 --- a/web/src/pages/AgentGroupsPage.tsx +++ b/web/src/pages/AgentGroupsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client'; import PageHeader from '../components/PageHeader'; @@ -7,6 +8,7 @@ import DataTable from '../components/DataTable'; import type { Column } from '../components/DataTable'; import StatusBadge from '../components/StatusBadge'; import ErrorState from '../components/ErrorState'; +import ConfirmDialog from '../components/ConfirmDialog'; import { formatDateTime } from '../api/utils'; import type { AgentGroup } from '../api/types'; @@ -254,6 +256,7 @@ export default function AgentGroupsPage() { const queryClient = useQueryClient(); const [showCreate, setShowCreate] = useState(false); const [editingGroup, setEditingGroup] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); const { data, isLoading, error, refetch } = useQuery({ queryKey: ['agent-groups'], @@ -263,6 +266,8 @@ export default function AgentGroupsPage() { const deleteMutation = useTrackedMutation({ mutationFn: deleteAgentGroup, invalidates: [['agent-groups']], + onSuccess: () => toast.success('Agent group deleted'), + onError: (err: Error) => toast.error(`Delete failed: ${err.message}`), }); const createMutation = useTrackedMutation({ @@ -337,7 +342,7 @@ export default function AgentGroupsPage() { Edit
)} + {/* 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. */} + { + archiveMutation.mutate(); + setConfirmArchive(false); + }} + onCancel={() => setConfirmArchive(false)} + /> ); } diff --git a/web/src/pages/CertificatesPage.tsx b/web/src/pages/CertificatesPage.tsx index 7106578..327c68e 100644 --- a/web/src/pages/CertificatesPage.tsx +++ b/web/src/pages/CertificatesPage.tsx @@ -1,5 +1,7 @@ -import { useState } from 'react'; +import { Fragment, useState } from 'react'; +import { Transition } from '@headlessui/react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { useListParams } from '../hooks/useListParams'; import { useNavigate } from 'react-router-dom'; @@ -511,9 +513,29 @@ export default function CertificatesPage() { total: result.total_matched, running: false, }); - } catch { + // UX-L5 closure (Phase 1): post-action toast with a "View jobs" + // action that deep-links to the Jobs page filtered to the + // certificate IDs we just renewed. The audit's missing + // "what just happened" affordance — operators can now jump + // straight to the resulting jobs. + if (result.total_enqueued > 0) { + toast.success( + `Triggered renewal for ${result.total_enqueued} certificate${result.total_enqueued > 1 ? 's' : ''}`, + { + action: { + label: `View ${result.total_enqueued} jobs`, + onClick: () => + navigate(`/jobs?certificate_ids=${ids.join(',')}`), + }, + duration: 8000, + }, + ); + } + } catch (err) { // surface as a "0 of N" terminal state — no retries. setBulkRenewProgress({ done: 0, total: ids.length, running: false }); + const msg = err instanceof Error ? err.message : String(err); + toast.error(`Bulk renewal failed: ${msg}`); } queryClient.invalidateQueries({ queryKey: ['certificates'] }); setSelectedIds(new Set()); @@ -566,8 +588,20 @@ export default function CertificatesPage() { } /> - {/* Bulk Action Bar */} - {hasSelection && ( + {/* Bulk Action Bar — UX-L5 (Phase 1): Headless UI + 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. */} +
{selectedArray.length} selected
@@ -593,7 +627,7 @@ export default function CertificatesPage() {
- )} +
{/* Bulk Renewal Success */} {bulkRenewProgress && !bulkRenewProgress.running && ( diff --git a/web/src/pages/OwnersPage.tsx b/web/src/pages/OwnersPage.tsx index fcfb96e..0f51bea 100644 --- a/web/src/pages/OwnersPage.tsx +++ b/web/src/pages/OwnersPage.tsx @@ -1,11 +1,13 @@ import { useEffect, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { getOwners, getTeams, deleteOwner, createOwner, updateOwner } from '../api/client'; import PageHeader from '../components/PageHeader'; import DataTable from '../components/DataTable'; import type { Column } from '../components/DataTable'; import ErrorState from '../components/ErrorState'; +import ConfirmDialog from '../components/ConfirmDialog'; import { formatDateTime } from '../api/utils'; import type { Owner, Team } from '../api/types'; @@ -211,10 +213,13 @@ export default function OwnersPage() { queryFn: () => getTeams(), }); + const [confirmDelete, setConfirmDelete] = useState(null); + const deleteMutation = useTrackedMutation({ mutationFn: deleteOwner, invalidates: [['owners']], - onError: (err: Error) => alert(`Delete failed: ${err.message}`), + onSuccess: () => toast.success('Owner deleted'), + onError: (err: Error) => toast.error(`Delete failed: ${err.message}`), }); const createMutation = useTrackedMutation({ @@ -279,7 +284,7 @@ export default function OwnersPage() { Edit