Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EmailVerificationPage } from "./email-verification.js";
|
||||
export type { EmailVerificationPageState } from "./email-verification.types.js";
|
||||
Reference in New Issue
Block a user