Files
suaveplan/openspec/specs/react-auth-whitelabel/spec.md
2026-05-27 23:29:53 +01:00

10 KiB

Capability Spec: react-auth-whitelabel

Package: @suaveplan/react-auth-whitelabel Status: ACTIVE

Every requirement carries a **Verified by:** line.

Requirements

Requirement: AuthProvider exposes auth verbs over an injectable transport

The package SHALL ship an AuthProvider React context provider that exposes login, loginWithProvider, register, logout, requestPasswordReset, and resetPassword verbs. Each verb invokes the configured backend through config.fetch (a FetchLike, defaulting to globalThis.fetch) and routes redirect side-effects through config.navigate (defaulting to a window.location.href writer). The context value also exposes user, isLoading, isAuthenticated, and error.

Scenario: login posts to authUrl and stores the returned user

  • WHEN useAuth().login("a@b.c", "pw") is called with fetch returning { ok: true, json: { user } }
  • THEN the request body is {"email":"a@b.c","password":"pw"}, the request URL ends with /login, and user becomes the context's user

Scenario: loginWithProvider routes through the navigate seam

  • WHEN useAuth().loginWithProvider("google") is called
  • THEN config.navigate(url) is invoked exactly once with a URL that includes /oauth/google?redirect=

Scenario: useAuth outside provider throws PROVIDER_OUTSIDE_TREE

  • WHEN useAuth() is called with no <AuthProvider> ancestor
  • THEN a ReactAuthWhitelabelError with .code === "REACT_AUTH_WL.PROVIDER_OUTSIDE_TREE" is thrown

Verified by: src/context/auth-context.test.tsx::"login() posts credentials to {authUrl}/login and sets user" AND src/context/auth-context.test.tsx::"loginWithProvider routes through the navigate seam" AND src/context/auth-context.test.tsx::"useAuth outside provider throws REACT_AUTH_WL.PROVIDER_OUTSIDE_TREE"


Requirement: BrandingProvider writes tenant branding through the react-ui theme mutator

The package SHALL ship a BrandingProvider that, on every change of the resolved TenantBranding, calls useThemeMutator().setExtensionTokens({...}) with keys --brand-primary, --brand-secondary, --brand-accent, and --brand-font. Direct document.documentElement.style.setProperty("--brand-*", ...) writes from package code are forbidden. Non-token branding surfaces (document title, favicon <link rel="icon">, and tenant custom CSS <style id="tenant-custom-css">) remain direct DOM writes. The mutator's automatic unmount cleanup removes every --brand-* declaration.

Scenario: static branding applies extension tokens after mount

  • WHEN <ThemeProvider>...<BrandingProvider staticBranding={…}> mounts
  • THEN document.documentElement.style.getPropertyValue("--brand-primary") returns the static branding's primaryColor

Scenario: extension tokens are removed on unmount

  • WHEN the <BrandingProvider> is unmounted
  • THEN document.documentElement.style.getPropertyValue("--brand-primary") returns the empty string

Scenario: Zod failure on fetched branding raises BRANDING_INVALID_PAYLOAD

  • WHEN the fetched branding response fails TenantBrandingSchema.safeParse
  • THEN the context exposes a ReactAuthWhitelabelError whose .code === "REACT_AUTH_WL.BRANDING_INVALID_PAYLOAD"

Scenario: network failure raises BRANDING_FETCH_FAILED

  • WHEN the configured fetch resolves with { ok: false, status: 503 }
  • THEN the context exposes a ReactAuthWhitelabelError whose .code === "REACT_AUTH_WL.BRANDING_FETCH_FAILED"

Verified by: src/context/branding-context.test.tsx::"setExtensionTokens propagates --brand-primary after mount" AND src/context/branding-context.test.tsx::"unmount removes every --brand-* property" AND src/context/branding-context.test.tsx::"invalid fetched payload exposes REACT_AUTH_WL.BRANDING_INVALID_PAYLOAD" AND src/context/branding-context.test.tsx::"fetch failure exposes REACT_AUTH_WL.BRANDING_FETCH_FAILED"


Requirement: TenantBranding has a Zod schema

The package SHALL export a Zod schema TenantBrandingSchema that validates every field of TenantBranding. The BrandingProvider SHALL run TenantBrandingSchema.safeParse(...) on every fetched payload before storing it.

Scenario: schema accepts a fully-populated branding payload

  • WHEN TenantBrandingSchema.parse({ tenantId, name, primaryColor: "#fff", secondaryColor: "#000", … }) is called
  • THEN parsing succeeds

Scenario: schema rejects payload without primaryColor

  • WHEN TenantBrandingSchema.safeParse({ tenantId: "t", name: "n", secondaryColor: "#000" }) is called
  • THEN the result is { success: false }

