Initial Commit
This commit is contained in:
488
openspec/specs/react-auth/spec.md
Normal file
488
openspec/specs/react-auth/spec.md
Normal file
@@ -0,0 +1,488 @@
|
||||
<!-- 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 -->
|
||||
Reference in New Issue
Block a user