mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
feat(frontend): Phase 5 Accessibility + Forms — close FE-H3 + UX-H4 primitive + FE-M1 primitive + axe-core gate
Closes the Phase 5 batch from cowork/frontend-design-audit.html: ships
the joint UX-H4 + FE-M1 lever (FormField primitive + react-hook-form +
zod schemas) and the FE-H3 fix (Headless UI Dialog focus trap on the 3
inline-managed modals), with an axe-core regression test + CI guard to
prevent UX-H4 regressions.
═════════════════════════ AUDIT VERIFICATION ═════════════════════════
Confirmed live against the repo before implementing:
• Q1 labels / htmlFor / input-id = 139 / 6 / 0
(audit said 138 / 6 / 0 — labels +1, otherwise accurate)
• Q2 no form library installed
(no react-hook-form, formik, @tanstack/react-form, final-form)
• Q3 3 inline-managed dialog sites confirmed:
SCEPAdminPage.tsx:272, AgentsPage.tsx:314, ESTAdminPage.tsx:281
• Q4 audit's top-6 list was OFF — actual top form-heaviest pages
by useState count are: OIDCProviderDetailPage 21, AgentGroupsPage
18, CertificatesPage 17, CertificateDetailPage 14, BreakglassPage
13, ProfilesPage 13 — NOT the audit-suggested OnboardingWizard 5
(now split in Phase 4) / OIDCProvidersPage 8 / IssuersPage 11 /
ProfilesPage 13 / TargetsPage 9 / ApprovalsPage 5. Audit's
intuition skipped the higher-useState pages.
• Q5 jest-dom imported in src/test/setup.ts — axe-core landed
cleanly
═════════════════════════════ CLOSURES ═══════════════════════════════
UX-H4 (label/input binding) — FormField primitive shipped
• web/src/components/FormField.tsx wraps a <label> + an input child
and auto-generates a stable id via React 18's useId(); cloneElement
threads that id onto BOTH the <label htmlFor> AND the child's id
prop so the WCAG 1.3.1 binding holds by construction. Supports
`required` (asterisk + aria-required), `description` (wires
aria-describedby), `error` (aria-invalid + role=alert + extends
aria-describedby). 7 tests pin the contract.
FE-M1 (no form library) — react-hook-form + @hookform/resolvers + zod
• Added react-hook-form 7.75, @hookform/resolvers 5.2, zod 4.4 as
runtime deps; @axe-core/react, jest-axe, @types/jest-axe as devDeps
• Representative migration of CreateTeamModalInline (inside
onboarding/CertificateStep — operator's first-run experience)
from 3-useState + manual handlers to useForm + zodResolver +
FormField. Schema at pages/onboarding/team.schema.ts.
• Per the audit's "top-6 only, primitive is the lever" rule, the
other 5 audit-suggested pages migrate organically as feature
work touches them — documented as Phase 5 follow-up. The
FormField primitive is the leverage point; per-page migrations
are mechanical applications.
FE-H3 (no focus trap on modal pages)
• New ModalDialog primitive at web/src/components/ModalDialog.tsx —
Headless UI Dialog wrapper for arbitrary-content modals
(complements ConfirmDialog which is confirm-only). Auto-emits
role=dialog + aria-modal + aria-labelledby + ESC-to-close +
backdrop-click-to-close + focus trap.
• All 3 inline-managed modal sites migrated:
• SCEPAdminPage ConfirmReloadModal
• ESTAdminPage ConfirmReloadModal (data-testid preserved)
• AgentsPage RetireAgentModal (3-mode: confirm / blocked / error
— title + footer change per mode; body slot stays the same)
• 37/37 existing modal-page tests stay green — no behavior change
visible to the test suite, only the focus-trap + ESC handling.
UX-H4 regression gate
• web/src/test/a11y.test.tsx runs axe-core (not jest-axe — its
`toHaveNoViolations` matcher uses jest's expect API which can't
plug into Vitest's expect.extend; fails with "expectAssertion.call
is not a function"). Direct axe.run + assert violations.length===0
gives the same gate with a readable failure message.
• Scope: primitives, not page sweeps. Primitives carry the risk
surface; pages compose them. 5 tests covering FormField (with +
without description/error), Skeleton (all 4 variants),
ModalDialog, Breadcrumbs. ~400ms total.
• Skeleton.table's empty <th> cells are decorative shimmers inside
a role=status + aria-busy=true tree — axe-core's
`empty-table-header` rule doesn't model aria-busy gating, so it
is suppressed for the Skeleton variant scan with a clear comment.
• scripts/ci-guards/no-unbound-label.sh — fails CI if a new <label>
without htmlFor lands. Baseline-driven (132 today) so the existing
backlog doesn't block CI; every migration to FormField drops the
baseline. `--strict` mode rejects any unbound label once the
backlog clears.
═══════════════════════════ VERIFICATION ═════════════════════════════
• npx tsc --noEmit — exits 0
• New tests: FormField 7/7, ModalDialog 6/6, a11y 5/5 = 18/18 new
• Component suite: 14 files / 150/150 green
• Page suite (representative subset run): 16 files in first run
(timeout truncated final summary) + 10 files / 48/48 in second
run — all green
• OnboardingWizard 4/4 (the migrated CreateTeamModalInline test
case is the second one — `+ New team opens the inline modal,
calls createTeam, invalidates the cache, and auto-selects the
new team`)
• SCEPAdminPage 20/20, ESTAdminPage 14/14, AgentsPage 3/3 — all
37 modal-page tests stay green after ModalDialog migration
• npm run build ✓ in 3.27s
• CI guard: bash scripts/ci-guards/no-unbound-label.sh — passes at
baseline 132 (current unbound count matches; failure mode is
only on increase). --strict path will fail until backlog clears.
═══════════════════════════ RESIDUAL RISK ════════════════════════════
• RHF migration risk: zod resolver's input/output type mismatch
bit me once during this work (description: z.string().optional()
gave Input: string|undefined vs Output: string after .default()).
Both sides typed as string + defaultValues providing empty string
fixes it; documented in team.schema.ts. Pattern applies to every
future Zod schema with optional-but-empty-string fields.
• The audit's "top-6" page list is stale (Phase 4 split
OnboardingWizard; useState ranks shifted). Future RHF migrations
should re-derive the priority list against live useState counts,
not the audit's stamped names.
• DataTable per-row React.memo (PERF-M1 follow-up from Phase 4)
remains deferred — orthogonal to Phase 5 scope.
This commit is contained in:
Generated
+602
-4
@@ -12,23 +12,29 @@
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@headlessui/react": "^2.2.10",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"recharts": "^3.8.0",
|
||||
"sonner": "^2.0.7"
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/react": "^4.11.3",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/jest-axe": "^3.5.9",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"jest-axe": "^10.0.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"orval": "^7.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
@@ -161,13 +167,23 @@
|
||||
"@types/json-schema": "^7.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@axe-core/react": {
|
||||
"version": "4.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@axe-core/react/-/react-4.11.3.tgz",
|
||||
"integrity": "sha512-G7TrxptKNFfrQZ+Iygb8nGx5w8Su0jIjwmtVN/9Jc4G6RumCh1rD3pZRT2NzZKjT70q4BReuWVwEazS3HxFfjg==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"axe-core": "~4.11.4",
|
||||
"requestidlecallback": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"js-tokens": "^4.0.0",
|
||||
@@ -183,7 +199,6 @@
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -998,6 +1013,18 @@
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.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",
|
||||
@@ -1059,6 +1086,85 @@
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/diff-sequences": {
|
||||
"version": "30.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz",
|
||||
"integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/expect-utils": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz",
|
||||
"integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/get-type": "30.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/get-type": {
|
||||
"version": "30.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
|
||||
"integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/pattern": {
|
||||
"version": "30.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz",
|
||||
"integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"jest-regex-util": "30.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/schemas": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz",
|
||||
"integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/types": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz",
|
||||
"integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/pattern": "30.4.0",
|
||||
"@jest/schemas": "30.4.1",
|
||||
"@types/istanbul-lib-coverage": "^2.0.6",
|
||||
"@types/istanbul-reports": "^3.0.4",
|
||||
"@types/node": "*",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"chalk": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2674,6 +2780,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.49",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
||||
"integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -3307,6 +3420,81 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-report": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz",
|
||||
"integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/istanbul-lib-coverage": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/istanbul-reports": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz",
|
||||
"integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/istanbul-lib-report": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest": {
|
||||
"version": "30.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz",
|
||||
"integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expect": "^30.0.0",
|
||||
"pretty-format": "^30.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest-axe": {
|
||||
"version": "3.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest-axe/-/jest-axe-3.5.9.tgz",
|
||||
"integrity": "sha512-z98CzR0yVDalCEuhGXXO4/zN4HHuSebAukXDjTLJyjEAgoUf1H1i+sr7SUB/mz8CRS/03/XChsx0dcLjHkndoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jest": "*",
|
||||
"axe-core": "^3.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest-axe/node_modules/axe-core": {
|
||||
"version": "3.5.6",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-3.5.6.tgz",
|
||||
"integrity": "sha512-LEUDjgmdJoA3LqklSTwKYqkjcZ4HKc4ddIYGSAiSkr46NTjzg2L9RNB+lekO9P7Dlpa87+hBtzc2Fzn/+GUWMQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jest/node_modules/pretty-format": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz",
|
||||
"integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/schemas": "30.4.1",
|
||||
"ansi-styles": "^5.2.0",
|
||||
"react-is-18": "npm:react-is@^18.3.1",
|
||||
"react-is-19": "npm:react-is@^19.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -3344,6 +3532,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -3364,6 +3559,23 @@
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
"integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs-parser": {
|
||||
"version": "21.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
|
||||
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
@@ -3615,7 +3827,6 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -3812,6 +4023,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.11.4",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz",
|
||||
"integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
@@ -4087,6 +4308,22 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/ci-info": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
|
||||
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
@@ -4533,6 +4770,16 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/diff-sequences": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
||||
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
@@ -4854,6 +5101,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
|
||||
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
@@ -4914,6 +5171,24 @@
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/expect": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz",
|
||||
"integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/expect-utils": "30.4.1",
|
||||
"@jest/get-type": "30.1.0",
|
||||
"jest-matcher-utils": "30.4.1",
|
||||
"jest-message-util": "30.4.1",
|
||||
"jest-mock": "30.4.1",
|
||||
"jest-util": "30.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -5947,6 +6222,261 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jest-axe": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-axe/-/jest-axe-10.0.0.tgz",
|
||||
"integrity": "sha512-9QR0M7//o5UVRnEUUm68IsGapHrcKGakYy9dKWWMX79LmeUKguDI6DREyljC5I13j78OUmtKLF5My6ccffLFBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axe-core": "4.10.2",
|
||||
"chalk": "4.1.2",
|
||||
"jest-matcher-utils": "29.2.2",
|
||||
"lodash.merge": "4.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-axe/node_modules/@jest/schemas": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
|
||||
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.27.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-axe/node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.10",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
|
||||
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jest-axe/node_modules/axe-core": {
|
||||
"version": "4.10.2",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz",
|
||||
"integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-axe/node_modules/jest-diff": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
|
||||
"integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.0.0",
|
||||
"diff-sequences": "^29.6.3",
|
||||
"jest-get-type": "^29.6.3",
|
||||
"pretty-format": "^29.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-axe/node_modules/jest-matcher-utils": {
|
||||
"version": "29.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.2.2.tgz",
|
||||
"integrity": "sha512-4DkJ1sDPT+UX2MR7Y3od6KtvRi9Im1ZGLGgdLFLm4lPexbTaCgJW5NN3IOXlQHF7NSHY/VHhflQ+WoKtD/vyCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.0.0",
|
||||
"jest-diff": "^29.2.1",
|
||||
"jest-get-type": "^29.2.0",
|
||||
"pretty-format": "^29.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-axe/node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/schemas": "^29.6.3",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^18.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-axe/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jest-diff": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz",
|
||||
"integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/diff-sequences": "30.4.0",
|
||||
"@jest/get-type": "30.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"pretty-format": "30.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-diff/node_modules/pretty-format": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz",
|
||||
"integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/schemas": "30.4.1",
|
||||
"ansi-styles": "^5.2.0",
|
||||
"react-is-18": "npm:react-is@^18.3.1",
|
||||
"react-is-19": "npm:react-is@^19.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-get-type": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
|
||||
"integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-matcher-utils": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz",
|
||||
"integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/get-type": "30.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"jest-diff": "30.4.1",
|
||||
"pretty-format": "30.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-matcher-utils/node_modules/pretty-format": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz",
|
||||
"integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/schemas": "30.4.1",
|
||||
"ansi-styles": "^5.2.0",
|
||||
"react-is-18": "npm:react-is@^18.3.1",
|
||||
"react-is-19": "npm:react-is@^19.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-message-util": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz",
|
||||
"integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@jest/types": "30.4.1",
|
||||
"@types/stack-utils": "^2.0.3",
|
||||
"chalk": "^4.1.2",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"jest-util": "30.4.1",
|
||||
"picomatch": "^4.0.3",
|
||||
"pretty-format": "30.4.1",
|
||||
"slash": "^3.0.0",
|
||||
"stack-utils": "^2.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-message-util/node_modules/pretty-format": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz",
|
||||
"integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/schemas": "30.4.1",
|
||||
"ansi-styles": "^5.2.0",
|
||||
"react-is-18": "npm:react-is@^18.3.1",
|
||||
"react-is-19": "npm:react-is@^19.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-mock": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz",
|
||||
"integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/types": "30.4.1",
|
||||
"@types/node": "*",
|
||||
"jest-util": "30.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-regex-util": {
|
||||
"version": "30.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz",
|
||||
"integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-util": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz",
|
||||
"integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/types": "30.4.1",
|
||||
"@types/node": "*",
|
||||
"chalk": "^4.1.2",
|
||||
"ci-info": "^4.2.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -6424,6 +6954,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.omitby": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.omitby/-/lodash.omitby-4.6.0.tgz",
|
||||
@@ -7615,6 +8152,22 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.75.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz",
|
||||
"integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -7622,6 +8175,22 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-is-18": {
|
||||
"name": "react-is",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-is-19": {
|
||||
"name": "react-is",
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
|
||||
"integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
@@ -7899,6 +8468,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/requestidlecallback": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/requestidlecallback/-/requestidlecallback-0.3.0.tgz",
|
||||
"integrity": "sha512-TWHFkT7S9p7IxLC5A1hYmAYQx2Eb9w1skrXmQ+dS1URyvR8tenMLl4lHbqEOUnpEYxNKpkVMXUgknVpBZWXXfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -8358,6 +8934,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-utils": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||
"integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -9626,6 +10215,15 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -18,23 +18,29 @@
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@headlessui/react": "^2.2.10",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"recharts": "^3.8.0",
|
||||
"sonner": "^2.0.7"
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/react": "^4.11.3",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/jest-axe": "^3.5.9",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"jest-axe": "^10.0.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"orval": "^7.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import FormField from './FormField';
|
||||
|
||||
describe('FormField', () => {
|
||||
it('label htmlFor matches input id (the WCAG 1.3.1 contract)', () => {
|
||||
render(
|
||||
<FormField label="Email">
|
||||
<input type="email" />
|
||||
</FormField>,
|
||||
);
|
||||
const label = screen.getByText('Email');
|
||||
const input = screen.getByLabelText('Email');
|
||||
// Programmatic label association — what screen readers use.
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(label).toHaveAttribute('for', input.id);
|
||||
// useId() gives a non-empty id by definition.
|
||||
expect(input.id).toMatch(/^field-/);
|
||||
});
|
||||
|
||||
it('two siblings get independent ids (no collision)', () => {
|
||||
render(
|
||||
<>
|
||||
<FormField label="Name"><input /></FormField>
|
||||
<FormField label="Description"><input /></FormField>
|
||||
</>,
|
||||
);
|
||||
const a = screen.getByLabelText('Name');
|
||||
const b = screen.getByLabelText('Description');
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
it('required surfaces the asterisk + aria-required on the child', () => {
|
||||
render(
|
||||
<FormField label="Email" required>
|
||||
<input type="email" />
|
||||
</FormField>,
|
||||
);
|
||||
expect(screen.getByText('*')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Email/)).toHaveAttribute('aria-required', 'true');
|
||||
});
|
||||
|
||||
it('description wires aria-describedby to the child', () => {
|
||||
render(
|
||||
<FormField label="Token" description="Paste the API key from /auth/keys">
|
||||
<input />
|
||||
</FormField>,
|
||||
);
|
||||
const input = screen.getByLabelText('Token');
|
||||
const desc = screen.getByText(/Paste the API key/);
|
||||
expect(input.getAttribute('aria-describedby')).toContain(desc.id);
|
||||
});
|
||||
|
||||
it('error sets aria-invalid + role=alert + extends aria-describedby', () => {
|
||||
render(
|
||||
<FormField label="Email" error="Must be a valid email address">
|
||||
<input type="email" />
|
||||
</FormField>,
|
||||
);
|
||||
const input = screen.getByLabelText('Email');
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true');
|
||||
const err = screen.getByRole('alert');
|
||||
expect(err).toHaveTextContent('Must be a valid email address');
|
||||
expect(input.getAttribute('aria-describedby')).toContain(err.id);
|
||||
});
|
||||
|
||||
it('composes cleanly with react-hook-form register() — spread + clone preserves both', () => {
|
||||
function Form({ onSubmit }: { onSubmit: (v: { name: string }) => void }) {
|
||||
const { register, handleSubmit } = useForm<{ name: string }>();
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormField label="Name">
|
||||
<input {...register('name')} />
|
||||
</FormField>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
let captured = '';
|
||||
render(<Form onSubmit={(v) => { captured = v.name; }} />);
|
||||
const input = screen.getByLabelText('Name');
|
||||
fireEvent.change(input, { target: { value: 'alice' } });
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
expect(captured).toBe('alice');
|
||||
// Both RHF's name and FormField's id co-exist.
|
||||
expect(input.getAttribute('name')).toBe('name');
|
||||
expect(input.id).toMatch(/^field-/);
|
||||
resolve();
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws clearly when child is not a single valid element', () => {
|
||||
// Suppress React's error-boundary console spam for this assertion.
|
||||
const orig = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
expect(() =>
|
||||
render(
|
||||
<FormField label="Bad">
|
||||
{'plain string is not valid'}
|
||||
</FormField>,
|
||||
),
|
||||
).toThrow();
|
||||
} finally {
|
||||
console.error = orig;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// FormField — Phase 5 closure for UX-H4 + the foundation of FE-M1.
|
||||
//
|
||||
// Pre-Phase-5 state: 139 <label> elements in production tsx; 6 with
|
||||
// htmlFor; 0 inputs with id. WCAG 1.3.1 (info-and-relationships) fails
|
||||
// on ~99% of form fields — screen readers can't programmatically pair
|
||||
// a label with its input, so "Email" reads as a floating string rather
|
||||
// than as the accessible name of the adjacent input.
|
||||
//
|
||||
// FormField fixes this by generating a stable id with React 18's
|
||||
// useId() and threading it to BOTH the <label htmlFor=...> AND the
|
||||
// child input's id prop via cloneElement. Consumers write:
|
||||
//
|
||||
// <FormField label="Email" required>
|
||||
// <input type="email" value={email} onChange={…} />
|
||||
// </FormField>
|
||||
//
|
||||
// — no manual id wiring, no risk of id-mismatch drift, no chance a
|
||||
// developer copies the JSX and forgets to update one of the two
|
||||
// strings. The label-↔-input binding is correct by construction.
|
||||
//
|
||||
// Composition with react-hook-form is straight-forward — RHF's
|
||||
// register('field') returns onChange/onBlur/ref/name which spread onto
|
||||
// the input alongside FormField's auto-id. The Zod-resolver path picks
|
||||
// up errors and FormField surfaces them via the `error` prop slot.
|
||||
|
||||
import { Children, cloneElement, isValidElement, useId } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
interface FormFieldProps {
|
||||
/** Visible label text. Required for a11y — never render an unbound input. */
|
||||
label: string;
|
||||
/** Render `*` next to the label when true (display-only; validation lives in Zod). */
|
||||
required?: boolean;
|
||||
/** Optional helper / description text below the input. */
|
||||
description?: string;
|
||||
/** Optional error message — when set, surfaces below the input + flags aria-invalid. */
|
||||
error?: string;
|
||||
/** Optional class override for the wrapping div. */
|
||||
className?: string;
|
||||
/**
|
||||
* Exactly one input-shaped child (<input>, <select>, <textarea>, or any
|
||||
* forwardRef'd component that accepts `id` + `aria-describedby` +
|
||||
* `aria-invalid` as props). FormField clones it and injects the
|
||||
* auto-generated id so the label-↔-input pairing is correct by
|
||||
* construction.
|
||||
*/
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function FormField({
|
||||
label,
|
||||
required,
|
||||
description,
|
||||
error,
|
||||
className,
|
||||
children,
|
||||
}: FormFieldProps) {
|
||||
// useId() returns a stable id that's unique per render-tree-position,
|
||||
// safe under StrictMode, and SSR-friendly. Two siblings get different
|
||||
// ids automatically.
|
||||
const reactId = useId();
|
||||
const inputId = `field-${reactId}`;
|
||||
const descId = description ? `desc-${reactId}` : undefined;
|
||||
const errorId = error ? `err-${reactId}` : undefined;
|
||||
|
||||
// Build the aria-describedby chain from optional description + error.
|
||||
// Browsers concatenate space-separated ids, so screen readers announce
|
||||
// "Email, [description], [error]".
|
||||
const describedBy = [descId, errorId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
const onlyChild = Children.only(children);
|
||||
if (!isValidElement(onlyChild)) {
|
||||
// Surface a clear error in dev rather than render a broken control.
|
||||
throw new Error('FormField expects exactly one valid React element child');
|
||||
}
|
||||
|
||||
// cloneElement preserves the child's existing props (including any
|
||||
// RHF `register(...)` spread) and overlays the FormField-managed
|
||||
// a11y props on top. The child's `id` / `aria-*` are always set
|
||||
// here, but `name`/`value`/`onChange` from the child are preserved.
|
||||
const childWithA11y = cloneElement(
|
||||
onlyChild as ReactElement<Record<string, unknown>>,
|
||||
{
|
||||
id: inputId,
|
||||
'aria-describedby': describedBy,
|
||||
'aria-invalid': error ? true : undefined,
|
||||
'aria-required': required ? true : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className ?? 'mb-4'}>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-ink mb-1.5"
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="text-red-600 ml-0.5" aria-hidden="true">*</span>
|
||||
)}
|
||||
</label>
|
||||
{childWithA11y}
|
||||
{description && (
|
||||
<p id={descId} className="mt-1 text-xs text-ink-muted">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="mt-1 text-xs text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ModalDialog from './ModalDialog';
|
||||
|
||||
describe('ModalDialog', () => {
|
||||
it('renders nothing when open=false', () => {
|
||||
render(
|
||||
<ModalDialog open={false} title="Hidden" onClose={() => {}}>
|
||||
body content
|
||||
</ModalDialog>,
|
||||
);
|
||||
expect(screen.queryByText('Hidden')).toBeNull();
|
||||
expect(screen.queryByText('body content')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders title + children when open', () => {
|
||||
render(
|
||||
<ModalDialog open={true} title="Confirm thing" onClose={() => {}}>
|
||||
<p>This is the body</p>
|
||||
</ModalDialog>,
|
||||
);
|
||||
expect(screen.getByText('Confirm thing')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is the body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Headless UI sets role=dialog + aria-modal on the panel', () => {
|
||||
render(
|
||||
<ModalDialog open={true} title="t" onClose={() => {}}>
|
||||
<span>body</span>
|
||||
</ModalDialog>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
});
|
||||
|
||||
it('title acts as aria-labelledby target', () => {
|
||||
render(
|
||||
<ModalDialog open={true} title="Pin me" onClose={() => {}}>
|
||||
<span>body</span>
|
||||
</ModalDialog>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const labelId = dialog.getAttribute('aria-labelledby');
|
||||
expect(labelId).toBeTruthy();
|
||||
const labelEl = document.getElementById(labelId!);
|
||||
expect(labelEl).toHaveTextContent('Pin me');
|
||||
});
|
||||
|
||||
it('ESC key fires onClose', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ModalDialog open={true} title="x" onClose={onClose}>
|
||||
<span>body</span>
|
||||
</ModalDialog>,
|
||||
);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('footer renders separately when provided', () => {
|
||||
render(
|
||||
<ModalDialog
|
||||
open={true}
|
||||
title="x"
|
||||
onClose={() => {}}
|
||||
footer={<button>OK</button>}
|
||||
>
|
||||
body
|
||||
</ModalDialog>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// ModalDialog — Phase 5 closure for FE-H3 (3 inline-managed modal
|
||||
// pages — SCEPAdminPage, AgentsPage, ESTAdminPage — set
|
||||
// role="dialog" + aria-modal="true" + aria-labelledby but no focus
|
||||
// trap, no ESC-to-close, no backdrop-click-to-close).
|
||||
//
|
||||
// Built on Headless UI's <Dialog>, identical pattern to ConfirmDialog
|
||||
// (Phase 1) but accepts arbitrary <ModalDialog.Body> content rather
|
||||
// than the constrained confirm/cancel button pair ConfirmDialog
|
||||
// provides. Use ConfirmDialog for "click YES to do destructive thing";
|
||||
// use ModalDialog for "modal that contains a form / multi-action
|
||||
// content / a status display".
|
||||
//
|
||||
// What Headless UI gives us for free (same as ConfirmDialog):
|
||||
// • automatic focus trap (Tab/Shift-Tab stays inside the dialog)
|
||||
// • automatic ESC-to-close → onClose() callback
|
||||
// • automatic backdrop-click-to-close → onClose() callback
|
||||
// • role="dialog" + aria-modal="true" on the panel
|
||||
// • aria-labelledby on the title node
|
||||
// • <Transition> respects prefers-reduced-motion via the global
|
||||
// @media block in src/index.css
|
||||
//
|
||||
// FE-H3 closure scope: the 3 inline-managed modal sites all get
|
||||
// migrated to this primitive in the same commit. ConfirmDialog stays
|
||||
// as-is for confirm-only flows it already serves.
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
|
||||
export interface ModalDialogProps {
|
||||
/** Controls visibility. Parent owns the boolean. */
|
||||
open: boolean;
|
||||
/** Title shown at the top — also acts as aria-labelledby target. */
|
||||
title: string;
|
||||
/** Fires on ESC, backdrop click, or external close trigger. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Dialog body — render the form, status, or multi-action content here.
|
||||
* The body is wrapped in the styled panel; consumers don't need to
|
||||
* wrap their content in another <div>.
|
||||
*/
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Footer slot for action buttons. Optional — some modals (e.g. error
|
||||
* displays) only show a "Close" affordance which can live inside
|
||||
* children. When provided, footer is separated by a top border.
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
/** Maximum width — defaults to `max-w-md` (matches ConfirmDialog). */
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
}
|
||||
|
||||
const maxWidthMap = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
} as const;
|
||||
|
||||
export default function ModalDialog({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
maxWidth = 'md',
|
||||
}: ModalDialogProps) {
|
||||
return (
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog onClose={onClose} className="relative z-50">
|
||||
{/* Backdrop. Headless UI wires backdrop-click → onClose. */}
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* Panel container. */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={`bg-surface w-full ${maxWidthMap[maxWidth]} rounded-lg shadow-xl border border-surface-border`}
|
||||
>
|
||||
<div className="p-6">
|
||||
<Dialog.Title className="text-base font-semibold text-ink mb-3">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<div className="text-sm text-ink">{children}</div>
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
+124
-119
@@ -9,6 +9,7 @@ import {
|
||||
BlockedByDependenciesError,
|
||||
} from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ModalDialog from '../components/ModalDialog';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
@@ -309,129 +310,133 @@ function RetireModal({
|
||||
}) {
|
||||
if (mode.kind === 'closed') return null;
|
||||
|
||||
// Phase 5 closure (FE-H3): swapped inline `<div role="dialog">` markup
|
||||
// for ModalDialog (Headless UI). Each of the 3 modes (confirm / blocked /
|
||||
// error) renders inside the same dialog shell, so focus trap + ESC + click-
|
||||
// outside come for free. Title + footer change per mode; body is the
|
||||
// mode-specific content.
|
||||
const title =
|
||||
mode.kind === 'confirm' ? 'Retire agent' :
|
||||
mode.kind === 'blocked' ? 'Cannot retire — active dependencies' :
|
||||
/* error */ 'Retire failed';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/40"
|
||||
onClick={onClose}
|
||||
<ModalDialog
|
||||
open={true}
|
||||
title={title}
|
||||
onClose={pending ? () => {} : onClose}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
mode.kind === 'confirm' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
||||
disabled={pending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSoftRetire}
|
||||
disabled={pending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Retiring…' : 'Retire'}
|
||||
</button>
|
||||
</>
|
||||
) : mode.kind === 'blocked' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
||||
disabled={pending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForceRetire}
|
||||
// Backend enforces reason on force; keep the GUI in lockstep
|
||||
// rather than letting a 400 bounce back.
|
||||
disabled={pending || !mode.reason.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Force-retiring…' : 'Force retire'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-lg bg-surface p-6 shadow-lg border border-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{mode.kind === 'confirm' && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-ink">Retire agent</h2>
|
||||
<p className="mt-2 text-sm text-ink-muted">
|
||||
<span className="font-mono">{mode.agent.name}</span> ({mode.agent.id}) will be
|
||||
soft-retired. The agent will stop receiving heartbeats and be removed from active
|
||||
listings. This is reversible only by direct database intervention.
|
||||
</p>
|
||||
<label className="mt-4 block text-xs font-medium text-ink-muted">
|
||||
Reason (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={mode.reason}
|
||||
onChange={(e) => onReasonChange(e.target.value)}
|
||||
placeholder="e.g. decommissioning rack 7"
|
||||
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
||||
disabled={pending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSoftRetire}
|
||||
disabled={pending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Retiring…' : 'Retire'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mode.kind === 'confirm' && (
|
||||
<>
|
||||
<p className="text-sm text-ink-muted">
|
||||
<span className="font-mono">{mode.agent.name}</span> ({mode.agent.id}) will be
|
||||
soft-retired. The agent will stop receiving heartbeats and be removed from active
|
||||
listings. This is reversible only by direct database intervention.
|
||||
</p>
|
||||
<label className="mt-4 block text-xs font-medium text-ink-muted">
|
||||
Reason (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={mode.reason}
|
||||
onChange={(e) => onReasonChange(e.target.value)}
|
||||
placeholder="e.g. decommissioning rack 7"
|
||||
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode.kind === 'blocked' && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-ink">Cannot retire — active dependencies</h2>
|
||||
<p className="mt-2 text-sm text-ink-muted">
|
||||
The agent <span className="font-mono">{mode.agent.name}</span> still has downstream
|
||||
work tied to it. Force-retiring will cascade-retire all active targets and fail any
|
||||
pending jobs.
|
||||
</p>
|
||||
<dl className="mt-4 grid grid-cols-3 gap-3 text-center">
|
||||
<div className="rounded border border-border bg-surface-alt p-3">
|
||||
<dt className="text-xs text-ink-muted">Active targets</dt>
|
||||
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.active_targets}</dd>
|
||||
</div>
|
||||
<div className="rounded border border-border bg-surface-alt p-3">
|
||||
<dt className="text-xs text-ink-muted">Active certs</dt>
|
||||
<dd className="mt-1 text-xl font-semibold text-ink">
|
||||
{mode.counts.active_certificates}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded border border-border bg-surface-alt p-3">
|
||||
<dt className="text-xs text-ink-muted">Pending jobs</dt>
|
||||
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.pending_jobs}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<label className="mt-4 block text-xs font-medium text-ink-muted">
|
||||
Reason <span className="text-danger">(required for force retire)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mode.reason}
|
||||
onChange={(e) => onReasonChange(e.target.value)}
|
||||
placeholder="e.g. rack 7 decommission, cascade retire"
|
||||
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
||||
disabled={pending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForceRetire}
|
||||
// Backend enforces reason on force; keep the GUI in lockstep
|
||||
// rather than letting a 400 bounce back.
|
||||
disabled={pending || !mode.reason.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Force-retiring…' : 'Force retire'}
|
||||
</button>
|
||||
{mode.kind === 'blocked' && (
|
||||
<>
|
||||
<p className="text-sm text-ink-muted">
|
||||
The agent <span className="font-mono">{mode.agent.name}</span> still has downstream
|
||||
work tied to it. Force-retiring will cascade-retire all active targets and fail any
|
||||
pending jobs.
|
||||
</p>
|
||||
<dl className="mt-4 grid grid-cols-3 gap-3 text-center">
|
||||
<div className="rounded border border-border bg-surface-alt p-3">
|
||||
<dt className="text-xs text-ink-muted">Active targets</dt>
|
||||
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.active_targets}</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="rounded border border-border bg-surface-alt p-3">
|
||||
<dt className="text-xs text-ink-muted">Active certs</dt>
|
||||
<dd className="mt-1 text-xl font-semibold text-ink">
|
||||
{mode.counts.active_certificates}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded border border-border bg-surface-alt p-3">
|
||||
<dt className="text-xs text-ink-muted">Pending jobs</dt>
|
||||
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.pending_jobs}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<label className="mt-4 block text-xs font-medium text-ink-muted">
|
||||
Reason <span className="text-danger">(required for force retire)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mode.reason}
|
||||
onChange={(e) => onReasonChange(e.target.value)}
|
||||
placeholder="e.g. rack 7 decommission, cascade retire"
|
||||
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode.kind === 'error' && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-ink">Retire failed</h2>
|
||||
<p className="mt-2 text-sm text-danger">{mode.message}</p>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{mode.kind === 'error' && (
|
||||
<p className="text-sm text-danger">{mode.message}</p>
|
||||
)}
|
||||
</ModalDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getAuditEvents,
|
||||
} from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ModalDialog from '../components/ModalDialog';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { useAuth } from '../components/AuthProvider';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
@@ -276,30 +277,18 @@ interface ConfirmReloadModalProps {
|
||||
|
||||
function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) {
|
||||
const pathLabel = profile.path_id || '(legacy /.well-known/est root)';
|
||||
// Phase 5 closure (FE-H3): swapped the inline-managed <div role="dialog">
|
||||
// for ModalDialog (Headless UI) — focus trap + ESC-to-close + backdrop-
|
||||
// click-to-close come for free. Existing test data-testids preserved
|
||||
// verbatim so est-reload-cancel / est-reload-confirm / est-reload-error
|
||||
// assertions keep working.
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-labelledby="est-reload-trust-title"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
>
|
||||
<div className="bg-surface w-full max-w-md rounded-lg shadow-xl border border-surface-border p-6">
|
||||
<h3 id="est-reload-trust-title" className="text-base font-semibold text-ink mb-2">
|
||||
Reload EST mTLS trust anchor
|
||||
</h3>
|
||||
<p className="text-sm text-ink-muted mb-4">
|
||||
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
|
||||
swaps the trust pool for EST profile <strong>{pathLabel}</strong>. Equivalent to sending
|
||||
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
|
||||
previous trust pool stays in place — enrollments keep working off the old trust anchor while you
|
||||
fix the file.
|
||||
</p>
|
||||
{errorMessage && (
|
||||
<div className="mb-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800" data-testid="est-reload-error">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<ModalDialog
|
||||
open={true}
|
||||
title="Reload EST mTLS trust anchor"
|
||||
onClose={pending ? () => {} : onCancel}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
@@ -318,9 +307,22 @@ function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessag
|
||||
>
|
||||
{pending ? 'Reloading…' : 'Reload trust anchor'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-muted mb-3">
|
||||
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
|
||||
swaps the trust pool for EST profile <strong>{pathLabel}</strong>. Equivalent to sending
|
||||
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
|
||||
previous trust pool stays in place — enrollments keep working off the old trust anchor while you
|
||||
fix the file.
|
||||
</p>
|
||||
{errorMessage && (
|
||||
<div className="rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800" data-testid="est-reload-error">
|
||||
{errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getAuditEvents,
|
||||
} from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ModalDialog from '../components/ModalDialog';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { useAuth } from '../components/AuthProvider';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
@@ -267,30 +268,19 @@ interface ConfirmReloadModalProps {
|
||||
|
||||
function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) {
|
||||
const pathLabel = profile.path_id || '(legacy /scep root)';
|
||||
// Phase 5 closure (FE-H3): swapped the inline-managed <div role="dialog">
|
||||
// for ModalDialog (Headless UI) so the operator gets focus trap, ESC-to-
|
||||
// close, and backdrop-click-to-close. Pre-Phase-5 the modal had aria
|
||||
// attrs but no focus management — Tab would escape out of the panel into
|
||||
// the underlying page, and ESC did nothing. ModalDialog wires both to
|
||||
// onCancel automatically.
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-labelledby="reload-trust-title"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
>
|
||||
<div className="bg-surface w-full max-w-md rounded-lg shadow-xl border border-surface-border p-6">
|
||||
<h3 id="reload-trust-title" className="text-base font-semibold text-ink mb-2">
|
||||
Reload Intune trust anchor
|
||||
</h3>
|
||||
<p className="text-sm text-ink-muted mb-4">
|
||||
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
|
||||
swaps the trust pool for SCEP profile <strong>{pathLabel}</strong>. Equivalent to sending
|
||||
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
|
||||
previous trust pool stays in place — enrollments keep working off the old trust anchor while you
|
||||
fix the file.
|
||||
</p>
|
||||
{errorMessage && (
|
||||
<div className="mb-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<ModalDialog
|
||||
open={true}
|
||||
title="Reload Intune trust anchor"
|
||||
onClose={pending ? () => {} : onCancel}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
@@ -307,9 +297,22 @@ function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessag
|
||||
>
|
||||
{pending ? 'Reloading…' : 'Reload trust anchor'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-muted mb-3">
|
||||
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
|
||||
swaps the trust pool for SCEP profile <strong>{pathLabel}</strong>. Equivalent to sending
|
||||
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
|
||||
previous trust pool stays in place — enrollments keep working off the old trust anchor while you
|
||||
fix the file.
|
||||
</p>
|
||||
{errorMessage && (
|
||||
<div className="rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800">
|
||||
{errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,81 +11,117 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTrackedMutation } from '../../hooks/useTrackedMutation';
|
||||
import {
|
||||
getIssuers, getAgents, getProfiles, getOwners, getTeams, getRenewalPolicies,
|
||||
createCertificate, triggerRenewal, createTeam, createOwner,
|
||||
} from '../../api/client';
|
||||
import FormField from '../../components/FormField';
|
||||
import ModalDialog from '../../components/ModalDialog';
|
||||
import { teamSchema, type TeamFormValues } from './team.schema';
|
||||
import { WizardFooter } from './StepShell';
|
||||
|
||||
// Inline CreateTeamModal — mirrors TeamsPage.tsx CreateTeamModal pattern.
|
||||
// Used inside CertificateStep so users can create a team without leaving the wizard.
|
||||
// Phase 5 closure (FE-M1 + UX-H4): converted from 3 useState + manual
|
||||
// onChange handlers to react-hook-form + zodResolver + FormField. The
|
||||
// FormField primitive auto-pairs <label htmlFor> with <input id> via
|
||||
// useId(), so the WCAG 1.3.1 binding contract holds by construction.
|
||||
// Zod schema lives in team.schema.ts so it can be reused if another
|
||||
// page needs the same create-team contract.
|
||||
function CreateTeamModalInline({ isOpen, onClose, onCreated }: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (teamId: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [serverError, setServerError] = useState('');
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TeamFormValues>({
|
||||
resolver: zodResolver(teamSchema),
|
||||
// Validate on submit (which fires when the form-bound footer button
|
||||
// dispatches "submit") rather than gating the button on `isValid`.
|
||||
// RHF's isValid doesn't reliably flip synchronously after a single
|
||||
// fireEvent.change in jsdom — submit-time validation via the Zod
|
||||
// resolver gives the same UX (errors land under the field) without
|
||||
// the timing footgun.
|
||||
mode: 'onSubmit',
|
||||
defaultValues: { name: '', description: '' },
|
||||
});
|
||||
|
||||
const mutation = useTrackedMutation({
|
||||
mutationFn: () => createTeam({ name: name.trim(), description: description.trim() }),
|
||||
mutationFn: (values: TeamFormValues) =>
|
||||
createTeam({ name: values.name, description: values.description }),
|
||||
invalidates: [['teams']],
|
||||
onSuccess: (team) => {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setError('');
|
||||
reset();
|
||||
setServerError('');
|
||||
onCreated(team.id);
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => setError(err.message),
|
||||
onError: (err: Error) => setServerError(err.message),
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
const onSubmit = (values: TeamFormValues) => {
|
||||
setServerError('');
|
||||
mutation.mutate(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-ink mb-4">Create Team</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||
<form onSubmit={(e) => { e.preventDefault(); if (!name.trim()) return; mutation.mutate(); }} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-2">
|
||||
Name <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Platform Engineering"
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-2">
|
||||
Description <span className="text-xs text-ink-muted font-normal">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending || !name.trim()}
|
||||
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{mutation.isPending ? 'Creating...' : 'Create Team'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<ModalDialog
|
||||
open={isOpen}
|
||||
title="Create Team"
|
||||
onClose={isSubmitting ? () => {} : () => { reset(); setServerError(''); onClose(); }}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { reset(); setServerError(''); onClose(); }}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="create-team-form"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Team'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{serverError && (
|
||||
<div className="mb-3 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
<form id="create-team-form" onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
||||
<FormField label="Name" required error={errors.name?.message}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Platform Engineering"
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
{...register('name')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Description" description="Optional — what does this team own?">
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
{...register('description')}
|
||||
/>
|
||||
</FormField>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// Zod schema for the inline "Create Team" form inside CertificateStep
|
||||
// (the wizard's third step). Phase 5 closure scaffolding for FE-M1 +
|
||||
// UX-H4 — proves the FormField + react-hook-form + zodResolver pattern
|
||||
// on a small, contained form that's part of the operator's first-run
|
||||
// experience.
|
||||
//
|
||||
// Backend contract: POST /api/v1/teams accepts `{ name: string,
|
||||
// description?: string }`. Name is required (handler 400s on empty);
|
||||
// description is optional. We mirror that here so submit-time
|
||||
// validation matches what the server will accept.
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Both fields typed as string (no `optional()`) so the schema's input
|
||||
// and output types match — RHF + zodResolver require Input == Output
|
||||
// when the resolver TFieldValues generic is invariant. Description
|
||||
// defaults to '' from the form's defaultValues; the backend treats
|
||||
// empty-string and absent identically (handler at internal/api/handler/
|
||||
// teams.go:34 calls strings.TrimSpace before checking len).
|
||||
export const teamSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Team name is required'),
|
||||
description: z
|
||||
.string()
|
||||
.trim(),
|
||||
});
|
||||
|
||||
export type TeamFormValues = z.infer<typeof teamSchema>;
|
||||
@@ -0,0 +1,112 @@
|
||||
// Phase 5 closure (FE-H3 + UX-H4 regression gate): axe-core a11y
|
||||
// assertions on the primitives that other pages reuse. Failing this
|
||||
// suite means a future change reintroduced an unbound label, a missing
|
||||
// aria-* attr on a primitive, or a similar a11y bug.
|
||||
//
|
||||
// Implementation notes:
|
||||
// • Uses axe-core directly (not jest-axe) — jest-axe's
|
||||
// `toHaveNoViolations` matcher uses the jest expect API, which
|
||||
// Vitest's expect.extend can't host (TypeError: expectAssertion.call
|
||||
// is not a function). Asserting violations.length === 0 with a
|
||||
// readable failure message gives the same gate without the
|
||||
// compatibility headache.
|
||||
// • Scope is primitives, not page sweeps — primitives carry the risk
|
||||
// surface, pages mostly compose them. Faster runtime + tighter
|
||||
// fail signal when a primitive regresses.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import axe from 'axe-core';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import FormField from '../components/FormField';
|
||||
import ModalDialog from '../components/ModalDialog';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import Breadcrumbs from '../components/Breadcrumbs';
|
||||
|
||||
async function expectNoViolations(
|
||||
container: HTMLElement,
|
||||
extraSuppressedRules: string[] = [],
|
||||
) {
|
||||
const suppressed: Record<string, { enabled: false }> = {
|
||||
// color-contrast needs computed-styles which jsdom doesn't compute;
|
||||
// that rule is suppressed in axe defaults under jsdom anyway but
|
||||
// pinning it here keeps the failure mode loud if axe-core changes
|
||||
// default behavior.
|
||||
'color-contrast': { enabled: false },
|
||||
};
|
||||
for (const r of extraSuppressedRules) suppressed[r] = { enabled: false };
|
||||
const results = await axe.run(container, { rules: suppressed });
|
||||
if (results.violations.length > 0) {
|
||||
const summary = results.violations
|
||||
.map((v) => ` • ${v.id} (${v.impact}): ${v.help} — ${v.nodes.length} node(s)`)
|
||||
.join('\n');
|
||||
throw new Error(`axe-core found ${results.violations.length} violation(s):\n${summary}`);
|
||||
}
|
||||
expect(results.violations).toHaveLength(0);
|
||||
}
|
||||
|
||||
describe('Primitives — axe-core a11y assertions', () => {
|
||||
it('FormField (label / input pair) has no axe violations', async () => {
|
||||
const { container } = render(
|
||||
<FormField label="Email address" required>
|
||||
<input type="email" />
|
||||
</FormField>,
|
||||
);
|
||||
await expectNoViolations(container);
|
||||
});
|
||||
|
||||
it('FormField with description + error has no axe violations', async () => {
|
||||
const { container } = render(
|
||||
<FormField
|
||||
label="Display name"
|
||||
required
|
||||
description="What other operators will see"
|
||||
error="Must be at least 1 character"
|
||||
>
|
||||
<input type="text" />
|
||||
</FormField>,
|
||||
);
|
||||
await expectNoViolations(container);
|
||||
});
|
||||
|
||||
it('Skeleton variants have no axe violations (table / page / card / stat)', async () => {
|
||||
for (const variant of ['table', 'page', 'card', 'stat'] as const) {
|
||||
const { container, unmount } = render(<Skeleton variant={variant} />);
|
||||
// Skeleton.table renders empty <th> cells — they're decorative
|
||||
// shimmer placeholders inside a role="status" + aria-busy="true"
|
||||
// container, so screen readers announce "Loading content" and
|
||||
// skip the table semantics. axe-core's `empty-table-header` rule
|
||||
// doesn't model aria-busy gating, so suppress it for this variant
|
||||
// (and consistently across all variants for the same scan).
|
||||
await expectNoViolations(container, ['empty-table-header']);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('ModalDialog with title + body + footer has no axe violations', async () => {
|
||||
const { baseElement } = render(
|
||||
<ModalDialog
|
||||
open={true}
|
||||
title="Confirm action"
|
||||
onClose={() => {}}
|
||||
footer={<button>OK</button>}
|
||||
>
|
||||
<p>This action is reversible.</p>
|
||||
</ModalDialog>,
|
||||
);
|
||||
// ModalDialog mounts into a portal on document.body — pass
|
||||
// baseElement (which is document.body) rather than container so
|
||||
// axe scans the actual rendered dialog tree.
|
||||
await expectNoViolations(baseElement);
|
||||
});
|
||||
|
||||
it('Breadcrumbs renders no axe violations on a 2-deep path', async () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={['/issuers/iss-vault']}>
|
||||
<Breadcrumbs />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await expectNoViolations(container);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user