Initial Commit

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

View File

@@ -0,0 +1,126 @@
# @suaveplan/react-auth - React authentication core: Better-Auth client wrapper, AuthProvider context, AuthGuard, useAuthGuard/usePermission hooks, locale packs.
React authentication core for Suaveplan apps — a typed wrapper around `better-auth/client`, an `AuthProvider` React context, an `AuthGuard` route component, `useAuthGuard` and `usePermission` hooks, a `betterAuthAdapter` response bridge, OTel instruments, a `REACT_AUTH_CODES` error registry, and shipped translation packs for `en-GB`, `cy-GB`, and `zh-CN`. Wave 1 (this package) ships the foundational core that downstream packages (the parallel `react-auth-whitelabel`, and consumer apps) build on; form components (Wave 2) and page components (Wave 3) layer on top without changing this surface.
<!-- begin:auto-docs -->
## Documentation tree
- [client](./src/client/auth-client.md) — `auth-client.ts` ships a typed `AuthClient` class plus the `createAuthClient(config)` factory that wraps `better-auth/c…
- [auth-client](./src/client/auth-client.md) — `auth-client.ts` ships a typed `AuthClient` class plus the `createAuthClient(config)` factory that wraps `better-auth/c…
- [components](./src/components/test-wrap.md) — Internal test helper consumed only by the Wave 2 form `.test.tsx` files in `src/components/<form>/`. The helper wraps a…
- [auth-guard](./src/components/auth-guard/auth-guard.md) — `<AuthGuard>` is the declarative route gate. It reads the current auth state from the surrounding `AuthProvider`, evalu…
- [auth-guard](./src/components/auth-guard/auth-guard.md) — `<AuthGuard>` is the declarative route gate. It reads the current auth state from the surrounding `AuthProvider`, evalu…
- [auth-provider](./src/components/auth-provider/auth-provider.md) — `auth-provider.tsx` is a thin back-compatibility re-export shim that forwards `AuthContext`, `AuthProvider`, `useAuth`,…
- [auth-provider](./src/components/auth-provider/auth-provider.md) — `auth-provider.tsx` is a thin back-compatibility re-export shim that forwards `AuthContext`, `AuthProvider`, `useAuth`,…
- [email-verification-view](./src/components/email-verification-view/email-verification-view.md) — `<EmailVerificationView>` is the pure-UI surface that renders the four states of an email-verification flow — `verifyin…
- [email-verification-view](./src/components/email-verification-view/email-verification-view.md) — `<EmailVerificationView>` is the pure-UI surface that renders the four states of an email-verification flow — `verifyin…
- [forgot-password-form](./src/components/forgot-password-form/forgot-password-form.md) — `<ForgotPasswordForm>` is the pure-UI surface for requesting a password-reset email. It owns the email input, the Zod v…
- [forgot-password-form](./src/components/forgot-password-form/forgot-password-form.md) — `<ForgotPasswordForm>` is the pure-UI surface for requesting a password-reset email. It owns the email input, the Zod v…
- [login-form](./src/components/login-form/login-form.md) — `<LoginForm>` is a pure-UI sign-in form used by the Wave 3 `<Login>` page (and any custom consumer). It owns the render…
- [login-form](./src/components/login-form/login-form.md) — `<LoginForm>` is a pure-UI sign-in form used by the Wave 3 `<Login>` page (and any custom consumer). It owns the render…
- [permission-gate](./src/components/permission-gate/permission-gate.md) — `<PermissionGate>` is a declarative conditional renderer that consumes the Wave 1 `usePermission()` hook and decides wh…
- [permission-gate](./src/components/permission-gate/permission-gate.md) — `<PermissionGate>` is a declarative conditional renderer that consumes the Wave 1 `usePermission()` hook and decides wh…
- [register-form](./src/components/register-form/register-form.md) — `<RegisterForm>` is the pure-UI surface for account creation. It renders an identity grid (email + username + password…
- [register-form-fields](./src/components/register-form/register-form-fields.md) — `<RegisterFormFields>` is an internal helper that owns the responsive identity-grid layout used by `<RegisterForm>`. It…
- [register-form](./src/components/register-form/register-form.md) — `<RegisterForm>` is the pure-UI surface for account creation. It renders an identity grid (email + username + password…
- [reset-password-form](./src/components/reset-password-form/reset-password-form.md) — `<ResetPasswordForm>` is the pure-UI surface for setting a new password after the user clicks a reset link. It owns thr…
- [reset-password-form](./src/components/reset-password-form/reset-password-form.md) — `<ResetPasswordForm>` is the pure-UI surface for setting a new password after the user clicks a reset link. It owns thr…
- [test-wrap](./src/components/test-wrap.md) — Internal test helper consumed only by the Wave 2 form `.test.tsx` files in `src/components/<form>/`. The helper wraps a…
- [context](./src/context/auth-context.md) — `auth-context.tsx` exposes the React Context surface for the auth flow. It defines `AuthContext`, the `AuthProvider` co…
- [auth-context](./src/context/auth-context.md) — `auth-context.tsx` exposes the React Context surface for the auth flow. It defines `AuthContext`, the `AuthProvider` co…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the stable error-code registry for `@suaveplan/react-auth` plus the ten typed `ReactAuthError`…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the stable error-code registry for `@suaveplan/react-auth` plus the ten typed `ReactAuthError`…
- [use-auth-guard](./src/hooks/use-auth-guard.md) — `useAuthGuard(options)` is the imperative counterpart to the declarative `<AuthGuard>` component. It reads the current…
- [use-permission](./src/hooks/use-permission.md) — `usePermission()` exposes a bitwise permission-check API over the current `user.roles` bitmask. The package ships a clo…
- [lib](./src/lib/better-auth-adapter.md) — `better-auth-adapter.ts` provides the response-narrowing bridge between `better-auth/client` (which returns a `BetterAu…
- [better-auth-adapter](./src/lib/better-auth-adapter.md) — `better-auth-adapter.ts` provides the response-narrowing bridge between `better-auth/client` (which returns a `BetterAu…
- [observability](./src/observability/react-auth-metrics.md) — `react-auth-metrics.ts` builds the OTel instrument set for `@suaveplan/react-auth`. Instruments are constructed lazily…
- [react-auth-metrics](./src/observability/react-auth-metrics.md) — `react-auth-metrics.ts` builds the OTel instrument set for `@suaveplan/react-auth`. Instruments are constructed lazily…
- [email-verification](./src/pages/email-verification/email-verification.md) — `<EmailVerificationPage>` is the routing-aware adapter that wraps the Wave 2 `<EmailVerificationView>` for the `?token=…
- [email-verification](./src/pages/email-verification/email-verification.md) — `<EmailVerificationPage>` is the routing-aware adapter that wraps the Wave 2 `<EmailVerificationView>` for the `?token=…
- [forgot-password](./src/pages/forgot-password/forgot-password.md) — `<ForgotPasswordPage>` is the routing-aware adapter that wraps the Wave 2 `<ForgotPasswordForm>` with the navigation an…
- [forgot-password](./src/pages/forgot-password/forgot-password.md) — `<ForgotPasswordPage>` is the routing-aware adapter that wraps the Wave 2 `<ForgotPasswordForm>` with the navigation an…
- [login](./src/pages/login/login.md) — `<LoginPage>` is the routing-aware adapter that wraps the Wave 2 `<LoginForm>` with the navigation and auth-context wir…
- [login](./src/pages/login/login.md) — `<LoginPage>` is the routing-aware adapter that wraps the Wave 2 `<LoginForm>` with the navigation and auth-context wir…
- [profile](./src/pages/profile/profile.md) — `<ProfilePage>` is the routing-aware profile-settings surface for an authenticated user. It renders three tabs — Profil…
- [profile-account-section](./src/pages/profile/profile-account-section.md) — `profile-account-section.tsx` houses the delete-account tab renderer for `<ProfilePage>`. It is split out of `profile-s…
- [profile-sections](./src/pages/profile/profile-sections.md) — `profile-sections.tsx` houses the two read/write tab renderers for `<ProfilePage>`: `<ProfileSection>` (name + email up…
- [profile](./src/pages/profile/profile.md) — `<ProfilePage>` is the routing-aware profile-settings surface for an authenticated user. It renders three tabs — Profil…
- [register](./src/pages/register/register.md) — `<RegisterPage>` is the routing-aware adapter that wraps the Wave 2 `<RegisterForm>` with the navigation and auth-conte…
- [register](./src/pages/register/register.md) — `<RegisterPage>` is the routing-aware adapter that wraps the Wave 2 `<RegisterForm>` with the navigation and auth-conte…
- [reset-password](./src/pages/reset-password/reset-password.md) — `<ResetPasswordPage>` is the routing-aware adapter that wraps the Wave 2 `<ResetPasswordForm>` for the `?token=…`-beari…
- [reset-password](./src/pages/reset-password/reset-password.md) — `<ResetPasswordPage>` is the routing-aware adapter that wraps the Wave 2 `<ResetPasswordForm>` for the `?token=…`-beari…
- [cy-gb](./src/translations/cy-gb.md) — `cy-gb.ts` ships the Welsh (Cymraeg, `cy-GB`) translation pack for the auth surface. The file conforms structurally to…
- [en-gb](./src/translations/en-gb.md) — `en-gb.ts` ships the British-English string table consumed by the Wave 2 forms and Wave 3 page components. The file als…
- [zh-cn](./src/translations/zh-cn.md) — `zh-cn.ts` ships the Simplified-Chinese (`zh-CN`) translation pack for the auth surface. The file conforms structurally…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/react-auth
```
Peer requirements: `react@^19`, `react-dom@^19`, `react-router-dom@^7`.
## Quick start
```tsx
import {
AuthProvider,
AuthGuard,
createAuthClient,
useAuth,
} from "@suaveplan/react-auth";
const client = createAuthClient({
baseURL: "https://auth.example.com",
siteDomain: "tenant-a",
});
export function App({ children }: { children: React.ReactNode }) {
return (
<AuthProvider config={{ baseURL: "https://auth.example.com" }} client={client}>
<AuthGuard requireAuth requireVerified fallback={<LoginRedirect />}>
{children}
</AuthGuard>
</AuthProvider>
);
}
function Profile() {
const { user, signOut } = useAuth();
return (
<>
<h1>{user?.email}</h1>
<button onClick={() => signOut()}>Sign out</button>
</>
);
}
```
## Modules
- [`src/client/auth-client.md`](./src/client/auth-client.md) — typed wrapper around `better-auth/client`, propagates `x-site-domain`.
- [`src/context/auth-context.md`](./src/context/auth-context.md) — `AuthProvider` + `useAuth` + 11 actions.
- [`src/components/auth-guard/auth-guard.md`](./src/components/auth-guard/auth-guard.md) — declarative gate component.
- [`src/components/auth-provider/auth-provider.md`](./src/components/auth-provider/auth-provider.md) — back-compat re-export of the context surface.
- [`src/hooks/use-auth-guard.md`](./src/hooks/use-auth-guard.md) — imperative `react-router-dom` redirect counterpart.
- [`src/hooks/use-permission.md`](./src/hooks/use-permission.md) — bitwise permission check (16 flags).
- [`src/lib/better-auth-adapter.md`](./src/lib/better-auth-adapter.md) — narrows `better-auth` response unions to internal shapes.
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `REACT_AUTH_CODES` registry + 10 typed error classes.
- [`src/observability/react-auth-metrics.md`](./src/observability/react-auth-metrics.md) — counters + histogram under `suaveplan.react-auth`.
- [`src/translations/en-gb.md`](./src/translations/en-gb.md) — British English locale pack + `AuthTranslationPack` shape.
- [`src/translations/cy-gb.md`](./src/translations/cy-gb.md) — Welsh locale pack.
- [`src/translations/zh-cn.md`](./src/translations/zh-cn.md) — Simplified Chinese locale pack.
- [`src/types/auth.types.md`](./src/types/auth.types.md) — `User`, `Session`, credential / response shapes.
- [`src/types/crud.types.md`](./src/types/crud.types.md) — multi-tenant CRUD shapes (Site, UserSettings, Friend, …).
## Wave scope
This package is the **Wave 1** core. The Wave 2 forms (`LoginForm`, `RegisterForm`, `ForgotPasswordForm`, `ResetPasswordForm`, `EmailVerifyForm`) and Wave 3 page components (`Login`, `Register`, `ForgotPassword`, `ResetPassword`, `EmailVerify`, `Profile`, `PermissionGate`) extend this core without changing it.
## Observability
All instrumented call paths emit through `metrics.getMeter("suaveplan.react-auth")`. Counters: `react_auth.sign_in.total`, `react_auth.sign_up.total`, `react_auth.sign_out.total`, `react_auth.guard.allow.total`, `react_auth.guard.deny.total`. Histogram: `react_auth.session_fetch.duration_ms`.

View File

@@ -0,0 +1,222 @@
{
"name": "@suaveplan/react-auth",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "React authentication core: Better-Auth client wrapper, AuthProvider context, AuthGuard, useAuthGuard/usePermission hooks, locale packs.",
"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"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./client/auth-client": {
"import": {
"types": "./dist/client/auth-client.d.ts",
"default": "./dist/client/auth-client.js"
}
},
"./lib/better-auth-adapter": {
"import": {
"types": "./dist/lib/better-auth-adapter.d.ts",
"default": "./dist/lib/better-auth-adapter.js"
}
},
"./context/auth-context": {
"import": {
"types": "./dist/context/auth-context.d.ts",
"default": "./dist/context/auth-context.js"
}
},
"./hooks/use-auth-guard": {
"import": {
"types": "./dist/hooks/use-auth-guard.d.ts",
"default": "./dist/hooks/use-auth-guard.js"
}
},
"./hooks/use-permission": {
"import": {
"types": "./dist/hooks/use-permission.d.ts",
"default": "./dist/hooks/use-permission.js"
}
},
"./components/auth-guard": {
"import": {
"types": "./dist/components/auth-guard/auth-guard.d.ts",
"default": "./dist/components/auth-guard/auth-guard.js"
}
},
"./components/auth-provider": {
"import": {
"types": "./dist/components/auth-provider/auth-provider.d.ts",
"default": "./dist/components/auth-provider/auth-provider.js"
}
},
"./components/login-form": {
"import": {
"types": "./dist/components/login-form/login-form.d.ts",
"default": "./dist/components/login-form/login-form.js"
}
},
"./components/register-form": {
"import": {
"types": "./dist/components/register-form/register-form.d.ts",
"default": "./dist/components/register-form/register-form.js"
}
},
"./components/forgot-password-form": {
"import": {
"types": "./dist/components/forgot-password-form/forgot-password-form.d.ts",
"default": "./dist/components/forgot-password-form/forgot-password-form.js"
}
},
"./components/reset-password-form": {
"import": {
"types": "./dist/components/reset-password-form/reset-password-form.d.ts",
"default": "./dist/components/reset-password-form/reset-password-form.js"
}
},
"./components/email-verification-view": {
"import": {
"types": "./dist/components/email-verification-view/email-verification-view.d.ts",
"default": "./dist/components/email-verification-view/email-verification-view.js"
}
},
"./components/permission-gate": {
"import": {
"types": "./dist/components/permission-gate/permission-gate.d.ts",
"default": "./dist/components/permission-gate/permission-gate.js"
}
},
"./pages/login": {
"import": {
"types": "./dist/pages/login/login.d.ts",
"default": "./dist/pages/login/login.js"
}
},
"./pages/register": {
"import": {
"types": "./dist/pages/register/register.d.ts",
"default": "./dist/pages/register/register.js"
}
},
"./pages/forgot-password": {
"import": {
"types": "./dist/pages/forgot-password/forgot-password.d.ts",
"default": "./dist/pages/forgot-password/forgot-password.js"
}
},
"./pages/reset-password": {
"import": {
"types": "./dist/pages/reset-password/reset-password.d.ts",
"default": "./dist/pages/reset-password/reset-password.js"
}
},
"./pages/email-verification": {
"import": {
"types": "./dist/pages/email-verification/email-verification.d.ts",
"default": "./dist/pages/email-verification/email-verification.js"
}
},
"./pages/profile": {
"import": {
"types": "./dist/pages/profile/profile.d.ts",
"default": "./dist/pages/profile/profile.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./translations": {
"import": {
"types": "./dist/translations/index.d.ts",
"default": "./dist/translations/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/react-auth-metrics.d.ts",
"default": "./dist/observability/react-auth-metrics.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@hookform/resolvers": "catalog:forms",
"@suaveplan/error": "workspace:^",
"@suaveplan/react-ui": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"better-auth": "catalog:auth",
"lucide-react": "catalog:ui",
"react-hook-form": "catalog:forms",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@suaveplan/i18n": "workspace:^",
"@suaveplan/react-i18n": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@testing-library/jest-dom": "catalog:testing",
"@testing-library/react": "catalog:testing",
"@testing-library/user-event": "catalog:testing",
"@types/node": "catalog:",
"@types/react": "catalog:testing",
"@types/react-dom": "catalog:testing",
"@vitejs/plugin-react": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing",
"happy-dom": "catalog:testing",
"react": "catalog:testing",
"react-dom": "catalog:testing",
"react-router-dom": "catalog:react",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,82 @@
# `auth-client`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`auth-client.ts` ships a typed `AuthClient` class plus the `createAuthClient(config)` factory that wraps `better-auth/client`. The wrapper centralises three concerns the underlying client leaves to the caller: (1) consistent injection of an optional `x-site-domain` header across every request, used for multi-tenant routing on the auth server; (2) a structural surface that does not depend on the dynamic plugin generic graph emitted by `better-auth` (each method we call is typed as an optional callable so plugin presence is detected at runtime and reported as a no-op when absent rather than as a compile-time error); and (3) normalisation of the `listSessions` payload from native `Date` to ISO string for serialisation safety. Configuration is validated through Zod at the boundary and a `ReactAuthConfigInvalidError` is thrown with code `REACT_AUTH.CONFIG_INVALID` when the URL or site domain is malformed.
## Features
- `AuthClient` class + `createAuthClient(config)` factory.
- Optional `siteDomain` propagates as the `x-site-domain` header on every call.
- Optional `fetch` injection seam for testing (no `vi.mock` required).
- Zod-validated config; invalid input throws `ReactAuthConfigInvalidError`.
- `listSessions()` normalises `Date` fields to ISO strings.
- Every method is plugin-aware: when the underlying client lacks the method it returns `undefined` (or `[]` for `listSessions`).
## Usage: Basic
```ts
import { createAuthClient } from "@suaveplan/react-auth";
const client = createAuthClient({
baseURL: "https://auth.example.com",
siteDomain: "tenant-a",
});
await client.signIn({ email: "a@b.com", password: "secret" });
const session = await client.getSession();
```
## Usage: Advanced
Inject a custom `fetch` (e.g. a recorder or a happy-path stub) without ever mocking the `better-auth` module:
```ts
import { createAuthClient } from "@suaveplan/react-auth";
const captured: Request[] = [];
const recordingFetch: typeof fetch = async (input, init) => {
const req = new Request(input, init);
captured.push(req);
return new Response(JSON.stringify({ data: null }), { status: 200 });
};
const client = createAuthClient({
baseURL: "https://auth.example.com",
siteDomain: "tenant-b",
fetch: recordingFetch,
});
await client.getSession();
expect(captured[0].headers.get("x-site-domain")).toBe("tenant-b");
```
## API Reference
- `interface AuthClientConfig` — `baseURL: string` (URL), `siteDomain?: string`, `fetch?: typeof fetch`.
- `class AuthClient`
- `constructor(config: AuthClientConfig)` — validates `config`, throws `ReactAuthConfigInvalidError` on failure.
- `signIn(credentials: LoginCredentials): Promise<unknown>`
- `signUp(data: RegisterData): Promise<unknown>`
- `signOut(): Promise<unknown>`
- `getSession(): Promise<unknown>`
- `forgotPassword(email: string): Promise<unknown>`
- `resetPassword(data: { token: string; password: string }): Promise<unknown>`
- `changePassword(data: ChangePasswordData): Promise<unknown>`
- `verifyEmail(token: string): Promise<unknown>`
- `resendVerificationEmail(email: string): Promise<unknown>`
- `updateProfile(data: UpdateProfileData): Promise<unknown>`
- `deleteAccount(password: string): Promise<unknown>`
- `listSessions(): Promise<Session[]>` — Date fields ISO-normalised.
- `revokeSession(sessionId: string): Promise<unknown>`
- `revokeAllSessions(): Promise<unknown>`
- `getRawClient(): BetterAuthClientLike` — escape hatch for plugin-specific access.
- `function createAuthClient(config: AuthClientConfig): AuthClient` — thin factory.
## Implementation Notes
The client does not throw on missing plugin methods. The upstream package emitted a single `@ts-expect-error` to work around `forgetPassword` not being statically typed; the wrapper replaces that with a structural interface (`BetterAuthClientLike`) where every method is optional. This keeps the call sites compile-safe while also covering deployments where the auth server registers a different plugin set. `listSessions` returns `[]` when no `data` array is present so callers can render an empty state without branching on `undefined`. The `x-site-domain` header is computed per-call from `this.config.siteDomain` so that future hot-swaps of the client config (out of scope for Wave 1) automatically pick up the new value. Constructor-time validation uses Zod with `z.string().url()` for `baseURL` — failing fast catches misconfiguration during app boot rather than on first request.

View File

@@ -0,0 +1,257 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AuthClient, createAuthClient } from "./auth-client.js";
interface CapturedCall {
method: string;
input: Record<string, unknown>;
}
function buildRawClient(
captured: CapturedCall[],
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
const wrap =
(method: string, result: unknown = { data: { status: true } }) =>
async (input: Record<string, unknown>) => {
captured.push({ method, input });
return result;
};
return {
signIn: { email: wrap("signIn", { data: { user: {} } }) },
signUp: { email: wrap("signUp", { data: { user: {} } }) },
signOut: wrap("signOut"),
getSession: wrap("getSession", { data: null }),
forgetPassword: wrap("forgetPassword"),
resetPassword: wrap("resetPassword"),
changePassword: wrap("changePassword"),
verifyEmail: wrap("verifyEmail"),
sendVerificationEmail: wrap("sendVerificationEmail"),
updateUser: wrap("updateUser"),
deleteUser: wrap("deleteUser"),
listSessions: wrap("listSessions", { data: [] }),
revokeSession: wrap("revokeSession"),
revokeSessions: wrap("revokeSessions"),
...overrides,
};
}
describe("AuthClient", () => {
it("throws ReactAuthConfigInvalidError when baseURL is not a valid URL", {
timeout: 5000,
}, () => {
expect(() => createAuthClient({ baseURL: "not-a-url" })).toThrow();
});
it("propagates x-site-domain header on every method when siteDomain is configured", {
timeout: 5000,
}, async () => {
const captured: CapturedCall[] = [];
const client = createAuthClient({
baseURL: "https://auth.example.com",
siteDomain: "tenant-a",
rawClient: buildRawClient(captured),
});
await client.signIn({
email: "a@b.com",
password: "p",
rememberMe: true,
});
await client.signUp({
email: "a@b.com",
password: "p",
username: "u",
firstName: "F",
});
await client.signOut();
await client.getSession();
await client.forgotPassword("a@b.com");
await client.resetPassword({ token: "t", password: "p" });
await client.changePassword({ currentPassword: "a", newPassword: "b" });
await client.verifyEmail("vt");
await client.resendVerificationEmail("a@b.com");
await client.updateProfile({ firstName: "X" });
await client.deleteAccount("p");
await client.listSessions();
await client.revokeSession("sid");
await client.revokeAllSessions();
for (const call of captured) {
const env = call.input as {
fetchOptions: { headers: Record<string, string> };
};
expect(env.fetchOptions.headers["x-site-domain"]).toBe("tenant-a");
}
expect(captured.length).toBe(14);
});
it("omits x-site-domain header when siteDomain is not configured", {
timeout: 5000,
}, async () => {
const captured: CapturedCall[] = [];
const client = createAuthClient({
baseURL: "https://auth.example.com",
rawClient: buildRawClient(captured),
});
await client.getSession();
const env = captured[0]?.input as {
fetchOptions: { headers: Record<string, string> };
};
expect(env.fetchOptions.headers["x-site-domain"]).toBeUndefined();
});
it("converts Date fields on listSessions to ISO strings", {
timeout: 5000,
}, async () => {
const captured: CapturedCall[] = [];
const client = createAuthClient({
baseURL: "https://auth.example.com",
rawClient: buildRawClient(captured, {
listSessions: async () => ({
data: [
{
id: "s1",
userId: "u1",
createdAt: new Date("2026-05-19T00:00:00.000Z"),
updatedAt: new Date("2026-05-19T00:00:00.000Z"),
expiresAt: new Date("2026-05-20T00:00:00.000Z"),
ipAddress: "127.0.0.1",
userAgent: "ua",
},
],
}),
}),
});
const sessions = await client.listSessions();
expect(sessions[0]?.createdAt).toBe("2026-05-19T00:00:00.000Z");
expect(sessions[0]?.updatedAt).toBe("2026-05-19T00:00:00.000Z");
expect(sessions[0]?.expiresAt?.toISOString()).toBe(
"2026-05-20T00:00:00.000Z"
);
expect(sessions[0]?.ipAddress).toBe("127.0.0.1");
expect(sessions[0]?.userAgent).toBe("ua");
});
it("normalises string-valued timestamps and missing optional fields on listSessions", {
timeout: 5000,
}, async () => {
const client = createAuthClient({
baseURL: "https://auth.example.com",
rawClient: buildRawClient([], {
listSessions: async () => ({
data: [
{
id: "s2",
userId: "u2",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
expiresAt: "2026-02-01T00:00:00.000Z",
},
{},
],
}),
}),
});
const sessions = await client.listSessions();
expect(sessions[0]?.createdAt).toBe("2026-01-01T00:00:00.000Z");
expect(sessions[0]?.expiresAt?.toISOString()).toBe(
"2026-02-01T00:00:00.000Z"
);
expect(sessions[1]?.createdAt).toBe("");
expect(sessions[1]?.expiresAt).toBeUndefined();
expect(sessions[1]?.ipAddress).toBeUndefined();
});
it("returns empty array from listSessions when no data array is present", {
timeout: 5000,
}, async () => {
const client = createAuthClient({
baseURL: "https://auth.example.com",
rawClient: buildRawClient([], {
listSessions: async () => ({ data: null }),
}),
});
expect(await client.listSessions()).toEqual([]);
});
it("returns undefined / empty when a method is missing on the underlying client", {
timeout: 5000,
}, async () => {
const client = createAuthClient({
baseURL: "https://auth.example.com",
rawClient: {
signIn: {},
signUp: {},
},
});
expect(
await client.signIn({ email: "a@b.com", password: "p" })
).toBeUndefined();
expect(
await client.signUp({
email: "a@b.com",
password: "p",
username: "u",
})
).toBeUndefined();
expect(await client.signOut()).toBeUndefined();
expect(await client.getSession()).toBeUndefined();
expect(await client.forgotPassword("a@b.com")).toBeUndefined();
expect(
await client.resetPassword({ token: "t", password: "p" })
).toBeUndefined();
expect(
await client.changePassword({
currentPassword: "a",
newPassword: "b",
})
).toBeUndefined();
expect(await client.verifyEmail("v")).toBeUndefined();
expect(await client.resendVerificationEmail("a@b.com")).toBeUndefined();
expect(await client.updateProfile({})).toBeUndefined();
expect(await client.deleteAccount("p")).toBeUndefined();
expect(await client.listSessions()).toEqual([]);
expect(await client.revokeSession("sid")).toBeUndefined();
expect(await client.revokeAllSessions()).toBeUndefined();
});
it("uses username as the better-auth name when firstName is absent on signUp", {
timeout: 5000,
}, async () => {
const captured: CapturedCall[] = [];
const client = createAuthClient({
baseURL: "https://auth.example.com",
rawClient: buildRawClient(captured),
});
await client.signUp({
email: "a@b.com",
password: "p",
username: "alice",
});
const signUpCall = captured.find((c) => c.method === "signUp");
expect((signUpCall?.input as { name: string }).name).toBe("alice");
});
it("accepts a custom fetch implementation via config (constructs real client)", {
timeout: 5000,
}, () => {
const customFetch: typeof globalThis.fetch = async () =>
new Response("", { status: 200 });
const client = createAuthClient({
baseURL: "https://auth.example.com",
fetch: customFetch,
});
expect(client).toBeInstanceOf(AuthClient);
});
it("getRawClient exposes the underlying client (rawClient injection)", {
timeout: 5000,
}, () => {
const rawClient = { signIn: {} };
const client = createAuthClient({
baseURL: "https://auth.example.com",
rawClient,
});
expect(client.getRawClient()).toBe(rawClient);
});
});

View File

@@ -0,0 +1,314 @@
import { createAuthClient as createBetterAuthClient } from "better-auth/client";
import z from "zod";
import { ReactAuthConfigInvalidError } from "../error-codes/error-codes.js";
import type {
ChangePasswordData,
LoginCredentials,
RegisterData,
Session,
UpdateProfileData,
} from "../types/auth.types.js";
/** Runtime configuration for `AuthClient`. */
export interface AuthClientConfig {
/** Base URL of the better-auth endpoint (e.g. `https://auth.example.com`). */
readonly baseURL: string;
/** Optional `x-site-domain` header value (multi-tenant routing). */
readonly siteDomain?: string;
/** Optional fetch implementation injected for testing. */
readonly fetch?: typeof globalThis.fetch;
/** Optional pre-built `better-auth/client` shape (test injection seam). */
readonly rawClient?: unknown;
}
const AuthClientConfigSchema = z.object({
baseURL: z.string().url(),
siteDomain: z.string().min(1).optional(),
fetch: z.custom<typeof globalThis.fetch>().optional(),
rawClient: z.unknown().optional(),
});
/**
* Structural shape of the methods we consume from `better-auth/client`.
*
* Better-Auth derives the concrete shape from registered plugins at compile
* time. We type each method we call as an optional callable carrying the
* minimal request envelope. This avoids reaching into the upstream plugin
* generic graph while still rejecting argument-shape mistakes at the call
* site.
*/
interface BetterAuthFetchEnvelope {
readonly fetchOptions: {
readonly headers: Record<string, string>;
};
}
interface BetterAuthClientLike {
signIn?: {
readonly email?: (
input: BetterAuthFetchEnvelope & {
email: string;
password: string;
rememberMe?: boolean;
}
) => Promise<unknown>;
};
signUp?: {
readonly email?: (
input: BetterAuthFetchEnvelope & {
email: string;
password: string;
name: string;
}
) => Promise<unknown>;
};
signOut?: (input: BetterAuthFetchEnvelope) => Promise<unknown>;
getSession?: (input: BetterAuthFetchEnvelope) => Promise<unknown>;
forgetPassword?: (
input: BetterAuthFetchEnvelope & {
email: string;
redirectTo: string;
}
) => Promise<unknown>;
resetPassword?: (
input: BetterAuthFetchEnvelope & { newPassword: string }
) => Promise<unknown>;
changePassword?: (
input: BetterAuthFetchEnvelope & {
currentPassword: string;
newPassword: string;
}
) => Promise<unknown>;
verifyEmail?: (
input: BetterAuthFetchEnvelope & { query: { token: string } }
) => Promise<unknown>;
sendVerificationEmail?: (
input: BetterAuthFetchEnvelope & { email: string }
) => Promise<unknown>;
updateUser?: (
input: BetterAuthFetchEnvelope & { name?: string }
) => Promise<unknown>;
deleteUser?: (
input: BetterAuthFetchEnvelope & { password: string }
) => Promise<unknown>;
listSessions?: (input: BetterAuthFetchEnvelope) => Promise<unknown>;
revokeSession?: (
input: BetterAuthFetchEnvelope & { token: string }
) => Promise<unknown>;
revokeSessions?: (input: BetterAuthFetchEnvelope) => Promise<unknown>;
}
interface ListSessionsRow {
readonly id?: string;
readonly userId?: string;
readonly createdAt?: Date | string;
readonly updatedAt?: Date | string;
readonly expiresAt?: Date | string;
readonly ipAddress?: string;
readonly userAgent?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/** Typed wrapper over `better-auth/client` with `x-site-domain` propagation. */
export class AuthClient {
private readonly client: BetterAuthClientLike;
private readonly config: AuthClientConfig;
constructor(config: AuthClientConfig) {
const parsed = AuthClientConfigSchema.safeParse(config);
if (!parsed.success) {
throw new ReactAuthConfigInvalidError({
message: parsed.error.message,
cause: parsed.error,
});
}
this.config = config;
if (config.rawClient !== undefined) {
this.client = config.rawClient as BetterAuthClientLike;
} else {
this.client = createBetterAuthClient({
baseURL: this.config.baseURL,
...(this.config.fetch
? {
fetchOptions: {
customFetchImpl: this.config.fetch,
},
}
: {}),
}) as unknown as BetterAuthClientLike;
}
}
/** Build the fetchOptions header bag with optional site-domain. */
private headers(): BetterAuthFetchEnvelope {
const headers: Record<string, string> = {};
if (this.config.siteDomain) {
headers["x-site-domain"] = this.config.siteDomain;
}
return { fetchOptions: { headers } };
}
/** Sign in with email + password. */
async signIn(credentials: LoginCredentials): Promise<unknown> {
const email = this.client.signIn?.email;
if (!email) return undefined;
return email({
email: credentials.email,
password: credentials.password,
...(credentials.rememberMe !== undefined
? { rememberMe: credentials.rememberMe }
: {}),
...this.headers(),
});
}
/** Sign up with email + password (+ optional username/firstName). */
async signUp(data: RegisterData): Promise<unknown> {
const email = this.client.signUp?.email;
if (!email) return undefined;
return email({
email: data.email,
password: data.password,
name: data.firstName ?? data.username,
...this.headers(),
});
}
/** Sign out the current session. */
async signOut(): Promise<unknown> {
if (!this.client.signOut) return undefined;
return this.client.signOut(this.headers());
}
/** Fetch the current session. */
async getSession(): Promise<unknown> {
if (!this.client.getSession) return undefined;
return this.client.getSession(this.headers());
}
/** Request a password-reset email. */
async forgotPassword(email: string): Promise<unknown> {
if (!this.client.forgetPassword) return undefined;
return this.client.forgetPassword({
email,
redirectTo: `${this.config.baseURL}/reset-password`,
...this.headers(),
});
}
/** Reset the password using a reset token. */
async resetPassword(data: {
token: string;
password: string;
}): Promise<unknown> {
if (!this.client.resetPassword) return undefined;
return this.client.resetPassword({
newPassword: data.password,
...this.headers(),
});
}
/** Change the current user's password. */
async changePassword(data: ChangePasswordData): Promise<unknown> {
if (!this.client.changePassword) return undefined;
return this.client.changePassword({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
...this.headers(),
});
}
/** Verify an email address using a token. */
async verifyEmail(token: string): Promise<unknown> {
if (!this.client.verifyEmail) return undefined;
return this.client.verifyEmail({
query: { token },
...this.headers(),
});
}
/** Resend the verification email to a given address. */
async resendVerificationEmail(email: string): Promise<unknown> {
if (!this.client.sendVerificationEmail) return undefined;
return this.client.sendVerificationEmail({
email,
...this.headers(),
});
}
/** Update the current user's profile. */
async updateProfile(data: UpdateProfileData): Promise<unknown> {
if (!this.client.updateUser) return undefined;
return this.client.updateUser({
...(data.firstName !== undefined ? { name: data.firstName } : {}),
...this.headers(),
});
}
/** Delete the current user's account. */
async deleteAccount(password: string): Promise<unknown> {
if (!this.client.deleteUser) return undefined;
return this.client.deleteUser({
password,
...this.headers(),
});
}
/** List all active sessions, normalising Date fields to ISO strings. */
async listSessions(): Promise<Session[]> {
if (!this.client.listSessions) return [];
const result = await this.client.listSessions(this.headers());
if (!isRecord(result) || !Array.isArray(result.data)) return [];
return (result.data as ListSessionsRow[]).map((row) => {
const session: Session = {
id: row.id ?? "",
userId: row.userId ?? "",
createdAt:
row.createdAt instanceof Date
? row.createdAt.toISOString()
: (row.createdAt ?? ""),
updatedAt:
row.updatedAt instanceof Date
? row.updatedAt.toISOString()
: (row.updatedAt ?? ""),
};
if (row.expiresAt !== undefined) {
session.expiresAt =
row.expiresAt instanceof Date
? row.expiresAt
: new Date(row.expiresAt);
}
if (row.ipAddress !== undefined) session.ipAddress = row.ipAddress;
if (row.userAgent !== undefined) session.userAgent = row.userAgent;
return session;
});
}
/** Revoke a single session by token. */
async revokeSession(sessionId: string): Promise<unknown> {
if (!this.client.revokeSession) return undefined;
return this.client.revokeSession({
token: sessionId,
...this.headers(),
});
}
/** Revoke every active session for the current user. */
async revokeAllSessions(): Promise<unknown> {
if (!this.client.revokeSessions) return undefined;
return this.client.revokeSessions(this.headers());
}
/** Return the underlying better-auth client (escape hatch). */
getRawClient(): BetterAuthClientLike {
return this.client;
}
}
/** Factory for `AuthClient` matching the upstream contract. */
export function createAuthClient(config: AuthClientConfig): AuthClient {
return new AuthClient(config);
}

View File

@@ -0,0 +1,5 @@
export {
AuthClient,
type AuthClientConfig,
createAuthClient,
} from "./auth-client.js";

View File

@@ -0,0 +1,55 @@
# `auth-guard`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<AuthGuard>` is the declarative route gate. It reads the current auth state from the surrounding `AuthProvider`, evaluates four optional predicates (`requireAuth`, `requireGuest`, `requireVerified`, `allowedRoles`), and renders one of three things: the configured `loadingComponent` while the auth state is loading, the `children` if every predicate passes, or the `fallback` element (defaulting to `null`) when a predicate fails. Unlike `useAuthGuard`, the guard does not navigate the user — it composes into the render tree, so callers can show a different sub-tree (a sign-in CTA, a 403 message) without hitting the router. Every deny path increments the `react_auth.guard.deny.total` counter with a `predicate` attribute; the allow path increments `react_auth.guard.allow.total`. Props are Zod-validated; misuse throws `ReactAuthGuardPropsInvalidError`.
## Features
- Four predicates: `requireAuth`, `requireGuest`, `requireVerified`, `allowedRoles`.
- `loadingComponent` slot with a default `<Loader2>` spinner.
- `fallback` slot for declarative deny UI.
- `onUnauthorized` callback invoked on every deny path.
- OTel allow/deny counters labelled by predicate.
- Zod-validated props; misuse throws typed error.
## Usage: Basic
```tsx
import { AuthGuard } from "@suaveplan/react-auth";
<AuthGuard requireAuth>
<Dashboard />
</AuthGuard>;
```
## Usage: Advanced
```tsx
import { AuthGuard, defaultAccessConfigs } from "@suaveplan/react-auth";
<AuthGuard
{...defaultAccessConfigs.adminOnly}
loadingComponent={<CustomLoader />}
fallback={<AccessDeniedPanel />}
onUnauthorized={() => audit.log("admin_panel_denied")}
>
<AdminPanel />
</AuthGuard>;
```
## API Reference
- `interface AuthGuardProps` — `{ children: ReactNode; redirectTo?: string; requireAuth?: boolean; requireGuest?: boolean; requireVerified?: boolean; allowedRoles?: readonly string[]; loadingComponent?: ReactNode; fallback?: ReactNode; onUnauthorized?: () => void }`.
- `interface AccessConfig` — predicate-only block (no `children`), useful for sharing config across routes.
- `interface RouteAccessConfig` — `{ public; protected; guestOnly; verifiedOnly; adminOnly }`.
- `const defaultAccessConfigs: RouteAccessConfig` — pre-built configs.
- `function AuthGuard(props: AuthGuardProps): ReactNode`.
## Implementation Notes
The guard renders `loadingComponent ?? defaultLoading()` while `isLoading === true`. The default loader uses `lucide-react`'s `<Loader2>` and a tailwind-flavoured spinner; consumers wanting a different visual override via the `loadingComponent` prop without monkey-patching styles. Each predicate failure increments a distinct deny counter so dashboards can surface which check is rejecting traffic. The `onUnauthorized` callback is invoked before the deny render, so analytics fires even when `fallback === undefined` and the component falls through to `null`. The `requireGuest` branch is useful for login pages where an authenticated user should be bounced back to `/`; the deny render returns `null` so the caller-level `useAuthGuard` can handle the actual redirect.

View File

@@ -0,0 +1,132 @@
/**
* Storybook stories for `<AuthGuard>`. `@storybook/react` is NOT a dep
* of `@suaveplan/react-auth`; story-meta shapes are inlined.
*/
import type { ReactElement } from "react";
import { AuthContext } from "../../context/auth-context.js";
interface StoryMeta<TArgs> {
title: string;
component: unknown;
parameters?: Record<string, unknown>;
args?: Partial<TArgs>;
}
interface StoryObj<TComponent> {
args?: Partial<TComponent extends (p: infer P) => unknown ? P : never>;
render?: (
args: TComponent extends (p: infer P) => unknown ? P : never
) => ReactElement;
}
type Meta<T> = StoryMeta<T extends (p: infer P) => unknown ? P : never>;
import { createAuthClient } from "../../client/auth-client.js";
import type {
AuthContextValue,
AuthState,
} from "../../context/auth-context.js";
import { AuthGuard } from "./auth-guard.js";
const meta: Meta<typeof AuthGuard> = {
title: "Auth/Components/AuthGuard",
component: AuthGuard,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof AuthGuard>;
function makeCtx(state: Partial<AuthState>): AuthContextValue {
const noop = async () => {};
const noop2 = async (_a: unknown, _b?: unknown) => {};
return {
user: null,
session: null,
isLoading: false,
isAuthenticated: false,
error: null,
...state,
client: createAuthClient({ baseURL: "https://auth.example.com" }),
signIn: noop as AuthContextValue["signIn"],
signUp: noop as AuthContextValue["signUp"],
signOut: noop as AuthContextValue["signOut"],
updateProfile: noop as AuthContextValue["updateProfile"],
changePassword: noop as AuthContextValue["changePassword"],
forgotPassword: noop as AuthContextValue["forgotPassword"],
resetPassword: noop2 as AuthContextValue["resetPassword"],
verifyEmail: noop as AuthContextValue["verifyEmail"],
resendVerificationEmail:
noop as AuthContextValue["resendVerificationEmail"],
deleteAccount: noop as AuthContextValue["deleteAccount"],
refreshSession: noop as AuthContextValue["refreshSession"],
};
}
const authedUser = {
id: "u1",
username: "alice",
email: "alice@example.com",
emailVerified: true,
roles: 0,
status: 0,
role: "admin",
createdAt: "",
updatedAt: "",
} as const;
export const Default: Story = {
render: () => (
<AuthContext.Provider
value={makeCtx({
isAuthenticated: true,
user: { ...authedUser },
})}
>
<AuthGuard>
<div style={{ padding: 32 }}>Protected content</div>
</AuthGuard>
</AuthContext.Provider>
),
};
export const Playground: Story = {
args: {
requireAuth: true,
requireVerified: false,
allowedRoles: [],
},
render: (args) => (
<AuthContext.Provider
value={makeCtx({
isAuthenticated: true,
user: { ...authedUser },
})}
>
<AuthGuard {...args}>
<div style={{ padding: 32 }}>Playground content</div>
</AuthGuard>
</AuthContext.Provider>
),
};
export const Loading: Story = {
render: () => (
<AuthContext.Provider value={makeCtx({ isLoading: true })}>
<AuthGuard>
<div>Hidden</div>
</AuthGuard>
</AuthContext.Provider>
),
};
export const Denied: Story = {
render: () => (
<AuthContext.Provider value={makeCtx({ isAuthenticated: false })}>
<AuthGuard
requireAuth
fallback={<div style={{ padding: 32 }}>Please sign in</div>}
>
<div>Hidden</div>
</AuthGuard>
</AuthContext.Provider>
),
};

View File

@@ -0,0 +1,371 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { render, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { createAuthClient } from "../../client/auth-client.js";
import type {
AuthContextValue,
AuthState,
} from "../../context/auth-context.js";
import { AuthContext } from "../../context/auth-context.js";
import { ReactAuthGuardPropsInvalidError } from "../../error-codes/error-codes.js";
import {
getReactAuthMetricsState,
resetReactAuthMetricsState,
} from "../../observability/react-auth-metrics.js";
import { AuthGuard } from "./auth-guard.js";
function makeCtx(state: Partial<AuthState>): AuthContextValue {
const noop = async () => {};
const noop2 = async (_a: unknown, _b?: unknown) => {};
return {
user: null,
session: null,
isLoading: false,
isAuthenticated: false,
error: null,
...state,
client: createAuthClient({ baseURL: "https://auth.example.com" }),
signIn: noop as AuthContextValue["signIn"],
signUp: noop as AuthContextValue["signUp"],
signOut: noop as AuthContextValue["signOut"],
updateProfile: noop as AuthContextValue["updateProfile"],
changePassword: noop as AuthContextValue["changePassword"],
forgotPassword: noop as AuthContextValue["forgotPassword"],
resetPassword: noop2 as AuthContextValue["resetPassword"],
verifyEmail: noop as AuthContextValue["verifyEmail"],
resendVerificationEmail:
noop as AuthContextValue["resendVerificationEmail"],
deleteAccount: noop as AuthContextValue["deleteAccount"],
refreshSession: noop as AuthContextValue["refreshSession"],
};
}
function wrapCtx(value: AuthContextValue) {
return ({ children }: { children: ReactNode }) => (
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);
}
describe("AuthGuard", () => {
it("renders the default loader while loading", { timeout: 5000 }, () => {
const Wrap = wrapCtx(makeCtx({ isLoading: true }));
const { container } = render(
<Wrap>
<AuthGuard>
<span>hidden</span>
</AuthGuard>
</Wrap>
);
expect(container.querySelector('[role="status"]')).toBeTruthy();
});
it("renders a custom loadingComponent when provided", {
timeout: 5000,
}, () => {
const Wrap = wrapCtx(makeCtx({ isLoading: true }));
const { getByText } = render(
<Wrap>
<AuthGuard loadingComponent={<span>my-loader</span>}>
<span>hidden</span>
</AuthGuard>
</Wrap>
);
expect(getByText("my-loader")).toBeTruthy();
});
it("renders children when authenticated and no extra predicates are set", {
timeout: 5000,
}, async () => {
resetReactAuthMetricsState();
const Wrap = wrapCtx(
makeCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "u",
email: "a@b.com",
emailVerified: true,
roles: 0,
status: 0,
createdAt: "",
updatedAt: "",
},
})
);
const { findByText } = render(
<Wrap>
<AuthGuard>
<span>protected</span>
</AuthGuard>
</Wrap>
);
await findByText("protected");
await waitFor(() => {
expect(
getReactAuthMetricsState().guardAllowTotal.length
).toBeGreaterThan(0);
});
});
it("renders fallback when unauthenticated and requireAuth is set", {
timeout: 5000,
}, () => {
const Wrap = wrapCtx(makeCtx({ isAuthenticated: false }));
const { getByText } = render(
<Wrap>
<AuthGuard requireAuth fallback={<span>denied</span>}>
<span>protected</span>
</AuthGuard>
</Wrap>
);
expect(getByText("denied")).toBeTruthy();
});
it("returns null (no children) when fallback is omitted and requireAuth fails", {
timeout: 5000,
}, () => {
const onUnauthorized = (): void => {};
const Wrap = wrapCtx(makeCtx({ isAuthenticated: false }));
const { container } = render(
<Wrap>
<AuthGuard requireAuth onUnauthorized={onUnauthorized}>
<span>protected</span>
</AuthGuard>
</Wrap>
);
expect(container.textContent).toBe("");
});
it("denies when requireGuest is set and the user is authenticated", {
timeout: 5000,
}, () => {
const Wrap = wrapCtx(
makeCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "u",
email: "a@b.com",
emailVerified: true,
roles: 0,
status: 0,
createdAt: "",
updatedAt: "",
},
})
);
const { getByText } = render(
<Wrap>
<AuthGuard
requireAuth={false}
requireGuest
fallback={<span>guest-denied</span>}
>
<span>protected</span>
</AuthGuard>
</Wrap>
);
expect(getByText("guest-denied")).toBeTruthy();
});
it("denies when requireVerified is set and emailVerified is false", {
timeout: 5000,
}, () => {
const Wrap = wrapCtx(
makeCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "u",
email: "a@b.com",
emailVerified: false,
roles: 0,
status: 0,
createdAt: "",
updatedAt: "",
},
})
);
const { getByText } = render(
<Wrap>
<AuthGuard requireVerified fallback={<span>verify</span>}>
<span>protected</span>
</AuthGuard>
</Wrap>
);
expect(getByText("verify")).toBeTruthy();
});
it("denies when allowedRoles does not include the user role", {
timeout: 5000,
}, () => {
const Wrap = wrapCtx(
makeCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "u",
email: "a@b.com",
emailVerified: true,
roles: 0,
status: 0,
role: "user",
createdAt: "",
updatedAt: "",
},
})
);
const { getByText } = render(
<Wrap>
<AuthGuard
allowedRoles={["admin"]}
fallback={<span>roles-deny</span>}
>
<span>protected</span>
</AuthGuard>
</Wrap>
);
expect(getByText("roles-deny")).toBeTruthy();
});
it("renders null when a predicate fails and fallback is omitted (every branch)", {
timeout: 5000,
}, () => {
const cases: AuthContextValue[] = [
makeCtx({ isAuthenticated: false }), // requireAuth fail
makeCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "u",
email: "a@b.com",
emailVerified: true,
roles: 0,
status: 0,
createdAt: "",
updatedAt: "",
},
}), // requireGuest fail
makeCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "u",
email: "a@b.com",
emailVerified: false,
roles: 0,
status: 0,
createdAt: "",
updatedAt: "",
},
}), // requireVerified fail
makeCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "u",
email: "a@b.com",
emailVerified: true,
roles: 0,
status: 0,
role: "user",
createdAt: "",
updatedAt: "",
},
}), // allowedRoles fail
];
const guards = [
<AuthGuard key="auth" requireAuth>
<span>x</span>
</AuthGuard>,
<AuthGuard key="guest" requireAuth={false} requireGuest>
<span>x</span>
</AuthGuard>,
<AuthGuard key="verified" requireVerified>
<span>x</span>
</AuthGuard>,
<AuthGuard key="roles" allowedRoles={["admin"]}>
<span>x</span>
</AuthGuard>,
];
for (let i = 0; i < cases.length; i++) {
const Wrap = wrapCtx(cases[i] as AuthContextValue);
const { container } = render(<Wrap>{guards[i]}</Wrap>);
expect(container.textContent).toBe("");
}
});
it("allows access when allowedRoles matches the user role", {
timeout: 5000,
}, () => {
const Wrap = wrapCtx(
makeCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "u",
email: "a@b.com",
emailVerified: true,
roles: 0,
status: 0,
role: "admin",
createdAt: "",
updatedAt: "",
},
})
);
const { getByText } = render(
<Wrap>
<AuthGuard allowedRoles={["admin"]}>
<span>admin-content</span>
</AuthGuard>
</Wrap>
);
expect(getByText("admin-content")).toBeTruthy();
});
it("renders multiple children when access is granted", {
timeout: 5000,
}, () => {
const Wrap = wrapCtx(
makeCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "u",
email: "a@b.com",
emailVerified: true,
roles: 0,
status: 0,
createdAt: "",
updatedAt: "",
},
})
);
const { getByText } = render(
<Wrap>
<AuthGuard>
<span>one</span>
<span>two</span>
</AuthGuard>
</Wrap>
);
expect(getByText("one")).toBeTruthy();
expect(getByText("two")).toBeTruthy();
});
it("throws ReactAuthGuardPropsInvalidError when redirectTo is empty string", {
timeout: 5000,
}, () => {
const Wrap = wrapCtx(makeCtx({ isAuthenticated: false }));
expect(() =>
render(
<Wrap>
<AuthGuard redirectTo="">
<span>x</span>
</AuthGuard>
</Wrap>
)
).toThrow(ReactAuthGuardPropsInvalidError);
});
});

