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
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ import { CountryCombobox } from "@/ui/partners/country-combobox";
import { Partner } from "@dub/prisma/client";
import {
Button,
buttonVariants,
FileUpload,
ToggleGroup,
TooltipContent,
useEnterSubmit,
useLocalStorage,
useMediaQuery,
} from "@dub/ui";
import { cn } from "@dub/utils/src/functions";
import { cn } from "@dub/utils";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useSession } from "next-auth/react";
import { useAction } from "next-safe-action/hooks";
Expand Down Expand Up @@ -111,7 +110,7 @@ export function OnboardingForm({
className="flex w-full flex-col gap-6 text-left"
>
<label>
<span className="text-sm font-medium text-neutral-800">Full Name</span>
<span className="text-sm font-medium text-neutral-800">Name</span>
<input
type="text"
className={cn(
Expand All @@ -129,22 +128,19 @@ export function OnboardingForm({

<label>
<span className="text-sm font-medium text-neutral-800">
Profile Image
Profile image
<span className="font-normal text-neutral-500"> (optional)</span>
</span>
<div className="flex items-center gap-5">
<Controller
control={control}
name="image"
rules={{ required: true }}
render={({ field }) => (
<FileUpload
accept="images"
className={cn(
"mt-1.5 size-20 rounded-full border border-neutral-300",
errors.image && "border-0 ring-2 ring-red-500",
)}
className="mt-1.5 size-20 rounded-full border border-neutral-300"
iconClassName="size-5"
previewClassName="size-10 rounded-full"
previewClassName="size-20 rounded-full"
variant="plain"
imageSrc={field.value}
readFile
Expand All @@ -156,16 +152,11 @@ export function OnboardingForm({
)}
/>
<div>
<div
className={cn(
buttonVariants({ variant: "secondary" }),
"flex h-7 w-fit cursor-pointer items-center rounded-md border px-2 text-xs",
)}
>
Upload image
</div>
<p className="mt-1.5 text-xs text-neutral-500">
Recommended size: 160x160px
<p className="text-xs text-neutral-500">
Square image recommended, up to 2 MB.
</p>
<p className="mt-0.5 text-xs font-medium text-neutral-500">
Adding can improve your program approvals.
</p>
</div>
</div>
Expand Down
50 changes: 36 additions & 14 deletions apps/web/lib/actions/partners/onboard-partner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { storage } from "@/lib/storage";
import { onboardPartnerSchema } from "@/lib/zod/schemas/partners";
import { prisma } from "@dub/prisma";
import { Prisma } from "@dub/prisma/client";
import { nanoid } from "@dub/utils";
import { nanoid, OG_AVATAR_URL, R2_URL } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { authUserActionClient } from "../safe-action";

Expand All @@ -19,22 +19,42 @@ export const onboardPartnerAction = authUserActionClient
const { user } = ctx;
const { name, image, country, description, profileType } = parsedInput;

const existingPartner = await prisma.partner.findUnique({
where: {
email: user.email,
},
});
const [existingPartner, userHasProjects] = await Promise.all([
prisma.partner.findUnique({
where: {
email: user.email,
},
}),
// Check if user has any workspaces (dub account)
prisma.projectUsers.findFirst({
where: {
userId: user.id,
},
select: { id: true },
}),
]);

const partnerId = existingPartner
? existingPartner.id
: createId({ prefix: "pn_" });

const imageUrl = await storage
.upload({
key: `partners/${partnerId}/image_${nanoid(7)}`,
body: image,
})
.then(({ url }) => url);
// Determine if we should sync the partner image to the user account
// Only sync on partner creation (not update) and only if user has no dub account (no projects)
// Also don't overwrite if user already has a custom image (stored in R2, not a default avatar)
const isNewPartner = !existingPartner;
const userHasCustomImage = user.image?.startsWith(R2_URL);
const shouldSyncImageToUser =
isNewPartner && !userHasProjects && !userHasCustomImage;

// Use uploaded image or generate default avatar URL
const imageUrl = image
? await storage
.upload({
key: `partners/${partnerId}/image_${nanoid(7)}`,
body: image,
})
.then(({ url }) => url)
: `${OG_AVATAR_URL}${name || user.email}`;

// country, profileType, and companyName cannot be changed once set
const payload: Prisma.PartnerCreateInput = {
Expand Down Expand Up @@ -80,13 +100,15 @@ export const onboardPartnerAction = authUserActionClient
}),

// only set the default partner ID if the user doesn't already have one
!user.defaultPartnerId &&
// also sync the partner image to user account if creating a new partner and user has no dub account
(!user.defaultPartnerId || shouldSyncImageToUser) &&
prisma.user.update({
where: {
id: user.id,
},
data: {
defaultPartnerId: partnerId,
...(!user.defaultPartnerId && { defaultPartnerId: partnerId }),
...(shouldSyncImageToUser && { image: imageUrl }),
},
}),
]);
Expand Down
20 changes: 19 additions & 1 deletion apps/web/lib/zod/schemas/partners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,24 @@ const partnerImageSchema = z
message: "Image is required",
});

// Optional image schema for onboarding (image is not required)
const optionalPartnerImageSchema = z
.union([
base64ImageSchema,
storedR2ImageUrlSchema,
publicHostedImageSchema,
z
.string()
.url()
.trim()
.refine((url) => url.startsWith(GOOGLE_FAVICON_URL), {
message: `Image URL must start with ${GOOGLE_FAVICON_URL}`,
}),
z.literal(""),
])
.optional()
.transform((v) => v || undefined);

Comment on lines +589 to +606
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 | 🟠 Major

Schema likely rejects OG_AVATAR_URL (and can break prefilled session.user.image).
optionalPartnerImageSchema doesn’t allow the default avatar URL pattern used elsewhere (OG_AVATAR_URL), but the onboarding form may prefill image from session.user.image. If that image is an OG avatar URL, submission will fail validation.

Suggested adjustment:

 const optionalPartnerImageSchema = z
   .union([
     base64ImageSchema,
     storedR2ImageUrlSchema,
     publicHostedImageSchema,
     z
       .string()
       .url()
       .trim()
       .refine((url) => url.startsWith(GOOGLE_FAVICON_URL), {
         message: `Image URL must start with ${GOOGLE_FAVICON_URL}`,
       }),
+    z
+      .string()
+      .url()
+      .trim()
+      .refine((url) => url.startsWith(OG_AVATAR_URL), {
+        message: `Image URL must start with ${OG_AVATAR_URL}`,
+      }),
     z.literal(""),
   ])
   .optional()
   .transform((v) => v || undefined);

(Requires importing OG_AVATAR_URL here, or alternatively: don’t prefill unsupported URLs in the form.)

📝 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
// Optional image schema for onboarding (image is not required)
const optionalPartnerImageSchema = z
.union([
base64ImageSchema,
storedR2ImageUrlSchema,
publicHostedImageSchema,
z
.string()
.url()
.trim()
.refine((url) => url.startsWith(GOOGLE_FAVICON_URL), {
message: `Image URL must start with ${GOOGLE_FAVICON_URL}`,
}),
z.literal(""),
])
.optional()
.transform((v) => v || undefined);
// Optional image schema for onboarding (image is not required)
const optionalPartnerImageSchema = z
.union([
base64ImageSchema,
storedR2ImageUrlSchema,
publicHostedImageSchema,
z
.string()
.url()
.trim()
.refine((url) => url.startsWith(GOOGLE_FAVICON_URL), {
message: `Image URL must start with ${GOOGLE_FAVICON_URL}`,
}),
z
.string()
.url()
.trim()
.refine((url) => url.startsWith(OG_AVATAR_URL), {
message: `Image URL must start with ${OG_AVATAR_URL}`,
}),
z.literal(""),
])
.optional()
.transform((v) => v || undefined);

export const onboardPartnerSchema = createPartnerSchema
.omit({
username: true,
Expand All @@ -595,7 +613,7 @@ export const onboardPartnerSchema = createPartnerSchema
.merge(
z.object({
name: z.string().min(1, "Name is required"),
image: partnerImageSchema,
image: optionalPartnerImageSchema,
country: z.enum(COUNTRY_CODES),
profileType: z.nativeEnum(PartnerProfileType).default("individual"),
companyName: z.string().nullish(),
Expand Down