Skip to content
Open
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

Large diffs are not rendered by default.

122 changes: 98 additions & 24 deletions apps/web/app/api/user/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { DubApiError } from "@/lib/api/errors";
import { withSession } from "@/lib/auth";
import { confirmEmailChange } from "@/lib/auth/confirm-email-change";
import { hasPermission } from "@/lib/auth/partner-users/partner-user-permissions";
import {
assertEmailAvailableForIdentitySync,
isImageReferencedByPartner,
requestSyncedEmailChange,
syncNameAndImageToPartner,
} from "@/lib/partners/sync-partner-identity";
import { prisma } from "@/lib/prisma";
import { storage } from "@/lib/storage";
import { uploadedImageSchema } from "@/lib/zod/schemas/images";
import {
APP_DOMAIN,
APP_HOSTNAMES,
PARTNERS_DOMAIN,
PARTNERS_HOSTNAMES,
R2_URL,
nanoid,
trim,
Expand All @@ -22,6 +29,7 @@ const updateUserSchema = z.object({
image: uploadedImageSchema.nullish(),
source: z.preprocess(trim, z.string().min(1).max(32)).optional(),
defaultWorkspace: z.preprocess(trim, z.string().min(1)).optional(),
syncIdentity: z.boolean().optional(),
});

// GET /api/user – get a specific user
Expand Down Expand Up @@ -64,9 +72,41 @@ export const GET = withSession(async ({ session }) => {

// PATCH /api/user – edit a specific user
export const PATCH = withSession(async ({ req, session }) => {
let { name, email, image, source, defaultWorkspace } =
let { name, email, image, source, defaultWorkspace, syncIdentity } =
await updateUserSchema.parseAsync(await req.json());

const hostName = req.headers.get("host") || "";
const isPartnersDomain = PARTNERS_HOSTNAMES.has(hostName);
const emailChangeHost = isPartnersDomain ? PARTNERS_DOMAIN : APP_DOMAIN;
const partnerId = session.user.defaultPartnerId;
const shouldSyncIdentity =
syncIdentity === true && isPartnersDomain && !!partnerId;

if (shouldSyncIdentity && partnerId) {
const partnerUser = await prisma.partnerUser.findUnique({
where: {
userId_partnerId: {
userId: session.user.id,
partnerId,
},
},
select: {
role: true,
},
});

if (
!partnerUser ||
!hasPermission(partnerUser.role, "partner_profile.update")
) {
throw new DubApiError({
code: "forbidden",
message:
"You don't have permission to update the partner profile linked to this account.",
});
}
}

if (image) {
const { url } = await storage.upload({
key: `avatars/${session.user.id}_${nanoid(7)}`,
Expand Down Expand Up @@ -95,27 +135,42 @@ export const PATCH = withSession(async ({ req, session }) => {

// Verify email ownership if the email is being changed
if (email && email !== session.user.email) {
const userWithEmail = await prisma.user.findUnique({
where: {
email,
},
});
if (shouldSyncIdentity && partnerId) {
await assertEmailAvailableForIdentitySync({
newEmail: email,
userId: session.user.id,
partnerId,
});

if (userWithEmail) {
throw new DubApiError({
code: "conflict",
message: "Email is already in use.",
await requestSyncedEmailChange({
currentEmail: session.user.email!,
newEmail: email,
userId: session.user.id,
partnerId,
hostName: PARTNERS_DOMAIN,
redirectTo: "/account/settings",
});
} else {
const userWithEmail = await prisma.user.findUnique({
where: {
email,
},
});
}

const hostName = req.headers.get("host") || "";
if (userWithEmail) {
throw new DubApiError({
code: "conflict",
message: "Email is already in use.",
});
}

await confirmEmailChange({
email: session.user.email,
newEmail: email,
identifier: session.user.id,
hostName: APP_HOSTNAMES.has(hostName) ? APP_DOMAIN : PARTNERS_DOMAIN,
});
await confirmEmailChange({
email: session.user.email,
newEmail: email,
identifier: session.user.id,
hostName: emailChangeHost,
});
}
}

const response = await prisma.user.update({
Expand All @@ -124,23 +179,42 @@ export const PATCH = withSession(async ({ req, session }) => {
},
data: {
...(name && { name }),
...(image && { image }),
...(image !== undefined && { image: image ?? null }),
...(source && { source }),
...(defaultWorkspace && { defaultWorkspace }),
},
});

if (shouldSyncIdentity && partnerId) {
await syncNameAndImageToPartner({
partnerId,
...(name !== undefined && name && { name }),
...(image !== undefined && { image }),
});
}

waitUntil(
(async () => {
// Delete only if a new image is uploaded and the old image exists
// Delete only if a new image is uploaded and the old image exists.
// Skip if the partner profile still references the old image (e.g. user
// chose to update login account only after a prior identity sync).
if (
image &&
session.user.image &&
session.user.image.startsWith(`${R2_URL}/avatars/${session.user.id}`)
) {
await storage.delete({
key: session.user.image.replace(`${R2_URL}/`, ""),
});
const partnerStillUsesImage =
partnerId &&
(await isImageReferencedByPartner({
partnerId,
imageUrl: session.user.image,
}));

if (!partnerStillUsesImage) {
await storage.delete({
key: session.user.image.replace(`${R2_URL}/`, ""),
});
}
}
})(),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { toast } from "sonner";

export default async function ConfirmEmailChangePageClient({
export default function ConfirmEmailChangePageClient({
isPartnerProfile,
redirectTo,
}: {
isPartnerProfile: boolean;
redirectTo?: "/profile" | "/account/settings";
}) {
const router = useRouter();
const { update, status } = useSession();
Expand All @@ -24,11 +26,13 @@ export default async function ConfirmEmailChangePageClient({
hasUpdatedSession.current = true;
await update();
toast.success("Successfully updated your email!");
router.replace(isPartnerProfile ? "/profile" : "/account/settings");
router.replace(
redirectTo ?? (isPartnerProfile ? "/profile" : "/account/settings"),
);
}

updateSession();
}, [status, update]);
}, [status, update, isPartnerProfile, redirectTo, router]);

return (
<EmptyState
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getSession, hashToken } from "@/lib/auth";
import { hasPermission } from "@/lib/auth/partner-users/partner-user-permissions";
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/upstash";
import EmptyState from "@/ui/shared/empty-state";
Expand Down Expand Up @@ -75,17 +76,50 @@ const VerifyEmailChange = async ({ params, searchParams }: PageProps) => {
redirect(`/login?next=/auth/confirm-email-change/${token}`);
}

const { id: userId, defaultPartnerId: partnerId } = session.user;
const { id: userId } = session.user;
const tokenIdentifier = tokenFound.identifier;

const identifier = tokenFound.identifier.startsWith("pn_")
? partnerId
: userId;
if (tokenIdentifier.startsWith("pn_")) {
const partnerUser = await prisma.partnerUser.findUnique({
where: {
userId_partnerId: {
userId,
partnerId: tokenIdentifier,
},
},
select: { role: true },
});

if (
!partnerUser ||
!hasPermission(partnerUser.role, "partner_profile.update")
) {
return (
<EmptyState
icon={InputPassword}
title="Invalid Token"
description="This token is invalid. Please request a new one."
/>
);
}
} else if (tokenIdentifier !== userId) {
return (
<EmptyState
icon={InputPassword}
title="Invalid Token"
description="This token is invalid. Please request a new one."
/>
);
}

const data = await redis.get<{
email: string;
newEmail: string;
isPartnerProfile?: boolean;
}>(`email-change-request:user:${identifier}`);
syncIdentity?: boolean;
partnerId?: string;
redirectTo?: "/profile" | "/account/settings";
}>(`email-change-request:user:${tokenIdentifier}`);

if (!data) {
return (
Expand All @@ -97,21 +131,67 @@ const VerifyEmailChange = async ({ params, searchParams }: PageProps) => {
);
}

// Update the partner profile email
if (data.isPartnerProfile) {
if (!partnerId) {
if (data.syncIdentity) {
const syncedPartnerId = data.partnerId;

if (!syncedPartnerId) {
return (
<EmptyState
icon={InputPassword}
title="Invalid Token"
description="This token is invalid. Please request a new one."
/>
);
}

const partnerUser = await prisma.partnerUser.findUnique({
where: {
userId_partnerId: {
userId,
partnerId: syncedPartnerId,
},
},
select: { role: true },
});

if (
!partnerUser ||
!hasPermission(partnerUser.role, "partner_profile.update")
) {
return (
<EmptyState
icon={InputPassword}
title="No Partner Profile Found"
description="We couldn’t find a partner profile for your account. Please make sure you’re logged in with the correct account at https://partners.dub.co"
title="Unauthorized"
description="You don't have access to update the partner profile associated with this email change request."
/>
);
}

await prisma.$transaction([
prisma.user.update({
where: {
id: userId,
},
data: {
email: data.newEmail,
},
}),
prisma.partner.update({
where: {
id: syncedPartnerId,
},
data: {
email: data.newEmail,
},
}),
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Update the partner profile email
else if (data.isPartnerProfile) {
await prisma.partner.update({
where: {
id: partnerId,
id: tokenIdentifier,
},
data: {
email: data.newEmail,
Expand Down Expand Up @@ -142,13 +222,17 @@ const VerifyEmailChange = async ({ params, searchParams }: PageProps) => {
oldEmail: data.email,
newEmail: data.newEmail,
isPartnerProfile: !!data.isPartnerProfile,
syncIdentity: !!data.syncIdentity,
}),
}),
]),
);

return (
<ConfirmEmailChangePageClient isPartnerProfile={!!data.isPartnerProfile} />
<ConfirmEmailChangePageClient
isPartnerProfile={!!data.isPartnerProfile}
redirectTo={data.redirectTo}
/>
);
};

Expand Down
Loading
Loading