Summary
Seven admin console screens hand-roll their own create/edit form state with
useState + manual onChange handlers + no client-side validation, even
though react-hook-form (7.85.0) and zod (4.4.3) are already
dependencies and are already used correctly in the composer
(admin/app/page.tsx). Adopt the same pattern for the remaining forms.
Source of truth: docs/design/frontend-package-audit.md ("Forms:
react-hook-form + zod — adopt" section), from the frontend package audit
requested by the maintainer.
Scope
Convert the hand-rolled form state in these screens to react-hook-form +
zodResolver, following the composer's existing pattern:
admin/app/providers/providers-screen.tsx — edit dialog (EditFormState)
admin/app/routes/routes-screen.tsx — create/edit route
admin/app/webhooks/webhooks-screen.tsx — create endpoint, rotate-secret
confirmation
admin/app/sender-ids/sender-ids-screen.tsx — create sender ID, edit
registration (two separate forms)
admin/app/users/users-screen.tsx — provision user, edit role assignment
(two forms)
admin/app/apps/apps-screen.tsx — provision app, edit app (two forms)
admin/app/opt-outs/opt-outs-screen.tsx — search-by-MSISDN form,
record-opt-out form (two forms)
Roughly ten independent form-state machines across these seven screens, each
currently ~30–50 lines of state interface + useEffect population + manual
onChange + ad hoc coercion at submit time. Estimated 250–350 lines
removed/simplified.
Properties that must survive this change — verify each explicitly
- 412 (
isStaleWriteError) handling is unaffected and must stay exactly
as-is. This lives in packages/gateway/src/errors.ts and is independent
of form-state management — it's a response-shape check on the mutation
result, not something react-hook-form should intercept or reinterpret.
The "someone else changed this row, reload" UX (#59) depends on it
reaching the screen unchanged.
- Layer-2
403 denials must keep surfacing as a real, visible error,
not be silently absorbed into react-hook-form's field-level error
display as if they were a validation failure. Keep the existing pattern of
a form-level inline banner (e.g. providers-screen.tsx's
updateMutation.isError && <div>Save failed: {message}</div>) — a
permission denial is not a field error.
trpc.ts's errorFormatter already threads GatewayError.fieldErrors
through to error.data (packages/api/src/trpc.ts) — built for
react-hook-form's setError, currently unused by every hand-rolled
screen. Wire it up as part of this change so it's live, not dormant.
- Number-typed fields need explicit
zod coercion at the boundary
(z.coerce.number() or RHF's valueAsNumber) — replacing the current
hand-rolled Number(form.maxTps)-style coercion, which silently produces
NaN on an empty string.
Decimal-on-the-wire fields (costPerSegmentXaf and similar) must stay
z.string(), never a numeric type. This repo's money-safety convention
is "never floating point for minor units" — a zod schema that coerces one
of these to number is a real regression, not a style choice. Validate
the string shape (regex/format), don't coerce it.
Sequencing note
The admin console redesign (docs/design/console-redesign.md) is currently
in flight and touches these same screen files for visual/structural reasons.
This issue is about form state and validation logic, not layout — but
touches the same files, so coordinate timing with whoever is executing that
redesign to avoid a collision (either land after it settles, or split this
work per-screen alongside it).
Verification
pnpm --filter admin typecheck and the existing screen-level tests (if
any) stay green.
- Manually exercise: a stale
If-Match (two tabs editing the same row) still
shows the reload-and-retry UX, not a generic error.
- Manually exercise: a role without the relevant
*:update/*:manage
permission still gets a visible 403, not a silently failed submit.
- A money field (
costPerSegmentXaf) round-trips as a string through the
form, never becomes a JS number.
AI Usage Declaration
This issue was filed by an AI agent (Claude) performing a maintainer-
requested audit of packages/* for hand-rolled machinery a well-maintained
library could replace. The scope, the must-survive properties, and the
verdict were derived by reading every named screen's source directly (not
inferred) and cross-checking against this repo's own documented conventions
(AGENTS.md, docs/design/console-redesign.md). A human should review the
scoped file list and the must-survive properties before implementation
begins, and should confirm implementation before merging.
Summary
Seven admin console screens hand-roll their own create/edit form state with
useState+ manualonChangehandlers + no client-side validation, eventhough
react-hook-form(7.85.0) andzod(4.4.3) are alreadydependencies and are already used correctly in the composer
(
admin/app/page.tsx). Adopt the same pattern for the remaining forms.Source of truth:
docs/design/frontend-package-audit.md("Forms:react-hook-form+zod— adopt" section), from the frontend package auditrequested by the maintainer.
Scope
Convert the hand-rolled form state in these screens to
react-hook-form+zodResolver, following the composer's existing pattern:admin/app/providers/providers-screen.tsx— edit dialog (EditFormState)admin/app/routes/routes-screen.tsx— create/edit routeadmin/app/webhooks/webhooks-screen.tsx— create endpoint, rotate-secretconfirmation
admin/app/sender-ids/sender-ids-screen.tsx— create sender ID, editregistration (two separate forms)
admin/app/users/users-screen.tsx— provision user, edit role assignment(two forms)
admin/app/apps/apps-screen.tsx— provision app, edit app (two forms)admin/app/opt-outs/opt-outs-screen.tsx— search-by-MSISDN form,record-opt-out form (two forms)
Roughly ten independent form-state machines across these seven screens, each
currently ~30–50 lines of state interface +
useEffectpopulation + manualonChange+ ad hoc coercion at submit time. Estimated 250–350 linesremoved/simplified.
Properties that must survive this change — verify each explicitly
isStaleWriteError) handling is unaffected and must stay exactlyas-is. This lives in
packages/gateway/src/errors.tsand is independentof form-state management — it's a response-shape check on the mutation
result, not something
react-hook-formshould intercept or reinterpret.The "someone else changed this row, reload" UX (
#59) depends on itreaching the screen unchanged.
403denials must keep surfacing as a real, visible error,not be silently absorbed into
react-hook-form's field-level errordisplay as if they were a validation failure. Keep the existing pattern of
a form-level inline banner (e.g.
providers-screen.tsx'supdateMutation.isError && <div>Save failed: {message}</div>) — apermission denial is not a field error.
trpc.ts'serrorFormatteralready threadsGatewayError.fieldErrorsthrough to
error.data(packages/api/src/trpc.ts) — built forreact-hook-form'ssetError, currently unused by every hand-rolledscreen. Wire it up as part of this change so it's live, not dormant.
zodcoercion at the boundary(
z.coerce.number()or RHF'svalueAsNumber) — replacing the currenthand-rolled
Number(form.maxTps)-style coercion, which silently producesNaNon an empty string.Decimal-on-the-wire fields (costPerSegmentXafand similar) must stayz.string(), never a numeric type. This repo's money-safety conventionis "never floating point for minor units" — a zod schema that coerces one
of these to
numberis a real regression, not a style choice. Validatethe string shape (regex/format), don't coerce it.
Sequencing note
The admin console redesign (
docs/design/console-redesign.md) is currentlyin flight and touches these same screen files for visual/structural reasons.
This issue is about form state and validation logic, not layout — but
touches the same files, so coordinate timing with whoever is executing that
redesign to avoid a collision (either land after it settles, or split this
work per-screen alongside it).
Verification
pnpm --filter admin typecheckand the existing screen-level tests (ifany) stay green.
If-Match(two tabs editing the same row) stillshows the reload-and-retry UX, not a generic error.
*:update/*:managepermission still gets a visible
403, not a silently failed submit.costPerSegmentXaf) round-trips as a string through theform, never becomes a JS
number.AI Usage Declaration
This issue was filed by an AI agent (Claude) performing a maintainer-
requested audit of
packages/*for hand-rolled machinery a well-maintainedlibrary could replace. The scope, the must-survive properties, and the
verdict were derived by reading every named screen's source directly (not
inferred) and cross-checking against this repo's own documented conventions
(
AGENTS.md,docs/design/console-redesign.md). A human should review thescoped file list and the must-survive properties before implementation
begins, and should confirm implementation before merging.
string-safety were verified live, not just typechecked