-
-
Notifications
You must be signed in to change notification settings - Fork 147
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Added email verification system (#1069)
* feat: Added email verification system
- Loading branch information
Showing
14 changed files
with
1,837 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import { getServerAuthSession } from "@/server/auth"; | ||
import { | ||
deleteTokenFromDb, | ||
getTokenFromDb, | ||
updateEmail, | ||
} from "@/utils/emailToken"; | ||
import { NextRequest, NextResponse } from "next/server"; | ||
|
||
export async function GET(req: NextRequest, res: NextResponse) { | ||
try { | ||
const token = req.nextUrl.searchParams.get("token"); | ||
|
||
if (!token) | ||
return NextResponse.json({ message: "Invalid request" }, { status: 400 }); | ||
|
||
const session = await getServerAuthSession(); | ||
|
||
if (!session || !session.user) | ||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); | ||
|
||
const tokenFromDb = await getTokenFromDb(token, session.user.id); | ||
|
||
if (!tokenFromDb || !tokenFromDb.length) | ||
return NextResponse.json({ message: "Invalid token" }, { status: 400 }); | ||
|
||
const { userId, expiresAt, email } = tokenFromDb[0]; | ||
if (expiresAt < new Date()) | ||
return NextResponse.json({ message: "Token expired" }, { status: 400 }); | ||
|
||
await updateEmail(userId, email); | ||
|
||
await deleteTokenFromDb(token); | ||
|
||
return NextResponse.json( | ||
{ message: "Email successfully verified" }, | ||
{ status: 200 }, | ||
); | ||
} catch (error) { | ||
return NextResponse.json( | ||
{ error: "Internal server error" }, | ||
{ status: 500 }, | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
"use client"; | ||
|
||
import { Button } from "@headlessui/react"; | ||
import { AlertCircle, CheckCircle, Loader } from "lucide-react"; | ||
import { useRouter, useSearchParams } from "next/navigation"; | ||
import React, { useEffect, useState } from "react"; | ||
|
||
function Content() { | ||
const params = useSearchParams(); | ||
const router = useRouter(); | ||
const [status, setStatus] = useState< | ||
"idle" | "loading" | "success" | "error" | ||
>("idle"); | ||
const [message, setMessage] = useState(""); | ||
const [token, setToken] = useState<string | null>(null); | ||
|
||
useEffect(() => { | ||
const tokenParam = params.get("token"); | ||
if (tokenParam && !token) { | ||
setToken(tokenParam); | ||
} | ||
}, [params, token]); | ||
|
||
useEffect(() => { | ||
const verifyEmail = async () => { | ||
if (!token) { | ||
setStatus("error"); | ||
setMessage( | ||
"No verification token found. Please check your email for the correct link.", | ||
); | ||
return; | ||
} | ||
setStatus("loading"); | ||
|
||
try { | ||
const res = await fetch(`/api/verify-email?token=${token}`); | ||
const data = await res.json(); | ||
if (res.ok) { | ||
setStatus("success"); | ||
} else { | ||
setStatus("error"); | ||
} | ||
setMessage(data.message); | ||
} catch (error) { | ||
setStatus("error"); | ||
setMessage( | ||
"An error occurred during verification. Please try again later.", | ||
); | ||
} | ||
}; | ||
|
||
verifyEmail(); | ||
}, [token]); | ||
|
||
return ( | ||
<div className="flex min-h-screen items-center justify-center bg-gray-100"> | ||
<div className="w-[350px] rounded-lg border bg-white shadow-sm"> | ||
<div className="flex flex-col space-y-1.5 p-6 text-center"> | ||
<div className="text-2xl font-bold">Email Verification</div> | ||
<div className="text-gray-400">Verifying your email address</div> | ||
</div> | ||
<div className="min-h-12 p-6 pt-0"> | ||
{status === "loading" && ( | ||
<div className="flex flex-col items-center justify-center py-4"> | ||
<Loader className="text-primary h-4 w-4 animate-spin" /> | ||
<p className="text-muted-foreground mt-2 text-sm"> | ||
Verifying your email... | ||
</p> | ||
</div> | ||
)} | ||
{status === "success" && ( | ||
<div className="flex flex-col items-center justify-center py-4"> | ||
<CheckCircle className="h-8 w-8 text-green-500" /> | ||
<p className="mt-2 text-center text-sm">{message}</p> | ||
</div> | ||
)} | ||
{status === "error" && ( | ||
<div className="flex flex-col items-center justify-center py-4"> | ||
<AlertCircle className="h-8 w-8 text-red-500" /> | ||
<p className="mt-2 text-center text-sm">{message}</p> | ||
</div> | ||
)} | ||
</div> | ||
{status === "success" && ( | ||
<div className="flex items-center justify-center p-6 pt-0"> | ||
<Button | ||
onClick={() => router.push("/settings")} | ||
className="mt-4 h-10 rounded-md bg-gray-200 px-4 py-2 transition-colors hover:bg-gray-300" | ||
> | ||
Return to Settings | ||
</Button> | ||
</div> | ||
)} | ||
</div> | ||
</div> | ||
); | ||
} | ||
|
||
export default Content; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import { getServerAuthSession } from "@/server/auth"; | ||
import { redirect } from "next/navigation"; | ||
import React from "react"; | ||
import Content from "./_client"; | ||
import { db } from "@/server/db"; | ||
|
||
export const metadata = { | ||
title: "Verify Email", | ||
}; | ||
|
||
export default async function Page() { | ||
const session = await getServerAuthSession(); | ||
|
||
if (!session || !session.user) { | ||
redirect("/not-found"); | ||
} | ||
|
||
const existingUser = await db.query.user.findFirst({ | ||
columns: { | ||
id: true, | ||
}, | ||
where: (users, { eq }) => eq(users.id, session.user!.id), | ||
}); | ||
|
||
if (!existingUser) { | ||
redirect("/not-found"); | ||
} | ||
|
||
return <Content />; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
const TOKEN_EXPIRATION_TIME = 1000 * 60 * 60; // 1 hour | ||
|
||
export { TOKEN_EXPIRATION_TIME }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
CREATE TABLE IF NOT EXISTS "EmailVerificationToken" ( | ||
"id" serial PRIMARY KEY NOT NULL, | ||
"token" text NOT NULL, | ||
"createdAt" timestamp DEFAULT now() NOT NULL, | ||
"expiresAt" timestamp NOT NULL, | ||
"email" text NOT NULL, | ||
"userId" text NOT NULL, | ||
CONSTRAINT "EmailVerificationToken_token_unique" UNIQUE("token"), | ||
CONSTRAINT "EmailVerificationToken_email_unique" UNIQUE("email") | ||
); | ||
--> statement-breakpoint | ||
DO $$ BEGIN | ||
ALTER TABLE "EmailVerificationToken" ADD CONSTRAINT "EmailVerificationToken_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action; | ||
EXCEPTION | ||
WHEN duplicate_object THEN null; | ||
END $$; |
Oops, something went wrong.