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

489 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec: react-auth
**Package**: `@suaveplan/react-auth`
**Status**: SHIPPED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: AuthClient wraps better-auth with site-domain header propagation
The package SHALL expose an `AuthClient` class and a `createAuthClient(config)` factory. The class SHALL wrap `better-auth/client` and SHALL forward every request through the underlying client. When `AuthClientConfig.siteDomain` is set, every request SHALL carry an `x-site-domain` header.
#### Scenario: site-domain header attached when configured
- **WHEN** `createAuthClient({ baseURL, siteDomain: "tenant-a" }).signIn({ email, password })` is called and the underlying client method is observed
- **THEN** the call options include `fetchOptions.headers["x-site-domain"] === "tenant-a"`
#### Scenario: header omitted when not configured
- **WHEN** `createAuthClient({ baseURL }).getSession()` is called
- **THEN** the call options omit the `x-site-domain` header
#### Scenario: AuthClientConfig validates at the boundary
- **WHEN** `createAuthClient` is invoked with a non-URL `baseURL`
- **THEN** a `ReactAuthConfigInvalidError` is thrown with code `REACT_AUTH.CONFIG_INVALID`
**Verified by:** `src/client/auth-client.test.ts::"propagates x-site-domain header on every method when siteDomain is configured"` AND `src/client/auth-client.test.ts::"omits x-site-domain header when siteDomain is not configured"` AND `src/client/auth-client.test.ts::"throws ReactAuthConfigInvalidError when baseURL is not a valid URL"`
---
### Requirement: AuthClient list-sessions normalises Date fields to ISO strings
The `AuthClient.listSessions()` method SHALL return `Session[]` and SHALL convert each session's `createdAt` / `updatedAt` / `expiresAt` fields from `Date` to ISO string (or leave them as the underlying type when already a string).
#### Scenario: dates converted
- **WHEN** the underlying client returns sessions with `Date` instances
- **THEN** the returned `Session[]` contains ISO-string `createdAt` / `updatedAt`
**Verified by:** `src/client/auth-client.test.ts::"converts Date fields on listSessions to ISO strings"`
---
### Requirement: AuthProvider exposes auth state and 11 actions through context
The package SHALL expose an `AuthProvider` component and a `useAuth()` hook. The context value SHALL include `{ user, session, isLoading, isAuthenticated, error, client }` and the 11 actions `signIn` / `signUp` / `signOut` / `updateProfile` / `changePassword` / `forgotPassword` / `resetPassword` / `verifyEmail` / `resendVerificationEmail` / `deleteAccount` / `refreshSession`. `useAuth()` outside an `AuthProvider` SHALL throw `ReactAuthContextMissingError` with code `REACT_AUTH.CONTEXT_MISSING`.
#### Scenario: initial session loaded on mount
- **WHEN** `AuthProvider` mounts with `skipInitialCheck=false` and the client's `getSession()` returns `{ data: { user, session } }`
- **THEN** the context settles with `isAuthenticated=true`, `user`, `session`, `isLoading=false`
#### Scenario: useAuth outside provider throws
- **WHEN** `useAuth()` is called outside any `AuthProvider`
- **THEN** a `ReactAuthContextMissingError` is thrown with code `REACT_AUTH.CONTEXT_MISSING`
#### Scenario: each action settles loading and surfaces errors
- **WHEN** any of the 11 actions resolves successfully OR rejects
- **THEN** `isLoading` flips false; on reject `error` carries the wrapped `Error` and the action re-throws
**Verified by:** `src/context/auth-context.test.tsx::"loads the initial session and settles isAuthenticated=true"` AND `src/context/auth-context.test.tsx::"useAuth outside an AuthProvider throws ReactAuthContextMissingError"` AND `src/context/auth-context.test.tsx::"every action settles isLoading and propagates errors"`
---
### Requirement: AuthGuard gates children by auth / guest / verified / roles
The `AuthGuard` component SHALL render `loadingComponent` (or a default spinner) while `isLoading=true`. When loading completes it SHALL render `children` if and only if every configured predicate (`requireAuth` / `requireGuest` / `requireVerified` / `allowedRoles`) is satisfied; otherwise it SHALL render `fallback` (or `null`).
#### Scenario: authenticated user passes default requireAuth
- **WHEN** an authenticated, verified user enters an `AuthGuard requireAuth`
- **THEN** `children` is rendered
#### Scenario: unauthenticated user gets fallback when requireAuth
- **WHEN** an unauthenticated user enters an `AuthGuard requireAuth fallback={<X/>}`
- **THEN** `<X/>` is rendered and `children` is not
#### Scenario: role mismatch denied
- **WHEN** an authenticated user with `role="user"` enters `AuthGuard allowedRoles={["admin"]}`
- **THEN** the guard does not render `children`
**Verified by:** `src/components/auth-guard/auth-guard.test.tsx::"renders children when authenticated and no extra predicates are set"` AND `src/components/auth-guard/auth-guard.test.tsx::"renders fallback when unauthenticated and requireAuth is set"` AND `src/components/auth-guard/auth-guard.test.tsx::"denies when allowedRoles does not include the user role"`
---
### Requirement: useAuthGuard imperatively redirects via react-router-dom
The `useAuthGuard(options)` hook SHALL call `react-router-dom`'s `useNavigate()` and SHALL navigate to `redirectTo` (default `/auth/login`) when `requireAuth` fails, to `/` when `requireGuest` fails, to `/auth/verify-email` when `requireVerified` fails, and to `/unauthorized` when `allowedRoles` fails. It SHALL invoke `onUnauthorized` before the auth / role redirects. The hook SHALL also return `{ isLoading, isAuthenticated, user, canAccess }` for inline checks.
#### Scenario: redirects to login when not authenticated
- **WHEN** an unauthenticated user mounts a component calling `useAuthGuard({ requireAuth: true })`
- **THEN** `navigate("/auth/login")` is called once
#### Scenario: canAccess reflects every predicate
- **WHEN** any predicate fails
- **THEN** `canAccess === false`; when all predicates pass, `canAccess === true`
**Verified by:** `src/hooks/use-auth-guard.test.tsx::"redirects to /auth/login when requireAuth fails"` AND `src/hooks/use-auth-guard.test.tsx::"canAccess matrix reflects every predicate combination"`
---
### Requirement: usePermission exposes bitwise permission checks
The `usePermission()` hook SHALL expose `hasPermission` / `hasAnyPermission` / `hasAllPermissions` / `getPermissionNames` / `userRoles`. Each predicate SHALL accept either a `Permission` string key or a numeric bitmask and SHALL test `(user.roles & bit) === bit`. With no authenticated user, every predicate SHALL return `false` and `getPermissionNames()` SHALL return `[]`.
#### Scenario: string and numeric permission accepted
- **WHEN** `hasPermission("USER_READ")` and `hasPermission(1)` are called with `user.roles = 0b1`
- **THEN** both return `true`
#### Scenario: unauthenticated bails out
- **WHEN** no user is present
- **THEN** `hasPermission`, `hasAnyPermission`, `hasAllPermissions` all return `false`
**Verified by:** `src/hooks/use-permission.test.tsx::"accepts string and numeric Permission inputs"` AND `src/hooks/use-permission.test.tsx::"returns false for every predicate when unauthenticated"`
---
### Requirement: better-auth response adapter narrows union to internal shapes
The package SHALL expose `isBetterAuthError` / `hasBetterAuthData` / `transformSessionResponse` / `transformAuthResponse` / `transformGenericResponse` / `transformUpdateProfileResponse`. Each transform SHALL accept `unknown` (no `any`), SHALL detect the `error`-tagged and `data`-tagged variants, and SHALL emit the package's internal `User` / `Session` / `AuthResponse` shapes with ISO-string timestamps.
#### Scenario: session response with Date timestamps normalised
- **WHEN** `transformSessionResponse({ data: { user: { ..., createdAt: Date }, session: { ..., expiresAt: Date } } })` is called
- **THEN** the returned `{ user, session }` carries ISO strings
#### Scenario: error variant surfaces message
- **WHEN** the response carries `{ error, message }`
- **THEN** the transform returns `{ session: null, user: null, error: <message> }`
**Verified by:** `src/lib/better-auth-adapter.test.ts::"transformSessionResponse normalises Date timestamps to ISO strings"` AND `src/lib/better-auth-adapter.test.ts::"transforms surface the better-auth error message"`
---
### Requirement: REACT_AUTH error code registry covers every thrown error
The package SHALL export `REACT_AUTH_CODES = defineCodes("REACT_AUTH", [...])` with at least the 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`. Every code SHALL be thrown by at least one production path and SHALL surface through a typed `ReactAuthError` subclass.
#### Scenario: every code has a thrown call site
- **WHEN** the test suite enumerates `REACT_AUTH_CODES` values
- **THEN** every code is reachable via a typed subclass that ships in `src/error-codes/error-codes.ts`
**Verified by:** `src/error-codes/error-codes.test.ts::"every REACT_AUTH code is thrown by at least one production path"`
---
### Requirement: OTel instruments emit through suaveplan.react-auth meter
The package SHALL build OTel instruments lazily under `metrics.getMeter("suaveplan.react-auth")` and SHALL emit at minimum: `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` counters, and `react_auth.session_fetch.duration_ms` histogram.
#### Scenario: in-memory MeterProvider captures every emit
- **WHEN** a real `MeterProvider` + `InMemoryMetricExporter` is wired and every production path that emits is executed
- **THEN** the corresponding instrument records the expected attributes
**Verified by:** `src/observability/react-auth-metrics.test.ts::"emits sign-in / sign-up / sign-out / guard / session-fetch instruments through suaveplan.react-auth"`
---
### Requirement: Locale packs ship for en-GB / cy-GB / zh-CN with key parity
The package SHALL export `enGb`, `cyGb`, `zhCn` (default exports) and an aggregate `authTranslations = { "en-gb": enGb, "cy-gb": cyGb, "zh-cn": zhCn }`. Every locale SHALL carry the same set of `auth.<group>.<key>` paths so Wave-2 forms can resolve without locale-specific guards.
#### Scenario: key parity across locales
- **WHEN** the locale-pack test flattens every dotted key in each locale
- **THEN** the three sets are equal
**Verified by:** `src/translations/translations.test.ts::"every locale pack exposes the same dotted key set"`
---
### Requirement: Root barrel uses explicit named re-exports
The root `src/index.ts` and every feature barrel SHALL use explicit `export { … }` re-exports — never `export *`. The barrel SHALL expose the public surface enumerated in `proposal.md` `## What changes` and nothing else.
#### Scenario: package roots are explicit
- **WHEN** repo-wide `grep -rn '^export \\*' packages/react/react-auth/src/` is run
- **THEN** the result is empty
**Verified by:** `src/index.test.ts::"the root barrel exports exactly the documented public surface"`
---
### Requirement: LoginForm renders a Zod-validated sign-in form with email, password, and remember-me
The package SHALL expose a `LoginForm` component that renders a card-layout sign-in form with email + password + remember-me fields. The form SHALL validate via `react-hook-form` + `zodResolver` against an internal `loginSchema` requiring email to be a non-empty valid email and password to be at least 8 characters. Submit SHALL invoke the caller-supplied `onSubmit({ email, password, rememberMe })`. The component SHALL render a destructive `Alert` when `error` is non-null, disable the submit button when `isLoading` or `formState.isSubmitting` is true, and accept a `translations` prop overriding the default `enGb.auth.login` strings.
#### Scenario: invalid email shows Zod error on blur
- **WHEN** the user types `not-an-email` into the email field and submits
- **THEN** the text "Invalid email address" appears
#### Scenario: short password rejected
- **WHEN** the user submits with password `"abc"`
- **THEN** the text "Password must be at least 8 characters" appears and `onSubmit` is NOT called
#### Scenario: happy path submit
- **WHEN** the user fills `alice@example.com` + `correcthorsebatterystaple` and clicks Sign in
- **THEN** `onSubmit({ email: "alice@example.com", password: "correcthorsebatterystaple", rememberMe: true })` is called
#### Scenario: error prop renders alert
- **WHEN** `error={new Error("Invalid credentials")}` is passed
- **THEN** an alert containing "Invalid credentials" is in the document
#### Scenario: translations prop overrides defaults
- **WHEN** `translations={{ submit: "Connexion" }}` is passed
- **THEN** the submit button displays "Connexion"
**Verified by:** `src/components/login-form/login-form.test.tsx::"shows Zod error when email is invalid"` AND `src/components/login-form/login-form.test.tsx::"shows Zod error when password is shorter than 8 characters"` AND `src/components/login-form/login-form.test.tsx::"calls onSubmit with the typed payload on happy path"` AND `src/components/login-form/login-form.test.tsx::"renders the error prop in a destructive Alert"` AND `src/components/login-form/login-form.test.tsx::"overrides the submit copy when translations prop is supplied"`
---
### Requirement: RegisterForm renders a Zod-validated sign-up form with cross-field password match
The package SHALL expose a `RegisterForm` component rendering email, username, password, confirm-password, optional first-name, surname, date-of-birth, sex (4-option select), gender, company fields. The Zod schema SHALL enforce email validity, username `^[a-zA-Z0-9_]+$` with length 3–30, password ≥ 8 chars, and a `confirmPassword` cross-field refinement matching `password`. Submit SHALL fire only when validation passes. The component SHALL accept `error`, `isLoading`, and `translations` props mirroring `LoginForm` semantics.
#### Scenario: confirmPassword mismatch blocks submit
- **WHEN** password `"longenough1"` and confirmPassword `"longenough2"` are submitted
- **THEN** "Passwords don't match" appears under the confirm field and `onSubmit` is NOT called
#### Scenario: username regex enforced
- **WHEN** username `"has space"` is submitted
- **THEN** the Zod regex error appears and `onSubmit` is NOT called
#### Scenario: happy path submit
- **WHEN** every required field is valid and the form is submitted
- **THEN** `onSubmit` receives the full typed payload including the default `sex: "prefer-not-to-say"`
**Verified by:** `src/components/register-form/register-form.test.tsx::"blocks submit when passwords do not match"` AND `src/components/register-form/register-form.test.tsx::"rejects usernames that do not match the regex"` AND `src/components/register-form/register-form.test.tsx::"calls onSubmit with the typed payload on happy path"` AND `src/components/register-form/register-form.test.tsx::"renders the error prop in a destructive Alert"`
---
### Requirement: ForgotPasswordForm renders an email-only Zod-validated form with success view
The package SHALL expose a `ForgotPasswordForm` component rendering an email-only form. When `isSuccess` is true the component SHALL switch to a confirmation card displaying `emailSent`, a "send another" button wired to `onReset`, and a "back to login" link. Otherwise it SHALL render the email form with Zod validation, a destructive alert when `error` is non-null, and a submit button disabled while `isLoading` or `isSubmitting`. A `translations` prop SHALL override `enGb.auth.forgotPassword` defaults.
#### Scenario: invalid email blocked
- **WHEN** the user submits "not-email"
- **THEN** "Invalid email address" appears and `onSubmit` is NOT called
#### Scenario: success view rendered
- **WHEN** `isSuccess` is true and `emailSent="alice@example.com"`
- **THEN** the document contains "alice@example.com" and a button whose click calls `onReset`
**Verified by:** `src/components/forgot-password-form/forgot-password-form.test.tsx::"blocks submit when email is invalid"` AND `src/components/forgot-password-form/forgot-password-form.test.tsx::"calls onSubmit with the typed payload on happy path"` AND `src/components/forgot-password-form/forgot-password-form.test.tsx::"renders the success view with emailSent and a working onReset button"` AND `src/components/forgot-password-form/forgot-password-form.test.tsx::"renders the error prop in a destructive Alert"`
---
### Requirement: ResetPasswordForm renders new+confirm password with success and invalid-token views
The package SHALL expose a `ResetPasswordForm` component supporting three view modes: form (default), success (`isSuccess=true`), and invalid token (`hasToken=false`). The schema SHALL require both `password` and `confirmPassword` ≥ 8 chars with a cross-field match refinement. The success view SHALL render a "sign in" CTA wired to `onNavigateToLogin`; the invalid-token view SHALL render a "request new link" CTA wired to `onNavigateToForgotPassword`. The form view SHALL accept `error`, `isLoading`, and `translations` props.
#### Scenario: password mismatch blocks submit
- **WHEN** password `"longenough1"` and confirm `"longenough2"` are submitted
- **THEN** "Passwords don't match" appears and `onSubmit` is NOT called
#### Scenario: success view renders CTA
- **WHEN** `isSuccess=true`
- **THEN** clicking the Sign in CTA calls `onNavigateToLogin`
#### Scenario: invalid token view renders CTA
- **WHEN** `hasToken=false`
- **THEN** clicking the Request new reset link CTA calls `onNavigateToForgotPassword`
**Verified by:** `src/components/reset-password-form/reset-password-form.test.tsx::"blocks submit when passwords do not match"` AND `src/components/reset-password-form/reset-password-form.test.tsx::"calls onSubmit with the typed payload on happy path"` AND `src/components/reset-password-form/reset-password-form.test.tsx::"renders the success view with a working onNavigateToLogin CTA"` AND `src/components/reset-password-form/reset-password-form.test.tsx::"renders the invalid-token view with a working onNavigateToForgotPassword CTA"`
---
### Requirement: EmailVerificationView renders four status modes with caller-supplied translations
The package SHALL expose an `EmailVerificationView` component accepting `status: "verifying" | "success" | "expired" | "error"`. Each status SHALL render a Card with an icon, title, description, and (where applicable) a CTA wired to one of `onContinueToLogin` / `onRegisterAgain` / `onTryAgain`. The `error` status SHALL render a destructive Alert; the `error` retry CTA SHALL be disabled while `isLoading`. A `translations` prop SHALL override `enGb.auth.emailVerification` defaults; an optional `message` prop SHALL override the default status description.
#### Scenario: verifying state renders spinner and copy
- **WHEN** `status="verifying"`
- **THEN** a `role="status"` spinner is present and the default English "Verifying email..." copy is visible
#### Scenario: success CTA fires onContinueToLogin
- **WHEN** `status="success"` and the CTA is clicked
- **THEN** `onContinueToLogin` is called once
#### Scenario: error CTA fires onTryAgain and respects isLoading
- **WHEN** `status="error"` and `isLoading=false`
- **THEN** the retry CTA is enabled and clicking it calls `onTryAgain`
#### Scenario: expired CTA fires onRegisterAgain
- **WHEN** `status="expired"` and the CTA is clicked
- **THEN** `onRegisterAgain` is called once
**Verified by:** `src/components/email-verification-view/email-verification-view.test.tsx::"renders the verifying status with a spinner"` AND `src/components/email-verification-view/email-verification-view.test.tsx::"fires onContinueToLogin from the success CTA"` AND `src/components/email-verification-view/email-verification-view.test.tsx::"fires onTryAgain from the error CTA"` AND `src/components/email-verification-view/email-verification-view.test.tsx::"fires onRegisterAgain from the expired CTA"` AND `src/components/email-verification-view/email-verification-view.test.tsx::"overrides the default copy when translations prop is supplied"`
---
### Requirement: Wave 2 form components reachable from the root barrel
The `@suaveplan/react-auth` root entry SHALL re-export the 5 Wave 2 form components as named exports (`LoginForm`, `RegisterForm`, `ForgotPasswordForm`, `ResetPasswordForm`, `EmailVerificationView`) with no `export *`. Each form SHALL also be reachable via a dedicated subpath export (`./components/login-form`, etc.) for tree-shaking.
#### Scenario: Wave 2 surface present in root barrel
- **WHEN** the root-barrel test enumerates `Object.keys(import("@suaveplan/react-auth"))`
- **THEN** every Wave 2 form symbol is present alongside the Wave 1 surface
**Verified by:** `src/index.test.ts::"the root barrel exports exactly the documented public surface"`
### Requirement: LoginPage wires LoginForm to the auth context with router redirect
The package SHALL expose a `<LoginPage>` component that renders the Wave 2 `<LoginForm>`, calls `useAuth().signIn(values)` on submit, redirects to `useSearchParams().get("returnTo")` (or `/dashboard` if absent) via `useNavigate`, and applies `useAuthGuard({ requireGuest: true })` so already-authenticated visitors are redirected away. The page SHALL swallow rejections from `signIn` — the underlying `useAuth().error` field carries the surfaced message into the form's destructive alert.
#### Scenario: happy path navigates to returnTo
- **WHEN** `<LoginPage>` is mounted under `/auth/login?returnTo=/inbox` and the user submits valid credentials
- **THEN** `useAuth().signIn` is called with the typed credentials AND `useNavigate` is called with `"/inbox"`
#### Scenario: happy path defaults to /dashboard
- **WHEN** `<LoginPage>` is mounted under `/auth/login` (no `returnTo`) and the user submits valid credentials
- **THEN** `useNavigate` is called with `"/dashboard"`
#### Scenario: signIn rejection does not throw
- **WHEN** `useAuth().signIn` rejects
- **THEN** the page does not throw and the error message renders inside the `<LoginForm>` alert
**Verified by:** `src/pages/login/login.test.tsx::"navigates to returnTo on happy path"` AND `src/pages/login/login.test.tsx::"defaults to /dashboard when returnTo is absent"` AND `src/pages/login/login.test.tsx::"swallows signIn rejection and surfaces the error in the form"`
### Requirement: RegisterPage wires RegisterForm to signUp and strips confirmPassword
The package SHALL expose a `<RegisterPage>` component that renders the Wave 2 `<RegisterForm>`, strips `confirmPassword` from the submitted values before calling `useAuth().signUp(data)`, redirects to `/dashboard` on success, and applies `useAuthGuard({ requireGuest: true })`. The page SHALL swallow `signUp` rejections (errors surface via the form alert).
#### Scenario: happy path strips confirmPassword
- **WHEN** the form submits `{ email, password, confirmPassword, username, ... }`
- **THEN** `useAuth().signUp` is called with the payload minus `confirmPassword` AND `useNavigate("/dashboard")` is invoked
#### Scenario: signUp rejection does not throw
- **WHEN** `useAuth().signUp` rejects
- **THEN** the page does not throw
**Verified by:** `src/pages/register/register.test.tsx::"strips confirmPassword and navigates to /dashboard on happy path"` AND `src/pages/register/register.test.tsx::"swallows signUp rejection"`
### Requirement: ForgotPasswordPage wires ForgotPasswordForm to forgotPassword with success view
The package SHALL expose a `<ForgotPasswordPage>` component that renders the Wave 2 `<ForgotPasswordForm>`, calls `useAuth().forgotPassword(email)` on submit, drives the `isSuccess` / `emailSent` props from local state on resolution, and clears that state when the form's `onReset` callback fires.
#### Scenario: happy path flips to success view
- **WHEN** the form submits and `forgotPassword` resolves
- **THEN** the form is re-rendered with `isSuccess={true}` AND `emailSent` equals the submitted email
#### Scenario: reset returns to entry view
- **WHEN** `isSuccess` is true and the success view's reset callback fires
- **THEN** the form is re-rendered with `isSuccess={false}` AND `emailSent=""`
**Verified by:** `src/pages/forgot-password/forgot-password.test.tsx::"flips to success view on happy path"` AND `src/pages/forgot-password/forgot-password.test.tsx::"reset returns to entry view"`
### Requirement: ResetPasswordPage extracts ?token and drives ResetPasswordForm state
The package SHALL expose a `<ResetPasswordPage>` component that reads `?token` from `useSearchParams`, passes `hasToken={!!token}` to the Wave 2 `<ResetPasswordForm>`, calls `useAuth().resetPassword(token, password)` on submit when a token exists, flips `isSuccess` on resolution, and exposes `onNavigateToLogin` (`/auth/login`) and `onNavigateToForgotPassword` (`/auth/forgot-password`) callbacks for the form's success / invalid-token CTAs. The page SHALL apply `useAuthGuard({ requireGuest: true })`.
#### Scenario: missing token renders invalid-token view
- **WHEN** the page is mounted under `/auth/reset-password` (no `?token`)
- **THEN** `<ResetPasswordForm>` is rendered with `hasToken={false}`
#### Scenario: happy path flips to success
- **WHEN** the form submits with `?token=abc` present and `resetPassword` resolves
- **THEN** `useAuth().resetPassword` is called with `("abc", password)` AND the form re-renders with `isSuccess={true}`
#### Scenario: navigation CTAs fire
- **WHEN** the form invokes `onNavigateToLogin` or `onNavigateToForgotPassword`
- **THEN** `useNavigate` is called with `/auth/login` or `/auth/forgot-password` respectively
**Verified by:** `src/pages/reset-password/reset-password.test.tsx::"renders invalid-token view when ?token is absent"` AND `src/pages/reset-password/reset-password.test.tsx::"flips to success on happy path"` AND `src/pages/reset-password/reset-password.test.tsx::"navigates on login and forgot-password CTAs"`
### Requirement: EmailVerificationPage auto-verifies ?token and classifies errors
The package SHALL expose an `<EmailVerificationPage>` component that reads `?token` from `useSearchParams`, calls `useAuth().verifyEmail(token)` once on mount, drives the Wave 2 `<EmailVerificationView>` status via local state (`verifying` → `success` / `error` / `expired`), classifies any error whose message contains `expired` or `invalid` (case-insensitive) as `expired`, and exposes navigation callbacks (`onContinueToLogin` → `/auth/login`, `onRegisterAgain` → `/auth/register`, `onTryAgain` reloads). The page SHALL apply `useAuthGuard({ requireGuest: true })`.
#### Scenario: missing token sets error status
- **WHEN** the page mounts with no `?token`
- **THEN** `<EmailVerificationView status="error">` is rendered AND `useAuth().verifyEmail` is NOT called
#### Scenario: happy-path sets success status
- **WHEN** `verifyEmail` resolves
- **THEN** `<EmailVerificationView status="success">` is rendered
#### Scenario: expired token classifies as expired
- **WHEN** `verifyEmail` rejects with `new Error("token has expired")`
- **THEN** `<EmailVerificationView status="expired">` is rendered
**Verified by:** `src/pages/email-verification/email-verification.test.tsx::"renders error status when ?token is absent"` AND `src/pages/email-verification/email-verification.test.tsx::"renders success status on happy path"` AND `src/pages/email-verification/email-verification.test.tsx::"classifies expired-token rejection as expired"`
### Requirement: ProfilePage renders a three-tab account surface bound to useAuth
The package SHALL expose a `<ProfilePage>` (aliased as `Profile` for upstream compatibility) component that renders a three-tab surface (`profile`, `password`, `account`) using `@suaveplan/react-ui` `<Tabs>`, hydrates the `profile` form from `useAuth().user`, calls `useAuth().updateProfile({ name, email })` on profile-tab submit, calls `useAuth().changePassword({ currentPassword, newPassword })` on password-tab submit (after local validation: required, ≥8 chars, match), and calls `useAuth().deleteAccount(currentPassword)` on account-tab confirmation (then navigates to `/auth/login`). The page SHALL apply `useAuthGuard({ requireAuth: true })`. The page SHALL accept a `translations?: Partial<AuthProfilePageStrings>` prop defaulting to `enGb.auth.profilePage`.
#### Scenario: guards unauthenticated visitors
- **WHEN** the page is mounted with `useAuth().isAuthenticated === false`
- **THEN** `useNavigate("/auth/login")` is invoked
#### Scenario: tab switching renders the requested section
- **WHEN** the user clicks the `password` tab
- **THEN** the password form is rendered AND the profile form is hidden
#### Scenario: profile update calls updateProfile and shows success
- **WHEN** the profile form is submitted with edited name + email
- **THEN** `updateProfile({ name, email })` is called AND a success alert appears
#### Scenario: password change validates locally before calling changePassword
- **WHEN** newPassword and confirmPassword do not match
- **THEN** `changePassword` is NOT called AND a mismatch error renders
#### Scenario: delete-account flow
- **WHEN** the user confirms delete with a password
- **THEN** `deleteAccount(password)` is called AND `useNavigate("/auth/login")` is invoked
**Verified by:** `src/pages/profile/profile.test.tsx::"guards unauthenticated visitors"` AND `src/pages/profile/profile.test.tsx::"switches tabs on click"` AND `src/pages/profile/profile.test.tsx::"profile update success"` AND `src/pages/profile/profile.test.tsx::"password change validates mismatch locally"` AND `src/pages/profile/profile.test.tsx::"delete account confirmation calls deleteAccount and navigates"`
### Requirement: PermissionGate conditionally renders by bitmask permission check
The package SHALL expose a `<PermissionGate>` component that consumes `usePermission()` from Wave 1 and renders `children` when the configured `permissions` array passes the check, otherwise renders `fallback` (default `null`). The default semantics SHALL be ANY-of; setting `requireAll={true}` SHALL switch to ALL-of semantics. Each entry in `permissions` MAY be a named `Permission` key OR a raw `number` bitmask.
#### Scenario: any-of permits when one bit matches
- **WHEN** the user has `Permissions.USER_READ` and the gate requires `["USER_READ", "ADMIN_WRITE"]`
- **THEN** the children are rendered
#### Scenario: requireAll denies when one bit missing
- **WHEN** the user has only `Permissions.USER_READ` and the gate requires `["USER_READ", "ADMIN_WRITE"]` with `requireAll={true}`
- **THEN** the fallback is rendered
#### Scenario: numeric permission accepted
- **WHEN** the gate is given a raw numeric bitmask matching the user's bits
- **THEN** the children are rendered
#### Scenario: unauthenticated user always denied
- **WHEN** `useAuth().user === null`
- **THEN** the fallback is rendered
**Verified by:** `src/components/permission-gate/permission-gate.test.tsx::"renders children when any-of matches"` AND `src/components/permission-gate/permission-gate.test.tsx::"renders fallback when requireAll fails"` AND `src/components/permission-gate/permission-gate.test.tsx::"accepts numeric permission"` AND `src/components/permission-gate/permission-gate.test.tsx::"renders fallback when unauthenticated"`
<!-- markdownlint-enable MD024 -->