View File

@@ -0,0 +1,96 @@
import { Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import z from "zod";
import { useAuth } from "../../context/auth-context.js";
import { ReactAuthGuardPropsInvalidError } from "../../error-codes/error-codes.js";
import {
recordGuardAllow,
recordGuardDeny,
} from "../../observability/react-auth-metrics.js";
import type { AuthGuardProps } from "./auth-guard.types.js";
const AuthGuardPropsSchema = z.object({
redirectTo: z.string().min(1).optional(),
requireAuth: z.boolean().optional(),
requireGuest: z.boolean().optional(),
requireVerified: z.boolean().optional(),
allowedRoles: z.array(z.string()).optional(),
});
function defaultLoading(): ReactNode {
return (
<div
className="flex min-h-screen items-center justify-center"
role="status"
aria-label="Loading authentication status"
>
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
/**
* Declarative auth guard. Renders `children` when every configured
* predicate is satisfied; otherwise renders `fallback` (or `null`).
*/
export function AuthGuard({
children,
requireAuth = true,
requireGuest = false,
requireVerified = false,
allowedRoles = [],
loadingComponent,
fallback,
redirectTo,
onUnauthorized,
}: AuthGuardProps): ReactNode {
const parsed = AuthGuardPropsSchema.safeParse({
redirectTo,
requireAuth,
requireGuest,
requireVerified,
allowedRoles,
});
if (!parsed.success) {
throw new ReactAuthGuardPropsInvalidError({
message: parsed.error.message,
cause: parsed.error,
});
}
const { user, isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <>{loadingComponent ?? defaultLoading()}</>;
}
if (requireAuth && !isAuthenticated) {
recordGuardDeny("requireAuth");
onUnauthorized?.();
return fallback !== undefined ? fallback : null;
}
if (requireGuest && isAuthenticated) {
recordGuardDeny("requireGuest");
onUnauthorized?.();
return fallback !== undefined ? fallback : null;
}
if (requireVerified && user && !user.emailVerified) {
recordGuardDeny("requireVerified");
onUnauthorized?.();
return fallback !== undefined ? fallback : null;
}
if (allowedRoles.length > 0 && user) {
const ok = allowedRoles.some((role) => user.role === role);
if (!ok) {
recordGuardDeny("allowedRoles");
onUnauthorized?.();
return fallback !== undefined ? fallback : null;
}
}
recordGuardAllow("all");
return <>{children}</>;
}

View File

@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
/** Props accepted by `<AuthGuard>`. */
export interface AuthGuardProps {
/** Content rendered when access is granted. */
children: ReactNode;
/** Where to redirect when access is denied (consumed by `useAuthGuard`). */
redirectTo?: string;
/** Require an authenticated user (default: `true`). */
requireAuth?: boolean;
/** Require an unauthenticated (guest) user (e.g. login/register pages). */
requireGuest?: boolean;
/** Require `user.emailVerified`. */
requireVerified?: boolean;
/** Whitelist of `user.role` values. */
allowedRoles?: readonly string[];
/** Custom loading indicator (default: a centred spinner). */
loadingComponent?: ReactNode;
/** Custom denial UI (default: `null` to let the imperative guard navigate). */
fallback?: ReactNode;
/** Callback invoked when access is denied. */
onUnauthorized?: () => void;
}
/** Single-route access-configuration block (omits `children`). */
export interface AccessConfig {
requireAuth?: boolean;
requireGuest?: boolean;
requireVerified?: boolean;
allowedRoles?: readonly string[];
redirectTo?: string;
}
/** Aggregate access configuration for common route types. */
export interface RouteAccessConfig {
public: AccessConfig;
protected: AccessConfig;
guestOnly: AccessConfig;
verifiedOnly: AccessConfig;
adminOnly: AccessConfig;
}
/** Sensible defaults for the five route classes. */
export const defaultAccessConfigs: RouteAccessConfig = {
public: {
requireAuth: false,
requireGuest: false,
requireVerified: false,
allowedRoles: [],
},
protected: {
requireAuth: true,
requireGuest: false,
requireVerified: false,
allowedRoles: [],
},
guestOnly: {
requireAuth: false,
requireGuest: true,
requireVerified: false,
allowedRoles: [],
},
verifiedOnly: {
requireAuth: true,
requireGuest: false,
requireVerified: true,
allowedRoles: [],
},
adminOnly: {
requireAuth: true,
requireGuest: false,
requireVerified: true,
allowedRoles: ["admin", "super-admin"],
},
};

View File

@@ -0,0 +1,7 @@
export { AuthGuard } from "./auth-guard.js";
export {
type AccessConfig,
type AuthGuardProps,
defaultAccessConfigs,
type RouteAccessConfig,
} from "./auth-guard.types.js";

View File

@@ -0,0 +1,56 @@
# `auth-provider`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`auth-provider.tsx` is a thin back-compatibility re-export shim that forwards `AuthContext`, `AuthProvider`, `useAuth`, and the associated types from the canonical implementation at `src/context/auth-context.tsx`. Upstream consumers of `@suaveplan/react-auth` historically imported `AuthProvider` from `@suaveplan/react-auth` (re-exported through `components/auth-provider/auth-provider.tsx`). The migration keeps that path stable so apps porting from upstream continue to compile without touching imports. Internally everything points at the single implementation; there is no duplicate state machine, no parallel context, and no diverging behaviour. Tests, stories, and Wave-2 forms will all consume the same context value regardless of which import path the caller picks.
## Features
- Stable backward-compatible import path.
- Single source of truth — re-exports the canonical context.
- Identical runtime behaviour to the canonical surface.
## Usage: Basic
```tsx
import { AuthProvider } from "@suaveplan/react-auth/components/auth-provider";
<AuthProvider config={{ baseURL: "https://auth.example.com" }}>
<App />
</AuthProvider>;
```
## Usage: Advanced
```tsx
import {
AuthContext,
AuthProvider,
type AuthContextValue,
} from "@suaveplan/react-auth/components/auth-provider";
import { useContext } from "react";
function DebugBadge() {
const ctx = useContext(AuthContext) as AuthContextValue | undefined;
return <span>{ctx?.isAuthenticated ? "in" : "out"}</span>;
}
```
## API Reference
This module re-exports the following from `src/context/auth-context.tsx`:
- `AuthContext` — React Context handle.
- `type AuthContextValue` — full context payload.
- `AuthProvider` — provider component.
- `type AuthProviderProps` — provider prop shape.
- `type AuthState` — state-block shape.
- `useAuth` — context-reading hook.
## Implementation Notes
The re-export is a single `export { ... } from "../../context/auth-context.js"` statement so Tree-shaking sees both paths as the same module under `sideEffects: false`. No additional runtime code lives here — every symbol is forwarded by name with no namespace wrapping. The path exists purely for ergonomics: callers porting from upstream do not need to grep-replace import statements as part of the migration. Wave 2 form components and Wave 3 page components will continue to use the canonical `context/auth-context.js` path internally for their imports to keep the dependency tree flat.

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AuthProvider as CanonicalAuthProvider,
useAuth as canonicalUseAuth,
} from "../../context/auth-context.js";
import { AuthContext, AuthProvider, useAuth } from "./auth-provider.js";
describe("auth-provider re-export", () => {
it("AuthProvider re-export is identical to the canonical implementation", {
timeout: 5000,
}, () => {
expect(AuthProvider).toBe(CanonicalAuthProvider);
});
it("useAuth re-export is identical to the canonical implementation", {
timeout: 5000,
}, () => {
expect(useAuth).toBe(canonicalUseAuth);
});
it("AuthContext re-export is a React Context handle", {
timeout: 5000,
}, () => {
expect(AuthContext).toBeDefined();
expect(typeof AuthContext.Provider).toBe("object");
});
});

View File

@@ -0,0 +1,15 @@
/**
* Back-compatibility re-export of the canonical `AuthProvider`.
*
* Upstream consumers `import { AuthProvider } from "@suaveplan/react-auth"` —
* the canonical implementation lives in `src/context/auth-context.tsx`. This
* file forwards the symbol and its types so the import path remains stable.
*/
export {
AuthContext,
type AuthContextValue,
AuthProvider,
type AuthProviderProps,
type AuthState,
useAuth,
} from "../../context/auth-context.js";

View File

@@ -0,0 +1,8 @@
export {
AuthContext,
type AuthContextValue,
AuthProvider,
type AuthProviderProps,
type AuthState,
useAuth,
} from "./auth-provider.js";

View File

@@ -0,0 +1,118 @@
# EmailVerificationView
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<EmailVerificationView>` is the pure-UI surface that renders the four states of an email-verification flow — `verifying`, `success`, `expired`, `error` — with the right icon, copy, and CTA per state. It is a status view rather than a form: there are no inputs, no validation, just a deterministic switch from `status` to one of four cards. The page-level host owns the token-extraction, the API call against `useAuth().verifyEmail`, and the status transitions; the view is a dumb renderer that can also be reused inside admin re-verification flows or as a notification banner.
## Features
- Four discrete render branches keyed off the `status` prop, each with its own icon (`Loader2` / `CheckCircle2` / `AlertTriangle` / `XCircle`).
- Each branch ships a CTA wired to one of `onContinueToLogin` / `onRegisterAgain` / `onTryAgain` callbacks supplied by the host.
- The `error` branch additionally renders a destructive `<Alert>` with the same message for screen-reader prominence.
- The `error` retry CTA shows a spinner and is disabled while `isLoading` is true.
- Optional `message` prop overrides the default per-status description copy.
- `translations?: Partial<AuthEmailVerificationStrings>` for full Welsh / zh-CN coverage via Wave 1's translation packs.
- Always renders a "back to login" link except in the `verifying` state (where no action is meaningful yet).
## Usage: Basic
```tsx
import { EmailVerificationView } from "@suaveplan/react-auth";
import { BrowserRouter } from "react-router-dom";
export function VerifyEmailPage() {
return (
<BrowserRouter>
<EmailVerificationView status="verifying" />
</BrowserRouter>
);
}
```
## Usage: Advanced
Drive every transition from URL state + `useAuth().verifyEmail`:
```tsx
import {
EmailVerificationView,
useAuth,
} from "@suaveplan/react-auth";
import {
useEffect,
useState,
} from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
export function VerifyEmail() {
const { verifyEmail } = useAuth();
const navigate = useNavigate();
const [params] = useSearchParams();
const token = params.get("token");
const [status, setStatus] = useState<
"verifying" | "success" | "error" | "expired"
>("verifying");
const [message, setMessage] = useState("");
useEffect(() => {
if (token === null) {
setStatus("expired");
return;
}
verifyEmail({ token })
.then(() => setStatus("success"))
.catch((err: unknown) => {
setStatus("error");
setMessage(
err instanceof Error ? err.message : String(err)
);
});
}, [token, verifyEmail]);
return (
<EmailVerificationView
status={status}
message={message}
onContinueToLogin={() => navigate("/auth/login")}
onRegisterAgain={() => navigate("/auth/register")}
onTryAgain={() => setStatus("verifying")}
/>
);
}
```
## API Reference
### `EmailVerificationView(props: EmailVerificationViewProps): ReactElement`
| Prop | Type | Default | Description |
|---|---|---|---|
| `status` | `"verifying" \| "success" \| "error" \| "expired"` | — required | Which card to render. |
| `message` | `string` | `""` | Override for the default description copy. |
| `onContinueToLogin` | `() => void` | — | Fired by the success-status CTA. |
| `onRegisterAgain` | `() => void` | — | Fired by the expired-status CTA. |
| `onTryAgain` | `() => void` | — | Fired by the error-status retry CTA. |
| `isLoading` | `boolean` | `false` | Disables the error-status retry CTA. |
| `translations` | `Partial<AuthEmailVerificationStrings>` | `enGb.auth.emailVerification` | Override any subset of the 8 copy keys. |
### `EmailVerificationStatus`
```ts
type EmailVerificationStatus =
| "verifying"
| "success"
| "error"
| "expired";
```
## Implementation Notes
- Each card is implemented as a small internal component (`VerifyingCard`, `SuccessCard`, `ExpiredCard`, `ErrorCard`) and dispatched through a `switch`-style helper to keep the public file under the 300-line cap.
- The component intentionally does NOT consume `useTranslation` — Wave 1 dropped the `@suaveplan/react-translate` dependency, and Wave 2 follows that precedent. Translations are injected via the `translations` prop instead.
- The `verifying` card carries `role="status"` on the spinner wrapper for assistive-tech announcements.
- The error card duplicates the message in a destructive `<Alert>` so the failure is announced even when the CTA is off-screen on narrow viewports.
- No OTel instruments emitted from the view; metrics live on the Wave 1 `verifyEmail` action.

View File

@@ -0,0 +1,71 @@
/**
* Storybook stories for `<EmailVerificationView>`.
*/
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { cyGb } from "../../translations/index.js";
import { EmailVerificationView } from "./email-verification-view.js";
interface StoryMeta<TArgs> {
title: string;
component: unknown;
parameters?: Record<string, unknown>;
args?: Partial<TArgs>;
decorators?: Array<(story: () => ReactElement) => ReactElement>;
}
interface StoryObj<TComponent> {
args?: Partial<TComponent extends (p: infer P) => unknown ? P : never>;
}
type Meta<T> = StoryMeta<T extends (p: infer P) => unknown ? P : never>;
const meta: Meta<typeof EmailVerificationView> = {
title: "Auth/Forms/EmailVerificationView",
component: EmailVerificationView,
parameters: { layout: "fullscreen" },
decorators: [(Story) => <MemoryRouter>{Story()}</MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof EmailVerificationView>;
export const Default: Story = {
args: { status: "verifying" },
};
export const Playground: Story = {
args: {
status: "success",
message: "",
onContinueToLogin: () => {},
},
};
export const Success: Story = {
args: {
status: "success",
onContinueToLogin: () => {},
},
};
export const ErrorState: Story = {
args: {
status: "error",
message: "Token already consumed",
onTryAgain: () => {},
},
};
export const Expired: Story = {
args: {
status: "expired",
onRegisterAgain: () => {},
},
};
export const WelshCopy: Story = {
args: {
status: "success",
onContinueToLogin: () => {},
translations: cyGb.auth.emailVerification,
},
};

View File

@@ -0,0 +1,120 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { wrapForm } from "../test-wrap.js";
import { EmailVerificationView } from "./email-verification-view.js";
afterEach(cleanup);
function renderView(props: Parameters<typeof EmailVerificationView>[0]) {
return render(wrapForm(<EmailVerificationView {...props} />));
}
describe("EmailVerificationView", () => {
it("renders the verifying status with a spinner", { timeout: 5000 }, () => {
renderView({ status: "verifying" });
expect(document.querySelector('[role="status"]')).toBeTruthy();
expect(screen.getByText(/verifying/i)).toBeTruthy();
});
it("fires onContinueToLogin from the success CTA", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onContinueToLogin = vi.fn();
renderView({
status: "success",
onContinueToLogin,
});
await user.click(
screen.getByRole("button", { name: /back to sign in/i })
);
expect(onContinueToLogin).toHaveBeenCalledTimes(1);
});
it("fires onTryAgain from the error CTA", { timeout: 5000 }, async () => {
const user = userEvent.setup();
const onTryAgain = vi.fn();
renderView({
status: "error",
onTryAgain,
});
await user.click(
screen.getByRole("button", {
name: /resend verification email/i,
})
);
expect(onTryAgain).toHaveBeenCalledTimes(1);
});
it("fires onRegisterAgain from the expired CTA", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onRegisterAgain = vi.fn();
renderView({
status: "expired",
onRegisterAgain,
});
await user.click(
screen.getByRole("button", {
name: /resend verification email/i,
})
);
expect(onRegisterAgain).toHaveBeenCalledTimes(1);
});
it("disables the retry CTA in error mode when isLoading is true", {
timeout: 5000,
}, () => {
renderView({
status: "error",
isLoading: true,
});
const btn = screen.getByRole("button", {
name: /resend verification email/i,
});
expect(btn.hasAttribute("disabled")).toBe(true);
});
it("overrides the default copy when translations prop is supplied", {
timeout: 5000,
}, () => {
renderView({
status: "success",
translations: { backToLogin: "Connexion" },
});
expect(screen.getByRole("button", { name: /connexion/i })).toBeTruthy();
});
it("renders a custom message in the success card when provided", {
timeout: 5000,
}, () => {
renderView({
status: "success",
message: "Welcome aboard, Alice!",
});
expect(screen.getByText(/welcome aboard, alice/i)).toBeTruthy();
});
it("renders a custom message in the error card when provided", {
timeout: 5000,
}, () => {
renderView({
status: "error",
message: "Token already consumed",
});
const matches = screen.getAllByText(/token already consumed/i);
expect(matches.length).toBeGreaterThan(0);
});
it("renders a custom message in the expired card when provided", {
timeout: 5000,
}, () => {
renderView({
status: "expired",
message: "Link expired 7 days ago",
});
expect(screen.getByText(/link expired 7 days ago/i)).toBeTruthy();
});
});

View File

@@ -0,0 +1,249 @@
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@suaveplan/react-ui";
import { AlertTriangle, CheckCircle2, Loader2, XCircle } from "lucide-react";
import type { ReactElement } from "react";
import { Link } from "react-router-dom";
import enGb from "../../translations/en-gb.js";
import type {
EmailVerificationStatus,
EmailVerificationViewProps,
} from "./email-verification-view.types.js";
const baseStrings = enGb.auth.emailVerification;
interface RenderArgs {
status: EmailVerificationStatus;
message: string;
isLoading: boolean;
onContinueToLogin?: (() => void) | undefined;
onRegisterAgain?: (() => void) | undefined;
onTryAgain?: (() => void) | undefined;
t: typeof baseStrings;
}
function VerifyingCard({ t }: { t: typeof baseStrings }): ReactElement {
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center space-y-2">
<div className="flex justify-center" role="status">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
<CardTitle className="text-2xl font-bold">
{t.verifying}
</CardTitle>
<CardDescription>{t.title}</CardDescription>
</CardHeader>
</Card>
);
}
function SuccessCard({
message,
onContinueToLogin,
t,
}: {
message: string;
onContinueToLogin?: (() => void) | undefined;
t: typeof baseStrings;
}): ReactElement {
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center space-y-2">
<div className="flex justify-center">
<CheckCircle2 className="h-12 w-12 text-green-500" />
</div>
<CardTitle className="text-2xl font-bold text-green-700">
{t.success}
</CardTitle>
<CardDescription className="text-green-600">
{message || t.success}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
onClick={onContinueToLogin}
className="w-full"
size="lg"
aria-label={t.backToLogin}
>
{t.backToLogin}
</Button>
</CardContent>
</Card>
);
}
function ExpiredCard({
message,
onRegisterAgain,
t,
}: {
message: string;
onRegisterAgain?: (() => void) | undefined;
t: typeof baseStrings;
}): ReactElement {
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center space-y-2">
<div className="flex justify-center">
<AlertTriangle className="h-12 w-12 text-amber-500" />
</div>
<CardTitle className="text-2xl font-bold text-amber-700">
{t.invalidToken}
</CardTitle>
<CardDescription className="text-amber-600">
{message || t.invalidToken}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
onClick={onRegisterAgain}
className="w-full"
size="lg"
aria-label={t.resend}
>
{t.resend}
</Button>
<div className="text-center">
<Link
to="/auth/login"
className="text-sm text-muted-foreground hover:text-primary underline-offset-4 hover:underline"
>
{t.backToLogin}
</Link>
</div>
</CardContent>
</Card>
);
}
function ErrorCard({
message,
isLoading,
onTryAgain,
t,
}: {
message: string;
isLoading: boolean;
onTryAgain?: (() => void) | undefined;
t: typeof baseStrings;
}): ReactElement {
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center space-y-2">
<div className="flex justify-center">
<XCircle className="h-12 w-12 text-destructive" />
</div>
<CardTitle className="text-2xl font-bold text-destructive">
{t.failed}
</CardTitle>
<CardDescription className="text-destructive">
{message || t.failed}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertDescription>{message || t.failed}</AlertDescription>
</Alert>
<Button
onClick={onTryAgain}
variant="outline"
className="w-full"
size="lg"
disabled={isLoading}
aria-label={t.resend}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.resend}
</>
) : (
t.resend
)}
</Button>
<div className="text-center">
<Link
to="/auth/login"
className="text-sm text-muted-foreground hover:text-primary underline-offset-4 hover:underline"
>
{t.backToLogin}
</Link>
</div>
</CardContent>
</Card>
);
}
function renderCard(args: RenderArgs): ReactElement {
switch (args.status) {
case "verifying":
return <VerifyingCard t={args.t} />;
case "success":
return (
<SuccessCard
message={args.message}
onContinueToLogin={args.onContinueToLogin}
t={args.t}
/>
);
case "expired":
return (
<ExpiredCard
message={args.message}
onRegisterAgain={args.onRegisterAgain}
t={args.t}
/>
);
default:
return (
<ErrorCard
message={args.message}
isLoading={args.isLoading}
onTryAgain={args.onTryAgain}
t={args.t}
/>
);
}
}
/**
* `<EmailVerificationView>` — pure UI status view rendering one of four
* email-verification states with appropriate icon, copy, and CTA.
*/
export function EmailVerificationView({
status,
message = "",
onContinueToLogin,
onRegisterAgain,
onTryAgain,
isLoading = false,
translations,
}: EmailVerificationViewProps): ReactElement {
const t = { ...baseStrings, ...translations };
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full space-y-8">
<div className="flex justify-center">
{renderCard({
status,
message,
isLoading,
onContinueToLogin,
onRegisterAgain,
onTryAgain,
t,
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import type { AuthEmailVerificationStrings } from "../../translations/en-gb.js";
/** Discrete UI status of the email-verification flow. */
export type EmailVerificationStatus =
| "verifying"
| "success"
| "error"
| "expired";
/** Props for the `<EmailVerificationView>` component. */
export interface EmailVerificationViewProps {
/** Current verification status. */
status: EmailVerificationStatus;
/** Optional message overriding the default status description. */
message?: string | undefined;
/** Invoked by the success-status CTA. */
onContinueToLogin?: (() => void) | undefined;
/** Invoked by the expired-status CTA. */
onRegisterAgain?: (() => void) | undefined;
/** Invoked by the error-status retry CTA. */
onTryAgain?: (() => void) | undefined;
/** When `true`, the error-status retry CTA is disabled. */
isLoading?: boolean | undefined;
/**
* Optional translation overrides. Falls back to
* `enGb.auth.emailVerification`.
*/
translations?: Partial<AuthEmailVerificationStrings> | undefined;
}

View File

@@ -0,0 +1,5 @@
export { EmailVerificationView } from "./email-verification-view.js";
export type {
EmailVerificationStatus,
EmailVerificationViewProps,
} from "./email-verification-view.types.js";

View File

@@ -0,0 +1,110 @@
# ForgotPasswordForm
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<ForgotPasswordForm>` is the pure-UI surface for requesting a password-reset email. It owns the email input, the Zod validation, the destructive error alert, the "send another email" success card, and the link back to sign-in — but it never imports the Wave 1 `AuthClient` or `useNavigate`. The page-level host wires `onSubmit` to `useAuth().forgotPassword` and flips `isSuccess` once the call resolves. This split keeps the form trivially testable and reusable inside modals, embedded widgets, or admin tools where routing is owned by the host.
## Features
- React Hook Form + `zodResolver` against a one-field `forgotPasswordSchema` (`email` required and well-formed).
- `mode: "onBlur"` — validation runs as the field is left, before the user smashes submit.
- Destructive `<Alert>` slot rendering the caller-supplied `Error.message`.
- Two render branches: the request form (default) and a success card (`isSuccess`) showing `emailSent` and an `onReset` "send another" button.
- Submit button disables and spins while either `isSubmitting` or `isLoading` is true.
- `translations?: Partial<AuthForgotPasswordStrings>` for full Welsh / zh-CN coverage via Wave 1's translation packs.
## Usage: Basic
```tsx
import { ForgotPasswordForm } from "@suaveplan/react-auth";
import { BrowserRouter } from "react-router-dom";
export function ForgotPasswordPage() {
return (
<BrowserRouter>
<ForgotPasswordForm
onSubmit={async ({ email }) => {
console.log("requesting reset for", email);
}}
/>
</BrowserRouter>
);
}
```
## Usage: Advanced
Wire to the Wave 1 `useAuth().forgotPassword` action with success-card
state and Welsh copy:
```tsx
import {
ForgotPasswordForm,
cyGb,
useAuth,
} from "@suaveplan/react-auth";
import { useState } from "react";
export function CymraegForgot() {
const { forgotPassword } = useAuth();
const [sentTo, setSentTo] = useState<string | null>(null);
const [error, setError] = useState<Error | null>(null);
return (
<ForgotPasswordForm
translations={cyGb.auth.forgotPassword}
error={error}
isSuccess={sentTo !== null}
emailSent={sentTo ?? ""}
onReset={() => {
setSentTo(null);
}}
onSubmit={async ({ email }) => {
setError(null);
try {
await forgotPassword({ email });
setSentTo(email);
} catch (caught) {
setError(
caught instanceof Error
? caught
: new Error(String(caught))
);
}
}}
/>
);
}
```
## API Reference
### `ForgotPasswordForm(props: ForgotPasswordFormProps): ReactElement`
| Prop | Type | Default | Description |
|---|---|---|---|
| `onSubmit` | `(data: ForgotPasswordFormData) => Promise<void> \| void` | — required | Invoked once Zod accepts the payload. |
| `error` | `Error \| null \| undefined` | `null` | Rendered in a destructive `<Alert>`. |
| `isLoading` | `boolean` | `false` | Disables the submit button. |
| `isSuccess` | `boolean` | `false` | Switches to the success card. |
| `emailSent` | `string` | `""` | Email shown in the success card. |
| `onReset` | `() => void` | — | Fired by the success-card "send another" button. |
| `translations` | `Partial<AuthForgotPasswordStrings>` | `enGb.auth.forgotPassword` | Override any subset of the 6 copy keys. |
### `ForgotPasswordFormData`
```ts
interface ForgotPasswordFormData {
email: string;
}
```
## Implementation Notes
- Both render branches reuse the same `<Card>` chassis so width / spacing remain stable across the request → success transition.
- The success card uses an `<Alert>` (non-destructive) to surface the "check your spam folder" hint rendered from the `translations.description` slot.
- `onReset` is optional — when the host wants the success card to be a dead-end (e.g. after rate-limit lockout), simply omit the prop and the button becomes a no-op.
- No OTel instruments are emitted from the form itself; metrics live on the Wave 1 `forgotPassword` action.

View File

@@ -0,0 +1,60 @@
/**
* Storybook stories for `<ForgotPasswordForm>`. `@storybook/react` is
* NOT a dep of `@suaveplan/react-auth`; story-meta shapes are inlined.
*/
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { cyGb } from "../../translations/index.js";
import { ForgotPasswordForm } from "./forgot-password-form.js";
interface StoryMeta<TArgs> {
title: string;
component: unknown;
parameters?: Record<string, unknown>;
args?: Partial<TArgs>;
decorators?: Array<(story: () => ReactElement) => ReactElement>;
}
interface StoryObj<TComponent> {
args?: Partial<TComponent extends (p: infer P) => unknown ? P : never>;
}
type Meta<T> = StoryMeta<T extends (p: infer P) => unknown ? P : never>;
const meta: Meta<typeof ForgotPasswordForm> = {
title: "Auth/Forms/ForgotPasswordForm",
component: ForgotPasswordForm,
parameters: { layout: "fullscreen" },
decorators: [(Story) => <MemoryRouter>{Story()}</MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof ForgotPasswordForm>;
export const Default: Story = {
args: { onSubmit: async () => {} },
};
export const Playground: Story = {
args: { onSubmit: async () => {}, error: null, isLoading: false },
};
export const Success: Story = {
args: {
onSubmit: async () => {},
isSuccess: true,
emailSent: "alice@example.com",
},
};
export const WithError: Story = {
args: {
onSubmit: async () => {},
error: new Error("We could not find an account with that email."),
},
};
export const WelshCopy: Story = {
args: {
onSubmit: async () => {},
translations: cyGb.auth.forgotPassword,
},
};

View File

@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { wrapForm } from "../test-wrap.js";
import { ForgotPasswordForm } from "./forgot-password-form.js";
afterEach(cleanup);
function renderForm(
props: Partial<Parameters<typeof ForgotPasswordForm>[0]> = {}
) {
const onSubmit = props.onSubmit ?? vi.fn(async () => {});
const view = render(
wrapForm(<ForgotPasswordForm {...props} onSubmit={onSubmit} />)
);
return { ...view, onSubmit };
}
describe("ForgotPasswordForm", () => {
it("blocks submit when email is invalid", { timeout: 5000 }, async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.click(
screen.getByRole("button", { name: /send reset link/i })
);
await waitFor(() => {
expect(screen.getByText(/invalid email address/i)).toBeTruthy();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("calls onSubmit with the typed payload on happy path", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onSubmit = vi.fn(async () => {});
renderForm({ onSubmit });
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
await user.click(
screen.getByRole("button", { name: /send reset link/i })
);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit).toHaveBeenCalledWith({
email: "alice@example.com",
});
});
it("renders the success view with emailSent and a working onReset button", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onReset = vi.fn();
render(
wrapForm(
<ForgotPasswordForm
onSubmit={async () => {}}
isSuccess
emailSent="alice@example.com"
onReset={onReset}
/>
)
);
expect(screen.getByText(/alice@example\.com/i)).toBeTruthy();
const btn = screen.getByRole("button", {
name: /send reset link/i,
});
await user.click(btn);
expect(onReset).toHaveBeenCalledTimes(1);
});
it("renders the error prop in a destructive Alert", {
timeout: 5000,
}, () => {
renderForm({ error: new Error("Account not found") });
expect(screen.getByText(/account not found/i)).toBeTruthy();
});
it("overrides default copy when translations prop is supplied", {
timeout: 5000,
}, () => {
renderForm({ translations: { submit: "Envoyer le lien" } });
expect(
screen.getByRole("button", { name: /envoyer le lien/i })
).toBeTruthy();
});
it("disables the submit button while isLoading is true", {
timeout: 5000,
}, () => {
renderForm({ isLoading: true });
const btn = screen.getByRole("button", {
name: /send reset link/i,
});
expect(btn.hasAttribute("disabled")).toBe(true);
});
});

View File

@@ -0,0 +1,173 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Input,
Label,
} from "@suaveplan/react-ui";
import { ArrowLeft, Loader2, Mail } from "lucide-react";
import type { ReactElement } from "react";
import { type Resolver, useForm } from "react-hook-form";
import { Link } from "react-router-dom";
import z from "zod";
import enGb from "../../translations/en-gb.js";
import type {
ForgotPasswordFormData,
ForgotPasswordFormProps,
} from "./forgot-password-form.types.js";
const forgotPasswordSchema = z.object({
email: z
.string()
.min(1, "Email is required")
.email("Invalid email address"),
});
type ForgotPasswordValues = z.infer<typeof forgotPasswordSchema>;
const baseStrings = enGb.auth.forgotPassword;
/**
* `<ForgotPasswordForm>` — pure UI single-field form requesting a
* password-reset email. Switches to a success card when `isSuccess`
* is true. Validation runs through `react-hook-form` + `zodResolver`.
*/
export function ForgotPasswordForm({
onSubmit,
error,
isLoading = false,
isSuccess = false,
emailSent = "",
onReset,
translations,
}: ForgotPasswordFormProps): ReactElement {
const t = { ...baseStrings, ...translations };
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ForgotPasswordValues>({
resolver: zodResolver(
forgotPasswordSchema
) as Resolver<ForgotPasswordValues>,
mode: "onBlur",
defaultValues: { email: "" },
});
const busy = isSubmitting || isLoading;
const submit = handleSubmit(async (values) => {
await onSubmit(values as ForgotPasswordFormData);
});
if (isSuccess) {
return (
<div className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Mail className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">
{t.success}
</CardTitle>
<CardDescription>{emailSent}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription>{t.description}</AlertDescription>
</Alert>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button
variant="outline"
className="w-full"
onClick={onReset}
aria-label={t.submit}
>
{t.submit}
</Button>
<Link
to="/auth/login"
className="text-center text-sm text-primary hover:underline"
>
{t.backToLogin}
</Link>
</CardFooter>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
{t.title}
</CardTitle>
<CardDescription className="text-center">
{t.description}
</CardDescription>
</CardHeader>
<form onSubmit={submit} noValidate>
<CardContent className="space-y-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>
{error.message}
</AlertDescription>
</Alert>
) : null}
<div className="space-y-2">
<Label htmlFor="email">{t.email}</Label>
<Input
id="email"
type="email"
autoComplete="email"
{...register("email")}
disabled={isSubmitting}
/>
{errors.email ? (
<p className="text-sm text-destructive">
{errors.email.message}
</p>
) : null}
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button
type="submit"
className="w-full"
disabled={busy}
aria-label={t.submit}
>
{busy ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.submit}
</>
) : (
t.submit
)}
</Button>
<Link
to="/auth/login"
className="flex items-center justify-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t.backToLogin}
</Link>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import type { AuthForgotPasswordStrings } from "../../translations/en-gb.js";
/** Validated payload emitted by `<ForgotPasswordForm>` on submit. */
export interface ForgotPasswordFormData {
email: string;
}
/** Props for the `<ForgotPasswordForm>` component. */
export interface ForgotPasswordFormProps {
/** Submit handler invoked with the validated payload. */
onSubmit: (data: ForgotPasswordFormData) => Promise<void> | void;
/** Error to display in a destructive `<Alert>`. */
error?: Error | null | undefined;
/** When `true`, the submit button is disabled and shows a spinner. */
isLoading?: boolean | undefined;
/** When `true`, the success card is rendered instead of the form. */
isSuccess?: boolean | undefined;
/** Email address shown in the success card. */
emailSent?: string | undefined;
/** Invoked when the success-view "send another" button is clicked. */
onReset?: (() => void) | undefined;
/**
* Optional translation overrides. Falls back to `enGb.auth.forgotPassword`.
*/
translations?: Partial<AuthForgotPasswordStrings> | undefined;
}

View File

@@ -0,0 +1,5 @@
export { ForgotPasswordForm } from "./forgot-password-form.js";
export type {
ForgotPasswordFormData,
ForgotPasswordFormProps,
} from "./forgot-password-form.types.js";

View File

@@ -0,0 +1,40 @@
export { AuthGuard } from "./auth-guard/auth-guard.js";
export {
type AccessConfig,
type AuthGuardProps,
defaultAccessConfigs,
type RouteAccessConfig,
} from "./auth-guard/auth-guard.types.js";
export {
AuthContext,
type AuthContextValue,
AuthProvider,
type AuthProviderProps,
type AuthState,
useAuth,
} from "./auth-provider/auth-provider.js";
export { EmailVerificationView } from "./email-verification-view/email-verification-view.js";
export type {
EmailVerificationStatus,
EmailVerificationViewProps,
} from "./email-verification-view/email-verification-view.types.js";
export { ForgotPasswordForm } from "./forgot-password-form/forgot-password-form.js";
export type {
ForgotPasswordFormData,
ForgotPasswordFormProps,
} from "./forgot-password-form/forgot-password-form.types.js";
export { LoginForm } from "./login-form/login-form.js";
export type { LoginFormProps } from "./login-form/login-form.types.js";
export type { PermissionGateProps } from "./permission-gate/permission-gate.js";
export { PermissionGate } from "./permission-gate/permission-gate.js";
export { RegisterForm } from "./register-form/register-form.js";
export type {
RegisterFormData,
RegisterFormProps,
RegisterFormSex,
} from "./register-form/register-form.types.js";
export { ResetPasswordForm } from "./reset-password-form/reset-password-form.js";
export type {
ResetPasswordFormData,
ResetPasswordFormProps,
} from "./reset-password-form/reset-password-form.types.js";

View File

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

View File

@@ -0,0 +1,110 @@
# LoginForm
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<LoginForm>` is a pure-UI sign-in form used by the Wave 3 `<Login>` page (and any custom consumer). It owns the rendering, validation, and accessibility of the email + password + remember-me input cluster, but never imports the Wave 1 `AuthClient` or `useNavigate`. Side effects — authentication, navigation, error toasts — belong to the page-level host that supplies `onSubmit`. This separation makes the form trivial to test in isolation and reusable inside non-router contexts such as modals or embedded widgets.
## Features
- React Hook Form with the `zodResolver` adapter against an internal `loginSchema` (email required + valid, password ≥ 8 chars, optional remember-me boolean).
- `mode: "onBlur"` validation — errors appear as the user leaves each field rather than after the first submit.
- Password show/hide toggle with an accessible `aria-label` flipping between "Show password" and "Hide password".
- Destructive `<Alert>` slot that renders any caller-supplied `Error`.
- Submit button shows a `Loader2` spinner when either internal `isSubmitting` or external `isLoading` is true.
- `translations?: Partial<AuthLoginStrings>` prop overrides the default English copy. Pass `cyGb.auth.login` or `zhCn.auth.login` from Wave 1's translation packs for full Welsh / Simplified-Chinese coverage.
- "Forgot password?" and "Sign up" links use `<Link>` from `react-router-dom`, so the form must mount inside a `<BrowserRouter>` (or `<MemoryRouter>` in tests).
## Usage: Basic
```tsx
import { LoginForm } from "@suaveplan/react-auth";
import { BrowserRouter } from "react-router-dom";
export function App() {
return (
<BrowserRouter>
<LoginForm
onSubmit={async ({ email, password }) => {
console.log("authenticating", email, password);
}}
/>
</BrowserRouter>
);
}
```
## Usage: Advanced
Wire the form to the Wave 1 `useAuth().signIn` action with optimistic error
surfacing and a translated copy pack:
```tsx
import { LoginForm, useAuth, cyGb } from "@suaveplan/react-auth";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
export function CymraegLogin() {
const { signIn } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);
return (
<LoginForm
translations={cyGb.auth.login}
error={error}
isLoading={loading}
onSubmit={async ({ email, password, rememberMe }) => {
setError(null);
setLoading(true);
try {
await signIn({ email, password, rememberMe });
navigate("/");
} catch (caught) {
setError(
caught instanceof Error
? caught
: new Error(String(caught))
);
} finally {
setLoading(false);
}
}}
/>
);
}
```
## API Reference
### `LoginForm(props: LoginFormProps): ReactElement`
| Prop | Type | Default | Description |
|---|---|---|---|
| `onSubmit` | `(data: LoginCredentials) => Promise<void> \| void` | — required | Invoked with the typed payload only after Zod validation passes. |
| `error` | `Error \| null \| undefined` | `null` | When non-null, rendered inside a destructive `<Alert>`. |
| `isLoading` | `boolean` | `false` | When `true` the submit button is disabled and spins. |
| `translations` | `Partial<AuthLoginStrings>` | `enGb.auth.login` | Override any subset of the 8 copy keys; missing keys fall back to the default English pack. |
### `LoginCredentials`
```ts
interface LoginCredentials {
email: string;
password: string;
rememberMe?: boolean;
}
```
Imported from `@suaveplan/react-auth` (Wave 1 surface).
## Implementation Notes
- The Zod schema is defined at module scope so the resolver instance is stable across renders — RHF does not re-build the validator on every mount.
- `useForm`'s generic is set to `z.infer<typeof loginSchema>` (the locally inferred shape) and the validated payload is `as LoginCredentials`-cast at the submit boundary. The two shapes share the same fields; the cast is documentation-grade, not a behavioural change.
- The password toggle uses `useState` rather than RHF state because it is purely presentational.
- The form renders inside a full-bleed `<Card>` (max-width `md`) sized to fit the Wave 3 `<Login>` page; embedded callers can wrap the form in their own container by setting `className` overrides on the parent.
- No OTel instruments are emitted from the form — metrics live on the Wave 1 `signIn` action.

View File

@@ -0,0 +1,70 @@
/**
* Storybook stories for `<LoginForm>`. `@storybook/react` is NOT a dep
* of `@suaveplan/react-auth`; story-meta shapes are inlined.
*/
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { cyGb } from "../../translations/index.js";
import { LoginForm } from "./login-form.js";
interface StoryMeta<TArgs> {
title: string;
component: unknown;
parameters?: Record<string, unknown>;
args?: Partial<TArgs>;
decorators?: Array<(story: () => ReactElement) => ReactElement>;
}
interface StoryObj<TComponent> {
args?: Partial<TComponent extends (p: infer P) => unknown ? P : never>;
render?: (
args: TComponent extends (p: infer P) => unknown ? P : never
) => ReactElement;
}
type Meta<T> = StoryMeta<T extends (p: infer P) => unknown ? P : never>;
const meta: Meta<typeof LoginForm> = {
title: "Auth/Forms/LoginForm",
component: LoginForm,
parameters: { layout: "fullscreen" },
decorators: [(Story) => <MemoryRouter>{Story()}</MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof LoginForm>;
export const Default: Story = {
args: {
onSubmit: async (data) => {
console.warn("Default submit", data);
},
},
};
export const Playground: Story = {
args: {
onSubmit: async () => {},
error: null,
isLoading: false,
},
};
export const WithError: Story = {
args: {
onSubmit: async () => {},
error: new Error("Invalid email or password"),
},
};
export const Loading: Story = {
args: {
onSubmit: async () => {},
isLoading: true,
},
};
export const WelshCopy: Story = {
args: {
onSubmit: async () => {},
translations: cyGb.auth.login,
},
};

View File

@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { wrapForm } from "../test-wrap.js";
import { LoginForm } from "./login-form.js";
afterEach(cleanup);
function renderForm(props: Partial<Parameters<typeof LoginForm>[0]> = {}) {
const onSubmit = props.onSubmit ?? vi.fn(async () => {});
const view = render(wrapForm(<LoginForm {...props} onSubmit={onSubmit} />));
return { ...view, onSubmit };
}
describe("LoginForm", () => {
it("shows Zod error when email is invalid", { timeout: 5000 }, async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.type(screen.getByLabelText(/^password$/i), "long-enough");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText(/invalid email address/i)).toBeTruthy();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows Zod error when password is shorter than 8 characters", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
await user.type(screen.getByLabelText(/^password$/i), "abc");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText(/at least 8 characters/i)).toBeTruthy();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("calls onSubmit with the typed payload on happy path", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onSubmit = vi.fn(async () => {});
renderForm({ onSubmit });
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
await user.type(
screen.getByLabelText(/^password$/i),
"correcthorsebatterystaple"
);
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit).toHaveBeenCalledWith({
email: "alice@example.com",
password: "correcthorsebatterystaple",
rememberMe: true,
});
});
it("renders the error prop in a destructive Alert", {
timeout: 5000,
}, () => {
renderForm({ error: new Error("Invalid credentials") });
expect(screen.getByText(/invalid credentials/i)).toBeTruthy();
});
it("overrides the submit copy when translations prop is supplied", {
timeout: 5000,
}, () => {
renderForm({
translations: { submit: "Connexion" },
});
expect(screen.getByRole("button", { name: /connexion/i })).toBeTruthy();
});
it("toggles password visibility on icon click", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
renderForm();
const pwd = screen.getByLabelText(/^password$/i);
expect(pwd.getAttribute("type")).toBe("password");
await user.click(
screen.getByRole("button", { name: /show password/i })
);
expect(pwd.getAttribute("type")).toBe("text");
await user.click(
screen.getByRole("button", { name: /hide password/i })
);
expect(pwd.getAttribute("type")).toBe("password");
});
it("toggles the remember-me checkbox when clicked", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onSubmit = vi.fn(async () => {});
renderForm({ onSubmit });
await user.click(
screen.getByRole("checkbox", { name: /remember me/i })
);
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
await user.type(
screen.getByLabelText(/^password$/i),
"correcthorsebatterystaple"
);
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit.mock.calls[0]?.[0]?.rememberMe).toBe(false);
});
it("disables the submit button while isLoading is true", {
timeout: 5000,
}, () => {
renderForm({ isLoading: true });
const btn = screen.getByRole("button", { name: /sign in/i });
expect(btn.hasAttribute("disabled")).toBe(true);
});
});

View File

@@ -0,0 +1,205 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Checkbox,
Input,
Label,
} from "@suaveplan/react-ui";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { type ReactElement, useState } from "react";
import { type Resolver, useForm } from "react-hook-form";
import { Link } from "react-router-dom";
import z from "zod";
import enGb from "../../translations/en-gb.js";
import type { LoginCredentials } from "../../types/auth.types.js";
import type { LoginFormProps } from "./login-form.types.js";
const loginSchema = z.object({
email: z
.string()
.min(1, "Email is required")
.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
rememberMe: z.boolean().optional(),
});
type LoginFormValues = z.infer<typeof loginSchema>;
const baseStrings = enGb.auth.login;
/**
* `<LoginForm>` — pure UI sign-in form with email, password, and
* remember-me. Validation runs through `react-hook-form` + `zodResolver`
* against `loginSchema`. Submission delegates to the caller-supplied
* `onSubmit`. The form never imports the Wave 1 `AuthClient` — wire
* navigation and side effects at the page layer.
*/
export function LoginForm({
onSubmit,
error,
isLoading = false,
translations,
}: LoginFormProps): ReactElement {
const t = { ...baseStrings, ...translations };
const [showPassword, setShowPassword] = useState(false);
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema) as Resolver<LoginFormValues>,
mode: "onBlur",
defaultValues: {
email: "",
password: "",
rememberMe: true,
},
});
const rememberMe = watch("rememberMe");
const busy = isSubmitting || isLoading;
const submit = handleSubmit(async (values) => {
await onSubmit(values as LoginCredentials);
});
return (
<div className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
{t.title}
</CardTitle>
<CardDescription className="text-center">
{t.title}
</CardDescription>
</CardHeader>
<form onSubmit={submit} noValidate>
<CardContent className="space-y-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>
{error.message}
</AlertDescription>
</Alert>
) : null}
<div className="space-y-2">
<Label htmlFor="email">{t.email}</Label>
<Input
id="email"
type="email"
autoComplete="email"
{...register("email")}
disabled={isSubmitting}
/>
{errors.email ? (
<p className="text-sm text-destructive">
{errors.email.message}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t.password}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
{...register("password")}
disabled={isSubmitting}
/>
<button
type="button"
aria-label={
showPassword
? "Hide password"
: "Show password"
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => {
setShowPassword((prev) => !prev);
}}
>
{showPassword ? (
<EyeOff size={18} />
) : (
<Eye size={18} />
)}
</button>
</div>
{errors.password ? (
<p className="text-sm text-destructive">
{errors.password.message}
</p>
) : null}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
id="rememberMe"
checked={rememberMe === true}
onCheckedChange={(checked) => {
setValue(
"rememberMe",
checked === true
);
}}
disabled={isSubmitting}
/>
<Label htmlFor="rememberMe">
{t.rememberMe}
</Label>
</div>
<Link
to="/auth/forgot-password"
className="text-sm text-primary hover:underline"
>
{t.forgotPassword}
</Link>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button
type="submit"
className="w-full"
disabled={busy}
aria-label={t.submit}
>
{busy ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.submit}
</>
) : (
t.submit
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
{t.noAccount}{" "}
<Link
to="/auth/register"
className="text-primary hover:underline"
>
{t.register}
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { AuthLoginStrings } from "../../translations/en-gb.js";
import type { LoginCredentials } from "../../types/auth.types.js";
/**
* Props for the `<LoginForm>` component. The form is pure UI — it
* never calls any auth client or router. The host wires the submit
* handler to `useAuth().signIn` (or a custom handler) and renders
* navigation links via Wave 1's `AuthGuard` or app routes.
*/
export interface LoginFormProps {
/** Submit handler invoked with the validated payload. */
onSubmit: (data: LoginCredentials) => Promise<void> | void;
/** Error to display in a destructive `<Alert>`. Pass `null` to hide. */
error?: Error | null | undefined;
/** When `true` the submit button is disabled and shows a spinner. */
isLoading?: boolean | undefined;
/**
* Optional translation overrides. Any field omitted falls back to
* the Wave 1 `enGb.auth.login` table. Wave 1's `cyGb` and `zhCn`
* packs ship with the matching keys — pass `cyGb.auth.login` to
* render the form in Welsh.
*/
translations?: Partial<AuthLoginStrings> | undefined;
}

View File

@@ -0,0 +1,2 @@
export type { PermissionGateProps } from "./permission-gate.js";
export { PermissionGate } from "./permission-gate.js";

View File

@@ -0,0 +1,71 @@
# `<PermissionGate>` — bitmask permission gate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<PermissionGate>` is a declarative conditional renderer that consumes the Wave 1 `usePermission()` hook and decides whether to render its children based on the current user's bitwise permission set. It is the primary tool for hiding UI surfaces (admin links, destructive buttons, advanced settings panels) from users who lack the required permission bits.
The component is intentionally tiny — every interesting policy decision is delegated to `usePermission()`, which in turn reads `useAuth().user.roles` and performs the bitwise AND against the configured permission constants from `Permissions`. Two semantics are supported: any-of (the default, useful for "show this if any one of these roles applies") and all-of (`requireAll`, useful for compound predicates such as "must have both moderator and admin").
## Features
- Any-of semantics by default; all-of via `requireAll`.
- Accepts named `Permission` keys (`"USER_READ"`, `"ADMIN_WRITE"`, ...) or raw numeric bitmasks.
- Fallback subtree (default `null`) rendered when the check fails.
- Unauthenticated users always fail the check — `usePermission()` returns `false` when `user === null`.
- Zero runtime cost beyond a single context read + bitwise AND.
## Usage: Basic
```tsx
import { PermissionGate } from "@suaveplan/react-auth";
export function AdminLink() {
return (
<PermissionGate permissions={["ADMIN_READ"]}>
<a href="/admin">Admin Console</a>
</PermissionGate>
);
}
```
## Usage: Advanced
```tsx
import { PermissionGate, Permissions } from "@suaveplan/react-auth";
export function ModeratorPanel() {
return (
<PermissionGate
permissions={[
Permissions.MODERATE_USERS,
Permissions.MODERATE_CONTENT,
]}
requireAll
fallback={<p>You do not have moderator access.</p>}
>
<ModeratorTools />
</PermissionGate>
);
}
```
## API Reference
### `PermissionGateProps`
| Field | Type | Default | Description |
|---|---|---|---|
| `children` | `ReactNode` | — | Subtree rendered when the permission check passes. |
| `fallback` | `ReactNode` | `null` | Subtree rendered when the permission check fails. |
| `permissions` | `ReadonlyArray<Permission \| number>` | `[]` | Permission keys or raw numeric bitmasks. |
| `requireAll` | `boolean` | `false` | When true, every entry must match (all-of). |
## Implementation Notes
- Empty `permissions` array combined with default `requireAll={false}` denies (because `[].some(...) === false`). This matches the upstream contract; pass `requireAll={true}` to allow on empty input.
- The gate does NOT throw when no `<AuthProvider>` is mounted — `usePermission()` does, surfacing the misuse at the call site rather than silently rendering the fallback.
- For route-level guarding, prefer `<AuthGuard>` (which redirects); `<PermissionGate>` is for in-page subtree hiding.

View File

@@ -0,0 +1,63 @@
/**
* Storybook stories for `<PermissionGate>`. `@storybook/react` is NOT a
* dep of `@suaveplan/react-auth`; story-meta shapes are inlined.
*/
import type { ReactElement } from "react";
import { PermissionGate } from "./permission-gate.js";
interface StoryMeta<TArgs> {
title: string;
component: unknown;
parameters?: Record<string, unknown>;
args?: Partial<TArgs>;
decorators?: Array<(story: () => ReactElement) => ReactElement>;
}
interface StoryObj<TComponent> {
args?: Partial<TComponent extends (p: infer P) => unknown ? P : never>;
render?: (
args: TComponent extends (p: infer P) => unknown ? P : never
) => ReactElement;
}
type Meta<T> = StoryMeta<T extends (p: infer P) => unknown ? P : never>;
const meta: Meta<typeof PermissionGate> = {
title: "Auth/Components/PermissionGate",
component: PermissionGate,
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj<typeof PermissionGate>;
export const Default: Story = {
args: {
permissions: ["USER_READ"],
children: <p>You can see this — you have USER_READ.</p>,
},
};
export const Playground: Story = {
args: {
permissions: ["ADMIN_READ"],
requireAll: false,
children: <p>Admin tools.</p>,
fallback: <p>Access denied.</p>,
},
};
export const Denied: Story = {
args: {
permissions: ["ADMIN_DELETE"],
children: <p>Should be hidden.</p>,
fallback: <p>Insufficient privileges.</p>,
},
};
export const RequireAll: Story = {
args: {
permissions: ["MODERATE_USERS", "MODERATE_CONTENT"],
requireAll: true,
children: <p>Both moderator bits required.</p>,
fallback: <p>You need both moderator bits.</p>,
},
};

View File

@@ -0,0 +1,153 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, render } from "@testing-library/react";
import type { ReactNode } from "react";
import { AuthContext } from "../../context/auth-context.js";
import { Permissions } from "../../hooks/use-permission.js";
import { makeAuthCtx } from "../test-wrap.js";
import { PermissionGate } from "./permission-gate.js";
afterEach(cleanup);
function authedUser(roles: number) {
return makeAuthCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "alice",
email: "alice@example.com",
emailVerified: true,
roles,
status: 0,
createdAt: "",
updatedAt: "",
},
});
}
function withCtx(ctxValue: ReturnType<typeof makeAuthCtx>, node: ReactNode) {
return <AuthContext.Provider value={ctxValue}>{node}</AuthContext.Provider>;
}
describe("PermissionGate", () => {
it("renders children when any-of matches", { timeout: 5000 }, () => {
const ctx = authedUser(Permissions.USER_READ);
const { getByText } = render(
withCtx(
ctx,
<PermissionGate permissions={["USER_READ", "ADMIN_WRITE"]}>
<span>visible</span>
</PermissionGate>
)
);
expect(getByText("visible")).toBeTruthy();
});
it("renders fallback when any-of fails", { timeout: 5000 }, () => {
const ctx = authedUser(Permissions.USER_READ);
const { getByText } = render(
withCtx(
ctx,
<PermissionGate
permissions={["ADMIN_DELETE"]}
fallback={<span>denied</span>}
>
<span>hidden</span>
</PermissionGate>
)
);
expect(getByText("denied")).toBeTruthy();
});
it("renders fallback when requireAll fails", { timeout: 5000 }, () => {
const ctx = authedUser(Permissions.USER_READ);
const { getByText } = render(
withCtx(
ctx,
<PermissionGate
permissions={["USER_READ", "ADMIN_WRITE"]}
requireAll
fallback={<span>denied-all</span>}
>
<span>hidden</span>
</PermissionGate>
)
);
expect(getByText("denied-all")).toBeTruthy();
});
it("renders children when requireAll succeeds", { timeout: 5000 }, () => {
const ctx = authedUser(
Permissions.MODERATE_USERS | Permissions.MODERATE_CONTENT
);
const { getByText } = render(
withCtx(
ctx,
<PermissionGate
permissions={["MODERATE_USERS", "MODERATE_CONTENT"]}
requireAll
>
<span>all-visible</span>
</PermissionGate>
)
);
expect(getByText("all-visible")).toBeTruthy();
});
it("accepts numeric permission", { timeout: 5000 }, () => {
const ctx = authedUser(Permissions.USER_READ);
const { getByText } = render(
withCtx(
ctx,
<PermissionGate permissions={[Permissions.USER_READ]}>
<span>num-visible</span>
</PermissionGate>
)
);
expect(getByText("num-visible")).toBeTruthy();
});
it("renders fallback when unauthenticated", { timeout: 5000 }, () => {
const ctx = makeAuthCtx();
const { getByText, queryByText } = render(
withCtx(
ctx,
<PermissionGate
permissions={["USER_READ"]}
fallback={<span>anon-denied</span>}
>
<span>hidden</span>
</PermissionGate>
)
);
expect(getByText("anon-denied")).toBeTruthy();
expect(queryByText("hidden")).toBeNull();
});
it("defaults permissions to empty array (any-of denies)", {
timeout: 5000,
}, () => {
const ctx = authedUser(Permissions.USER_READ);
const { container } = render(
withCtx(
ctx,
<PermissionGate fallback={<span>empty-deny</span>}>
<span>hidden</span>
</PermissionGate>
)
);
expect(container.textContent).toContain("empty-deny");
});
it("defaults fallback to null when omitted", { timeout: 5000 }, () => {
const ctx = authedUser(0);
const { container } = render(
withCtx(
ctx,
<PermissionGate permissions={["ADMIN_READ"]}>
<span>hidden</span>
</PermissionGate>
)
);
expect(container.textContent).toBe("");
});
});

