From 12621336e4c8213fd5af390b34b0534a3a65910c Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Mon, 22 Jun 2026 20:29:51 -0300 Subject: [PATCH 1/6] sync partner profile and account --- .../profile/profile-details-form.tsx | 600 +++++++++++------- apps/web/app/api/user/route.ts | 116 +++- .../[token]/page-client.tsx | 10 +- .../confirm-email-change/[token]/page.tsx | 40 +- .../account/settings/page-client.tsx | 314 +++++++-- .../partners/update-partner-profile.ts | 67 +- apps/web/lib/auth/confirm-email-change.ts | 6 + .../web/lib/partners/sync-partner-identity.ts | 169 +++++ apps/web/ui/account/upload-avatar.tsx | 46 +- apps/web/ui/modals/confirm-modal.tsx | 11 +- 10 files changed, 1023 insertions(+), 356 deletions(-) create mode 100644 apps/web/lib/partners/sync-partner-identity.ts diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx index 3505369d395..87d12e6d963 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx @@ -4,6 +4,7 @@ import { hasPermission } from "@/lib/auth/partner-users/partner-user-permissions import { mutatePrefix } from "@/lib/swr/mutate"; import usePartnerPayoutsCount from "@/lib/swr/use-partner-payouts-count"; import { PartnerProps } from "@/lib/types"; +import { useConfirmModal } from "@/ui/modals/confirm-modal"; import { CountryCombobox } from "@/ui/partners/country-combobox"; import { PartnerPlatformsForm, @@ -22,8 +23,17 @@ import { import { OG_AVATAR_URL, cn } from "@dub/utils"; import { PartnerProfileType } from "@prisma/client"; import { AnimatePresence, LayoutGroup, motion } from "motion/react"; +import { useSession } from "next-auth/react"; import { useAction } from "next-safe-action/hooks"; -import { Dispatch, RefObject, SetStateAction, useEffect, useRef } from "react"; +import { + Dispatch, + RefObject, + SetStateAction, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { Controller, FormProvider, @@ -44,6 +54,63 @@ type BasicInfoFormData = { companyName: string | null; }; +type PendingProfileSubmit = { + data: BasicInfoFormData; + imageChanged: boolean; +}; + +function getProfileSyncCandidates({ + data, + partner, + user, + imageChanged, +}: { + data: BasicInfoFormData; + partner?: PartnerProps; + user?: { name?: string | null; email?: string | null; image?: string | null }; + imageChanged: boolean; +}) { + const candidates: string[] = []; + + if (data.name !== partner?.name && data.name !== user?.name) { + candidates.push("name"); + } + + if (data.email !== partner?.email && data.email !== user?.email) { + candidates.push("email"); + } + + if (imageChanged && data.image !== user?.image) { + candidates.push("image"); + } + + return candidates; +} + +function buildProfileSyncDescription(candidates: string[]) { + const lines = candidates.map((field) => { + if (field === "name") return "Display name"; + if (field === "email") { + return "Email (requires a confirmation email)"; + } + return "Profile picture"; + }); + + return ( +
+

+ You're updating your partner profile. These fields differ from your + login account: +

