Initial Commit
This commit is contained in:
106
packages/ui/react/react-auth-whitelabel/README.md
Normal file
106
packages/ui/react/react-auth-whitelabel/README.md
Normal 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`.
|
||||
|
||||
22
packages/ui/react/react-auth-whitelabel/e2e/README.md
Normal file
22
packages/ui/react/react-auth-whitelabel/e2e/README.md
Normal 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
|
||||
```
|
||||
87
packages/ui/react/react-auth-whitelabel/package.json
Normal file
87
packages/ui/react/react-auth-whitelabel/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LoginForm } from "./login-form.js";
|
||||
export type { LoginFormProps, LoginFormValues } from "./login-form.types.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`.
|
||||
@@ -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=");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(() => ({}));
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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`.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
15
packages/ui/react/react-auth-whitelabel/src/context/index.ts
Normal file
15
packages/ui/react/react-auth-whitelabel/src/context/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
REACT_AUTH_WL_CODES,
|
||||
type ReactAuthWhitelabelCode,
|
||||
} from "./error-codes.js";
|
||||
export { ReactAuthWhitelabelError } from "./react-auth-whitelabel-error.js";
|
||||
@@ -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"`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
10
packages/ui/react/react-auth-whitelabel/src/hooks/index.ts
Normal file
10
packages/ui/react/react-auth-whitelabel/src/hooks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
useAuth,
|
||||
useIsAuthenticated,
|
||||
useUser,
|
||||
} from "../context/auth-context.js";
|
||||
export {
|
||||
useBranding,
|
||||
useLogo,
|
||||
useTenantBranding,
|
||||
} from "../context/branding-context.js";
|
||||
59
packages/ui/react/react-auth-whitelabel/src/index.ts
Normal file
59
packages/ui/react/react-auth-whitelabel/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
12
packages/ui/react/react-auth-whitelabel/src/types/index.ts
Normal file
12
packages/ui/react/react-auth-whitelabel/src/types/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {};
|
||||
11
packages/ui/react/react-auth-whitelabel/tsconfig.json
Normal file
11
packages/ui/react/react-auth-whitelabel/tsconfig.json
Normal 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"]
|
||||
}
|
||||
17
packages/ui/react/react-auth-whitelabel/tsconfig.lib.json
Normal file
17
packages/ui/react/react-auth-whitelabel/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
53
packages/ui/react/react-auth-whitelabel/vite.config.ts
Normal file
53
packages/ui/react/react-auth-whitelabel/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
53
packages/ui/react/react-auth-whitelabel/vitest.config.ts
Normal file
53
packages/ui/react/react-auth-whitelabel/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user