Skip to content

Commit

Permalink
feat: Added email verification system (#1069)
Browse files Browse the repository at this point in the history
* feat: Added email verification system
  • Loading branch information
Nil2000 authored Oct 10, 2024
1 parent 751a74a commit 2f416a8
Show file tree
Hide file tree
Showing 14 changed files with 1,837 additions and 6 deletions.
104 changes: 99 additions & 5 deletions app/(app)/settings/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { saveSettingsSchema } from "@/schema/profile";

import { uploadFile } from "@/utils/s3helpers";
import type { user } from "@/server/db/schema";
import { Button } from "@/components/ui-components/button";
import { CheckCheck, Loader2 } from "lucide-react";

function classNames(...classes: string[]) {
return classes.filter(Boolean).join(" ");
Expand All @@ -27,6 +29,8 @@ type User = Pick<
| "emailNotifications"
| "newsletter"
| "image"
| "email"
| "id"
>;

type ProfilePhoto = {
Expand All @@ -42,7 +46,10 @@ const Settings = ({ profile }: { profile: User }) => {
formState: { errors },
} = useForm<saveSettingsInput>({
resolver: zodResolver(saveSettingsSchema),
defaultValues: { ...profile, username: profile.username || "" },
defaultValues: {
...profile,
username: profile.username || "",
},
});

const bio = watch("bio");
Expand All @@ -52,6 +59,9 @@ const Settings = ({ profile }: { profile: User }) => {

const [emailNotifications, setEmailNotifications] = useState(eNotifications);
const [weeklyNewsletter, setWeeklyNewsletter] = useState(newsletter);
const [newEmail, setNewEmail] = useState("");
const [sendForVerification, setSendForVerification] = useState(false);
const [loading, setLoading] = useState(false);

const [profilePhoto, setProfilePhoto] = useState<ProfilePhoto>({
status: "idle",
Expand All @@ -60,6 +70,10 @@ const Settings = ({ profile }: { profile: User }) => {

const { mutate, isError, isSuccess, isLoading } =
api.profile.edit.useMutation();
const { mutate: getUploadUrl } = api.profile.getUploadUrl.useMutation();
const { mutate: updateUserPhotoUrl } =
api.profile.updateProfilePhotoUrl.useMutation();
const { mutate: updateEmail } = api.profile.updateEmail.useMutation();

useEffect(() => {
if (isSuccess) {
Expand All @@ -70,10 +84,6 @@ const Settings = ({ profile }: { profile: User }) => {
}
}, [isError, isSuccess]);

const { mutate: getUploadUrl } = api.profile.getUploadUrl.useMutation();
const { mutate: updateUserPhotoUrl } =
api.profile.updateProfilePhotoUrl.useMutation();

const onSubmit: SubmitHandler<saveSettingsInput> = (values) => {
mutate({ ...values, newsletter: weeklyNewsletter, emailNotifications });
};
Expand Down Expand Up @@ -127,6 +137,27 @@ const Settings = ({ profile }: { profile: User }) => {
}
};

const handleNewEmailUpdate = async () => {
setLoading(true);
await updateEmail(
{ newEmail },
{
onError(error) {
setLoading(false);
if (error) return toast.error(error.message);
return toast.error(
"Something went wrong sending the verification link.",
);
},
onSuccess() {
setLoading(false);
toast.success("Verification link sent to your email.");
setSendForVerification(true);
},
},
);
};

return (
<div className="old-input py-8">
<div className="mx-auto flex w-full max-w-2xl flex-grow flex-col justify-center px-4 sm:px-6 lg:col-span-9">
Expand Down Expand Up @@ -338,6 +369,69 @@ const Settings = ({ profile }: { profile: User }) => {
</div>
</div>
</div>
<div className="mt-6 text-neutral-600 dark:text-neutral-400">
<h2 className="text-xl font-bold tracking-tight text-neutral-800 dark:text-white">
Update email
</h2>
<p className="mt-1 text-sm">Change your email here.</p>
<div className="mt-2 flex flex-col gap-2">
<div className="flex flex-col">
<label htmlFor="currEmail">Current email</label>
<div>
<input
type="email"
id="currEmail"
value={profile.email!}
disabled
/>
</div>
</div>
<div className="flex flex-col">
<label htmlFor="newEmail">Update email</label>
<div>
<input
type="email"
id="newEmail"
onChange={(e) => setNewEmail(e.target.value)}
value={newEmail}
/>
</div>
</div>
{!sendForVerification ? (
<Button
className="w-[200px]"
disabled={
!newEmail || newEmail === profile.email || loading
}
onClick={handleNewEmailUpdate}
>
{loading && (
<Loader2 className="text-primary h-6 w-6 animate-spin" />
)}
Send verification link
</Button>
) : (
<div className="mt-2 flex flex-row gap-2">
<h2 className="flex items-center gap-2 text-sm italic text-green-400">
<CheckCheck />
Verification link sent
</h2>
<Button
className="w-[250px]"
disabled={
!newEmail || newEmail === profile.email || loading
}
onClick={handleNewEmailUpdate}
>
{loading && (
<Loader2 className="text-primary h-6 w-6 animate-spin" />
)}
Resend verification link
</Button>
</div>
)}
</div>
</div>
<div className="divide-y divide-neutral-200 pt-6">
<div>
<div className="text-neutral-600 dark:text-neutral-400">
Expand Down
4 changes: 4 additions & 0 deletions app/(app)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default async function Page() {

const existingUser = await db.query.user.findFirst({
columns: {
id: true,
name: true,
username: true,
bio: true,
Expand All @@ -31,6 +32,7 @@ export default async function Page() {
emailNotifications: true,
newsletter: true,
image: true,
email: true,
},
where: (users, { eq }) => eq(users.id, session.user!.id),
});
Expand All @@ -50,6 +52,7 @@ export default async function Page() {
.set({ username: initialUsername })
.where(eq(user.id, session.user.id))
.returning({
id: user.id,
name: user.name,
username: user.username,
bio: user.bio,
Expand All @@ -58,6 +61,7 @@ export default async function Page() {
emailNotifications: user.emailNotifications,
newsletter: user.newsletter,
image: user.image,
email: user.email,
});
return <Content profile={newUser} />;
}
Expand Down
44 changes: 44 additions & 0 deletions app/api/verify-email/route.ts
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 },
);
}
}
99 changes: 99 additions & 0 deletions app/verify-email/_client.tsx
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;
30 changes: 30 additions & 0 deletions app/verify-email/page.tsx
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 />;
}
3 changes: 3 additions & 0 deletions config/constants.ts
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 };
16 changes: 16 additions & 0 deletions drizzle/0009_email-verification.sql
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 $$;
Loading

0 comments on commit 2f416a8

Please sign in to comment.