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,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";