View File

@@ -0,0 +1,39 @@
import type { ReactElement, ReactNode } from "react";
import { type Permission, usePermission } from "../../hooks/use-permission.js";
/** Props accepted by `<PermissionGate>`. */
export interface PermissionGateProps {
/** Subtree rendered when the permission check passes. */
children: ReactNode;
/** Subtree rendered when the permission check fails. Defaults to `null`. */
fallback?: ReactNode;
/**
* Permission requirements as named keys or raw numeric bitmasks.
* Empty array short-circuits to ANY-of `true` (always allow).
*/
permissions?: ReadonlyArray<Permission | number>;
/** When `true`, every entry must match; otherwise any-of semantics. */
requireAll?: boolean;
}
/**
* `<PermissionGate>` — conditionally renders `children` based on the current
* user's bitwise permission set as exposed by `usePermission()`. Renders
* `fallback` (default `null`) when the check fails. Default semantics are
* any-of; pass `requireAll` for all-of semantics. Named `Permission` keys and
* raw numeric bitmasks are both accepted in the `permissions` array.
*/
export function PermissionGate({
children,
fallback = null,
permissions = [],
requireAll = false,
}: PermissionGateProps): ReactElement {
const { hasAllPermissions, hasAnyPermission } = usePermission();
const hasAccess = requireAll
? hasAllPermissions(permissions)
: hasAnyPermission(permissions);
return <>{hasAccess ? children : fallback}</>;
}

