Skip to content

Commit 4b8af33

Browse files
authored
feat: add resend verification email (#340)
* docs: add resend verification email design spec Design spec for adding a "Resend email" button to the Check Your Email page, backed by a custom Payload CMS endpoint that regenerates verification tokens and re-sends the email via AWS SES. * docs: add resend verification email implementation plan 7-task TDD plan covering: shared email helper, endpoint handler, i18n translations, sign-up redirect, and CheckYourEmail component with 30-second cooldown resend button. * feat: add shared verification email template helper * refactor: extract verification email template to shared helper * feat: add resend verification email endpoint Implements POST /api/users/resend-verification Payload CMS endpoint that generates a new token, updates the user record, and sends a verification email — returning a generic success response in all cases to prevent user enumeration. * feat: add resend verification email i18n keys * feat: pass email to check-your-email page via query param * refactor: simplify verify-email formatting Inline short parameter types and assertion strings for readability. * feat: add resend verification email button with cooldown Add resend button to CheckYourEmail component that calls the /v1/api/users/resend-verification endpoint, with a 30-second cooldown and countdown display after clicking. Install @testing-library/user-event and cover all behaviour with 7 unit tests (TDD). * style: reformat imports in check-your-email component and test * fix(quality): resolve typescript:S7772 in resend-verification.ts (MINOR) Use node: protocol prefix for crypto import. * fix(quality): resolve typescript:S6759 and S7764 in check-your-email (MINOR) Mark component props as read-only; use globalThis over global.
1 parent 95f66b4 commit 4b8af33

16 files changed

Lines changed: 1576 additions & 30 deletions

File tree

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@
139139
"@tanstack/eslint-plugin-query": "5.91.4",
140140
"@testing-library/jest-dom": "6.9.1",
141141
"@testing-library/react": "16.3.0",
142+
"@testing-library/user-event": "^14.6.1",
142143
"@types/chroma-js": "2.4.5",
143144
"@types/geojson": "^7946.0.16",
144145
"@types/jest": "29.5.14",

client/pnpm-lock.yaml

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/src/app/(frontend)/[locale]/(app)/auth/check-your-email/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ export async function generateMetadata({
1717
};
1818
}
1919

20-
export default async function CheckYourEmailPage(
21-
_props: PageProps<"/[locale]/auth/check-your-email">,
22-
) {
20+
export default async function CheckYourEmailPage({
21+
searchParams,
22+
}: PageProps<"/[locale]/auth/check-your-email">) {
23+
const { email } = await searchParams;
24+
2325
return (
2426
<section className="flex grow items-center justify-center">
2527
<div className="mx-auto w-full max-w-lg">
26-
<CheckYourEmail />
28+
<CheckYourEmail email={typeof email === "string" ? email : undefined} />
2729
</div>
2830
</section>
2931
);

client/src/cms/collections/Users.ts

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { auth, signOut } from "@/lib/auth";
77
import { adminAccess } from "@/cms/access/admin";
88
import { anyoneAccess } from "@/cms/access/anyone";
99
import { userAccess } from "@/cms/access/user";
10+
import { buildVerifyEmailHTML, VERIFY_EMAIL_SUBJECT } from "@/cms/emails/verify-email";
11+
import { resendVerificationHandler } from "@/cms/endpoints/resend-verification";
1012
import { beforeDeleteUser } from "@/cms/hooks/user";
1113
import { or } from "@/cms/utils/or";
1214

@@ -19,31 +21,13 @@ export const Users: CollectionConfig = {
1921
auth: {
2022
verify: {
2123
generateEmailSubject: async () => {
22-
return "Verify your email address";
24+
return VERIFY_EMAIL_SUBJECT;
2325
},
2426
generateEmailHTML: async (params) => {
25-
// Use the token provided to verify your user's email address
26-
const verifyEmailURL = `${env.NEXT_PUBLIC_URL}/auth/verify-email?token=${params.token}`;
27-
28-
return `
29-
<!doctype html>
30-
<html lang="en">
31-
<head>
32-
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
33-
</head>
34-
<body>
35-
<h1>Email verification</h1>
36-
<p>
37-
Hello ${params.user.email},<br /><br />
38-
Thank you for registering an account with AmazoniaForever360+!. Please verify your email address by clicking the link below:<br />
39-
<a href="${verifyEmailURL}">Verify your email address</a><br /><br />
40-
If you did not create this account, please ignore this email.<br /><br />
41-
Thank you!<br />
42-
AmazoniaForever360+ Team
43-
</p>
44-
</body>
45-
</html>
46-
`;
27+
return buildVerifyEmailHTML({
28+
email: params.user.email,
29+
token: params.token,
30+
});
4731
},
4832
},
4933
forgotPassword: {
@@ -133,6 +117,11 @@ export const Users: CollectionConfig = {
133117
});
134118
},
135119
},
120+
{
121+
path: "/resend-verification",
122+
method: "post",
123+
handler: resendVerificationHandler,
124+
},
136125
],
137126
hooks: {
138127
beforeDelete: [beforeDeleteUser],
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
jest.mock("@/env.mjs", () => ({
2+
env: {
3+
NEXT_PUBLIC_URL: "http://localhost:3000",
4+
},
5+
}));
6+
7+
import { buildVerifyEmailHTML, VERIFY_EMAIL_SUBJECT } from "./verify-email";
8+
9+
describe("verify-email", () => {
10+
describe("VERIFY_EMAIL_SUBJECT", () => {
11+
it("returns the expected subject line", () => {
12+
expect(VERIFY_EMAIL_SUBJECT).toBe("Verify your email address");
13+
});
14+
});
15+
16+
describe("buildVerifyEmailHTML", () => {
17+
it("includes the verification URL with the provided token", () => {
18+
const html = buildVerifyEmailHTML({
19+
email: "test@example.com",
20+
token: "abc123",
21+
});
22+
23+
expect(html).toContain("http://localhost:3000/auth/verify-email?token=abc123");
24+
});
25+
26+
it("includes the user email in the greeting", () => {
27+
const html = buildVerifyEmailHTML({
28+
email: "test@example.com",
29+
token: "abc123",
30+
});
31+
32+
expect(html).toContain("test@example.com");
33+
});
34+
35+
it("returns a complete HTML document", () => {
36+
const html = buildVerifyEmailHTML({
37+
email: "test@example.com",
38+
token: "abc123",
39+
});
40+
41+
expect(html).toContain("<!doctype html>");
42+
expect(html).toContain("</html>");
43+
});
44+
45+
it("includes the clickable verification link", () => {
46+
const html = buildVerifyEmailHTML({
47+
email: "test@example.com",
48+
token: "token456",
49+
});
50+
51+
expect(html).toContain('<a href="http://localhost:3000/auth/verify-email?token=token456">');
52+
});
53+
});
54+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { env } from "@/env.mjs";
2+
3+
export const VERIFY_EMAIL_SUBJECT = "Verify your email address";
4+
5+
export function buildVerifyEmailHTML(params: { email: string; token: string }): string {
6+
const verifyEmailURL = `${env.NEXT_PUBLIC_URL}/auth/verify-email?token=${params.token}`;
7+
8+
return `
9+
<!doctype html>
10+
<html lang="en">
11+
<head>
12+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
13+
</head>
14+
<body>
15+
<h1>Email verification</h1>
16+
<p>
17+
Hello ${params.email},<br /><br />
18+
Thank you for registering an account with AmazoniaForever360+!. Please verify your email address by clicking the link below:<br />
19+
<a href="${verifyEmailURL}">Verify your email address</a><br /><br />
20+
If you did not create this account, please ignore this email.<br /><br />
21+
Thank you!<br />
22+
AmazoniaForever360+ Team
23+
</p>
24+
</body>
25+
</html>
26+
`;
27+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
jest.mock("@/env.mjs", () => ({
6+
env: {
7+
NEXT_PUBLIC_URL: "http://localhost:3000",
8+
},
9+
}));
10+
11+
jest.mock("@/cms/emails/verify-email", () => ({
12+
VERIFY_EMAIL_SUBJECT: "Verify your email address",
13+
buildVerifyEmailHTML: jest.fn().mockReturnValue("<html>mock email</html>"),
14+
}));
15+
16+
import { buildVerifyEmailHTML } from "@/cms/emails/verify-email";
17+
18+
import { resendVerificationHandler } from "./resend-verification";
19+
20+
function createMockReq(body: Record<string, unknown>) {
21+
return {
22+
payload: {
23+
find: jest.fn(),
24+
update: jest.fn(),
25+
sendEmail: jest.fn(),
26+
},
27+
json: jest.fn().mockResolvedValue(body),
28+
} as unknown as Parameters<typeof resendVerificationHandler>[0];
29+
}
30+
31+
describe("resendVerificationHandler", () => {
32+
beforeEach(() => {
33+
jest.clearAllMocks();
34+
});
35+
36+
it("returns 400 when email is missing", async () => {
37+
const req = createMockReq({});
38+
39+
const response = await resendVerificationHandler(req);
40+
const json = await response.json();
41+
42+
expect(response.status).toBe(400);
43+
expect(json.message).toBe("Invalid email address.");
44+
});
45+
46+
it("returns 400 when email is invalid", async () => {
47+
const req = createMockReq({ email: "not-an-email" });
48+
49+
const response = await resendVerificationHandler(req);
50+
const json = await response.json();
51+
52+
expect(response.status).toBe(400);
53+
expect(json.message).toBe("Invalid email address.");
54+
});
55+
56+
it("returns generic success when user is not found", async () => {
57+
const req = createMockReq({ email: "unknown@example.com" });
58+
(req.payload.find as jest.Mock).mockResolvedValue({ docs: [] });
59+
60+
const response = await resendVerificationHandler(req);
61+
const json = await response.json();
62+
63+
expect(response.status).toBe(200);
64+
expect(json.message).toContain("If an account exists");
65+
expect(req.payload.sendEmail).not.toHaveBeenCalled();
66+
});
67+
68+
it("returns generic success when user is already verified", async () => {
69+
const req = createMockReq({ email: "verified@example.com" });
70+
(req.payload.find as jest.Mock).mockResolvedValue({
71+
docs: [{ id: "1", email: "verified@example.com", _verified: true }],
72+
});
73+
74+
const response = await resendVerificationHandler(req);
75+
const json = await response.json();
76+
77+
expect(response.status).toBe(200);
78+
expect(json.message).toContain("If an account exists");
79+
expect(req.payload.sendEmail).not.toHaveBeenCalled();
80+
});
81+
82+
it("generates a new token, updates user, and sends email for unverified user", async () => {
83+
const req = createMockReq({ email: "unverified@example.com" });
84+
(req.payload.find as jest.Mock).mockResolvedValue({
85+
docs: [{ id: "42", email: "unverified@example.com", _verified: false }],
86+
});
87+
88+
const response = await resendVerificationHandler(req);
89+
const json = await response.json();
90+
91+
expect(response.status).toBe(200);
92+
expect(json.message).toContain("If an account exists");
93+
94+
// Verify token was updated
95+
expect(req.payload.update).toHaveBeenCalledWith(
96+
expect.objectContaining({
97+
collection: "users",
98+
id: "42",
99+
data: expect.objectContaining({
100+
_verificationToken: expect.any(String),
101+
}),
102+
overrideAccess: true,
103+
}),
104+
);
105+
106+
// Verify the token is a 40-char hex string (20 random bytes)
107+
const updateCall = (req.payload.update as jest.Mock).mock.calls[0][0];
108+
expect(updateCall.data._verificationToken).toMatch(/^[a-f0-9]{40}$/);
109+
110+
// Verify email was built with the new token
111+
expect(buildVerifyEmailHTML).toHaveBeenCalledWith({
112+
email: "unverified@example.com",
113+
token: updateCall.data._verificationToken,
114+
});
115+
116+
// Verify email was sent
117+
expect(req.payload.sendEmail).toHaveBeenCalledWith({
118+
to: "unverified@example.com",
119+
subject: "Verify your email address",
120+
html: "<html>mock email</html>",
121+
});
122+
});
123+
124+
it("returns 400 when request body is not valid JSON", async () => {
125+
const req = {
126+
payload: {
127+
find: jest.fn(),
128+
update: jest.fn(),
129+
sendEmail: jest.fn(),
130+
},
131+
json: jest.fn().mockRejectedValue(new Error("Invalid JSON")),
132+
} as unknown as Parameters<typeof resendVerificationHandler>[0];
133+
134+
const response = await resendVerificationHandler(req);
135+
const json = await response.json();
136+
137+
expect(response.status).toBe(400);
138+
expect(json.message).toBe("Invalid request body.");
139+
});
140+
});

0 commit comments

Comments
 (0)