Files
2026-05-27 23:29:53 +01:00

25 KiB
Raw Permalink Blame History

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-<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-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.<form>.<key> 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.<code>).
    • enterprise-react-ui-v2 (sibling, in-flight) — provides the ComponentPack<TName> 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), <Trans> 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, <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

  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 <PackRegistryProvider packs={[authPack]}> 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.<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 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 <PackRegistryProvider packs={[authPack]}> 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<<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 — 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: "<same enum>" } — 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', '<form>') 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/<form>/<form>.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/<form>/<form>.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 <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 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, <Trans>, 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").