View File

@@ -0,0 +1,6 @@
export { RegisterForm } from "./register-form.js";
export type {
RegisterFormData,
RegisterFormProps,
RegisterFormSex,
} from "./register-form.types.js";

View File

@@ -0,0 +1,75 @@
# RegisterFormFields
## Purpose
`<RegisterFormFields>` is an internal helper that owns the responsive identity-grid layout used by `<RegisterForm>`. It is extracted from the parent file so neither file exceeds the 300-line hard cap from `AGENTS.md §3`. The helper accepts every `react-hook-form` and translation prop it needs explicitly — it never touches `useForm` itself — so it remains a thin, dumb, framework-free renderer that is easy to inspect during code review.
## Features
- Renders the 10 form cells (email, username, password, confirm password, first name, surname, date of birth, sex select, gender, company) in a `grid-cols-1 sm:grid-cols-2` two-column responsive grid.
- Encapsulates the shared `<PasswordCell>` helper for the password + confirm password input + show/hide toggle pair.
- Accepts an explicit `t: AuthRegisterStrings` so all copy comes from the parent's merged translation table; the helper owns no defaults.
- Accepts `setValue` for the `sex` `<Select>` because Radix's controlled API does not surface through `register`.
## Usage: Basic
This helper is not exported from `@suaveplan/react-auth` — consumers should reach for `<RegisterForm>` directly. The internal call site looks like:
```tsx
<RegisterFormFields
register={register}
setValue={setValue}
errors={errors}
disabled={isSubmitting}
showPassword={showPassword}
showConfirmPassword={showConfirmPassword}
onTogglePassword={() => setShowPassword((v) => !v)}
onToggleConfirmPassword={() => setShowConfirmPassword((v) => !v)}
t={t}
/>
```
## Usage: Advanced
Although the helper is internal, the same pattern can be reused inside a custom registration form — for example a tenant-specific one that adds an `inviteCode` field. Wrap the helper, append your own field cells, and feed it your own `useForm` instance:
```tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { RegisterFormFields } from "@suaveplan/react-auth/components/register-form/register-form-fields";
// ...inside a custom form:
const form = useForm<MyExtendedRegisterValues>({
resolver: zodResolver(myExtendedSchema),
});
return (
<form onSubmit={form.handleSubmit(submit)}>
<RegisterFormFields {...buildProps(form)} t={t} />
<Input id="inviteCode" {...form.register("inviteCode")} />
</form>
);
```
(Note: this subpath is not part of the public API. The helper is documented here for code-reviewers; consumers should compose `<RegisterForm>` rather than the internal helper.)
## API Reference
### `RegisterFormFields(props: RegisterFormFieldsProps): ReactElement`
| Prop | Type | Description |
|---|---|---|
| `register` | `UseFormRegister<RegisterFormData>` | Forwarded from the parent `useForm`. |
| `setValue` | `UseFormSetValue<RegisterFormData>` | Used by the `sex` `<Select>` to write back into the form state. |
| `errors` | `FieldErrors<RegisterFormData>` | Per-field error messages. |
| `disabled` | `boolean` | Disables every input when `isSubmitting` is true. |
| `showPassword` | `boolean` | Toggle state for the password field. |
| `showConfirmPassword` | `boolean` | Toggle state for the confirm-password field. |
| `onTogglePassword` | `() => void` | Flip the password toggle. |
| `onToggleConfirmPassword` | `() => void` | Flip the confirm-password toggle. |
| `t` | `AuthRegisterStrings` | Fully-merged translation table (parent owns the merge with defaults). |
## Implementation Notes
- The internal `<PasswordCell>` is private to this file because it carries a layout assumption (the toggle button is absolutely positioned over the input) that does not generalise.
- The `sex` `<Select>` defaults to `"prefer-not-to-say"` and writes back through `setValue("sex", value)`. The Zod schema treats `sex` as optional so missing the field never blocks submission.
- No OTel instruments emitted; metrics live on the Wave 1 `signUp` action.

View File

