mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 12:38:56 +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:
+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>;
|
||||
Reference in New Issue
Block a user