+ +
+ ); +} + export function ProfileDetailsForm({ partner, setShowMergePartnerAccountsModal, @@ -182,18 +249,11 @@ function BasicInfoForm({ }, [isSubmitSuccessful, reset, getValues]); const { profileType } = watch(); + const { data: session, update: updateSession } = useSession(); + const pendingSubmitRef = useRef(null); + const [syncDescription, setSyncDescription] = useState(""); const { executeAsync } = useAction(updatePartnerProfileAction, { - 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."); - } - mutatePrefix("/api/partner-profile"); - }, onError({ error }) { if (error.validationErrors) { toast.error(parseActionError(error, "Could not update your profile.")); @@ -217,249 +277,331 @@ function BasicInfoForm({ }, }); + const saveProfile = async ({ + data, + imageChanged, + syncIdentity, + }: PendingProfileSubmit & { syncIdentity: boolean }) => { + const result = await executeAsync({ + ...data, + username: data.username || undefined, + image: imageChanged ? data.image : null, + syncIdentity, + }); + + if (!result?.data) { + return; + } + + if (result.data.needsEmailVerification) { + toast.success( + "Please check your email to verify your new email address.", + ); + } else { + toast.success("Your profile has been updated."); + } + + setTimeout(() => { + mutatePrefix("/api/partner-profile"); + }, 0); + + if (syncIdentity && !result.data.needsEmailVerification) { + await updateSession(); + } + }; + + const submitProfile = async (syncIdentity: boolean) => { + const pending = pendingSubmitRef.current; + + if (!pending) { + return; + } + + try { + await saveProfile({ ...pending, syncIdentity }); + } finally { + pendingSubmitRef.current = null; + } + }; + + const { setShowConfirmModal, confirmModal } = useConfirmModal({ + title: "Also update your login account?", + description: syncDescription, + confirmText: "Update both", + cancelText: "Only update profile", + onConfirm: async () => { + await submitProfile(true); + }, + onCancel: async () => { + await submitProfile(false); + }, + }); + return ( -
{ - if (e.key !== "Enter") return; - - const target = e.target as HTMLElement; - if (target.tagName !== "INPUT") return; - - e.preventDefault(); - onSubmitAction(); - }} - onSubmit={handleSubmit(async (data) => { - const imageChanged = data.image !== partner?.image; - - await executeAsync({ - ...data, - username: data.username || undefined, - image: imageChanged ? data.image : null, - }); - })} - > -
-

- 3–30 characters. Lowercase letters, numbers, hyphens, and - underscores only. + 3–30 characters. Lowercase letters, numbers, and hyphens only.

diff --git a/apps/web/app/api/user/route.ts b/apps/web/app/api/user/route.ts index c0403789927..5247f63898f 100644 --- a/apps/web/app/api/user/route.ts +++ b/apps/web/app/api/user/route.ts @@ -145,6 +145,7 @@ export const PATCH = withSession(async ({ req, session }) => { currentEmail: session.user.email!, newEmail: email, userId: session.user.id, + partnerId, hostName: PARTNERS_DOMAIN, redirectTo: "/account/settings", }); 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 abe39114652..71064e4ce9a 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 @@ -86,6 +86,7 @@ const VerifyEmailChange = async ({ params, searchParams }: PageProps) => { newEmail: string; isPartnerProfile?: boolean; syncIdentity?: boolean; + partnerId?: string; redirectTo?: "/profile" | "/account/settings"; }>(`email-change-request:user:${identifier}`); @@ -100,12 +101,34 @@ const VerifyEmailChange = async ({ params, searchParams }: PageProps) => { } if (data.syncIdentity) { - if (!partnerId) { + const syncedPartnerId = data.partnerId; + + if (!syncedPartnerId) { return ( + ); + } + + const partnerUser = await prisma.partnerUser.findUnique({ + where: { + userId_partnerId: { + userId, + partnerId: syncedPartnerId, + }, + }, + select: { userId: true }, + }); + + if (!partnerUser) { + return ( + ); } @@ -121,7 +144,7 @@ const VerifyEmailChange = async ({ params, searchParams }: PageProps) => { }), prisma.partner.update({ where: { - id: partnerId, + id: syncedPartnerId, }, data: { email: data.newEmail, diff --git a/apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx index cec7518e3f6..641f26c0e1c 100644 --- a/apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx @@ -84,7 +84,7 @@ function buildAccountSyncDescription(candidates: string[]) { export function SettingsPageClient() { const { data: session, update, status } = useSession(); const { subdomain } = useCurrentSubdomain(); - const isPartnerDomain = subdomain !== "app"; + const isPartnerDomain = subdomain === "partners"; const { partner } = usePartnerProfile(); const pendingPatchRef = useRef(null); const [syncDescription, setSyncDescription] = useState(""); diff --git a/apps/web/lib/actions/partners/update-partner-profile.ts b/apps/web/lib/actions/partners/update-partner-profile.ts index c6ef34dfcaa..9b7e9299eb8 100644 --- a/apps/web/lib/actions/partners/update-partner-profile.ts +++ b/apps/web/lib/actions/partners/update-partner-profile.ts @@ -196,6 +196,7 @@ export const updatePartnerProfileAction = authPartnerActionClient currentEmail: user.email!, newEmail, userId: user.id, + partnerId: partner.id, hostName: PARTNERS_DOMAIN, redirectTo: "/profile", }); diff --git a/apps/web/lib/auth/confirm-email-change.ts b/apps/web/lib/auth/confirm-email-change.ts index fb6e1fe9dc2..d496c194b32 100644 --- a/apps/web/lib/auth/confirm-email-change.ts +++ b/apps/web/lib/auth/confirm-email-change.ts @@ -14,6 +14,7 @@ export const confirmEmailChange = async ({ identifier, isPartnerProfile = false, syncIdentity = false, + partnerId, redirectTo, hostName, }: { @@ -22,9 +23,17 @@ export const confirmEmailChange = async ({ identifier: string; isPartnerProfile?: boolean; // If true, the email is being changed for a partner profile syncIdentity?: boolean; // If true, update both user and partner email on confirm + partnerId?: string; redirectTo?: "/profile" | "/account/settings"; hostName: string; }) => { + if (syncIdentity && !partnerId) { + throw new DubApiError({ + code: "bad_request", + message: "Partner ID is required when syncing identity.", + }); + } + const { success } = await ratelimit(3, "1 d").limit( `email-change-request:${identifier}`, ); @@ -63,7 +72,7 @@ export const confirmEmailChange = async ({ email, newEmail, ...(isPartnerProfile && { isPartnerProfile }), - ...(syncIdentity && { syncIdentity }), + ...(syncIdentity && { syncIdentity, partnerId }), ...(redirectTo && { redirectTo }), }, { diff --git a/apps/web/lib/partners/sync-partner-identity.ts b/apps/web/lib/partners/sync-partner-identity.ts index d771a7c329a..2d80ca5c20e 100644 --- a/apps/web/lib/partners/sync-partner-identity.ts +++ b/apps/web/lib/partners/sync-partner-identity.ts @@ -32,9 +32,10 @@ export async function assertEmailAvailableForIdentitySync({ } if (partnerWithEmail && partnerWithEmail.id !== partnerId) { - throw new Error( - `Email ${newEmail} is already in use. Do you want to merge your partner accounts instead? (https://d.to/merge-partners)`, - ); + throw new DubApiError({ + code: "conflict", + message: `Email ${newEmail} is already in use. Do you want to merge your partner accounts instead? (https://d.to/merge-partners)`, + }); } } @@ -149,12 +150,14 @@ export async function requestSyncedEmailChange({ currentEmail, newEmail, userId, + partnerId, hostName, redirectTo, }: { currentEmail: string; newEmail: string; userId: string; + partnerId: string; hostName: string; redirectTo: "/profile" | "/account/settings"; }) { @@ -164,6 +167,7 @@ export async function requestSyncedEmailChange({ identifier: userId, hostName, syncIdentity: true, + partnerId, redirectTo, }); } diff --git a/apps/web/ui/modals/confirm-modal.tsx b/apps/web/ui/modals/confirm-modal.tsx index fc0791cefcb..cb42500ef62 100644 --- a/apps/web/ui/modals/confirm-modal.tsx +++ b/apps/web/ui/modals/confirm-modal.tsx @@ -41,6 +41,8 @@ function ConfirmModal({ const [isLoading, setIsLoading] = useState(false); const handleConfirm = async () => { + if (isLoading) return; + setIsLoading(true); try { await onConfirm(); @@ -50,6 +52,18 @@ function ConfirmModal({ } }; + const handleCancel = async () => { + if (isLoading) return; + + setIsLoading(true); + try { + await onCancel?.(); + setShowConfirmModal(false); + } finally { + setIsLoading(false); + } + }; + return ( { - setIsLoading(true); - try { - await onCancel?.(); - setShowConfirmModal(false); - } finally { - setIsLoading(false); - } - }} + disabled={isLoading} + onClick={handleCancel} />