@@ -0,0 +1,226 @@
import {
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@suaveplan/react-ui";
import { Eye, EyeOff } from "lucide-react";
import type { ReactElement } from "react";
import type {
FieldErrors,
UseFormRegister,
UseFormSetValue,
} from "react-hook-form";
import type { AuthRegisterStrings } from "../../translations/en-gb.js";
import type {
RegisterFormData,
RegisterFormSex,
} from "./register-form.types.js";
export interface RegisterFormFieldsProps {
register: UseFormRegister<RegisterFormData>;
setValue: UseFormSetValue<RegisterFormData>;
errors: FieldErrors<RegisterFormData>;
disabled: boolean;
showPassword: boolean;
showConfirmPassword: boolean;
onTogglePassword: () => void;
onToggleConfirmPassword: () => void;
t: AuthRegisterStrings;
}
interface PasswordCellProps {
id: "password" | "confirmPassword";
label: string;
autoComplete: string;
register: UseFormRegister<RegisterFormData>;
show: boolean;
onToggle: () => void;
error?: string | undefined;
disabled: boolean;
}
function PasswordCell({
id,
label,
autoComplete,
register,
show,
onToggle,
error,
disabled,
}: PasswordCellProps): ReactElement {
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}*</Label>
<div className="relative">
<Input
id={id}
type={show ? "text" : "password"}
autoComplete={autoComplete}
{...register(id)}
disabled={disabled}
/>
<button
type="button"
aria-label={
show
? `Hide ${label.toLowerCase()}`
: `Show ${label.toLowerCase()}`
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={onToggle}
>
{show ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
);
}
/**
* Renders the full registration field grid. Extracted from
* `<RegisterForm>` to keep the parent file under the 300-line cap (§3).
*/
export function RegisterFormFields({
register,
setValue,
errors,
disabled,
showPassword,
showConfirmPassword,
onTogglePassword,
onToggleConfirmPassword,
t,
}: RegisterFormFieldsProps): ReactElement {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="email">{t.email}*</Label>
<Input
id="email"
type="email"
autoComplete="email"
{...register("email")}
disabled={disabled}
/>
{errors.email ? (
<p className="text-sm text-destructive">
{errors.email.message}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="username">{t.username}*</Label>
<Input
id="username"
type="text"
autoComplete="username"
{...register("username")}
disabled={disabled}
/>
{errors.username ? (
<p className="text-sm text-destructive">
{errors.username.message}
</p>
) : null}
</div>
<PasswordCell
id="password"
label={t.password}
autoComplete="new-password"
register={register}
show={showPassword}
onToggle={onTogglePassword}
error={errors.password?.message}
disabled={disabled}
/>
<PasswordCell
id="confirmPassword"
label={t.confirmPassword}
autoComplete="new-password"
register={register}
show={showConfirmPassword}
onToggle={onToggleConfirmPassword}
error={errors.confirmPassword?.message}
disabled={disabled}
/>
<div className="space-y-2">
<Label htmlFor="firstName">{t.firstName}</Label>
<Input
id="firstName"
type="text"
autoComplete="given-name"
{...register("firstName")}
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="surname">{t.surname}</Label>
<Input
id="surname"
type="text"
autoComplete="family-name"
{...register("surname")}
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dob">{t.dateOfBirth}</Label>
<Input
id="dob"
type="date"
{...register("dob")}
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sex">{t.sex}</Label>
<Select
defaultValue="prefer-not-to-say"
onValueChange={(value: string) => {
setValue("sex", value as RegisterFormSex);
}}
disabled={disabled}
>
<SelectTrigger id="sex">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="prefer-not-to-say">
Prefer not to say
</SelectItem>
<SelectItem value="traditional-male">Male</SelectItem>
<SelectItem value="traditional-female">
Female
</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="gender">{t.gender}</Label>
<Input
id="gender"
type="text"
{...register("gender")}
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="company">{t.company}</Label>
<Input
id="company"
type="text"
autoComplete="organization"
{...register("company")}
disabled={disabled}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
# RegisterForm
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<RegisterForm>` is the pure-UI surface for account creation. It renders an identity grid (email + username + password + confirm + optional first-name / surname / dob / sex / gender / company), runs the entire input set through a single `registerSchema` (Zod), and emits one validated payload from `onSubmit`. The form never imports the Wave 1 `AuthClient` or `useNavigate` — the page-level host wires `onSubmit` to `useAuth().signUp` and handles post-registration redirects. This separation keeps the form testable in isolation and reusable inside onboarding tours, admin invite flows, and tenant-specific registration pages.
## Features
- React Hook Form + `zodResolver` with email validity, username regex (`/^[a-zA-Z0-9_]+$/`, length 3–30), password ≥ 8 chars (max 100), and a cross-field `confirmPassword` refinement.
- Default `sex` value `"prefer-not-to-say"` so the field is always submittable.
- Two-column responsive grid (`grid-cols-1 sm:grid-cols-2`) collapsing to a single column on narrow viewports.
- Password show/hide toggles on both password and confirm fields, each with discriminating `aria-label`s.
- Destructive `<Alert>` for caller-supplied `Error.message`.
- `translations?: Partial<AuthRegisterStrings>` for full Welsh / zh-CN coverage via Wave 1's translation packs.
- Implementation split across `register-form.tsx` and `register-form-fields.tsx` to honour the 300-line cap (§3); the split is invisible to consumers — only `RegisterForm` is exported.
## Usage: Basic
```tsx
import { RegisterForm } from "@suaveplan/react-auth";
import { BrowserRouter } from "react-router-dom";
export function RegisterPage() {
return (
<BrowserRouter>
<RegisterForm
onSubmit={async (data) => {
console.log("creating account", data);
}}
/>
</BrowserRouter>
);
}
```
## Usage: Advanced
Wire to `useAuth().signUp` with error capture, navigation on success, and
Welsh copy:
```tsx
import {
RegisterForm,
cyGb,
useAuth,
} from "@suaveplan/react-auth";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
export function CymraegRegister() {
const { signUp } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);
return (
<RegisterForm
translations={cyGb.auth.register}
error={error}
isLoading={loading}
onSubmit={async (data) => {
setError(null);
setLoading(true);
try {
await signUp({
email: data.email,
password: data.password,
username: data.username,
firstName: data.firstName,
surname: data.surname,
});
navigate("/auth/verify-email");
} catch (caught) {
setError(
caught instanceof Error
? caught
: new Error(String(caught))
);
} finally {
setLoading(false);
}
}}
/>
);
}
```
## API Reference
### `RegisterForm(props: RegisterFormProps): ReactElement`
| Prop | Type | Default | Description |
|---|---|---|---|
| `onSubmit` | `(data: RegisterFormData) => Promise<void> \| void` | — required | Invoked with the typed payload once Zod accepts. |
| `error` | `Error \| null \| undefined` | `null` | Rendered in a destructive `<Alert>`. |
| `isLoading` | `boolean` | `false` | Disables the submit button. |
| `translations` | `Partial<AuthRegisterStrings>` | `enGb.auth.register` | Override any subset of the 16 copy keys. |
### `RegisterFormData`
```ts
interface RegisterFormData {
email: string;
password: string;
confirmPassword: string;
username: string;
firstName?: string;
surname?: string;
dob?: string;
sex?: "prefer-not-to-say"
| "traditional-male"
| "traditional-female"
| "other";
gender?: string;
company?: string;
}
```
## Implementation Notes
- Field grid lives in `register-form-fields.tsx` and accepts an explicit `t: AuthRegisterStrings` so the child file owns no translation defaults of its own.
- The `sex` select uses Wave 1's `@suaveplan/react-ui` `<Select>` (a Radix portal). In headless test environments the dropdown is not exercised — the default value flows through the Zod schema and the submit payload regardless.
- The cross-field `password === confirmPassword` check uses Zod's `.refine` with `path: ["confirmPassword"]` so the error attaches under the right field.
- No OTel instruments emitted from the form; metrics live on the Wave 1 `signUp` action.

View File

@@ -0,0 +1,55 @@
/**
* Storybook stories for `<RegisterForm>`.
*/
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { cyGb } from "../../translations/index.js";
import { RegisterForm } from "./register-form.js";
interface StoryMeta<TArgs> {
title: string;
component: unknown;
parameters?: Record<string, unknown>;
args?: Partial<TArgs>;
decorators?: Array<(story: () => ReactElement) => ReactElement>;
}
interface StoryObj<TComponent> {
args?: Partial<TComponent extends (p: infer P) => unknown ? P : never>;
}
type Meta<T> = StoryMeta<T extends (p: infer P) => unknown ? P : never>;
const meta: Meta<typeof RegisterForm> = {
title: "Auth/Forms/RegisterForm",
component: RegisterForm,
parameters: { layout: "fullscreen" },
decorators: [(Story) => <MemoryRouter>{Story()}</MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof RegisterForm>;
export const Default: Story = {
args: { onSubmit: async () => {} },
};
export const Playground: Story = {
args: { onSubmit: async () => {}, error: null, isLoading: false },
};
export const WithError: Story = {
args: {
onSubmit: async () => {},
error: new Error("Username already taken"),
},
};
export const Loading: Story = {
args: { onSubmit: async () => {}, isLoading: true },
};
export const WelshCopy: Story = {
args: {
onSubmit: async () => {},
translations: cyGb.auth.register,
},
};

View File

@@ -0,0 +1,222 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { wrapForm } from "../test-wrap.js";
import { RegisterForm } from "./register-form.js";
afterEach(cleanup);
function renderForm(props: Partial<Parameters<typeof RegisterForm>[0]> = {}) {
const onSubmit = props.onSubmit ?? vi.fn(async () => {});
const view = render(
wrapForm(<RegisterForm {...props} onSubmit={onSubmit} />)
);
return { ...view, onSubmit };
}
async function fillRequired(
user: ReturnType<typeof userEvent.setup>,
overrides: {
email?: string;
username?: string;
password?: string;
confirm?: string;
} = {}
) {
await user.type(
screen.getByLabelText(/email address/i),
overrides.email ?? "alice@example.com"
);
await user.type(
screen.getByLabelText(/^username/i),
overrides.username ?? "alice_42"
);
await user.type(
screen.getByLabelText(/^password\*?$/i),
overrides.password ?? "longenough1"
);
await user.type(
screen.getByLabelText(/^confirm password\*?$/i),
overrides.confirm ?? overrides.password ?? "longenough1"
);
}
describe("RegisterForm", () => {
it("blocks submit when passwords do not match", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
await fillRequired(user, {
password: "longenough1",
confirm: "longenough2",
});
await user.click(
screen.getByRole("button", { name: /create account/i })
);
await waitFor(() => {
expect(screen.getByText(/passwords don't match/i)).toBeTruthy();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("rejects usernames that do not match the regex", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
await fillRequired(user, { username: "has space" });
await user.click(
screen.getByRole("button", { name: /create account/i })
);
await waitFor(() => {
expect(
screen.getByText(
/can only contain letters, numbers, and underscores/i
)
).toBeTruthy();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("blocks submit when email is invalid", { timeout: 5000 }, async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
await fillRequired(user, { email: "not-an-email" });
await user.click(
screen.getByRole("button", { name: /create account/i })
);
await waitFor(() => {
expect(screen.getByText(/invalid email address/i)).toBeTruthy();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("blocks submit when password is shorter than 8 characters", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
await fillRequired(user, { password: "abc", confirm: "abc" });
await user.click(
screen.getByRole("button", { name: /create account/i })
);
await waitFor(() => {
expect(screen.getByText(/at least 8 characters/i)).toBeTruthy();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("calls onSubmit with the typed payload on happy path", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onSubmit = vi.fn(async () => {});
renderForm({ onSubmit });
await fillRequired(user);
await user.click(
screen.getByRole("button", { name: /create account/i })
);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
const arg = onSubmit.mock.calls[0]?.[0] as Record<string, unknown>;
expect(arg.email).toBe("alice@example.com");
expect(arg.username).toBe("alice_42");
expect(arg.password).toBe("longenough1");
expect(arg.confirmPassword).toBe("longenough1");
expect(arg.sex).toBe("prefer-not-to-say");
});
it("renders the error prop in a destructive Alert", {
timeout: 5000,
}, () => {
renderForm({ error: new Error("Username taken") });
expect(screen.getByText(/username taken/i)).toBeTruthy();
});
it("overrides default copy when translations prop is supplied", {
timeout: 5000,
}, () => {
renderForm({ translations: { submit: "Anmelden" } });
expect(screen.getByRole("button", { name: /anmelden/i })).toBeTruthy();
});
it("toggles password and confirm password visibility independently", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
renderForm();
const pwd = screen.getByLabelText(/^password\*?$/i);
const conf = screen.getByLabelText(/^confirm password\*?$/i);
expect(pwd.getAttribute("type")).toBe("password");
await user.click(
screen.getByRole("button", { name: /show password/i })
);
expect(pwd.getAttribute("type")).toBe("text");
expect(conf.getAttribute("type")).toBe("password");
await user.click(
screen.getByRole("button", {
name: /show confirm password/i,
})
);
expect(conf.getAttribute("type")).toBe("text");
});
it("disables the submit button while isLoading is true", {
timeout: 5000,
}, () => {
renderForm({ isLoading: true });
const btn = screen.getByRole("button", {
name: /create account/i,
});
expect(btn.hasAttribute("disabled")).toBe(true);
});
it("writes the sex select value back into the form state", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onSubmit = vi.fn(async () => {});
renderForm({ onSubmit });
const trigger = screen.getByRole("combobox");
await user.click(trigger);
await user.keyboard("{ArrowDown}{Enter}");
await fillRequired(user);
await user.click(
screen.getByRole("button", { name: /create account/i })
);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
const arg = onSubmit.mock.calls[0]?.[0] as Record<string, unknown>;
expect([
"prefer-not-to-say",
"traditional-male",
"traditional-female",
"other",
]).toContain(arg.sex);
});
it("renders all optional identity fields", { timeout: 5000 }, async () => {
const user = userEvent.setup();
const onSubmit = vi.fn(async () => {});
renderForm({ onSubmit });
await fillRequired(user);
await user.type(screen.getByLabelText(/first name/i), "Alice");
await user.type(screen.getByLabelText(/surname/i), "Liddell");
await user.type(screen.getByLabelText(/gender/i), "non-binary");
await user.type(screen.getByLabelText(/company/i), "Wonderland Ltd");
await user.click(
screen.getByRole("button", { name: /create account/i })
);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
const arg = onSubmit.mock.calls[0]?.[0] as Record<string, unknown>;
expect(arg.firstName).toBe("Alice");
expect(arg.surname).toBe("Liddell");
expect(arg.gender).toBe("non-binary");
expect(arg.company).toBe("Wonderland Ltd");
});
});

View File

@@ -0,0 +1,165 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@suaveplan/react-ui";
import { Loader2 } from "lucide-react";
import { type ReactElement, useState } from "react";
import { type Resolver, useForm } from "react-hook-form";
import { Link } from "react-router-dom";
import z from "zod";
import enGb from "../../translations/en-gb.js";
import type {
RegisterFormData,
RegisterFormProps,
} from "./register-form.types.js";
import { RegisterFormFields } from "./register-form-fields.js";
const registerSchema = z
.object({
email: z
.string()
.email("Invalid email address")
.max(320, "Email too long"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(100, "Password too long"),
confirmPassword: z.string(),
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(30, "Username must be at most 30 characters")
.regex(
/^[a-zA-Z0-9_]+$/,
"Username can only contain letters, numbers, and underscores"
),
firstName: z.string().optional(),
surname: z.string().optional(),
dob: z.string().optional(),
sex: z
.enum([
"prefer-not-to-say",
"traditional-male",
"traditional-female",
"other",
])
.optional(),
gender: z.string().optional(),
company: z.string().optional(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type RegisterValues = z.infer<typeof registerSchema>;
const baseStrings = enGb.auth.register;
/**
* `<RegisterForm>` — pure UI sign-up form with full identity grid.
* Validates via `react-hook-form` + `zodResolver` against
* `registerSchema` (email + username regex + password length +
* cross-field `confirmPassword` refinement).
*/
export function RegisterForm({
onSubmit,
error,
isLoading = false,
translations,
}: RegisterFormProps): ReactElement {
const t = { ...baseStrings, ...translations };
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<RegisterValues>({
resolver: zodResolver(registerSchema) as Resolver<RegisterValues>,
mode: "onBlur",
defaultValues: { sex: "prefer-not-to-say" },
});
const busy = isSubmitting || isLoading;
const submit = handleSubmit(async (values) => {
await onSubmit(values as RegisterFormData);
});
return (
<div className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
{t.title}
</CardTitle>
<CardDescription className="text-center">
{t.email}
</CardDescription>
</CardHeader>
<form onSubmit={submit} noValidate>
<CardContent className="space-y-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>
{error.message}
</AlertDescription>
</Alert>
) : null}
<RegisterFormFields
register={register}
setValue={setValue}
errors={errors}
disabled={isSubmitting}
showPassword={showPassword}
showConfirmPassword={showConfirmPassword}
onTogglePassword={() => {
setShowPassword((prev) => !prev);
}}
onToggleConfirmPassword={() => {
setShowConfirmPassword((prev) => !prev);
}}
t={t}
/>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button
type="submit"
className="w-full"
disabled={busy}
aria-label={t.submit}
>
{busy ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.submit}
</>
) : (
t.submit
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
{t.hasAccount}{" "}
<Link
to="/auth/login"
className="text-primary hover:underline"
>
{t.login}
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import type { AuthRegisterStrings } from "../../translations/en-gb.js";
export type RegisterFormSex =
| "prefer-not-to-say"
| "traditional-male"
| "traditional-female"
| "other";
/** Validated payload emitted by `<RegisterForm>` on submit. */
export interface RegisterFormData {
email: string;
password: string;
confirmPassword: string;
username: string;
firstName?: string | undefined;
surname?: string | undefined;
dob?: string | undefined;
sex?: RegisterFormSex | undefined;
gender?: string | undefined;
company?: string | undefined;
}
/** Props for the `<RegisterForm>` component. */
export interface RegisterFormProps {
onSubmit: (data: RegisterFormData) => Promise<void> | void;
error?: Error | null | undefined;
isLoading?: boolean | undefined;
/**
* Optional translation overrides. Falls back to `enGb.auth.register`.
*/
translations?: Partial<AuthRegisterStrings> | undefined;
}

View File

@@ -0,0 +1,5 @@
export { ResetPasswordForm } from "./reset-password-form.js";
export type {
ResetPasswordFormData,
ResetPasswordFormProps,
} from "./reset-password-form.types.js";

View File

@@ -0,0 +1,118 @@
# ResetPasswordForm
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<ResetPasswordForm>` is the pure-UI surface for setting a new password after the user clicks a reset link. It owns three render modes — the form itself, a success card, and an invalid-token card — but never imports the Wave 1 `AuthClient` or `useNavigate`. The page-level host extracts the token from the URL, flips `hasToken` accordingly, calls `useAuth().resetPassword` from `onSubmit`, and flips `isSuccess` once the call resolves. The form is therefore trivially testable in isolation and reusable inside embedded reset flows where routing is owned by the host.
## Features
- React Hook Form + `zodResolver` against `resetPasswordSchema` (both fields ≥ 8 chars + cross-field match refinement).
- `mode: "onBlur"` validation; password mismatch surfaces under the confirm field via Zod `path: ["confirmPassword"]`.
- Independent password show/hide toggle per field with discriminating `aria-label`s ("Show new password" vs. "Show confirm new password").
- Three render branches: form (default), success (`isSuccess`), invalid-token (`hasToken: false`). Each renders its own CTA wired to a host-supplied navigation callback.
- Destructive `<Alert>` slot rendering caller-supplied `Error.message`.
- `translations?: Partial<AuthResetPasswordStrings>` for Welsh / zh-CN coverage via Wave 1's translation packs.
## Usage: Basic
```tsx
import { ResetPasswordForm } from "@suaveplan/react-auth";
import { BrowserRouter } from "react-router-dom";
export function ResetPasswordPage() {
return (
<BrowserRouter>
<ResetPasswordForm
onSubmit={async ({ password }) => {
console.log("resetting", password);
}}
/>
</BrowserRouter>
);
}
```
## Usage: Advanced
Wire URL-token extraction to `hasToken`, call `useAuth().resetPassword`,
and route on success:
```tsx
import {
ResetPasswordForm,
useAuth,
} from "@suaveplan/react-auth";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useState } from "react";
export function ResetPassword() {
const { resetPassword } = useAuth();
const [params] = useSearchParams();
const token = params.get("token");
const navigate = useNavigate();
const [error, setError] = useState<Error | null>(null);
const [success, setSuccess] = useState(false);
return (
<ResetPasswordForm
hasToken={token !== null}
error={error}
isSuccess={success}
onNavigateToLogin={() => {
navigate("/auth/login");
}}
onNavigateToForgotPassword={() => {
navigate("/auth/forgot-password");
}}
onSubmit={async ({ password }) => {
setError(null);
try {
await resetPassword({ password }, token ?? "");
setSuccess(true);
} catch (caught) {
setError(
caught instanceof Error
? caught
: new Error(String(caught))
);
}
}}
/>
);
}
```
## API Reference
### `ResetPasswordForm(props: ResetPasswordFormProps): ReactElement`
| Prop | Type | Default | Description |
|---|---|---|---|
| `onSubmit` | `(data: ResetPasswordFormData) => Promise<void> \| void` | — required | Invoked once Zod accepts the payload. |
| `onNavigateToLogin` | `() => void` | — | Fired by the success-view CTA. |
| `onNavigateToForgotPassword` | `() => void` | — | Fired by the invalid-token-view CTA. |
| `error` | `Error \| null \| undefined` | `null` | Rendered in a destructive `<Alert>`. |
| `isLoading` | `boolean` | `false` | Disables the submit button. |
| `isSuccess` | `boolean` | `false` | Switches to the success card. |
| `hasToken` | `boolean` | `true` | When `false`, switches to the invalid-token card. |
| `translations` | `Partial<AuthResetPasswordStrings>` | `enGb.auth.resetPassword` | Override any subset of the 7 copy keys. |
### `ResetPasswordFormData`
```ts
interface ResetPasswordFormData {
password: string;
confirmPassword: string;
}
```
## Implementation Notes
- The shared internal `<PasswordField>` helper encapsulates the input + toggle button pair. This keeps the public file under the 300-line cap (§3) and ensures both fields render identically.
- Render-branch order is intentional: `isSuccess` short-circuits before `hasToken` so a successful reset on a now-expired-but-already-used token still shows the success card.
- Toggle `aria-label` strings derive from the (translated) field label so screen-reader output is localised end-to-end.
- No OTel instruments emitted from the form; metrics live on the Wave 1 `resetPassword` action.

View File

@@ -0,0 +1,67 @@
/**
* Storybook stories for `<ResetPasswordForm>`.
*/
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { cyGb } from "../../translations/index.js";
import { ResetPasswordForm } from "./reset-password-form.js";
interface StoryMeta<TArgs> {
title: string;
component: unknown;
parameters?: Record<string, unknown>;
args?: Partial<TArgs>;
decorators?: Array<(story: () => ReactElement) => ReactElement>;
}
interface StoryObj<TComponent> {
args?: Partial<TComponent extends (p: infer P) => unknown ? P : never>;
}
type Meta<T> = StoryMeta<T extends (p: infer P) => unknown ? P : never>;
const meta: Meta<typeof ResetPasswordForm> = {
title: "Auth/Forms/ResetPasswordForm",
component: ResetPasswordForm,
parameters: { layout: "fullscreen" },
decorators: [(Story) => <MemoryRouter>{Story()}</MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof ResetPasswordForm>;
export const Default: Story = {
args: { onSubmit: async () => {} },
};
export const Playground: Story = {
args: { onSubmit: async () => {}, error: null, isLoading: false },
};
export const Success: Story = {
args: {
onSubmit: async () => {},
isSuccess: true,
onNavigateToLogin: () => {},
},
};
export const InvalidToken: Story = {
args: {
onSubmit: async () => {},
hasToken: false,
onNavigateToForgotPassword: () => {},
},
};
export const WithError: Story = {
args: {
onSubmit: async () => {},
error: new Error("Reset token expired"),
},
};
export const WelshCopy: Story = {
args: {
onSubmit: async () => {},
translations: cyGb.auth.resetPassword,
},
};

View File

@@ -0,0 +1,177 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { wrapForm } from "../test-wrap.js";
import { ResetPasswordForm } from "./reset-password-form.js";
afterEach(cleanup);
function renderForm(
props: Partial<Parameters<typeof ResetPasswordForm>[0]> = {}
) {
const onSubmit = props.onSubmit ?? vi.fn(async () => {});
const view = render(
wrapForm(<ResetPasswordForm {...props} onSubmit={onSubmit} />)
);
return { ...view, onSubmit };
}
describe("ResetPasswordForm", () => {
it("blocks submit when passwords do not match", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
await user.type(
screen.getByLabelText(/^new password$/i),
"longenough1"
);
await user.type(
screen.getByLabelText(/^confirm new password$/i),
"longenough2"
);
await user.click(
screen.getByRole("button", { name: /update password/i })
);
await waitFor(() => {
expect(screen.getByText(/passwords don't match/i)).toBeTruthy();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("blocks submit when password is shorter than 8 characters", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
await user.type(screen.getByLabelText(/^new password$/i), "abc");
await user.type(
screen.getByLabelText(/^confirm new password$/i),
"abc"
);
await user.click(
screen.getByRole("button", { name: /update password/i })
);
await waitFor(() => {
expect(
screen.getAllByText(/at least 8 characters/i).length
).toBeGreaterThan(0);
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("calls onSubmit with the typed payload on happy path", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onSubmit = vi.fn(async () => {});
renderForm({ onSubmit });
await user.type(
screen.getByLabelText(/^new password$/i),
"longenough1"
);
await user.type(
screen.getByLabelText(/^confirm new password$/i),
"longenough1"
);
await user.click(
screen.getByRole("button", { name: /update password/i })
);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit).toHaveBeenCalledWith({
password: "longenough1",
confirmPassword: "longenough1",
});
});
it("renders the success view with a working onNavigateToLogin CTA", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onNavigateToLogin = vi.fn();
render(
wrapForm(
<ResetPasswordForm
onSubmit={async () => {}}
isSuccess
onNavigateToLogin={onNavigateToLogin}
/>
)
);
await user.click(
screen.getByRole("button", { name: /back to sign in/i })
);
expect(onNavigateToLogin).toHaveBeenCalledTimes(1);
});
it("renders the invalid-token view with a working onNavigateToForgotPassword CTA", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
const onNavigateToForgotPassword = vi.fn();
render(
wrapForm(
<ResetPasswordForm
onSubmit={async () => {}}
hasToken={false}
onNavigateToForgotPassword={onNavigateToForgotPassword}
/>
)
);
await user.click(
screen.getByRole("button", { name: /update password/i })
);
expect(onNavigateToForgotPassword).toHaveBeenCalledTimes(1);
});
it("renders the error prop in a destructive Alert", {
timeout: 5000,
}, () => {
renderForm({ error: new Error("Reset token expired") });
expect(screen.getByText(/reset token expired/i)).toBeTruthy();
});
it("toggles password visibility on each field independently", {
timeout: 5000,
}, async () => {
const user = userEvent.setup();
renderForm();
const pwd = screen.getByLabelText(/^new password$/i);
const conf = screen.getByLabelText(/^confirm new password$/i);
expect(pwd.getAttribute("type")).toBe("password");
await user.click(
screen.getByRole("button", { name: /show new password/i })
);
expect(pwd.getAttribute("type")).toBe("text");
expect(conf.getAttribute("type")).toBe("password");
await user.click(
screen.getByRole("button", {
name: /show confirm new password/i,
})
);
expect(conf.getAttribute("type")).toBe("text");
});
it("disables the submit button while isLoading is true", {
timeout: 5000,
}, () => {
renderForm({ isLoading: true });
const btn = screen.getByRole("button", {
name: /update password/i,
});
expect(btn.hasAttribute("disabled")).toBe(true);
});
it("overrides default copy when translations prop is supplied", {
timeout: 5000,
}, () => {
renderForm({
translations: { submit: "Aktualisieren" },
});
expect(
screen.getByRole("button", { name: /aktualisieren/i })
).toBeTruthy();
});
});

View File

@@ -0,0 +1,248 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Input,
Label,
} from "@suaveplan/react-ui";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { type ReactElement, useState } from "react";
import { type Resolver, useForm } from "react-hook-form";
import { Link } from "react-router-dom";
import z from "zod";
import enGb from "../../translations/en-gb.js";
import type {
ResetPasswordFormData,
ResetPasswordFormProps,
} from "./reset-password-form.types.js";
const resetPasswordSchema = z
.object({
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z
.string()
.min(8, "Password must be at least 8 characters"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type ResetPasswordValues = z.infer<typeof resetPasswordSchema>;
const baseStrings = enGb.auth.resetPassword;
interface PasswordFieldProps {
id: "password" | "confirmPassword";
label: string;
register: ReturnType<typeof useForm<ResetPasswordValues>>["register"];
show: boolean;
onToggle: () => void;
error?: string | undefined;
disabled: boolean;
}
function PasswordField({
id,
label,
register,
show,
onToggle,
error,
disabled,
}: PasswordFieldProps): ReactElement {
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<div className="relative">
<Input
id={id}
type={show ? "text" : "password"}
autoComplete="new-password"
{...register(id)}
disabled={disabled}
/>
<button
type="button"
aria-label={
show
? `Hide ${label.toLowerCase()}`
: `Show ${label.toLowerCase()}`
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={onToggle}
>
{show ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
);
}
/**
* `<ResetPasswordForm>` — three-view UI surface: form / success /
* invalid-token. Validates new + confirm passwords through
* `react-hook-form` + `zodResolver`.
*/
export function ResetPasswordForm({
onSubmit,
onNavigateToLogin,
onNavigateToForgotPassword,
error,
isLoading = false,
isSuccess = false,
hasToken = true,
translations,
}: ResetPasswordFormProps): ReactElement {
const t = { ...baseStrings, ...translations };
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ResetPasswordValues>({
resolver: zodResolver(
resetPasswordSchema
) as Resolver<ResetPasswordValues>,
mode: "onBlur",
defaultValues: { password: "", confirmPassword: "" },
});
if (isSuccess) {
return (
<div className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
{t.success}
</CardTitle>
<CardDescription className="text-center">
{t.description}
</CardDescription>
</CardHeader>
<CardFooter>
<Button
onClick={onNavigateToLogin}
className="w-full"
aria-label={t.backToLogin}
>
{t.backToLogin}
</Button>
</CardFooter>
</Card>
</div>
);
}
if (!hasToken) {
return (
<div className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
{t.invalidToken}
</CardTitle>
<CardDescription className="text-center">
{t.description}
</CardDescription>
</CardHeader>
<CardFooter>
<Button
onClick={onNavigateToForgotPassword}
className="w-full"
aria-label={t.submit}
>
{t.submit}
</Button>
</CardFooter>
</Card>
</div>
);
}
const busy = isSubmitting || isLoading;
const submit = handleSubmit(async (values) => {
await onSubmit(values as ResetPasswordFormData);
});
return (
<div className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
{t.title}
</CardTitle>
<CardDescription className="text-center">
{t.description}
</CardDescription>
</CardHeader>
<form onSubmit={submit} noValidate>
<CardContent className="space-y-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>
{error.message}
</AlertDescription>
</Alert>
) : null}
<PasswordField
id="password"
label={t.password}
register={register}
show={showPassword}
onToggle={() => {
setShowPassword((prev) => !prev);
}}
error={errors.password?.message}
disabled={isSubmitting}
/>
<PasswordField
id="confirmPassword"
label={t.confirmPassword}
register={register}
show={showConfirm}
onToggle={() => {
setShowConfirm((prev) => !prev);
}}
error={errors.confirmPassword?.message}
disabled={isSubmitting}
/>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button
type="submit"
className="w-full"
disabled={busy}
aria-label={t.submit}
>
{busy ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.submit}
</>
) : (
t.submit
)}
</Button>
<Link
to="/auth/login"
className="text-center text-sm text-primary hover:underline"
>
{t.backToLogin}
</Link>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import type { AuthResetPasswordStrings } from "../../translations/en-gb.js";
/** Validated payload emitted by `<ResetPasswordForm>` on submit. */
export interface ResetPasswordFormData {
password: string;
confirmPassword: string;
}
/** Props for the `<ResetPasswordForm>` component. */
export interface ResetPasswordFormProps {
/** Submit handler invoked with the validated payload. */
onSubmit: (data: ResetPasswordFormData) => Promise<void> | void;
/** Invoked by the success-view CTA. */
onNavigateToLogin?: (() => void) | undefined;
/** Invoked by the invalid-token-view CTA. */
onNavigateToForgotPassword?: (() => void) | undefined;
/** Error to display in a destructive `<Alert>`. */
error?: Error | null | undefined;
/** When `true`, the submit button is disabled and shows a spinner. */
isLoading?: boolean | undefined;
/** When `true`, the "password reset successful" view is rendered. */
isSuccess?: boolean | undefined;
/** When `false`, the "invalid token" view is rendered. */
hasToken?: boolean | undefined;
/**
* Optional translation overrides. Falls back to
* `enGb.auth.resetPassword`.
*/
translations?: Partial<AuthResetPasswordStrings> | undefined;
}

View File

@@ -0,0 +1,58 @@
# test-wrap
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Internal test helper consumed only by the Wave 2 form `.test.tsx` files in `src/components/<form>/`. The helper wraps a node in every provider the forms need to mount inside a happy-dom environment: `<MemoryRouter>`, `<TranslationProvider>`, and `<ThemeProvider>`. Without these providers, `@suaveplan/react-ui` primitives (which the forms compose) throw `react-ui.use_themed_variants.no_provider` and `react-i18n` `useTranslation()` panics on first render. The helper centralises the wrap so every form test mounts under the same conditions.
## Features
- Single `wrapForm(node)` function returning a fully-wrapped `ReactElement`.
- A module-scope `createI18n` call that yields an empty-message i18n instance — the tests assert against the form's own translation defaults rather than catalog lookups, so an empty catalog is correct.
- Uses Wave 1's `@suaveplan/react-ui`'s `lightTheme` via the dedicated subpath export so the theme bundle is the same one production callers ship.
- Renders inside a `<MemoryRouter>` because every form ships at least one `<Link>` to `/auth/login` / `/auth/register`.
## Usage: Basic
```tsx
import { render } from "@testing-library/react";
import { wrapForm } from "../test-wrap.js";
import { LoginForm } from "./login-form.js";
const { getByLabelText } = render(
wrapForm(<LoginForm onSubmit={async () => {}} />)
);
```
## Usage: Advanced
Pass any node — including nested fragments and arrays — and the wrap is the same:
```tsx
import { wrapForm } from "../test-wrap.js";
import { LoginForm, RegisterForm } from "@suaveplan/react-auth";
render(
wrapForm(
<>
<LoginForm onSubmit={async () => {}} />
<RegisterForm onSubmit={async () => {}} />
</>
)
);
```
## API Reference
### `wrapForm(node: ReactNode): ReactElement`
Returns the input node wrapped in `<MemoryRouter><TranslationProvider><ThemeProvider>{node}</ThemeProvider></TranslationProvider></MemoryRouter>`.
## Implementation Notes
- Not exported from the package root barrel — `wrapForm` is internal test plumbing only.
- Excluded from coverage via `vitest.config.ts` (`exclude: ["src/components/test-wrap.tsx"]`) so it does not skew the 100/100/100/100 threshold.
- The empty `messages` map on the i18n instance keeps tests deterministic — every translation lookup returns its key, and the forms supply their own English defaults via the `translations` prop.

View File

@@ -0,0 +1,160 @@
/**
* Internal test helper — wraps a node in `<TranslationProvider>` and
* `<ThemeProvider>` so `@suaveplan/react-ui` primitives mount in a
* test environment. NOT exported from the package root; consumed only
* by `*.test.tsx` files in this directory tree.
*
* This file lives under `src/` because tests need to import it, but
* `vitest.config.ts` excludes `test-wrap.tsx` from coverage so it does
* not skew the 100/100/100/100 threshold.
*/
import { createI18n } from "@suaveplan/i18n/plugin";
import { TranslationProvider } from "@suaveplan/react-i18n";
import { ThemeProvider } from "@suaveplan/react-ui/theme/theme-provider";
import { lightTheme } from "@suaveplan/react-ui/theme/themes/light";
import type { ReactElement, ReactNode } from "react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { createAuthClient } from "../client/auth-client.js";
import {
AuthContext,
type AuthContextValue,
type AuthState,
} from "../context/auth-context.js";
// Cast widens the i18n generic from `"en-GB"` to `string` so the
// `<TranslationProvider>` `i18n` prop (which expects the broader
// `I18nInstance<string, TranslationRecord>` shape) accepts it.
const i18n = createI18n({
defaultLocale: "en-GB" as string,
messages: { "en-GB": {} },
});
/**
* Wrap a form node in every provider the form needs:
*
* - `<MemoryRouter>` because every form ships at least one `<Link>`.
* - `<TranslationProvider>` because `@suaveplan/react-ui` primitives
* call `useTranslation()` internally.
* - `<ThemeProvider>` because `@suaveplan/react-ui` primitives call
* `useThemedVariants()` against the active theme.
*/
export function wrapForm(node: ReactNode): ReactElement {
return (
<MemoryRouter>
<TranslationProvider i18n={i18n}>
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
{node}
</ThemeProvider>
</TranslationProvider>
</MemoryRouter>
);
}
/** Options accepted by `makeAuthCtx`. */
export interface MakeAuthCtxOptions
extends Partial<AuthState>,
Partial<
Pick<
AuthContextValue,
| "signIn"
| "signUp"
| "signOut"
| "updateProfile"
| "changePassword"
| "forgotPassword"
| "resetPassword"
| "verifyEmail"
| "resendVerificationEmail"
| "deleteAccount"
| "refreshSession"
>
> {}
/** Build a fully-populated `AuthContextValue` with safe noop defaults. */
export function makeAuthCtx(
options: MakeAuthCtxOptions = {}
): AuthContextValue {
const noop = async () => {};
const noop2 = async (_a: unknown, _b?: unknown) => {};
const {
user = null,
session = null,
isLoading = false,
isAuthenticated = false,
error = null,
signIn,
signUp,
signOut,
updateProfile,
changePassword,
forgotPassword,
resetPassword,
verifyEmail,
resendVerificationEmail,
deleteAccount,
refreshSession,
} = options;
return {
user,
session,
isLoading,
isAuthenticated,
error,
client: createAuthClient({ baseURL: "https://auth.example.com" }),
signIn: (signIn ?? noop) as AuthContextValue["signIn"],
signUp: (signUp ?? noop) as AuthContextValue["signUp"],
signOut: (signOut ?? noop) as AuthContextValue["signOut"],
updateProfile: (updateProfile ??
noop) as AuthContextValue["updateProfile"],
changePassword: (changePassword ??
noop) as AuthContextValue["changePassword"],
forgotPassword: (forgotPassword ??
noop) as AuthContextValue["forgotPassword"],
resetPassword: (resetPassword ??
noop2) as AuthContextValue["resetPassword"],
verifyEmail: (verifyEmail ?? noop) as AuthContextValue["verifyEmail"],
resendVerificationEmail: (resendVerificationEmail ??
noop) as AuthContextValue["resendVerificationEmail"],
deleteAccount: (deleteAccount ??
noop) as AuthContextValue["deleteAccount"],
refreshSession: (refreshSession ??
noop) as AuthContextValue["refreshSession"],
};
}
function LocationCapture(): ReactElement {
const location = useLocation();
return (
<div data-testid="navigated" data-pathname={location.pathname}>
navigated:{location.pathname}
</div>
);
}
/** Page-level wrap: like `wrapForm` but also injects an `AuthContext.Provider`. */
export function wrapPage(
node: ReactNode,
options: {
ctx?: AuthContextValue;
initialEntries?: string[];
landingPath?: string;
} = {}
): ReactElement {
const ctx = options.ctx ?? makeAuthCtx();
const initialEntries = options.initialEntries ?? ["/"];
const landingPath = options.landingPath ?? "/";
return (
<MemoryRouter initialEntries={initialEntries}>
<TranslationProvider i18n={i18n}>
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
<AuthContext.Provider value={ctx}>
<Routes>
<Route path={landingPath} element={node} />
<Route path="*" element={<LocationCapture />} />
</Routes>
</AuthContext.Provider>
</ThemeProvider>
</TranslationProvider>
</MemoryRouter>
);
}

View File

@@ -0,0 +1,74 @@
# `auth-context`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`auth-context.tsx` exposes the React Context surface for the auth flow. It defines `AuthContext`, the `AuthProvider` component, and the `useAuth()` hook. The provider owns an `AuthClient` instance (either injected or constructed from `config`) and a state block of `{ user, session, isLoading, isAuthenticated, error }`. On mount (unless `skipInitialCheck` is set) it fetches the current session, normalises it through `transformSessionResponse`, and settles the state. It exposes eleven action callbacks (`signIn`, `signUp`, `signOut`, `updateProfile`, `changePassword`, `forgotPassword`, `resetPassword`, `verifyEmail`, `resendVerificationEmail`, `deleteAccount`, `refreshSession`) — every action wraps `client` calls with loading/error state mutations, re-fetches the session where appropriate, records the corresponding OTel counter, and re-throws on failure so callers can `catch` for form-level UI handling. `useAuth()` outside an `AuthProvider` throws `ReactAuthContextMissingError` so the failure is immediate and typed rather than a downstream `undefined` access.
## Features
- `AuthContext` + `AuthProvider` + `useAuth()` triple.
- 11 typed action callbacks; each settles `isLoading` and surfaces `error` deterministically.
- `skipInitialCheck` flag short-circuits the mount-time session fetch (test surface).
- `onAuthStateChange` callback fires on every state transition (useful for analytics).
- Re-throws action errors so form-level UI can render messages; never silently swallows.
## Usage: Basic
```tsx
import { AuthProvider, useAuth } from "@suaveplan/react-auth";
function Inside() {
const { user, signOut } = useAuth();
return user ? <button onClick={() => signOut()}>Sign out</button> : null;
}
<AuthProvider config={{ baseURL: "https://auth.example.com" }}>
<Inside />
</AuthProvider>;
```
## Usage: Advanced
Inject a pre-built client and observe state changes:
```tsx
import {
AuthProvider,
createAuthClient,
type AuthState,
} from "@suaveplan/react-auth";
const client = createAuthClient({
baseURL: "https://auth.example.com",
siteDomain: "tenant-a",
});
function onAuthStateChange(state: AuthState) {
analytics.track("auth_state", { authenticated: state.isAuthenticated });
}
<AuthProvider
config={{ baseURL: "https://auth.example.com" }}
client={client}
onAuthStateChange={onAuthStateChange}
>
{/* ... */}
</AuthProvider>;
```
## API Reference
- `interface AuthState` — `{ user: User | null; session: Session | null; isLoading: boolean; isAuthenticated: boolean; error: Error | null }`.
- `interface AuthContextValue extends AuthState` — adds `client: AuthClient` plus the 11 action callbacks.
- `interface AuthProviderProps` — `{ children: ReactNode; config: AuthClientConfig; client?: AuthClient; onAuthStateChange?: (state: AuthState) => void; skipInitialCheck?: boolean }`.
- `const AuthContext: React.Context<AuthContextValue | undefined>` — context handle.
- `function AuthProvider(props: AuthProviderProps): ReactNode` — wraps children with the context.
- `function useAuth(): AuthContextValue` — reads the context; throws `ReactAuthContextMissingError` when outside an `AuthProvider`.
## Implementation Notes
State is consolidated in a single `useState` block so transitions are atomic — callers never see a half-updated value. Every action wraps its critical section in `try`/`catch`/`finally`-style logic but with explicit `setState` calls rather than a `finally` clause, because the success and failure branches set different sets of fields. Caught values pass through `toError(value)` which wraps non-`Error` throwables so the `error` field is always a real `Error` (this avoids violating biome's `useThrowOnlyError` rule when a downstream surface re-throws from the context). Session fetches go through `timedSessionFetch` which records the wall-clock duration into the `react_auth.session_fetch.duration_ms` histogram with a `success`/`failure` label. The `mounted` ref guards the initial-load effect from setting state after unmount; React 19's `StrictMode` double-mount runs the cleanup before the second mount, so the first effect's `mounted = false` is honoured.

View File

@@ -0,0 +1,444 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, render, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { AuthClient, createAuthClient } from "../client/auth-client.js";
import { ReactAuthContextMissingError } from "../error-codes/error-codes.js";
import { AuthProvider, type AuthState, useAuth } from "./auth-context.js";
const validUser = {
id: "u1",
email: "a@b.com",
emailVerified: true,
name: "Alice",
createdAt: new Date("2026-05-19T00:00:00.000Z"),
updatedAt: new Date("2026-05-19T00:00:00.000Z"),
};
const validSession = {
id: "s1",
userId: "u1",
expiresAt: new Date("2026-05-20T00:00:00.000Z"),
};
interface RawProgram {
session: unknown;
fail?: unknown;
}
function buildClient(program: RawProgram): AuthClient {
const succeed = async () => ({ data: { status: true } });
const sess = async () => {
if (program.fail !== undefined) throw program.fail;
return program.session;
};
return createAuthClient({
baseURL: "https://auth.example.com",
rawClient: {
signIn: {
email: async () => ({ data: { user: { id: "u1" } } }),
},
signUp: {
email: async () => ({ data: { user: { id: "u1" } } }),
},
signOut: succeed,
getSession: sess,
forgetPassword: succeed,
resetPassword: succeed,
changePassword: succeed,
verifyEmail: succeed,
sendVerificationEmail: succeed,
updateUser: succeed,
deleteUser: succeed,
listSessions: async () => ({ data: [] }),
},
});
}
function failingClient(value: unknown): AuthClient {
const throws = async () => {
throw value;
};
return createAuthClient({
baseURL: "https://auth.example.com",
rawClient: {
signIn: { email: throws },
signUp: { email: throws },
signOut: throws,
getSession: throws,
forgetPassword: throws,
resetPassword: throws,
changePassword: throws,
verifyEmail: throws,
sendVerificationEmail: throws,
updateUser: throws,
deleteUser: throws,
},
});
}
function wrap(
client: AuthClient,
skipInitialCheck = true,
onAuthStateChange?: (state: AuthState) => void
) {
return ({ children }: { children: ReactNode }) => (
<AuthProvider
config={{ baseURL: "https://auth.example.com" }}
client={client}
skipInitialCheck={skipInitialCheck}
{...(onAuthStateChange ? { onAuthStateChange } : {})}
>
{children}
</AuthProvider>
);
}
describe("AuthProvider / useAuth", () => {
it("useAuth outside an AuthProvider throws ReactAuthContextMissingError", {
timeout: 5000,
}, () => {
expect(() => renderHook(() => useAuth())).toThrow(
ReactAuthContextMissingError
);
});
it("loads the initial session and settles isAuthenticated=true", {
timeout: 5000,
}, async () => {
const client = buildClient({
session: { data: { user: validUser, session: validSession } },
});
const { result } = renderHook(() => useAuth(), {
wrapper: wrap(client, false),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.email).toBe("a@b.com");
expect(result.current.session?.id).toBe("s1");
});
it("settles with isAuthenticated=false when there is no session", {
timeout: 5000,
}, async () => {
const client = buildClient({ session: { data: null } });
const { result } = renderHook(() => useAuth(), {
wrapper: wrap(client, false),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
it("captures errors thrown by the initial session fetch", {
timeout: 5000,
}, async () => {
const client = buildClient({
session: null,
fail: new Error("network boom"),
});
const { result } = renderHook(() => useAuth(), {
wrapper: wrap(client, false),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error?.message).toBe("network boom");
});
it("wraps a non-Error throw into an Error on initial fetch", {
timeout: 5000,
}, async () => {
const client = buildClient({
session: null,
fail: "string-throwable",
});
const { result } = renderHook(() => useAuth(), {
wrapper: wrap(client, false),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toBe("string-throwable");
});
it("every action settles isLoading and propagates success paths", {
timeout: 5000,
}, async () => {
const client = buildClient({
session: { data: { user: validUser, session: validSession } },
});
const { result } = renderHook(() => useAuth(), {
wrapper: wrap(client, true),
});
await act(async () => {
await result.current.signIn({ email: "a@b.com", password: "p" });
});
expect(result.current.isAuthenticated).toBe(true);
await act(async () => {
await result.current.signUp({
email: "a@b.com",
password: "p",
username: "u",
firstName: "F",
});
});
expect(result.current.isLoading).toBe(false);
await act(async () => {
await result.current.signOut();
});
expect(result.current.user).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
await act(async () => {
await result.current.updateProfile({ firstName: "X" });
});
expect(result.current.isLoading).toBe(false);
await act(async () => {
await result.current.changePassword({
currentPassword: "a",
newPassword: "b",
});
});
expect(result.current.isLoading).toBe(false);
await act(async () => {
await result.current.forgotPassword("a@b.com");
});
expect(result.current.isLoading).toBe(false);
await act(async () => {
await result.current.resetPassword("t", "p");
});
expect(result.current.isLoading).toBe(false);
await act(async () => {
await result.current.verifyEmail("t");
});
expect(result.current.isLoading).toBe(false);
await act(async () => {
await result.current.resendVerificationEmail("a@b.com");
});
expect(result.current.isLoading).toBe(false);
await act(async () => {
await result.current.deleteAccount("p");
});
expect(result.current.user).toBeNull();
await act(async () => {
await result.current.refreshSession();
});
expect(result.current.isLoading).toBe(false);
});
it("refreshSession can transition back to unauthenticated", {
timeout: 5000,
}, async () => {
// First session present, then null
const sessions = [
{ data: { user: validUser, session: validSession } },
{ data: null },
];
let idx = 0;
const client = createAuthClient({
baseURL: "https://auth.example.com",
rawClient: {
getSession: async () => sessions[idx++ % sessions.length],
},
});
const { result } = renderHook(() => useAuth(), {
wrapper: wrap(client, false),
});
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
await act(async () => {
await result.current.refreshSession();
});
expect(result.current.isAuthenticated).toBe(false);
});
it("every action re-throws when the underlying call fails", {
timeout: 5000,
}, async () => {
const client = failingClient("literal-failure");
const { result } = renderHook(() => useAuth(), {
wrapper: wrap(client, true),
});
const expectsThrow = async (op: () => Promise<unknown>) => {
await expect(
act(async () => {
await op();
})
).rejects.toBeInstanceOf(Error);
};
await expectsThrow(() =>
result.current.signIn({ email: "a@b.com", password: "p" })
);
await expectsThrow(() =>
result.current.signUp({
email: "a@b.com",
password: "p",
username: "u",
})
);
await expectsThrow(() => result.current.signOut());
await expectsThrow(() => result.current.updateProfile({}));
await expectsThrow(() =>
result.current.changePassword({
currentPassword: "a",
newPassword: "b",
})
);
await expectsThrow(() => result.current.forgotPassword("a@b.com"));
await expectsThrow(() => result.current.resetPassword("t", "p"));
await expectsThrow(() => result.current.verifyEmail("t"));
await expectsThrow(() =>
result.current.resendVerificationEmail("a@b.com")
);
await expectsThrow(() => result.current.deleteAccount("p"));
await expectsThrow(() => result.current.refreshSession());
});
it("resendVerificationEmail requires an email when no user is signed in", {
timeout: 5000,
}, async () => {
const client = buildClient({ session: { data: null } });
const { result } = renderHook(() => useAuth(), {
wrapper: wrap(client, true),
});
await expect(
act(async () => {
await result.current.resendVerificationEmail();
})
).rejects.toThrow("Email is required for resending verification");
});
it("resendVerificationEmail falls back to the current user's email", {
timeout: 5000,
}, async () => {
const client = buildClient({
session: { data: { user: validUser, session: validSession } },
});
const { result } = renderHook(() => useAuth(), {
wrapper: wrap(client, false),
});
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
await act(async () => {
await result.current.resendVerificationEmail();
});
expect(result.current.error).toBeNull();
});
it("deleteAccount requires a password", { timeout: 5000 }, async () => {
const client = buildClient({ session: { data: null } });
const { result } = renderHook(() => useAuth(), {
wrapper: wrap(client, true),
});
await expect(
act(async () => {
await result.current.deleteAccount();
})
).rejects.toThrow("Password is required for account deletion");
});
it("invokes onAuthStateChange on transitions", {
timeout: 5000,
}, async () => {
const client = buildClient({ session: { data: null } });
const seen: AuthState[] = [];
renderHook(() => useAuth(), {
wrapper: wrap(client, false, (s) => {
seen.push(s);
}),
});
await waitFor(() => expect(seen.length).toBeGreaterThan(0));
});
it("AuthProvider applies the default skipInitialCheck=false when the prop is omitted", {
timeout: 5000,
}, async () => {
const client = buildClient({ session: { data: null } });
const { result } = renderHook(() => useAuth(), {
wrapper: ({ children }: { children: ReactNode }) => (
<AuthProvider
config={{ baseURL: "https://auth.example.com" }}
client={client}
>
{children}
</AuthProvider>
),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
});
it("AuthProvider constructs its own client from config when none is injected", {
timeout: 5000,
}, () => {
const { result } = renderHook(() => useAuth(), {
wrapper: ({ children }: { children: ReactNode }) => (
<AuthProvider
config={{ baseURL: "https://auth.example.com" }}
skipInitialCheck
>
{children}
</AuthProvider>
),
});
expect(result.current.client).toBeInstanceOf(AuthClient);
});
it("guards against setState after unmount on a thrown initial fetch", {
timeout: 5000,
}, async () => {
let resolveFail: ((v: unknown) => void) | null = null;
const failingClient = createAuthClient({
baseURL: "https://auth.example.com",
rawClient: {
getSession: () =>
new Promise((_resolve, reject) => {
resolveFail = (v: unknown) => reject(v);
}),
},
});
const { unmount } = renderHook(() => useAuth(), {
wrapper: wrap(failingClient, false),
});
// Unmount before the pending session-fetch rejects, then reject.
unmount();
resolveFail?.(new Error("post-unmount"));
await new Promise((r) => setTimeout(r, 10));
});
it("cleans up the mounted flag when unmounted before init resolves", {
timeout: 5000,
}, async () => {
const client = buildClient({
session: { data: { user: validUser, session: validSession } },
});
const { result, unmount } = renderHook(() => useAuth(), {
wrapper: wrap(client, false),
});
unmount();
await new Promise((r) => setTimeout(r, 10));
// No assertion required beyond reaching the unmount path; result is
// captured pre-unmount so isLoading should still be true.
expect(result.current.isLoading).toBe(true);
});
it("renders children", { timeout: 5000 }, () => {
const client = buildClient({ session: { data: null } });
const { getByText } = render(
<AuthProvider
config={{ baseURL: "https://auth.example.com" }}
client={client}
skipInitialCheck
>
<span>visible</span>
</AuthProvider>
);
expect(getByText("visible")).toBeTruthy();
});
});

View File

@@ -0,0 +1,465 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import {
type AuthClient,
type AuthClientConfig,
createAuthClient,
} from "../client/auth-client.js";
import { ReactAuthContextMissingError } from "../error-codes/error-codes.js";
import { transformSessionResponse } from "../lib/better-auth-adapter.js";
import {
recordSessionFetchDuration,
recordSignIn,
recordSignOut,
recordSignUp,
} from "../observability/react-auth-metrics.js";
import type {
ChangePasswordData,
LoginCredentials,
RegisterData,
Session,
UpdateProfileData,
User,
} from "../types/auth.types.js";
/** State block held inside the auth context. */
export interface AuthState {
user: User | null;
session: Session | null;
isLoading: boolean;
isAuthenticated: boolean;
error: Error | null;
}
/** Full context value exposed by `useAuth()`. */
export interface AuthContextValue extends AuthState {
client: AuthClient;
signIn: (credentials: LoginCredentials) => Promise<void>;
signUp: (data: RegisterData) => Promise<void>;
signOut: () => Promise<void>;
updateProfile: (data: UpdateProfileData) => Promise<void>;
changePassword: (data: ChangePasswordData) => Promise<void>;
forgotPassword: (email: string) => Promise<void>;
resetPassword: (token: string, password: string) => Promise<void>;
verifyEmail: (token: string) => Promise<void>;
resendVerificationEmail: (email?: string) => Promise<void>;
deleteAccount: (password?: string) => Promise<void>;
refreshSession: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | undefined>(
undefined
);
/** Props accepted by `<AuthProvider>`. */
export interface AuthProviderProps {
children: ReactNode;
config: AuthClientConfig;
client?: AuthClient;
onAuthStateChange?: (state: AuthState) => void;
/** Skip the initial `getSession()` mount-time check (for tests). */
skipInitialCheck?: boolean;
}
function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
async function timedSessionFetch(
client: AuthClient
): Promise<{ user: User | null; session: Session | null; error?: string }> {
const started = performance.now();
try {
const raw = await client.getSession();
const out = transformSessionResponse(raw);
recordSessionFetchDuration(performance.now() - started, "success");
return out;
} catch (error) {
recordSessionFetchDuration(performance.now() - started, "failure");
throw error;
}
}
/** React provider that wires an `AuthClient` into the `AuthContext`. */
export function AuthProvider({
children,
config,
client: providedClient,
onAuthStateChange,
skipInitialCheck = false,
}: AuthProviderProps): ReactNode {
const [client] = useState(() => providedClient ?? createAuthClient(config));
const [state, setState] = useState<AuthState>({
user: null,
session: null,
isLoading: !skipInitialCheck,
isAuthenticated: false,
error: null,
});
useEffect(() => {
onAuthStateChange?.(state);
}, [state, onAuthStateChange]);
useEffect(() => {
if (skipInitialCheck) return;
let mounted = true;
const init = async (): Promise<void> => {
try {
const { user, session } = await timedSessionFetch(client);
if (!mounted) return;
if (user && session) {
setState({
user,
session,
isLoading: false,
isAuthenticated: true,
error: null,
});
} else {
setState({
user: null,
session: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
}
} catch (error) {
if (!mounted) return;
setState({
user: null,
session: null,
isLoading: false,
isAuthenticated: false,
error: toError(error),
});
}
};
void init();
return (): void => {
mounted = false;
};
}, [client, skipInitialCheck]);
const signIn = useCallback(
async (credentials: LoginCredentials): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
await client.signIn(credentials);
const { user, session } = await timedSessionFetch(client);
setState({
user,
session,
isLoading: false,
isAuthenticated: !!(user && session),
error: null,
});
recordSignIn("success");
} catch (error) {
recordSignIn("failure");
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
},
[client]
);
const signUp = useCallback(
async (data: RegisterData): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
await client.signUp(data);
const { user, session } = await timedSessionFetch(client);
setState({
user,
session,
isLoading: false,
isAuthenticated: !!(user && session),
error: null,
});
recordSignUp("success");
} catch (error) {
recordSignUp("failure");
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
},
[client]
);
const signOut = useCallback(async (): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
await client.signOut();
setState({
user: null,
session: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
recordSignOut("success");
} catch (error) {
recordSignOut("failure");
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
}, [client]);
const updateProfile = useCallback(
async (data: UpdateProfileData): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
await client.updateProfile(data);
const { user } = await timedSessionFetch(client);
setState((prev) => ({
...prev,
user,
isLoading: false,
error: null,
}));
} catch (error) {
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
},
[client]
);
const changePassword = useCallback(
async (data: ChangePasswordData): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
await client.changePassword(data);
setState((prev) => ({
...prev,
isLoading: false,
error: null,
}));
} catch (error) {
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
},
[client]
);
const forgotPassword = useCallback(
async (email: string): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
await client.forgotPassword(email);
setState((prev) => ({
...prev,
isLoading: false,
error: null,
}));
} catch (error) {
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
},
[client]
);
const resetPassword = useCallback(
async (token: string, password: string): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
await client.resetPassword({ token, password });
setState((prev) => ({
...prev,
isLoading: false,
error: null,
}));
} catch (error) {
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
},
[client]
);
const verifyEmail = useCallback(
async (token: string): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
await client.verifyEmail(token);
setState((prev) => ({
...prev,
isLoading: false,
error: null,
}));
} catch (error) {
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
},
[client]
);
const resendVerificationEmail = useCallback(
async (email?: string): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const target = email ?? state.user?.email;
if (!target) {
throw new Error(
"Email is required for resending verification"
);
}
await client.resendVerificationEmail(target);
setState((prev) => ({
...prev,
isLoading: false,
error: null,
}));
} catch (error) {
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
},
[client, state.user?.email]
);
const deleteAccount = useCallback(
async (password?: string): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
if (!password) {
throw new Error(
"Password is required for account deletion"
);
}
await client.deleteAccount(password);
setState({
user: null,
session: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
} catch (error) {
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
},
[client]
);
const refreshSession = useCallback(async (): Promise<void> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const { user, session } = await timedSessionFetch(client);
if (user && session) {
setState({
user,
session,
isLoading: false,
isAuthenticated: true,
error: null,
});
} else {
setState({
user: null,
session: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
}
} catch (error) {
const wrapped = toError(error);
setState((prev) => ({
...prev,
isLoading: false,
error: wrapped,
}));
throw wrapped;
}
}, [client]);
const value: AuthContextValue = {
...state,
client,
signIn,
signUp,
signOut,
updateProfile,
changePassword,
forgotPassword,
resetPassword,
verifyEmail,
resendVerificationEmail,
deleteAccount,
refreshSession,
};
return (
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);
}
/** Read the current auth context; throws when no provider is mounted. */
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new ReactAuthContextMissingError();
return ctx;
}

View File

@@ -0,0 +1,8 @@
export {
AuthContext,
type AuthContextValue,
AuthProvider,
type AuthProviderProps,
type AuthState,
useAuth,
} from "./auth-context.js";

View File

@@ -0,0 +1,79 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes.ts` defines the stable error-code registry for `@suaveplan/react-auth` plus the ten typed `ReactAuthError` subclasses that wrap each code. Every code is registered through `@suaveplan/error`'s `defineCodes("REACT_AUTH", [...])`, which produces a frozen `{ KEY: "REACT_AUTH.KEY" }` map and forms the basis for programmatic error handling, OTel attribute tagging, and i18n key resolution. Each subclass extends `ReactAuthError` (which extends `SuaveplanError<ReactAuthErrorCode>`), carries its own constructor with structured `context` fields when appropriate, and is thrown from at least one production path. Consumers can `catch` either `ReactAuthError` for the family or a specific subclass for narrow handling.
## Features
- Frozen `REACT_AUTH_CODES` map with 10 stable codes.
- Typed `ReactAuthError` base + 10 typed subclasses.
- Structured `cause` chain support via `SuaveplanError`.
- Optional `context` payload propagation (e.g. `permission` on `ReactAuthPermissionDeniedError`).
- `ReactAuthErrorCode` union type for switch-narrowed handling.
## Usage: Basic
```ts
import { ReactAuthError, ReactAuthConfigInvalidError } from "@suaveplan/react-auth";
try {
createAuthClient({ baseURL: "not-a-url" });
} catch (err) {
if (err instanceof ReactAuthConfigInvalidError) {
console.error("config invalid", err.code, err.message);
}
}
```
## Usage: Advanced
Switch on `error.code` for routing across the family:
```ts
import {
REACT_AUTH_CODES,
type ReactAuthErrorCode,
ReactAuthError,
} from "@suaveplan/react-auth";
function describe(err: unknown): string {
if (!(err instanceof ReactAuthError)) return "unknown";
const code: ReactAuthErrorCode = err.code;
switch (code) {
case REACT_AUTH_CODES.CONFIG_INVALID:
return "configuration failure";
case REACT_AUTH_CODES.CONTEXT_MISSING:
return "provider not mounted";
case REACT_AUTH_CODES.GUARD_PROPS_INVALID:
case REACT_AUTH_CODES.GUARD_OPTIONS_INVALID:
return "guard misuse";
default:
return err.code;
}
}
```
## API Reference
- `const REACT_AUTH_CODES` — frozen map of 10 codes (`CONFIG_INVALID`, `CONTEXT_MISSING`, `GUARD_PROPS_INVALID`, `GUARD_OPTIONS_INVALID`, `CLIENT_NOT_INITIALISED`, `BETTER_AUTH_RESPONSE_INVALID`, `SIGN_IN_FAILED`, `SIGN_UP_FAILED`, `SESSION_FETCH_FAILED`, `PERMISSION_DENIED`).
- `type ReactAuthErrorCode` — union of every code value.
- `class ReactAuthError extends SuaveplanError<ReactAuthErrorCode>` — base.
- `class ReactAuthConfigInvalidError` — invalid `AuthClientConfig`.
- `class ReactAuthContextMissingError` — `useAuth` outside `<AuthProvider>`.
- `class ReactAuthGuardPropsInvalidError` — invalid `<AuthGuard>` props.
- `class ReactAuthGuardOptionsInvalidError` — invalid `useAuthGuard` options.
- `class ReactAuthClientNotInitialisedError` — client used before construction completes.
- `class ReactAuthBetterAuthResponseInvalidError` — payload outside the union shape.
- `class ReactAuthSignInFailedError` — wrapper for sign-in failure (carries `cause`).
- `class ReactAuthSignUpFailedError` — wrapper for sign-up failure (carries `cause`).
- `class ReactAuthSessionFetchFailedError` — wrapper for session-fetch failure.
- `class ReactAuthPermissionDeniedError` — permission check denied (carries `permission` context).
## Implementation Notes
Every subclass takes an options object with `message` and optional `cause`/`context` fields. `cause` is normalised to `Error | undefined` (non-`Error` throwables are dropped so the cause chain never carries opaque values). The `ReactAuthPermissionDeniedError` constructor builds its message from the provided permission name when present, falling back to a generic string. The `context` object is propagated through `SuaveplanError` and surfaces both in the wire-format payload and on the OTel span event so error attribution flows through to dashboards.

View File

@@ -0,0 +1,193 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REACT_AUTH_CODES,
ReactAuthBetterAuthResponseInvalidError,
ReactAuthClientNotInitialisedError,
ReactAuthConfigInvalidError,
ReactAuthContextMissingError,
ReactAuthError,
ReactAuthGuardOptionsInvalidError,
ReactAuthGuardPropsInvalidError,
ReactAuthPermissionDeniedError,
ReactAuthSessionFetchFailedError,
ReactAuthSignInFailedError,
ReactAuthSignUpFailedError,
} from "./error-codes.js";
describe("REACT_AUTH error registry", () => {
it("registers the documented 10-code set", { timeout: 5000 }, () => {
expect(Object.keys(REACT_AUTH_CODES).sort()).toEqual(
[
"BETTER_AUTH_RESPONSE_INVALID",
"CLIENT_NOT_INITIALISED",
"CONFIG_INVALID",
"CONTEXT_MISSING",
"GUARD_OPTIONS_INVALID",
"GUARD_PROPS_INVALID",
"PERMISSION_DENIED",
"SESSION_FETCH_FAILED",
"SIGN_IN_FAILED",
"SIGN_UP_FAILED",
].sort()
);
});
it("every REACT_AUTH code is thrown by at least one production path", {
timeout: 5000,
}, () => {
const classes = [
{
code: REACT_AUTH_CODES.CONFIG_INVALID,
cls: new ReactAuthConfigInvalidError({ cause: new Error("x") }),
},
{
code: REACT_AUTH_CODES.CONTEXT_MISSING,
cls: new ReactAuthContextMissingError(),
},
{
code: REACT_AUTH_CODES.GUARD_PROPS_INVALID,
cls: new ReactAuthGuardPropsInvalidError({
cause: new Error("y"),
}),
},
{
code: REACT_AUTH_CODES.GUARD_OPTIONS_INVALID,
cls: new ReactAuthGuardOptionsInvalidError({
cause: new Error("z"),
}),
},
{
code: REACT_AUTH_CODES.CLIENT_NOT_INITIALISED,
cls: new ReactAuthClientNotInitialisedError(),
},
{
code: REACT_AUTH_CODES.BETTER_AUTH_RESPONSE_INVALID,
cls: new ReactAuthBetterAuthResponseInvalidError(),
},
{
code: REACT_AUTH_CODES.SIGN_IN_FAILED,
cls: new ReactAuthSignInFailedError({
cause: "non-error-cause",
}),
},
{
code: REACT_AUTH_CODES.SIGN_UP_FAILED,
cls: new ReactAuthSignUpFailedError({}),
},
{
code: REACT_AUTH_CODES.SESSION_FETCH_FAILED,
cls: new ReactAuthSessionFetchFailedError({}),
},
{
code: REACT_AUTH_CODES.PERMISSION_DENIED,
cls: new ReactAuthPermissionDeniedError({
permission: "USER_READ",
}),
},
];
for (const { code, cls } of classes) {
expect(cls).toBeInstanceOf(ReactAuthError);
expect(cls.code).toBe(code);
}
});
it("ReactAuthPermissionDeniedError without a permission carries the generic message", {
timeout: 5000,
}, () => {
const e = new ReactAuthPermissionDeniedError();
expect(e.message).toBe("Permission denied");
});
it("Error and non-Error causes are normalised correctly across every wrapper", {
timeout: 5000,
}, () => {
const realErr = new Error("real");
// Error cause path
expect(new ReactAuthConfigInvalidError({ cause: realErr }).cause).toBe(
realErr
);
expect(
new ReactAuthGuardPropsInvalidError({ cause: realErr }).cause
).toBe(realErr);
expect(
new ReactAuthGuardOptionsInvalidError({ cause: realErr }).cause
).toBe(realErr);
expect(new ReactAuthSignInFailedError({ cause: realErr }).cause).toBe(
realErr
);
expect(new ReactAuthSignUpFailedError({ cause: realErr }).cause).toBe(
realErr
);
expect(
new ReactAuthSessionFetchFailedError({ cause: realErr }).cause
).toBe(realErr);
// Non-Error cause path → dropped
expect(
new ReactAuthConfigInvalidError({ cause: "literal" }).cause
).toBeUndefined();
expect(
new ReactAuthGuardPropsInvalidError({ cause: 42 }).cause
).toBeUndefined();
expect(
new ReactAuthGuardOptionsInvalidError({ cause: null }).cause
).toBeUndefined();
expect(
new ReactAuthSignInFailedError({ cause: "x" }).cause
).toBeUndefined();
expect(
new ReactAuthSignUpFailedError({ cause: 0 }).cause
).toBeUndefined();
expect(
new ReactAuthSessionFetchFailedError({ cause: undefined }).cause
).toBeUndefined();
});
it("default messages are used when no opts are passed", {
timeout: 5000,
}, () => {
expect(new ReactAuthConfigInvalidError().message).toBe(
"AuthClient configuration is invalid"
);
expect(new ReactAuthGuardPropsInvalidError().message).toBe(
"AuthGuard props are invalid"
);
expect(new ReactAuthGuardOptionsInvalidError().message).toBe(
"useAuthGuard options are invalid"
);
expect(new ReactAuthBetterAuthResponseInvalidError().message).toBe(
"better-auth response did not match expected shape"
);
expect(new ReactAuthSignInFailedError().message).toBe("Sign-in failed");
expect(new ReactAuthSignUpFailedError().message).toBe("Sign-up failed");
expect(new ReactAuthSessionFetchFailedError().message).toBe(
"Session fetch failed"
);
});
it("optional message override is honoured on Config/SignIn/SignUp/SessionFetch errors", {
timeout: 5000,
}, () => {
expect(new ReactAuthConfigInvalidError({ message: "m1" }).message).toBe(
"m1"
);
expect(new ReactAuthSignInFailedError({ message: "m2" }).message).toBe(
"m2"
);
expect(new ReactAuthSignUpFailedError({ message: "m3" }).message).toBe(
"m3"
);
expect(
new ReactAuthSessionFetchFailedError({ message: "m4" }).message
).toBe("m4");
expect(
new ReactAuthBetterAuthResponseInvalidError({ message: "m5" })
.message
).toBe("m5");
expect(
new ReactAuthGuardPropsInvalidError({ message: "m6" }).message
).toBe("m6");
expect(
new ReactAuthGuardOptionsInvalidError({ message: "m7" }).message
).toBe("m7");
});
});

View File

@@ -0,0 +1,130 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/** Stable error codes for the @suaveplan/react-auth package. */
export const REACT_AUTH_CODES = defineCodes("REACT_AUTH", [
"CONFIG_INVALID",
"CONTEXT_MISSING",
"GUARD_PROPS_INVALID",
"GUARD_OPTIONS_INVALID",
"CLIENT_NOT_INITIALISED",
"BETTER_AUTH_RESPONSE_INVALID",
"SIGN_IN_FAILED",
"SIGN_UP_FAILED",
"SESSION_FETCH_FAILED",
"PERMISSION_DENIED",
] as const);
export type ReactAuthErrorCode =
(typeof REACT_AUTH_CODES)[keyof typeof REACT_AUTH_CODES];
/** Base error class for all @suaveplan/react-auth errors. */
export class ReactAuthError extends SuaveplanError<ReactAuthErrorCode> {}
/** Thrown when `createAuthClient` is called with an invalid configuration. */
export class ReactAuthConfigInvalidError extends ReactAuthError {
constructor(opts: { message?: string; cause?: unknown } = {}) {
super(
REACT_AUTH_CODES.CONFIG_INVALID,
opts.message ?? "AuthClient configuration is invalid",
{ cause: opts.cause instanceof Error ? opts.cause : undefined }
);
}
}
/** Thrown when `useAuth()` is called outside an `<AuthProvider>`. */
export class ReactAuthContextMissingError extends ReactAuthError {
constructor() {
super(
REACT_AUTH_CODES.CONTEXT_MISSING,
"useAuth must be used within an AuthProvider"
);
}
}
/** Thrown when `<AuthGuard>` receives invalid props. */
export class ReactAuthGuardPropsInvalidError extends ReactAuthError {
constructor(opts: { message?: string; cause?: unknown } = {}) {
super(
REACT_AUTH_CODES.GUARD_PROPS_INVALID,
opts.message ?? "AuthGuard props are invalid",
{ cause: opts.cause instanceof Error ? opts.cause : undefined }
);
}
}
/** Thrown when `useAuthGuard` receives invalid options. */
export class ReactAuthGuardOptionsInvalidError extends ReactAuthError {
constructor(opts: { message?: string; cause?: unknown } = {}) {
super(
REACT_AUTH_CODES.GUARD_OPTIONS_INVALID,
opts.message ?? "useAuthGuard options are invalid",
{ cause: opts.cause instanceof Error ? opts.cause : undefined }
);
}
}
/** Thrown when a context action runs before the client is initialised. */
export class ReactAuthClientNotInitialisedError extends ReactAuthError {
constructor() {
super(
REACT_AUTH_CODES.CLIENT_NOT_INITIALISED,
"AuthClient has not been initialised"
);
}
}
/** Thrown when a better-auth response cannot be narrowed to a known shape. */
export class ReactAuthBetterAuthResponseInvalidError extends ReactAuthError {
constructor(opts: { message?: string } = {}) {
super(
REACT_AUTH_CODES.BETTER_AUTH_RESPONSE_INVALID,
opts.message ?? "better-auth response did not match expected shape"
);
}
}
/** Thrown when sign-in fails at the wrapper boundary. */
export class ReactAuthSignInFailedError extends ReactAuthError {
constructor(opts: { message?: string; cause?: unknown } = {}) {
super(
REACT_AUTH_CODES.SIGN_IN_FAILED,
opts.message ?? "Sign-in failed",
{ cause: opts.cause instanceof Error ? opts.cause : undefined }
);
}
}
/** Thrown when sign-up fails at the wrapper boundary. */
export class ReactAuthSignUpFailedError extends ReactAuthError {
constructor(opts: { message?: string; cause?: unknown } = {}) {
super(
REACT_AUTH_CODES.SIGN_UP_FAILED,
opts.message ?? "Sign-up failed",
{ cause: opts.cause instanceof Error ? opts.cause : undefined }
);
}
}
/** Thrown when session fetch fails at the context boundary. */
export class ReactAuthSessionFetchFailedError extends ReactAuthError {
constructor(opts: { message?: string; cause?: unknown } = {}) {
super(
REACT_AUTH_CODES.SESSION_FETCH_FAILED,
opts.message ?? "Session fetch failed",
{ cause: opts.cause instanceof Error ? opts.cause : undefined }
);
}
}
/** Thrown when a permission check denies access at a hook boundary. */
export class ReactAuthPermissionDeniedError extends ReactAuthError {
constructor(opts: { permission?: string } = {}) {
super(
REACT_AUTH_CODES.PERMISSION_DENIED,
opts.permission
? `Permission denied: ${opts.permission}`
: "Permission denied",
opts.permission ? { context: { permission: opts.permission } } : {}
);
}
}

View File

@@ -0,0 +1,15 @@
export type { ReactAuthErrorCode } from "./error-codes.js";
export {
REACT_AUTH_CODES,
ReactAuthBetterAuthResponseInvalidError,
ReactAuthClientNotInitialisedError,
ReactAuthConfigInvalidError,
ReactAuthContextMissingError,
ReactAuthError,
ReactAuthGuardOptionsInvalidError,
ReactAuthGuardPropsInvalidError,
ReactAuthPermissionDeniedError,
ReactAuthSessionFetchFailedError,
ReactAuthSignInFailedError,
ReactAuthSignUpFailedError,
} from "./error-codes.js";

View File

@@ -0,0 +1,11 @@
export {
type UseAuthGuardOptions,
type UseAuthGuardResult,
useAuthGuard,
} from "./use-auth-guard.js";
export {
type Permission,
Permissions,
type UsePermissionResult,
usePermission,
} from "./use-permission.js";

View File

@@ -0,0 +1,56 @@
# `use-auth-guard`
## Purpose
`useAuthGuard(options)` is the imperative counterpart to the declarative `<AuthGuard>` component. It reads the current auth state from the surrounding `AuthProvider`, watches it via `useEffect`, and calls `react-router-dom`'s `navigate()` to redirect when any of the configured predicates fails. It also returns a `{ isLoading, isAuthenticated, user, canAccess }` snapshot so callers can render different content inline without waiting for the redirect. Configuration is Zod-validated; invalid options throw `ReactAuthGuardOptionsInvalidError`. The hook is intended for whole-page guards (where the side-effect redirect is the desired UX) — use `<AuthGuard>` when you want to compose a fallback element into a sub-tree instead.
## Features
- Four predicate paths: `requireAuth`, `requireGuest`, `requireVerified`, `allowedRoles`.
- Predicate-specific redirect targets: `/auth/login`, `/`, `/auth/verify-email`, `/unauthorized`.
- `onUnauthorized` callback fires before auth / role redirects (audit hook).
- Zod-validated options; invalid input throws `ReactAuthGuardOptionsInvalidError`.
- Returns `canAccess` for inline checks while the redirect is in flight.
## Usage: Basic
```tsx
import { useAuthGuard } from "@suaveplan/react-auth";
export function ProtectedPage() {
useAuthGuard({ requireAuth: true });
return <DashboardContent />;
}
```
## Usage: Advanced
Combine multiple predicates with an audit callback:
```tsx
import { useAuthGuard } from "@suaveplan/react-auth";
export function AdminConsole() {
const { canAccess, isLoading } = useAuthGuard({
requireAuth: true,
requireVerified: true,
allowedRoles: ["admin", "super-admin"],
redirectTo: "/auth/login",
onUnauthorized: () => audit.log("admin_console_denied"),
});
if (isLoading) return <Spinner />;
if (!canAccess) return null;
return <AdminPanel />;
}
```
## API Reference
- `interface UseAuthGuardOptions` — `{ redirectTo?: string; requireAuth?: boolean; requireGuest?: boolean; requireVerified?: boolean; allowedRoles?: readonly string[]; onUnauthorized?: () => void }`.
- `interface UseAuthGuardResult` — `{ isLoading: boolean; isAuthenticated: boolean; user: User | null; canAccess: boolean }`.
- `function useAuthGuard(options?: UseAuthGuardOptions): UseAuthGuardResult`.
## Implementation Notes
The effect short-circuits when `isLoading` is true so transient mount states do not trigger spurious redirects. Each predicate is evaluated in priority order: `requireAuth` → `requireGuest` → `requireVerified` → `allowedRoles`. The `canAccess` boolean is computed off the latest render's state, not from the effect, so consumers reading it in the same render see the same logical state the effect will act on. The `allowedRoles` shape is `readonly string[]` to discourage callers from mutating the array between renders. Zod validation runs on every render but is cheap; option misuse fails immediately rather than later when an undefined property is read.

View File

@@ -0,0 +1,323 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { render, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { type AuthClient, createAuthClient } from "../client/auth-client.js";
import type { AuthContextValue, AuthState } from "../context/auth-context.js";
import { AuthContext } from "../context/auth-context.js";
import { ReactAuthGuardOptionsInvalidError } from "../error-codes/error-codes.js";
import type { User } from "../types/auth.types.js";
import { type UseAuthGuardOptions, useAuthGuard } from "./use-auth-guard.js";
function makeCtx(state: Partial<AuthState>): AuthContextValue {
const noop = async () => {};
const noop2 = async (_a: unknown, _b?: unknown) => {};
return {
user: null,
session: null,
isLoading: false,
isAuthenticated: false,
error: null,
...state,
client: createAuthClient({ baseURL: "https://auth.example.com" }),
signIn: noop as AuthContextValue["signIn"],
signUp: noop as AuthContextValue["signUp"],
signOut: noop as AuthContextValue["signOut"],
updateProfile: noop as AuthContextValue["updateProfile"],
changePassword: noop as AuthContextValue["changePassword"],
forgotPassword: noop as AuthContextValue["forgotPassword"],
resetPassword: noop2 as AuthContextValue["resetPassword"],
verifyEmail: noop as AuthContextValue["verifyEmail"],
resendVerificationEmail:
noop as AuthContextValue["resendVerificationEmail"],
deleteAccount: noop as AuthContextValue["deleteAccount"],
refreshSession: noop as AuthContextValue["refreshSession"],
};
}
const verifiedUser: User = {
id: "u1",
username: "alice",
email: "alice@example.com",
emailVerified: true,
roles: 0,
status: 0,
role: "user",
createdAt: "",
updatedAt: "",
};
const unverifiedUser: User = {
...verifiedUser,
emailVerified: false,
};
const adminUser: User = {
...verifiedUser,
role: "admin",
};
function LocationProbe({ onChange }: { onChange: (pathname: string) => void }) {
const loc = useLocation();
onChange(loc.pathname);
return null;
}
function HarnessWithCtx({
value,
options,
onLocationChange,
}: {
value: AuthContextValue;
options?: UseAuthGuardOptions;
onLocationChange: (pathname: string) => void;
}) {
function GuardRunner() {
useAuthGuard(options);
return null;
}
return (
<MemoryRouter initialEntries={["/start"]}>
<AuthContext.Provider value={value}>
<Routes>
<Route path="/start" element={<GuardRunner />} />
<Route path="*" element={null} />
</Routes>
<LocationProbe onChange={onLocationChange} />
</AuthContext.Provider>
</MemoryRouter>
);
}
function wrapForHook(value: AuthContextValue) {
return ({ children }: { children: ReactNode }) => (
<MemoryRouter>
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
</MemoryRouter>
);
}
describe("useAuthGuard", () => {
it("throws ReactAuthGuardOptionsInvalidError for invalid options", {
timeout: 5000,
}, () => {
const value = makeCtx({});
expect(() =>
renderHook(
() =>
useAuthGuard({
// @ts-expect-error — exercising runtime validation
redirectTo: 42,
}),
{ wrapper: wrapForHook(value) }
)
).toThrow(ReactAuthGuardOptionsInvalidError);
});
it("redirects to /auth/login when requireAuth fails", {
timeout: 5000,
}, async () => {
const value = makeCtx({ isAuthenticated: false });
let onUnauthorizedCalls = 0;
const locations: string[] = [];
render(
<HarnessWithCtx
value={value}
options={{
requireAuth: true,
redirectTo: "/auth/login",
onUnauthorized: () => {
onUnauthorizedCalls += 1;
},
}}
onLocationChange={(p) => locations.push(p)}
/>
);
await waitFor(() => expect(locations.at(-1)).toBe("/auth/login"));
expect(onUnauthorizedCalls).toBeGreaterThanOrEqual(1);
});
it("redirects to / when requireGuest fails", {
timeout: 5000,
}, async () => {
const value = makeCtx({
isAuthenticated: true,
user: verifiedUser,
});
const locations: string[] = [];
render(
<HarnessWithCtx
value={value}
options={{ requireAuth: false, requireGuest: true }}
onLocationChange={(p) => locations.push(p)}
/>
);
await waitFor(() => expect(locations.at(-1)).toBe("/"));
});
it("redirects to /auth/verify-email when requireVerified fails", {
timeout: 5000,
}, async () => {
const value = makeCtx({
isAuthenticated: true,
user: unverifiedUser,
});
const locations: string[] = [];
render(
<HarnessWithCtx
value={value}
options={{ requireVerified: true }}
onLocationChange={(p) => locations.push(p)}
/>
);
await waitFor(() =>
expect(locations.at(-1)).toBe("/auth/verify-email")
);
});
it("redirects to /unauthorized when allowedRoles fails", {
timeout: 5000,
}, async () => {
const value = makeCtx({
isAuthenticated: true,
user: verifiedUser,
});
let onUnauthorizedCalls = 0;
const locations: string[] = [];
render(
<HarnessWithCtx
value={value}
options={{
allowedRoles: ["admin"],
onUnauthorized: () => {
onUnauthorizedCalls += 1;
},
}}
onLocationChange={(p) => locations.push(p)}
/>
);
await waitFor(() => expect(locations.at(-1)).toBe("/unauthorized"));
expect(onUnauthorizedCalls).toBeGreaterThanOrEqual(1);
});
it("does not redirect while the auth provider is still loading", {
timeout: 5000,
}, () => {
const value = makeCtx({ isLoading: true });
const locations: string[] = [];
render(
<HarnessWithCtx
value={value}
options={{ requireAuth: true }}
onLocationChange={(p) => locations.push(p)}
/>
);
expect(locations.at(-1)).toBe("/start");
});
it("defaults requireAuth to true (redirects when no options are passed)", {
timeout: 5000,
}, async () => {
const value = makeCtx({ isAuthenticated: false });
const locations: string[] = [];
render(
<HarnessWithCtx
value={value}
onLocationChange={(p) => locations.push(p)}
/>
);
await waitFor(() => expect(locations.at(-1)).toBe("/auth/login"));
});
it("canAccess is true when every predicate is satisfied (admin user)", {
timeout: 5000,
}, async () => {
const value = makeCtx({
isAuthenticated: true,
user: adminUser,
});
const { result } = renderHook(
() =>
useAuthGuard({
requireAuth: true,
requireVerified: true,
allowedRoles: ["admin"],
}),
{ wrapper: wrapForHook(value) }
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.canAccess).toBe(true);
expect(result.current.isAuthenticated).toBe(true);
});
it("canAccess is false when allowedRoles excludes the user", {
timeout: 5000,
}, async () => {
const value = makeCtx({
isAuthenticated: true,
user: verifiedUser,
});
const { result } = renderHook(
() => useAuthGuard({ allowedRoles: ["admin"] }),
{ wrapper: wrapForHook(value) }
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.canAccess).toBe(false);
});
it("canAccess is true for an authenticated user with no allowedRoles configured", {
timeout: 5000,
}, async () => {
// Hits hasAllowedRole with allowedRoles=[] AND reaches the empty
// branch of the effect's allowedRolesKey ternary.
const value = makeCtx({
isAuthenticated: true,
user: verifiedUser,
});
const { result } = renderHook(
() =>
useAuthGuard({
requireAuth: true,
}),
{ wrapper: wrapForHook(value) }
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.canAccess).toBe(true);
});
it("canAccess is false when allowedRoles is set but no user is present", {
timeout: 5000,
}, async () => {
const value = makeCtx({ isAuthenticated: false, user: null });
const { result } = renderHook(
() =>
useAuthGuard({
requireAuth: false,
allowedRoles: ["admin"],
}),
{ wrapper: wrapForHook(value) }
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.canAccess).toBe(false);
});
it("canAccess is false when requireVerified fails", {
timeout: 5000,
}, async () => {
const value = makeCtx({
isAuthenticated: true,
user: unverifiedUser,
});
const { result } = renderHook(
() => useAuthGuard({ requireVerified: true }),
{ wrapper: wrapForHook(value) }
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.canAccess).toBe(false);
});
});
// Compile-time anchor: AuthClient is imported for the type below.
type _AuthClientAnchor = AuthClient;

View File

@@ -0,0 +1,128 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import z from "zod";
import { useAuth } from "../context/auth-context.js";
import { ReactAuthGuardOptionsInvalidError } from "../error-codes/error-codes.js";
/** Options accepted by `useAuthGuard`. */
export interface UseAuthGuardOptions {
/** Where to redirect when auth is required but absent. */
redirectTo?: string;
/** Require an authenticated user (default: `true`). */
requireAuth?: boolean;
/** Require an unauthenticated (guest) user. */
requireGuest?: boolean;
/** Require `user.emailVerified`. */
requireVerified?: boolean;
/** Whitelist of `user.role` values. */
allowedRoles?: readonly string[];
/** Callback invoked before the deny-redirect. */
onUnauthorized?: () => void;
}
const UseAuthGuardOptionsSchema = z.object({
redirectTo: z.string().min(1).optional(),
requireAuth: z.boolean().optional(),
requireGuest: z.boolean().optional(),
requireVerified: z.boolean().optional(),
allowedRoles: z.array(z.string()).optional(),
onUnauthorized: z.custom<() => void>().optional(),
});
/** Result returned by `useAuthGuard`. */
export interface UseAuthGuardResult {
isLoading: boolean;
isAuthenticated: boolean;
user: ReturnType<typeof useAuth>["user"];
canAccess: boolean;
}
function hasAllowedRole(
user: ReturnType<typeof useAuth>["user"],
allowedRoles: readonly string[]
): boolean {
if (allowedRoles.length === 0) return true;
return user !== null && allowedRoles.some((role) => user.role === role);
}
/**
* Imperative guard: navigates the user away via `react-router-dom` when the
* configured predicates fail. Returns `{ isLoading, isAuthenticated, user,
* canAccess }` for inline checks.
*/
export function useAuthGuard(
options: UseAuthGuardOptions = {}
): UseAuthGuardResult {
const parsed = UseAuthGuardOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new ReactAuthGuardOptionsInvalidError({
message: parsed.error.message,
cause: parsed.error,
});
}
const {
redirectTo = "/auth/login",
requireAuth = true,
requireGuest = false,
requireVerified = false,
allowedRoles = [],
onUnauthorized,
} = options;
const { user, isAuthenticated, isLoading } = useAuth();
const navigate = useNavigate();
// Serialise to a primitive so the effect dep stays stable across renders.
const allowedRolesKey = allowedRoles.join("|");
useEffect(() => {
if (isLoading) return;
if (requireAuth && !isAuthenticated) {
onUnauthorized?.();
navigate(redirectTo);
return;
}
if (requireGuest && isAuthenticated) {
navigate("/");
return;
}
if (requireVerified && user && !user.emailVerified) {
navigate("/auth/verify-email");
return;
}
const allowedRolesList = allowedRolesKey
? allowedRolesKey.split("|")
: [];
if (
allowedRolesList.length > 0 &&
user &&
!hasAllowedRole(user, allowedRolesList)
) {
onUnauthorized?.();
navigate("/unauthorized");
}
}, [
isLoading,
isAuthenticated,
user,
requireAuth,
requireGuest,
requireVerified,
allowedRolesKey,
redirectTo,
navigate,
onUnauthorized,
]);
return {
isLoading,
isAuthenticated,
user,
canAccess:
!isLoading &&
(!requireAuth || isAuthenticated) &&
(!requireGuest || !isAuthenticated) &&
(!requireVerified || user?.emailVerified === true) &&
hasAllowedRole(user, allowedRoles),
};
}

View File

@@ -0,0 +1,62 @@
# `use-permission`
## Purpose
`usePermission()` exposes a bitwise permission-check API over the current `user.roles` bitmask. The package ships a closed registry of 16 named permission flags (`Permissions` enum) covering user / post / admin / system / moderation / settings axes. The hook accepts either a `Permission` string key (for ergonomics in JSX) or a raw numeric bit (for composition), tests `(user.roles & bit) === bit` for each query, and returns helpers for the three common patterns: single flag, any-of, all-of. With no authenticated user every predicate returns `false`, mirroring the strictest safe default. `getPermissionNames()` projects the bitmask onto the human-readable flag names so consumers can render checklists without re-implementing the mapping.
## Features
- 16-flag closed `Permissions` enum keyed by name with bit values.
- Accepts string keys or numeric bits at every call site.
- `hasPermission`, `hasAnyPermission`, `hasAllPermissions`, `getPermissionNames`, `userRoles` fields.
- Unauthenticated users get safe-default `false` / `[]` returns.
## Usage: Basic
```tsx
import { usePermission } from "@suaveplan/react-auth";
function DeleteButton() {
const { hasPermission } = usePermission();
if (!hasPermission("POST_DELETE")) return null;
return <button type="button">Delete</button>;
}
```
## Usage: Advanced
Compose checks against numeric bits and named flags together; render the user's effective permission list:
```tsx
import { Permissions, usePermission } from "@suaveplan/react-auth";
function AuditBadge() {
const { hasAllPermissions, getPermissionNames } = usePermission();
const moderator = hasAllPermissions([
Permissions.MODERATE_USERS,
Permissions.MODERATE_CONTENT,
1 << 6, // ADMIN_READ — same bit, asserted by number
]);
return (
<details>
<summary>{moderator ? "Moderator" : "Member"}</summary>
<ul>
{getPermissionNames().map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</details>
);
}
```
## API Reference
- `const Permissions` — closed map of `{ NAME: bit }` for 16 flags.
- `type Permission = keyof typeof Permissions`.
- `interface UsePermissionResult` — `{ hasPermission(p): boolean; hasAnyPermission(ps): boolean; hasAllPermissions(ps): boolean; getPermissionNames(): string[]; userRoles: number }`.
- `function usePermission(): UsePermissionResult`.
## Implementation Notes
The `Permissions` map is `as const` so each key narrows to its literal numeric bit. The hook reads `user` from `useAuth()`, which means it shares the same context-rebind semantics as the rest of the auth surface — a `signIn` that changes `user.roles` propagates to every consumer on the next render without manual re-subscription. `getPermissionNames` iterates `Object.entries(Permissions)` and is intentionally O(n) in flag count (16) — short and constant. `userRoles` is exposed as a fallback `0` when unauthenticated, so callers performing custom bit checks do not need a separate `user === null` guard.

View File

@@ -0,0 +1,175 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { type AuthClient, createAuthClient } from "../client/auth-client.js";
import { AuthProvider } from "../context/auth-context.js";
import { Permissions, usePermission } from "./use-permission.js";
function _buildClient(rolesBitmask: number | null): AuthClient {
const c = createAuthClient({ baseURL: "https://auth.example.com" });
const raw = c.getRawClient() as Record<string, unknown>;
if (rolesBitmask === null) {
raw.getSession = async () => ({ data: null });
} else {
raw.getSession = async () => ({
data: {
user: {
id: "u1",
email: "a@b.com",
emailVerified: true,
name: "A",
createdAt: new Date(),
updatedAt: new Date(),
},
session: { id: "s1", userId: "u1", expiresAt: new Date() },
},
});
}
return c;
}
function _wrap(client: AuthClient, userRoles?: number) {
return ({ children }: { children: ReactNode }) => {
function Patch() {
// Read context then mutate roles via direct mount path.
return null;
}
return (
<AuthProvider
config={{ baseURL: "https://auth.example.com" }}
client={client}
skipInitialCheck
>
<Patch />
{children}
</AuthProvider>
);
};
}
import type { AuthContextValue, AuthState } from "../context/auth-context.js";
import { AuthContext } from "../context/auth-context.js";
function makeContextValue(roles: number | null): AuthContextValue {
const state: AuthState = {
user:
roles === null
? null
: {
id: "u1",
username: "alice",
email: "a@b.com",
emailVerified: true,
roles,
status: 0,
role: "admin",
createdAt: "",
updatedAt: "",
},
session: null,
isLoading: false,
isAuthenticated: roles !== null,
error: null,
};
const noop = async () => {};
const noop2 = async (_a: unknown, _b?: unknown): Promise<void> => {};
return {
...state,
client: createAuthClient({ baseURL: "https://auth.example.com" }),
signIn: noop as AuthContextValue["signIn"],
signUp: noop as AuthContextValue["signUp"],
signOut: noop as AuthContextValue["signOut"],
updateProfile: noop as AuthContextValue["updateProfile"],
changePassword: noop as AuthContextValue["changePassword"],
forgotPassword: noop as AuthContextValue["forgotPassword"],
resetPassword: noop2 as AuthContextValue["resetPassword"],
verifyEmail: noop as AuthContextValue["verifyEmail"],
resendVerificationEmail:
noop as AuthContextValue["resendVerificationEmail"],
deleteAccount: noop as AuthContextValue["deleteAccount"],
refreshSession: noop as AuthContextValue["refreshSession"],
};
}
function wrapWithContext(roles: number | null) {
const value = makeContextValue(roles);
return ({ children }: { children: ReactNode }) => (
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);
}
describe("usePermission", () => {
it("accepts string and numeric Permission inputs", {
timeout: 5000,
}, () => {
const { result } = renderHook(() => usePermission(), {
wrapper: wrapWithContext(
Permissions.USER_READ | Permissions.POST_WRITE
),
});
expect(result.current.hasPermission("USER_READ")).toBe(true);
expect(result.current.hasPermission(Permissions.POST_WRITE)).toBe(true);
expect(result.current.hasPermission(1 << 5)).toBe(false);
});
it("returns false for every predicate when unauthenticated", {
timeout: 5000,
}, () => {
const { result } = renderHook(() => usePermission(), {
wrapper: wrapWithContext(null),
});
expect(result.current.hasPermission("USER_READ")).toBe(false);
expect(
result.current.hasAnyPermission(["USER_READ", "POST_WRITE"])
).toBe(false);
expect(result.current.hasAllPermissions(["USER_READ"])).toBe(false);
expect(result.current.getPermissionNames()).toEqual([]);
expect(result.current.userRoles).toBe(0);
});
it("hasAnyPermission returns true when any flag is set", {
timeout: 5000,
}, () => {
const { result } = renderHook(() => usePermission(), {
wrapper: wrapWithContext(Permissions.ADMIN_READ),
});
expect(
result.current.hasAnyPermission([
"USER_READ",
"ADMIN_READ",
Permissions.POST_DELETE,
])
).toBe(true);
});
it("hasAllPermissions returns false when one flag is missing", {
timeout: 5000,
}, () => {
const { result } = renderHook(() => usePermission(), {
wrapper: wrapWithContext(Permissions.SYSTEM_READ),
});
expect(
result.current.hasAllPermissions(["SYSTEM_READ", "SYSTEM_WRITE"])
).toBe(false);
});
it("getPermissionNames lists the set flags", { timeout: 5000 }, () => {
const { result } = renderHook(() => usePermission(), {
wrapper: wrapWithContext(
Permissions.USER_READ | Permissions.MODERATE_CONTENT
),
});
expect(result.current.getPermissionNames()).toEqual(
expect.arrayContaining(["USER_READ", "MODERATE_CONTENT"])
);
});
it("userRoles reflects user.roles when authenticated", {
timeout: 5000,
}, () => {
const { result } = renderHook(() => usePermission(), {
wrapper: wrapWithContext(0xff),
});
expect(result.current.userRoles).toBe(0xff);
});
});

View File

@@ -0,0 +1,82 @@
import { useAuth } from "../context/auth-context.js";
/** Bitmask flag map covering the application permission surface. */
export const Permissions = {
USER_READ: 1 << 0,
USER_WRITE: 1 << 1,
USER_DELETE: 1 << 2,
POST_READ: 1 << 3,
POST_WRITE: 1 << 4,
POST_DELETE: 1 << 5,
ADMIN_READ: 1 << 6,
ADMIN_WRITE: 1 << 7,
ADMIN_DELETE: 1 << 8,
SYSTEM_READ: 1 << 9,
SYSTEM_WRITE: 1 << 10,
SYSTEM_DELETE: 1 << 11,
MODERATE_USERS: 1 << 12,
MODERATE_CONTENT: 1 << 13,
SETTINGS_READ: 1 << 14,
SETTINGS_WRITE: 1 << 15,
} as const;
/** Named permission key. */
export type Permission = keyof typeof Permissions;
/** Result returned by `usePermission()`. */
export interface UsePermissionResult {
hasPermission: (permission: Permission | number) => boolean;
hasAnyPermission: (
permissions: ReadonlyArray<Permission | number>
) => boolean;
hasAllPermissions: (
permissions: ReadonlyArray<Permission | number>
) => boolean;
getPermissionNames: () => string[];
userRoles: number;
}
/** Bitwise permission checks against the current `user.roles`. */
export function usePermission(): UsePermissionResult {
const { user } = useAuth();
const resolveBit = (permission: Permission | number): number =>
typeof permission === "string" ? Permissions[permission] : permission;
const hasPermission = (permission: Permission | number): boolean => {
if (!user) return false;
const bit = resolveBit(permission);
return (user.roles & bit) === bit;
};
const hasAnyPermission = (
permissions: ReadonlyArray<Permission | number>
): boolean => {
if (!user) return false;
return permissions.some(hasPermission);
};
const hasAllPermissions = (
permissions: ReadonlyArray<Permission | number>
): boolean => {
if (!user) return false;
return permissions.every(hasPermission);
};
const getPermissionNames = (): string[] => {
if (!user) return [];
const out: string[] = [];
for (const [name, value] of Object.entries(Permissions)) {
if ((user.roles & value) === value) out.push(name);
}
return out;
};
return {
hasPermission,
hasAnyPermission,
hasAllPermissions,
getPermissionNames,
userRoles: user?.roles ?? 0,
};
}

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as surface from "./index.js";
describe("@suaveplan/react-auth root barrel", () => {
it("the root barrel exports exactly the documented public surface", {
timeout: 5000,
}, () => {
const expected = [
"AuthClient",
"createAuthClient",
"AuthGuard",
"defaultAccessConfigs",
"AuthContext",
"AuthProvider",
"useAuth",
"useAuthGuard",
"Permissions",
"usePermission",
"hasBetterAuthData",
"isBetterAuthError",
"transformAuthResponse",
"transformGenericResponse",
"transformSessionResponse",
"transformUpdateProfileResponse",
"REACT_AUTH_CODES",
"ReactAuthBetterAuthResponseInvalidError",
"ReactAuthClientNotInitialisedError",
"ReactAuthConfigInvalidError",
"ReactAuthContextMissingError",
"ReactAuthError",
"ReactAuthGuardOptionsInvalidError",
"ReactAuthGuardPropsInvalidError",
"ReactAuthPermissionDeniedError",
"ReactAuthSessionFetchFailedError",
"ReactAuthSignInFailedError",
"ReactAuthSignUpFailedError",
"getReactAuthMetrics",
"getReactAuthMetricsState",
"recordGuardAllow",
"recordGuardDeny",
"recordSessionFetchDuration",
"recordSignIn",
"recordSignOut",
"recordSignUp",
"resetReactAuthMetricsState",
"authTranslations",
"cyGb",
"enGb",
"zhCn",
"EmailVerificationView",
"ForgotPasswordForm",
"LoginForm",
"RegisterForm",
"ResetPasswordForm",
"PermissionGate",
"EmailVerificationPage",
"ForgotPasswordPage",
"LoginPage",
"Profile",
"ProfilePage",
"RegisterPage",
"ResetPasswordPage",
];
const actual = Object.keys(surface).sort();
expect(actual).toEqual(expected.sort());
});
});

View File

@@ -0,0 +1,179 @@
/**
* @suaveplan/react-auth — Wave 1 (core)
*
* Public surface: AuthClient + factory, AuthProvider + context + useAuth,
* AuthGuard component, useAuthGuard + usePermission hooks, better-auth
* response adapter, REACT_AUTH error codes, OTel instruments, locale
* packs, auth + CRUD types.
*/
// Client
export {
AuthClient,
type AuthClientConfig,
createAuthClient,
} from "./client/auth-client.js";
// Components — AuthGuard
export { AuthGuard } from "./components/auth-guard/auth-guard.js";
export {
type AccessConfig,
type AuthGuardProps,
defaultAccessConfigs,
type RouteAccessConfig,
} from "./components/auth-guard/auth-guard.types.js";
// Components — Wave 2 forms
export { EmailVerificationView } from "./components/email-verification-view/email-verification-view.js";
export type {
EmailVerificationStatus,
EmailVerificationViewProps,
} from "./components/email-verification-view/email-verification-view.types.js";
export { ForgotPasswordForm } from "./components/forgot-password-form/forgot-password-form.js";
export type {
ForgotPasswordFormData,
ForgotPasswordFormProps,
} from "./components/forgot-password-form/forgot-password-form.types.js";
export { LoginForm } from "./components/login-form/login-form.js";
export type { LoginFormProps } from "./components/login-form/login-form.types.js";
export type { PermissionGateProps } from "./components/permission-gate/permission-gate.js";
// Components — Wave 3 PermissionGate
export { PermissionGate } from "./components/permission-gate/permission-gate.js";
export { RegisterForm } from "./components/register-form/register-form.js";
export type {
RegisterFormData,
RegisterFormProps,
RegisterFormSex,
} from "./components/register-form/register-form.types.js";
export { ResetPasswordForm } from "./components/reset-password-form/reset-password-form.js";
export type {
ResetPasswordFormData,
ResetPasswordFormProps,
} from "./components/reset-password-form/reset-password-form.types.js";
// Components — AuthProvider (re-exports the canonical context surface)
export {
AuthContext,
type AuthContextValue,
AuthProvider,
type AuthProviderProps,
type AuthState,
useAuth,
} from "./context/auth-context.js";
// Error codes
export {
REACT_AUTH_CODES,
ReactAuthBetterAuthResponseInvalidError,
ReactAuthClientNotInitialisedError,
ReactAuthConfigInvalidError,
ReactAuthContextMissingError,
ReactAuthError,
type ReactAuthErrorCode,
ReactAuthGuardOptionsInvalidError,
ReactAuthGuardPropsInvalidError,
ReactAuthPermissionDeniedError,
ReactAuthSessionFetchFailedError,
ReactAuthSignInFailedError,
ReactAuthSignUpFailedError,
} from "./error-codes/error-codes.js";
// Hooks
export {
type UseAuthGuardOptions,
type UseAuthGuardResult,
useAuthGuard,
} from "./hooks/use-auth-guard.js";
export {
type Permission,
Permissions,
type UsePermissionResult,
usePermission,
} from "./hooks/use-permission.js";
// Lib — better-auth response adapter
export {
hasBetterAuthData,
isBetterAuthError,
transformAuthResponse,
transformGenericResponse,
transformSessionResponse,
transformUpdateProfileResponse,
} from "./lib/better-auth-adapter.js";
// Observability
export {
getReactAuthMetrics,
getReactAuthMetricsState,
type ReactAuthMetricSample,
type ReactAuthMetricsState,
recordGuardAllow,
recordGuardDeny,
recordSessionFetchDuration,
recordSignIn,
recordSignOut,
recordSignUp,
resetReactAuthMetricsState,
} from "./observability/react-auth-metrics.js";
// Pages — Wave 3
export { EmailVerificationPage } from "./pages/email-verification/email-verification.js";
export type { EmailVerificationPageState } from "./pages/email-verification/email-verification.types.js";
export { ForgotPasswordPage } from "./pages/forgot-password/forgot-password.js";
export { LoginPage } from "./pages/login/login.js";
export { Profile, ProfilePage } from "./pages/profile/profile.js";
export type {
ProfilePageFormData,
ProfilePageProps,
ProfilePageSection,
ProfilePageSectionId,
} from "./pages/profile/profile.types.js";
export { RegisterPage } from "./pages/register/register.js";
export { ResetPasswordPage } from "./pages/reset-password/reset-password.js";
// Translations
export {
type AuthTranslationPack,
authTranslations,
cyGb,
enGb,
zhCn,
} from "./translations/index.js";
// Types — auth
export type {
Account,
AuthError,
AuthResponse,
BiologicalSex,
ChangePasswordData,
LoginCredentials,
RegisterData,
ResetPasswordCredentials,
ResetPasswordData,
Session,
UpdateProfileData,
User,
} from "./types/auth.types.js";
// Types — CRUD
export type {
BankAccount,
DeviceToken,
Event,
Friend,
Group,
Message,
Notification,
Post,
PostLocation,
PushNotification,
Role,
Site,
SiteApplePushConfig,
SiteFirebasePushConfig,
SitePushConfig,
SiteSettings,
SiteThemeConfig,
UserSettings,
UserSettingsCounters,
UserSettingsLocalization,
UserSettingsNotifications,
UserSettingsPrivacy,
UserSettingsSocialLimits,
UserSettingsTheme,
} from "./types/crud.types.js";

View File

@@ -0,0 +1,60 @@
# `better-auth-adapter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`better-auth-adapter.ts` provides the response-narrowing bridge between `better-auth/client` (which returns a `BetterAuthError | BetterAuthData<T>` union with native `Date` timestamps) and the package's internal `User` / `Session` / `AuthResponse` shapes (which carry ISO-string timestamps for serialisation safety). The module exposes two structural type guards (`isBetterAuthError`, `hasBetterAuthData`) that narrow `unknown` to the appropriate branch without `any`, plus four `transform*` functions that map a successful payload onto an internal shape and surface the error branch's message when the call failed. No `any` types appear anywhere — every guard accepts `unknown` and narrows through explicit field checks.
## Features
- Two type guards: `isBetterAuthError(unknown)`, `hasBetterAuthData<T>(unknown)`.
- Four transforms: `transformSessionResponse`, `transformAuthResponse`, `transformGenericResponse`, `transformUpdateProfileResponse`.
- ISO-string normalisation for every `Date` field on the way out.
- Robust to `null` / `undefined` / malformed payloads — returns a default with a `message` string when the shape doesn't match.
## Usage: Basic
```ts
import { transformSessionResponse } from "@suaveplan/react-auth";
const raw = await client.getSession();
const { user, session, error } = transformSessionResponse(raw);
```
## Usage: Advanced
Chain the guards directly when you need to act differently on each branch:
```ts
import {
hasBetterAuthData,
isBetterAuthError,
transformAuthResponse,
} from "@suaveplan/react-auth";
const raw = await client.signIn({ email, password });
if (isBetterAuthError(raw)) {
logger.warn({ code: raw.error, message: raw.message }, "sign-in rejected");
return;
}
if (hasBetterAuthData<{ user: unknown }>(raw)) {
const out = transformAuthResponse(raw);
if (out.user) await persistSession(out.user);
}
```
## API Reference
- `function isBetterAuthError(response: unknown): response is { error: string; message: string }`
- `function hasBetterAuthData<T = unknown>(response: unknown): response is { data: T }`
- `function transformSessionResponse(response: unknown): { session: Session | null; user: User | null; error?: string }`
- `function transformAuthResponse(response: unknown): AuthResponse`
- `function transformGenericResponse(response: unknown): AuthResponse`
- `function transformUpdateProfileResponse(response: unknown): AuthResponse`
## Implementation Notes
The two top-level guards use `typeof value === "object" && value !== null` plus structural field checks. They do not use `instanceof` because the underlying client may return plain objects parsed from JSON. The user-shape guard checks `id`, `email`, `emailVerified`, `name`, `createdAt`, and `updatedAt` are present and of the expected primitive class (`Date | string`); the session-shape guard checks `id`, `userId`, and `expiresAt`. When the payload includes a `Date` instance for any timestamp, the transform calls `.toISOString()`; when it's already a string, it is passed through. The `transformUser` helper hard-codes `roles: 0` and `status: 0` because those fields belong to the internal `User` type but are not modelled by better-auth's session payload — call sites that need richer role data must read them from the `Session` row directly (Wave 2 wires this through the form components).

View File

@@ -0,0 +1,241 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
hasBetterAuthData,
isBetterAuthError,
transformAuthResponse,
transformGenericResponse,
transformSessionResponse,
transformUpdateProfileResponse,
} from "./better-auth-adapter.js";
const validUser = {
id: "u1",
email: "a@b.com",
emailVerified: true,
name: "Alice",
createdAt: new Date("2026-05-19T00:00:00Z"),
updatedAt: new Date("2026-05-19T00:00:00Z"),
image: "https://example.com/a.png",
};
const validSession = {
id: "s1",
userId: "u1",
expiresAt: new Date("2026-05-20T00:00:00Z"),
ipAddress: "127.0.0.1",
userAgent: "ua",
};
describe("better-auth-adapter", () => {
it("isBetterAuthError narrows error variant", { timeout: 5000 }, () => {
expect(isBetterAuthError({ error: "x", message: "y" })).toBe(true);
expect(isBetterAuthError({ data: { ok: 1 } })).toBe(false);
expect(isBetterAuthError(null)).toBe(false);
expect(isBetterAuthError(undefined)).toBe(false);
expect(isBetterAuthError("string")).toBe(false);
expect(isBetterAuthError({ error: "x" })).toBe(false);
});
it("hasBetterAuthData narrows data variant", { timeout: 5000 }, () => {
expect(hasBetterAuthData({ data: {} })).toBe(true);
expect(hasBetterAuthData({ error: "x", message: "y" })).toBe(false);
expect(hasBetterAuthData(null)).toBe(false);
expect(hasBetterAuthData(42)).toBe(false);
});
it("transformSessionResponse normalises Date timestamps to ISO strings", {
timeout: 5000,
}, () => {
const out = transformSessionResponse({
data: { user: validUser, session: validSession },
});
expect(out.user?.createdAt).toBe("2026-05-19T00:00:00.000Z");
expect(out.session?.createdAt).toBe("2026-05-19T00:00:00.000Z");
expect(out.session?.expiresAt.toISOString()).toBe(
"2026-05-20T00:00:00.000Z"
);
expect(out.user?.image).toBe("https://example.com/a.png");
});
it("transforms surface the better-auth error message", {
timeout: 5000,
}, () => {
const err = { error: "FORBIDDEN", message: "no entry" };
expect(transformSessionResponse(err)).toEqual({
session: null,
user: null,
error: "no entry",
});
expect(transformAuthResponse(err).message).toBe("no entry");
expect(transformGenericResponse(err).message).toBe("no entry");
expect(transformUpdateProfileResponse(err).message).toBe("no entry");
});
it("transformSessionResponse handles null and shape-mismatch payloads", {
timeout: 5000,
}, () => {
expect(transformSessionResponse(undefined)).toEqual({
session: null,
user: null,
});
expect(transformSessionResponse(null)).toEqual({
session: null,
user: null,
});
expect(transformSessionResponse({ stray: 1 })).toEqual({
session: null,
user: null,
});
expect(
transformSessionResponse({ data: { user: null, session: null } })
).toEqual({ session: null, user: null });
expect(transformSessionResponse({ data: 42 })).toEqual({
session: null,
user: null,
});
});
it("transformAuthResponse builds an internal AuthResponse from valid data", {
timeout: 5000,
}, () => {
const out = transformAuthResponse({
data: { user: validUser, session: validSession },
});
expect(out.user?.email).toBe("a@b.com");
expect(out.session?.id).toBe("s1");
});
it("transformAuthResponse falls back to Invalid response format", {
timeout: 5000,
}, () => {
expect(transformAuthResponse(undefined).message).toBe(
"No response from server"
);
expect(transformAuthResponse(null).message).toBe(
"No response from server"
);
expect(transformAuthResponse({ data: null }).message).toBe(
"Invalid response format"
);
expect(
transformAuthResponse({ data: { user: null, session: null } })
.message
).toBe("Invalid response format");
expect(transformAuthResponse({ wrong: 1 }).message).toBe(
"Invalid response format"
);
});
it("transformGenericResponse returns the upstream message when present", {
timeout: 5000,
}, () => {
expect(
transformGenericResponse({ data: { status: true, message: "ok" } })
.message
).toBe("ok");
expect(
transformGenericResponse({ data: { status: true } }).message
).toBe("Success");
expect(transformGenericResponse(undefined).message).toBe(
"No response from server"
);
expect(transformGenericResponse({ stray: 1 }).message).toBe(
"Invalid response format"
);
});
it("transformUpdateProfileResponse maps the user field", {
timeout: 5000,
}, () => {
const out = transformUpdateProfileResponse({
data: { user: validUser },
});
expect(out.user?.email).toBe("a@b.com");
expect(
transformUpdateProfileResponse({ data: { user: null } }).message
).toBe("Invalid response format");
expect(transformUpdateProfileResponse(undefined).message).toBe(
"No response from server"
);
expect(transformUpdateProfileResponse({ stray: 1 }).message).toBe(
"Invalid response format"
);
});
it("accepts string-typed timestamps and emits the same string back", {
timeout: 5000,
}, () => {
const stringUser = {
...validUser,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const stringSession = {
...validSession,
expiresAt: "2026-02-01T00:00:00.000Z",
};
const out = transformSessionResponse({
data: { user: stringUser, session: stringSession },
});
expect(out.user?.createdAt).toBe("2026-01-01T00:00:00.000Z");
expect(out.session?.expiresAt.toISOString()).toBe(
"2026-02-01T00:00:00.000Z"
);
});
it("transformUser drops invalid user shapes", { timeout: 5000 }, () => {
const out = transformSessionResponse({
data: { user: { incomplete: true }, session: validSession },
});
expect(out.user).toBeNull();
expect(out.session).toBeNull();
});
it("transformSession drops invalid session shapes", {
timeout: 5000,
}, () => {
const out = transformSessionResponse({
data: { user: validUser, session: { incomplete: true } },
});
expect(out.user).not.toBeNull();
expect(out.session).toBeNull();
});
it("user without image still serialises", { timeout: 5000 }, () => {
const userNoImage: Record<string, unknown> = { ...validUser };
delete userNoImage.image;
const out = transformSessionResponse({
data: { user: userNoImage, session: validSession },
});
expect(out.user?.image).toBeUndefined();
});
it("transformSession returns null when session payload is not a record", {
timeout: 5000,
}, () => {
const out = transformSessionResponse({
data: { user: validUser, session: "not-a-record" },
});
expect(out.session).toBeNull();
});
it("transformUpdateProfileResponse returns Invalid response when data is not a record", {
timeout: 5000,
}, () => {
const out = transformUpdateProfileResponse({ data: "not-an-object" });
expect(out.message).toBe("Invalid response format");
});
it("session without ipAddress/userAgent omits those keys", {
timeout: 5000,
}, () => {
const sessionNoOpt: Record<string, unknown> = { ...validSession };
delete sessionNoOpt.ipAddress;
delete sessionNoOpt.userAgent;
const out = transformSessionResponse({
data: { user: validUser, session: sessionNoOpt },
});
expect(out.session?.ipAddress).toBeUndefined();
expect(out.session?.userAgent).toBeUndefined();
});
});

View File

@@ -0,0 +1,228 @@
/**
* Type adapter for Better-Auth responses.
*
* Better-Auth returns a union `BetterAuthError | BetterAuthData<T>` for
* every call. This module exposes:
*
* - structural guards (`isBetterAuthError`, `hasBetterAuthData`) that
* narrow `unknown` to one or the other variant without `any`;
* - four `transform*` functions that map a successful Better-Auth payload
* onto the package's internal `User` / `Session` / `AuthResponse` shapes
* with ISO-string timestamps, and surface the error variant's message
* when the call failed.
*/
import type { AuthResponse, Session, User } from "../types/auth.types.js";
interface BetterAuthErrorPayload {
readonly error: string;
readonly message: string;
}
interface BetterAuthDataPayload<T> {
readonly data: T;
}
interface BetterAuthSessionUser {
readonly id: string;
readonly email: string;
readonly emailVerified: boolean;
readonly name: string;
readonly createdAt: Date | string;
readonly updatedAt: Date | string;
readonly image?: string;
}
interface BetterAuthSessionToken {
readonly id: string;
readonly userId: string;
readonly expiresAt: Date | string;
readonly ipAddress?: string;
readonly userAgent?: string;
}
interface BetterAuthSessionData {
readonly user: BetterAuthSessionUser | null;
readonly session: BetterAuthSessionToken | null;
}
interface BetterAuthSignInData {
readonly user: BetterAuthSessionUser;
readonly session: BetterAuthSessionToken;
}
interface BetterAuthStatusPayload {
readonly status: boolean;
readonly message?: string;
}
interface BetterAuthUserUpdatedPayload {
readonly user: BetterAuthSessionUser | null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/** Narrow `unknown` to a Better-Auth error variant. */
export function isBetterAuthError(
response: unknown
): response is BetterAuthErrorPayload {
if (!isRecord(response)) return false;
return (
typeof response.error === "string" &&
typeof response.message === "string"
);
}
/** Narrow `unknown` to a Better-Auth data variant. */
export function hasBetterAuthData<T = unknown>(
response: unknown
): response is BetterAuthDataPayload<T> {
if (!isRecord(response)) return false;
return "data" in response;
}
function toIsoString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : value;
}
function isSessionUser(value: unknown): value is BetterAuthSessionUser {
if (!isRecord(value)) return false;
return (
typeof value.id === "string" &&
typeof value.email === "string" &&
typeof value.emailVerified === "boolean" &&
typeof value.name === "string" &&
(value.createdAt instanceof Date ||
typeof value.createdAt === "string") &&
(value.updatedAt instanceof Date || typeof value.updatedAt === "string")
);
}
function isSessionToken(value: unknown): value is BetterAuthSessionToken {
if (!isRecord(value)) return false;
return (
typeof value.id === "string" &&
typeof value.userId === "string" &&
(value.expiresAt instanceof Date || typeof value.expiresAt === "string")
);
}
function transformUser(value: unknown): User | null {
if (!isSessionUser(value)) return null;
const user: User = {
id: value.id,
username: value.email,
email: value.email,
name: value.name,
emailVerified: value.emailVerified,
roles: 0,
status: 0,
createdAt: toIsoString(value.createdAt),
updatedAt: toIsoString(value.updatedAt),
};
if (value.image !== undefined) user.image = value.image;
return user;
}
function transformSession(user: unknown, session: unknown): Session | null {
if (!isSessionUser(user) || !isSessionToken(session)) return null;
const out: Session = {
id: session.id,
userId: session.userId,
expiresAt:
session.expiresAt instanceof Date
? session.expiresAt
: new Date(session.expiresAt),
createdAt: toIsoString(user.createdAt),
updatedAt: toIsoString(user.updatedAt),
};
if (session.ipAddress !== undefined) out.ipAddress = session.ipAddress;
if (session.userAgent !== undefined) out.userAgent = session.userAgent;
return out;
}
/** Transform a better-auth `getSession` response. */
export function transformSessionResponse(response: unknown): {
session: Session | null;
user: User | null;
error?: string;
} {
if (response === undefined || response === null) {
return { session: null, user: null };
}
if (isBetterAuthError(response)) {
return { session: null, user: null, error: response.message };
}
if (hasBetterAuthData<BetterAuthSessionData>(response)) {
const data = response.data;
if (!isRecord(data)) return { session: null, user: null };
return {
user: transformUser(data.user),
session: transformSession(data.user, data.session),
};
}
return { session: null, user: null };
}
/** Transform a better-auth `signIn` / `signUp` response. */
export function transformAuthResponse(response: unknown): AuthResponse {
if (response === undefined || response === null) {
return { message: "No response from server" };
}
if (isBetterAuthError(response)) {
return { message: response.message };
}
if (hasBetterAuthData<BetterAuthSignInData>(response)) {
const data = response.data;
if (!isRecord(data)) {
return { message: "Invalid response format" };
}
const user = transformUser(data.user);
const session = transformSession(data.user, data.session);
if (user && session) {
return { user, session };
}
}
return { message: "Invalid response format" };
}
/** Transform a generic better-auth status response. */
export function transformGenericResponse(response: unknown): AuthResponse {
if (response === undefined || response === null) {
return { message: "No response from server" };
}
if (isBetterAuthError(response)) {
return { message: response.message };
}
if (hasBetterAuthData<BetterAuthStatusPayload>(response)) {
const data = response.data;
if (isRecord(data) && typeof data.message === "string") {
return { message: data.message };
}
return { message: "Success" };
}
return { message: "Invalid response format" };
}
/** Transform a better-auth `updateUser` response. */
export function transformUpdateProfileResponse(
response: unknown
): AuthResponse {
if (response === undefined || response === null) {
return { message: "No response from server" };
}
if (isBetterAuthError(response)) {
return { message: response.message };
}
if (hasBetterAuthData<BetterAuthUserUpdatedPayload>(response)) {
const data = response.data;
if (isRecord(data)) {
const user = transformUser(data.user);
if (user) {
return { user };
}
}
}
return { message: "Invalid response format" };
}

View File

@@ -0,0 +1,8 @@
export {
hasBetterAuthData,
isBetterAuthError,
transformAuthResponse,
transformGenericResponse,
transformSessionResponse,
transformUpdateProfileResponse,
} from "./better-auth-adapter.js";

View File

@@ -0,0 +1,13 @@
export {
getReactAuthMetrics,
getReactAuthMetricsState,
type ReactAuthMetricSample,
type ReactAuthMetricsState,
recordGuardAllow,
recordGuardDeny,
recordSessionFetchDuration,
recordSignIn,
recordSignOut,
recordSignUp,
resetReactAuthMetricsState,
} from "./react-auth-metrics.js";

View File

@@ -0,0 +1,68 @@
# `react-auth-metrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`react-auth-metrics.ts` builds the OTel instrument set for `@suaveplan/react-auth`. Instruments are constructed lazily through `metrics.getMeter("suaveplan.react-auth")` so consumers without a registered `MeterProvider` pay zero overhead — the OTel API ships a no-op meter by default. The module exposes five counters (`react_auth.sign_in.total`, `react_auth.sign_up.total`, `react_auth.sign_out.total`, `react_auth.guard.allow.total`, `react_auth.guard.deny.total`) and one histogram (`react_auth.session_fetch.duration_ms`), plus helper functions that record the corresponding event with a typed attribute payload. The helpers also push every event into an in-memory `state` ring so spec-conformance tests can assert exact attribute payloads without standing up a full SDK pipeline. `resetReactAuthMetricsState()` clears the ring between tests.
## Features
- Lazy OTel instrument construction; no-op until a `MeterProvider` is registered.
- 5 counters + 1 histogram covering the wave-1 production paths.
- Typed `outcome`/`predicate` attributes per record helper.
- In-memory `state` ring (`getReactAuthMetricsState`) for assertion-friendly tests.
- `resetReactAuthMetricsState()` test-only helper.
## Usage: Basic
```ts
import { recordSignIn } from "@suaveplan/react-auth";
await client.signIn(credentials);
recordSignIn("success");
```
## Usage: Advanced
Wire a real `MeterProvider` (e.g. for shipping metrics to Prometheus) and assert via the in-memory ring in tests:
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import {
getReactAuthMetricsState,
recordSessionFetchDuration,
resetReactAuthMetricsState,
} from "@suaveplan/react-auth";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
resetReactAuthMetricsState();
recordSessionFetchDuration(42, "success");
const state = getReactAuthMetricsState();
expect(state.sessionFetchDurationMs).toEqual([
{ attributes: { outcome: "success" }, value: 42 },
]);
```
## API Reference
- `interface ReactAuthMetricSample` — `{ attributes: Record<string, string>; value?: number }`.
- `interface ReactAuthMetricsState` — `{ signInTotal; signUpTotal; signOutTotal; guardAllowTotal; guardDenyTotal; sessionFetchDurationMs }` arrays.
- `function getReactAuthMetrics()` — returns the cached instrument bundle.
- `function recordSignIn(outcome: "success" | "failure"): void`.
- `function recordSignUp(outcome: "success" | "failure"): void`.
- `function recordSignOut(outcome: "success" | "failure"): void`.
- `function recordGuardAllow(predicate: string): void`.
- `function recordGuardDeny(predicate: string): void`.
- `function recordSessionFetchDuration(durationMs: number, outcome: "success" | "failure"): void`.
- `function getReactAuthMetricsState(): ReactAuthMetricsState`.
- `function resetReactAuthMetricsState(): void`.
## Implementation Notes
The OTel API exposes a global no-op `MeterProvider` until the application opts in by calling `setGlobalMeterProvider(...)`; this means the helpers are safe to call from production paths without any registration ceremony. The in-memory state ring is unbounded by design — it is intended only for tests and the package's own conformance checks. A long-running process would never call `getReactAuthMetricsState()` directly, so leak risk is bounded by test scope. The cached instrument bundle is module-level (`let cached: CachedInstruments | undefined`) so the meter is resolved at most once per process; the lazy build means importing the module is free.

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
getReactAuthMetrics,
getReactAuthMetricsState,
recordGuardAllow,
recordGuardDeny,
recordSessionFetchDuration,
recordSignIn,
recordSignOut,
recordSignUp,
resetReactAuthMetricsState,
} from "./react-auth-metrics.js";
describe("react-auth-metrics", () => {
beforeEach(() => {
resetReactAuthMetricsState();
});
it("emits sign-in / sign-up / sign-out / guard / session-fetch instruments through suaveplan.react-auth", {
timeout: 5000,
}, () => {
recordSignIn("success");
recordSignIn("failure");
recordSignUp("success");
recordSignOut("failure");
recordGuardAllow("all");
recordGuardDeny("requireAuth");
recordSessionFetchDuration(42, "success");
recordSessionFetchDuration(7, "failure");
const state = getReactAuthMetricsState();
expect(state.signInTotal).toEqual([
{ attributes: { outcome: "success" } },
{ attributes: { outcome: "failure" } },
]);
expect(state.signUpTotal).toEqual([
{ attributes: { outcome: "success" } },
]);
expect(state.signOutTotal).toEqual([
{ attributes: { outcome: "failure" } },
]);
expect(state.guardAllowTotal).toEqual([
{ attributes: { predicate: "all" } },
]);
expect(state.guardDenyTotal).toEqual([
{ attributes: { predicate: "requireAuth" } },
]);
expect(state.sessionFetchDurationMs).toEqual([
{ attributes: { outcome: "success" }, value: 42 },
{ attributes: { outcome: "failure" }, value: 7 },
]);
});
it("getReactAuthMetrics is idempotent — repeated calls return the same instrument bundle", {
timeout: 5000,
}, () => {
const a = getReactAuthMetrics();
const b = getReactAuthMetrics();
expect(a).toBe(b);
});
it("resetReactAuthMetricsState empties every ring", {
timeout: 5000,
}, () => {
recordSignIn("success");
recordSignUp("success");
recordSignOut("success");
recordGuardAllow("all");
recordGuardDeny("all");
recordSessionFetchDuration(1, "success");
resetReactAuthMetricsState();
const state = getReactAuthMetricsState();
expect(state.signInTotal).toEqual([]);
expect(state.signUpTotal).toEqual([]);
expect(state.signOutTotal).toEqual([]);
expect(state.guardAllowTotal).toEqual([]);
expect(state.guardDenyTotal).toEqual([]);
expect(state.sessionFetchDurationMs).toEqual([]);
});
});

View File

@@ -0,0 +1,143 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* OTel instruments for the @suaveplan/react-auth package.
*
* Built lazily via `metrics.getMeter("suaveplan.react-auth")`. All
* instruments are no-ops when no MeterProvider is registered (the
* default), so consumers pay zero overhead unless they wire the SDK.
*
* In addition to emitting through the OTel API, this module retains a
* small in-memory state ring so spec-conformance tests can assert
* exact attribute payloads without standing up a full SDK pipeline.
*/
export interface ReactAuthMetricSample {
readonly attributes: Record<string, string>;
readonly value?: number;
}
export interface ReactAuthMetricsState {
readonly signInTotal: ReactAuthMetricSample[];
readonly signUpTotal: ReactAuthMetricSample[];
readonly signOutTotal: ReactAuthMetricSample[];
readonly guardAllowTotal: ReactAuthMetricSample[];
readonly guardDenyTotal: ReactAuthMetricSample[];
readonly sessionFetchDurationMs: ReactAuthMetricSample[];
}
interface CachedInstruments {
readonly signInTotal: Counter;
readonly signUpTotal: Counter;
readonly signOutTotal: Counter;
readonly guardAllowTotal: Counter;
readonly guardDenyTotal: Counter;
readonly sessionFetchDurationMs: Histogram;
}
let cached: CachedInstruments | undefined;
const state: ReactAuthMetricsState = {
signInTotal: [],
signUpTotal: [],
signOutTotal: [],
guardAllowTotal: [],
guardDenyTotal: [],
sessionFetchDurationMs: [],
};
/** Build (or return the cached) OTel instruments. */
export function getReactAuthMetrics(): CachedInstruments {
if (cached) return cached;
const meter = metrics.getMeter("suaveplan.react-auth");
cached = {
signInTotal: meter.createCounter("react_auth.sign_in.total", {
description: "Number of sign-in attempts, labelled by outcome",
}),
signUpTotal: meter.createCounter("react_auth.sign_up.total", {
description: "Number of sign-up attempts, labelled by outcome",
}),
signOutTotal: meter.createCounter("react_auth.sign_out.total", {
description: "Number of sign-out attempts, labelled by outcome",
}),
guardAllowTotal: meter.createCounter("react_auth.guard.allow.total", {
description:
"Number of AuthGuard allow decisions, labelled by predicate",
}),
guardDenyTotal: meter.createCounter("react_auth.guard.deny.total", {
description:
"Number of AuthGuard deny decisions, labelled by predicate",
}),
sessionFetchDurationMs: meter.createHistogram(
"react_auth.session_fetch.duration_ms",
{
description:
"Wall-clock duration of session-fetch calls, in milliseconds",
unit: "ms",
}
),
};
return cached;
}
/** Increment the sign-in counter with an outcome attribute. */
export function recordSignIn(outcome: "success" | "failure"): void {
const out = { outcome };
getReactAuthMetrics().signInTotal.add(1, out);
state.signInTotal.push({ attributes: out });
}
/** Increment the sign-up counter with an outcome attribute. */
export function recordSignUp(outcome: "success" | "failure"): void {
const out = { outcome };
getReactAuthMetrics().signUpTotal.add(1, out);
state.signUpTotal.push({ attributes: out });
}
/** Increment the sign-out counter with an outcome attribute. */
export function recordSignOut(outcome: "success" | "failure"): void {
const out = { outcome };
getReactAuthMetrics().signOutTotal.add(1, out);
state.signOutTotal.push({ attributes: out });
}
/** Increment the guard-allow counter with a predicate attribute. */
export function recordGuardAllow(predicate: string): void {
const out = { predicate };
getReactAuthMetrics().guardAllowTotal.add(1, out);
state.guardAllowTotal.push({ attributes: out });
}
/** Increment the guard-deny counter with a predicate attribute. */
export function recordGuardDeny(predicate: string): void {
const out = { predicate };
getReactAuthMetrics().guardDenyTotal.add(1, out);
state.guardDenyTotal.push({ attributes: out });
}
/** Record a session-fetch duration in milliseconds, labelled by outcome. */
export function recordSessionFetchDuration(
durationMs: number,
outcome: "success" | "failure"
): void {
const out = { outcome };
getReactAuthMetrics().sessionFetchDurationMs.record(durationMs, out);
state.sessionFetchDurationMs.push({
attributes: out,
value: durationMs,
});
}
/** Snapshot of in-memory metric samples for testing. */
export function getReactAuthMetricsState(): ReactAuthMetricsState {
return state;
}
/** Reset the in-memory state ring (test-only). */
export function resetReactAuthMetricsState(): void {
state.signInTotal.length = 0;
state.signUpTotal.length = 0;
state.signOutTotal.length = 0;
state.guardAllowTotal.length = 0;
state.guardDenyTotal.length = 0;
state.sessionFetchDurationMs.length = 0;
}

View File

@@ -0,0 +1,65 @@
# `<EmailVerificationPage>` — routing-aware verification page
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<EmailVerificationPage>` is the routing-aware adapter that wraps the Wave 2 `<EmailVerificationView>` for the `?token=…`-bearing email-verification link sent by the server. It extracts the token from the URL search params, calls `useAuth().verifyEmail(token)` exactly once on mount, and renders one of four states via the underlying view component: `verifying` (default while the call is in flight), `success` (after a resolved call), `expired` (after a rejection whose message contains "expired" or "invalid"), or `error` (everything else).
Use this as the `Route element` for `/auth/verify-email`. The page never re-implements the four state cards — those live in the Wave 2 view.
## Features
- `useAuthGuard({ requireGuest: true })` so authenticated visitors are redirected away.
- Single in-flight `verifyEmail(token)` call on mount; React's `useEffect` dep array is `[token, verifyEmail]` so re-renders don't fire spurious calls.
- Error classifier: rejections whose `message` contains `expired` or `invalid` (case-insensitive) map to the `expired` state; all other failures map to `error`.
- Three navigation callbacks wired into the view: `onContinueToLogin` → `/auth/login`, `onRegisterAgain` → `/auth/register`, `onTryAgain` reloads the page.
## Usage: Basic
```tsx
import { Route, Routes } from "react-router-dom";
import { EmailVerificationPage } from "@suaveplan/react-auth";
export function AuthRoutes() {
return (
<Routes>
<Route
path="/auth/verify-email"
element={<EmailVerificationPage />}
/>
</Routes>
);
}
```
## Usage: Advanced
When sending the verification email, embed the token in the URL:
```ts
const link = new URL("/auth/verify-email", site.origin);
link.searchParams.set("token", verificationToken);
await mailer.send({ to: user.email, subject, body: link.toString() });
```
If you want a different post-verification destination, wrap the page in a custom layout that hooks into `useAuth()` to redirect on `user.emailVerified === true`.
## API Reference
`<EmailVerificationPage>` accepts no props.
### `EmailVerificationPageState`
| Field | Type | Description |
|---|---|---|
| `status` | `EmailVerificationStatus` | One of `"verifying" \| "success" \| "error" \| "expired"`. |
| `message` | `string` | Human-readable message displayed by the view. |
## Implementation Notes
- The reload-style `onTryAgain` handler intentionally short-circuits on SSR (`typeof window === "undefined"`). The page is browser-only in practice, but the guard keeps the code defensive.
- The `verifyEmail` mount-time call is fired even if the token is malformed; the server's response carries the failure mode (404, expired, malformed) which the classifier maps to `expired` / `error`.
- The page intentionally does NOT renavigate on success — the user clicks the visible CTA to continue to login, mirroring the upstream UX where some users may want to copy state from the verification screen first.

View File

@@ -0,0 +1,46 @@
/**
* Storybook stories for `<EmailVerificationPage>`.
*/
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { EmailVerificationPage } from "./email-verification.js";
interface StoryMeta<TArgs> {
title: string;
component: unknown;
parameters?: Record<string, unknown>;
args?: Partial<TArgs>;
decorators?: Array<(story: () => ReactElement) => ReactElement>;
}
interface StoryObj<TComponent> {
args?: Partial<TComponent extends (p: infer P) => unknown ? P : never>;
}
type Meta<T> = StoryMeta<T extends (p: infer P) => unknown ? P : never>;
const meta: Meta<typeof EmailVerificationPage> = {
title: "Auth/Pages/EmailVerificationPage",
component: EmailVerificationPage,
parameters: { layout: "fullscreen" },
decorators: [
(Story) => (
<MemoryRouter initialEntries={["/auth/verify-email?token=abc"]}>
{Story()}
</MemoryRouter>
),
],
};
export default meta;
type Story = StoryObj<typeof EmailVerificationPage>;
export const Default: Story = { args: {} };
export const Playground: Story = { args: {} };
export const NoToken: Story = {
decorators: [
(Story) => (
<MemoryRouter initialEntries={["/auth/verify-email"]}>
{Story()}
</MemoryRouter>
),
],
};

View File

@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { makeAuthCtx, wrapPage } from "../../components/test-wrap.js";
import { EmailVerificationPage } from "./email-verification.js";
afterEach(cleanup);
describe("EmailVerificationPage", () => {
it("renders error status when ?token is absent", {
timeout: 5000,
}, async () => {
const verifyEmail = vi.fn(async () => {});
const ctx = makeAuthCtx({ verifyEmail });
render(
wrapPage(<EmailVerificationPage />, {
ctx,
initialEntries: ["/auth/verify-email"],
landingPath: "/auth/verify-email",
})
);
await waitFor(() => {
expect(screen.getByText(/email verification failed/i)).toBeTruthy();
});
expect(verifyEmail).not.toHaveBeenCalled();
});
it("renders success status on happy path", {
timeout: 5000,
}, async () => {
const verifyEmail = vi.fn(async () => {});
const ctx = makeAuthCtx({ verifyEmail });
render(
wrapPage(<EmailVerificationPage />, {
ctx,
initialEntries: ["/auth/verify-email?token=abc"],
landingPath: "/auth/verify-email",
})
);
await waitFor(() => {
expect(verifyEmail).toHaveBeenCalledWith("abc");
});
await waitFor(() => {
expect(
screen.getAllByText(/email verified successfully/i).length
).toBeGreaterThan(0);
});
});
it("classifies expired-token rejection as expired", {
timeout: 5000,
}, async () => {
const verifyEmail = vi.fn(async () => {
throw new Error("token has expired");
});
const ctx = makeAuthCtx({ verifyEmail });
render(
wrapPage(<EmailVerificationPage />, {
ctx,
initialEntries: ["/auth/verify-email?token=stale"],
landingPath: "/auth/verify-email",
})
);
await waitFor(() => {
expect(
screen.getByText(/invalid or expired verification link/i)
).toBeTruthy();
});
});
it("classifies non-expired-non-invalid rejection as error", {
timeout: 5000,
}, async () => {
const verifyEmail = vi.fn(async () => {
throw new Error("server is down");
});
const ctx = makeAuthCtx({ verifyEmail });
render(
wrapPage(<EmailVerificationPage />, {
ctx,
initialEntries: ["/auth/verify-email?token=tok"],
landingPath: "/auth/verify-email",
})
);
await waitFor(() => {
expect(
screen.getAllByText("server is down").length
).toBeGreaterThan(0);
});
expect(
screen.getAllByText(/email verification failed/i).length
).toBeGreaterThan(0);
});
it("falls back to default failure message on non-Error rejection", {
timeout: 5000,
}, async () => {
const verifyEmail = vi.fn(async () => {
throw "weird";
});
const ctx = makeAuthCtx({ verifyEmail });
render(
wrapPage(<EmailVerificationPage />, {
ctx,
initialEntries: ["/auth/verify-email?token=tok"],
landingPath: "/auth/verify-email",
})
);
await waitFor(() => {
expect(
screen.getAllByText(/email verification failed/i).length
).toBeGreaterThan(0);
});
});
it("continue-to-login CTA navigates to /auth/login", {
timeout: 5000,
}, async () => {
const verifyEmail = vi.fn(async () => {});
const ctx = makeAuthCtx({ verifyEmail });
render(
wrapPage(<EmailVerificationPage />, {
ctx,
initialEntries: ["/auth/verify-email?token=ok"],
landingPath: "/auth/verify-email",
})
);
await waitFor(() => {
expect(
screen.getAllByText(/email verified successfully/i).length
).toBeGreaterThan(0);
});
const user = userEvent.setup();
await user.click(
screen.getByRole("button", { name: /back to sign in/i })
);
await waitFor(() => {
expect(screen.getByTestId("navigated").dataset.pathname).toBe(
"/auth/login"
);
});
});
it("register-again CTA navigates to /auth/register from expired", {
timeout: 5000,
}, async () => {
const verifyEmail = vi.fn(async () => {
throw new Error("token is expired");
});
const ctx = makeAuthCtx({ verifyEmail });
render(
wrapPage(<EmailVerificationPage />, {
ctx,
initialEntries: ["/auth/verify-email?token=stale"],
landingPath: "/auth/verify-email",
})
);
await waitFor(() => {
expect(
screen.getByText(/invalid or expired verification link/i)
).toBeTruthy();
});
const user = userEvent.setup();
await user.click(
screen.getByRole("button", { name: /resend verification email/i })
);
await waitFor(() => {
expect(screen.getByTestId("navigated").dataset.pathname).toBe(
"/auth/register"
);
});
});
it("try-again CTA invokes window.location.reload on error", {
timeout: 5000,
}, async () => {
const verifyEmail = vi.fn(async () => {
throw new Error("server is down");
});
const ctx = makeAuthCtx({ verifyEmail });
const reloadSpy = vi.fn();
const originalReload = window.location.reload;
// Override using defineProperty since reload is non-writable in jsdom/happy-dom.
Object.defineProperty(window.location, "reload", {
configurable: true,
value: reloadSpy,
});
render(
wrapPage(<EmailVerificationPage />, {
ctx,
initialEntries: ["/auth/verify-email?token=tok"],
landingPath: "/auth/verify-email",
})
);
await waitFor(() => {
expect(
screen.getAllByText("server is down").length
).toBeGreaterThan(0);
});
const user = userEvent.setup();
await user.click(
screen.getByRole("button", { name: /resend verification email/i })
);
expect(reloadSpy).toHaveBeenCalled();
Object.defineProperty(window.location, "reload", {
configurable: true,
value: originalReload,
});
});
});

View File

@@ -0,0 +1,96 @@
import { type ReactElement, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { EmailVerificationView } from "../../components/email-verification-view/email-verification-view.js";
import { useAuth } from "../../context/auth-context.js";
import { useAuthGuard } from "../../hooks/use-auth-guard.js";
import enGb from "../../translations/en-gb.js";
import type { EmailVerificationPageState } from "./email-verification.types.js";
const baseStrings = enGb.auth.emailVerification;
function classifyError(
err: unknown,
fallback: string
): EmailVerificationPageState {
const message = err instanceof Error ? err.message : fallback;
const isExpired =
message.toLowerCase().includes("expired") ||
message.toLowerCase().includes("invalid");
return {
status: isExpired ? "expired" : "error",
message,
};
}
/**
* `<EmailVerificationPage>` — routing-aware page wrapping the Wave 2
* `<EmailVerificationView>`.
*
* - Applies `useAuthGuard({ requireGuest: true })`.
* - Reads `?token` from `useSearchParams`. When absent, immediately renders
* the `error` view with a missing-token message.
* - When a token is present, calls `useAuth().verifyEmail(token)` once on
* mount. On resolution, flips local state to `success`. On rejection,
* classifies the message: any message containing "expired" or "invalid"
* (case-insensitive) maps to `expired`; everything else maps to `error`.
* - Navigation callbacks: `onContinueToLogin` → `/auth/login`,
* `onRegisterAgain` → `/auth/register`, `onTryAgain` reloads the page.
*/
export function EmailVerificationPage(): ReactElement {
const { verifyEmail, isLoading } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [state, setState] = useState<EmailVerificationPageState>({
status: "verifying",
message: "",
});
useAuthGuard({ requireAuth: false, requireGuest: true });
const token = searchParams.get("token");
useEffect(() => {
const run = async (): Promise<void> => {
if (!token) {
setState({
status: "error",
message: baseStrings.invalidToken,
});
return;
}
try {
await verifyEmail(token);
setState({
status: "success",
message: baseStrings.success,
});
} catch (err) {
setState(classifyError(err, baseStrings.failed));
}
};
void run();
}, [token, verifyEmail]);
const handleContinueToLogin = (): void => {
navigate("/auth/login");
};
const handleRegisterAgain = (): void => {
navigate("/auth/register");
};
const handleTryAgain = (): void => {
window.location.reload();
};
return (
<EmailVerificationView
status={state.status}
message={state.message}
onContinueToLogin={handleContinueToLogin}
onRegisterAgain={handleRegisterAgain}
onTryAgain={handleTryAgain}
isLoading={isLoading}
/>
);
}

View File

@@ -0,0 +1,9 @@
import type { EmailVerificationStatus } from "../../components/email-verification-view/email-verification-view.types.js";
/** Local UI state held by `<EmailVerificationPage>`. */
export interface EmailVerificationPageState {
/** Current verification status. */
status: EmailVerificationStatus;
/** Human-readable message for the view. */
message: string;
}

View File

@@ -0,0 +1,2 @@
export { EmailVerificationPage } from "./email-verification.js";
export type { EmailVerificationPageState } from "./email-verification.types.js";

View File

@@ -0,0 +1,70 @@
# `<ForgotPasswordPage>` — routing-aware reset-link page
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<ForgotPasswordPage>` is the routing-aware adapter that wraps the Wave 2 `<ForgotPasswordForm>` with the navigation and auth-context wiring needed for a "forgot your password?" route. It drives the form's `isSuccess` / `emailSent` props from local state on a successful `useAuth().forgotPassword(email)` call, and clears that state when the form's `onReset` callback fires.
Use this as the `Route element` for `/auth/forgot-password`. The page never navigates on success — it transitions the same form node to its built-in success view, mirroring the upstream UX.
## Features
- Calls `useAuth().forgotPassword(email)` on submit.
- Tracks local `isSuccess` / `emailSent` state so the form renders the success card after a resolved call.
- `onReset` clears local state and returns the form to its entry view, so the user can send another link if needed.
- `forgotPassword` rejections are swallowed; errors surface via `useAuth().error`.
## Usage: Basic
```tsx
import { Route, Routes } from "react-router-dom";
import { ForgotPasswordPage } from "@suaveplan/react-auth";
export function AuthRoutes() {
return (
<Routes>
<Route
path="/auth/forgot-password"
element={<ForgotPasswordPage />}
/>
</Routes>
);
}
```
## Usage: Advanced
To override the success-view copy or wire analytics into the submission, mount `<ForgotPasswordForm>` directly with custom handlers — for example to capture an analytics event:
```tsx
import { useAuth, ForgotPasswordForm } from "@suaveplan/react-auth";
function CustomForgotPassword() {
const { forgotPassword, error } = useAuth();
const [success, setSuccess] = useState(false);
return (
<ForgotPasswordForm
onSubmit={async ({ email }) => {
track("password.forgot.submit", { email });
await forgotPassword(email);
setSuccess(true);
}}
isSuccess={success}
emailSent="(redacted)"
error={error}
/>
);
}
```
## API Reference
`<ForgotPasswordPage>` accepts no props.
## Implementation Notes
- The page does NOT guard against authenticated visitors — a logged-in user reaching the forgot-password route is a legitimate flow when they want to rotate their password from a known-good session.
- The `emailSent` state is the literal value the user typed, not a redacted echo — the form decides how to render it.

View File

@@ -0,0 +1,37 @@
/**
* Storybook stories for `<ForgotPasswordPage>`.
*/
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { ForgotPasswordPage } from "./forgot-password.js";
interface StoryMeta<TArgs> {
title: string;
component: unknown;
parameters?: Record<string, unknown>;
args?: Partial<TArgs>;
decorators?: Array<(story: () => ReactElement) => ReactElement>;
}
interface StoryObj<TComponent> {
args?: Partial<TComponent extends (p: infer P) => unknown ? P : never>;
}
type Meta<T> = StoryMeta<T extends (p: infer P) => unknown ? P : never>;
const meta: Meta<typeof ForgotPasswordPage> = {
title: "Auth/Pages/ForgotPasswordPage",
component: ForgotPasswordPage,
parameters: { layout: "fullscreen" },
decorators: [
(Story) => (
<MemoryRouter initialEntries={["/auth/forgot-password"]}>
{Story()}
</MemoryRouter>
),
],
};
export default meta;
type Story = StoryObj<typeof ForgotPasswordPage>;
export const Default: Story = { args: {} };
export const Playground: Story = { args: {} };

View File

@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { makeAuthCtx, wrapPage } from "../../components/test-wrap.js";
import { ForgotPasswordPage } from "./forgot-password.js";
afterEach(cleanup);
describe("ForgotPasswordPage", () => {
it("flips to success view on happy path", { timeout: 5000 }, async () => {
const forgotPassword = vi.fn(async () => {});
const ctx = makeAuthCtx({ forgotPassword });
render(
wrapPage(<ForgotPasswordPage />, {
ctx,
initialEntries: ["/auth/forgot-password"],
landingPath: "/auth/forgot-password",
})
);
const user = userEvent.setup();
await user.type(
screen.getByLabelText(/email address/i),
"alice@example.com"
);
await user.click(
screen.getByRole("button", { name: /send reset link/i })
);
await waitFor(() => {
expect(forgotPassword).toHaveBeenCalledWith("alice@example.com");
});
await waitFor(() => {
expect(screen.getByText("alice@example.com")).toBeTruthy();
});
});
it("reset returns to entry view", { timeout: 5000 }, async () => {
const forgotPassword = vi.fn(async () => {});
const ctx = makeAuthCtx({ forgotPassword });
render(
wrapPage(<ForgotPasswordPage />, {
ctx,
initialEntries: ["/auth/forgot-password"],
landingPath: "/auth/forgot-password",
})
);
const user = userEvent.setup();
await user.type(
screen.getByLabelText(/email address/i),
"bob@example.com"
);
await user.click(
screen.getByRole("button", { name: /send reset link/i })
);
await waitFor(() => {
expect(screen.queryByText("bob@example.com")).toBeTruthy();
});
// Success-view's submit-labelled button triggers onReset
const sendAnother = screen.getAllByRole("button", {
name: /send reset link/i,
});
await user.click(sendAnother[0] as HTMLElement);
await waitFor(() => {
expect(screen.queryByText("bob@example.com")).toBeNull();
});
});
it("swallows forgotPassword rejection and surfaces error", {
timeout: 5000,
}, async () => {
const forgotPassword = vi.fn(async () => {
throw new Error("rate limited");
});
const ctx = makeAuthCtx({
forgotPassword,
error: new Error("Rate limit exceeded"),
});
render(
wrapPage(<ForgotPasswordPage />, {
ctx,
initialEntries: ["/auth/forgot-password"],
landingPath: "/auth/forgot-password",
})
);
const user = userEvent.setup();
await user.type(
screen.getByLabelText(/email address/i),
"alice@example.com"
);
await user.click(
screen.getByRole("button", { name: /send reset link/i })
);
await waitFor(() => {
expect(forgotPassword).toHaveBeenCalledTimes(1);
});
// Did not flip to success
expect(screen.queryByText("alice@example.com")).toBeNull();
expect(screen.getByText(/rate limit exceeded/i)).toBeTruthy();
});
});

View File

@@ -0,0 +1,49 @@
import { type ReactElement, useState } from "react";
import { ForgotPasswordForm } from "../../components/forgot-password-form/forgot-password-form.js";
import type { ForgotPasswordFormData } from "../../components/forgot-password-form/forgot-password-form.types.js";
import { useAuth } from "../../context/auth-context.js";
/**
* `<ForgotPasswordPage>` — routing-aware page wrapping the Wave 2
* `<ForgotPasswordForm>`.
*
* - On submit, calls `useAuth().forgotPassword(email)`. On resolution, flips
* the form to the success view via local `isSuccess`/`emailSent` state.
* - The success-view "send another" button triggers `onReset`, returning the
* form to the entry view.
* - `forgotPassword` rejections are swallowed — the surfaced
* `useAuth().error` flows into the form's destructive alert.
*/
export function ForgotPasswordPage(): ReactElement {
const { forgotPassword, isLoading, error } = useAuth();
const [isSuccess, setIsSuccess] = useState(false);
const [emailSent, setEmailSent] = useState("");
const handleSubmit = async (
data: ForgotPasswordFormData
): Promise<void> => {
try {
await forgotPassword(data.email);
setEmailSent(data.email);
setIsSuccess(true);
} catch {
// Error is exposed via `useAuth().error` and rendered by the form.
}
};
const handleReset = (): void => {
setIsSuccess(false);
setEmailSent("");
};
return (
<ForgotPasswordForm
onSubmit={handleSubmit}
error={error}
isLoading={isLoading}
isSuccess={isSuccess}
emailSent={emailSent}
onReset={handleReset}
/>
);
}

View File

@@ -0,0 +1 @@
export { ForgotPasswordPage } from "./forgot-password.js";

View File

@@ -0,0 +1 @@
export { LoginPage } from "./login.js";

View File

@@ -0,0 +1,67 @@
# `<LoginPage>` — routing-aware login page
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<LoginPage>` is the routing-aware adapter that wraps the Wave 2 `<LoginForm>` with the navigation and auth-context wiring needed to drop into a `react-router-dom` v7 application. It applies a guest-only auth guard, calls `useAuth().signIn` on submit, redirects to `?returnTo` (or `/dashboard` as fallback) on success, and surfaces errors through the form's destructive alert via the `useAuth().error` field.
Use this component for the sign-in route of a shipping application. For a customisable lower-level UI consider mounting `<LoginForm>` directly and wiring submission and navigation manually.
## Features
- `useAuthGuard({ requireGuest: true })` — already-authenticated visitors are redirected to `/`.
- `?returnTo` query param honoured for post-login navigation; defaults to `/dashboard`.
- `signIn` rejections are swallowed so the page never throws into a router error boundary.
- Reads `useAuth().isLoading` to disable the submit button during the in-flight `signIn`.
- Reads `useAuth().error` to render the destructive alert in the form.
## Usage: Basic
```tsx
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { AuthProvider, LoginPage } from "@suaveplan/react-auth";
export function App() {
return (
<AuthProvider config={{ baseURL: "https://auth.example.com" }}>
<BrowserRouter>
<Routes>
<Route path="/auth/login" element={<LoginPage />} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
```
## Usage: Advanced
When a protected route renders `<AuthGuard requireAuth fallback={<Navigate to={`/auth/login?returnTo=${path}`} />}>`, the user returns to the original path after sign-in:
```tsx
import { Navigate, useLocation } from "react-router-dom";
function Protected({ children }) {
const { isAuthenticated } = useAuth();
const loc = useLocation();
if (!isAuthenticated) {
return <Navigate to={`/auth/login?returnTo=${loc.pathname}`} replace />;
}
return children;
}
```
The `LoginPage` reads the `returnTo` search param via `useSearchParams` and routes the user back after a successful sign-in.
## API Reference
`<LoginPage>` accepts no props. It reads context via `useAuth()` and `useSearchParams()`.
## Implementation Notes
- The page mounts the Wave 2 `<LoginForm>` and never re-implements form-level concerns (Zod validation, react-hook-form wiring, password show/hide). Those remain in the form layer.
- The submit handler is `async` and catches synchronously; nothing propagates to a React error boundary on a failed `signIn`.
- The guard is a side-effect of `useAuthGuard` — the page still renders the form for one frame while the guard's `useEffect` schedules the redirect.

View File

@@ -0,0 +1,50 @@
/**
* Storybook stories for `<LoginPage>`. `@storybook/react` is NOT a dep of
* `@suaveplan/react-auth`; story-meta shapes are inlined.
*/
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { LoginPage } from "./login.js";
interface StoryMeta<TArgs> {
title: string;
component: unknown;
parameters?: Record<string, unknown>;
args?: Partial<TArgs>;
decorators?: Array<(story: () => ReactElement) => ReactElement>;
}
interface StoryObj<TComponent> {
args?: Partial<TComponent extends (p: infer P) => unknown ? P : never>;
render?: (
args: TComponent extends (p: infer P) => unknown ? P : never
) => ReactElement;
}
type Meta<T> = StoryMeta<T extends (p: infer P) => unknown ? P : never>;
const meta: Meta<typeof LoginPage> = {
title: "Auth/Pages/LoginPage",
component: LoginPage,
parameters: { layout: "fullscreen" },
decorators: [
(Story) => (
<MemoryRouter initialEntries={["/auth/login"]}>
{Story()}
</MemoryRouter>
),
],
};
export default meta;
type Story = StoryObj<typeof LoginPage>;
export const Default: Story = { args: {} };
export const Playground: Story = { args: {} };
export const WithReturnTo: Story = {
decorators: [
(Story) => (
<MemoryRouter initialEntries={["/auth/login?returnTo=/inbox"]}>
{Story()}
</MemoryRouter>
),
],
};

View File

@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { makeAuthCtx, wrapPage } from "../../components/test-wrap.js";
import { LoginPage } from "./login.js";
afterEach(cleanup);
async function fillAndSubmit() {
const user = userEvent.setup();
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
await user.type(
screen.getByLabelText(/^password$/i),
"correcthorsebattery"
);
await user.click(screen.getByRole("button", { name: /sign in/i }));
}
describe("LoginPage", () => {
it("navigates to returnTo on happy path", { timeout: 5000 }, async () => {
const signIn = vi.fn(async () => {});
const ctx = makeAuthCtx({ signIn });
render(
wrapPage(<LoginPage />, {
ctx,
initialEntries: ["/auth/login?returnTo=/inbox"],
landingPath: "/auth/login",
})
);
await fillAndSubmit();
await waitFor(() => {
expect(signIn).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(screen.getByTestId("navigated").dataset.pathname).toBe(
"/inbox"
);
});
});
it("defaults to /dashboard when returnTo is absent", {
timeout: 5000,
}, async () => {
const signIn = vi.fn(async () => {});
const ctx = makeAuthCtx({ signIn });
render(
wrapPage(<LoginPage />, {
ctx,
initialEntries: ["/auth/login"],
landingPath: "/auth/login",
})
);
await fillAndSubmit();
await waitFor(() => {
expect(screen.getByTestId("navigated").dataset.pathname).toBe(
"/dashboard"
);
});
});
it("swallows signIn rejection and surfaces the error in the form", {
timeout: 5000,
}, async () => {
const signIn = vi.fn(async () => {
throw new Error("boom");
});
const ctx = makeAuthCtx({
signIn,
error: new Error("Invalid email or password"),
});
render(
wrapPage(<LoginPage />, {
ctx,
initialEntries: ["/auth/login"],
landingPath: "/auth/login",
})
);
await fillAndSubmit();
await waitFor(() => {
expect(signIn).toHaveBeenCalledTimes(1);
});
// No navigation should have occurred.
expect(screen.queryByTestId("navigated")).toBeNull();
expect(screen.getByText(/invalid email or password/i)).toBeTruthy();
});
it("redirects authenticated visitors away via the requireGuest guard", {
timeout: 5000,
}, async () => {
const ctx = makeAuthCtx({
isAuthenticated: true,
user: {
id: "u1",
username: "alice",
email: "alice@example.com",
emailVerified: true,
roles: 0,
status: 0,
createdAt: "",
updatedAt: "",
},
});
render(
wrapPage(<LoginPage />, {
ctx,
initialEntries: ["/auth/login"],
landingPath: "/auth/login",
})
);
await waitFor(() => {
expect(screen.getByTestId("navigated").dataset.pathname).toBe("/");
});
});
});

View File

@@ -0,0 +1,43 @@
import type { ReactElement } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { LoginForm } from "../../components/login-form/login-form.js";
import { useAuth } from "../../context/auth-context.js";
import { useAuthGuard } from "../../hooks/use-auth-guard.js";
import type { LoginCredentials } from "../../types/auth.types.js";
/**
* `<LoginPage>` — routing-aware page wrapping the Wave 2 `<LoginForm>`.
*
* - Applies `useAuthGuard({ requireGuest: true })` so already-authenticated
* visitors are redirected to `/`.
* - On submit, calls `useAuth().signIn(credentials)`. On success, navigates
* to `?returnTo` if present, otherwise `/dashboard`.
* - `signIn` rejections are swallowed — the surfaced `useAuth().error` flows
* into the form's destructive alert.
*/
export function LoginPage(): ReactElement {
const { signIn, isLoading, error } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
useAuthGuard({ requireAuth: false, requireGuest: true });
const returnTo = searchParams.get("returnTo") ?? "/dashboard";
const handleSubmit = async (data: LoginCredentials): Promise<void> => {
try {
await signIn(data);
navigate(returnTo);
} catch {
// Error is exposed via `useAuth().error` and rendered by the form.
}
};
return (
<LoginForm
onSubmit={handleSubmit}
error={error}
isLoading={isLoading}
/>
);
}

View File

@@ -0,0 +1,7 @@
export { Profile, ProfilePage } from "./profile.js";
export type {
ProfilePageFormData,
ProfilePageProps,
ProfilePageSection,
ProfilePageSectionId,
} from "./profile.types.js";

View File

@@ -0,0 +1,64 @@
# profile-account-section — `<AccountSection>`
## Purpose
`profile-account-section.tsx` houses the delete-account tab renderer for `<ProfilePage>`. It is split out of `profile-sections.tsx` so each implementation file stays under the 300-line hard cap. The component renders an `<Alert variant="destructive">` framing a danger-zone block; clicking the initial delete button reveals a confirmation pane with a password input and two CTAs (`confirm-delete`, `cancel-delete`). The parent page owns the visibility state via `showDeleteConfirm` and the three callbacks (`onRequestDelete`, `onCancelDelete`, `onConfirmDelete`).
## Features
- Single-card destructive surface using the `react-ui` `<Alert variant="destructive">` primitive.
- Two-stage confirmation: initial "Delete Account" button → confirm pane with password re-entry + "Yes, Delete My Account" + "Cancel".
- The confirm button is disabled while `isLoading` is true OR the password field is empty.
- Spinning loader (`<Loader2>`) appears alongside the deleting copy while the call is in flight.
- Errors at the `currentPassword` field surface inline.
## Usage: Basic
```tsx
import { AccountSection } from "./profile-account-section.js";
<AccountSection
t={t}
formData={formData}
errors={errors}
isLoading={isLoading}
onChange={handleInputChange}
showDeleteConfirm={showDeleteConfirm}
onRequestDelete={() => setShowDeleteConfirm(true)}
onCancelDelete={handleCancelDelete}
onConfirmDelete={handleConfirmDelete}
/>;
```
## Usage: Advanced
Hooking an analytics callback into the delete flow is handled at the parent page level via `onDeleteAccount`. From this component's perspective, the `onConfirmDelete` callback is the only one that triggers a network call:
```tsx
async function handleConfirmDelete() {
if (!formData.currentPassword) {
setErrors({ currentPassword: t.errorPasswordRequiredForDelete });
return;
}
await deleteAccount(formData.currentPassword);
track("account.deleted");
navigate("/auth/login");
}
```
## API Reference
### `AccountSectionProps extends SectionProps`
| Field | Type | Description |
|---|---|---|
| `showDeleteConfirm` | `boolean` | When `true`, the confirm pane is rendered. |
| `onRequestDelete` | `() => void` | Invoked when the initial delete button is clicked. |
| `onCancelDelete` | `() => void` | Invoked when the cancel button is clicked. |
| `onConfirmDelete` | `() => Promise<void>` | Invoked when the confirm button is clicked. |
## Implementation Notes
- The component reuses `formData.currentPassword` instead of introducing a `deletePassword` field — the parent page resets that field on cancel.
- The placeholder copy on the delete-password input is intentionally the `errorPasswordRequiredForDelete` string (also used as the inline error). This matches upstream UX: the placeholder explains the requirement, the error reinforces it after a failed submit.
- The component does not render its own `<form>` — the confirm button is a regular `<Button>` whose `onClick` triggers the delete. That keeps the password input out of the `<form>` element used by `<PasswordSection>` and prevents accidental form-submit collisions.

View File

@@ -0,0 +1,134 @@
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
} from "@suaveplan/react-ui";
import { AlertTriangle, Loader2, Shield } from "lucide-react";
import type { ReactElement } from "react";
import type { SectionProps } from "./profile-sections.js";
/** Account section props. */
export interface AccountSectionProps extends SectionProps {
showDeleteConfirm: boolean;
onRequestDelete: () => void;
onCancelDelete: () => void;
onConfirmDelete: () => Promise<void>;
}
export function AccountSection({
t,
formData,
errors,
isLoading,
onChange,
showDeleteConfirm,
onRequestDelete,
onCancelDelete,
onConfirmDelete,
}: AccountSectionProps): ReactElement {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
{t.sectionAccount}
</CardTitle>
<CardDescription>{t.accountDescription}</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-4">
<div>
<h3 className="font-semibold text-destructive">
{t.dangerZoneTitle}
</h3>
<p className="text-sm text-destructive mt-1">
{t.dangerZoneDescription}
</p>
</div>
{showDeleteConfirm ? (
<div className="space-y-4">
<Alert variant="destructive">
<AlertDescription>
<p className="font-semibold">
{t.dangerZoneConfirmMessage}
</p>
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="deletePassword">
{t.currentPassword}
</Label>
<Input
id="deletePassword"
type="password"
value={formData.currentPassword}
onChange={onChange(
"currentPassword"
)}
disabled={isLoading}
aria-label={
t.errorPasswordRequiredForDelete
}
/>
{errors.currentPassword ? (
<p className="text-sm text-destructive">
{errors.currentPassword}
</p>
) : null}
</div>
<div className="flex gap-2">
<Button
onClick={onConfirmDelete}
variant="destructive"
size="sm"
disabled={
isLoading ||
!formData.currentPassword
}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.deleting}
</>
) : (
t.dangerZoneConfirmDelete
)}
</Button>
<Button
onClick={onCancelDelete}
variant="outline"
size="sm"
disabled={isLoading}
>
{t.dangerZoneCancel}
</Button>
</div>
</div>
) : (
<Button
onClick={onRequestDelete}
variant="destructive"
size="sm"
disabled={isLoading}
>
{t.dangerZoneDeleteAccount}
</Button>
)}
</div>
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More