25 KiB
OpenSpec Change: enterprise-react-ui-pack-auth
- Change ID: enterprise-react-ui-pack-auth
- Package:
@suaveplan/react-ui-pack-auth - Category: react
- Target path:
packages/react/react-ui-pack-auth - Status: PROPOSED
- Date: 2026-05-05
- Design source:
openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md§1.2 row 5, §5 (component-pack contract), §9 (createDomainFormfactory), §11 (TranslationKey) - Naming note: this package replaces what would historically have been a hand-rolled
@suaveplan/react-auth-forms. Naming follows the@suaveplan/react-ui-pack-<name>convention introduced in the master design §5.8 so theme packs (react-ui-theme-<name>) and component packs share a discoverable prefix. - Prereqs (one SHIPPED, two in-flight):
enterprise-react-i18nshipped 2026-05-05 — archived atopenspec/archive/2026-05-05-enterprise-react-i18n/; capability spec atopenspec/specs/react-i18n/spec.md. This pack consumesTranslationKeyfor every form-field label across LoginForm / RegisterForm / PasswordResetForm / TwoFactorForm / MagicLinkForm, contributesreact-ui-pack-auth.<form>.<key>keys toContributedTranslationKeysin all 5 locales, and usestDynamicfor any runtime-computed error-code-to-message lookups (e.g. server-sideauth.error.<code>).enterprise-react-ui-v2(sibling, in-flight) — provides theComponentPack<TName>contract,PackComponentsglobal augmentation interface,PackRegistryProvider,usePackComponenttyped lookup, andpeerRangevalidation that this pack registers against.enterprise-react-forms-v2(sibling, in-flight) — provides thecreateDomainFormfactory each of the five forms is built atop, and the validators (required,email,passwordStrength,pattern) the form schemas compose from.
Why
This package exists for three reasons, each of which would justify it on its own:
- Prove the
ComponentPackcontract end-to-end as an external package. Section 5 of the master design defines a TypeScript-augmented registry (PackComponents), a per-pack scoped React context, lifecycle hooks (onRegister/onUnregister), pack-shipped translation contributions (§5.5), and apeerRangecompatibility check (§5.6). Until at least one external pack consumes this surface, the contract is unverified.react-ui-pack-authis that external consumer — it lives in its own package, it has its ownpackage.json, and it depends on@suaveplan/react-uionly as a peer. If the contract has gaps, this package is the smallest viable surface that surfaces them before downstream consumers (the ~40react-*packages waiting onreact-ui) start adopting it. - Exercise the full
react-ui+react-i18n+react-forms+@suaveplan/api-schemasstack in a single high-fidelity demo. Every layer the React stack ships gets a real workout in one place —createDomainForm(forms factory, §9.4),TranslationKey-typed labels (§11.2),<Trans>for marketing copy in marketing slots, locale-bundle contribution via module augmentation (§5.5 + §11.2), pack-scoped lifecycle, runtime override viausePackComponent, and Zod validation sourced directly from@suaveplan/api-schemas/auth. If any of those boundaries are awkward, the auth pack will surface the awkwardness. - Ship genuinely useful auth components every consumer app needs anyway. Login, register, password reset, two-factor entry, and magic-link request are not bespoke flows — every Suaveplan-built product ships them. Packaging them as a contract-proving demo means consumers get a production-ready pack on day one rather than a contrived "Hello, Pack" example.
Current-state audit (genesis)
packages/react/react-ui-pack-auth/ does not exist. There is no @suaveplan/react-ui-pack-auth package in the workspace. The five forms this pack ships do not exist anywhere on genesis. @suaveplan/api-schemas/auth ships LoginInput, RegisterInput, RefreshInput, LogoutInput and the LoginUser / LoginOutput / RegisterOutput response types — the additional auth schemas this pack needs (PasswordResetRequestInput, PasswordResetConfirmInput, TwoFactorVerifyInput, MagicLinkRequestInput, MagicLinkVerifyInput) do not yet exist and are added in the same change set under §"What changes" below.
Available workspace deps on genesis
| Package | Role | Version range |
|---|---|---|
@suaveplan/react-ui |
Provides ComponentPack contract, usePackComponent, useThemedVariants, every primitive (Button, Field, Input, Label, Alert, Card, Sheet) the forms render with |
peer + workspace:^ (dev) |
@suaveplan/react-i18n |
Provides TranslationKey brand, useTranslation, <Trans>, ContributedTranslationKeys augmentation point |
peer + workspace:^ (dev) |
@suaveplan/react-forms |
createDomainForm factory, useDomainForm, validation primitives, recipe surfaces (used by 2FA's countdown timer) |
workspace:^ |
@suaveplan/api-schemas |
Provides LoginInput / RegisterInput / RefreshInput / LogoutInput schemas; this change extends the auth subpath with five new schemas (see below) |
workspace:^ |
@suaveplan/error |
defineCodes("REACT_UI_PACK_AUTH", [...]) registry, SuaveplanError base class |
workspace:^ |
@suaveplan/types |
Shared Clock, MessageResolver, LoggerLike contracts |
workspace:^ |
@suaveplan/testing |
@suaveplan/testing/runner — sole test-runner import surface (CLAUDE.md §11) |
workspace:^ (dev) |
@suaveplan/testing-dom |
render, screen, userEvent, jest-dom matchers, composeStories adapter |
workspace:^ (dev) |
@suaveplan/testing-e2e |
Playwright config factory, fixture extensions, axe-core helpers | workspace:^ (dev) |
| React | Function-component peer | peer ^18.0.0 or ^19.0.0 |
tslib |
Compiled-helper runtime | catalog:build |
zod |
Runtime schema validation at every public boundary | catalog:forms |
@opentelemetry/api |
Submit-counter and submit-duration histogram | catalog:observability |
External: no Radix imports — Radix is a peer of @suaveplan/react-ui, not this package. No form-library imports — react-hook-form is a peer of @suaveplan/react-forms. No direct i18next / react-i18next imports — the TranslationKey brand and useTranslation come exclusively from @suaveplan/react-i18n.
Public API surface
// Pack export — the canonical ComponentPack<'auth'> instance
export { authPack };
// Per-form components (direct-import path, tree-shake friendly)
export { LoginForm, type LoginFormProps } from "./components/login-form/login-form.js";
export { RegisterForm, type RegisterFormProps } from "./components/register-form/register-form.js";
export {
PasswordResetForm,
type PasswordResetFormProps,
} from "./components/password-reset-form/password-reset-form.js";
export { TwoFactorForm, type TwoFactorFormProps } from "./components/two-factor-form/two-factor-form.js";
export { MagicLinkForm, type MagicLinkFormProps } from "./components/magic-link-form/magic-link-form.js";
// Translation-key contribution (side-effect import on root `index.ts`)
import "./translations/augmentation.js";
// Error registry
export { ReactUiPackAuthError, REACT_UI_PACK_AUTH_CODES } from "./errors/errors.js";
Each form's Props is the union { onSuccess: (result: <SchemaOutput>) => void; onError?: (error: SuaveplanError) => void; defaultValues?: Partial<<SchemaInput>>; submitClient: AuthSubmitClient; redirectTo?: string }. The AuthSubmitClient interface is published from ./types/auth-submit-client.types.ts and is an injectable contract with one method per form (login, register, requestPasswordReset, confirmPasswordReset, verifyTwoFactor, requestMagicLink, verifyMagicLink). No global state assumptions — every form takes its submit client as a prop (or pulls it from a <AuthSubmitClientProvider> whose default is unset).
Design decisions
-
Implements
ComponentPack<'auth'>from@suaveplan/react-ui/pack. The exportedauthPackobject satisfies theComponentPack<'auth'>interface verbatim —name: 'auth',version: '0.0.0',peerRange: '^0.1.0',components: { LoginForm, RegisterForm, PasswordResetForm, TwoFactorForm, MagicLinkForm }, plus the optionaltranslations,onRegister,onUnregisterslots. Apack.contract.test.tsxasserts the object literally satisfies the type viaexpectTypeOfand that registering it through<PackRegistryProvider packs={[authPack]}>causesusePackComponent('auth', 'LoginForm')to return the same component reference (Object.is). -
Each form is built via
createDomainFormfrom@suaveplan/react-forms— no hand-rolled JSX. The five forms are pure factory invocations. This is deliberate: it exercisescreateDomainForm's expressiveness across four different submit-shape patterns (single-step credential, multi-field with cross-field validation, single-input with rate-limit feedback, OTP with countdown timer, magic-link with bifurcated request/verify states) and proves the factory handles them all without dropping into the render-prop escape hatch. Any form that would require the escape hatch is a signal thatcreateDomainFormis incomplete and needs a follow-up change against@suaveplan/react-formsrather than a workaround here. -
Schemas come straight from
@suaveplan/api-schemas/auth— no schema duplication.LoginFormconsumesLoginInput,RegisterFormconsumesRegisterInput. The five new schemas (PasswordResetRequestInput,PasswordResetConfirmInput,TwoFactorVerifyInput,MagicLinkRequestInput,MagicLinkVerifyInput) are added to@suaveplan/api-schemas/authin the same change set so the API server and the React client share a single source of truth. The pack never re-defines a Zod object inline; if the server changes the contract, the form fails at compile time the next timebun installruns. -
All field labels, placeholders, helper text, and error messages typed as
TranslationKey(master design §11). Every user-visible string the pack renders is a key, never a literal. Each form's field config uses keys under the namespacereact-ui-pack-auth.<form>.<field>.<role>— e.g.react-ui-pack-auth.login.email.label,react-ui-pack-auth.login.email.placeholder,react-ui-pack-auth.login.email.errors.required. The pack's locale JSON files are the canonical source for the type-generation script that augmentsContributedTranslationKeysso a typo in the field config failstscnot at runtime. -
Per-locale translations under
react-ui-pack-auth.*namespace, registered via module augmentation. A generatedsrc/translations/augmentation.d.tsmodule-augments@suaveplan/react-i18n'sContributedTranslationKeysinterface with every key insrc/translations/en-GB.json, andsrc/translations/augmentation.tsimports each locale JSON for its runtime side effect (registering the bundle into theauthPack.translationsmap). Importing the package'sindex.tsregisters both the type-level union and the runtime bundles — consumers do not need a separate import. -
Five locales:
en-GB,zh-CN,ar-AE,cy-GB,ru-RU. Mirrors the master-design §1.1 set.ar-AEproves RTL flip on every form;cy-GBproves Welsh's six plural categories don't break the OTP "X attempts remaining" string ({count, plural, zero {…} one {…} two {…} few {…} many {…} other {…}}). The pack's locale completeness gate (scripts/lint/check-locale-completeness.ts, run viabunx turbo lint) blocks any commit that introduces a key inen-GB.jsonwithout translations in the other four locales. -
onRegister(ctx)lifecycle hook pre-warms i18n bundles for the auth namespace. When<PackRegistryProvider packs={[authPack]}>mounts, it callsauthPack.onRegister(ctx)with aPackContextexposingi18n,theme,logger, and a pack-scopedAbortSignal. The hook callsctx.i18n.addResourceBundle('react-ui-pack-auth', authPack.translations[ctx.i18n.locale])and warms the message catalog so the first form-render does not Suspense.onUnregister(ctx)callsremoveResourceBundleso the i18n core stays canonical and pack mount/unmount is symmetric. -
Each form takes typed
onSuccess/onErrorcallbacks — no global state assumptions. The pack does not callwindow.location.assign, does not push to a router, does not write tolocalStorage.LoginFormreturns aLoginOutput-shaped value to itsonSuccesscallback; the consumer wires it to whatever auth/session/router stack they ship. This keeps the pack composable across Next.js, Remix, vanilla Vite, and React Native Web consumers without per-host adapters. -
Single-direction import policy. The pack imports from
@suaveplan/react-ui(peer-dep) but never re-exports its types — consumers depend on@suaveplan/react-uidirectly per CLAUDE.md rule 4 ("never re-export external deps"). The pack'spack.tsfile is a thin object literal whose components reference internal-to-this-package implementations only. -
No hidden retries or backoff. Submit calls are one-shot. If a consumer wants exponential backoff or rate-limit handling, they wrap their submit client with
@suaveplan/retry. The pack stays at the rendering layer.
What changes
@suaveplan/react-ui-pack-auth (new package)
- ADDED:
authPack— aComponentPack<'auth'>instance exporting all five forms, all five locale bundles, theonRegisterwarmer, and theonUnregisterunwinder. - ADDED:
LoginForm— built viacreateDomainForm({ schema: LoginInput, fields: { email, password }, submit: { ... } }). Field kinds:email,password. OptionaldefaultValues.emailfor the pre-filled-from-magic-link path. - ADDED:
RegisterForm— built viacreateDomainForm({ schema: RegisterInput.extend({ confirmPassword: ..., terms: ... }), fields: { email, name, password, confirmPassword, terms }, submit: { ... } }). Cross-field validator:confirmPassword === password. Thetermscheckbox is required and has its own translation key. - ADDED:
PasswordResetForm— two-mode factory (mode: 'request' | 'confirm'). Request mode usesPasswordResetRequestInput(single email field); confirm mode usesPasswordResetConfirmInput(token + new password + confirm). Mode is a prop, not internal state. - ADDED:
TwoFactorForm— built viacreateDomainForm({ schema: TwoFactorVerifyInput, fields: { code }, ... }). Thecodefield iskind: 'otp'(six-digit InputOTP fromreact-ui). Includes a "send new code" button gated by a 60-second countdown surfaced via@suaveplan/timing's countdown helper passed in as a prop (so tests can inject a fake clock). - ADDED:
MagicLinkForm— bifurcated factory. Request mode usesMagicLinkRequestInput(email only); verify mode usesMagicLinkVerifyInput(token from the URL — typically auto-submitted on mount, with a manual fallback button). - ADDED: Translations namespace
react-ui-pack-auth.*with full coverage inen-GB,zh-CN,ar-AE,cy-GB,ru-RUfor every label, placeholder, helper text, success message, and error message every form renders. - ADDED: Module augmentation
declare module "@suaveplan/react-i18n" { interface ContributedTranslationKeys { ... } }registering everyreact-ui-pack-auth.*key into the brand union. - ADDED: Module augmentation
declare module "@suaveplan/react-ui/pack" { interface PackComponents { auth: { LoginForm: ...; RegisterForm: ...; PasswordResetForm: ...; TwoFactorForm: ...; MagicLinkForm: ... } } }registering the pack's component types into theusePackComponentlookup union. - ADDED:
AuthSubmitClientinterface — the injectable contract every form takes as a prop. One method per form (login,register,requestPasswordReset,confirmPasswordReset,verifyTwoFactor,requestMagicLink,verifyMagicLink), each returning a typedPromise<<SchemaOutput>>. - ADDED: Error codes via
defineCodes("REACT_UI_PACK_AUTH", [...]):REACT_UI_PACK_AUTH.SUBMIT_CLIENT_MISSING— form rendered without a submit client (neither prop nor provider)REACT_UI_PACK_AUTH.PACK_VERSION_MISMATCH—peerRangecheck against running@suaveplan/react-uiversion failed at registrationREACT_UI_PACK_AUTH.UNKNOWN_FIELD_KIND— internal sanity check; thrown if a field config references a kind the pack-local registry has not whitelistedREACT_UI_PACK_AUTH.MODE_REQUIRED—PasswordResetFormorMagicLinkFormrendered without the mandatorymodeprop
@suaveplan/api-schemas (extension)
- ADDED to
./auth:PasswordResetRequestInput({ email }),PasswordResetRequestOutput({ requestedAt }) - ADDED to
./auth:PasswordResetConfirmInput({ token, newPassword }),PasswordResetConfirmOutput({ confirmedAt }) - ADDED to
./auth:TwoFactorVerifyInput({ code }),TwoFactorVerifyOutput({ token, refreshToken, user: LoginUser }) - ADDED to
./auth:MagicLinkRequestInput({ email }),MagicLinkRequestOutput({ requestedAt }) - ADDED to
./auth:MagicLinkVerifyInput({ token }),MagicLinkVerifyOutput({ token, refreshToken, user: LoginUser })
These five additions live in packages/api/api-schemas/src/auth/ alongside login.ts / register.ts, follow the existing convention (input + output Zod schemas, inferred TypeScript types, sibling .md), and are exported from packages/api/api-schemas/src/auth/index.ts. They land in this change set rather than a separate change because react-ui-pack-auth is the first consumer that needs them — splitting them into a separate change would be a @suaveplan/api-schemas change with no consumer.
Parity Checklist
N/A — genesis-original; not a migration.
This package does not exist in ../suaveplan/. There is no upstream react-auth-forms (or equivalently named) library to port. The pack's existence is justified by the master design §1.2 row 5, which scopes it as one of the five new genesis packages. A parity checklist is included by openspec/AGENTS.md §0a only when a library is being migrated from upstream — this is a new-build, so the checklist is intentionally absent. The five form components ARE inspired in spirit by the legacy react-auth and react-auth-whitelabel packages on main, but the implementation, public API, and dependency layout are wholly new — there is no shape to preserve and no upstream feature set to enumerate.
Telemetry
- ADDED: counter
pack.auth.submit_totalwith attributes{ form: "login" | "register" | "passwordResetRequest" | "passwordResetConfirm" | "twoFactor" | "magicLinkRequest" | "magicLinkVerify", outcome: "success" | "validation_error" | "submit_error" }— incremented on every form-submit completion. Allowlist enforced at the histogram-write site so unbounded label cardinality cannot creep in. - ADDED: histogram
pack.auth.submit.duration_mswith attribute{ form: "<same enum>" }— wall-clock time from form submit throughonSuccess/onErrorresolution. - ADDED: counter
pack.auth.lifecycle_totalwith attribute{ phase: "register" | "unregister", outcome: "success" | "version_mismatch" | "failed" }— fired by theonRegister/onUnregisterhooks; theversion_mismatchvalue carries the structuredpack.version_mismatchwarning out via@suaveplan/loggerper master design §5.6.
All three instruments are no-ops when no exporter is registered (the @opentelemetry/api default behaviour). The pack does not register an SDK — that is the consumer's responsibility.
Verification approach
Each spec requirement carries a **Verified by:** line listing the concrete tests that exercise it.
packages/react/react-ui-pack-auth/src/pack/pack.contract.test.tsx—authPacktype-conforms toComponentPack<'auth'>; registering it makesusePackComponent('auth', '<form>')return the same component reference for all five forms.packages/react/react-ui-pack-auth/src/pack/pack.lifecycle.test.tsx—onRegisteris called on mount with the active i18n / theme / logger / signal;onUnregisteris called on unmount; calling order is FIFO across multiple packs; bundles are added on register and removed on unregister.packages/react/react-ui-pack-auth/src/components/<form>/<form>.test.tsx(five files) — each form renders without throwing under all five locales (parametricit.each); submitting calls the injectedAuthSubmitClientmethod exactly once with the typed payload;onSuccessfires with the typed response; field-level validation errors render the correct translation key; field labels, placeholders, helper text, and error messages all resolve throughreact-i18n's catalog (no string literal in the rendered DOM).packages/react/react-ui-pack-auth/src/components/<form>/<form>.stories.tsx(five files) —composeStoriesround-trips each story throughvitest; one story per locale per form (× theme variant for theWithThemestory).packages/react/react-ui-pack-auth/e2e/auth-submit-success.spec.ts— Playwright; for each form, fills required fields, submits against a stubbedAuthSubmitClient, assertsonSuccesspayload matches the API-schema shape.packages/react/react-ui-pack-auth/e2e/auth-submit-error.spec.ts— Playwright; submits against a stub that throwsSuaveplanError; asserts the form maps the error code to the correct translation key and renders it next to the offending field (or as a form-level alert when no field maps).packages/react/react-ui-pack-auth/e2e/auth-rtl.spec.ts— Playwright; loads each form underar-AE, asserts<html dir="rtl">, asserts the OTP cells flow right-to-left, asserts the password "show / hide" eye icon mirrors.packages/react/react-ui-pack-auth/e2e/auth-themes.spec.ts— Playwright; loads theLoginFormunderlight,dark,highContrast, anddracula; asserts each renders without console errors and axe-core finds zero serious or critical violations.packages/react/react-ui-pack-auth/src/translations/translations.test.ts— runs the locale-completeness gate; asserts every key inen-GB.jsonexists in the other four files; asserts the generatedaugmentation.d.tsmirrorsen-GB.jsonexactly.
Out of scope
These flows are deliberately deferred — the pack stays narrowly scoped to the five forms the master design names. Each is justified individually:
- OAuth / social-login buttons — defer until first consumer ask.
@suaveplan/react-ui-socialis a separate package onSTATUS.md; combining it with the auth pack would entangle two independently versioned surfaces. - Passkey / WebAuthn — credential management is a wholly different UX (browser-native prompts, no form fields), and the relevant browser APIs (
navigator.credentials.create/.get) deserve their own pack so the auth pack does not pull@suaveplan/cryptointo the React tree. - Password recovery via SMS — telephony is not an
@suaveplan/api-schemascapability today; adding it requires a new schema family and a transport contract that does not yet exist. - Multi-tenant org-picker — pre-auth tenant selection is highly product-specific (subdomain routing, query-param routing, header-based routing); the master design does not name it as part of the v1 pack and a generic implementation would be a guess.
- Session management UI ("active sessions" list, "log out everywhere" action) — belongs in a follow-on
@suaveplan/react-ui-pack-accountonce the auth pack contracts settle. - Email-verification confirmation page — verification typically happens on a server-rendered route with a one-time token, not an interactive form; if a future consumer needs an interactive widget, add it as a sixth form in a follow-on change.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/react-ui-pack-auth to enumerate. Currently zero — this is a leaf pack that no genesis package depends on (verified by grep -rl "@suaveplan/react-ui-pack-auth" /home/ubuntu/web/genesis/packages/ --include="package.json" returning empty before this change lands).
- No dependents (leaf pack; verified by
bunx turbo ls --filter=...@suaveplan/react-ui-pack-auth— empty result).
Adjacent packages touched in the same change set
@suaveplan/api-schemas— five new auth schemas added under./auth(see## What changes). Existing exports are not modified; no consumer ofLoginInput,RegisterInput,RefreshInput, orLogoutInputsees a behaviour change. Tests run:bunx turbo test --filter=@suaveplan/api-schemas.
Prerequisites
This change cannot ship until the four upstream packages it depends on are green on genesis:
@suaveplan/react-i18n(changeenterprise-react-i18n) — providesTranslationKey,useTranslation,<Trans>,ContributedTranslationKeys.@suaveplan/react-ui(changeenterprise-react-ui-v2per master design §13) — providesComponentPack,PackComponents,usePackComponent, every primitive the forms render with.@suaveplan/react-forms(changeenterprise-react-forms-v2per master design §13) — providescreateDomainForm,useDomainForm, validation primitives.@suaveplan/api-schemas— already on genesis; this change extends itsauthsubpath.
The order matches master design §14 Wave 6 ("react-ui-pack-auth and react-ui-theme-dracula ship together; end-to-end proof that contracts work").