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:
shankar0123
2026-05-14 16:44:37 +00:00
parent 868f1c25be
commit c9f932be65
14 changed files with 1537 additions and 225 deletions
+112
View File
@@ -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;
}
});
});
+118
View File
@@ -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>
);
}
+73
View File
@@ -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();
});
});
+119
View File
@@ -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
View File
@@ -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>
);
}
+27 -25
View File
@@ -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>
);
}
+28 -25
View File
@@ -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>
);
}
+87 -51
View File
@@ -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>
);
}
+33
View File
@@ -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>;
+112
View File
@@ -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);
});
});