Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
# @suaveplan/react-auth-whitelabel - White-label authentication React components with tenant branding wired through @suaveplan/react-ui runtime theme mutator
White-label authentication React components with tenant branding wired
through the `@suaveplan/react-ui` runtime theme-token mutator.
<!-- begin:auto-docs -->
## Documentation tree
- [login-form](./src/components/login-form/login-form.md) — `<LoginForm>` is the white-label login surface consumed by every tenant in the stack. It renders a controlled e…
- [login-form](./src/components/login-form/login-form.md) — `<LoginForm>` is the white-label login surface consumed by every tenant in the stack. It renders a controlled e…
- [password-reset-form](./src/components/password-reset-form/password-reset-form.md) — `<PasswordResetForm>` is the white-label password-reset surface. It owns a single state machine across two modes — `req…
- [password-reset-confirm](./src/components/password-reset-form/password-reset-confirm.md) — `PasswordResetConfirm` is the confirm-mode body of `<PasswordResetForm>` — the screen the user lands on from the email…
- [password-reset-form](./src/components/password-reset-form/password-reset-form.md) — `<PasswordResetForm>` is the white-label password-reset surface. It owns a single state machine across two modes — `req…
- [password-reset-request](./src/components/password-reset-form/password-reset-request.md) — `PasswordResetRequest` is the request-mode body of `<PasswordResetForm>` — the screen the user lands on when they click…
- [password-reset-success](./src/components/password-reset-form/password-reset-success.md) — `PasswordResetSuccess` is the success state of `<PasswordResetForm>`, shared between the request and confirm modes. It…
- [register-form](./src/components/register-form/register-form.md) — `<RegisterForm>` is the white-label self-registration surface. It renders the header (logo + title + subtitle), the inl…
- [register-form-fields](./src/components/register-form/register-form-fields.md) — `RegisterFormFields` is the inner field block of `<RegisterForm>` — name (optional, optionally required), email, passwo…
- [register-form-terms](./src/components/register-form/register-form-terms.md) — `RegisterFormTerms` is the terms-acceptance checkbox block of `<RegisterForm>`. It renders the acceptance checkbox, the…
- [register-form](./src/components/register-form/register-form.md) — `<RegisterForm>` is the white-label self-registration surface. It renders the header (logo + title + subtitle), the inl…
- [auth-context](./src/context/auth-context.md) — `auth-context.tsx` ships the `<AuthProvider>` React context provider together with the three hooks `useAuth`, `useUser`…
- [auth-http](./src/context/auth-http.md) — `auth-http.ts` houses the three helpers every `AuthProvider` verb shares: `resolveFetch`, `resolveNavigate`, and `postJ…
- [branding-context](./src/context/branding-context.md) — `branding-context.tsx` ships the `<BrandingProvider>` React context provider together with the hooks `useBranding`, `us…
- [error-codes](./src/errors/error-codes.md) — `error-codes.ts` registers the canonical, stable error-code registry used by every error thrown from `@suaveplan/react-…
- [react-auth-whitelabel-error](./src/errors/react-auth-whitelabel-error.md) — `ReactAuthWhitelabelError` is the canonical error class for every error thrown by `@suaveplan/react-auth-whitelabel`. I…
- [types](./src/types/branding.schema.md) — `TenantBrandingSchema` is the Zod schema that validates every `TenantBranding` payload entering the package from an unt…
- [branding.schema](./src/types/branding.schema.md) — `TenantBrandingSchema` is the Zod schema that validates every `TenantBranding` payload entering the package from an unt…
- [utils](./src/utils/navigate-or-call.md) — `navigateOrCall(target)` is the canonical helper used by every form component in this package to act on a "URL or click…
- [navigate-or-call](./src/utils/navigate-or-call.md) — `navigateOrCall(target)` is the canonical helper used by every form component in this package to act on a "URL or click…
<!-- end:auto-docs -->
## What ships
- **Forms** — `LoginForm`, `RegisterForm`, `PasswordResetForm`
- **Contexts** — `AuthProvider`, `BrandingProvider`
- **Hooks** — `useAuth`, `useBranding`, `useIsAuthenticated`, `useLogo`,
`useTenantBranding`, `useUser`
- **Types** — `AuthConfig`, `AuthContextValue`, `AuthUser`,
`BrandingContextValue`, `BrandingProviderProps`, `RegisterData`,
`TenantBranding`, `TenantBrandingSchema`
- **Errors** — `REACT_AUTH_WL_CODES` + `ReactAuthWhitelabelError`
## Quick start
`BrandingProvider` calls `useThemeMutator()` from
`@suaveplan/react-ui/theme`. That hook MUST be invoked inside a
`<ThemeProvider>`. Wrap your subtree like this:
```tsx
import { ThemeProvider, lightTheme } from "@suaveplan/react-ui/theme";
import {
AuthProvider,
BrandingProvider,
LoginForm,
} from "@suaveplan/react-auth-whitelabel";
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
<BrandingProvider
brandingEndpoint="/api/branding"
tenantId={tenantSlug}
>
<AuthProvider config={{ authUrl: "/api/auth" }}>
<LoginForm
onForgotPassword={() => router.push("/forgot")}
onRegister={() => router.push("/register")}
/>
</AuthProvider>
</BrandingProvider>
</ThemeProvider>;
```
On every branding change, `BrandingProvider` writes `--brand-primary`,
`--brand-secondary`, `--brand-accent`, and `--brand-font` to
`document.documentElement.style` via
`useThemeMutator().setExtensionTokens(...)`. Cleanup is automatic on
unmount.
## Modules
- [`src/components/login-form/login-form.md`](./src/components/login-form/login-form.md)
- [`src/components/register-form/register-form.md`](./src/components/register-form/register-form.md)
- [`src/components/register-form/register-form-fields.md`](./src/components/register-form/register-form-fields.md)
- [`src/components/register-form/register-form-terms.md`](./src/components/register-form/register-form-terms.md)
- [`src/components/password-reset-form/password-reset-form.md`](./src/components/password-reset-form/password-reset-form.md)
- [`src/components/password-reset-form/password-reset-request.md`](./src/components/password-reset-form/password-reset-request.md)
- [`src/components/password-reset-form/password-reset-confirm.md`](./src/components/password-reset-form/password-reset-confirm.md)
- [`src/components/password-reset-form/password-reset-success.md`](./src/components/password-reset-form/password-reset-success.md)
- [`src/context/auth-context.md`](./src/context/auth-context.md)
- [`src/context/auth-http.md`](./src/context/auth-http.md)
- [`src/context/branding-context.md`](./src/context/branding-context.md)
- [`src/types/branding.schema.md`](./src/types/branding.schema.md)
- [`src/errors/error-codes.md`](./src/errors/error-codes.md)
- [`src/errors/react-auth-whitelabel-error.md`](./src/errors/react-auth-whitelabel-error.md)
## Out of scope (Excluded)
- MFA / passkey / WebAuthn flows — deferred until `@suaveplan/webauthn`
- `@suaveplan/otp` ship .
- Magic-link / SMS OTP — depends on `@suaveplan/security-codes` /
`@suaveplan/emailer` / `@suaveplan/sms`.
- Better-Auth first-class adapter — overlaps with the concurrent
`@suaveplan/react-auth` Wave 1.
- Audit-log integration, password-strength meter, rate limiter, axe-core
story gate — depend on packages not yet .
- Bundle-size snapshot — repo-wide tooling not yet wired.
- Playwright E2E specs — see `e2e/README.md`.

View File

@@ -0,0 +1,22 @@
# E2E specs — deferred
### Excluded
The three upstream Playwright specs at
`/mnt/s/web/suaveplan/packages/react/react-auth-whitelabel/e2e/{login-form,register-form,password-reset-form}.spec.ts`
were not ported in the initial migration. They rely on a running Storybook
instance at `/iframe.html?id=components-loginform--default&viewMode=story`
and on Playwright wiring that has not yet landed in Suaveplan.
Once `@suaveplan/storybook-test-utils` (referenced in the enterprise plan
§4 as a soft dep) ships and `playwright.config.ts` is wired at the repo
root for the React stack, port the three specs verbatim — every selector
already matches the JSX output (labels, `role="alert"`, button
names are identical to upstream because the `useTranslation()` default
values are byte-identical).
Manual smoke coverage in the interim:
```
bun run --filter=@suaveplan/react-auth-whitelabel build-storybook
# then open the served bundle and walk every story
```

View File

@@ -0,0 +1,87 @@
{
"name": "@suaveplan/react-auth-whitelabel",
"version": "0.1.0",
"description": "White-label authentication React components with tenant branding wired through @suaveplan/react-ui runtime theme mutator",
"type": "module",
"sideEffects": false,
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./components": {
"import": {
"types": "./dist/components/index.d.ts",
"default": "./dist/components/index.js"
}
},
"./context": {
"import": {
"types": "./dist/context/index.d.ts",
"default": "./dist/context/index.js"
}
},
"./hooks": {
"import": {
"types": "./dist/hooks/index.d.ts",
"default": "./dist/hooks/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
}
},
"files": ["dist", "src", "README.md"],
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check .",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json"
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/react-i18n": "workspace:^",
"@suaveplan/react-ui": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/i18n": "workspace:*",
"@suaveplan/testing": "workspace:*",
"@suaveplan/testing-dom": "workspace:*",
"@suaveplan/typescript-config": "workspace:*",
"@testing-library/jest-dom": "catalog:testing",
"@testing-library/react": "catalog:testing",
"@testing-library/user-event": "catalog:testing",
"@types/react": "catalog:testing",
"@types/react-dom": "catalog:testing",
"@vitejs/plugin-react": "catalog:build",
"happy-dom": "catalog:testing",
"react": "catalog:testing",
"react-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing"
},
"peerDependencies": {
"react": ">=19.0.0",
"react-dom": ">=19.0.0"
}
}

View File

@@ -0,0 +1,27 @@
export {
LoginForm,
type LoginFormProps,
type LoginFormValues,
} from "./login-form/index.js";
export {
PasswordResetConfirm,
type PasswordResetConfirmProps,
type PasswordResetConfirmValues,
PasswordResetForm,
type PasswordResetFormProps,
type PasswordResetMode,
PasswordResetRequest,
type PasswordResetRequestProps,
type PasswordResetRequestValues,
PasswordResetSuccess,
type PasswordResetSuccessProps,
} from "./password-reset-form/index.js";
export {
RegisterForm,
RegisterFormFields,
type RegisterFormFieldsProps,
type RegisterFormProps,
RegisterFormTerms,
type RegisterFormTermsProps,
type RegisterFormValues,
} from "./register-form/index.js";

View File

@@ -0,0 +1,2 @@
export { LoginForm } from "./login-form.js";
export type { LoginFormProps, LoginFormValues } from "./login-form.types.js";

View File

@@ -0,0 +1,74 @@
# `<LoginForm>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<LoginForm>` is the white-label login surface consumed by every tenant in
the stack. It renders a controlled email + password form with
optional remember-me checkbox, optional OAuth provider list, and optional
forgot-password / register-link affordances. The component pulls its visible
strings from `useTranslation()` so every label is localised, sources its
logo + tenant name from `useBranding()` + `useLogo()`, and delegates the
actual auth call to `useAuth().login(...)` unless the caller supplies an
`onSubmit` override. Submission errors are rendered inline in a `role="alert"`
region; provider-button clicks invoke `useAuth().loginWithProvider(...)`.
## Features
- Fully controlled inputs — no `react-hook-form` dependency.
- `useTranslation()` with `{ defaultValue }` for every visible string.
- Optional OAuth provider button list.
- Optional remember-me, forgot-password, register-link affordances.
- `onSubmit` override for plug-in flows (Better Auth, custom REST).
- Custom logo, title, subtitle overrides.
- Disabled state propagates to every input + button.
## Usage: Basic
```tsx
<LoginForm
onForgotPassword={() => router.push("/forgot")}
onRegister={() => router.push("/register")}
/>
```
## Usage: Advanced
```tsx
<LoginForm
providers={[
{ id: "google", name: "Google", icon: GoogleIcon },
{ id: "github", name: "GitHub", icon: "https://cdn/github.svg" },
]}
showRememberMe
onSubmit={async ({ email, password, rememberMe }) => {
await betterAuthClient.signIn.email({ email, password, rememberMe });
}}
onSuccess={() => router.push("/dashboard")}
onError={(err) => analytics.track("login_error", { code: err.code })}
logo={<TenantLogo />}
title={t("custom.title")}
subtitle={t("custom.subtitle")}
/>
```
## API Reference
- `LoginForm(props: LoginFormProps): ReactNode` — props on
`login-form.types.ts`.
- Returns a `role="alert"` region on error; a `role="form"` is implied via
the native `<form>` element.
## Implementation Notes
The OAuth list is hidden when `providers.length === 0`, so a tenant with no
configured providers gets a clean form. The remember-me checkbox is a UI
hint only — the actual semantics live with the backend the consumer wires
through `AuthConfig.fetch`. Provider-icon rendering supports both a React
component (`provider.icon` as `ComponentType<{ className?: string }>`) and
a URL string (`<img src={...} />`). The `navigateOrCall` helper inside the
file handles the string-or-handler shape on every link prop so consumers can
pass a URL or an in-app router callback.

View File

@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from "@storybook/react";
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui/theme";
import type { ReactNode } from "react";
import { AuthProvider } from "../../context/auth-context.js";
import { BrandingProvider } from "../../context/branding-context.js";
import { LoginForm } from "./login-form.js";
function Wrap({ children }: { children: ReactNode }): ReactNode {
return (
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
<BrandingProvider
staticBranding={{
tenantId: "demo",
name: "Demo Co",
primaryColor: "#3B82F6",
secondaryColor: "#10B981",
}}
>
<AuthProvider
config={{ authUrl: "/api/auth" }}
initialUser={null}
>
{children}
</AuthProvider>
</BrandingProvider>
</ThemeProvider>
);
}
const meta: Meta<typeof LoginForm> = {
title: "react-auth-whitelabel/LoginForm",
component: LoginForm,
tags: ["autodocs"],
decorators: [
(Story) => (
<Wrap>
<Story />
</Wrap>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
showRememberMe: true,
showForgotPassword: true,
onForgotPassword: () => undefined,
showRegisterLink: true,
onRegister: () => undefined,
},
};
export const Playground: Story = {
args: {
title: "Welcome",
subtitle: "Sign in to continue",
showRememberMe: true,
showForgotPassword: true,
onForgotPassword: () => undefined,
showRegisterLink: true,
onRegister: () => undefined,
},
};
export const WithProviders: Story = {
args: {
providers: [
{ id: "google", name: "Google" },
{ id: "github", name: "GitHub" },
{ id: "microsoft", name: "Microsoft" },
],
showRememberMe: true,
showForgotPassword: true,
onForgotPassword: () => undefined,
showRegisterLink: true,
onRegister: () => undefined,
},
};
export const Minimal: Story = {
args: {
showRememberMe: false,
showForgotPassword: false,
showRegisterLink: false,
},
};
export const Disabled: Story = {
args: { disabled: true, showRememberMe: true },
};
export const WithError: Story = {
args: {
onSubmit: async () => {
throw new Error("Invalid email or password");
},
},
};

View File

@@ -0,0 +1,383 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act, cleanup, fireEvent } from "@testing-library/react";
import {
FormWrapper,
jsonResponse,
makeFetch,
} from "../../../tests/setup/form-test-utils.js";
import { LoginForm } from "./login-form.js";
afterEach(cleanup);
describe("LoginForm", () => {
it("renders required email and password inputs", { timeout: 5000 }, () => {
const { container } = renderComponent(
<FormWrapper>
<LoginForm />
</FormWrapper>
);
const email = container.querySelector(
'input[name="email"]'
) as HTMLInputElement;
const pw = container.querySelector(
'input[name="password"]'
) as HTMLInputElement;
expect(email).not.toBeNull();
expect(pw).not.toBeNull();
expect(email.required).toBe(true);
expect(pw.required).toBe(true);
});
it("title and submit copy resolve through useTranslation", {
timeout: 5000,
}, () => {
const { getByRole, container } = renderComponent(
<FormWrapper>
<LoginForm />
</FormWrapper>
);
expect(container.querySelector("h1.auth-title")?.textContent).toBe(
"Sign In"
);
expect(getByRole("button", { name: "Sign In" })).toBeInstanceOf(
HTMLButtonElement
);
});
it("submit invokes onSubmit override when provided", {
timeout: 5000,
}, async () => {
const calls: Array<{
email: string;
password: string;
rememberMe?: boolean;
}> = [];
const { container } = renderComponent(
<FormWrapper>
<LoginForm
onSubmit={async (values) => {
calls.push(values);
}}
/>
</FormWrapper>
);
const form = container.querySelector("form") as HTMLFormElement;
const email = container.querySelector(
'input[name="email"]'
) as HTMLInputElement;
const pw = container.querySelector(
'input[name="password"]'
) as HTMLInputElement;
await act(async () => {
fireEvent.change(email, { target: { value: "a@b.c" } });
fireEvent.change(pw, { target: { value: "secret" } });
fireEvent.submit(form);
});
expect(calls).toHaveLength(1);
expect(calls[0].email).toBe("a@b.c");
expect(calls[0].password).toBe("secret");
});
it("OAuth provider button click routes through loginWithProvider", {
timeout: 5000,
}, async () => {
const navigated: string[] = [];
const { fetch: fetchImpl } = makeFetch(() => jsonResponse({}));
const { getByRole } = renderComponent(
<FormWrapper
options={{
navigated,
authConfig: {
authUrl: "https://auth.example.com",
fetch: fetchImpl,
navigate: (u) => navigated.push(u),
},
}}
>
<LoginForm providers={[{ id: "google", name: "Google" }]} />
</FormWrapper>
);
await act(async () => {
fireEvent.click(getByRole("button", { name: "Google" }));
});
expect(navigated.some((u) => u.includes("/oauth/google"))).toBe(true);
});
it("renders an error alert on submit failure", {
timeout: 5000,
}, async () => {
const { container, getByRole } = renderComponent(
<FormWrapper>
<LoginForm
onSubmit={async () => {
throw new Error("nope");
}}
/>
</FormWrapper>
);
const form = container.querySelector("form") as HTMLFormElement;
const email = container.querySelector(
'input[name="email"]'
) as HTMLInputElement;
const pw = container.querySelector(
'input[name="password"]'
) as HTMLInputElement;
await act(async () => {
fireEvent.change(email, { target: { value: "a@b.c" } });
fireEvent.change(pw, { target: { value: "x" } });
fireEvent.submit(form);
});
expect(getByRole("alert").textContent).toBe("nope");
});
it("OAuth provider button failure surfaces via onError", {
timeout: 5000,
}, async () => {
const errors: Error[] = [];
const fetchImpl = async (): Promise<Response> => {
throw new TypeError("offline");
};
const { getByRole } = renderComponent(
<FormWrapper
options={{
authConfig: {
authUrl: "https://auth.example.com",
fetch: fetchImpl,
navigate: () => {
throw new Error("nav-failed");
},
},
}}
>
<LoginForm
providers={[{ id: "google", name: "Google" }]}
onError={(e) => errors.push(e)}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.click(getByRole("button", { name: "Google" }));
});
expect(errors.length).toBeGreaterThan(0);
});
it("remember-me checkbox toggles", { timeout: 5000 }, async () => {
const { container } = renderComponent(
<FormWrapper>
<LoginForm />
</FormWrapper>
);
const cb = container.querySelector(
'input[type="checkbox"]'
) as HTMLInputElement;
expect(cb.checked).toBe(false);
await act(async () => {
fireEvent.click(cb);
});
expect(cb.checked).toBe(true);
});
it("forgot-password handler fires on click", {
timeout: 5000,
}, async () => {
let called = 0;
const { getByRole } = renderComponent(
<FormWrapper>
<LoginForm
onForgotPassword={() => {
called += 1;
}}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.click(
getByRole("button", { name: "Forgot your password?" })
);
});
expect(called).toBe(1);
});
it("register handler fires on sign-up click", {
timeout: 5000,
}, async () => {
let called = 0;
const { getByRole } = renderComponent(
<FormWrapper>
<LoginForm
onRegister={() => {
called += 1;
}}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.click(getByRole("button", { name: "Sign up" }));
});
expect(called).toBe(1);
});
it("custom logo, title, and subtitle override defaults", {
timeout: 5000,
}, () => {
const { container } = renderComponent(
<FormWrapper>
<LoginForm
logo={<div data-testid="logo">L</div>}
title="Hi"
subtitle="World"
/>
</FormWrapper>
);
expect(container.querySelector(".auth-title")?.textContent).toBe("Hi");
expect(container.querySelector(".auth-subtitle")?.textContent).toBe(
"World"
);
expect(container.querySelector('[data-testid="logo"]')).not.toBeNull();
});
it("renders provider icon as image when string", { timeout: 5000 }, () => {
const { getByRole } = renderComponent(
<FormWrapper>
<LoginForm
providers={[
{
id: "google",
name: "Google",
icon: "https://cdn/g.svg",
},
]}
/>
</FormWrapper>
);
const btn = getByRole("button", { name: /Google/i });
const img = btn.querySelector("img") as HTMLImageElement;
expect(img.src).toContain("g.svg");
});
it("renders provider icon as component when ComponentType", {
timeout: 5000,
}, () => {
const Icon = (): JSX.Element => <span data-testid="comp-icon" />;
const { container } = renderComponent(
<FormWrapper>
<LoginForm
providers={[{ id: "github", name: "GitHub", icon: Icon }]}
/>
</FormWrapper>
);
expect(
container.querySelector('[data-testid="comp-icon"]')
).not.toBeNull();
});
it("submit with no override invokes useAuth().login", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl, calls } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({ user: { id: "u", email: "a@b.c" } });
});
const { container } = renderComponent(
<FormWrapper
options={{
authConfig: {
authUrl: "https://auth.example.com",
fetch: fetchImpl,
},
}}
>
<LoginForm />
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector('input[name="email"]') as HTMLElement,
{ target: { value: "a@b.c" } }
);
fireEvent.change(
container.querySelector(
'input[name="password"]'
) as HTMLElement,
{ target: { value: "pw" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
await new Promise((r) => setTimeout(r, 0));
});
expect(calls.find((c) => c.url.endsWith("/login"))).toBeDefined();
});
it("non-Error onSubmit failure is wrapped", { timeout: 5000 }, async () => {
const { container, getByRole } = renderComponent(
<FormWrapper>
<LoginForm
onSubmit={async () => {
const thrown: unknown = "string-failure";
throw thrown;
}}
/>
</FormWrapper>
);
const form = container.querySelector("form") as HTMLFormElement;
await act(async () => {
fireEvent.change(
container.querySelector('input[name="email"]') as HTMLElement,
{ target: { value: "a@b.c" } }
);
fireEvent.change(
container.querySelector(
'input[name="password"]'
) as HTMLElement,
{ target: { value: "x" } }
);
fireEvent.submit(form);
});
expect(getByRole("alert").textContent).toBe("Login failed");
});
it("non-Error provider login failure is wrapped", {
timeout: 5000,
}, async () => {
const errors: Error[] = [];
const { getByRole } = renderComponent(
<FormWrapper
options={{
authConfig: {
authUrl: "https://x",
navigate: () => {
const thrown: unknown = "boom";
throw thrown;
},
},
}}
>
<LoginForm
providers={[{ id: "g", name: "G" }]}
onError={(e) => errors.push(e)}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.click(getByRole("button", { name: "G" }));
});
expect(errors[0]?.message).toBe("Provider login failed");
});
it("disabled prop disables inputs and submit", { timeout: 5000 }, () => {
const { container, getByRole } = renderComponent(
<FormWrapper>
<LoginForm disabled />
</FormWrapper>
);
const email = container.querySelector(
'input[name="email"]'
) as HTMLInputElement;
expect(email.disabled).toBe(true);
const btn = getByRole("button", { name: "Sign In" });
expect(btn).toBeInstanceOf(HTMLButtonElement);
expect((btn as HTMLButtonElement).disabled).toBe(true);
});
});

View File

@@ -0,0 +1,240 @@
import { useTranslation } from "@suaveplan/react-i18n";
import { type FormEvent, type ReactNode, useState } from "react";
import { useAuth } from "../../context/auth-context.js";
import { useBranding, useLogo } from "../../context/branding-context.js";
import { navigateOrCall } from "../../utils/navigate-or-call.js";
import type { LoginFormProps, LoginFormValues } from "./login-form.types.js";
/** White-label login form. */
export function LoginForm({
onSubmit,
onSuccess,
onError,
providers = [],
showRememberMe = true,
showForgotPassword = true,
onForgotPassword,
showRegisterLink = true,
onRegister,
className = "",
disabled = false,
logo: customLogo,
title,
subtitle,
}: LoginFormProps): ReactNode {
const { login, loginWithProvider, isLoading } = useAuth();
const { branding } = useBranding();
const logoUrl = useLogo();
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: FormEvent): Promise<void> => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
const values: LoginFormValues = { email, password, rememberMe };
try {
if (onSubmit !== undefined) {
await onSubmit(values);
} else {
await login(email, password);
}
onSuccess?.();
} catch (err) {
const e2 = err instanceof Error ? err : new Error("Login failed");
setError(e2.message);
onError?.(e2);
} finally {
setIsSubmitting(false);
}
};
const handleProviderLogin = async (providerId: string): Promise<void> => {
try {
await loginWithProvider(providerId);
} catch (err) {
const e2 =
err instanceof Error ? err : new Error("Provider login failed");
setError(e2.message);
onError?.(e2);
}
};
const isDisabled = disabled || isLoading || isSubmitting;
const displayTitle =
title ?? t("auth.signIn.title", { defaultValue: "Sign In" });
const displaySubtitle =
subtitle ??
t("auth.signIn.description", {
defaultValue: "Enter your credentials to access your account",
});
return (
<div className={`auth-form-container ${className}`}>
<div className="auth-form-header">
{customLogo ??
(logoUrl !== undefined && (
<img
src={logoUrl}
alt={branding.name}
className="auth-logo"
/>
))}
<h1 className="auth-title">{displayTitle}</h1>
<p className="auth-subtitle">{displaySubtitle}</p>
</div>
{error !== null && (
<div className="auth-error" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<div className="auth-field">
<label htmlFor="email" className="auth-label">
{t("auth.fields.email.label", {
defaultValue: "Email Address",
})}
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={isDisabled}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="auth-input"
placeholder={t("auth.fields.email.placeholder", {
defaultValue: "you@example.com",
})}
/>
</div>
<div className="auth-field">
<label htmlFor="password" className="auth-label">
{t("auth.fields.password.label", {
defaultValue: "Password",
})}
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
disabled={isDisabled}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="auth-input"
/>
</div>
<div className="auth-options">
{showRememberMe && (
<label className="auth-checkbox-label">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) =>
setRememberMe(e.target.checked)
}
disabled={isDisabled}
className="auth-checkbox"
/>
<span>
{t("auth.fields.rememberMe.label", {
defaultValue: "Remember me",
})}
</span>
</label>
)}
{showForgotPassword && onForgotPassword !== undefined && (
<button
type="button"
onClick={() => navigateOrCall(onForgotPassword)}
className="auth-link"
disabled={isDisabled}
>
{t("auth.signIn.forgotPassword", {
defaultValue: "Forgot your password?",
})}
</button>
)}
</div>
<button
type="submit"
disabled={isDisabled}
className="auth-submit-button"
>
{isSubmitting
? t("auth.signIn.signingIn", {
defaultValue: "Signing in...",
})
: t("auth.signIn.submit", { defaultValue: "Sign In" })}
</button>
</form>
{providers.length > 0 && (
<div className="auth-providers">
<div className="auth-divider">
<span>
{t("auth.signIn.orContinueWith", {
defaultValue: "or continue with email",
})}
</span>
</div>
<div className="auth-provider-buttons">
{providers.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleProviderLogin(provider.id)}
disabled={isDisabled}
className="auth-provider-button"
>
{typeof provider.icon === "string" ? (
<img
src={provider.icon}
alt=""
className="auth-provider-icon"
/>
) : provider.icon !== undefined ? (
<provider.icon className="auth-provider-icon" />
) : null}
<span>{provider.name}</span>
</button>
))}
</div>
</div>
)}
{showRegisterLink && onRegister !== undefined && (
<p className="auth-footer">
{t("auth.signIn.noAccount", {
defaultValue: "Don't have an account?",
})}{" "}
<button
type="button"
onClick={() => navigateOrCall(onRegister)}
className="auth-link"
disabled={isDisabled}
>
{t("auth.signIn.signUpLink", {
defaultValue: "Sign up",
})}
</button>
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import type { AuthProvider } from "../../types/branding.types.js";
/** Values collected by the login form. */
export interface LoginFormValues {
email: string;
password: string;
rememberMe?: boolean | undefined;
}
/** Props for `<LoginForm>`. */
export interface LoginFormProps {
onSubmit?: ((values: LoginFormValues) => Promise<void>) | undefined;
onSuccess?: (() => void) | undefined;
onError?: ((error: Error) => void) | undefined;
providers?: AuthProvider[] | undefined;
showRememberMe?: boolean | undefined;
showForgotPassword?: boolean | undefined;
onForgotPassword?: string | (() => void) | undefined;
showRegisterLink?: boolean | undefined;
onRegister?: string | (() => void) | undefined;
className?: string | undefined;
disabled?: boolean | undefined;
logo?: ReactNode | undefined;
title?: string | undefined;
subtitle?: string | undefined;
}

View File

@@ -0,0 +1,13 @@
export { PasswordResetConfirm } from "./password-reset-confirm.js";
export { PasswordResetForm } from "./password-reset-form.js";
export type {
PasswordResetConfirmProps,
PasswordResetConfirmValues,
PasswordResetFormProps,
PasswordResetMode,
PasswordResetRequestProps,
PasswordResetRequestValues,
PasswordResetSuccessProps,
} from "./password-reset-form.types.js";
export { PasswordResetRequest } from "./password-reset-request.js";
export { PasswordResetSuccess } from "./password-reset-success.js";

View File

@@ -0,0 +1,70 @@
# `<PasswordResetConfirm>`
## Purpose
`PasswordResetConfirm` is the confirm-mode body of `<PasswordResetForm>` —
the screen the user lands on from the email link with a `token` query
parameter. It renders the new-password input, the confirm-password input,
the submit button (with translated in-flight copy), and an optional
back-to-login affordance. It is a separate named sub-component so the
parent stays under the 300-LOC hard budget and so the request/confirm
markup can be tested independently. The parent owns the `token` and the
network call; this component is purely the controlled-input shell.
## Features
- Two controlled inputs driven by the parent.
- Browser-native `minLength={minPasswordLength}` on the password field
supplements the parent's JS validation.
- Submit copy toggles via the parent's `isSubmitting` flag.
- Optional back-to-login affordance.
## Usage: Basic
```tsx
<PasswordResetConfirm
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
onSubmit={handleSubmit}
minPasswordLength={8}
isDisabled={false}
isSubmitting={false}
showLoginLink
/>
```
## Usage: Advanced
```tsx
<PasswordResetConfirm
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
onSubmit={async (e) => {
e.preventDefault();
await resetPassword(token, password);
setIsSuccess(true);
}}
minPasswordLength={12}
isDisabled={isLoading}
isSubmitting={isSubmitting}
showLoginLink
onLogin={() => router.push("/login")}
/>
```
## API Reference
- `PasswordResetConfirm(props: PasswordResetConfirmProps): ReactNode` —
see `password-reset-form.types.ts` for the prop shape.
## Implementation Notes
The component intentionally does NOT receive the reset token — the parent
holds it and either threads it into the `resetPassword(token, password)`
call or surfaces a `MISSING_RESET_TOKEN` error in the parent's `role="alert"`
region BEFORE handing off to this sub-component. Splitting that policy
out keeps the confirm-body purely concerned with the controlled inputs.

View File

@@ -0,0 +1,97 @@
import { useTranslation } from "@suaveplan/react-i18n";
import type { ReactNode } from "react";
import { navigateOrCall } from "../../utils/navigate-or-call.js";
import type { PasswordResetConfirmProps } from "./password-reset-form.types.js";
/**
* Confirm-mode body for `<PasswordResetForm>`. Collects the new password
* + confirmation and renders the submit button + optional back-to-login
* affordance.
*/
export function PasswordResetConfirm({
password,
setPassword,
confirmPassword,
setConfirmPassword,
onSubmit,
minPasswordLength,
isDisabled,
isSubmitting,
showLoginLink,
onLogin,
}: PasswordResetConfirmProps): ReactNode {
const { t } = useTranslation();
return (
<>
<form onSubmit={onSubmit} className="auth-form">
<div className="auth-field">
<label htmlFor="password" className="auth-label">
{t("auth.fields.newPassword.label", {
defaultValue: "New Password",
})}
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
disabled={isDisabled}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="auth-input"
minLength={minPasswordLength}
/>
</div>
<div className="auth-field">
<label htmlFor="confirmPassword" className="auth-label">
{t("auth.fields.confirmPassword.label", {
defaultValue: "Confirm Password",
})}
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
disabled={isDisabled}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="auth-input"
/>
</div>
<button
type="submit"
disabled={isDisabled}
className="auth-submit-button"
>
{isSubmitting
? t("auth.resetPassword.resetting", {
defaultValue: "Resetting...",
})
: t("auth.resetPassword.submit", {
defaultValue: "Reset Password",
})}
</button>
</form>
{showLoginLink && onLogin !== undefined && (
<p className="auth-footer">
<button
type="button"
onClick={() => navigateOrCall(onLogin)}
className="auth-link"
disabled={isDisabled}
>
{t("auth.forgotPassword.backToSignIn", {
defaultValue: "Back to sign in",
})}
</button>
</p>
)}
</>
);
}

View File

@@ -0,0 +1,79 @@
# `<PasswordResetForm>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<PasswordResetForm>` is the white-label password-reset surface. It owns a
single state machine across two modes — `request` (email entry) and
`confirm` (new password entry with a reset token) — and a shared success
state. The component delegates the request-mode body to
`<PasswordResetRequest>`, the confirm-mode body to
`<PasswordResetConfirm>`, and the success view to
`<PasswordResetSuccess>`, keeping each rendered slice independently
testable and the parent file under the 300-LOC hard budget. Confirm mode
short-circuits with an inline `MISSING_RESET_TOKEN` error when the
`token` prop is missing — the network is never touched.
## Features
- Dual-mode form sharing a single state machine.
- Local validation (min length, confirm-password match, missing token)
blocks submission before any network call.
- Optional `onSubmit` override.
- Mode-aware default titles, subtitles, and success messages — every
string sourced from `useTranslation()`.
- Optional back-to-login affordance shared by every mode.
## Usage: Basic
```tsx
<PasswordResetForm mode="request" onLogin="/login" />
```
```tsx
<PasswordResetForm
mode="confirm"
token={new URLSearchParams(location.search).get("token") ?? undefined}
onLogin="/login"
/>
```
## Usage: Advanced
```tsx
<PasswordResetForm
mode={mode}
token={token}
minPasswordLength={12}
onSubmit={async (values) => {
if ("email" in values) {
await betterAuthClient.forgetPassword({ email: values.email });
} else {
await betterAuthClient.resetPassword({ token, password: values.password });
}
}}
successMessage={customSuccess}
onSuccess={() => analytics.track("password_reset_success")}
onError={(err) => analytics.track("password_reset_error", { code: err.code })}
/>
```
## API Reference
- `PasswordResetForm(props: PasswordResetFormProps): ReactNode` — see
`password-reset-form.types.ts`. `minPasswordLength` defaults to 8;
`mode` defaults to `"request"`.
## Implementation Notes
The success state is a single boolean — both modes share the same
`isSuccess` so the success view renders identically regardless of which
mode produced it. The mode-specific copy lives in lookup tables
(`defaultTitles`, `defaultSubtitles`, `defaultSuccessMessages`) keyed by
the `mode` discriminator. Submission errors are surfaced inline AND
forwarded to the `onError` callback; consumers that want analytics
without UI side-effects can ignore the inline error region by overriding
its styling.

View File

@@ -0,0 +1,98 @@
import type { Meta, StoryObj } from "@storybook/react";
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui/theme";
import type { ReactNode } from "react";
import { AuthProvider } from "../../context/auth-context.js";
import { BrandingProvider } from "../../context/branding-context.js";
import { PasswordResetForm } from "./password-reset-form.js";
function Wrap({ children }: { children: ReactNode }): ReactNode {
return (
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
<BrandingProvider
staticBranding={{
tenantId: "demo",
name: "Demo Co",
primaryColor: "#3B82F6",
secondaryColor: "#10B981",
}}
>
<AuthProvider
config={{ authUrl: "/api/auth" }}
initialUser={null}
>
{children}
</AuthProvider>
</BrandingProvider>
</ThemeProvider>
);
}
const meta: Meta<typeof PasswordResetForm> = {
title: "react-auth-whitelabel/PasswordResetForm",
component: PasswordResetForm,
tags: ["autodocs"],
decorators: [
(Story) => (
<Wrap>
<Story />
</Wrap>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
mode: "request",
showLoginLink: true,
onLogin: () => undefined,
},
};
export const Playground: Story = {
args: {
mode: "confirm",
token: "playground-token",
minPasswordLength: 12,
showLoginLink: true,
onLogin: () => undefined,
title: "Reset your password",
subtitle: "Enter a new strong password",
},
};
export const RequestMode: Story = {
args: {
mode: "request",
showLoginLink: true,
onLogin: () => undefined,
},
};
export const ConfirmMode: Story = {
args: {
mode: "confirm",
token: "abc123",
showLoginLink: true,
onLogin: () => undefined,
},
};
export const MissingToken: Story = {
args: {
mode: "confirm",
showLoginLink: true,
onLogin: () => undefined,
},
};
export const WithError: Story = {
args: {
mode: "request",
onSubmit: async () => {
throw new Error("Reset failed");
},
},
};

View File

@@ -0,0 +1,471 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act, cleanup, fireEvent } from "@testing-library/react";
import {
FormWrapper,
jsonResponse,
makeFetch,
} from "../../../tests/setup/form-test-utils.js";
import { PasswordResetForm } from "./password-reset-form.js";
afterEach(cleanup);
describe("PasswordResetForm", () => {
it("request mode submits email and shows success state", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const { container, getByText } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="request"
onSubmit={async (v) => {
if ("email" in v) calls.push(v.email);
}}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector('input[name="email"]') as HTMLElement,
{ target: { value: "a@b.c" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(calls).toEqual(["a@b.c"]);
// success state: heading switches
expect(getByText("Check Your Email")).not.toBeNull();
});
it("confirm mode without token surfaces MISSING_RESET_TOKEN", {
timeout: 5000,
}, async () => {
let submitted = false;
const { container, getByRole } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="confirm"
onSubmit={async () => {
submitted = true;
}}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector(
'input[name="password"]'
) as HTMLElement,
{ target: { value: "12345678" } }
);
fireEvent.change(
container.querySelector(
'input[name="confirmPassword"]'
) as HTMLElement,
{ target: { value: "12345678" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(submitted).toBe(false);
expect(getByRole("alert").textContent).toMatch(/token/i);
});
it("confirm mode short password blocks submission", {
timeout: 5000,
}, async () => {
const { container, getByRole } = renderComponent(
<FormWrapper>
<PasswordResetForm mode="confirm" token="t" />
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector(
'input[name="password"]'
) as HTMLElement,
{ target: { value: "x" } }
);
fireEvent.change(
container.querySelector(
'input[name="confirmPassword"]'
) as HTMLElement,
{ target: { value: "x" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByRole("alert").textContent).toMatch(/at least/i);
});
it("confirm mode password mismatch blocks submission", {
timeout: 5000,
}, async () => {
const { container, getByRole } = renderComponent(
<FormWrapper>
<PasswordResetForm mode="confirm" token="t" />
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector(
'input[name="password"]'
) as HTMLElement,
{ target: { value: "12345678" } }
);
fireEvent.change(
container.querySelector(
'input[name="confirmPassword"]'
) as HTMLElement,
{ target: { value: "00000000" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByRole("alert").textContent).toMatch(/match/i);
});
it("confirm mode valid submission invokes resetPassword", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl, calls } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({});
});
const { container, getByText } = renderComponent(
<FormWrapper
options={{
authConfig: {
authUrl: "https://auth.example.com",
fetch: fetchImpl,
},
}}
>
<PasswordResetForm mode="confirm" token="t-abc" />
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector(
'input[name="password"]'
) as HTMLElement,
{ target: { value: "12345678" } }
);
fireEvent.change(
container.querySelector(
'input[name="confirmPassword"]'
) as HTMLElement,
{ target: { value: "12345678" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
await new Promise((r) => setTimeout(r, 0));
});
expect(
calls.find((c) => c.url.endsWith("/password-reset/confirm"))
).toBeDefined();
expect(getByText("Password Reset Complete")).not.toBeNull();
});
it("request mode submission failure surfaces inline + onError", {
timeout: 5000,
}, async () => {
const errors: Error[] = [];
const { container, getByRole } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="request"
onSubmit={async () => {
throw new Error("nope");
}}
onError={(e) => errors.push(e)}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector('input[name="email"]') as HTMLElement,
{ target: { value: "a@b.c" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByRole("alert").textContent).toBe("nope");
expect(errors).toHaveLength(1);
});
it("confirm mode submission failure surfaces inline + onError", {
timeout: 5000,
}, async () => {
const errors: Error[] = [];
const { container, getByRole } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="confirm"
token="t"
onSubmit={async () => {
throw new Error("backend-down");
}}
onError={(e) => errors.push(e)}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector(
'input[name="password"]'
) as HTMLElement,
{ target: { value: "12345678" } }
);
fireEvent.change(
container.querySelector(
'input[name="confirmPassword"]'
) as HTMLElement,
{ target: { value: "12345678" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByRole("alert").textContent).toBe("backend-down");
expect(errors).toHaveLength(1);
});
it("request mode + no override calls requestPasswordReset", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl, calls } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({});
});
const { container } = renderComponent(
<FormWrapper
options={{
authConfig: {
authUrl: "https://auth.example.com",
fetch: fetchImpl,
},
}}
>
<PasswordResetForm mode="request" />
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector('input[name="email"]') as HTMLElement,
{ target: { value: "a@b.c" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
await new Promise((r) => setTimeout(r, 0));
});
expect(
calls.find((c) => c.url.endsWith("/password-reset"))
).toBeDefined();
});
it("back-to-login click in request mode fires onLogin", {
timeout: 5000,
}, async () => {
let called = 0;
const { getByRole } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="request"
onLogin={() => {
called += 1;
}}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.click(getByRole("button", { name: "Back to sign in" }));
});
expect(called).toBe(1);
});
it("back-to-login click in confirm mode fires onLogin", {
timeout: 5000,
}, async () => {
let called = 0;
const { getByRole } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="confirm"
token="t"
onLogin={() => {
called += 1;
}}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.click(getByRole("button", { name: "Back to sign in" }));
});
expect(called).toBe(1);
});
it("success state renders back-to-login affordance", {
timeout: 5000,
}, async () => {
let called = 0;
const { container, getByRole } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="request"
onSubmit={async () => undefined}
onLogin={() => {
called += 1;
}}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector('input[name="email"]') as HTMLElement,
{ target: { value: "a@b.c" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
await act(async () => {
fireEvent.click(getByRole("button", { name: "Back to sign in" }));
});
expect(called).toBe(1);
});
it("custom title/subtitle/successMessage override defaults", {
timeout: 5000,
}, () => {
const { container } = renderComponent(
<FormWrapper>
<PasswordResetForm mode="request" title="X" subtitle="Y" />
</FormWrapper>
);
expect(container.querySelector(".auth-title")?.textContent).toBe("X");
expect(container.querySelector(".auth-subtitle")?.textContent).toBe(
"Y"
);
});
it("custom logo renders inside success state", {
timeout: 5000,
}, async () => {
const { container } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="request"
logo={<div data-testid="logo">L</div>}
onSubmit={async () => undefined}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector('input[name="email"]') as HTMLElement,
{ target: { value: "a@b.c" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(container.querySelector('[data-testid="logo"]')).not.toBeNull();
});
it("disabled prop disables inputs", { timeout: 5000 }, () => {
const { container } = renderComponent(
<FormWrapper>
<PasswordResetForm mode="request" disabled />
</FormWrapper>
);
const email = container.querySelector(
'input[name="email"]'
) as HTMLInputElement;
expect(email.disabled).toBe(true);
});
it("defaults to request mode when mode prop is omitted", {
timeout: 5000,
}, () => {
const { container } = renderComponent(
<FormWrapper>
<PasswordResetForm />
</FormWrapper>
);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
});
it("wraps non-Error request submit failure", {
timeout: 5000,
}, async () => {
const { container, getByRole } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="request"
onSubmit={async () => {
const thrown: unknown = "boom";
throw thrown;
}}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector('input[name="email"]') as HTMLElement,
{ target: { value: "a@b.c" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByRole("alert").textContent).toBe(
"Password reset request failed"
);
});
it("wraps non-Error confirm submit failure", {
timeout: 5000,
}, async () => {
const { container, getByRole } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="confirm"
token="t"
onSubmit={async () => {
const thrown: unknown = "boom";
throw thrown;
}}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector(
'input[name="password"]'
) as HTMLElement,
{ target: { value: "12345678" } }
);
fireEvent.change(
container.querySelector(
'input[name="confirmPassword"]'
) as HTMLElement,
{ target: { value: "12345678" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByRole("alert").textContent).toBe("Password reset failed");
});
it("successMessage prop overrides default success copy", {
timeout: 5000,
}, async () => {
const { container, getByText } = renderComponent(
<FormWrapper>
<PasswordResetForm
mode="request"
onSubmit={async () => undefined}
successMessage="Custom success here"
/>
</FormWrapper>
);
await act(async () => {
fireEvent.change(
container.querySelector('input[name="email"]') as HTMLElement,
{ target: { value: "a@b.c" } }
);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByText("Custom success here")).not.toBeNull();
});
});

View File

@@ -0,0 +1,222 @@
import { useTranslation } from "@suaveplan/react-i18n";
import { type FormEvent, type ReactNode, useState } from "react";
import { useAuth } from "../../context/auth-context.js";
import { useBranding, useLogo } from "../../context/branding-context.js";
import {
REACT_AUTH_WL_CODES,
ReactAuthWhitelabelError,
} from "../../errors/index.js";
import { PasswordResetConfirm } from "./password-reset-confirm.js";
import type {
PasswordResetConfirmValues,
PasswordResetFormProps,
PasswordResetRequestValues,
} from "./password-reset-form.types.js";
import { PasswordResetRequest } from "./password-reset-request.js";
import { PasswordResetSuccess } from "./password-reset-success.js";
/** White-label password-reset form. Two modes: `request` and `confirm`. */
export function PasswordResetForm({
mode = "request",
token,
onSubmit,
onSuccess,
onError,
showLoginLink = true,
onLogin,
className = "",
disabled = false,
logo: customLogo,
title,
subtitle,
minPasswordLength = 8,
successMessage,
}: PasswordResetFormProps): ReactNode {
const { requestPasswordReset, resetPassword, isLoading } = useAuth();
const { branding } = useBranding();
const logoUrl = useLogo();
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const handleRequestSubmit = async (e: FormEvent): Promise<void> => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
const values: PasswordResetRequestValues = { email };
try {
if (onSubmit !== undefined) {
await onSubmit(values);
} else {
await requestPasswordReset(email);
}
setIsSuccess(true);
onSuccess?.();
} catch (err) {
const e2 =
err instanceof Error
? err
: new Error("Password reset request failed");
setError(e2.message);
onError?.(e2);
} finally {
setIsSubmitting(false);
}
};
const handleConfirmSubmit = async (e: FormEvent): Promise<void> => {
e.preventDefault();
setError(null);
if (password.length < minPasswordLength) {
setError(
t("auth.resetPassword.errors.tooShort", {
defaultValue: `Password must be at least ${minPasswordLength} characters`,
})
);
return;
}
if (password !== confirmPassword) {
setError(
t("auth.resetPassword.errors.mismatch", {
defaultValue: "Passwords do not match",
})
);
return;
}
if (token === undefined || token === "") {
const missingTokenError = new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.MISSING_RESET_TOKEN,
"react_auth_wl.error.missing_reset_token"
);
setError(
t("auth.resetPassword.errors.missingToken", {
defaultValue: "Reset token is missing",
})
);
onError?.(missingTokenError);
return;
}
setIsSubmitting(true);
const values: PasswordResetConfirmValues = {
password,
confirmPassword,
};
try {
if (onSubmit !== undefined) {
await onSubmit(values);
} else {
await resetPassword(token, password);
}
setIsSuccess(true);
onSuccess?.();
} catch (err) {
const e2 =
err instanceof Error ? err : new Error("Password reset failed");
setError(e2.message);
onError?.(e2);
} finally {
setIsSubmitting(false);
}
};
const isDisabled = disabled || isLoading || isSubmitting;
const defaultTitles = {
request: t("auth.forgotPassword.title", {
defaultValue: "Reset Password",
}),
confirm: t("auth.resetPassword.title", {
defaultValue: "Set New Password",
}),
};
const defaultSubtitles = {
request: t("auth.forgotPassword.description", {
defaultValue:
"Enter your email address and we'll send you a reset link",
}),
confirm: t("auth.resetPassword.description", {
defaultValue: "Enter your new password below",
}),
};
const defaultSuccessMessages = {
request: t("auth.forgotPassword.successMessage", {
defaultValue:
"We've sent a password reset link to your email address. Please check your inbox and follow the instructions.",
}),
confirm: t("auth.resetPassword.successMessage", {
defaultValue:
"Your password has been successfully reset. You can now sign in with your new password.",
}),
};
const displayTitle = title ?? defaultTitles[mode];
const displaySubtitle = subtitle ?? defaultSubtitles[mode];
const displaySuccessMessage =
successMessage ?? defaultSuccessMessages[mode];
const headerLogo =
customLogo ??
(logoUrl !== undefined && (
<img src={logoUrl} alt={branding.name} className="auth-logo" />
));
if (isSuccess) {
return (
<div className={`auth-form-container ${className}`}>
<div className="auth-form-header">{headerLogo}</div>
<PasswordResetSuccess
mode={mode}
message={displaySuccessMessage}
showLoginLink={showLoginLink}
onLogin={onLogin}
/>
</div>
);
}
return (
<div className={`auth-form-container ${className}`}>
<div className="auth-form-header">
{headerLogo}
<h1 className="auth-title">{displayTitle}</h1>
<p className="auth-subtitle">{displaySubtitle}</p>
</div>
{error !== null && (
<div className="auth-error" role="alert">
{error}
</div>
)}
{mode === "request" ? (
<PasswordResetRequest
email={email}
setEmail={setEmail}
onSubmit={handleRequestSubmit}
isDisabled={isDisabled}
isSubmitting={isSubmitting}
showLoginLink={showLoginLink}
onLogin={onLogin}
/>
) : (
<PasswordResetConfirm
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
onSubmit={handleConfirmSubmit}
minPasswordLength={minPasswordLength}
isDisabled={isDisabled}
isSubmitting={isSubmitting}
showLoginLink={showLoginLink}
onLogin={onLogin}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import type { FormEvent, ReactNode } from "react";
/** Request-mode values. */
export interface PasswordResetRequestValues {
email: string;
}
/** Confirm-mode values. */
export interface PasswordResetConfirmValues {
password: string;
confirmPassword: string;
}
/** Form mode. */
export type PasswordResetMode = "request" | "confirm";
/** Props for `<PasswordResetForm>`. */
export interface PasswordResetFormProps {
mode?: PasswordResetMode | undefined;
token?: string | undefined;
onSubmit?:
| ((
values: PasswordResetRequestValues | PasswordResetConfirmValues
) => Promise<void>)
| undefined;
onSuccess?: (() => void) | undefined;
onError?: ((error: Error) => void) | undefined;
showLoginLink?: boolean | undefined;
onLogin?: string | (() => void) | undefined;
className?: string | undefined;
disabled?: boolean | undefined;
logo?: ReactNode | undefined;
title?: string | undefined;
subtitle?: string | undefined;
minPasswordLength?: number | undefined;
successMessage?: string | undefined;
}
/** Props for the request-mode body sub-component. */
export interface PasswordResetRequestProps {
email: string;
setEmail: (v: string) => void;
onSubmit: (e: FormEvent) => Promise<void>;
isDisabled: boolean;
isSubmitting: boolean;
showLoginLink: boolean;
onLogin?: string | (() => void) | undefined;
}
/** Props for the confirm-mode body sub-component. */
export interface PasswordResetConfirmProps {
password: string;
setPassword: (v: string) => void;
confirmPassword: string;
setConfirmPassword: (v: string) => void;
onSubmit: (e: FormEvent) => Promise<void>;
minPasswordLength: number;
isDisabled: boolean;
isSubmitting: boolean;
showLoginLink: boolean;
onLogin?: string | (() => void) | undefined;
}
/** Props for the success-state sub-component. */
export interface PasswordResetSuccessProps {
mode: PasswordResetMode;
message: string;
showLoginLink: boolean;
onLogin?: string | (() => void) | undefined;
}

View File

@@ -0,0 +1,64 @@
# `<PasswordResetRequest>`
## Purpose
`PasswordResetRequest` is the request-mode body of `<PasswordResetForm>` —
the screen the user lands on when they click "Forgot password?". It
renders the email input, the submit button (with a translated
"Sending..." copy while the network call is in flight), and an optional
back-to-login affordance. It exists as a named sub-component so the parent
`password-reset-form.tsx` stays under the 300-LOC hard budget while
keeping the request-vs-confirm modes structurally separate.
## Features
- Single controlled email input driven by the parent.
- Submit button copy toggles between "Send Reset Link" / "Sending..."
via the parent's `isSubmitting` flag.
- Optional back-to-login affordance (URL or handler).
- Every visible string sourced from `useTranslation()`.
## Usage: Basic
```tsx
<PasswordResetRequest
email={email}
setEmail={setEmail}
onSubmit={handleSubmit}
isDisabled={false}
isSubmitting={false}
showLoginLink
onLogin={() => router.push("/login")}
/>
```
## Usage: Advanced
```tsx
<PasswordResetRequest
email={email}
setEmail={setEmail}
onSubmit={async (e) => {
e.preventDefault();
await requestPasswordReset(email);
setIsSuccess(true);
}}
isDisabled={isLoading}
isSubmitting={isSubmitting}
showLoginLink
onLogin="https://app.example.com/login"
/>
```
## API Reference
- `PasswordResetRequest(props: PasswordResetRequestProps): ReactNode` —
see `password-reset-form.types.ts`.
## Implementation Notes
The component takes the submit handler as a prop instead of owning it,
because the parent form needs to coordinate request-vs-confirm submissions
through a single `isSubmitting` / `isSuccess` state machine. The
`navigateOrCall` helper handles both string-URL and callback shapes for
`onLogin` so consumers can pass either.

View File

@@ -0,0 +1,76 @@
import { useTranslation } from "@suaveplan/react-i18n";
import type { ReactNode } from "react";
import { navigateOrCall } from "../../utils/navigate-or-call.js";
import type { PasswordResetRequestProps } from "./password-reset-form.types.js";
/**
* Request-mode body for `<PasswordResetForm>`. Collects the user's email
* and renders the submit button + optional back-to-login affordance.
*/
export function PasswordResetRequest({
email,
setEmail,
onSubmit,
isDisabled,
isSubmitting,
showLoginLink,
onLogin,
}: PasswordResetRequestProps): ReactNode {
const { t } = useTranslation();
return (
<>
<form onSubmit={onSubmit} className="auth-form">
<div className="auth-field">
<label htmlFor="email" className="auth-label">
{t("auth.fields.email.label", {
defaultValue: "Email Address",
})}
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={isDisabled}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="auth-input"
placeholder={t("auth.fields.email.placeholder", {
defaultValue: "you@example.com",
})}
/>
</div>
<button
type="submit"
disabled={isDisabled}
className="auth-submit-button"
>
{isSubmitting
? t("auth.forgotPassword.sending", {
defaultValue: "Sending...",
})
: t("auth.forgotPassword.submit", {
defaultValue: "Send Reset Link",
})}
</button>
</form>
{showLoginLink && onLogin !== undefined && (
<p className="auth-footer">
<button
type="button"
onClick={() => navigateOrCall(onLogin)}
className="auth-link"
disabled={isDisabled}
>
{t("auth.forgotPassword.backToSignIn", {
defaultValue: "Back to sign in",
})}
</button>
</p>
)}
</>
);
}

View File

@@ -0,0 +1,52 @@
# `<PasswordResetSuccess>`
## Purpose
`PasswordResetSuccess` is the success state of `<PasswordResetForm>`,
shared between the request and confirm modes. It renders a mode-specific
heading ("Check Your Email" for request mode, "Password Reset Complete"
for confirm mode), the success copy in a `role="status"` live region,
and an optional back-to-login button. The component is intentionally
stateless — it is purely a presentational view of the parent's success
state machine, which lets the parent file stay under the 300-LOC budget.
## Features
- Mode-specific success heading.
- `role="status"` live region for assistive technology.
- Optional back-to-login affordance (URL or handler).
## Usage: Basic
```tsx
<PasswordResetSuccess
mode="request"
message={t("auth.forgotPassword.successMessage", { defaultValue: "..." })}
showLoginLink
onLogin="/login"
/>
```
## Usage: Advanced
```tsx
<PasswordResetSuccess
mode={mode}
message={successMessage ?? defaultSuccessMessages[mode]}
showLoginLink={showLoginLink}
onLogin={onLogin}
/>
```
## API Reference
- `PasswordResetSuccess(props: PasswordResetSuccessProps): ReactNode` —
see `password-reset-form.types.ts`.
## Implementation Notes
`role="status"` is the WAI-ARIA pattern for non-interruptive live
announcements — assistive technologies will announce the success copy
without stealing focus. The parent owns the message string so a tenant
override (passed via `successMessage`) flows through this component
without any translation indirection.

View File

@@ -0,0 +1,41 @@
import { useTranslation } from "@suaveplan/react-i18n";
import type { ReactNode } from "react";
import { navigateOrCall } from "../../utils/navigate-or-call.js";
import type { PasswordResetSuccessProps } from "./password-reset-form.types.js";
/** Success state for `<PasswordResetForm>` (both modes). */
export function PasswordResetSuccess({
mode,
message,
showLoginLink,
onLogin,
}: PasswordResetSuccessProps): ReactNode {
const { t } = useTranslation();
return (
<>
<h1 className="auth-title">
{mode === "request"
? t("auth.forgotPassword.successTitle", {
defaultValue: "Check Your Email",
})
: t("auth.resetPassword.successTitle", {
defaultValue: "Password Reset Complete",
})}
</h1>
<div className="auth-success" role="status">
{message}
</div>
{showLoginLink && onLogin !== undefined && (
<button
type="button"
onClick={() => navigateOrCall(onLogin)}
className="auth-submit-button"
>
{t("auth.forgotPassword.backToSignIn", {
defaultValue: "Back to sign in",
})}
</button>
)}
</>
);
}

View File

@@ -0,0 +1,9 @@
export { RegisterForm } from "./register-form.js";
export type {
RegisterFormFieldsProps,
RegisterFormProps,
RegisterFormTermsProps,
RegisterFormValues,
} from "./register-form.types.js";
export { RegisterFormFields } from "./register-form-fields.js";
export { RegisterFormTerms } from "./register-form-terms.js";

View File

@@ -0,0 +1,64 @@
# `<RegisterFormFields>`
## Purpose
`RegisterFormFields` is the inner field block of `<RegisterForm>` — name
(optional, optionally required), email, password, and confirm-password
inputs together with their labels and required indicators. It exists as a
named sub-component so the parent `register-form.tsx` stays under the
300-LOC hard budget while keeping every field's translation key, autocomplete
attribute, and placeholder co-located with the input element. All four
inputs are fully controlled by the parent — every value + setter pair is
passed in through props.
## Features
- Fully controlled inputs driven by parent setters.
- `autocomplete` set per field (`name`, `email`, `new-password`).
- Required indicators driven by `requireName` and the always-required
email + password.
- `minLength={minPasswordLength}` on the password field — supplements the
parent's JS validation with browser-native min-length check.
- Every label/placeholder routed through `useTranslation()` with
`{ defaultValue }`.
## Usage: Basic
```tsx
<form>
<RegisterFormFields {...fieldProps} />
</form>
```
## Usage: Advanced
```tsx
<RegisterFormFields
name={name}
setName={setName}
email={email}
setEmail={setEmail}
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
showNameField
requireName
minPasswordLength={12}
isDisabled={isLoading || isSubmitting}
/>
```
## API Reference
- `RegisterFormFields(props: RegisterFormFieldsProps): ReactNode` — see
`register-form.types.ts` for the prop shape.
## Implementation Notes
The component is pure — it renders state but does not own it. The parent
form handles submission, error rendering, and submit-button copy. Splitting
the field block from the form shell isolates the markup churn (which is
mostly localised string keys) from the submission logic, making both files
easier to audit. Coverage is exercised end-to-end via the parent form's
test file.

View File

@@ -0,0 +1,149 @@
import { createI18n } from "@suaveplan/i18n/plugin";
import { TranslationProvider } from "@suaveplan/react-i18n";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { cleanup, fireEvent } from "@testing-library/react";
import type { ReactElement, ReactNode } from "react";
import { RegisterFormFields } from "./register-form-fields.js";
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
function wrap(node: ReactElement): ReactNode {
return <TranslationProvider i18n={i18n}>{node}</TranslationProvider>;
}
afterEach(cleanup);
function defaults(): {
name: string;
setName: (v: string) => void;
email: string;
setEmail: (v: string) => void;
password: string;
setPassword: (v: string) => void;
confirmPassword: string;
setConfirmPassword: (v: string) => void;
showNameField: boolean;
requireName: boolean;
minPasswordLength: number;
isDisabled: boolean;
} {
return {
name: "",
setName: () => undefined,
email: "",
setEmail: () => undefined,
password: "",
setPassword: () => undefined,
confirmPassword: "",
setConfirmPassword: () => undefined,
showNameField: true,
requireName: false,
minPasswordLength: 8,
isDisabled: false,
};
}
describe("RegisterFormFields", () => {
it("renders name input when showNameField is true", {
timeout: 5000,
}, () => {
const { container } = renderComponent(
wrap(<RegisterFormFields {...defaults()} />)
);
expect(container.querySelector('input[name="name"]')).not.toBeNull();
});
it("omits name input when showNameField is false", {
timeout: 5000,
}, () => {
const { container } = renderComponent(
wrap(<RegisterFormFields {...defaults()} showNameField={false} />)
);
expect(container.querySelector('input[name="name"]')).toBeNull();
});
it("name input is required when requireName is true", {
timeout: 5000,
}, () => {
const { container } = renderComponent(
wrap(<RegisterFormFields {...defaults()} requireName />)
);
const name = container.querySelector(
'input[name="name"]'
) as HTMLInputElement;
expect(name.required).toBe(true);
});
it("password input has minLength attribute", { timeout: 5000 }, () => {
const { container } = renderComponent(
wrap(<RegisterFormFields {...defaults()} minPasswordLength={12} />)
);
const pw = container.querySelector(
'input[name="password"]'
) as HTMLInputElement;
expect(pw.minLength).toBe(12);
});
it("setters fire on input change", { timeout: 5000 }, () => {
const captures: Record<string, string> = {};
const { container } = renderComponent(
wrap(
<RegisterFormFields
{...defaults()}
setName={(v) => {
captures.name = v;
}}
setEmail={(v) => {
captures.email = v;
}}
setPassword={(v) => {
captures.password = v;
}}
setConfirmPassword={(v) => {
captures.confirmPassword = v;
}}
/>
)
);
fireEvent.change(
container.querySelector('input[name="name"]') as HTMLElement,
{ target: { value: "N" } }
);
fireEvent.change(
container.querySelector('input[name="email"]') as HTMLElement,
{ target: { value: "E" } }
);
fireEvent.change(
container.querySelector('input[name="password"]') as HTMLElement,
{ target: { value: "P" } }
);
fireEvent.change(
container.querySelector(
'input[name="confirmPassword"]'
) as HTMLElement,
{ target: { value: "C" } }
);
expect(captures).toEqual({
name: "N",
email: "E",
password: "P",
confirmPassword: "C",
});
});
it("isDisabled disables every input", { timeout: 5000 }, () => {
const { container } = renderComponent(
wrap(<RegisterFormFields {...defaults()} isDisabled />)
);
const inputs = container.querySelectorAll(
"input"
) as NodeListOf<HTMLInputElement>;
for (const i of Array.from(inputs)) {
expect(i.disabled).toBe(true);
}
});
});

View File

@@ -0,0 +1,117 @@
import { useTranslation } from "@suaveplan/react-i18n";
import type { ReactNode } from "react";
import type { RegisterFormFieldsProps } from "./register-form.types.js";
/**
* Field block for `<RegisterForm>` — name (optional), email, password,
* confirm-password. Split into its own sub-component so the parent form
* stays within the 300-LOC budget.
*/
export function RegisterFormFields({
name,
setName,
email,
setEmail,
password,
setPassword,
confirmPassword,
setConfirmPassword,
showNameField,
requireName,
minPasswordLength,
isDisabled,
}: RegisterFormFieldsProps): ReactNode {
const { t } = useTranslation();
return (
<>
{showNameField && (
<div className="auth-field">
<label htmlFor="name" className="auth-label">
{t("auth.fields.name.label", {
defaultValue: "Full Name",
})}{" "}
{requireName && <span className="required">*</span>}
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required={requireName}
disabled={isDisabled}
value={name}
onChange={(e) => setName(e.target.value)}
className="auth-input"
placeholder={t("auth.fields.name.placeholder", {
defaultValue: "John Doe",
})}
/>
</div>
)}
<div className="auth-field">
<label htmlFor="email" className="auth-label">
{t("auth.fields.email.label", {
defaultValue: "Email Address",
})}{" "}
<span className="required">*</span>
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={isDisabled}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="auth-input"
placeholder={t("auth.fields.email.placeholder", {
defaultValue: "you@example.com",
})}
/>
</div>
<div className="auth-field">
<label htmlFor="password" className="auth-label">
{t("auth.fields.password.label", {
defaultValue: "Password",
})}{" "}
<span className="required">*</span>
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
disabled={isDisabled}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="auth-input"
minLength={minPasswordLength}
/>
</div>
<div className="auth-field">
<label htmlFor="confirmPassword" className="auth-label">
{t("auth.fields.confirmPassword.label", {
defaultValue: "Confirm Password",
})}{" "}
<span className="required">*</span>
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
disabled={isDisabled}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="auth-input"
/>
</div>
</>
);
}

View File

@@ -0,0 +1,58 @@
# `<RegisterFormTerms>`
## Purpose
`RegisterFormTerms` is the terms-acceptance checkbox block of
`<RegisterForm>`. It renders the acceptance checkbox, the configurable
terms-of-service affordance (rendered as a link when `termsUrl` is supplied,
plain text otherwise), and an optional privacy-policy link. Splitting it
into its own sub-component keeps the parent form under the 300-LOC budget
while making the (slightly intricate) "link OR text" markup easy to test in
isolation. When `showTermsCheckbox` is `false`, the component renders
`null` so the parent does not need to wrap it in a conditional.
## Features
- Checkbox is fully controlled by the parent.
- Terms text renders as `<a target="_blank" rel="noopener noreferrer">`
when `termsUrl` is supplied, plain inline text otherwise.
- Optional privacy-policy `<a>` appended after a translated "and" connector.
- Renders `null` when `showTermsCheckbox` is `false`.
## Usage: Basic
```tsx
<RegisterFormTerms
acceptTerms={acceptTerms}
setAcceptTerms={setAcceptTerms}
showTermsCheckbox
isDisabled={false}
/>
```
## Usage: Advanced
```tsx
<RegisterFormTerms
acceptTerms={acceptTerms}
setAcceptTerms={setAcceptTerms}
showTermsCheckbox
termsUrl="https://example.com/terms"
privacyUrl="https://example.com/privacy"
isDisabled={isSubmitting}
/>
```
## API Reference
- `RegisterFormTerms(props: RegisterFormTermsProps): ReactNode` — props on
`register-form.types.ts`. Returns `null` when the checkbox is hidden.
## Implementation Notes
The "and" connector is a separate translation key so the surrounding
fragment composes cleanly in locales where the connector is more than a
word (e.g. Welsh "ac" vs Spanish "y"). The component intentionally keeps no
state of its own — the parent form is the single source of truth for the
`acceptTerms` flag, and the parent's submit validator gates submission on
that flag.

View File

@@ -0,0 +1,124 @@
import { createI18n } from "@suaveplan/i18n/plugin";
import { TranslationProvider } from "@suaveplan/react-i18n";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { cleanup, fireEvent } from "@testing-library/react";
import type { ReactElement, ReactNode } from "react";
import { RegisterFormTerms } from "./register-form-terms.js";
const i18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
function wrap(node: ReactElement): ReactNode {
return <TranslationProvider i18n={i18n}>{node}</TranslationProvider>;
}
afterEach(cleanup);
describe("RegisterFormTerms", () => {
it("renders null when showTermsCheckbox is false", {
timeout: 5000,
}, () => {
const { container } = renderComponent(
wrap(
<RegisterFormTerms
acceptTerms={false}
setAcceptTerms={() => undefined}
showTermsCheckbox={false}
isDisabled={false}
/>
)
);
expect(container.querySelector("input")).toBeNull();
});
it("renders checkbox with default copy when no urls supplied", {
timeout: 5000,
}, () => {
const { container } = renderComponent(
wrap(
<RegisterFormTerms
acceptTerms={false}
setAcceptTerms={() => undefined}
showTermsCheckbox
isDisabled={false}
/>
)
);
expect(
container.querySelector('input[type="checkbox"]')
).not.toBeNull();
expect(container.querySelectorAll("a").length).toBe(0);
});
it("renders terms link when termsUrl supplied", { timeout: 5000 }, () => {
const { container } = renderComponent(
wrap(
<RegisterFormTerms
acceptTerms={false}
setAcceptTerms={() => undefined}
showTermsCheckbox
termsUrl="https://example.com/tos"
isDisabled={false}
/>
)
);
const anchor = container.querySelector("a") as HTMLAnchorElement;
expect(anchor.href).toContain("/tos");
expect(anchor.rel).toContain("noopener");
});
it("renders both links when both urls supplied", { timeout: 5000 }, () => {
const { container } = renderComponent(
wrap(
<RegisterFormTerms
acceptTerms={false}
setAcceptTerms={() => undefined}
showTermsCheckbox
termsUrl="https://example.com/tos"
privacyUrl="https://example.com/privacy"
isDisabled={false}
/>
)
);
expect(container.querySelectorAll("a").length).toBe(2);
});
it("toggles via setAcceptTerms", { timeout: 5000 }, () => {
const seen: boolean[] = [];
const { container } = renderComponent(
wrap(
<RegisterFormTerms
acceptTerms={false}
setAcceptTerms={(v) => seen.push(v)}
showTermsCheckbox
isDisabled={false}
/>
)
);
const cb = container.querySelector(
'input[type="checkbox"]'
) as HTMLInputElement;
fireEvent.click(cb);
expect(seen).toEqual([true]);
});
it("isDisabled disables the checkbox", { timeout: 5000 }, () => {
const { container } = renderComponent(
wrap(
<RegisterFormTerms
acceptTerms={false}
setAcceptTerms={() => undefined}
showTermsCheckbox
isDisabled
/>
)
);
const cb = container.querySelector(
'input[type="checkbox"]'
) as HTMLInputElement;
expect(cb.disabled).toBe(true);
});
});

View File

@@ -0,0 +1,72 @@
import { useTranslation } from "@suaveplan/react-i18n";
import type { ReactNode } from "react";
import type { RegisterFormTermsProps } from "./register-form.types.js";
/**
* Terms + privacy checkbox block for `<RegisterForm>`. Renders the
* acceptance checkbox, the configurable terms-of-service link, and an
* optional privacy-policy link.
*/
export function RegisterFormTerms({
acceptTerms,
setAcceptTerms,
showTermsCheckbox,
termsUrl,
privacyUrl,
isDisabled,
}: RegisterFormTermsProps): ReactNode {
const { t } = useTranslation();
if (!showTermsCheckbox) {
return null;
}
return (
<div className="auth-terms">
<label className="auth-checkbox-label">
<input
type="checkbox"
checked={acceptTerms}
onChange={(e) => setAcceptTerms(e.target.checked)}
disabled={isDisabled}
className="auth-checkbox"
/>
<span>
{t("auth.fields.agreedToTerms.label", {
defaultValue: "I agree to the",
})}{" "}
{termsUrl !== undefined ? (
<a
href={termsUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("auth.fields.agreedToTerms.termsLink", {
defaultValue: "Terms and Conditions",
})}
</a>
) : (
t("auth.fields.agreedToTerms.termsLink", {
defaultValue: "Terms and Conditions",
})
)}
{privacyUrl !== undefined && (
<>
{" "}
{t("auth.fields.agreedToTerms.and", {
defaultValue: "and",
})}{" "}
<a
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("auth.fields.agreedToTerms.privacyLink", {
defaultValue: "Privacy Policy",
})}
</a>
</>
)}
</span>
</label>
</div>
);
}

View File

@@ -0,0 +1,66 @@
# `<RegisterForm>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<RegisterForm>` is the white-label self-registration surface. It renders
the header (logo + title + subtitle), the inline error region, the field
block (delegated to `RegisterFormFields`), the terms checkbox (delegated
to `RegisterFormTerms`), the submit button, an optional OAuth provider
list, and an optional back-to-login affordance. The component validates
password length, password equality, name presence (when `requireName`),
and terms acceptance BEFORE calling `useAuth().register(...)` (or the
`onSubmit` override). Every validation error renders inline in the
`role="alert"` region and short-circuits the auth call so the network is
never touched until the form is locally clean.
## Features
- Local validation: min password length, confirm-password match,
required name, terms acceptance.
- Field block + terms block extracted into sub-components so this file
stays under the 300-LOC hard budget.
- `onSubmit` override for plug-in flows.
- Optional OAuth providers, optional back-to-login link.
- Custom logo / title / subtitle.
## Usage: Basic
```tsx
<RegisterForm onLogin={() => router.push("/login")} />
```
## Usage: Advanced
```tsx
<RegisterForm
showNameField
requireName
minPasswordLength={12}
termsUrl="https://example.com/tos"
privacyUrl="https://example.com/privacy"
providers={[{ id: "google", name: "Google" }]}
onSubmit={async (values) => {
await betterAuthClient.signUp.email(values);
}}
onSuccess={() => router.push("/welcome")}
onError={(err) => analytics.track("signup_error", { code: err.code })}
/>
```
## API Reference
- `RegisterForm(props: RegisterFormProps): ReactNode` — see
`register-form.types.ts`. `minPasswordLength` defaults to 8.
## Implementation Notes
The validator is intentionally pre-`fetch` so a network round-trip never
runs against an invalid local form. The translator routes every error
message through `t(..., { defaultValue })` so a tenant overriding the
default English copy will see their override even on local validation.
The sub-component split is a budget-only concern; the parent owns all
state and submission logic.

View File

@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from "@storybook/react";
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui/theme";
import type { ReactNode } from "react";
import { AuthProvider } from "../../context/auth-context.js";
import { BrandingProvider } from "../../context/branding-context.js";
import { RegisterForm } from "./register-form.js";
function Wrap({ children }: { children: ReactNode }): ReactNode {
return (
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
<BrandingProvider
staticBranding={{
tenantId: "demo",
name: "Demo Co",
primaryColor: "#3B82F6",
secondaryColor: "#10B981",
}}
>
<AuthProvider
config={{ authUrl: "/api/auth" }}
initialUser={null}
>
{children}
</AuthProvider>
</BrandingProvider>
</ThemeProvider>
);
}
const meta: Meta<typeof RegisterForm> = {
title: "react-auth-whitelabel/RegisterForm",
component: RegisterForm,
tags: ["autodocs"],
decorators: [
(Story) => (
<Wrap>
<Story />
</Wrap>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
showNameField: true,
showTermsCheckbox: true,
showLoginLink: true,
onLogin: () => undefined,
},
};
export const Playground: Story = {
args: {
showNameField: true,
requireName: true,
showTermsCheckbox: true,
termsUrl: "https://example.com/tos",
privacyUrl: "https://example.com/privacy",
minPasswordLength: 12,
showLoginLink: true,
onLogin: () => undefined,
title: "Join us",
subtitle: "Create your account",
},
};
export const WithProviders: Story = {
args: {
providers: [
{ id: "google", name: "Google" },
{ id: "github", name: "GitHub" },
],
showTermsCheckbox: true,
showLoginLink: true,
onLogin: () => undefined,
},
};
export const Minimal: Story = {
args: {
showNameField: false,
showTermsCheckbox: false,
showLoginLink: false,
},
};
export const Disabled: Story = {
args: { disabled: true, showTermsCheckbox: true },
};
export const WithError: Story = {
args: {
showTermsCheckbox: false,
onSubmit: async () => {
throw new Error("Registration failed");
},
},
};

View File

@@ -0,0 +1,462 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act, cleanup, fireEvent } from "@testing-library/react";
import {
FormWrapper,
jsonResponse,
makeFetch,
} from "../../../tests/setup/form-test-utils.js";
import { RegisterForm } from "./register-form.js";
afterEach(cleanup);
function fillForm(
container: HTMLElement,
overrides: Partial<{
name: string;
email: string;
password: string;
confirmPassword: string;
}> = {}
): void {
const fields = {
name: overrides.name ?? "Alice",
email: overrides.email ?? "a@b.c",
password: overrides.password ?? "12345678",
confirmPassword: overrides.confirmPassword ?? "12345678",
};
for (const [name, value] of Object.entries(fields)) {
const input = container.querySelector(
`input[name="${name}"]`
) as HTMLInputElement | null;
if (input !== null) {
fireEvent.change(input, { target: { value } });
}
}
}
describe("RegisterForm", () => {
it("password mismatch blocks submission with inline error", {
timeout: 5000,
}, async () => {
const submitted: number[] = [];
const { container, getByRole } = renderComponent(
<FormWrapper>
<RegisterForm
showTermsCheckbox={false}
onSubmit={async () => {
submitted.push(1);
}}
/>
</FormWrapper>
);
await act(async () => {
fillForm(container, {
password: "12345678",
confirmPassword: "00000000",
});
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(submitted).toHaveLength(0);
expect(getByRole("alert").textContent).toMatch(/match/i);
});
it("unchecked terms checkbox blocks submission", {
timeout: 5000,
}, async () => {
const submitted: number[] = [];
const { container, getByRole } = renderComponent(
<FormWrapper>
<RegisterForm
onSubmit={async () => {
submitted.push(1);
}}
/>
</FormWrapper>
);
await act(async () => {
fillForm(container);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(submitted).toHaveLength(0);
expect(getByRole("alert").textContent).toMatch(/terms/i);
});
it("short password blocks submission with inline error", {
timeout: 5000,
}, async () => {
const { container, getByRole } = renderComponent(
<FormWrapper>
<RegisterForm showTermsCheckbox={false} minPasswordLength={8} />
</FormWrapper>
);
await act(async () => {
fillForm(container, {
password: "short",
confirmPassword: "short",
});
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByRole("alert").textContent).toMatch(/at least/i);
});
it("requireName + empty name blocks submission", {
timeout: 5000,
}, async () => {
const { container, getByRole } = renderComponent(
<FormWrapper>
<RegisterForm showTermsCheckbox={false} requireName />
</FormWrapper>
);
await act(async () => {
fillForm(container, { name: " " });
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByRole("alert").textContent).toMatch(/name/i);
});
it("valid submission invokes onSubmit override with values", {
timeout: 5000,
}, async () => {
const values: unknown[] = [];
const { container } = renderComponent(
<FormWrapper>
<RegisterForm
showTermsCheckbox={false}
onSubmit={async (v) => {
values.push(v);
}}
/>
</FormWrapper>
);
await act(async () => {
fillForm(container);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(values).toHaveLength(1);
});
it("valid submission with no override calls AuthProvider.register", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl, calls } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({ user: { id: "u9", email: "a@b.c" } });
});
const { container } = renderComponent(
<FormWrapper
options={{
authConfig: {
authUrl: "https://auth.example.com",
fetch: fetchImpl,
},
}}
>
<RegisterForm showTermsCheckbox={false} />
</FormWrapper>
);
await act(async () => {
fillForm(container);
fireEvent.submit(container.querySelector("form") as HTMLElement);
await new Promise((r) => setTimeout(r, 0));
});
expect(calls.find((c) => c.url.endsWith("/register"))).toBeDefined();
});
it("login-link click invokes onLogin", { timeout: 5000 }, async () => {
let called = 0;
const { getByRole } = renderComponent(
<FormWrapper>
<RegisterForm
showTermsCheckbox={false}
onLogin={() => {
called += 1;
}}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.click(getByRole("button", { name: "Sign in" }));
});
expect(called).toBe(1);
});
it("OAuth provider click routes through loginWithProvider", {
timeout: 5000,
}, async () => {
const navigated: string[] = [];
const { fetch: fetchImpl } = makeFetch(() => jsonResponse({}));
const { getByRole } = renderComponent(
<FormWrapper
options={{
navigated,
authConfig: {
authUrl: "https://auth.example.com",
fetch: fetchImpl,
navigate: (u) => navigated.push(u),
},
}}
>
<RegisterForm
showTermsCheckbox={false}
providers={[{ id: "github", name: "GitHub" }]}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.click(getByRole("button", { name: "GitHub" }));
});
expect(navigated.some((u) => u.includes("/oauth/github"))).toBe(true);
});
it("custom title/subtitle override defaults", { timeout: 5000 }, () => {
const { container } = renderComponent(
<FormWrapper>
<RegisterForm
showTermsCheckbox={false}
title="X"
subtitle="Y"
/>
</FormWrapper>
);
expect(container.querySelector(".auth-title")?.textContent).toBe("X");
expect(container.querySelector(".auth-subtitle")?.textContent).toBe(
"Y"
);
});
it("disabled prop disables inputs", { timeout: 5000 }, () => {
const { container } = renderComponent(
<FormWrapper>
<RegisterForm showTermsCheckbox={false} disabled />
</FormWrapper>
);
const email = container.querySelector(
'input[name="email"]'
) as HTMLInputElement;
expect(email.disabled).toBe(true);
});
it("renders terms links when termsUrl and privacyUrl supplied", {
timeout: 5000,
}, () => {
const { container } = renderComponent(
<FormWrapper>
<RegisterForm
termsUrl="https://example.com/tos"
privacyUrl="https://example.com/privacy"
/>
</FormWrapper>
);
const anchors = container.querySelectorAll(
"a"
) as NodeListOf<HTMLAnchorElement>;
const hrefs = Array.from(anchors).map((a) => a.href);
expect(hrefs.some((h) => h.includes("/tos"))).toBe(true);
expect(hrefs.some((h) => h.includes("/privacy"))).toBe(true);
});
it("submission failure surfaces via onError and inline alert", {
timeout: 5000,
}, async () => {
const errors: Error[] = [];
const { container, getByRole } = renderComponent(
<FormWrapper>
<RegisterForm
showTermsCheckbox={false}
onSubmit={async () => {
throw new Error("backend-fail");
}}
onError={(e) => errors.push(e)}
/>
</FormWrapper>
);
await act(async () => {
fillForm(container);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByRole("alert").textContent).toBe("backend-fail");
expect(errors).toHaveLength(1);
});
it("OAuth provider click failure surfaces via onError", {
timeout: 5000,
}, async () => {
const errors: Error[] = [];
const fetchImpl = async (): Promise<Response> => {
throw new TypeError("offline");
};
const { getByRole } = renderComponent(
<FormWrapper
options={{
authConfig: {
authUrl: "https://auth.example.com",
fetch: fetchImpl,
navigate: () => {
throw new Error("nav-fail");
},
},
}}
>
<RegisterForm
showTermsCheckbox={false}
providers={[{ id: "x", name: "Xprov" }]}
onError={(e) => errors.push(e)}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.click(getByRole("button", { name: "Xprov" }));
});
expect(errors.length).toBeGreaterThan(0);
});
it("wraps non-Error register submit failure", {
timeout: 5000,
}, async () => {
const { container, getByRole } = renderComponent(
<FormWrapper>
<RegisterForm
showTermsCheckbox={false}
onSubmit={async () => {
const thrown: unknown = "boom";
throw thrown;
}}
/>
</FormWrapper>
);
await act(async () => {
fillForm(container);
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(getByRole("alert").textContent).toBe("Registration failed");
});
it("wraps non-Error provider login failure", {
timeout: 5000,
}, async () => {
const errors: Error[] = [];
const { getByRole } = renderComponent(
<FormWrapper
options={{
authConfig: {
authUrl: "https://x",
navigate: () => {
const t: unknown = "boom";
throw t;
},
},
}}
>
<RegisterForm
showTermsCheckbox={false}
providers={[{ id: "g", name: "Gprov" }]}
onError={(e) => errors.push(e)}
/>
</FormWrapper>
);
await act(async () => {
fireEvent.click(getByRole("button", { name: "Gprov" }));
});
expect(errors[0]?.message).toBe("Provider login failed");
});
it("renders provider icon as image when string", { timeout: 5000 }, () => {
const { getByRole } = renderComponent(
<FormWrapper>
<RegisterForm
showTermsCheckbox={false}
providers={[
{ id: "g", name: "G", icon: "https://cdn/g.svg" },
]}
/>
</FormWrapper>
);
const btn = getByRole("button", { name: /G/ });
const img = btn.querySelector("img") as HTMLImageElement;
expect(img.src).toContain("g.svg");
});
it("renders provider icon as component", { timeout: 5000 }, () => {
const Icon = (): JSX.Element => <span data-testid="cmp" />;
const { container } = renderComponent(
<FormWrapper>
<RegisterForm
showTermsCheckbox={false}
providers={[{ id: "g", name: "G", icon: Icon }]}
/>
</FormWrapper>
);
expect(container.querySelector('[data-testid="cmp"]')).not.toBeNull();
});
it("submits with empty name when name field is hidden", {
timeout: 5000,
}, async () => {
const values: Array<{ name?: string | undefined }> = [];
const { container } = renderComponent(
<FormWrapper>
<RegisterForm
showNameField={false}
showTermsCheckbox={false}
onSubmit={async (v) => {
values.push(v);
}}
/>
</FormWrapper>
);
await act(async () => {
fillForm(container, { name: "" });
fireEvent.submit(container.querySelector("form") as HTMLElement);
});
expect(values).toHaveLength(1);
expect(values[0]?.name).toBeUndefined();
});
it("calls register with undefined name when name is empty", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl, calls } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({ user: { id: "u", email: "a@b.c" } });
});
const { container } = renderComponent(
<FormWrapper
options={{
authConfig: {
authUrl: "https://auth.example.com",
fetch: fetchImpl,
},
}}
>
<RegisterForm showNameField={false} showTermsCheckbox={false} />
</FormWrapper>
);
await act(async () => {
fillForm(container, { name: "" });
fireEvent.submit(container.querySelector("form") as HTMLElement);
await new Promise((r) => setTimeout(r, 0));
});
const reg = calls.find((c) => c.url.endsWith("/register"));
const body = JSON.parse(reg?.init?.body as string) as {
name?: string;
};
expect(body.name).toBeUndefined();
});
it("custom logo overrides default", { timeout: 5000 }, () => {
const { container } = renderComponent(
<FormWrapper>
<RegisterForm
showTermsCheckbox={false}
logo={<div data-testid="logo">L</div>}
/>
</FormWrapper>
);
expect(container.querySelector('[data-testid="logo"]')).not.toBeNull();
});
});

View File

@@ -0,0 +1,239 @@
import { useTranslation } from "@suaveplan/react-i18n";
import { type FormEvent, type ReactNode, useState } from "react";
import { useAuth } from "../../context/auth-context.js";
import { useBranding, useLogo } from "../../context/branding-context.js";
import { navigateOrCall } from "../../utils/navigate-or-call.js";
import type {
RegisterFormProps,
RegisterFormValues,
} from "./register-form.types.js";
import { RegisterFormFields } from "./register-form-fields.js";
import { RegisterFormTerms } from "./register-form-terms.js";
/** White-label registration form. */
export function RegisterForm({
onSubmit,
onSuccess,
onError,
providers = [],
showNameField = true,
requireName = false,
showTermsCheckbox = true,
termsUrl,
privacyUrl,
showLoginLink = true,
onLogin,
className = "",
disabled = false,
logo: customLogo,
title,
subtitle,
minPasswordLength = 8,
}: RegisterFormProps): ReactNode {
const { register, loginWithProvider, isLoading } = useAuth();
const { branding } = useBranding();
const logoUrl = useLogo();
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [name, setName] = useState("");
const [acceptTerms, setAcceptTerms] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const validate = (): string | null => {
if (password.length < minPasswordLength) {
return t("auth.signUp.errors.tooShort", {
defaultValue: `Password must be at least ${minPasswordLength} characters`,
});
}
if (password !== confirmPassword) {
return t("auth.signUp.errors.mismatch", {
defaultValue: "Passwords do not match",
});
}
if (requireName && name.trim() === "") {
return t("auth.signUp.errors.nameRequired", {
defaultValue: "Name is required",
});
}
if (showTermsCheckbox && !acceptTerms) {
return t("auth.signUp.errors.termsRequired", {
defaultValue: "You must accept the terms and conditions",
});
}
return null;
};
const handleSubmit = async (e: FormEvent): Promise<void> => {
e.preventDefault();
setError(null);
const validationError = validate();
if (validationError !== null) {
setError(validationError);
return;
}
setIsSubmitting(true);
const values: RegisterFormValues = {
email,
password,
confirmPassword,
name: name === "" ? undefined : name,
acceptTerms,
};
try {
if (onSubmit !== undefined) {
await onSubmit(values);
} else {
await register({
email,
password,
name: name === "" ? undefined : name,
});
}
onSuccess?.();
} catch (err) {
const e2 =
err instanceof Error ? err : new Error("Registration failed");
setError(e2.message);
onError?.(e2);
} finally {
setIsSubmitting(false);
}
};
const handleProviderLogin = async (providerId: string): Promise<void> => {
try {
await loginWithProvider(providerId);
} catch (err) {
const e2 =
err instanceof Error ? err : new Error("Provider login failed");
setError(e2.message);
onError?.(e2);
}
};
const isDisabled = disabled || isLoading || isSubmitting;
const displayTitle =
title ?? t("auth.signUp.title", { defaultValue: "Create Account" });
const displaySubtitle =
subtitle ??
t("auth.signUp.description", {
defaultValue: "Sign up to get started",
});
return (
<div className={`auth-form-container ${className}`}>
<div className="auth-form-header">
{customLogo ??
(logoUrl !== undefined && (
<img
src={logoUrl}
alt={branding.name}
className="auth-logo"
/>
))}
<h1 className="auth-title">{displayTitle}</h1>
<p className="auth-subtitle">{displaySubtitle}</p>
</div>
{error !== null && (
<div className="auth-error" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<RegisterFormFields
name={name}
setName={setName}
email={email}
setEmail={setEmail}
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
showNameField={showNameField}
requireName={requireName}
minPasswordLength={minPasswordLength}
isDisabled={isDisabled}
/>
<RegisterFormTerms
acceptTerms={acceptTerms}
setAcceptTerms={setAcceptTerms}
showTermsCheckbox={showTermsCheckbox}
termsUrl={termsUrl}
privacyUrl={privacyUrl}
isDisabled={isDisabled}
/>
<button
type="submit"
disabled={isDisabled}
className="auth-submit-button"
>
{isSubmitting
? t("auth.signUp.signingUp", {
defaultValue: "Creating account...",
})
: t("auth.signUp.submit", {
defaultValue: "Create Account",
})}
</button>
</form>
{providers.length > 0 && (
<div className="auth-providers">
<div className="auth-divider">
<span>
{t("auth.signUp.orContinueWith", {
defaultValue: "or sign up with email",
})}
</span>
</div>
<div className="auth-provider-buttons">
{providers.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleProviderLogin(provider.id)}
disabled={isDisabled}
className="auth-provider-button"
>
{typeof provider.icon === "string" ? (
<img
src={provider.icon}
alt=""
className="auth-provider-icon"
/>
) : provider.icon !== undefined ? (
<provider.icon className="auth-provider-icon" />
) : null}
<span>{provider.name}</span>
</button>
))}
</div>
</div>
)}
{showLoginLink && onLogin !== undefined && (
<p className="auth-footer">
{t("auth.signUp.hasAccount", {
defaultValue: "Already have an account?",
})}{" "}
<button
type="button"
onClick={() => navigateOrCall(onLogin)}
className="auth-link"
disabled={isDisabled}
>
{t("auth.signUp.signInLink", {
defaultValue: "Sign in",
})}
</button>
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import type { AuthProvider } from "../../types/branding.types.js";
/** Values collected by the register form. */
export interface RegisterFormValues {
email: string;
password: string;
confirmPassword: string;
name?: string | undefined;
acceptTerms?: boolean | undefined;
}
/** Props for `<RegisterForm>`. */
export interface RegisterFormProps {
onSubmit?: ((values: RegisterFormValues) => Promise<void>) | undefined;
onSuccess?: (() => void) | undefined;
onError?: ((error: Error) => void) | undefined;
providers?: AuthProvider[] | undefined;
showNameField?: boolean | undefined;
requireName?: boolean | undefined;
showTermsCheckbox?: boolean | undefined;
termsUrl?: string | undefined;
privacyUrl?: string | undefined;
showLoginLink?: boolean | undefined;
onLogin?: string | (() => void) | undefined;
className?: string | undefined;
disabled?: boolean | undefined;
logo?: ReactNode | undefined;
title?: string | undefined;
subtitle?: string | undefined;
minPasswordLength?: number | undefined;
}
/** Props consumed by the inner fields sub-component. */
export interface RegisterFormFieldsProps {
name: string;
setName: (value: string) => void;
email: string;
setEmail: (value: string) => void;
password: string;
setPassword: (value: string) => void;
confirmPassword: string;
setConfirmPassword: (value: string) => void;
showNameField: boolean;
requireName: boolean;
minPasswordLength: number;
isDisabled: boolean;
}
/** Props consumed by the terms-checkbox sub-component. */
export interface RegisterFormTermsProps {
acceptTerms: boolean;
setAcceptTerms: (value: boolean) => void;
showTermsCheckbox: boolean;
termsUrl?: string | undefined;
privacyUrl?: string | undefined;
isDisabled: boolean;
}

View File

@@ -0,0 +1,349 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act, cleanup } from "@testing-library/react";
import {
type AuthCaptureBag,
type AuthedCaptureBag,
CaptureAuth,
CaptureAuthed,
CaptureUser,
jsonResponse,
makeFetch,
type UserCaptureBag,
} from "../../tests/setup/auth-test-utils.js";
import { REACT_AUTH_WL_CODES } from "../errors/error-codes.js";
import { ReactAuthWhitelabelError } from "../errors/react-auth-whitelabel-error.js";
import { AuthProvider } from "./auth-context.js";
afterEach(cleanup);
describe("AuthProvider — error and session edges", () => {
it("logout transport failure raises AUTH_NETWORK_FAILED", {
timeout: 5000,
}, async () => {
const fetchImpl = async (): Promise<Response> => {
throw new TypeError("network down");
};
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
initialUser={{ id: "u", email: "a@b.c" }}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
let caught: unknown;
await act(async () => {
try {
await capture.value?.logout();
} catch (err) {
caught = err;
}
});
expect(caught).toBeInstanceOf(ReactAuthWhitelabelError);
expect((caught as ReactAuthWhitelabelError).code).toBe(
REACT_AUTH_WL_CODES.AUTH_NETWORK_FAILED
);
});
it("non-ok response raises AUTH_REJECTED with backend message", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return new Response(JSON.stringify({ message: "bad creds" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
});
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
let caught: unknown;
await act(async () => {
try {
await capture.value?.login("a@b.c", "wrong");
} catch (err) {
caught = err;
}
});
expect((caught as ReactAuthWhitelabelError).code).toBe(
REACT_AUTH_WL_CODES.AUTH_REJECTED
);
});
it("transport throw raises AUTH_NETWORK_FAILED", {
timeout: 5000,
}, async () => {
const fetchImpl = async (
input: string | URL | Request
): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/session")) {
return jsonResponse({ user: null });
}
throw new TypeError("offline");
};
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
let caught: unknown;
await act(async () => {
try {
await capture.value?.login("a@b.c", "pw");
} catch (err) {
caught = err;
}
});
expect((caught as ReactAuthWhitelabelError).code).toBe(
REACT_AUTH_WL_CODES.AUTH_NETWORK_FAILED
);
});
it("useUser surfaces the context user", { timeout: 5000 }, () => {
const { fetch: fetchImpl } = makeFetch(() =>
jsonResponse({ user: null })
);
const capture: UserCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
initialUser={{ id: "u", email: "a@b.c" }}
>
<CaptureUser capture={capture} />
</AuthProvider>
);
expect((capture.value as { id?: string } | null)?.id).toBe("u");
});
it("useIsAuthenticated reflects context user presence", {
timeout: 5000,
}, () => {
const { fetch: fetchImpl } = makeFetch(() =>
jsonResponse({ user: null })
);
const capture: AuthedCaptureBag = { value: false };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
initialUser={{ id: "u", email: "a@b.c" }}
>
<CaptureAuthed capture={capture} />
</AuthProvider>
);
expect(capture.value).toBe(true);
});
it("session probe failure leaves user anonymous", {
timeout: 5000,
}, async () => {
const fetchImpl = async (
input: string | URL | Request
): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/session")) {
throw new TypeError("offline");
}
return jsonResponse({});
};
const capture: AuthCaptureBag = { value: null };
await act(async () => {
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await new Promise((r) => setTimeout(r, 0));
});
expect(capture.value?.user).toBeNull();
});
it("session probe non-ok response keeps anonymous user", {
timeout: 5000,
}, async () => {
const fetchImpl = async (
input: string | URL | Request
): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/session")) {
return new Response("", { status: 401 });
}
return jsonResponse({});
};
const capture: AuthCaptureBag = { value: null };
await act(async () => {
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await new Promise((r) => setTimeout(r, 0));
});
expect(capture.value?.user).toBeNull();
});
it("session probe ok hydrates user", { timeout: 5000 }, async () => {
const fetchImpl = async (
input: string | URL | Request
): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/session")) {
return jsonResponse({
user: { id: "u9", email: "a@b.c" },
});
}
return jsonResponse({});
};
const capture: AuthCaptureBag = { value: null };
await act(async () => {
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await new Promise((r) => setTimeout(r, 0));
});
expect(capture.value?.user?.id).toBe("u9");
});
it("register error surfaces on context.error and rethrows", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return new Response(JSON.stringify({}), { status: 400 });
});
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
let caught: unknown;
await act(async () => {
try {
await capture.value?.register({
email: "a@b.c",
password: "12345678",
});
} catch (err) {
caught = err;
}
});
expect(caught).toBeInstanceOf(ReactAuthWhitelabelError);
expect(capture.value?.error).toBeInstanceOf(ReactAuthWhitelabelError);
});
it("requestPasswordReset error rethrows and surfaces on context.error", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return new Response(JSON.stringify({}), { status: 500 });
});
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
let caught: unknown;
await act(async () => {
try {
await capture.value?.requestPasswordReset("a@b.c");
} catch (err) {
caught = err;
}
});
expect(caught).toBeInstanceOf(ReactAuthWhitelabelError);
expect(capture.value?.error).not.toBeNull();
});
it("resetPassword error rethrows and surfaces on context.error", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return new Response(JSON.stringify({}), { status: 400 });
});
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
let caught: unknown;
await act(async () => {
try {
await capture.value?.resetPassword("tok", "12345678");
} catch (err) {
caught = err;
}
});
expect(caught).toBeInstanceOf(ReactAuthWhitelabelError);
});
});

View File

@@ -0,0 +1,75 @@
# `<AuthProvider>` and `useAuth` / `useUser` / `useIsAuthenticated`
## Purpose
`auth-context.tsx` ships the `<AuthProvider>` React context provider together
with the three hooks `useAuth`, `useUser`, and `useIsAuthenticated`. The
provider wires a tenant-aware auth backend through an injectable `fetch`
(`FetchLike`) and an injectable `navigate(url)` seam so consumers can plug
real backends in production and stub them in tests without `vi.mock`. Every
verb — `login`, `loginWithProvider`, `register`, `logout`,
`requestPasswordReset`, `resetPassword` — is surfaced on the context value
returned by `useAuth()` and translates HTTP failures into stable, code-tagged
errors via `ReactAuthWhitelabelError`.
## Features
- Injectable `fetch: FetchLike` for testability — defaults to
`globalThis.fetch`.
- Injectable `navigate(url)` seam — defaults to `window.location.href = url`
in browsers, no-op in non-browser environments.
- Session probe on mount unless `initialUser` is supplied (SSR/hydration).
- Network failures → `REACT_AUTH_WL.AUTH_NETWORK_FAILED`.
- Non-ok responses → `REACT_AUTH_WL.AUTH_REJECTED` with the backend's
`message` field threaded through when present.
- Hooks called outside the provider throw
`REACT_AUTH_WL.PROVIDER_OUTSIDE_TREE`.
## Usage: Basic
```tsx
<AuthProvider config={{ authUrl: "/api/auth" }}>
<App />
</AuthProvider>
```
```tsx
const { login, user, isAuthenticated } = useAuth();
await login("a@b.c", "pw");
```
## Usage: Advanced
```tsx
const config: AuthConfig = {
authUrl: "https://auth.acme.com",
redirectUrl: "/dashboard",
fetch: customFetch, // testable seam
navigate: (url) => router.navigate(url),
providers: [{ id: "google", name: "Google" }],
};
<AuthProvider config={config} initialUser={ssrUser}>
<Routes>...</Routes>
</AuthProvider>
```
## API Reference
- `AuthProvider({ config, initialUser?, children })` — context provider.
- `useAuth()` — returns `AuthContextValue` (`user`, `isLoading`,
`isAuthenticated`, `error`, `login`, `loginWithProvider`, `register`,
`logout`, `requestPasswordReset`, `resetPassword`).
- `useUser()` — returns `AuthUser | null`.
- `useIsAuthenticated()` — returns `boolean`.
## Implementation Notes
The session probe in `useEffect` swallows transport errors on purpose — a
session check that fails should not surface as a visible error to the user
(they simply remain anonymous). Verbs called from the UI translate failures
to typed errors AND surface them on the context's `error` state so a single
error region can render the current failure without each call site
re-implementing the same try/catch. Every verb sets `isLoading` true/false
around the network call so consumers can disable submit buttons via
`isLoading || isSubmitting`.

View File

@@ -0,0 +1,353 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act, cleanup } from "@testing-library/react";
import {
type AuthCaptureBag,
CaptureAuth,
type FetchCall,
jsonResponse,
makeFetch,
} from "../../tests/setup/auth-test-utils.js";
import { REACT_AUTH_WL_CODES } from "../errors/error-codes.js";
import { ReactAuthWhitelabelError } from "../errors/react-auth-whitelabel-error.js";
import type { AuthConfig } from "../types/branding.types.js";
import { AuthProvider } from "./auth-context.js";
afterEach(cleanup);
describe("AuthProvider — core verbs", () => {
it("login() posts credentials to {authUrl}/login and sets user", {
timeout: 5000,
}, async () => {
const navigated: string[] = [];
const { fetch: fetchImpl, calls } = makeFetch((call: FetchCall) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({
user: { id: "u1", email: "a@b.c" },
});
});
const config: AuthConfig = {
authUrl: "https://auth.example.com",
redirectUrl: "/home",
fetch: fetchImpl,
navigate: (url) => navigated.push(url),
};
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider config={config}>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.login("a@b.c", "pw");
});
const loginCall = calls.find((c) => c.url.endsWith("/login"));
expect(loginCall).toBeDefined();
expect(loginCall?.init?.body).toBe(
JSON.stringify({ email: "a@b.c", password: "pw" })
);
expect(capture.value?.user).toEqual({
id: "u1",
email: "a@b.c",
});
expect(navigated).toEqual(["/home"]);
});
it("loginWithProvider routes through the navigate seam", {
timeout: 5000,
}, async () => {
const navigated: string[] = [];
const { fetch: fetchImpl } = makeFetch(() => jsonResponse({}));
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
redirectUrl: "/dashboard",
fetch: fetchImpl,
navigate: (u) => navigated.push(u),
}}
initialUser={null}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.loginWithProvider("google");
});
expect(navigated[0]).toContain("/oauth/google?redirect=");
});
it("register posts to /register and stores returned user", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl, calls } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({ user: { id: "u2", email: "x@y.z" } });
});
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.register({
email: "x@y.z",
password: "12345678",
});
});
const registerCall = calls.find((c) => c.url.endsWith("/register"));
expect(registerCall).toBeDefined();
expect(capture.value?.user?.email).toBe("x@y.z");
});
it("login without redirectUrl skips navigate", {
timeout: 5000,
}, async () => {
const navigated: string[] = [];
const { fetch: fetchImpl } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({ user: { id: "u", email: "a@b.c" } });
});
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
navigate: (u) => navigated.push(u),
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.login("a@b.c", "pw");
});
expect(navigated).toEqual([]);
});
it("register success with redirectUrl invokes navigate", {
timeout: 5000,
}, async () => {
const navigated: string[] = [];
const { fetch: fetchImpl } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({ user: { id: "u4", email: "r@r.r" } });
});
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
redirectUrl: "/welcome",
fetch: fetchImpl,
navigate: (u) => navigated.push(u),
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.register({
email: "r@r.r",
password: "12345678",
});
});
expect(navigated).toEqual(["/welcome"]);
});
it("register response without user keeps user null", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({});
});
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.register({
email: "e@e.e",
password: "12345678",
});
});
expect(capture.value?.user).toBeNull();
});
it("logout clears user", { timeout: 5000 }, async () => {
const { fetch: fetchImpl } = makeFetch(() => jsonResponse({}));
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
initialUser={{ id: "u3", email: "z@z.z" }}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.logout();
});
expect(capture.value?.user).toBeNull();
});
it("requestPasswordReset POSTs to /password-reset", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl, calls } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({});
});
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.requestPasswordReset("a@b.c");
});
expect(
calls.find((c) => c.url.endsWith("/password-reset"))
).toBeDefined();
});
it("resetPassword POSTs to /password-reset/confirm", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl, calls } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({});
});
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.resetPassword("tok", "12345678");
});
expect(
calls.find((c) => c.url.endsWith("/password-reset/confirm"))
).toBeDefined();
});
it("useAuth outside provider throws REACT_AUTH_WL.PROVIDER_OUTSIDE_TREE", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
renderComponent(<CaptureAuth capture={{ value: null }} />);
} catch (err) {
caught = err;
}
let current: unknown = caught;
let found: ReactAuthWhitelabelError | null = null;
let depth = 0;
while (current !== undefined && current !== null && depth < 10) {
if (current instanceof ReactAuthWhitelabelError) {
found = current;
break;
}
current = (current as { cause?: unknown }).cause;
depth += 1;
}
expect(found?.code).toBe(REACT_AUTH_WL_CODES.PROVIDER_OUTSIDE_TREE);
});
it("default navigate writes window.location.href in browsers", {
timeout: 5000,
}, async () => {
const { fetch: fetchImpl } = makeFetch((call) => {
if (call.url.endsWith("/session")) {
return jsonResponse({ user: null });
}
return jsonResponse({ user: null });
});
const original = window.location.href;
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
redirectUrl: "/after-login",
fetch: fetchImpl,
}}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.login("a@b.c", "pw");
});
expect(window.location.href).not.toBe(original);
});
it("loginWithProvider falls back to window.location.href when redirectUrl is undefined", {
timeout: 5000,
}, async () => {
const navigated: string[] = [];
const { fetch: fetchImpl } = makeFetch(() => jsonResponse({}));
const capture: AuthCaptureBag = { value: null };
renderComponent(
<AuthProvider
config={{
authUrl: "https://auth.example.com",
fetch: fetchImpl,
navigate: (u) => navigated.push(u),
}}
initialUser={null}
>
<CaptureAuth capture={capture} />
</AuthProvider>
);
await act(async () => {
await capture.value?.loginWithProvider("github");
});
expect(navigated[0]).toContain("/oauth/github?redirect=");
});
});

View File

@@ -0,0 +1,258 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
REACT_AUTH_WL_CODES,
ReactAuthWhitelabelError,
} from "../errors/index.js";
import type {
AuthConfig,
AuthContextValue,
AuthUser,
RegisterData,
} from "../types/branding.types.js";
import { postJson, resolveFetch, resolveNavigate } from "./auth-http.js";
const AuthContext = createContext<AuthContextValue | null>(null);
/** Props for `<AuthProvider>`. */
export interface AuthProviderProps {
/** Auth configuration (URL, OAuth providers, redirect, fetch, navigate). */
config: AuthConfig;
/** Optional pre-resolved user for SSR / hydration. */
initialUser?: AuthUser | null;
/** Subtree. */
children: ReactNode;
}
/**
* `<AuthProvider>` — wires a tenant-aware auth backend through an
* injectable `fetch` + `navigate` seam. Every verb (`login`,
* `loginWithProvider`, `register`, `logout`, `requestPasswordReset`,
* `resetPassword`) is exposed on the context returned by `useAuth()`.
*/
export function AuthProvider({
config,
initialUser = null,
children,
}: AuthProviderProps): ReactNode {
const [user, setUser] = useState<AuthUser | null>(initialUser);
// `isLoading` reflects ongoing auth verb calls (login/register/etc).
// The session probe runs in the background but does NOT block form
// interaction — disabling every button on mount creates a flash of
// un-clickable UI for anonymous users, which is the common case.
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const isAuthenticated = useMemo(() => user !== null, [user]);
useEffect(() => {
if (initialUser !== null) {
return;
}
const fetchImpl = resolveFetch(config);
const checkSession = async (): Promise<void> => {
try {
const response = await fetchImpl(`${config.authUrl}/session`, {
credentials: "include",
});
if (response.ok) {
const data = (await response.json()) as {
user?: AuthUser | null;
};
setUser(data.user ?? null);
}
} catch {
// Session probe failure is non-fatal — anonymous user remains.
}
};
void checkSession();
}, [config, initialUser]);
const login = useCallback(
async (email: string, password: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const data = (await postJson(
resolveFetch(config),
`${config.authUrl}/login`,
{ email, password },
"react_auth_wl.error.login_rejected",
{ credentials: "include" }
)) as { user?: AuthUser };
setUser(data.user ?? null);
if (config.redirectUrl !== undefined) {
resolveNavigate(config)(config.redirectUrl);
}
} catch (err) {
setError(err as Error);
throw err;
} finally {
setIsLoading(false);
}
},
[config]
);
const loginWithProvider = useCallback(
async (providerId: string): Promise<void> => {
const redirectUrl = config.redirectUrl ?? window.location.href;
const url = `${config.authUrl}/oauth/${providerId}?redirect=${encodeURIComponent(redirectUrl)}`;
resolveNavigate(config)(url);
},
[config]
);
const register = useCallback(
async (data: RegisterData): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const response = (await postJson(
resolveFetch(config),
`${config.authUrl}/register`,
data,
"react_auth_wl.error.register_rejected",
{ credentials: "include" }
)) as { user?: AuthUser };
setUser(response.user ?? null);
if (config.redirectUrl !== undefined) {
resolveNavigate(config)(config.redirectUrl);
}
} catch (err) {
setError(err as Error);
throw err;
} finally {
setIsLoading(false);
}
},
[config]
);
const logout = useCallback(async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
await resolveFetch(config)(`${config.authUrl}/logout`, {
method: "POST",
credentials: "include",
});
setUser(null);
} catch (cause) {
const err = new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.AUTH_NETWORK_FAILED,
"react_auth_wl.error.network",
{ cause, context: { url: `${config.authUrl}/logout` } }
);
setError(err);
throw err;
} finally {
setIsLoading(false);
}
}, [config]);
const requestPasswordReset = useCallback(
async (email: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
await postJson(
resolveFetch(config),
`${config.authUrl}/password-reset`,
{ email },
"react_auth_wl.error.password_reset_rejected"
);
} catch (err) {
setError(err as Error);
throw err;
} finally {
setIsLoading(false);
}
},
[config]
);
const resetPassword = useCallback(
async (token: string, password: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
await postJson(
resolveFetch(config),
`${config.authUrl}/password-reset/confirm`,
{ token, password },
"react_auth_wl.error.password_reset_confirm_rejected"
);
} catch (err) {
setError(err as Error);
throw err;
} finally {
setIsLoading(false);
}
},
[config]
);
const value = useMemo<AuthContextValue>(
() => ({
user,
isLoading,
isAuthenticated,
error,
login,
loginWithProvider,
register,
logout,
requestPasswordReset,
resetPassword,
}),
[
user,
isLoading,
isAuthenticated,
error,
login,
loginWithProvider,
register,
logout,
requestPasswordReset,
resetPassword,
]
);
return (
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);
}
/** Hook to read the auth context. Throws `PROVIDER_OUTSIDE_TREE` outside. */
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (ctx === null) {
throw new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.PROVIDER_OUTSIDE_TREE,
"react_auth_wl.error.provider_outside_tree",
{ context: { hook: "useAuth" } }
);
}
return ctx;
}
/** Hook returning the currently authenticated user (or `null`). */
export function useUser(): AuthUser | null {
return useAuth().user;
}
/** Hook returning the current authentication state. */
export function useIsAuthenticated(): boolean {
return useAuth().isAuthenticated;
}
export { AuthContext };

View File

@@ -0,0 +1,63 @@
# `auth-http.ts` — fetch + navigate seam helpers
## Purpose
`auth-http.ts` houses the three helpers every `AuthProvider` verb shares:
`resolveFetch`, `resolveNavigate`, and `postJson`. Each helper exists so the
`AuthProvider` body stays under the 300-LOC budget while keeping the
network-translation policy in a single, testable file. Failures from the
configured `fetch` are translated to `REACT_AUTH_WL.AUTH_NETWORK_FAILED`;
non-ok responses are translated to `REACT_AUTH_WL.AUTH_REJECTED` with the
backend-supplied `message` field threaded through the i18n message key when
present.
## Features
- `resolveFetch(config)` — pick the injected `fetch` over `globalThis.fetch`.
- `resolveNavigate(config)` — pick the injected `navigate(url)` over the
default `window.location.href` writer.
- `postJson(fetchImpl, url, body, rejectMessage, options?)` — single helper
for every JSON POST, with error translation, optional credentials, and
best-effort `.json()` recovery on failure responses.
## Usage: Basic
```ts
const fetchImpl = resolveFetch(config);
const navigate = resolveNavigate(config);
const data = await postJson(fetchImpl, url, body, "ui.label.fallback_msg");
```
## Usage: Advanced
```ts
try {
await postJson(fetchImpl, `${config.authUrl}/login`, { email, password },
"ui.login.rejected", { credentials: "include" });
} catch (err) {
if (err.code === REACT_AUTH_WL_CODES.AUTH_NETWORK_FAILED) {
showOfflineBanner();
} else if (err.code === REACT_AUTH_WL_CODES.AUTH_REJECTED) {
focusErrorRegion();
}
throw err;
}
```
## API Reference
- `resolveFetch(config: AuthConfig): FetchLike` — never throws; falls back to
`globalThis.fetch` when the config omits one.
- `defaultNavigate(url: string): void` — no-op when `window` is undefined.
- `resolveNavigate(config: AuthConfig): (url: string) => void` — picks the
injected seam or the default.
- `postJson(fetchImpl, url, body, rejectMessage, options?)` — returns the
parsed JSON body. Errors are subclasses of `ReactAuthWhitelabelError`.
## Implementation Notes
`postJson` swallows `.json()` parse errors on failure responses on purpose:
some backends return an empty 401/403 body, in which case the `rejectMessage`
fallback is the user-visible label. The `.json()` parse on success is also
swallowed and returns `{}` so a 204-style "no content" response keeps a
clean caller signature.

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { AuthConfig } from "../types/branding.types.js";
import {
defaultNavigate,
postJson,
resolveFetch,
resolveNavigate,
} from "./auth-http.js";
describe("auth-http", () => {
it("resolveFetch falls back to globalThis.fetch when config.fetch is undefined", {
timeout: 5000,
}, () => {
const fn = resolveFetch({
authUrl: "https://x",
} satisfies AuthConfig);
expect(typeof fn).toBe("function");
});
it("resolveFetch picks the injected fetch when supplied", {
timeout: 5000,
}, () => {
const injected = async (): Promise<Response> => new Response();
const fn = resolveFetch({
authUrl: "https://x",
fetch: injected,
});
expect(fn).toBe(injected);
});
it("resolveNavigate falls back to defaultNavigate when config.navigate is undefined", {
timeout: 5000,
}, () => {
const fn = resolveNavigate({ authUrl: "https://x" });
expect(fn).toBe(defaultNavigate);
});
it("resolveNavigate picks the injected navigate when supplied", {
timeout: 5000,
}, () => {
const seen: string[] = [];
const inj = (u: string): void => {
seen.push(u);
};
const fn = resolveNavigate({
authUrl: "https://x",
navigate: inj,
});
expect(fn).toBe(inj);
});
it("defaultNavigate writes window.location.href in a browser environment", {
timeout: 5000,
}, () => {
const original = window.location.href;
defaultNavigate("https://example.com/x");
expect(window.location.href).not.toBe(original);
});
it("postJson returns parsed JSON on success", {
timeout: 5000,
}, async () => {
const fetchImpl = async (): Promise<Response> =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
const result = await postJson(
fetchImpl,
"https://x",
{ a: 1 },
"fallback"
);
expect((result as { ok: boolean }).ok).toBe(true);
});
it("postJson returns {} when success body cannot be parsed", {
timeout: 5000,
}, async () => {
const fetchImpl = async (): Promise<Response> =>
new Response("not-json", { status: 200 });
const result = await postJson(fetchImpl, "https://x", {}, "fallback");
expect(result).toEqual({});
});
it("postJson threads credentials when options.credentials is set", {
timeout: 5000,
}, async () => {
let seenInit: RequestInit | undefined;
const fetchImpl = async (
_: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
seenInit = init;
return new Response("{}", { status: 200 });
};
await postJson(fetchImpl, "https://x", {}, "fallback", {
credentials: "include",
});
expect(seenInit?.credentials).toBe("include");
});
it("postJson handles non-JSON failure body via empty object fallback", {
timeout: 5000,
}, async () => {
const fetchImpl = async (): Promise<Response> =>
new Response("html-error-page", { status: 500 });
let caught: unknown;
try {
await postJson(fetchImpl, "https://x", {}, "fallback");
} catch (err) {
caught = err;
}
expect(caught).toBeDefined();
});
it("postJson omits credentials when options.credentials is undefined", {
timeout: 5000,
}, async () => {
let seenInit: RequestInit | undefined;
const fetchImpl = async (
_: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
seenInit = init;
return new Response("{}", { status: 200 });
};
await postJson(fetchImpl, "https://x", {}, "fallback");
expect(seenInit?.credentials).toBeUndefined();
});
});

View File

@@ -0,0 +1,69 @@
import type { FetchLike } from "@suaveplan/types";
import {
REACT_AUTH_WL_CODES,
ReactAuthWhitelabelError,
} from "../errors/index.js";
import type { AuthConfig } from "../types/branding.types.js";
/**
* Resolve the `fetch` implementation an auth verb should use. Callers
* inject `config.fetch` for testability; otherwise we fall back to the
* global `fetch`.
*/
export function resolveFetch(config: AuthConfig): FetchLike {
return config.fetch ?? (globalThis.fetch as FetchLike);
}
/** Default navigate seam — writes `window.location.href`. */
export function defaultNavigate(url: string): void {
window.location.href = url;
}
/** Resolve the navigation seam — injectable for tests. */
export function resolveNavigate(config: AuthConfig): (url: string) => void {
return config.navigate ?? defaultNavigate;
}
/**
* Shared POST helper used by every `AuthProvider` verb. Translates network
* failures into `AUTH_NETWORK_FAILED` and non-ok responses into
* `AUTH_REJECTED`, threading the parsed `message` field through the error
* when present so callers can render a backend-supplied label.
*/
export async function postJson(
fetchImpl: FetchLike,
url: string,
body: unknown,
rejectMessage: string,
options?: { credentials?: RequestCredentials | undefined }
): Promise<unknown> {
let response: Response;
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
};
if (options?.credentials !== undefined) {
init.credentials = options.credentials;
}
try {
response = await fetchImpl(url, init);
} catch (cause) {
throw new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.AUTH_NETWORK_FAILED,
"react_auth_wl.error.network",
{ cause, context: { url } }
);
}
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as {
message?: string;
};
throw new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.AUTH_REJECTED,
data.message ?? rejectMessage,
{ context: { url, status: response.status } }
);
}
return response.json().catch(() => ({}));
}

View File

@@ -0,0 +1,309 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act, cleanup } from "@testing-library/react";
import {
type Capture,
CaptureBranding,
CaptureLogo,
jsonResponse,
resetBrandingDom,
wrapTheme,
} from "../../tests/setup/branding-test-utils.js";
import { REACT_AUTH_WL_CODES } from "../errors/error-codes.js";
import { ReactAuthWhitelabelError } from "../errors/react-auth-whitelabel-error.js";
import type {
BrandingContextValue,
TenantBranding,
} from "../types/branding.types.js";
import { BrandingProvider } from "./branding-context.js";
afterEach(() => {
cleanup();
resetBrandingDom();
});
describe("BrandingProvider — failure modes and useLogo", () => {
it("invalid fetched payload exposes REACT_AUTH_WL.BRANDING_INVALID_PAYLOAD", {
timeout: 5000,
}, async () => {
const fetchImpl = async (): Promise<Response> =>
jsonResponse({ tenantId: "", name: "" });
const capture: Capture<BrandingContextValue | null> = {
value: null,
};
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider
brandingEndpoint="https://api.example.com/branding"
fetch={fetchImpl}
>
<CaptureBranding capture={capture} />
</BrandingProvider>
)
);
await new Promise((r) => setTimeout(r, 0));
});
const err = capture.value?.error;
expect(err).toBeInstanceOf(ReactAuthWhitelabelError);
expect((err as ReactAuthWhitelabelError | undefined)?.code).toBe(
REACT_AUTH_WL_CODES.BRANDING_INVALID_PAYLOAD
);
});
it("fetch failure exposes REACT_AUTH_WL.BRANDING_FETCH_FAILED", {
timeout: 5000,
}, async () => {
const fetchImpl = async (): Promise<Response> => {
throw new TypeError("offline");
};
const capture: Capture<BrandingContextValue | null> = {
value: null,
};
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider
brandingEndpoint="https://api.example.com/branding"
fetch={fetchImpl}
>
<CaptureBranding capture={capture} />
</BrandingProvider>
)
);
await new Promise((r) => setTimeout(r, 0));
});
const err = capture.value?.error;
expect((err as ReactAuthWhitelabelError | undefined)?.code).toBe(
REACT_AUTH_WL_CODES.BRANDING_FETCH_FAILED
);
});
it("non-ok response exposes REACT_AUTH_WL.BRANDING_FETCH_FAILED", {
timeout: 5000,
}, async () => {
const fetchImpl = async (): Promise<Response> =>
new Response("", { status: 503 });
const capture: Capture<BrandingContextValue | null> = {
value: null,
};
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider
brandingEndpoint="https://api.example.com/branding"
fetch={fetchImpl}
>
<CaptureBranding capture={capture} />
</BrandingProvider>
)
);
await new Promise((r) => setTimeout(r, 0));
});
const err = capture.value?.error;
expect((err as ReactAuthWhitelabelError | undefined)?.code).toBe(
REACT_AUTH_WL_CODES.BRANDING_FETCH_FAILED
);
});
it("useLogo defaults to logoLight, returns logoDark on preferDark", {
timeout: 5000,
}, async () => {
const lightCap: Capture<string | undefined> = { value: undefined };
const darkCap: Capture<string | undefined> = { value: undefined };
const branding: TenantBranding = {
tenantId: "x",
name: "X",
primaryColor: "#fff",
secondaryColor: "#000",
logo: "https://cdn/fallback.svg",
logoLight: "https://cdn/light.svg",
logoDark: "https://cdn/dark.svg",
};
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider staticBranding={branding}>
<CaptureLogo capture={lightCap} />
<CaptureLogo capture={darkCap} preferDark />
</BrandingProvider>
)
);
});
expect(lightCap.value).toBe("https://cdn/light.svg");
expect(darkCap.value).toBe("https://cdn/dark.svg");
});
it("useLogo falls back to logo when variants are missing", {
timeout: 5000,
}, async () => {
const minimal: TenantBranding = {
tenantId: "min",
name: "Min",
primaryColor: "#000",
secondaryColor: "#fff",
logo: "https://cdn/just-logo.svg",
};
const lightCap: Capture<string | undefined> = { value: undefined };
const darkCap: Capture<string | undefined> = { value: undefined };
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider staticBranding={minimal}>
<CaptureLogo capture={lightCap} />
<CaptureLogo capture={darkCap} preferDark />
</BrandingProvider>
)
);
});
expect(lightCap.value).toBe("https://cdn/just-logo.svg");
expect(darkCap.value).toBe("https://cdn/just-logo.svg");
});
it("non-JSON body uses DEFAULT_BRANDING merge", {
timeout: 5000,
}, async () => {
const fetchImpl = async (): Promise<Response> =>
new Response("not-json-here", {
status: 200,
headers: { "Content-Type": "text/plain" },
});
const capture: Capture<BrandingContextValue | null> = {
value: null,
};
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider
brandingEndpoint="https://api.example.com/branding"
fetch={fetchImpl}
>
<CaptureBranding capture={capture} />
</BrandingProvider>
)
);
await new Promise((r) => setTimeout(r, 0));
});
// The JSON parse fails → raw becomes null → DEFAULT_BRANDING wins
expect(capture.value?.branding.tenantId).toBe("default");
});
it("non-object JSON response uses DEFAULT_BRANDING merge", {
timeout: 5000,
}, async () => {
const fetchImpl = async (): Promise<Response> =>
new Response("null", {
status: 200,
headers: { "Content-Type": "application/json" },
});
const capture: Capture<BrandingContextValue | null> = {
value: null,
};
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider
brandingEndpoint="https://api.example.com/branding"
fetch={fetchImpl}
>
<CaptureBranding capture={capture} />
</BrandingProvider>
)
);
await new Promise((r) => setTimeout(r, 0));
});
// DEFAULT_BRANDING is the merged baseline → validation passes
expect(capture.value?.branding.tenantId).toBe("default");
});
it("falls back to globalThis.fetch when fetch prop is omitted", {
timeout: 5000,
}, async () => {
const originalFetch = globalThis.fetch;
const calls: string[] = [];
(globalThis as unknown as { fetch: typeof fetch }).fetch = async (
input: string | URL | Request
): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
calls.push(url);
return new Response(
JSON.stringify({
tenantId: "global",
name: "Global",
primaryColor: "rgb(7, 7, 7)",
secondaryColor: "rgb(8, 8, 8)",
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
};
try {
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider brandingEndpoint="https://api.example.com/branding">
<div />
</BrandingProvider>
)
);
await new Promise((r) => setTimeout(r, 0));
});
expect(calls.length).toBeGreaterThan(0);
} finally {
(globalThis as unknown as { fetch: typeof fetch }).fetch =
originalFetch;
}
});
it("refresh re-fetches the branding payload", {
timeout: 5000,
}, async () => {
const responses = [
{
tenantId: "v1",
name: "V1",
primaryColor: "rgb(1, 1, 1)",
secondaryColor: "rgb(2, 2, 2)",
},
{
tenantId: "v2",
name: "V2",
primaryColor: "rgb(9, 9, 9)",
secondaryColor: "rgb(8, 8, 8)",
},
];
let idx = 0;
const fetchImpl = async (): Promise<Response> => {
const r = responses[Math.min(idx, responses.length - 1)];
idx += 1;
return jsonResponse(r);
};
const capture: Capture<BrandingContextValue | null> = {
value: null,
};
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider
brandingEndpoint="https://api.example.com/branding"
fetch={fetchImpl}
>
<CaptureBranding capture={capture} />
</BrandingProvider>
)
);
await new Promise((r) => setTimeout(r, 0));
});
expect(
document.documentElement.style.getPropertyValue("--brand-primary")
).toBe("rgb(1, 1, 1)");
await act(async () => {
await capture.value?.refresh();
});
expect(
document.documentElement.style.getPropertyValue("--brand-primary")
).toBe("rgb(9, 9, 9)");
});
});

View File

@@ -0,0 +1,79 @@
# `<BrandingProvider>` and tenant branding hooks
## Purpose
`branding-context.tsx` ships the `<BrandingProvider>` React context provider
together with the hooks `useBranding`, `useTenantBranding`, and `useLogo`.
The provider's job is to resolve a `TenantBranding` payload (either static
via prop or fetched from a configurable endpoint) and to project that
payload onto two surfaces: (1) the `@suaveplan/react-ui` runtime
theme-token layer for every `--brand-*` CSS custom property, and (2) the
non-CSS DOM chrome — `document.title`, `<link rel="icon">`, and the tenant
custom-CSS `<style>` injection. The CSS path goes through
`useThemeMutator().setExtensionTokens(...)` so per-tenant tokens stack on
top of the active chassis theme exactly like any other mutator layer, with
automatic cleanup on unmount.
## Features
- Static branding via prop OR endpoint fetch via `brandingEndpoint` (+
optional `/tenantId` segment).
- Injectable `fetch: FetchLike` for testability.
- Zod validation (`TenantBrandingSchema.safeParse`) on every fetched
payload — Zod failures surface as `BRANDING_INVALID_PAYLOAD`.
- Network failures (transport throw or non-ok response) surface as
`BRANDING_FETCH_FAILED`.
- `--brand-primary`, `--brand-secondary`, `--brand-accent`, `--brand-font`
written via `setExtensionTokens` — cleanup is automatic on unmount.
- `document.title`, favicon `<link>`, and tenant `<style>` injection are
direct DOM writes (not CSS variables; no conflict with the mutator).
## Usage: Basic
```tsx
import { ThemeProvider, lightTheme } from "@suaveplan/react-ui/theme";
import { BrandingProvider } from "@suaveplan/react-auth-whitelabel";
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
<BrandingProvider staticBranding={tenantBranding}>
<App />
</BrandingProvider>
</ThemeProvider>
```
## Usage: Advanced
```tsx
<BrandingProvider
brandingEndpoint="/api/branding"
tenantId={tenantSlug}
fetch={customFetch}
>
<App />
</BrandingProvider>
```
```tsx
const branding = useTenantBranding();
const lightLogo = useLogo(false);
const darkLogo = useLogo(true);
```
## API Reference
- `BrandingProvider(props)` — props include `brandingEndpoint?`,
`tenantId?`, `staticBranding?`, `fetch?`, `children`.
- `useBranding()` — returns `{ branding, isLoading, error, refresh }`.
- `useTenantBranding()` — returns the current `TenantBranding`.
- `useLogo(preferDark?)` — returns the most-appropriate logo URL or
`undefined`.
## Implementation Notes
The mutator effect runs on every `branding` change AND on every
`mutator` identity change. The mutator handle is stable across re-renders
(it's `useMemo`-wrapped inside `@suaveplan/react-ui`), so the effect only
re-fires when the branding payload actually changes. The cleanup
contract for `--brand-*` is delegated to `useThemeMutator` — when the
provider unmounts, the runtime invokes the mutator's `clear()` automatically,
removing every `--brand-*` declaration from `document.documentElement.style`.

View File

@@ -0,0 +1,229 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act, cleanup } from "@testing-library/react";
import {
type Capture,
CaptureBranding,
CaptureTenant,
jsonResponse,
resetBrandingDom,
STATIC_BRANDING,
wrapTheme,
} from "../../tests/setup/branding-test-utils.js";
import { REACT_AUTH_WL_CODES } from "../errors/error-codes.js";
import { ReactAuthWhitelabelError } from "../errors/react-auth-whitelabel-error.js";
import type {
BrandingContextValue,
TenantBranding,
} from "../types/branding.types.js";
import { BrandingProvider } from "./branding-context.js";
afterEach(() => {
cleanup();
resetBrandingDom();
});
describe("BrandingProvider — token + chrome propagation", () => {
it("setExtensionTokens propagates --brand-primary after mount", {
timeout: 5000,
}, async () => {
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider staticBranding={STATIC_BRANDING}>
<div />
</BrandingProvider>
)
);
});
expect(
document.documentElement.style.getPropertyValue("--brand-primary")
).toBe("rgb(255, 0, 0)");
expect(
document.documentElement.style.getPropertyValue("--brand-secondary")
).toBe("rgb(0, 255, 0)");
expect(
document.documentElement.style.getPropertyValue("--brand-accent")
).toBe("rgb(0, 0, 255)");
expect(
document.documentElement.style.getPropertyValue("--brand-font")
).toBe("Inter, sans-serif");
});
it("syncs document.title, favicon, and custom CSS", {
timeout: 5000,
}, async () => {
const link = document.createElement("link");
link.rel = "icon";
link.href = "";
document.head.appendChild(link);
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider staticBranding={STATIC_BRANDING}>
<div />
</BrandingProvider>
)
);
});
expect(document.title).toBe("Acme Co");
expect(link.href).toContain("favicon.ico");
const styleEl = document.getElementById("tenant-custom-css");
expect(styleEl).not.toBeNull();
expect(styleEl?.textContent).toBe(":root { --x: 1 }");
link.remove();
});
it("reuses existing #tenant-custom-css node across renders", {
timeout: 5000,
}, async () => {
const pre = document.createElement("style");
pre.id = "tenant-custom-css";
pre.textContent = "/* stale */";
document.head.appendChild(pre);
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider staticBranding={STATIC_BRANDING}>
<div />
</BrandingProvider>
)
);
});
expect(pre.textContent).toBe(":root { --x: 1 }");
});
it("unmount removes every --brand-* property", {
timeout: 5000,
}, async () => {
let unmount: () => void = () => undefined;
await act(async () => {
const result = renderComponent(
wrapTheme(
<BrandingProvider staticBranding={STATIC_BRANDING}>
<div />
</BrandingProvider>
)
);
unmount = result.unmount;
});
expect(
document.documentElement.style.getPropertyValue("--brand-primary")
).toBe("rgb(255, 0, 0)");
await act(async () => {
unmount();
});
expect(
document.documentElement.style.getPropertyValue("--brand-primary")
).toBe("");
expect(
document.documentElement.style.getPropertyValue("--brand-secondary")
).toBe("");
});
it("fetched branding propagates after the fetch resolves", {
timeout: 5000,
}, async () => {
const fetchImpl = async (): Promise<Response> =>
jsonResponse({
tenantId: "fetched",
name: "Fetched Co",
primaryColor: "rgb(10, 20, 30)",
secondaryColor: "rgb(40, 50, 60)",
});
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider
brandingEndpoint="https://api.example.com/branding"
tenantId="fetched"
fetch={fetchImpl}
>
<div />
</BrandingProvider>
)
);
await new Promise((r) => setTimeout(r, 0));
});
expect(
document.documentElement.style.getPropertyValue("--brand-primary")
).toBe("rgb(10, 20, 30)");
});
it("useTenantBranding returns the current branding", {
timeout: 5000,
}, async () => {
const capture: Capture<TenantBranding | null> = { value: null };
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider staticBranding={STATIC_BRANDING}>
<CaptureTenant capture={capture} />
</BrandingProvider>
)
);
});
expect(capture.value?.tenantId).toBe("acme");
});
it("useBranding outside provider throws PROVIDER_OUTSIDE_TREE", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
renderComponent(
wrapTheme(<CaptureBranding capture={{ value: null }} />)
);
} catch (err) {
caught = err;
}
let current: unknown = caught;
let found: ReactAuthWhitelabelError | null = null;
let depth = 0;
while (current !== undefined && current !== null && depth < 10) {
if (current instanceof ReactAuthWhitelabelError) {
found = current;
break;
}
current = (current as { cause?: unknown }).cause;
depth += 1;
}
expect(found?.code).toBe(REACT_AUTH_WL_CODES.PROVIDER_OUTSIDE_TREE);
});
it("skips favicon update when no <link rel='icon'> is present", {
timeout: 5000,
}, async () => {
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider staticBranding={STATIC_BRANDING}>
<div />
</BrandingProvider>
)
);
});
expect(document.title).toBe("Acme Co");
});
it("refresh with staticBranding is a no-op", {
timeout: 5000,
}, async () => {
const capture: Capture<BrandingContextValue | null> = {
value: null,
};
await act(async () => {
renderComponent(
wrapTheme(
<BrandingProvider staticBranding={STATIC_BRANDING}>
<CaptureBranding capture={capture} />
</BrandingProvider>
)
);
});
await act(async () => {
await capture.value?.refresh();
});
expect(capture.value?.branding.tenantId).toBe("acme");
});
});

View File

@@ -0,0 +1,206 @@
import { useThemeMutator } from "@suaveplan/react-ui/theme";
import type { FetchLike } from "@suaveplan/types";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
REACT_AUTH_WL_CODES,
ReactAuthWhitelabelError,
} from "../errors/index.js";
import { TenantBrandingSchema } from "../types/branding.schema.js";
import {
type BrandingContextValue,
type BrandingProviderProps,
DEFAULT_BRANDING,
type TenantBranding,
} from "../types/branding.types.js";
const BrandingContext = createContext<BrandingContextValue | null>(null);
const CUSTOM_CSS_STYLE_ID = "tenant-custom-css";
/**
* Sync the non-token branding surfaces (document title, favicon,
* tenant custom CSS). The `--brand-*` CSS custom properties are NOT
* touched here — they are owned by the `useThemeMutator()` layer
* applied separately in `<BrandingProvider>`.
*/
function syncDocumentChrome(branding: TenantBranding): void {
if (branding.favicon !== undefined) {
const link =
document.querySelector<HTMLLinkElement>("link[rel~='icon']");
if (link !== null) {
link.href = branding.favicon;
}
}
document.title = branding.name;
if (branding.customCss !== undefined) {
let styleEl = document.getElementById(
CUSTOM_CSS_STYLE_ID
) as HTMLStyleElement | null;
if (styleEl === null) {
styleEl = document.createElement("style");
styleEl.id = CUSTOM_CSS_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = branding.customCss;
}
}
function brandingToExtensionTokens(
branding: TenantBranding
): Record<string, string> {
const tokens: Record<string, string> = {
"--brand-primary": branding.primaryColor,
"--brand-secondary": branding.secondaryColor,
};
if (branding.accentColor !== undefined) {
tokens["--brand-accent"] = branding.accentColor;
}
if (branding.fontFamily !== undefined) {
tokens["--brand-font"] = branding.fontFamily;
}
return tokens;
}
/**
* `<BrandingProvider>` — fetches tenant branding (or accepts a static
* payload) and pushes `--brand-*` extension tokens through the
* `useThemeMutator()` layer surface. MUST be mounted inside a
* `<ThemeProvider>` (from `@suaveplan/react-ui`).
*/
export function BrandingProvider(props: BrandingProviderProps): ReactNode {
const { brandingEndpoint, tenantId, staticBranding, children } = props;
const mutator = useThemeMutator();
const [branding, setBranding] = useState<TenantBranding>(
staticBranding ?? DEFAULT_BRANDING
);
const [isLoading, setIsLoading] = useState<boolean>(
staticBranding === undefined && brandingEndpoint !== undefined
);
const [error, setError] = useState<Error | null>(null);
const fetchBranding = useCallback(async (): Promise<void> => {
if (staticBranding !== undefined || brandingEndpoint === undefined) {
return;
}
setIsLoading(true);
setError(null);
const fetchImpl = props.fetch ?? (globalThis.fetch as FetchLike);
const url =
tenantId !== undefined
? `${brandingEndpoint}/${tenantId}`
: brandingEndpoint;
let response: Response;
try {
response = await fetchImpl(url);
} catch (cause) {
setError(
new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.BRANDING_FETCH_FAILED,
"react_auth_wl.error.branding_fetch",
{ cause, context: { url } }
)
);
setIsLoading(false);
return;
}
if (!response.ok) {
setError(
new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.BRANDING_FETCH_FAILED,
"react_auth_wl.error.branding_fetch",
{ context: { url, status: response.status } }
)
);
setIsLoading(false);
return;
}
const raw = (await response.json().catch(() => null)) as unknown;
const parsed = TenantBrandingSchema.safeParse({
...DEFAULT_BRANDING,
...(typeof raw === "object" && raw !== null ? raw : {}),
});
if (!parsed.success) {
setError(
new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.BRANDING_INVALID_PAYLOAD,
"react_auth_wl.error.branding_invalid_payload",
{ context: { url, issues: parsed.error.issues } }
)
);
setIsLoading(false);
return;
}
setBranding(parsed.data as TenantBranding);
setIsLoading(false);
}, [brandingEndpoint, tenantId, staticBranding, props]);
useEffect(() => {
void fetchBranding();
}, [fetchBranding]);
useEffect(() => {
mutator.setExtensionTokens(brandingToExtensionTokens(branding));
syncDocumentChrome(branding);
}, [branding, mutator]);
const value = useMemo<BrandingContextValue>(
() => ({
branding,
isLoading,
error,
refresh: fetchBranding,
}),
[branding, isLoading, error, fetchBranding]
);
return (
<BrandingContext.Provider value={value}>
{children}
</BrandingContext.Provider>
);
}
/** Hook to read the branding context. Throws `PROVIDER_OUTSIDE_TREE`. */
export function useBranding(): BrandingContextValue {
const ctx = useContext(BrandingContext);
if (ctx === null) {
throw new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.PROVIDER_OUTSIDE_TREE,
"react_auth_wl.error.provider_outside_tree",
{ context: { hook: "useBranding" } }
);
}
return ctx;
}
/** Hook returning the currently resolved tenant branding payload. */
export function useTenantBranding(): TenantBranding {
return useBranding().branding;
}
/**
* Hook returning the active logo URL for a light/dark background. Returns
* `branding.logoDark` when `preferDark`, else `branding.logoLight`, falling
* back to `branding.logo` when the requested variant is missing.
*/
export function useLogo(preferDark = false): string | undefined {
const branding = useTenantBranding();
if (preferDark && branding.logoDark !== undefined) {
return branding.logoDark;
}
if (!preferDark && branding.logoLight !== undefined) {
return branding.logoLight;
}
return branding.logo;
}
export { BrandingContext };

View File

@@ -0,0 +1,15 @@
export {
AuthContext,
AuthProvider,
type AuthProviderProps,
useAuth,
useIsAuthenticated,
useUser,
} from "./auth-context.js";
export {
BrandingContext,
BrandingProvider,
useBranding,
useLogo,
useTenantBranding,
} from "./branding-context.js";

View File

@@ -0,0 +1,65 @@
# Error codes — `REACT_AUTH_WL`
## Purpose
`error-codes.ts` registers the canonical, stable error-code registry used by
every error thrown from `@suaveplan/react-auth-whitelabel`. The codes are
namespaced under `REACT_AUTH_WL` and registered at module load via
`defineCodes`, which means the `@suaveplan/error` registry is wired before any
consumer imports a hook, context, or component. The codes survive minification,
remain stable across releases, and let consumers branch on `.code` instead of
inspecting the constructor name or message text — both of which are unstable
across bundles.
## Features
- Single source of truth for error-code identity in this package.
- `as const` literal-string codes for type-narrowing.
- Module-load-time registration so the wire-format adapter, redaction layer,
OTel span-event bridge, and i18n resolver all see the codes without an
explicit init call.
- Each code is thrown by ≥1 production code path — the
`error-codes.test.ts` grep-test enforces the contract.
## Usage: Basic
```ts
import { REACT_AUTH_WL_CODES } from "@suaveplan/react-auth-whitelabel/errors";
if (err.code === REACT_AUTH_WL_CODES.BRANDING_FETCH_FAILED) {
showFallbackBrand();
}
```
## Usage: Advanced
```ts
import {
REACT_AUTH_WL_CODES,
type ReactAuthWhitelabelCode,
} from "@suaveplan/react-auth-whitelabel/errors";
function isRecoverable(code: ReactAuthWhitelabelCode): boolean {
switch (code) {
case REACT_AUTH_WL_CODES.BRANDING_FETCH_FAILED:
case REACT_AUTH_WL_CODES.AUTH_NETWORK_FAILED:
return true;
default:
return false;
}
}
```
## API Reference
- `REACT_AUTH_WL_CODES` — frozen record of literal string codes; each value is
prefixed with `"REACT_AUTH_WL."`.
- `ReactAuthWhitelabelCode` — union of every `REACT_AUTH_WL_CODES[K]` value.
## Implementation Notes
`defineCodes("REACT_AUTH_WL", [...])` enforces uniqueness inside the namespace
and stamps the registry with the call site for diagnostics. We intentionally
do NOT eagerly import the registry from package code other than the error
class — the registry side-effect runs once at module evaluation and that is
the only required wiring.

View File

@@ -0,0 +1,54 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { REACT_AUTH_WL_CODES } from "./error-codes.js";
const PACKAGE_SRC = join(__dirname, "..");
function collectProductionSources(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
collectProductionSources(full, acc);
continue;
}
if (
entry.endsWith(".test.ts") ||
entry.endsWith(".test.tsx") ||
entry.endsWith(".stories.tsx") ||
entry.endsWith(".types.ts") ||
entry === "error-codes.ts"
) {
continue;
}
if (entry.endsWith(".ts") || entry.endsWith(".tsx")) {
acc.push(full);
}
}
return acc;
}
describe("REACT_AUTH_WL error codes registry", () => {
it("every REACT_AUTH_WL code is thrown by at least one production file", {
timeout: 5000,
}, () => {
const files = collectProductionSources(PACKAGE_SRC);
const sources = files.map((f) => readFileSync(f, "utf8")).join("\n");
for (const [name] of Object.entries(REACT_AUTH_WL_CODES)) {
const literal = `REACT_AUTH_WL_CODES.${name}`;
expect(
sources.includes(literal),
`expected code ${name} to appear in a production file`
).toBe(true);
}
});
it("every code value is prefixed with REACT_AUTH_WL.", {
timeout: 5000,
}, () => {
for (const [, value] of Object.entries(REACT_AUTH_WL_CODES)) {
expect(value.startsWith("REACT_AUTH_WL.")).toBe(true);
}
});
});

View File

@@ -0,0 +1,42 @@
import { defineCodes } from "@suaveplan/error";
/**
* Stable error codes for `@suaveplan/react-auth-whitelabel`.
*
* Codes are grouped under the `REACT_AUTH_WL` namespace and registered at
* module load time via `defineCodes`. The registry is the single source
* of truth for error-code identity across the white-label auth surface —
* every thrown `ReactAuthWhitelabelError` carries one of these strings
* as its `.code`.
*
* Codes:
* - `BRANDING_FETCH_FAILED` — `BrandingProvider`'s configured fetch
* returned a non-ok response (or threw). The error surfaces on the
* context's `error` field; consumers can branch on `.code` to render a
* tenant-specific fallback.
* - `BRANDING_INVALID_PAYLOAD` — the fetched branding payload failed
* `TenantBrandingSchema.safeParse(...)`. The error carries a Zod issue
* tree in `context.issues`.
* - `AUTH_NETWORK_FAILED` — an `AuthProvider` verb's underlying `fetch`
* threw (offline / DNS / CORS preflight failure).
* - `AUTH_REJECTED` — the backend responded with `ok: false`; the
* parsed response body's `message` field, when present, is forwarded
* in the error's message-args context.
* - `MISSING_RESET_TOKEN` — `PasswordResetForm` was rendered in
* `mode="confirm"` without a `token` prop. The form blocks submission
* and surfaces the error inline.
* - `PROVIDER_OUTSIDE_TREE` — `useAuth()` / `useBranding()` was called
* without an enclosing provider. The error carries `context.hook` so
* logs identify the offending call site.
*/
export const REACT_AUTH_WL_CODES = defineCodes("REACT_AUTH_WL", [
"BRANDING_FETCH_FAILED",
"BRANDING_INVALID_PAYLOAD",
"AUTH_NETWORK_FAILED",
"AUTH_REJECTED",
"MISSING_RESET_TOKEN",
"PROVIDER_OUTSIDE_TREE",
] as const);
export type ReactAuthWhitelabelCode =
(typeof REACT_AUTH_WL_CODES)[keyof typeof REACT_AUTH_WL_CODES];

View File

@@ -0,0 +1,5 @@
export {
REACT_AUTH_WL_CODES,
type ReactAuthWhitelabelCode,
} from "./error-codes.js";
export { ReactAuthWhitelabelError } from "./react-auth-whitelabel-error.js";

View File

@@ -0,0 +1,68 @@
# `ReactAuthWhitelabelError`
## Purpose
`ReactAuthWhitelabelError` is the canonical error class for every error thrown
by `@suaveplan/react-auth-whitelabel`. It extends `SuaveplanError` so the
error participates in the project-wide error substrate (cause-chain traversal,
redaction-aware wire format, OTel span-event adapter, i18n message resolver).
Every instance is stamped with `context.tier === "react-auth-whitelabel"` so
log sinks and dashboards can group white-label auth failures without
inspecting `error.constructor.name`, which is unstable across minified
bundles. Construction always carries a `messageKey` so the i18n resolver can
localise the user-visible error string at the boundary.
## Features
- Type-narrowed `.code` of `ReactAuthWhitelabelCode`.
- Auto-injected `context.tier` for log/dashboard grouping.
- Carries a translation key, not a baked English string.
- Compatible with `SuaveplanError`'s cause-chain walker, wire serialiser,
and OTel span-event adapter.
## Usage: Basic
```ts
import { ReactAuthWhitelabelError } from "@suaveplan/react-auth-whitelabel/errors";
import { REACT_AUTH_WL_CODES } from "@suaveplan/react-auth-whitelabel/errors";
throw new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.PROVIDER_OUTSIDE_TREE,
"react_auth_wl.error.provider_outside_tree",
{ context: { hook: "useAuth" } }
);
```
## Usage: Advanced
```ts
try {
await login(email, password);
} catch (cause) {
throw new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.AUTH_NETWORK_FAILED,
"react_auth_wl.error.network",
{ cause, context: { authUrl: config.authUrl } }
);
}
```
The thrown error retains the original `cause` chain so monitoring sinks can
correlate transport errors with the user-facing failure.
## API Reference
- `new ReactAuthWhitelabelError(code, messageKey, options?)` — constructs an
error. `options.context.tier` is always set to `"react-auth-whitelabel"`,
even if the caller passes a different value (the helper merges, then
overrides `tier`).
- `.code` — `ReactAuthWhitelabelCode` literal.
- `.message` — short English description sourced from `messageKey` (the i18n
resolver substitutes a localised translation when available).
## Implementation Notes
The constructor merges caller-supplied `context` with the canonical `tier`
stamp. Callers must NOT pass a different `tier` — it will silently win at
merge time, but the contract is that this package's errors are always
tagged `"react-auth-whitelabel"`.

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { REACT_AUTH_WL_CODES } from "./error-codes.js";
import { ReactAuthWhitelabelError } from "./react-auth-whitelabel-error.js";
describe("ReactAuthWhitelabelError", () => {
it("stamps context.tier with 'react-auth-whitelabel'", {
timeout: 5000,
}, () => {
const err = new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.PROVIDER_OUTSIDE_TREE,
"k"
);
expect(err.context?.tier).toBe("react-auth-whitelabel");
});
it("preserves caller-supplied context", { timeout: 5000 }, () => {
const err = new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.AUTH_REJECTED,
"k",
{ context: { url: "https://x" } }
);
expect(err.context?.url).toBe("https://x");
expect(err.context?.tier).toBe("react-auth-whitelabel");
});
it("threads cause through SuaveplanError", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.AUTH_NETWORK_FAILED,
"k",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("carries the supplied code on .code", { timeout: 5000 }, () => {
const err = new ReactAuthWhitelabelError(
REACT_AUTH_WL_CODES.MISSING_RESET_TOKEN,
"k"
);
expect(err.code).toBe(REACT_AUTH_WL_CODES.MISSING_RESET_TOKEN);
});
});

View File

@@ -0,0 +1,29 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { ReactAuthWhitelabelCode } from "./error-codes.js";
const TIER = "react-auth-whitelabel";
/**
* Base error class for every error thrown by
* `@suaveplan/react-auth-whitelabel`. Extends `SuaveplanError` so the
* error participates in the monorepo-wide error substrate (cause-chain
* traversal, redaction-aware wire format, OTel span-event adapter,
* i18n message resolver).
*
* Every instance is stamped with `context.tier === "react-auth-whitelabel"`
* so log sinks and dashboards can group auth-UI failures without
* inspecting `error.constructor.name` (unstable across minified bundles).
*/
export class ReactAuthWhitelabelError extends SuaveplanError<ReactAuthWhitelabelCode> {
public constructor(
code: ReactAuthWhitelabelCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
const merged: SuaveplanErrorOptions = {
...options,
context: { tier: TIER, ...(options?.context ?? {}) },
};
super(code, messageKey, merged);
}
}

View File

@@ -0,0 +1,10 @@
export {
useAuth,
useIsAuthenticated,
useUser,
} from "../context/auth-context.js";
export {
useBranding,
useLogo,
useTenantBranding,
} from "../context/branding-context.js";

View File

@@ -0,0 +1,59 @@
/**
* `@suaveplan/react-auth-whitelabel` — white-label authentication React
* components with tenant branding wired through the `@suaveplan/react-ui`
* runtime theme-token mutator.
*/
export {
LoginForm,
type LoginFormProps,
type LoginFormValues,
PasswordResetConfirm,
type PasswordResetConfirmProps,
type PasswordResetConfirmValues,
PasswordResetForm,
type PasswordResetFormProps,
type PasswordResetMode,
PasswordResetRequest,
type PasswordResetRequestProps,
type PasswordResetRequestValues,
PasswordResetSuccess,
type PasswordResetSuccessProps,
RegisterForm,
RegisterFormFields,
type RegisterFormFieldsProps,
type RegisterFormProps,
RegisterFormTerms,
type RegisterFormTermsProps,
type RegisterFormValues,
} from "./components/index.js";
export {
AuthContext,
AuthProvider,
type AuthProviderProps,
BrandingContext,
BrandingProvider,
useAuth,
useBranding,
useIsAuthenticated,
useLogo,
useTenantBranding,
useUser,
} from "./context/index.js";
export {
REACT_AUTH_WL_CODES,
type ReactAuthWhitelabelCode,
ReactAuthWhitelabelError,
} from "./errors/index.js";
export {
type AuthConfig,
type AuthContextValue,
type AuthProvider as AuthProviderConfig,
type AuthUser,
type BrandingContextValue,
type BrandingProviderProps,
DEFAULT_BRANDING,
type RegisterData,
type TenantBranding,
TenantBrandingSchema,
} from "./types/index.js";

View File

@@ -0,0 +1,59 @@
# `TenantBrandingSchema`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TenantBrandingSchema` is the Zod schema that validates every `TenantBranding`
payload entering the package from an untrusted boundary — specifically the
JSON returned by the branding endpoint configured on `<BrandingProvider>`.
It mirrors `TenantBranding` exactly: every field's optionality and type
match the TypeScript definition. The schema is the boundary contract the
`BrandingProvider` enforces before storing a fetched payload, so an invalid
or partial JSON response cannot poison the running app's theme tokens or
document chrome.
## Features
- Field-by-field parity with `TenantBranding`.
- `satisfies z.ZodType<TenantBranding>` keeps schema and type in lockstep —
a missing field on either side fails the compile.
- Imported with `import z from "zod"` per the Zod import rule.
- Required string fields use `.min(1)` so a JSON `""` value is rejected.
## Usage: Basic
```ts
import { TenantBrandingSchema } from "@suaveplan/react-auth-whitelabel";
const parsed = TenantBrandingSchema.parse(await response.json());
```
## Usage: Advanced
```ts
const result = TenantBrandingSchema.safeParse(payload);
if (!result.success) {
logger.warn("invalid branding payload", { issues: result.error.issues });
return;
}
applyBranding(result.data);
```
The `BrandingProvider` uses `safeParse` so a malformed payload is converted
into a `ReactAuthWhitelabelError(REACT_AUTH_WL.BRANDING_INVALID_PAYLOAD)` and
surfaced on the context's `error` field instead of crashing the React tree.
## API Reference
- `TenantBrandingSchema` — a `z.ZodObject` whose inferred type is exactly
`TenantBranding`.
## Implementation Notes
The schema lives in its own file so the type-only consumer of `TenantBranding`
can keep the runtime Zod dependency tree-shaken when validation is not used.
Static branding passed via `staticBranding` is trusted and never parsed — the
consumer authored it. Network payloads are always parsed.

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TenantBrandingSchema } from "./branding.schema.js";
describe("TenantBrandingSchema", () => {
it("accepts a fully-populated branding payload", { timeout: 5000 }, () => {
const result = TenantBrandingSchema.safeParse({
tenantId: "acme",
name: "Acme Co",
logo: "https://cdn.example.com/logo.svg",
logoLight: "https://cdn.example.com/logo-light.svg",
logoDark: "https://cdn.example.com/logo-dark.svg",
favicon: "https://cdn.example.com/favicon.ico",
primaryColor: "#ff0000",
secondaryColor: "#00ff00",
accentColor: "#0000ff",
fontFamily: "Inter, sans-serif",
customCss: ":root { --x: 1 }",
supportEmail: "support@example.com",
privacyPolicyUrl: "https://example.com/privacy",
termsOfServiceUrl: "https://example.com/tos",
});
expect(result.success).toBe(true);
});
it("accepts a minimum-fields branding payload", { timeout: 5000 }, () => {
const result = TenantBrandingSchema.safeParse({
tenantId: "acme",
name: "Acme Co",
primaryColor: "#ff0000",
secondaryColor: "#00ff00",
});
expect(result.success).toBe(true);
});
it("rejects payload without primaryColor", { timeout: 5000 }, () => {
const result = TenantBrandingSchema.safeParse({
tenantId: "acme",
name: "Acme Co",
secondaryColor: "#00ff00",
});
expect(result.success).toBe(false);
});
it("rejects empty tenantId", { timeout: 5000 }, () => {
const result = TenantBrandingSchema.safeParse({
tenantId: "",
name: "Acme Co",
primaryColor: "#ff0000",
secondaryColor: "#00ff00",
});
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,35 @@
import z from "zod";
/**
* Zod schema for `TenantBranding`. The schema mirrors `branding.types.ts`
* exactly — every optional type field is reflected as `.optional()` here.
*
* `BrandingProvider` calls `TenantBrandingSchema.safeParse(...)` on every
* fetched payload before storing it in context. Static branding passed via
* the `staticBranding` prop is trusted (no parse) because the consumer
* authored it.
*
* Note: the schema's inferred output is structurally identical to
* `TenantBranding`. We do not assert the equivalence via `satisfies` here
* because Zod's `.optional()` produces `T | undefined` while
* `exactOptionalPropertyTypes` makes the genesis `TenantBranding` interface
* declare `field?: T | undefined` — equivalent at runtime, but the assertion
* surfaces a phantom inequality that `as TenantBranding` resolves at the
* single consumer site (`branding-context.tsx`).
*/
export const TenantBrandingSchema = z.object({
tenantId: z.string().min(1),
name: z.string().min(1),
logo: z.string().optional(),
logoLight: z.string().optional(),
logoDark: z.string().optional(),
favicon: z.string().optional(),
primaryColor: z.string().min(1),
secondaryColor: z.string().min(1),
accentColor: z.string().optional(),
fontFamily: z.string().optional(),
customCss: z.string().optional(),
supportEmail: z.string().optional(),
privacyPolicyUrl: z.string().optional(),
termsOfServiceUrl: z.string().optional(),
});

View File

@@ -0,0 +1,113 @@
/**
* @suaveplan/react-auth-whitelabel — Branding & auth public types.
*
* All shapes consumed at the package's public surface live here. The
* Zod schema for `TenantBranding` is in the sibling `branding.schema.ts`
* so build-time consumers can tree-shake the schema when validation is
* not required.
*
* Every optional field is declared with an explicit `| undefined` union
* because the genesis monorepo compiles with `exactOptionalPropertyTypes`
* — without the union, callers cannot pass `{ field: undefined }`.
*/
import type { FetchLike } from "@suaveplan/types";
import type { ComponentType, ReactNode } from "react";
/** Tenant-specific branding payload applied to the auth surface. */
export interface TenantBranding {
tenantId: string;
name: string;
logo?: string | undefined;
logoLight?: string | undefined;
logoDark?: string | undefined;
favicon?: string | undefined;
primaryColor: string;
secondaryColor: string;
accentColor?: string | undefined;
fontFamily?: string | undefined;
customCss?: string | undefined;
supportEmail?: string | undefined;
privacyPolicyUrl?: string | undefined;
termsOfServiceUrl?: string | undefined;
}
/** Default branding used when no tenant payload has been resolved yet. */
export const DEFAULT_BRANDING: TenantBranding = {
tenantId: "default",
name: "Application",
primaryColor: "#3B82F6",
secondaryColor: "#10B981",
fontFamily: "Inter, system-ui, sans-serif",
};
/** Value exposed by `useBranding()`. */
export interface BrandingContextValue {
branding: TenantBranding;
isLoading: boolean;
error: Error | null;
refresh: () => Promise<void>;
}
/** Props for `<BrandingProvider>`. */
export interface BrandingProviderProps {
brandingEndpoint?: string | undefined;
tenantId?: string | undefined;
staticBranding?: TenantBranding | undefined;
fetch?: FetchLike | undefined;
children: ReactNode;
}
/** OAuth provider entry rendered by every form. */
export interface AuthProvider {
id: string;
name: string;
icon?:
| ComponentType<{ className?: string | undefined }>
| string
| undefined;
}
/** `AuthProvider` configuration. */
export interface AuthConfig {
authUrl: string;
redirectUrl?: string | undefined;
providers?: AuthProvider[] | undefined;
enableEmailLogin?: boolean | undefined;
enableRegistration?: boolean | undefined;
enablePasswordReset?: boolean | undefined;
enableRememberMe?: boolean | undefined;
fetch?: FetchLike | undefined;
navigate?: ((url: string) => void) | undefined;
}
/** Authenticated user shape returned by the backend. */
export interface AuthUser {
id: string;
email: string;
name?: string | undefined;
avatar?: string | undefined;
role?: string | undefined;
permissions?: string[] | undefined;
}
/** Registration request payload. */
export interface RegisterData {
email: string;
password: string;
name?: string | undefined;
}
/** Value exposed by `useAuth()`. */
export interface AuthContextValue {
user: AuthUser | null;
isLoading: boolean;
isAuthenticated: boolean;
error: Error | null;
login: (email: string, password: string) => Promise<void>;
loginWithProvider: (providerId: string) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => Promise<void>;
requestPasswordReset: (email: string) => Promise<void>;
resetPassword: (token: string, password: string) => Promise<void>;
}

View File

@@ -0,0 +1,12 @@
export { TenantBrandingSchema } from "./branding.schema.js";
export type {
AuthConfig,
AuthContextValue,
AuthProvider,
AuthUser,
BrandingContextValue,
BrandingProviderProps,
RegisterData,
TenantBranding,
} from "./branding.types.js";
export { DEFAULT_BRANDING } from "./branding.types.js";

View File

@@ -0,0 +1,53 @@
# `navigateOrCall`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`navigateOrCall(target)` is the canonical helper used by every form
component in this package to act on a "URL or click handler" prop. The
forms accept `string | (() => void) | undefined` for affordances like
`onForgotPassword`, `onRegister`, `onLogin`, etc. This helper centralises
the three-branch routing so each form does not carry an inline copy of
the same logic. Treating string values as URLs and function values as
imperative handlers lets consumers wire either a static href or an
in-app router callback without changing the component prop signature.
## Features
- String target → `window.location.href = target` (no-op outside a
browser).
- Function target → invoke directly.
- `undefined` → no-op.
## Usage: Basic
```ts
navigateOrCall("/login");
navigateOrCall(() => router.push("/login"));
navigateOrCall(undefined);
```
## Usage: Advanced
```tsx
<button onClick={() => navigateOrCall(props.onLogin)}>
Sign in
</button>
```
## API Reference
- `navigateOrCall(target: string | (() => void) | undefined): void` —
never throws; safely handles non-browser environments by detecting
`typeof window`.
## Implementation Notes
The string branch checks `typeof window !== "undefined"` so a server-side
or test environment that lacks `window` does not crash. The helper does
not return the target — callers cannot observe whether the navigation
ran; this matches the imperative redirect semantics expected at every
call site.

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { navigateOrCall } from "./navigate-or-call.js";
describe("navigateOrCall", () => {
it("invokes a function target", { timeout: 5000 }, () => {
let called = 0;
navigateOrCall(() => {
called += 1;
});
expect(called).toBe(1);
});
it("writes window.location.href when target is a string", {
timeout: 5000,
}, () => {
const original = window.location.href;
navigateOrCall("https://example.com/somewhere");
expect(window.location.href).not.toBe(original);
});
it("is a no-op for undefined target", { timeout: 5000 }, () => {
expect(() => navigateOrCall(undefined)).not.toThrow();
});
});

View File

@@ -0,0 +1,17 @@
/**
* Helper used by every form to route a "link or handler" prop value.
* Accepts a URL string (assigns `window.location.href`), a callback
* (invokes it), or `undefined` (no-op). Centralised here so the four
* forms do not each carry an inline copy.
*/
export function navigateOrCall(
target: string | (() => void) | undefined
): void {
if (typeof target === "string") {
window.location.href = target;
return;
}
if (typeof target === "function") {
target();
}
}

View File

@@ -0,0 +1,89 @@
/**
* Shared test utilities for the auth-context test split. Exposed under
* `tests/setup/` so they are excluded from the istanbul coverage scope
* (`include: src/**`) while still being available to multiple `.test.tsx`
* files inside `src/context/`.
*/
import type { ReactNode } from "react";
import {
useAuth,
useIsAuthenticated,
useUser,
} from "../../src/context/auth-context.js";
import type {
AuthContextValue,
AuthUser,
} from "../../src/types/branding.types.js";
export interface FetchCall {
url: string;
init?: RequestInit;
}
export interface AuthCaptureBag {
value: AuthContextValue | null;
}
export interface UserCaptureBag {
value: AuthUser | null | unknown;
}
export interface AuthedCaptureBag {
value: boolean;
}
export function makeFetch(
handler: (call: FetchCall) => Response | Promise<Response>
): {
fetch: (
input: string | URL | Request,
init?: RequestInit
) => Promise<Response>;
calls: FetchCall[];
} {
const calls: FetchCall[] = [];
return {
calls,
fetch: async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
calls.push({ url, init });
return handler({ url, init });
},
};
}
export function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
export function CaptureAuth({
capture,
}: {
capture: AuthCaptureBag;
}): ReactNode {
capture.value = useAuth();
return null;
}
export function CaptureUser({
capture,
}: {
capture: UserCaptureBag;
}): ReactNode {
capture.value = useUser();
return null;
}
export function CaptureAuthed({
capture,
}: {
capture: AuthedCaptureBag;
}): ReactNode {
capture.value = useIsAuthenticated();
return null;
}

View File

@@ -0,0 +1,94 @@
/**
* Shared helpers for the branding-context test split.
*/
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui/theme";
import type { ReactNode } from "react";
import {
useBranding,
useLogo,
useTenantBranding,
} from "../../src/context/branding-context.js";
import type {
BrandingContextValue,
TenantBranding,
} from "../../src/types/branding.types.js";
export interface Capture<T> {
value: T;
}
export function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
export const STATIC_BRANDING: TenantBranding = {
tenantId: "acme",
name: "Acme Co",
primaryColor: "rgb(255, 0, 0)",
secondaryColor: "rgb(0, 255, 0)",
accentColor: "rgb(0, 0, 255)",
fontFamily: "Inter, sans-serif",
logo: "https://cdn/logo.svg",
logoLight: "https://cdn/logo-light.svg",
logoDark: "https://cdn/logo-dark.svg",
favicon: "https://cdn/favicon.ico",
customCss: ":root { --x: 1 }",
};
export function CaptureBranding({
capture,
}: {
capture: Capture<BrandingContextValue | null>;
}): ReactNode {
capture.value = useBranding();
return null;
}
export function CaptureTenant({
capture,
}: {
capture: Capture<TenantBranding | null>;
}): ReactNode {
capture.value = useTenantBranding();
return null;
}
export function CaptureLogo({
capture,
preferDark,
}: {
capture: Capture<string | undefined>;
preferDark?: boolean;
}): ReactNode {
capture.value = useLogo(preferDark);
return null;
}
export function wrapTheme(children: ReactNode): ReactNode {
return (
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
{children}
</ThemeProvider>
);
}
export function resetBrandingDom(): void {
const root = document.documentElement;
for (const prop of [
"--brand-primary",
"--brand-secondary",
"--brand-accent",
"--brand-font",
]) {
root.style.removeProperty(prop);
}
document.title = "";
const styleEl = document.getElementById("tenant-custom-css");
if (styleEl !== null) {
styleEl.remove();
}
}

View File

@@ -0,0 +1,100 @@
/**
* Shared test utilities for the form-component tests. Each form needs an
* `<AuthProvider>` + `<BrandingProvider>` wrap with controllable seams,
* inside a `<TranslationProvider>` so `useTranslation()` resolves.
*/
import { createI18n } from "@suaveplan/i18n/plugin";
import { TranslationProvider } from "@suaveplan/react-i18n";
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui/theme";
import type { ReactNode } from "react";
import { AuthProvider } from "../../src/context/auth-context.js";
import { BrandingProvider } from "../../src/context/branding-context.js";
import type {
AuthConfig,
TenantBranding,
} from "../../src/types/branding.types.js";
export interface FormFetchCall {
url: string;
init?: RequestInit;
}
export function makeFetch(
handler: (call: FormFetchCall) => Response | Promise<Response>
): {
fetch: (
input: string | URL | Request,
init?: RequestInit
) => Promise<Response>;
calls: FormFetchCall[];
} {
const calls: FormFetchCall[] = [];
return {
calls,
fetch: async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
calls.push({ url, init });
return handler({ url, init });
},
};
}
export function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
export const FORM_TEST_BRANDING: TenantBranding = {
tenantId: "test",
name: "Test Co",
primaryColor: "#3B82F6",
secondaryColor: "#10B981",
logo: "https://cdn/logo.svg",
logoLight: "https://cdn/logo-light.svg",
};
export interface FormWrapperOptions {
authConfig?: Partial<AuthConfig> | undefined;
navigated?: string[] | undefined;
}
const sharedI18n = createI18n({
defaultLocale: "en-GB",
messages: { "en-GB": {} },
});
export function FormWrapper({
children,
options = {},
}: {
children: ReactNode;
options?: FormWrapperOptions;
}): ReactNode {
const fetchImpl =
options.authConfig?.fetch ?? makeFetch(() => jsonResponse({})).fetch;
const navigate =
options.authConfig?.navigate ??
((url: string): void => {
options.navigated?.push(url);
});
const config: AuthConfig = {
authUrl: "https://auth.example.com",
...options.authConfig,
fetch: fetchImpl,
navigate,
};
return (
<TranslationProvider i18n={sharedI18n}>
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
<BrandingProvider staticBranding={FORM_TEST_BRANDING}>
<AuthProvider config={config} initialUser={null}>
{children}
</AuthProvider>
</BrandingProvider>
</ThemeProvider>
</TranslationProvider>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Minimal happy-dom polyfills for `@suaveplan/react-auth-whitelabel` tests.
*
* happy-dom from `catalog:testing` already provides a sufficient DOM for
* the controlled-input forms and contexts in this package, but the
* `BrandingProvider` redirects `document.title`, favicon link, and
* `<style id="tenant-custom-css">` writes through APIs that happy-dom
* implements partially. We do not need ResizeObserver or matchMedia.
*
* The file exists so the vitest setup pipeline can extend it later
* without churning the config — currently it is intentionally a no-op.
*/
export {};

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@suaveplan/typescript-config/react-library.json",
"compilerOptions": {
"rootDir": "./",
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx", "vite.config.ts", "vitest.config.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@suaveplan/typescript-config/react-library.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.stories.tsx",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,53 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/react-auth-whitelabel",
plugins: [
react(),
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"components/index": "src/components/index.ts",
"context/index": "src/context/index.ts",
"hooks/index": "src/hooks/index.ts",
"errors/index": "src/errors/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/react-i18n",
"@suaveplan/react-ui",
"@suaveplan/react-ui/theme",
"@suaveplan/types",
"react",
"react-dom",
"react/jsx-runtime",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,53 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
globals: false,
environment: "happy-dom",
pool: "forks",
fileParallelism: false,
server: {
deps: {
inline: [
"@suaveplan/i18n",
"@suaveplan/react-i18n",
"@suaveplan/react-ui",
"@opentelemetry/api",
],
},
},
setupFiles: [
"@suaveplan/testing/polyfills",
"./tests/setup/polyfills.ts",
],
include: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"tests/**/*.test.ts",
"tests/**/*.test.tsx",
],
exclude: ["e2e/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.types.ts",
"src/**/*.stories.tsx",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});