Verified by: src/types/branding.schema.test.ts::"accepts a fully-populated branding payload" AND src/types/branding.schema.test.ts::"rejects payload without primaryColor"


Requirement: LoginForm renders accessible email/password form with optional OAuth providers

The package SHALL ship a LoginForm React component with controlled email + password inputs, an optional rememberMe checkbox, an optional OAuth provider button list, an optional forgot-password affordance, and an optional register-link affordance. The submit button calls onSubmit? if supplied else useAuth().login(...). Errors are rendered in a role="alert" region. Every visible string is sourced from useTranslation().

Scenario: renders email + password inputs with required attribute

  • WHEN <LoginForm /> is mounted under an <AuthProvider>
  • THEN an input[name="email"][required] and input[name="password"][required] are present

Scenario: submit calls onSubmit override when supplied

  • WHEN <LoginForm onSubmit={spy} /> is mounted and the form is submitted with valid values
  • THEN spy is called once with { email, password, rememberMe }

Scenario: OAuth provider button click invokes loginWithProvider

  • WHEN a LoginForm rendered with providers=[{id:"google",name:"Google"}] has its Google button clicked
  • THEN the auth context's loginWithProvider is called once with "google"

Verified by: src/components/login-form/login-form.test.tsx::"renders required email and password inputs" AND src/components/login-form/login-form.test.tsx::"submit invokes onSubmit override when provided" AND src/components/login-form/login-form.test.tsx::"OAuth provider button click routes through loginWithProvider"


Requirement: RegisterForm validates password + terms + name before submitting

The package SHALL ship a RegisterForm React component that validates locally before invoking onSubmit? or useAuth().register(...). Validation rules: password length ≥ minPasswordLength (default 8); password equals confirmPassword; if requireName, name is non-empty; if showTermsCheckbox, terms checkbox is checked.

Scenario: password mismatch sets inline error and blocks submission

  • WHEN the user submits with password "12345678" and confirmPassword "00000000"
  • THEN the form renders a role="alert" text matching /match/i and no auth call is made

Scenario: terms unchecked blocks submission when showTermsCheckbox is on

  • WHEN the user submits without checking the terms checkbox
  • THEN the form renders an error matching /terms/i and no auth call is made

Verified by: src/components/register-form/register-form.test.tsx::"password mismatch blocks submission with inline error" AND src/components/register-form/register-form.test.tsx::"unchecked terms checkbox blocks submission"


Requirement: PasswordResetForm operates in request and confirm modes

The package SHALL ship a PasswordResetForm React component with mode: "request" | "confirm". Request mode collects email and calls useAuth().requestPasswordReset(email). Confirm mode collects new password + confirmation, validates min length + match, and calls useAuth().resetPassword(token, password). If mode === "confirm" and token is undefined, an inline MISSING_RESET_TOKEN error is shown and no auth call is made. On success a success state is rendered with an optional back-to-login affordance.

Scenario: request mode submits email and shows success state

  • WHEN the request form is submitted with email = "a@b.c"
  • THEN requestPasswordReset is called once with "a@b.c" and the success copy is rendered

Scenario: confirm mode without token surfaces MISSING_RESET_TOKEN

  • WHEN the confirm form is submitted with token = undefined
  • THEN the form renders a role="alert" containing the missing-token copy and resetPassword is NOT called

Verified by: src/components/password-reset-form/password-reset-form.test.tsx::"request mode submits email and shows success state" AND src/components/password-reset-form/password-reset-form.test.tsx::"confirm mode without token surfaces MISSING_RESET_TOKEN"


Requirement: REACT_AUTH_WL error codes are registered and thrown

The package SHALL register the REACT_AUTH_WL error namespace via defineCodes and SHALL throw or surface each of these codes from at least one production code path: BRANDING_FETCH_FAILED, BRANDING_INVALID_PAYLOAD, AUTH_NETWORK_FAILED, AUTH_REJECTED, MISSING_RESET_TOKEN, PROVIDER_OUTSIDE_TREE.

Scenario: every registered code is thrown by ≥1 production file

  • WHEN the test scans every .ts/.tsx non-test source file
  • THEN for each code in REACT_AUTH_WL_CODES, at least one production file matches the code literal in a throw or surface context

Verified by: src/errors/error-codes.test.ts::"every REACT_AUTH_WL code is thrown by at least one production file"


Requirement: i18n every user-visible string

The package SHALL source every user-visible string through useTranslation() from @suaveplan/react-i18n with the { defaultValue } options-bag signature. No bare string literals appear in JSX text content inside form components.

Scenario: every form translates its title and submit button via useTranslation

  • WHEN a form is rendered with no overriding title/subtitle prop
  • THEN the visible title and submit-button text come from a t(key, { defaultValue }) call

Verified by: src/components/login-form/login-form.test.tsx::"title and submit copy resolve through useTranslation"