diff --git a/app/(app)/settings/_client.tsx b/app/(app)/settings/_client.tsx index 3e6ee2ad..bafb4b1b 100644 --- a/app/(app)/settings/_client.tsx +++ b/app/(app)/settings/_client.tsx @@ -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(" "); @@ -27,6 +29,8 @@ type User = Pick< | "emailNotifications" | "newsletter" | "image" + | "email" + | "id" >; type ProfilePhoto = { @@ -42,7 +46,10 @@ const Settings = ({ profile }: { profile: User }) => { formState: { errors }, } = useForm({ resolver: zodResolver(saveSettingsSchema), - defaultValues: { ...profile, username: profile.username || "" }, + defaultValues: { + ...profile, + username: profile.username || "", + }, }); const bio = watch("bio"); @@ -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({ status: "idle", @@ -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) { @@ -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 = (values) => { mutate({ ...values, newsletter: weeklyNewsletter, emailNotifications }); }; @@ -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 (
@@ -338,6 +369,69 @@ const Settings = ({ profile }: { profile: User }) => {
+
+

+ Update email +

+

Change your email here.

+
+
+ +
+ +
+
+
+ +
+ setNewEmail(e.target.value)} + value={newEmail} + /> +
+
+ {!sendForVerification ? ( + + ) : ( +
+

+ + Verification link sent +

+ +
+ )} +
+
diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 1bf073aa..008f5145 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -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, @@ -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), }); @@ -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, @@ -58,6 +61,7 @@ export default async function Page() { emailNotifications: user.emailNotifications, newsletter: user.newsletter, image: user.image, + email: user.email, }); return ; } diff --git a/app/api/verify-email/route.ts b/app/api/verify-email/route.ts new file mode 100644 index 00000000..b9cb26b2 --- /dev/null +++ b/app/api/verify-email/route.ts @@ -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 }, + ); + } +} diff --git a/app/verify-email/_client.tsx b/app/verify-email/_client.tsx new file mode 100644 index 00000000..bf75df10 --- /dev/null +++ b/app/verify-email/_client.tsx @@ -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(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 ( +
+
+
+
Email Verification
+
Verifying your email address
+
+
+ {status === "loading" && ( +
+ +

+ Verifying your email... +

+
+ )} + {status === "success" && ( +
+ +

{message}

+
+ )} + {status === "error" && ( +
+ +

{message}

+
+ )} +
+ {status === "success" && ( +
+ +
+ )} +
+
+ ); +} + +export default Content; diff --git a/app/verify-email/page.tsx b/app/verify-email/page.tsx new file mode 100644 index 00000000..d6a9f2cb --- /dev/null +++ b/app/verify-email/page.tsx @@ -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 ; +} diff --git a/config/constants.ts b/config/constants.ts new file mode 100644 index 00000000..23729e5d --- /dev/null +++ b/config/constants.ts @@ -0,0 +1,3 @@ +const TOKEN_EXPIRATION_TIME = 1000 * 60 * 60; // 1 hour + +export { TOKEN_EXPIRATION_TIME }; diff --git a/drizzle/0009_email-verification.sql b/drizzle/0009_email-verification.sql new file mode 100644 index 00000000..b4b2658a --- /dev/null +++ b/drizzle/0009_email-verification.sql @@ -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 $$; diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 00000000..79bd410d --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1299 @@ +{ + "id": "66e28e9b-8506-48ef-86d3-c1946263d419", + "prevId": "33fbd4d2-b8c2-4941-9294-77b560ba6ccd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": ["provider", "providerAccountId"] + } + }, + "uniqueConstraints": {} + }, + "public.BannedUsers": { + "name": "BannedUsers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bannedById": { + "name": "bannedById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "BannedUsers_userId_key": { + "name": "BannedUsers_userId_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "BannedUsers_userId_user_id_fk": { + "name": "BannedUsers_userId_user_id_fk", + "tableFrom": "BannedUsers", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "BannedUsers_bannedById_user_id_fk": { + "name": "BannedUsers_bannedById_user_id_fk", + "tableFrom": "BannedUsers", + "tableTo": "user", + "columnsFrom": ["bannedById"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "BannedUsers_id_unique": { + "name": "BannedUsers_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.Bookmark": { + "name": "Bookmark", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "Bookmark_userId_postId_key": { + "name": "Bookmark_userId_postId_key", + "columns": [ + { + "expression": "postId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Bookmark_postId_Post_id_fk": { + "name": "Bookmark_postId_Post_id_fk", + "tableFrom": "Bookmark", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Bookmark_userId_user_id_fk": { + "name": "Bookmark_userId_user_id_fk", + "tableFrom": "Bookmark", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Bookmark_id_unique": { + "name": "Bookmark_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.Comment": { + "name": "Comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parentId": { + "name": "parentId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Comment_postId_Post_id_fk": { + "name": "Comment_postId_Post_id_fk", + "tableFrom": "Comment", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Comment_userId_user_id_fk": { + "name": "Comment_userId_user_id_fk", + "tableFrom": "Comment", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Comment_parentId_fkey": { + "name": "Comment_parentId_fkey", + "tableFrom": "Comment", + "tableTo": "Comment", + "columnsFrom": ["parentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Comment_id_unique": { + "name": "Comment_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.EmailVerificationToken": { + "name": "EmailVerificationToken", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "EmailVerificationToken_userId_user_id_fk": { + "name": "EmailVerificationToken_userId_user_id_fk", + "tableFrom": "EmailVerificationToken", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "EmailVerificationToken_token_unique": { + "name": "EmailVerificationToken_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "EmailVerificationToken_email_unique": { + "name": "EmailVerificationToken_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } + }, + "public.Flagged": { + "name": "Flagged", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notifierId": { + "name": "notifierId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commentId": { + "name": "commentId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Flagged_userId_user_id_fk": { + "name": "Flagged_userId_user_id_fk", + "tableFrom": "Flagged", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Flagged_notifierId_user_id_fk": { + "name": "Flagged_notifierId_user_id_fk", + "tableFrom": "Flagged", + "tableTo": "user", + "columnsFrom": ["notifierId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Flagged_postId_Post_id_fk": { + "name": "Flagged_postId_Post_id_fk", + "tableFrom": "Flagged", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Flagged_commentId_Comment_id_fk": { + "name": "Flagged_commentId_Comment_id_fk", + "tableFrom": "Flagged", + "tableTo": "Comment", + "columnsFrom": ["commentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Flagged_id_unique": { + "name": "Flagged_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.Like": { + "name": "Like", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commentId": { + "name": "commentId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "Like_userId_commentId_key": { + "name": "Like_userId_commentId_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "commentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Like_userId_postId_key": { + "name": "Like_userId_postId_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "postId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Like_userId_user_id_fk": { + "name": "Like_userId_user_id_fk", + "tableFrom": "Like", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Like_postId_Post_id_fk": { + "name": "Like_postId_Post_id_fk", + "tableFrom": "Like", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Like_commentId_Comment_id_fk": { + "name": "Like_commentId_Comment_id_fk", + "tableFrom": "Like", + "tableTo": "Comment", + "columnsFrom": ["commentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Like_id_unique": { + "name": "Like_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.Notification": { + "name": "Notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commentId": { + "name": "commentId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notifierId": { + "name": "notifierId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Notification_userId_user_id_fk": { + "name": "Notification_userId_user_id_fk", + "tableFrom": "Notification", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notification_postId_Post_id_fk": { + "name": "Notification_postId_Post_id_fk", + "tableFrom": "Notification", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notification_commentId_Comment_id_fk": { + "name": "Notification_commentId_Comment_id_fk", + "tableFrom": "Notification", + "tableTo": "Comment", + "columnsFrom": ["commentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notification_notifierId_user_id_fk": { + "name": "Notification_notifierId_user_id_fk", + "tableFrom": "Notification", + "tableTo": "user", + "columnsFrom": ["notifierId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Notification_id_unique": { + "name": "Notification_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.Post": { + "name": "Post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonicalUrl": { + "name": "canonicalUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coverImage": { + "name": "coverImage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "varchar(156)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "readTimeMins": { + "name": "readTimeMins", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "published": { + "name": "published", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "showComments": { + "name": "showComments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "likes": { + "name": "likes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "Post_id_key": { + "name": "Post_id_key", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Post_slug_key": { + "name": "Post_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Post_userId_user_id_fk": { + "name": "Post_userId_user_id_fk", + "tableFrom": "Post", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Post_id_unique": { + "name": "Post_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.PostTag": { + "name": "PostTag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "tagId": { + "name": "tagId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "PostTag_tagId_postId_key": { + "name": "PostTag_tagId_postId_key", + "columns": [ + { + "expression": "tagId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "postId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "PostTag_tagId_Tag_id_fk": { + "name": "PostTag_tagId_Tag_id_fk", + "tableFrom": "PostTag", + "tableTo": "Tag", + "columnsFrom": ["tagId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "PostTag_postId_Post_id_fk": { + "name": "PostTag_postId_Post_id_fk", + "tableFrom": "PostTag", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Tag": { + "name": "Tag", + "schema": "", + "columns": { + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "Tag_title_key": { + "name": "Tag_title_key", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Tag_id_unique": { + "name": "Tag_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + } + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'/images/person.png'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "bio": { + "name": "bio", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "websiteUrl": { + "name": "websiteUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "emailNotifications": { + "name": "emailNotifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dateOfBirth": { + "name": "dateOfBirth", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "professionalOrStudent": { + "name": "professionalOrStudent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workplace": { + "name": "workplace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jobTitle": { + "name": "jobTitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "levelOfStudy": { + "name": "levelOfStudy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "course": { + "name": "course", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "Role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + } + }, + "indexes": { + "User_username_key": { + "name": "User_username_key", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "User_email_key": { + "name": "User_email_key", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "User_username_id_idx": { + "name": "User_username_id_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {} + } + }, + "enums": { + "public.Role": { + "name": "Role", + "schema": "public", + "values": ["MODERATOR", "ADMIN", "USER"] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b2e7a5a1..cbdf766d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1728368844216, "tag": "0008_remove_firstName_and_surname", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1728461582872, + "tag": "0009_email-verification", + "breakpoints": true } ] } diff --git a/package-lock.json b/package-lock.json index 8e5e2eff..b08b026f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19126,4 +19126,4 @@ } } } -} +} \ No newline at end of file diff --git a/schema/token.ts b/schema/token.ts new file mode 100644 index 00000000..4c603b01 --- /dev/null +++ b/schema/token.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +export const emailTokenReqSchema = z.object({ + newEmail: z.string().email(), +}); + +export const TokenSchema = z.object({ + id: z.string().uuid(), + userId: z.string(), + token: z.string(), + createdAt: z.date().default(() => new Date()), + expiresAt: z.date(), +}); + +export type TokenInput = z.TypeOf; diff --git a/server/api/router/profile.ts b/server/api/router/profile.ts index a485c2cb..4fe2c75c 100644 --- a/server/api/router/profile.ts +++ b/server/api/router/profile.ts @@ -16,6 +16,14 @@ import { import { TRPCError } from "@trpc/server"; import { nanoid } from "nanoid"; import { eq } from "drizzle-orm"; +import { emailTokenReqSchema } from "@/schema/token"; +import { + checkIfEmailExists, + generateEmailToken, + sendVerificationEmail, + storeTokenInDb, +} from "@/utils/emailToken"; +import { TOKEN_EXPIRATION_TIME } from "@/config/constants"; export const profileRouter = createTRPCRouter({ edit: protectedProcedure @@ -126,4 +134,42 @@ export const profileRouter = createTRPCRouter({ } return profile; }), + updateEmail: protectedProcedure + .input(emailTokenReqSchema) + .mutation(async ({ input, ctx }) => { + try { + const { newEmail } = input; + + if (!newEmail) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid request", + }); + } + + const ifEmailExists = await checkIfEmailExists(newEmail); + + if (ifEmailExists) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Email already exists", + }); + } + + const userId = ctx.session.user.id; + + const token = generateEmailToken(); + const expiresAt = new Date(Date.now() + TOKEN_EXPIRATION_TIME); + + await storeTokenInDb(userId, token, expiresAt, newEmail); + await sendVerificationEmail(newEmail, token); + + return { message: "Verification email sent" }; + } catch (error: any) { + throw new TRPCError({ + code: error.code || "INTERNAL_SERVER_ERROR", + message: error.message || "Internal server error", + }); + } + }), }); diff --git a/server/db/schema.ts b/server/db/schema.ts index 2070bd6a..2b276a77 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -113,6 +113,17 @@ export const verificationToken = pgTable( }), ); +export const emailVerificationToken = pgTable("EmailVerificationToken", { + id: serial("id").primaryKey(), + token: text("token").unique().notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + expiresAt: timestamp("expiresAt").notNull(), + email: text("email").notNull().unique(), + userId: text("userId") + .notNull() + .references(() => user.id), +}); + export const post = pgTable( "Post", { diff --git a/utils/emailToken.ts b/utils/emailToken.ts new file mode 100644 index 00000000..0e6681e3 --- /dev/null +++ b/utils/emailToken.ts @@ -0,0 +1,163 @@ +import { db } from "@/server/db"; +import { emailVerificationToken, user } from "@/server/db/schema"; +import crypto from "crypto"; +import sendEmail from "./sendEmail"; +import { and, eq } from "drizzle-orm"; + +export const generateEmailToken = () => { + return crypto.randomBytes(64).toString("hex"); +}; + +export const storeTokenInDb = async ( + userId: string, + token: string, + expiresAt: Date, + email: string, +) => { + try { + const newToken = await db + .insert(emailVerificationToken) + .values({ + userId, + token, + expiresAt, + email, + }) + .returning(); + + return newToken[0]; + } catch (error) { + console.error("Error storing token in database:", error); + throw new Error("Failed to store email verification token"); + } +}; + +export const sendVerificationEmail = async (email: string, token: string) => { + const verificationLink = `${process.env.NEXT_PUBLIC_BACKEND_URL}/verify-email?token=${token}`; + const subject = "Verify Your Email Address"; + const htmlMessage = ` + + + + + + Email Verification + + + +
+

