# 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 (`createDomainForm` factory), §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-` convention introduced in the master design §5.8 so theme packs (`react-ui-theme-`) and component packs share a discoverable prefix. - **Prereqs (one SHIPPED, two in-flight)**: - `enterprise-react-i18n` shipped 2026-05-05 — archived at `openspec/archive/2026-05-05-enterprise-react-i18n/`; capability spec at `openspec/specs/react-i18n/spec.md`. This pack consumes `TranslationKey` for every form-field label across LoginForm / RegisterForm / PasswordResetForm / TwoFactorForm / MagicLinkForm, contributes `react-ui-pack-auth.
.` keys to `ContributedTranslationKeys` in all 5 locales, and uses `tDynamic` for any runtime-computed error-code-to-message lookups (e.g. server-side `auth.error.`). - `enterprise-react-ui-v2` (sibling, in-flight) — provides the `ComponentPack` contract, `PackComponents` global augmentation interface, `PackRegistryProvider`, `usePackComponent` typed lookup, and `peerRange` validation that this pack registers against. - `enterprise-react-forms-v2` (sibling, in-flight) — provides the `createDomainForm` factory 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: 1. **Prove the `ComponentPack` contract 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 a `peerRange` compatibility check (§5.6). Until at least one external pack consumes this surface, the contract is unverified. `react-ui-pack-auth` is that external consumer — it lives in its own package, it has its own `package.json`, and it depends on `@suaveplan/react-ui` only as a peer. If the contract has gaps, this package is the smallest viable surface that surfaces them before downstream consumers (the ~40 `react-*` packages waiting on `react-ui`) start adopting it. 2. **Exercise the full `react-ui` + `react-i18n` + `react-forms` + `@suaveplan/api-schemas` stack 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), `` for marketing copy in marketing slots, locale-bundle contribution via module augmentation (§5.5 + §11.2), pack-scoped lifecycle, runtime override via `usePackComponent`, and Zod validation sourced directly from `@suaveplan/api-schemas/auth`. If any of those boundaries are awkward, the auth pack will surface the awkwardness. 3. **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`, ``, `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 ```ts // 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: ) => void; onError?: (error: SuaveplanError) => void; defaultValues?: Partial<>; 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 `` whose default is unset). ## Design decisions 1. **Implements `ComponentPack<'auth'>` from `@suaveplan/react-ui/pack`.** The exported `authPack` object satisfies the `ComponentPack<'auth'>` interface verbatim — `name: 'auth'`, `version: '0.0.0'`, `peerRange: '^0.1.0'`, `components: { LoginForm, RegisterForm, PasswordResetForm, TwoFactorForm, MagicLinkForm }`, plus the optional `translations`, `onRegister`, `onUnregister` slots. A `pack.contract.test.tsx` asserts the object literally satisfies the type via `expectTypeOf` and that registering it through `` causes `usePackComponent('auth', 'LoginForm')` to return the same component reference (`Object.is`). 2. **Each form is built via `createDomainForm` from `@suaveplan/react-forms` — no hand-rolled JSX.** The five forms are pure factory invocations. This is deliberate: it exercises `createDomainForm`'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 that `createDomainForm` is incomplete and needs a follow-up change against `@suaveplan/react-forms` rather than a workaround here. 3. **Schemas come straight from `@suaveplan/api-schemas/auth` — no schema duplication.** `LoginForm` consumes `LoginInput`, `RegisterForm` consumes `RegisterInput`. The five new schemas (`PasswordResetRequestInput`, `PasswordResetConfirmInput`, `TwoFactorVerifyInput`, `MagicLinkRequestInput`, `MagicLinkVerifyInput`) are added to `@suaveplan/api-schemas/auth` in 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 time `bun install` runs. 4. **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 namespace `react-ui-pack-auth...` — 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 augments `ContributedTranslationKeys` so a typo in the field config fails `tsc` not at runtime. 5. **Per-locale translations under `react-ui-pack-auth.*` namespace, registered via module augmentation.** A generated `src/translations/augmentation.d.ts` module-augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with every key in `src/translations/en-GB.json`, and `src/translations/augmentation.ts` imports each locale JSON for its runtime side effect (registering the bundle into the `authPack.translations` map). Importing the package's `index.ts` registers both the type-level union and the runtime bundles — consumers do not need a separate import. 6. **Five locales: `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`.** Mirrors the master-design §1.1 set. `ar-AE` proves RTL flip on every form; `cy-GB` proves 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 via `bunx turbo lint`) blocks any commit that introduces a key in `en-GB.json` without translations in the other four locales. 7. **`onRegister(ctx)` lifecycle hook pre-warms i18n bundles for the auth namespace.** When `` mounts, it calls `authPack.onRegister(ctx)` with a `PackContext` exposing `i18n`, `theme`, `logger`, and a pack-scoped `AbortSignal`. The hook calls `ctx.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)` calls `removeResourceBundle` so the i18n core stays canonical and pack mount/unmount is symmetric. 8. **Each form takes typed `onSuccess` / `onError` callbacks — no global state assumptions.** The pack does not call `window.location.assign`, does not push to a router, does not write to `localStorage`. `LoginForm` returns a `LoginOutput`-shaped value to its `onSuccess` callback; 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. 9. **Single-direction import policy.** The pack imports from `@suaveplan/react-ui` (peer-dep) but never re-exports its types — consumers depend on `@suaveplan/react-ui` directly per CLAUDE.md rule 4 ("never re-export external deps"). The pack's `pack.ts` file is a thin object literal whose components reference internal-to-this-package implementations only. 10. **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` — a `ComponentPack<'auth'>` instance exporting all five forms, all five locale bundles, the `onRegister` warmer, and the `onUnregister` unwinder. - ADDED: `LoginForm` — built via `createDomainForm({ schema: LoginInput, fields: { email, password }, submit: { ... } })`. Field kinds: `email`, `password`. Optional `defaultValues.email` for the pre-filled-from-magic-link path. - ADDED: `RegisterForm` — built via `createDomainForm({ schema: RegisterInput.extend({ confirmPassword: ..., terms: ... }), fields: { email, name, password, confirmPassword, terms }, submit: { ... } })`. Cross-field validator: `confirmPassword === password`. The `terms` checkbox is required and has its own translation key. - ADDED: `PasswordResetForm` — two-mode factory (`mode: 'request' | 'confirm'`). Request mode uses `PasswordResetRequestInput` (single email field); confirm mode uses `PasswordResetConfirmInput` (token + new password + confirm). Mode is a prop, not internal state. - ADDED: `TwoFactorForm` — built via `createDomainForm({ schema: TwoFactorVerifyInput, fields: { code }, ... })`. The `code` field is `kind: 'otp'` (six-digit InputOTP from `react-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 uses `MagicLinkRequestInput` (email only); verify mode uses `MagicLinkVerifyInput` (token from the URL — typically auto-submitted on mount, with a manual fallback button). - ADDED: Translations namespace `react-ui-pack-auth.*` with full coverage in `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU` for every label, placeholder, helper text, success message, and error message every form renders. - ADDED: Module augmentation `declare module "@suaveplan/react-i18n" { interface ContributedTranslationKeys { ... } }` registering every `react-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 the `usePackComponent` lookup union. - ADDED: `AuthSubmitClient` interface — the injectable contract every form takes as a prop. One method per form (`login`, `register`, `requestPasswordReset`, `confirmPasswordReset`, `verifyTwoFactor`, `requestMagicLink`, `verifyMagicLink`), each returning a typed `Promise<>`. - 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` — `peerRange` check against running `@suaveplan/react-ui` version failed at registration - `REACT_UI_PACK_AUTH.UNKNOWN_FIELD_KIND` — internal sanity check; thrown if a field config references a kind the pack-local registry has not whitelisted - `REACT_UI_PACK_AUTH.MODE_REQUIRED` — `PasswordResetForm` or `MagicLinkForm` rendered without the mandatory `mode` prop ### `@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_total` with 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_ms` with attribute `{ form: "" }` — wall-clock time from form submit through `onSuccess` / `onError` resolution. - ADDED: counter `pack.auth.lifecycle_total` with attribute `{ phase: "register" | "unregister", outcome: "success" | "version_mismatch" | "failed" }` — fired by the `onRegister` / `onUnregister` hooks; the `version_mismatch` value carries the structured `pack.version_mismatch` warning out via `@suaveplan/logger` per 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` — `authPack` type-conforms to `ComponentPack<'auth'>`; registering it makes `usePackComponent('auth', '')` return the same component reference for all five forms. - `packages/react/react-ui-pack-auth/src/pack/pack.lifecycle.test.tsx` — `onRegister` is called on mount with the active i18n / theme / logger / signal; `onUnregister` is 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//.test.tsx` (five files) — each form renders without throwing under all five locales (parametric `it.each`); submitting calls the injected `AuthSubmitClient` method exactly once with the typed payload; `onSuccess` fires with the typed response; field-level validation errors render the correct translation key; field labels, placeholders, helper text, and error messages all resolve through `react-i18n`'s catalog (no string literal in the rendered DOM). - `packages/react/react-ui-pack-auth/src/components//.stories.tsx` (five files) — `composeStories` round-trips each story through `vitest`; one story per locale per form (× theme variant for the `WithTheme` story). - `packages/react/react-ui-pack-auth/e2e/auth-submit-success.spec.ts` — Playwright; for each form, fills required fields, submits against a stubbed `AuthSubmitClient`, asserts `onSuccess` payload matches the API-schema shape. - `packages/react/react-ui-pack-auth/e2e/auth-submit-error.spec.ts` — Playwright; submits against a stub that throws `SuaveplanError`; 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 under `ar-AE`, asserts ``, 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 the `LoginForm` under `light`, `dark`, `highContrast`, and `dracula`; 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 in `en-GB.json` exists in the other four files; asserts the generated `augmentation.d.ts` mirrors `en-GB.json` exactly. ## 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-social` is a separate package on `STATUS.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/crypto` into the React tree. - **Password recovery via SMS** — telephony is not an `@suaveplan/api-schemas` capability 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-account` once 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 of `LoginInput`, `RegisterInput`, `RefreshInput`, or `LogoutInput` sees 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` (change `enterprise-react-i18n`) — provides `TranslationKey`, `useTranslation`, ``, `ContributedTranslationKeys`. - `@suaveplan/react-ui` (change `enterprise-react-ui-v2` per master design §13) — provides `ComponentPack`, `PackComponents`, `usePackComponent`, every primitive the forms render with. - `@suaveplan/react-forms` (change `enterprise-react-forms-v2` per master design §13) — provides `createDomainForm`, `useDomainForm`, validation primitives. - `@suaveplan/api-schemas` — already on genesis; this change extends its `auth` subpath. 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").