From ecb4b10887ad03698d789ead60f1cb529d115fb2 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sat, 2 Aug 2025 17:23:15 +0530 Subject: [PATCH 1/4] Verification for email changes in partner profiles Extracts email change confirmation logic into a shared utility, updates both user and partner profile flows to use it, and ensures email verification is required when changing emails. Adds support for partner profile email changes, improves error handling, and updates related UI feedback and email templates. --- .../(dashboard)/profile/page-client.tsx | 10 +- apps/web/app/api/user/route.ts | 69 +------ .../confirm-email-change/[token]/page.tsx | 58 ++++-- .../partners/update-partner-profile.ts | 177 +++++++++++------- apps/web/lib/auth/confirm-email-change.ts | 80 ++++++++ .../src/templates/confirm-email-change.tsx | 2 +- 6 files changed, 248 insertions(+), 148 deletions(-) create mode 100644 apps/web/lib/auth/confirm-email-change.ts diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx index 1e1f3095e3e..8c80a4651ff 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx @@ -205,8 +205,14 @@ function ProfileForm({ const { handleKeyDown } = useEnterSubmit(); const { executeAsync } = useAction(updatePartnerProfileAction, { - onSuccess: async () => { - toast.success("Profile updated successfully."); + onSuccess: async ({ data }) => { + if (data?.needsEmailVerification) { + toast.success( + "Please check your email to verify your new email address.", + ); + } else { + toast.success("Your profile has been updated."); + } }, onError({ error }) { setError("root.serverError", { diff --git a/apps/web/app/api/user/route.ts b/apps/web/app/api/user/route.ts index 10820532f06..5585475c73d 100644 --- a/apps/web/app/api/user/route.ts +++ b/apps/web/app/api/user/route.ts @@ -1,22 +1,12 @@ import { DubApiError } from "@/lib/api/errors"; -import { hashToken, withSession } from "@/lib/auth"; +import { withSession } from "@/lib/auth"; +import { confirmEmailChange } from "@/lib/auth/confirm-email-change"; import { storage } from "@/lib/storage"; -import { ratelimit, redis } from "@/lib/upstash"; import { uploadedImageSchema } from "@/lib/zod/schemas/misc"; -import { sendEmail } from "@dub/email"; import { unsubscribe } from "@dub/email/resend/unsubscribe"; -import ConfirmEmailChange from "@dub/email/templates/confirm-email-change"; import { prisma } from "@dub/prisma"; -import { - APP_DOMAIN, - APP_HOSTNAMES, - PARTNERS_DOMAIN, - R2_URL, - nanoid, - trim, -} from "@dub/utils"; +import { R2_URL, nanoid, trim } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; -import { randomBytes } from "crypto"; import { NextResponse } from "next/server"; import { z } from "zod"; @@ -113,56 +103,11 @@ export const PATCH = withSession(async ({ req, session }) => { }); } - const { success } = await ratelimit(10, "1 d").limit( - `email-change-request:${session.user.id}`, - ); - - if (!success) { - throw new DubApiError({ - code: "rate_limit_exceeded", - message: - "You've requested too many email change requests. Please try again later.", - }); - } - - const token = randomBytes(32).toString("hex"); - const expiresIn = 15 * 60 * 1000; - - await prisma.verificationToken.create({ - data: { - identifier: session.user.id, - token: await hashToken(token, { secret: true }), - expires: new Date(Date.now() + expiresIn), - }, + await confirmEmailChange({ + email: session.user.email, + newEmail: email, + identifier: session.user.id, }); - - await redis.set( - `email-change-request:user:${session.user.id}`, - { - email: session.user.email, - newEmail: email, - }, - { - px: expiresIn, - }, - ); - - const hostName = req.headers.get("host") || ""; - const confirmUrl = APP_HOSTNAMES.has(hostName) - ? `${APP_DOMAIN}/auth/confirm-email-change/${token}` - : `${PARTNERS_DOMAIN}/auth/confirm-email-change/${token}`; - - waitUntil( - sendEmail({ - subject: "Confirm your email address change", - email, - react: ConfirmEmailChange({ - email: session.user.email, - newEmail: email, - confirmUrl, - }), - }), - ); } const response = await prisma.user.update({ diff --git a/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx b/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx index 80665451709..bed8eb37278 100644 --- a/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx +++ b/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx @@ -6,7 +6,7 @@ import { subscribe } from "@dub/email/resend/subscribe"; import { unsubscribe } from "@dub/email/resend/unsubscribe"; import EmailUpdated from "@dub/email/templates/email-updated"; import { prisma } from "@dub/prisma"; -import { VerificationToken } from "@dub/prisma/client"; +import { User, VerificationToken } from "@dub/prisma/client"; import { InputPassword, LoadingSpinner } from "@dub/ui"; import { waitUntil } from "@vercel/functions"; import { redirect } from "next/navigation"; @@ -80,11 +80,12 @@ const VerifyEmailChange = async ({ redirect(`/login?next=/auth/confirm-email-change/${token}`); } - const currentUserId = session.user.id; + const { id: currentUserId, defaultPartnerId } = session.user; const data = await redis.get<{ email: string; newEmail: string; + isPartnerProfile?: boolean; }>(`email-change-request:user:${currentUserId}`); if (!data) { @@ -97,23 +98,50 @@ const VerifyEmailChange = async ({ ); } - const user = await prisma.user.update({ - where: { - id: currentUserId, - }, - data: { - email: data.newEmail, - }, - select: { - subscribed: true, - }, - }); + let user: Pick | null = null; + + // Update the partner profile email + if (data.isPartnerProfile) { + if (!defaultPartnerId) { + return ( + + ); + } + + await prisma.partner.update({ + where: { + id: defaultPartnerId, + }, + data: { + email: data.newEmail, + }, + }); + } + + // Update the user email + else { + user = await prisma.user.update({ + where: { + id: currentUserId, + }, + data: { + email: data.newEmail, + }, + select: { + subscribed: true, + }, + }); + } waitUntil( - Promise.all([ + Promise.allSettled([ deleteRequest(tokenFound), - ...(user.subscribed + ...(user?.subscribed ? [ unsubscribe({ email: data.email }), subscribe({ email: data.newEmail }), diff --git a/apps/web/lib/actions/partners/update-partner-profile.ts b/apps/web/lib/actions/partners/update-partner-profile.ts index 3b7ae73a1b4..3eb839034b4 100644 --- a/apps/web/lib/actions/partners/update-partner-profile.ts +++ b/apps/web/lib/actions/partners/update-partner-profile.ts @@ -1,5 +1,6 @@ "use server"; +import { confirmEmailChange } from "@/lib/auth/confirm-email-change"; import { qstash } from "@/lib/cron"; import { storage } from "@/lib/storage"; import { prisma } from "@dub/prisma"; @@ -9,7 +10,7 @@ import { deepEqual, nanoid, } from "@dub/utils"; -import { PartnerProfileType, Prisma } from "@prisma/client"; +import { Partner, PartnerProfileType } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { stripe } from "../../stripe"; import z from "../../zod"; @@ -51,7 +52,7 @@ export const updatePartnerProfileAction = authPartnerActionClient const { partner } = ctx; const { name, - email, + email: newEmail, image, description, country, @@ -59,65 +60,23 @@ export const updatePartnerProfileAction = authPartnerActionClient companyName, } = parsedInput; - const emailChanged = partner.email !== email; - - const countryChanged = - partner.country?.toLowerCase() !== country?.toLowerCase(); - - const profileTypeChanged = - partner.profileType.toLowerCase() !== profileType.toLowerCase(); - - const companyNameChanged = - partner.companyName?.toLowerCase() !== companyName?.toLowerCase(); - - if ( - (emailChanged || - countryChanged || - profileTypeChanged || - companyNameChanged) && - partner.stripeConnectId - ) { - // Partner is not able to update their country, profile type, or company name - // if they have already have a Stripe Express account + any sent / completed payouts - const completedPayoutsCount = await prisma.payout.count({ - where: { - partnerId: partner.id, - status: { - in: ["sent", "completed"], - }, - }, - }); - - if (completedPayoutsCount > 0) { - throw new Error( - "Since you've already received payouts on Dub, you cannot change your email, country or profile type. Please contact support to update those fields.", - ); - } - - const response = await stripe.accounts.del(partner.stripeConnectId); - - if (response.deleted) { - await prisma.partner.update({ - where: { - id: partner.id, - }, - data: { - stripeConnectId: null, - payoutsEnabledAt: null, - }, - }); - } + // Delete the Stripe Express account if needed + await deleteStripeAccountIfRequired({ + partner, + input: parsedInput, + }); + + let imageUrl: string | null = null; + let needsEmailVerification = false; + const emailChanged = partner.email !== newEmail; + + // Upload the new image + if (image) { + const path = `partners/${partner.id}/image_${nanoid(7)}`; + const uploaded = await storage.upload(path, image); + imageUrl = uploaded.url; } - const imageUrl = image - ? ( - await storage.upload( - `partners/${partner.id}/image_${nanoid(7)}`, - image, - ) - ).url - : null; - try { const updatedPartner = await prisma.partner.update({ where: { @@ -125,7 +84,6 @@ export const updatePartnerProfileAction = authPartnerActionClient }, data: { name, - email, description, ...(imageUrl && { image: imageUrl }), country, @@ -134,6 +92,30 @@ export const updatePartnerProfileAction = authPartnerActionClient }, }); + // If the email is being changed, we need to verify the new email address + if (emailChanged) { + const partnerWithEmail = await prisma.partner.findUnique({ + where: { + email: newEmail, + }, + }); + + if (partnerWithEmail) { + throw new Error( + `Email ${newEmail} is already in use. Do you want to merge your partner accounts instead? (https://d.to/merge-partners)`, + ); + } + + await confirmEmailChange({ + email: partner.email!, + newEmail, + identifier: partner.id, + isPartnerProfile: true, + }); + + needsEmailVerification = true; + } + waitUntil( (async () => { const shouldExpireCache = !deepEqual( @@ -159,17 +141,76 @@ export const updatePartnerProfileAction = authPartnerActionClient }); })(), ); + + return { + needsEmailVerification, + }; } catch (error) { console.error(error); - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2002" - ) { - throw new Error( - "Email already in use. Do you want to merge your partner accounts instead? (https://d.to/merge-partners)", - ); - } throw new Error(error.message); } }); + +const deleteStripeAccountIfRequired = async ({ + partner, + input, +}: { + partner: Partner; + input: z.infer; +}) => { + const emailChanged = partner.email !== input.email; + + const countryChanged = + partner.country?.toLowerCase() !== input.country?.toLowerCase(); + + const profileTypeChanged = + partner.profileType.toLowerCase() !== input.profileType.toLowerCase(); + + const companyNameChanged = + partner.companyName?.toLowerCase() !== input.companyName?.toLowerCase(); + + const deleteExpressAccount = + (emailChanged || + countryChanged || + profileTypeChanged || + companyNameChanged) && + partner.stripeConnectId; + + if (!deleteExpressAccount) { + return; + } + + // Partner is not able to update their country, profile type, or company name + // if they have already have a Stripe Express account + any sent / completed payouts + const completedPayoutsCount = await prisma.payout.count({ + where: { + partnerId: partner.id, + status: { + in: ["sent", "completed"], + }, + }, + }); + + if (completedPayoutsCount > 0) { + throw new Error( + "Since you've already received payouts on Dub, you cannot change your email, country or profile type. Please contact support to update those fields.", + ); + } + + if (partner.stripeConnectId) { + const response = await stripe.accounts.del(partner.stripeConnectId); + + if (response.deleted) { + await prisma.partner.update({ + where: { + id: partner.id, + }, + data: { + stripeConnectId: null, + payoutsEnabledAt: null, + }, + }); + } + } +}; diff --git a/apps/web/lib/auth/confirm-email-change.ts b/apps/web/lib/auth/confirm-email-change.ts new file mode 100644 index 00000000000..9fb33a626ce --- /dev/null +++ b/apps/web/lib/auth/confirm-email-change.ts @@ -0,0 +1,80 @@ +import { sendEmail } from "@dub/email"; +import ConfirmEmailChange from "@dub/email/templates/confirm-email-change"; +import { prisma } from "@dub/prisma"; +import { APP_DOMAIN, PARTNERS_DOMAIN } from "@dub/utils"; +import { waitUntil } from "@vercel/functions"; +import { randomBytes } from "crypto"; +import { hashToken } from "."; +import { DubApiError } from "../api/errors"; +import { ratelimit, redis } from "../upstash"; + +// Send the OTP to confirm the email address change for existing users/partners +export const confirmEmailChange = async ({ + email, + newEmail, + identifier, + isPartnerProfile = false, +}: { + email: string; + newEmail: string; + identifier: string; + isPartnerProfile?: boolean; // If true, the email is being changed for a partner profile +}) => { + const { success } = await ratelimit(3, "1 d").limit( + `email-change-request:${identifier}`, + ); + + if (!success) { + throw new DubApiError({ + code: "rate_limit_exceeded", + message: + "You've requested too many email change requests. Please try again later.", + }); + } + + // Remove existing verification tokens + await prisma.verificationToken.deleteMany({ + where: { + identifier, + }, + }); + + const token = randomBytes(32).toString("hex"); + const expiresIn = 15 * 60 * 1000; + + // Create a new verification token + await prisma.verificationToken.create({ + data: { + identifier, + token: await hashToken(token, { secret: true }), + expires: new Date(Date.now() + expiresIn), + }, + }); + + // Set the email change request in Redis, we'll use this to verify the email change in /auth/confirm-email-change/[token] + await redis.set( + `email-change-request:user:${identifier}`, + { + email, + newEmail, + ...(isPartnerProfile && { isPartnerProfile }), + }, + { + px: expiresIn, + }, + ); + + const confirmUrl = `${!isPartnerProfile ? APP_DOMAIN : PARTNERS_DOMAIN}/auth/confirm-email-change/${token}`; + + waitUntil( + sendEmail({ + subject: "Confirm your email address change", + email: newEmail, + react: ConfirmEmailChange({ + email, + newEmail, + confirmUrl, + }), + }), + ); +}; diff --git a/packages/email/src/templates/confirm-email-change.tsx b/packages/email/src/templates/confirm-email-change.tsx index 8fbd1b70ede..a39f998929e 100644 --- a/packages/email/src/templates/confirm-email-change.tsx +++ b/packages/email/src/templates/confirm-email-change.tsx @@ -17,7 +17,7 @@ import { Footer } from "../components/footer"; export default function ConfirmEmailChange({ email = "panic@thedis.co", newEmail = "panic+1@thedis.co", - confirmUrl = "https://dub.co/auth/confirm-email-change", + confirmUrl = "https://dub.co/auth/confirm-email-change/d03324452e1ac9352954315f3ffc", }: { email: string; newEmail: string; From e3ab06c474935322950c582039417a38537d34b0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sat, 2 Aug 2025 18:05:31 +0530 Subject: [PATCH 2/4] Refactor email change confirmation flow to handle partner profiles --- .../[token]/page-client.tsx | 8 +++++-- .../confirm-email-change/[token]/page.tsx | 22 +++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page-client.tsx b/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page-client.tsx index 00dd8e182f9..ff287026386 100644 --- a/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page-client.tsx +++ b/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page-client.tsx @@ -6,7 +6,11 @@ import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; import { toast } from "sonner"; -export default async function ConfirmEmailChangePageClient() { +export default async function ConfirmEmailChangePageClient({ + isPartnerProfile, +}: { + isPartnerProfile: boolean; +}) { const router = useRouter(); const { update, status } = useSession(); const hasUpdatedSession = useRef(false); @@ -20,7 +24,7 @@ export default async function ConfirmEmailChangePageClient() { hasUpdatedSession.current = true; await update(); toast.success("Successfully updated your email!"); - router.replace("/account/settings"); + router.replace(isPartnerProfile ? "/profile" : "/account/settings"); } updateSession(); diff --git a/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx b/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx index bed8eb37278..f03c5cefc12 100644 --- a/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx +++ b/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx @@ -48,6 +48,7 @@ const VerifyEmailChange = async ({ }, }); + if (!tokenFound || tokenFound.expires < new Date()) { return ( (`email-change-request:user:${currentUserId}`); + }>(`email-change-request:user:${identifier}`); + + console.log({identifier}) + if (!data) { return ( @@ -102,7 +108,7 @@ const VerifyEmailChange = async ({ // Update the partner profile email if (data.isPartnerProfile) { - if (!defaultPartnerId) { + if (!partnerId) { return ( ; + return ( + + ); }; const deleteRequest = async (tokenFound: VerificationToken) => { - await Promise.all([ + await Promise.allSettled([ prisma.verificationToken.delete({ where: { token: tokenFound.token, From aed057f0d3636784d02de66b21b7c632396571f2 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sat, 2 Aug 2025 18:05:40 +0530 Subject: [PATCH 3/4] Update page.tsx --- .../(auth)/auth/confirm-email-change/[token]/page.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx b/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx index f03c5cefc12..cb8ddc4cab2 100644 --- a/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx +++ b/apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx @@ -48,7 +48,6 @@ const VerifyEmailChange = async ({ }, }); - if (!tokenFound || tokenFound.expires < new Date()) { return ( (`email-change-request:user:${identifier}`); - console.log({identifier}) - - if (!data) { return ( Date: Sat, 2 Aug 2025 21:25:25 +0530 Subject: [PATCH 4/4] fix the hostname --- apps/web/app/api/user/route.ts | 12 +++++++++++- .../lib/actions/partners/update-partner-profile.ts | 2 ++ apps/web/lib/auth/confirm-email-change.ts | 7 +++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/web/app/api/user/route.ts b/apps/web/app/api/user/route.ts index 5585475c73d..6b6e5e1f2ea 100644 --- a/apps/web/app/api/user/route.ts +++ b/apps/web/app/api/user/route.ts @@ -5,7 +5,14 @@ import { storage } from "@/lib/storage"; import { uploadedImageSchema } from "@/lib/zod/schemas/misc"; import { unsubscribe } from "@dub/email/resend/unsubscribe"; import { prisma } from "@dub/prisma"; -import { R2_URL, nanoid, trim } from "@dub/utils"; +import { + APP_DOMAIN, + APP_HOSTNAMES, + PARTNERS_DOMAIN, + R2_URL, + nanoid, + trim, +} from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import { z } from "zod"; @@ -103,10 +110,13 @@ export const PATCH = withSession(async ({ req, session }) => { }); } + const hostName = req.headers.get("host") || ""; + await confirmEmailChange({ email: session.user.email, newEmail: email, identifier: session.user.id, + hostName: APP_HOSTNAMES.has(hostName) ? APP_DOMAIN : PARTNERS_DOMAIN, }); } diff --git a/apps/web/lib/actions/partners/update-partner-profile.ts b/apps/web/lib/actions/partners/update-partner-profile.ts index 3eb839034b4..0eed7c77a96 100644 --- a/apps/web/lib/actions/partners/update-partner-profile.ts +++ b/apps/web/lib/actions/partners/update-partner-profile.ts @@ -9,6 +9,7 @@ import { COUNTRIES, deepEqual, nanoid, + PARTNERS_DOMAIN, } from "@dub/utils"; import { Partner, PartnerProfileType } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; @@ -111,6 +112,7 @@ export const updatePartnerProfileAction = authPartnerActionClient newEmail, identifier: partner.id, isPartnerProfile: true, + hostName: PARTNERS_DOMAIN, }); needsEmailVerification = true; diff --git a/apps/web/lib/auth/confirm-email-change.ts b/apps/web/lib/auth/confirm-email-change.ts index 9fb33a626ce..ed2bf6a2ded 100644 --- a/apps/web/lib/auth/confirm-email-change.ts +++ b/apps/web/lib/auth/confirm-email-change.ts @@ -1,7 +1,6 @@ import { sendEmail } from "@dub/email"; import ConfirmEmailChange from "@dub/email/templates/confirm-email-change"; import { prisma } from "@dub/prisma"; -import { APP_DOMAIN, PARTNERS_DOMAIN } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { randomBytes } from "crypto"; import { hashToken } from "."; @@ -14,11 +13,13 @@ export const confirmEmailChange = async ({ newEmail, identifier, isPartnerProfile = false, + hostName, }: { email: string; newEmail: string; identifier: string; isPartnerProfile?: boolean; // If true, the email is being changed for a partner profile + hostName: string; }) => { const { success } = await ratelimit(3, "1 d").limit( `email-change-request:${identifier}`, @@ -64,8 +65,6 @@ export const confirmEmailChange = async ({ }, ); - const confirmUrl = `${!isPartnerProfile ? APP_DOMAIN : PARTNERS_DOMAIN}/auth/confirm-email-change/${token}`; - waitUntil( sendEmail({ subject: "Confirm your email address change", @@ -73,7 +72,7 @@ export const confirmEmailChange = async ({ react: ConfirmEmailChange({ email, newEmail, - confirmUrl, + confirmUrl: `${hostName}/auth/confirm-email-change/${token}`, }), }), );