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

23 KiB

Tasks: enterprise-react-ui-pack-auth

Change ID: enterprise-react-ui-pack-auth Package: @suaveplan/react-ui-pack-auth Target path: packages/react/react-ui-pack-auth Prerequisite changes (must be archived before this change ships): enterprise-react-i18n, enterprise-react-ui-v2, enterprise-react-forms-v2 Prerequisite packages on Verdaccio: @suaveplan/react-i18n, @suaveplan/react-ui, @suaveplan/react-forms, @suaveplan/api-schemas, @suaveplan/error, @suaveplan/types, @suaveplan/testing, @suaveplan/testing-dom, @suaveplan/testing-e2e

0. Pre-flight (non-negotiable)

  • 0.1 Confirm clean working tree (git status reports nothing) and no in-flight worktrees with uncommitted work (git worktree list then git status in each)
  • 0.2 If any worktrees exist under .claude/worktrees/, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (git worktree remove) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
  • 0.3 Confirm the package set in MANIFEST.md matches find packages -maxdepth 3 -name package.json count. Drift here means a prior change archived without regenerating the manifest.

1. Spec authoring

  • 1.1 Read proposal.md and specs/react-ui-packs/spec.md end-to-end
  • 1.2 Read openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md §5 (component-pack contract), §9 (createDomainForm), §11 (TranslationKey)
  • 1.3 Read the in-flight enterprise-react-ui-v2 proposal (specifically the ComponentPack / PackRegistryProvider / usePackComponent surface) and confirm the augmentation pattern this pack uses matches that proposal verbatim
  • 1.4 Read the in-flight enterprise-react-forms-v2 proposal (specifically createDomainForm's field-config types and the TranslationKey-typed label / placeholder / helperText / errorMessage constraint)
  • 1.5 openspec change validate enterprise-react-ui-pack-auth --strict exits with no errors

2. Adjacent api-schemas extension

  • 2.1 In packages/api/api-schemas/src/auth/, add password-reset-request.ts exporting PasswordResetRequestInput ({ email }) and PasswordResetRequestOutput ({ requestedAt }) plus inferred types and sibling .md
  • 2.2 Add password-reset-confirm.ts exporting PasswordResetConfirmInput ({ token, newPassword }) and PasswordResetConfirmOutput ({ confirmedAt }) plus inferred types and sibling .md
  • 2.3 Add two-factor-verify.ts exporting TwoFactorVerifyInput ({ code }, six-digit string with regex(/^\d{6}$/) refinement) and TwoFactorVerifyOutput ({ token, refreshToken, user: LoginUser }) plus inferred types and sibling .md
  • 2.4 Add magic-link-request.ts exporting MagicLinkRequestInput ({ email }) and MagicLinkRequestOutput ({ requestedAt }) plus inferred types and sibling .md
  • 2.5 Add magic-link-verify.ts exporting MagicLinkVerifyInput ({ token }) and MagicLinkVerifyOutput ({ token, refreshToken, user: LoginUser }) plus inferred types and sibling .md
  • 2.6 Update packages/api/api-schemas/src/auth/index.ts with explicit named exports for all five new modules (no export *, per CLAUDE.md §10)
  • 2.7 Add a sibling .test.ts per new schema asserting parse success on a happy-path payload, parse failure on every refinement violation (empty, malformed email, invalid token, wrong code length), and idempotent round-trip through safeParse
  • 2.8 bunx turbo lint typecheck test --filter=@suaveplan/api-schemas green
  • 2.9 bunx turbo test:coverage --filter=@suaveplan/api-schemas at 100/100/100/100 across the touched files

3. Scaffold

  • 3.1 Create packages/react/react-ui-pack-auth/ with package.json at version 0.1.0, tsconfig.lib.json (extends @suaveplan/typescript-config/react-library.json), vitest.config.ts (uses createTestConfig() from @suaveplan/testing/config, provider: 'istanbul', thresholds 100/100/100/100), vite.config.ts (vite-plugin-dts), playwright.config.ts (uses createPlaywrightConfig() from @suaveplan/testing-e2e)
  • 3.2 Dependencies: @suaveplan/react-forms workspace:^, @suaveplan/api-schemas workspace:^, @suaveplan/error workspace:^, @suaveplan/types workspace:^, @opentelemetry/api catalog:observability, tslib catalog:build, zod catalog:forms
  • 3.3 Peer dependencies: react ^18.0.0 || ^19.0.0, @suaveplan/react-ui workspace:^, @suaveplan/react-i18n workspace:^
  • 3.4 Dev dependencies: @suaveplan/react-ui workspace:^, @suaveplan/react-i18n workspace:^, @suaveplan/testing workspace:^, @suaveplan/testing-dom workspace:^, @suaveplan/testing-e2e workspace:^, @vitest/coverage-istanbul catalog:testing, react, react-dom, @types/react, @types/react-dom, vitest, vite, vite-plugin-dts, fast-check catalog:testing
  • 3.5 NO direct imports of vitest / bun:test (CLAUDE.md §11) — all test-runner primitives via @suaveplan/testing/runner
  • 3.6 NO direct imports of react-hook-form, @radix-ui/*, i18next, react-i18next — all consumed transitively through @suaveplan/react-forms, @suaveplan/react-ui, @suaveplan/react-i18n
  • 3.7 "sideEffects": ["./src/translations/augmentation.js"] — only the augmentation file has a side effect (registering the bundle); everything else is pure
  • 3.8 Subpath exports: ., ./pack, ./components/login-form/login-form, ./components/register-form/register-form, ./components/password-reset-form/password-reset-form, ./components/two-factor-form/two-factor-form, ./components/magic-link-form/magic-link-form, ./errors, ./translations, ./types/auth-submit-client

4. Errors and shared types

  • 4.1 Create src/errors/errors.ts with defineCodes("REACT_UI_PACK_AUTH", ["SUBMIT_CLIENT_MISSING", "PACK_VERSION_MISMATCH", "UNKNOWN_FIELD_KIND", "MODE_REQUIRED"]); export REACT_UI_PACK_AUTH_CODES and ReactUiPackAuthError extends SuaveplanError<ReactUiPackAuthErrorCode>
  • 4.2 Sibling errors.test.ts — every code resolves through the registry, error chain preserves cause, JSON wire format round-trips
  • 4.3 Sibling errors.md
  • 4.4 Create src/types/auth-submit-client.types.ts defining the AuthSubmitClient interface — one method per form, each typed against the matching <Form>Input / <Form>Output from @suaveplan/api-schemas/auth
  • 4.5 Sibling auth-submit-client.md (no .test.ts — pure type file)
  • 4.6 Create src/types/auth-submit-client-provider.tsx exporting <AuthSubmitClientProvider> and useAuthSubmitClient() (returns the provider's value, throws SUBMIT_CLIENT_MISSING when no provider AND no prop is passed at the form level)
  • 4.7 Sibling auth-submit-client-provider.test.tsx and auth-submit-client-provider.md

5. Translations

  • 5.1 Create src/translations/en-GB.json — every key the five forms reference: react-ui-pack-auth.<form>.title, .subtitle, .<field>.label, .<field>.placeholder, .<field>.helperText, .<field>.errors.<code>, .submit.label, .submit.busyLabel, .cancel.label, .success.title, .success.body, .error.<code>.title, .error.<code>.body. Add the OTP plural string with full Welsh six-form coverage ({remaining, plural, zero {…} one {…} two {…} few {…} many {…} other {…}}).
  • 5.2 Create src/translations/zh-CN.json mirroring every key
  • 5.3 Create src/translations/ar-AE.json mirroring every key (RTL — translation values are LTR-marked correctly via Unicode bidi where required)
  • 5.4 Create src/translations/cy-GB.json mirroring every key (Welsh six-form plural for the OTP attempts string)
  • 5.5 Create src/translations/ru-RU.json mirroring every key (Russian three-form plural where applicable)
  • 5.6 Create src/translations/translations.schema.ts — Zod schema constraining the shape every locale JSON must satisfy (used by the locale-completeness gate to fail fast on missing keys)
  • 5.7 Create src/translations/augmentation.d.ts (generated) — declare module "@suaveplan/react-i18n" { interface ContributedTranslationKeys { ... } } registering every key from en-GB.json into the brand union
  • 5.8 Create src/translations/augmentation.ts — runtime side-effect entry that imports each locale JSON statically and exports bundles: Record<SupportedLocale, Record<string, string>>
  • 5.9 Create src/translations/translations.test.ts — locale-completeness gate (every key in en-GB.json exists in every other locale), parse-against-Zod test for every locale, augmentation-mirrors-en-GB test
  • 5.10 Create src/translations/translations.md (the only .md for the directory; per-JSON sibling docs not required since JSON files have no exports)
  • 5.11 Wire bun scripts/generate-translation-types.ts for this package so re-running it produces a byte-identical augmentation.d.ts

6. ComponentPack export

  • 6.1 Create src/pack/pack.ts exporting authPack: ComponentPack<'auth'> with name: 'auth', version: '0.0.0', peerRange: '^0.1.0', components: { LoginForm, RegisterForm, PasswordResetForm, TwoFactorForm, MagicLinkForm }, translations: bundles, onRegister, onUnregister
  • 6.2 onRegister(ctx) — calls ctx.i18n.addResourceBundle('react-ui-pack-auth', bundles[ctx.i18n.locale]), fires pack.auth.lifecycle_total{phase=register, outcome=success} counter, logs structured pack.version_mismatch warning via ctx.logger when the runtime @suaveplan/react-ui version falls outside peerRange
  • 6.3 onUnregister(ctx) — calls ctx.i18n.removeResourceBundle('react-ui-pack-auth', ctx.i18n.locale) and fires the matching counter
  • 6.4 Create src/pack/pack.augmentation.d.ts — declare module "@suaveplan/react-ui/pack" { interface PackComponents { auth: { LoginForm: ComponentType<LoginFormProps>; RegisterForm: ComponentType<RegisterFormProps>; PasswordResetForm: ComponentType<PasswordResetFormProps>; TwoFactorForm: ComponentType<TwoFactorFormProps>; MagicLinkForm: ComponentType<MagicLinkFormProps> } } }
  • 6.5 Create src/pack/pack.contract.test.tsx — expectTypeOf(authPack).toMatchTypeOf<ComponentPack<'auth'>>(); registering it through <PackRegistryProvider packs={[authPack]}> makes usePackComponent('auth', '<form>') return the same component reference (Object.is) for all five forms
  • 6.6 Create src/pack/pack.lifecycle.test.tsx — onRegister is called with { i18n, theme, logger, signal } on mount; onUnregister is called on unmount; addResourceBundle and removeResourceBundle were called with the matching arguments; pack.version_mismatch fires only when peerRange is unsatisfied
  • 6.7 Create src/pack/pack.md

7. LoginForm

  • 7.1 Create src/components/login-form/login-form.ts building the form via createDomainForm({ schema: LoginInput, fields: { email: { kind: 'email', label: 'react-ui-pack-auth.login.email.label', placeholder: 'react-ui-pack-auth.login.email.placeholder', autoComplete: 'email' }, password: { kind: 'password', label: 'react-ui-pack-auth.login.password.label', autoComplete: 'current-password' } }, layout: 'vertical', submit: { label: 'react-ui-pack-auth.login.submit.label', busyLabel: 'react-ui-pack-auth.login.submit.busyLabel', onSubmit: async (values, ctx) => { const result = await ctx.submitClient.login(values); ctx.onSuccess(result); } } })
  • 7.2 Export LoginForm and LoginFormProps
  • 7.3 Sibling login-form.test.tsx — renders under all 5 locales (it.each); submits the typed payload exactly once; calls onSuccess with the LoginOutput-typed value; renders the typed key from the catalog (no string literal); maps a thrown SuaveplanError("AUTH.INVALID_CREDENTIALS") to the correct react-ui-pack-auth.login.error.AUTH.INVALID_CREDENTIALS.body key
  • 7.4 Sibling login-form.stories.tsx — Default, Playground, WithI18n (parametric over the 5 locales), WithTheme (parametric over light/dark/highContrast/dracula), WithError (server-error mapping), WithDefaultEmail (the magic-link-prefilled flow)
  • 7.5 Sibling login-form.md

8. RegisterForm

  • 8.1 Create src/components/register-form/register-form.ts building the form via createDomainForm({ schema: RegisterInput.extend({ confirmPassword: z.string().min(8), terms: z.literal(true) }), fields: { email, name, password, confirmPassword: { kind: 'password', label: 'react-ui-pack-auth.register.confirmPassword.label', validate: { matches: 'password' } }, terms: { kind: 'checkbox', label: 'react-ui-pack-auth.register.terms.label', required: true } }, layout: 'vertical', submit: { ... onSuccess wires onto AuthSubmitClient.register ... } })
  • 8.2 Export RegisterForm and RegisterFormProps
  • 8.3 Sibling register-form.test.tsx — under all 5 locales; cross-field validator fires when confirmPassword !== password and renders react-ui-pack-auth.register.confirmPassword.errors.mismatch; the terms checkbox is required and submit stays disabled until checked; submit returns a RegisterOutput-typed value
  • 8.4 Sibling register-form.stories.tsx — Default, Playground, WithI18n, WithTheme, WithMismatchedPasswords, WithUncheckedTerms, WithExistingEmailError
  • 8.5 Sibling register-form.md

9. PasswordResetForm

  • 9.1 Create src/components/password-reset-form/password-reset-form.ts exporting a single component whose mode: 'request' | 'confirm' prop selects between two factory-built forms — mode = 'request' builds against PasswordResetRequestInput, mode = 'confirm' builds against PasswordResetConfirmInput
  • 9.2 When mode is missing or invalid, throw ReactUiPackAuthError(MODE_REQUIRED) at render time
  • 9.3 Export PasswordResetForm and PasswordResetFormProps (the props discriminated union over mode)
  • 9.4 Sibling password-reset-form.test.tsx — both modes render under all 5 locales; request-mode submit calls submitClient.requestPasswordReset once; confirm-mode submit calls submitClient.confirmPasswordReset once with { token, newPassword }; missing mode throws MODE_REQUIRED
  • 9.5 Sibling password-reset-form.stories.tsx — RequestDefault, ConfirmDefault, Playground, WithI18n, WithTheme, RequestSuccess, ConfirmSuccess, RequestRateLimitError, ConfirmInvalidTokenError
  • 9.6 Sibling password-reset-form.md

10. TwoFactorForm

  • 10.1 Create src/components/two-factor-form/two-factor-form.ts building via createDomainForm({ schema: TwoFactorVerifyInput, fields: { code: { kind: 'otp', length: 6, label: 'react-ui-pack-auth.twoFactor.code.label', helperText: 'react-ui-pack-auth.twoFactor.code.helperText' } }, layout: 'vertical', submit: { ... } }) — the kind: 'otp' field renders the react-ui InputOTP primitive
  • 10.2 Add a "send new code" sub-component gated by a 60-second countdown; the countdown is driven by an injectable clock?: Clock prop (default Date.now-based) so tests can use a FakeClock from @suaveplan/testing
  • 10.3 The remaining-attempts string uses the react-ui-pack-auth.twoFactor.code.helperText key with a count argument, exercising Welsh six-form plurals under cy-GB
  • 10.4 Export TwoFactorForm and TwoFactorFormProps
  • 10.5 Sibling two-factor-form.test.tsx — renders under all 5 locales; six-digit input pattern is enforced (submit disabled until /^\d{6}$/); submit calls submitClient.verifyTwoFactor with { code }; "send new code" is disabled while the countdown is active and re-enables when the FakeClock advances 60 s; Welsh plural categories zero / one / two / few / many / other all resolve to the right string
  • 10.6 Sibling two-factor-form.stories.tsx — Default, Playground, WithI18n, WithTheme, WithCountdownActive, WithCountdownExpired, WithInvalidCode, WithWelshPlurals (one story per Welsh plural category)
  • 10.7 Sibling two-factor-form.md

11. MagicLinkForm

  • 11.1 Create src/components/magic-link-form/magic-link-form.ts exporting a component whose mode: 'request' | 'verify' prop selects between two factory-built forms — mode = 'request' builds against MagicLinkRequestInput, mode = 'verify' builds against MagicLinkVerifyInput
  • 11.2 In verify mode, accept an optional token prop — when present, auto-submit on mount; when absent, render the manual fallback form
  • 11.3 Throw ReactUiPackAuthError(MODE_REQUIRED) if mode is missing
  • 11.4 Export MagicLinkForm and MagicLinkFormProps
  • 11.5 Sibling magic-link-form.test.tsx — both modes render under all 5 locales; request-mode submit calls submitClient.requestMagicLink; verify-mode auto-submit fires submitClient.verifyMagicLink exactly once on mount when token is provided; manual-fallback button submits the typed { token } payload otherwise
  • 11.6 Sibling magic-link-form.stories.tsx — RequestDefault, VerifyAutoSubmit, VerifyManualFallback, Playground, WithI18n, WithTheme, RequestRateLimitError, VerifyExpiredTokenError
  • 11.7 Sibling magic-link-form.md

12. Telemetry

  • 12.1 Create src/telemetry/telemetry.ts exporting the three instruments (pack.auth.submit_total, pack.auth.submit.duration_ms, pack.auth.lifecycle_total); enforce the outcome and phase allowlists at the recording site so unbounded label cardinality is impossible
  • 12.2 Wire each form's submit pipeline through the counter + histogram (success path increments outcome=success, validation failures outcome=validation_error, thrown errors outcome=submit_error)
  • 12.3 Sibling telemetry.test.ts — counters fire with the right attributes; histogram records duration; allowlist rejects unknown values
  • 12.4 Sibling telemetry.md

13. Public root + README

  • 13.1 Create src/index.ts with explicit named re-exports (no export *, per CLAUDE.md §10) for authPack, the five <Form> components, the matching <Form>Props types, AuthSubmitClient, AuthSubmitClientProvider, useAuthSubmitClient, ReactUiPackAuthError, REACT_UI_PACK_AUTH_CODES. Side-effect import ./translations/augmentation.js and ./pack/pack.augmentation.js so type augmentation and runtime bundle registration happen on root import.
  • 13.2 Create README.md at the package root with: package name, one-paragraph description, install snippet (bun add @suaveplan/react-ui-pack-auth), quick-start (registering the pack via <PackRegistryProvider packs={[authPack]}> and rendering <LoginForm submitClient={mySubmitClient} onSuccess={...} />), Modules section linking to every co-located .md
  • 13.3 Run the per-package doc-coverage gate: every implementation .ts/.tsx (excluding tests, types, stories, barrels) has a sibling .md ≥ 200 words with simple + advanced examples + API reference

14. End-to-end + a11y

  • 14.1 Create e2e/auth-submit-success.spec.ts — for each of the five forms, fill required fields and submit against a stub AuthSubmitClient; assert onSuccess payload matches the API-schema shape
  • 14.2 Create e2e/auth-submit-error.spec.ts — submit against a stub that throws a structured SuaveplanError; assert the form maps the error code to the correct translation key and renders next to the offending field (or as a form-level alert when no field maps)
  • 14.3 Create e2e/auth-rtl.spec.ts — load each form under ar-AE; assert <html dir="rtl">; assert OTP cells flow right-to-left; assert the password show/hide eye icon mirrors
  • 14.4 Create e2e/auth-themes.spec.ts — load LoginForm under each built-in theme + the dracula example theme; assert no console errors; assert axe-core finds zero serious or critical violations
  • 14.5 Create e2e/pack-lifecycle.spec.ts — mount the <PackRegistryProvider> with authPack; assert bundles are added; unmount; assert bundles are removed and usePackComponent('auth', 'LoginForm') now returns undefined

15. Pre-publish gate

  • 15.1 bunx turbo lint typecheck test --filter=@suaveplan/react-ui-pack-auth green
  • 15.2 bunx turbo test:coverage --filter=@suaveplan/react-ui-pack-auth at 100/100/100/100
  • 15.3 bunx turbo build --filter=@suaveplan/react-ui-pack-auth green
  • 15.4 Banned-pattern scan zero matches: grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only\|\bany\b\|console\.log" packages/react/react-ui-pack-auth/src/ --include="*.ts" --include="*.tsx"
  • 15.5 Test-runner-import scan zero matches: grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/react/react-ui-pack-auth/src/ --include="*.ts" --include="*.tsx"
  • 15.6 Barrel-shape scan zero matches in feature barrels: grep -rn "^\s*export\s\+\(type\s\+\)\?\*\s\+from" packages/react/react-ui-pack-auth/src/ --include="index.ts" | grep -v "/src/index\.ts:"
  • 15.7 Hardcoded-string scan against the §11 watchlist (label, placeholder, helperText, errorMessage, aria-label, title, alt) zero matches: every value resolves to a TranslationKey
  • 15.8 Locale-completeness scan green — every key in en-GB.json exists in zh-CN.json, ar-AE.json, cy-GB.json, ru-RU.json
  • 15.9 Translation-key-existence scan green — every t('...') call references a key that exists in en-GB.json
  • 15.10 React single-version check: find . -path "*/node_modules/react/package.json" | head -5 returns one path
  • 15.11 Subpath-export count matches feature module count (per CLAUDE.md §6)
  • 15.12 Dependent sweep bunx turbo test --filter=...@suaveplan/react-ui-pack-auth (expect zero dependents on first ship; future packs that consume this will be picked up automatically)

16. Manifest + STATUS refresh

  • 16.1 Add @suaveplan/react-ui-pack-auth to the package listing in CLAUDE.md under the react-ui plugin's package list (immediately after react-ui and react-ui-theme-dracula)
  • 16.2 Run bun scripts/folder-reorg/generate-manifest.ts to refresh MANIFEST.md
  • 16.3 Run bun scripts/generate-missing.ts to refresh MISSING.yml
  • 16.4 Update STATUS.md to move @suaveplan/react-ui-pack-auth (if it appears in Pending) to the Completed section, or add a new Completed row if it does not appear in Pending
  • 16.5 Update SUMMARY.md with a per-package paragraph reflecting the shipped surface (five forms, pack contract proof, five locales, AuthSubmitClient injection)
  • 16.6 bunx markdownlint-cli2 --fix — re-stage modified files; bunx markdownlint-cli2 exits 0

17. OpenSpec archival

  • 17.1 Fold specs/react-ui-packs/spec.md into openspec/specs/react-ui-packs/spec.md (creating the canonical spec file since this is the first change against the capability)
  • 17.2 Move openspec/changes/enterprise-react-ui-pack-auth/ → openspec/archive/<yyyy-mm-dd>-enterprise-react-ui-pack-auth/
  • 17.3 Confirm git status shows only intentional changes; no scratch / debug artifacts remain