Skip to content

Commit 3bb91a7

Browse files
feat: Improve the email verification flow
1 parent 46d80bc commit 3bb91a7

File tree

11 files changed

+235
-67
lines changed

11 files changed

+235
-67
lines changed
Lines changed: 10 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import { Metadata } from "next";
2-
import { notFound, redirect } from "next/navigation";
3-
import { getCloudflareContext } from "@opennextjs/cloudflare";
4-
import { getVerificationTokenKey } from "@/utils/auth-utils";
5-
import { getDB } from "@/db";
6-
import { userTable } from "@/db/schema";
7-
import { eq } from "drizzle-orm";
8-
import { updateAllSessionsOfUser } from "@/utils/kv-session";
9-
import { withRateLimit, RATE_LIMITS } from "@/utils/with-rate-limit";
2+
import { getSessionFromCookie } from "@/utils/auth";
3+
import { redirect } from "next/navigation";
4+
import VerifyEmailClientComponent from "./verify-email.client";
105

116
export const metadata: Metadata = {
127
title: "Verify Email",
@@ -16,64 +11,18 @@ export const metadata: Metadata = {
1611
export default async function VerifyEmailPage({
1712
searchParams,
1813
}: {
19-
searchParams: Promise<{ token?: string }>
14+
searchParams: Promise<{ token?: string }>;
2015
}) {
16+
const session = await getSessionFromCookie();
2117
const token = (await searchParams).token;
2218

23-
if (!token) {
24-
return notFound();
19+
if (session?.user.emailVerified) {
20+
return redirect('/dashboard');
2521
}
2622

27-
const { env } = getCloudflareContext();
28-
29-
const success = await withRateLimit(async () => {
30-
const verificationTokenStr = await env.NEXT_CACHE_WORKERS_KV.get(getVerificationTokenKey(token));
31-
32-
if (!verificationTokenStr) {
33-
return false;
34-
}
35-
36-
const verificationToken = JSON.parse(verificationTokenStr) as {
37-
userId: string;
38-
expiresAt: string;
39-
};
40-
41-
// Check if token is expired (although KV should have auto-deleted it)
42-
if (new Date() > new Date(verificationToken.expiresAt)) {
43-
return false;
44-
}
45-
46-
const db = getDB();
47-
48-
// Find user
49-
const user = await db.query.userTable.findFirst({
50-
where: eq(userTable.id, verificationToken.userId),
51-
});
52-
53-
if (!user) {
54-
return false;
55-
}
56-
57-
// Update user's email verification status
58-
await db.update(userTable)
59-
.set({ emailVerified: new Date() })
60-
.where(eq(userTable.id, verificationToken.userId));
61-
62-
// Update all sessions of the user to reflect the new email verification status
63-
await updateAllSessionsOfUser(verificationToken.userId);
64-
65-
// Delete the used token
66-
await env.NEXT_CACHE_WORKERS_KV.delete(getVerificationTokenKey(token));
67-
68-
// Add a small delay to ensure all updates are processed
69-
await new Promise((resolve) => setTimeout(resolve, 500));
70-
71-
return true;
72-
}, RATE_LIMITS.EMAIL);
73-
74-
if (success) {
75-
redirect("/dashboard");
23+
if (!token) {
24+
return redirect('/sign-in');
7625
}
7726

78-
return notFound();
27+
return <VerifyEmailClientComponent />;
7928
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"use server";
2+
3+
import "server-only";
4+
import { getCloudflareContext } from "@opennextjs/cloudflare";
5+
import { getVerificationTokenKey } from "@/utils/auth-utils";
6+
import { getDB } from "@/db";
7+
import { userTable } from "@/db/schema";
8+
import { eq } from "drizzle-orm";
9+
import { updateAllSessionsOfUser } from "@/utils/kv-session";
10+
import { withRateLimit, RATE_LIMITS } from "@/utils/with-rate-limit";
11+
import { verifyEmailSchema } from "@/schemas/verify-email.schema";
12+
import { createServerAction, ZSAError } from "zsa";
13+
14+
export const verifyEmailAction = createServerAction()
15+
.input(verifyEmailSchema)
16+
.handler(async ({ input }) => {
17+
return withRateLimit(
18+
async () => {
19+
const { env } = getCloudflareContext();
20+
const verificationTokenStr = await env.NEXT_CACHE_WORKERS_KV.get(getVerificationTokenKey(input.token));
21+
22+
if (!verificationTokenStr) {
23+
throw new ZSAError(
24+
"NOT_FOUND",
25+
"Verification token not found or expired"
26+
);
27+
}
28+
29+
const verificationToken = JSON.parse(verificationTokenStr) as {
30+
userId: string;
31+
expiresAt: string;
32+
};
33+
34+
// Check if token is expired (although KV should have auto-deleted it)
35+
if (new Date() > new Date(verificationToken.expiresAt)) {
36+
throw new ZSAError(
37+
"NOT_FOUND",
38+
"Verification token not found or expired"
39+
);
40+
}
41+
42+
const db = getDB();
43+
44+
// Find user
45+
const user = await db.query.userTable.findFirst({
46+
where: eq(userTable.id, verificationToken.userId),
47+
});
48+
49+
if (!user) {
50+
throw new ZSAError(
51+
"NOT_FOUND",
52+
"User not found"
53+
);
54+
}
55+
56+
try {
57+
// Update user's email verification status
58+
await db.update(userTable)
59+
.set({ emailVerified: new Date() })
60+
.where(eq(userTable.id, verificationToken.userId));
61+
62+
// Update all sessions of the user to reflect the new email verification status
63+
await updateAllSessionsOfUser(verificationToken.userId);
64+
65+
// Delete the used token
66+
await env.NEXT_CACHE_WORKERS_KV.delete(getVerificationTokenKey(input.token));
67+
68+
// Add a small delay to ensure all updates are processed
69+
await new Promise((resolve) => setTimeout(resolve, 500));
70+
71+
return { success: true };
72+
} catch (error) {
73+
console.error(error);
74+
75+
throw new ZSAError(
76+
"INTERNAL_SERVER_ERROR",
77+
"An unexpected error occurred"
78+
);
79+
}
80+
},
81+
RATE_LIMITS.EMAIL
82+
);
83+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
import { useRouter, useSearchParams } from "next/navigation";
5+
import { toast } from "sonner";
6+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7+
import { Button } from "@/components/ui/button";
8+
import { useServerAction } from "zsa-react";
9+
import { verifyEmailAction } from "./verify-email.action";
10+
import { verifyEmailSchema } from "@/schemas/verify-email.schema";
11+
import { Spinner } from "@/components/ui/spinner";
12+
13+
export default function VerifyEmailClientComponent() {
14+
const router = useRouter();
15+
const searchParams = useSearchParams();
16+
const token = searchParams.get("token");
17+
const hasCalledVerification = useRef(false);
18+
19+
const { execute: handleVerification, isPending, error } = useServerAction(verifyEmailAction, {
20+
onError: ({ err }) => {
21+
toast.dismiss();
22+
toast.error(err.message || "Failed to verify email");
23+
},
24+
onStart: () => {
25+
toast.loading("Verifying your email...");
26+
},
27+
onSuccess: () => {
28+
toast.dismiss();
29+
toast.success("Email verified successfully");
30+
31+
router.refresh();
32+
33+
setTimeout(() => {
34+
router.push("/dashboard");
35+
}, 500);
36+
},
37+
});
38+
39+
useEffect(() => {
40+
if (token && !hasCalledVerification.current) {
41+
const result = verifyEmailSchema.safeParse({ token });
42+
if (result.success) {
43+
hasCalledVerification.current = true;
44+
handleVerification(result.data);
45+
} else {
46+
toast.error("Invalid verification token");
47+
router.push("/sign-in");
48+
}
49+
}
50+
// eslint-disable-next-line react-hooks/exhaustive-deps
51+
}, [token]);
52+
53+
if (isPending) {
54+
return (
55+
<div className="container mx-auto px-4 flex items-center justify-center min-h-screen">
56+
<Card className="w-full max-w-md">
57+
<CardHeader className="text-center">
58+
<div className="flex flex-col items-center space-y-4">
59+
<Spinner size="large" />
60+
<CardTitle>Verifying Email</CardTitle>
61+
<CardDescription>
62+
Please wait while we verify your email address...
63+
</CardDescription>
64+
</div>
65+
</CardHeader>
66+
</Card>
67+
</div>
68+
);
69+
}
70+
71+
if (error) {
72+
return (
73+
<div className="container mx-auto px-4 flex items-center justify-center min-h-screen">
74+
<Card className="w-full max-w-md">
75+
<CardHeader>
76+
<CardTitle>Verification failed</CardTitle>
77+
<CardDescription>
78+
{error?.message || "Failed to verify email"}
79+
</CardDescription>
80+
</CardHeader>
81+
<CardContent>
82+
<Button
83+
variant="outline"
84+
className="w-full"
85+
onClick={() => router.push("/sign-in")}
86+
>
87+
Back to sign in
88+
</Button>
89+
</CardContent>
90+
</Card>
91+
</div>
92+
);
93+
}
94+
95+
if (!token) {
96+
return (
97+
<div className="container mx-auto px-4 flex items-center justify-center min-h-screen">
98+
<Card className="w-full max-w-md">
99+
<CardHeader>
100+
<CardTitle>Invalid verification link</CardTitle>
101+
<CardDescription>
102+
The verification link is invalid. Please request a new verification email.
103+
</CardDescription>
104+
</CardHeader>
105+
<CardContent>
106+
<Button
107+
variant="outline"
108+
className="w-full"
109+
onClick={() => router.push("/sign-in")}
110+
>
111+
Back to sign in
112+
</Button>
113+
</CardContent>
114+
</Card>
115+
</div>
116+
);
117+
}
118+
119+
return null;
120+
}

src/app/(dashboard)/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
1010
const session = await getSessionFromCookie()
1111

1212
if (!session) {
13-
redirect('/')
13+
return redirect('/')
1414
}
1515

1616
return (

src/app/(settings)/settings/[...segment]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export default async function SettingsPage() {
4646
const session = await getSessionFromCookie();
4747

4848
if (!session) {
49-
redirect("/sign-in");
49+
return redirect("/sign-in");
5050
}
5151

5252
return (

src/app/(settings)/settings/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export default async function SettingsLayout({
1818
const session = await getSessionFromCookie();
1919

2020
if (!session) {
21-
redirect("/sign-in");
21+
return redirect("/sign-in");
2222
}
2323

2424
return (

src/app/(settings)/settings/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export default async function SettingsPage() {
4646
const session = await getSessionFromCookie();
4747

4848
if (!session) {
49-
redirect("/sign-in");
49+
return redirect("/sign-in");
5050
}
5151

5252
return (

src/app/(settings)/settings/security/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export default async function SecurityPage() {
1919
const session = await getSessionFromCookie();
2020

2121
if (!session) {
22-
redirect("/sign-in");
22+
return redirect("/sign-in");
2323
}
2424

2525
const db = getDB();

src/app/(settings)/settings/sessions/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default async function SessionsPage() {
1313
const [sessions, error] = await getSessionsAction()
1414

1515
if (error) {
16-
redirect('/')
16+
return redirect('/')
1717
}
1818

1919
return (

src/components/email-verification-dialog.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { resendVerificationAction } from "@/app/(auth)/resend-verification.actio
1414
import { toast } from "sonner";
1515
import { useState } from "react";
1616
import { EMAIL_VERIFICATION_TOKEN_EXPIRATION_SECONDS } from "@/constants";
17+
import { Alert } from "@heroui/react"
18+
import isProd from "@/utils/is-prod";
1719

1820
export function EmailVerificationDialog() {
1921
const { session } = useSessionStore();
@@ -54,6 +56,15 @@ export function EmailVerificationDialog() {
5456
<DialogDescription>
5557
Please verify your email address to access all features. We sent a verification link to {session.user.email}.
5658
The verification link will expire in {Math.floor(EMAIL_VERIFICATION_TOKEN_EXPIRATION_SECONDS / 3600)} hours.
59+
60+
{!isProd && (
61+
<Alert
62+
color="warning"
63+
title="Development mode"
64+
description="You can find the verification link in the console."
65+
className="mt-4 mb-2"
66+
/>
67+
)}
5768
</DialogDescription>
5869
</DialogHeader>
5970
<div className="flex flex-col gap-4">

0 commit comments

Comments
 (0)