Confirm Your Email Address

+

Hello,

+

Thank you for registering with us! To complete your registration, please verify your email address by clicking the button below:

+ Verify Email +

Please note that this link is valid for 1 hour only. If it expires, you will need to request a new one.

+

If you did not create an account, please ignore this email.

+

Best regards,
The CodĂș Team

+ +
+ + + `; + + try { + return sendEmail({ recipient: email, htmlMessage, subject }); + } catch (error) { + console.error("Error sending verification email:", error); + throw new Error("Failed to send verification email"); + } +}; + +export const getTokenFromDb = async (token: string, userId: string) => { + try { + const tokenFromDb = await db + .select() + .from(emailVerificationToken) + .where( + and( + eq(emailVerificationToken.token, token), + eq(emailVerificationToken.userId, userId), + ), + ); + return tokenFromDb; + } catch (error) { + console.error("Error fetching token from database:", error); + throw new Error("Failed to fetch email verification token"); + } +}; + +export const updateEmail = async (userId: string, newEmail: string) => { + try { + await db.update(user).set({ email: newEmail }).where(eq(user.id, userId)); + } catch (error) { + console.error("Error updating email in database:", error); + throw new Error("Failed to update email"); + } +}; + +export const deleteTokenFromDb = async (token: string) => { + try { + await db + .delete(emailVerificationToken) + .where(eq(emailVerificationToken.token, token)); + } catch (error) { + console.error("Error deleting token from database:", error); + throw new Error("Failed to delete email verification token"); + } +}; + +export const checkIfEmailExists = async (email: string) => { + try { + const existingUser = await db.query.user.findFirst({ + where: (users, { eq }) => eq(users.email, email), + }); + + return !!existingUser; + } catch (error) { + console.error("Error checking if email exists:", error); + throw new Error("Failed to check if email exists"); + } +};