Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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";
Comment on lines +15 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Remove unused import 'CheckCheck' from 'lucide-react'.

The CheckCheck component is imported but not used in the code. Removing unused imports helps maintain a clean and efficient codebase.

Apply this diff to fix the issue:

-import { CheckCheck, Loader2 } from "lucide-react";
+import { Loader2 } from "lucide-react";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { Button } from "@/components/ui-components/button";
import { CheckCheck, Loader2 } from "lucide-react";
import { Button } from "@/components/ui-components/button";
import { 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);
},
},
);
};
Comment on lines +140 to +159
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance error handling in handleNewEmailUpdate.

The current error handling in handleNewEmailUpdate is generic. Consider providing more specific error messages based on the error type or code returned by the API.

Here's a suggested improvement:

const handleNewEmailUpdate = async () => {
  setLoading(true);
  await updateEmail(
    { newEmail },
    {
      onError(error: any) {
        setLoading(false);
        if (error.data?.code === "INVALID_EMAIL") {
          return toast.error("The provided email is invalid. Please check and try again.");
        } else if (error.data?.code === "EMAIL_IN_USE") {
          return toast.error("This email is already in use. Please use a different email.");
        }
        return toast.error(
          "Something went wrong sending the verification link.",
        );
      },
      onSuccess() {
        setLoading(false);
        toast.success("Verification link sent to your email.");
        setSendForVerification(true);
      },
    },
  );
};

This change provides more specific error messages based on potential API error codes, improving the user experience.


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>
Comment on lines +372 to +433
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve button disabled state logic.

The current implementation of the disabled state for the "Send verification link" and "Resend verification link" buttons can be improved for better user experience and logic clarity.

Consider the following changes:

  1. The button should be disabled when there's no new email entered or when it's the same as the current email.
  2. The loading state should also be considered in the disabled logic.

Apply this diff to improve the logic:

-                        disabled={!(newEmail || loading)}
+                        disabled={!newEmail || newEmail === profile.email || loading}

This change ensures that the button is disabled when:

  • No new email is entered
  • The new email is the same as the current email
  • The form is in a loading state

This provides a more intuitive user experience and prevents unnecessary API calls.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Remove the unused res parameter from the function signature

The res parameter in the GET function is unused. In Next.js API routes, responses are typically returned using NextResponse. Removing the unused parameter cleans up the code.

-export async function GET(req: NextRequest, res: NextResponse) {
+export async function GET(req: NextRequest) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function GET(req: NextRequest, res: NextResponse) {
export async function GET(req: NextRequest) {

try {
const token = req.nextUrl.searchParams.get("token");

if (!token)
return NextResponse.json({ message: "Invalid request" }, { status: 400 });

const session = await getServerAuthSession();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ensure getServerAuthSession is called with the correct arguments

The getServerAuthSession function may require the req (and possibly res) object to retrieve the user's session correctly. Verify if these parameters are needed and pass them accordingly.

If it requires the request object:

-const session = await getServerAuthSession();
+const session = await getServerAuthSession(req);

If both req and res are required, adjust the function and reinstate the res parameter:

+export async function GET(req: NextRequest, res: NextResponse) {
 const session = await getServerAuthSession(req, res);

Ensure consistency in your function signatures.

Committable suggestion was skipped due to low confidence.


if (!session || !session.user)
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
Comment on lines +18 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Reconsider requiring user authentication for email verification

Requiring an active user session for email verification may hinder users who are not logged in when clicking the verification link. This could lead to a poor user experience.

Consider modifying the endpoint to allow email verification without needing the user to be authenticated. Rely on the token to identify and verify the user's email.


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 });
Comment on lines +26 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ensure expiresAt is properly parsed as a Date object

When comparing expiresAt with new Date(), make sure expiresAt is a Date object. If it's retrieved as a string from the database, parse it first:

 const { userId, expiresAt, email } = tokenFromDb[0];
+const tokenExpiryDate = new Date(expiresAt);
-if (expiresAt < new Date())
+if (tokenExpiryDate < new Date())

This ensures an accurate comparison.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { userId, expiresAt, email } = tokenFromDb[0];
if (expiresAt < new Date())
return NextResponse.json({ message: "Token expired" }, { status: 400 });
const { userId, expiresAt, email } = tokenFromDb[0];
const tokenExpiryDate = new Date(expiresAt);
if (tokenExpiryDate < 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]);
Comment on lines +18 to +22
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Optimize dependency array in useEffect

In the first useEffect, you have [params, token] in the dependency array. However, since you only use params.get("token"), and params from useSearchParams() is stable, you might consider removing token from the dependencies to prevent unnecessary executions.

Apply this diff to adjust the dependency array:

 }, [params, token]);
+}, [params]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const tokenParam = params.get("token");
if (tokenParam && !token) {
setToken(tokenParam);
}
}, [params, token]);
const tokenParam = params.get("token");
if (tokenParam && !token) {
setToken(tokenParam);
}
}, [params]);


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.",
);
Comment on lines +45 to +48
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider logging errors for better debugging

In the catch block of verifyEmail, the caught error is not being logged. Adding console.error(error); will help in diagnosing issues during the email verification process.

Apply this diff to include error logging:

 try {
   // existing code...
 } catch (error) {
+  console.error(error);
   setStatus("error");
   setMessage(
     "An error occurred during verification. Please try again later.",
   );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setStatus("error");
setMessage(
"An error occurred during verification. Please try again later.",
);
console.error(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