Skip to content

Sync partner profile and account#4064

Open
pepeladeira wants to merge 7 commits into
mainfrom
partner-identity-sync
Open

Sync partner profile and account#4064
pepeladeira wants to merge 7 commits into
mainfrom
partner-identity-sync

Conversation

@pepeladeira

@pepeladeira pepeladeira commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features
    • Partner “Basic information” and Settings can now optionally synchronize name, email, and profile picture to your login account, prompting a confirmation when changes differ.
  • Bug Fixes
    • Prevents avatar deletion when another partner profile still references the previous image.
    • Strengthens synced email changes with permission checks, conflict handling, and correct post-confirm redirects.
  • Improvements
    • Confirmation modals now handle loading state reliably.
    • Profile picture syncing supports clearing images and refreshes relevant profile/account data after update.

@vercel

vercel Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Jun 23, 2026 8:17pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds optional bidirectional identity synchronization between partner profiles and user login accounts. A new sync-partner-identity library provides helpers for email conflict checks, image uploads to respective storage, and cross-entity name/image propagation. The email-change confirmation flow gains syncIdentity and redirectTo parameters persisted in Redis and used for atomic dual-entity updates. A new confirmation modal component prompts users before syncing identity changes. Both the partner profile form and account settings page branch submissions through the modal on the partners domain. The PATCH /api/user and partner profile action branch on syncIdentity to validate availability and propagate updates. A shared avatar deletion guard prevents orphaned images when the partner still references them.

Changes

Partner–User Identity Sync

Layer / File(s) Summary
Sync identity core library
apps/web/lib/partners/sync-partner-identity.ts
New module with assertEmailAvailableForIdentitySync (concurrent user/partner email conflict check), copyImageToPartnerStorage and copyImageToUserStorage (image upload helpers), syncNameAndImageToPartner and syncNameAndImageToUser (conditional image upload + Prisma update), isImageReferencedByPartner (avatar-reference check), and requestSyncedEmailChange (synced email-change request).
Email change confirmation plumbing
apps/web/lib/auth/confirm-email-change.ts, apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx, .../page-client.tsx
confirmEmailChange gains optional syncIdentity and redirectTo params stored in Redis; confirmation page performs partner authorization, atomically updates both user.email and partner.email when syncIdentity is true, and forwards redirectTo to client component; client component uses redirectTo for post-confirmation navigation.
Server-side sync in user API
apps/web/app/api/user/route.ts
PATCH /api/user accepts syncIdentity, detects partners-domain requests, gates sync on partner_profile.update permission, branches email-change through sync helpers, propagates name/image to partner, and guards avatar deletion with isImageReferencedByPartner.
Server-side sync in partner profile action
apps/web/lib/actions/partners/update-partner-profile.ts
updatePartnerProfileAction accepts syncIdentity, refactors image handling to tri-state (undefined/URL/null), branches email-change verification, and propagates updated name/image to associated user.
Shared UI components
apps/web/ui/account/upload-avatar.tsx, apps/web/ui/modals/confirm-modal.tsx, packages/email/src/templates/email-updated.tsx
UploadAvatar gains optional onSubmit prop with async/try-catch-finally handler; ConfirmModal cancel button is made async; EmailUpdated template gains optional syncIdentity prop to adjust profile description.
Identity sync confirmation modal UI
apps/web/ui/modals/identity-sync-confirm-modal.tsx
New component with types IdentitySyncField and IdentitySyncSnapshot, helper getIdentityDisplayFields, preview row to show current/next states, and useIdentitySyncConfirmModal hook to manage visibility and async confirm/cancel handlers with loading guards.
Partner profile form sync confirmation
apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx
BasicInfoForm submission now computes sync candidates against the session user, shows identity-sync confirmation modal to choose "Update both" vs. "Only update profile", normalizes username, conditionally includes image, triggers SWR revalidation, and conditionally calls updateSession().
Account settings page sync confirmation
apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx
SettingsPageClient refactored with centralized patchUser and requestSubmit that shows identity-sync confirmation modal on partner domain for name/email/avatar changes that diverge from the partner profile; conditional session.update() suppression for pending email changes.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ProfileForm as Partner Profile Form
  participant SettingsPage as Account Settings
  participant PartnerAction as updatePartnerProfileAction
  participant UserAPI as PATCH /api/user
  participant SyncLib as sync-partner-identity
  participant ConfirmEmail as Email Confirmation
  participant Prisma

  User->>ProfileForm: submit profile (name/image/email changed)
  ProfileForm->>ProfileForm: getProfileSyncCandidates vs session
  alt sync candidates exist
    ProfileForm->>User: show identity-sync modal
    User->>ProfileForm: "Update both" choice
  end
  ProfileForm->>PartnerAction: updatePartnerProfileAction {syncIdentity}
  alt email changed + syncIdentity
    PartnerAction->>SyncLib: assertEmailAvailableForIdentitySync
    PartnerAction->>SyncLib: requestSyncedEmailChange
    SyncLib->>ConfirmEmail: confirmEmailChange with syncIdentity=true
  end
  PartnerAction->>SyncLib: syncNameAndImageToUser

  User->>SettingsPage: submit name/image/email
  SettingsPage->>SettingsPage: getAccountSyncCandidates
  alt on partner domain + sync candidates
    SettingsPage->>User: show identity-sync modal
    User->>SettingsPage: "Update both" choice
  end
  SettingsPage->>UserAPI: PATCH {syncIdentity: true}
  UserAPI->>SyncLib: syncNameAndImageToPartner
  UserAPI->>SyncLib: isImageReferencedByPartner (guard deletion)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • steven-tey

Poem

🐰 Hop hop, the profiles align,
One name, one face, one email fine.
A modal asks: "sync both or one?"
The rabbit chose — and both were done!
No avatar lost, no email split,
Identity whole, not bit by bit.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Sync partner profile and account' accurately describes the core feature being introduced—synchronizing identity changes between partner profiles and user accounts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch partner-identity-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/web/app/`(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:
- Around line 480-488: The validation pattern attribute set to
"^[a-z0-9]+(?:-[a-z0-9]+)*$" only allows lowercase letters, numbers, and
hyphens, but the help text in the p element below it claims underscores are
allowed. Update the help text to remove the mention of underscores and
accurately reflect what the pattern validation actually permits - only lowercase
letters, numbers, and hyphens.
- Around line 308-310: The session is not being refreshed when syncIdentity is
true but needsEmailVerification is also true, which leaves the login cache stale
for name/avatar changes that were synced immediately. In the if statement
checking syncIdentity and result.data.needsEmailVerification, remove the
negation condition on needsEmailVerification so that updateSession() is called
whenever syncIdentity is true, regardless of whether email verification is
pending. This ensures name and avatar changes are reflected in the session cache
immediately even when an email change requires verification.

In `@apps/web/app/app.dub.co/`(dashboard)/account/settings/page-client.tsx:
- Around line 86-87: The `isPartnerDomain` assignment on line 87 uses `subdomain
!== "app"` which incorrectly treats the initial null value as a partner domain
and would also incorrectly classify other subdomains like "admin" as partners.
Change the condition to explicitly check if `subdomain` equals the specific
partners domain value instead of checking if it's not equal to "app", ensuring
partner-sync behavior is only enabled for the actual partners subdomain.

In `@apps/web/lib/auth/confirm-email-change.ts`:
- Around line 66-67: The `partnerId` is not being persisted with the email
change request, causing confirmation to use the wrong partner if the user
switches partners before clicking the email link. Modify the
`requestSyncedEmailChange` function to accept `partnerId` as an input parameter,
add it to the object spread pattern alongside `syncIdentity` and `redirectTo`
(using the same conditional spread syntax), store the `partnerId` in Redis with
the sync identity request, and add validation to ensure `partnerId` is provided
when `syncIdentity` is true. Then update the `confirmEmailChange` function to
retrieve and use the stored `partnerId` instead of relying on the current
session's defaultPartnerId when confirming the email change.

In `@apps/web/lib/partners/sync-partner-identity.ts`:
- Around line 34-37: The `assertEmailAvailableForIdentitySync` function is
throwing a plain Error when detecting a partner email conflict, which causes it
to be treated as a generic 500 error instead of a proper conflict response.
Replace the plain Error throw statement with a typed conflict error from the
shared helper so that when this function is called from `PATCH /api/user`, the
conflict is properly handled and returns the expected conflict response code
instead of a 500 error.

In `@apps/web/ui/modals/confirm-modal.tsx`:
- Around line 79-87: The cancel button in the confirm modal is not disabled
while the async onCancel operation is in progress, allowing it to be clicked
multiple times and invoke the handler repeatedly. Add a disabled prop to the
cancel button that evaluates the isLoading state (set to true at the start of
the onClick handler), similar to how the confirm button likely handles it, to
prevent multiple concurrent invocations of the onCancel function.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5bb2258a-ec9d-40ec-bcc4-216c48e6e303

📥 Commits

Reviewing files that changed from the base of the PR and between 518ea9a and 1262133.

📒 Files selected for processing (10)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx
  • apps/web/app/api/user/route.ts
  • apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page-client.tsx
  • apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx
  • apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx
  • apps/web/lib/actions/partners/update-partner-profile.ts
  • apps/web/lib/auth/confirm-email-change.ts
  • apps/web/lib/partners/sync-partner-identity.ts
  • apps/web/ui/account/upload-avatar.tsx
  • apps/web/ui/modals/confirm-modal.tsx

Comment thread apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx Outdated
Comment thread apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx Outdated
Comment thread apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx Outdated
Comment thread apps/web/lib/auth/confirm-email-change.ts Outdated
Comment thread apps/web/lib/partners/sync-partner-identity.ts Outdated
Comment thread apps/web/ui/modals/confirm-modal.tsx Outdated
@pepeladeira

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review. any breaking changes or security issues?

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read all key files changed in this PR

echo "=== sync-partner-identity.ts ==="
fd "sync-partner-identity.ts" --exec cat {}

echo ""
echo "=== update-partner-profile.ts ==="
fd "update-partner-profile.ts" --exec cat {}

echo ""
echo "=== confirm-email-change.ts (lib/auth) ==="
fd "confirm-email-change.ts" apps/web/lib/auth --exec cat {}

echo ""
echo "=== confirm-email-change page.tsx ==="
fd "page.tsx" apps/web/app/app.dub.co/\(auth\)/auth/confirm-email-change --exec cat {}

echo ""
echo "=== confirm-email-change page-client.tsx ==="
fd "page-client.tsx" apps/web/app/app.dub.co/\(auth\)/auth/confirm-email-change --exec cat {}

echo ""
echo "=== user/route.ts ==="
fd "route.ts" apps/web/app/api/user --exec cat {}

echo ""
echo "=== profile-details-form.tsx ==="
fd "profile-details-form.tsx" --exec cat {}

echo ""
echo "=== account settings page-client.tsx ==="
fd "page-client.tsx" "apps/web/app/app.dub.co/(dashboard)/account/settings" --exec cat {}

echo ""
echo "=== upload-avatar.tsx ==="
fd "upload-avatar.tsx" --exec cat {}

echo ""
echo "=== confirm-modal.tsx ==="
fd "confirm-modal.tsx" --exec cat {}

Length of output: 81755



@pepeladeira Here's my full review focused on security issues and breaking changes.


Breaking Changes

None identified. All additions are backward-compatible:

  • syncIdentity, redirectTo, partnerId in confirmEmailChange are optional with defaults
  • onSubmit in UploadAvatar is optional
  • redirectTo in ConfirmEmailChangePageClient is optional
  • ConfirmEmailChangePageClient changing from async to a sync function has no downstream impact

Security & Logic Issues

🔴 High – syncNameAndImageToUser / syncNameAndImageToPartner silently no-op when clearing images

In apps/web/lib/partners/sync-partner-identity.ts, both sync helpers use:

if (!name && !image) {
  return;
}

When a user explicitly clears their image (image === null) without a name change, !null === true and !undefined === true, so the function returns without doing anything. Image deletion is never synced to the other entity. The same issue applies to both syncNameAndImageToUser and syncNameAndImageToPartner. The fix should treat an explicit null differently from undefined:

const hasNameUpdate = name !== undefined;
const hasImageUpdate = image !== undefined; // includes null (clear)
if (!hasNameUpdate && !hasImageUpdate) return;

🟠 Medium – Host-header spoofing controls isPartnersDomain in PATCH /api/user

In apps/web/app/api/user/route.ts:

const hostName = req.headers.get("host") || "";
const isPartnersDomain = !APP_HOSTNAMES.has(hostName);

This is attacker-controllable. The direct consequence is limited because shouldSyncIdentity also requires partnerId and the hasPermission check. However, the non-sync branch also uses this flag:

hostName: APP_HOSTNAMES.has(hostName) ? APP_DOMAIN : PARTNERS_DOMAIN,

A spoofed host header would cause confirmation emails for regular app users to use PARTNERS_DOMAIN as the base URL (partners.dub.co/auth/confirm-email-change/...), which is an incorrect confirmation link for app-domain users. Consider reading the deployment hostname from a trusted server-side environment variable or a trusted header set by the reverse proxy (e.g., x-forwarded-host validated against an allow-list).


🟠 Medium – partner.email non-null assertion may throw at runtime

In apps/web/lib/actions/partners/update-partner-profile.ts:

await confirmEmailChange({
  email: partner.email!,   // ← non-null assertion
  newEmail,
  identifier: partner.id,
  isPartnerProfile: true,
  hostName: PARTNERS_DOMAIN,
});

If a partner record has no email (schema allows nullable), this assertion passes TypeScript but email will be null at runtime. The confirmation email would be sent to null, causing an unhandled error in the email service. This should validate partner.email is present before reaching this branch.


🟡 Low – Token identifier ownership not explicitly cross-checked in VerifyEmailChange

In apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx:

const identifier = tokenFound.identifier.startsWith("pn_")
  ? partnerId   // session.user.defaultPartnerId (may be null)
  : userId;

tokenFound.identifier is never asserted to equal the derived identifier. For partner tokens, the code substitutes session.user.defaultPartnerId. If defaultPartnerId is null, identifier becomes null/undefined, yielding a Redis key of email-change-request:user:null. This doesn't expose data (Redis lookup returns null → "Invalid Token"), but it's fragile. Add an explicit check:

if (tokenFound.identifier.startsWith("pn_") && tokenFound.identifier !== partnerId) {
  // Return "Invalid Token" – token belongs to a different partner
}

🟡 Low – Role not re-validated at email confirmation time for syncIdentity

In apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx, the syncIdentity path only checks whether a partnerUser row exists:

const partnerUser = await prisma.partnerUser.findUnique({...});
if (!partnerUser) { /* show error */ }
// → then mutates both user and partner emails

The partner_profile.update permission is enforced at initiation but not rechecked at confirmation. If the user's role is downgraded between the two steps, they would still complete the email change. Consider also checking hasPermission(partnerUser.role, "partner_profile.update") here.


🟡 Low – Notification email uses isPartnerProfile: false even for synced email changes

In page.tsx, after a syncIdentity email change, the notification email is sent with:

sendEmail({
  ...
  react: EmailUpdated({
    oldEmail: data.email,
    newEmail: data.newEmail,
    isPartnerProfile: !!data.isPartnerProfile,   // false for syncIdentity
  }),
})

Since data.isPartnerProfile is not set in the syncIdentity Redis payload, the email will be rendered as a user account email update even though both the user and partner email were changed. The notification email template may want a third state or both flags to accurately represent a synced change.


Summary Table

Severity File Issue
🔴 High sync-partner-identity.ts Image clear (null) silently no-op'd in sync helpers
🟠 Medium app/api/user/route.ts isPartnersDomain derived from spoofable host header
🟠 Medium update-partner-profile.ts partner.email! assertion can throw if email is null
🟡 Low confirm-email-change/page.tsx Token identifier not explicitly validated against session
🟡 Low confirm-email-change/page.tsx Role not re-checked at confirmation for syncIdentity
🟡 Low confirm-email-change/page.tsx Sync change notified as non-partner update via EmailUpdated
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx (2)

84-91: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Bind the Redis payload to the verification token identifier.

The lookup should use tokenFound.identifier after verifying it belongs to the current session. Deriving the key from the current default partner/user can read the wrong pending request if the default partner changes or another request exists.

🐛 Proposed fix shape
+  const tokenIdentifier = tokenFound.identifier;
+  const sessionIdentifier = tokenIdentifier.startsWith("pn_")
+    ? partnerId
+    : userId;
+
+  if (sessionIdentifier !== tokenIdentifier) {
+    return (
+      <EmptyState
+        icon={InputPassword}
+        title="Unauthorized"
+        description="This email change request does not belong to the current session."
+      />
+    );
+  }
+
   const data = await redis.get<{
     email: string;
     newEmail: string;
     isPartnerProfile?: boolean;
     syncIdentity?: boolean;
     partnerId?: string;
     redirectTo?: "/profile" | "/account/settings";
-  }>(`email-change-request:user:${identifier}`);
+  }>(`email-change-request:user:${tokenIdentifier}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/app.dub.co/`(auth)/auth/confirm-email-change/[token]/page.tsx
around lines 84 - 91, The Redis lookup for the email change request data is
using the current session's identifier when constructing the key
`email-change-request:user:${identifier}`, but it should use the identifier from
the verified token instead. After confirming that tokenFound exists and belongs
to the current session, replace the identifier variable in the Redis key
construction with tokenFound.identifier to ensure the correct pending request is
retrieved for the specific token being confirmed.

194-201: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Classify synced-email notifications as partner profile updates.

For data.syncIdentity, the transaction updates the partner email too, but the notification template receives isPartnerProfile: false.

🐛 Proposed fix
         react: EmailUpdated({
           oldEmail: data.email,
           newEmail: data.newEmail,
-          isPartnerProfile: !!data.isPartnerProfile,
+          isPartnerProfile: !!data.isPartnerProfile || !!data.syncIdentity,
         }),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/app.dub.co/`(auth)/auth/confirm-email-change/[token]/page.tsx
around lines 194 - 201, The `isPartnerProfile` parameter passed to the
`EmailUpdated` component in the `sendEmail` call only checks
`data.isPartnerProfile`, but when `data.syncIdentity` is true, the partner email
is also updated in the transaction. Update the `isPartnerProfile` parameter in
the `EmailUpdated` function call to account for both conditions - it should be
true when either `data.isPartnerProfile` is true OR when `data.syncIdentity` is
true, ensuring the email template correctly identifies when a partner profile
email has been synced and updated.
apps/web/app/api/user/route.ts (2)

14-18: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Default unrecognized hosts to the app flow.

!APP_HOSTNAMES.has(hostName) treats any unknown or spoofed Host as the partners domain, which can select the partners confirmation URL and enable the partner-sync branch. Only the explicit partners host should opt into partners behavior.

🛡️ Proposed fix
 import {
   APP_DOMAIN,
-  APP_HOSTNAMES,
   PARTNERS_DOMAIN,
   R2_URL,
   nanoid,
@@
   const hostName = req.headers.get("host") || "";
-  const isPartnersDomain = !APP_HOSTNAMES.has(hostName);
+  const isPartnersDomain = hostName === new URL(PARTNERS_DOMAIN).host;
@@
       await confirmEmailChange({
         email: session.user.email,
         newEmail: email,
         identifier: session.user.id,
-        hostName: APP_HOSTNAMES.has(hostName) ? APP_DOMAIN : PARTNERS_DOMAIN,
+        hostName: isPartnersDomain ? PARTNERS_DOMAIN : APP_DOMAIN,
       });

Also applies to: 78-82, 166-171

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/user/route.ts` around lines 14 - 18, The logic for detecting
the partners domain flow uses a negative check on APP_HOSTNAMES that defaults
unknown/spoofed hosts to partners behavior, which is a security issue. Replace
all instances of conditions that use !APP_HOSTNAMES.has(hostName) throughout the
file (including around lines 78-82 and 166-171) with explicit positive checks
that only activate partners behavior when hostName explicitly matches
PARTNERS_DOMAIN. This ensures only legitimate partners hosts can opt into the
partners behavior while unknown hosts default to the standard app flow.

175-216: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve null avatar updates through save, sync, and cleanup.

image: null passes validation, but the truthy spreads skip clearing user.image, skip sending the clear to the partner sync helper, and skip old-avatar cleanup.

🐛 Proposed fix
     data: {
       ...(name && { name }),
-      ...(image && { image }),
+      ...(image !== undefined && { image }),
       ...(source && { source }),
       ...(defaultWorkspace && { defaultWorkspace }),
     },
@@
     await syncNameAndImageToPartner({
       partnerId,
       ...(name && { name }),
-      ...(image && { image }),
+      ...(image !== undefined && { image }),
     });
   }
@@
       if (
-        image &&
+        image !== undefined &&
         session.user.image &&
         session.user.image.startsWith(`${R2_URL}/avatars/${session.user.id}`)
       ) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/user/route.ts` around lines 175 - 216, The issue is that the
conditional spreads using the AND operator (&&) skip null values, preventing
image from being cleared. When image is null, the spreads in the
prisma.user.update data object, the syncNameAndImageToPartner call, and the
cleanup logic all skip the null value because null is falsy. Instead of checking
if the values are truthy, you need to check if they were explicitly provided in
the request. Refactor the conditional logic to distinguish between properties
that were not provided versus properties that were intentionally set to null, so
that null values for name, image, source, and defaultWorkspace are properly
included in the database update, passed to syncNameAndImageToPartner, and
accounted for in the old avatar cleanup logic within the waitUntil function.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/app/app.dub.co/`(auth)/auth/confirm-email-change/[token]/page.tsx:
- Around line 116-153: The code checks if a user is a partnerUser member but
does not verify the user has the partner_profile.update permission before
applying the email changes in the prisma.$transaction call. A user's role could
be downgraded between the initial membership check and the actual update,
allowing them to modify the partner email without proper authorization. Add a
re-check of the partner_profile.update permission immediately before the
prisma.$transaction block that updates both the user email and partner email to
ensure the user still has the required permission at the time of applying the
changes.

In `@apps/web/lib/actions/partners/update-partner-profile.ts`:
- Around line 195-202: The non-null assertion operators on user.email and
partner.email in the requestSyncedEmailChange call do not validate that these
values are actually non-null at runtime, only suppress TypeScript checks. Add
explicit null/undefined checks for both user.email and partner.email before
calling requestSyncedEmailChange to ensure neither value is null before passing
them to the function. If either email is null, handle this case appropriately
(such as returning early or throwing a user-friendly error) rather than allowing
null values to be queued in the confirmation payload.
- Around line 228-233: The conditional spread operator for the imageUrl
parameter in the syncNameAndImageToUser function call is preventing null values
from being passed through, which means avatar clears are not synced to linked
users. Instead of using the conditional spread pattern `...(imageUrl && { image:
imageUrl })`, directly pass the image property to the syncNameAndImageToUser
call so that explicit null values are included and properly communicated to the
sync helper, allowing it to clear the avatar when needed.

In `@apps/web/lib/partners/sync-partner-identity.ts`:
- Around line 81-99: The early return condition at the start of the function
treats `image === null` (explicit avatar deletion) the same as no image being
provided, which causes avatar clear operations to be silently skipped.
Additionally, the conditional spread in the Prisma update data does not include
explicit null values for the image field. Modify the logic to distinguish
between "image not provided" and "image explicitly set to null" - the function
should only return early if both name and image are undefined (not provided),
and the Prisma data object should include `image: null` when image is explicitly
null to ensure avatar deletions are synced to the database.

In `@apps/web/ui/modals/confirm-modal.tsx`:
- Around line 55-60: The handleCancel function awaits onCancel on line 60, but
the onCancel prop in the PromptModalProps type definition around line 8 is
declared as a synchronous callback. Update the onCancel property type in
PromptModalProps to support both sync and async callbacks by changing it to
accept a function that returns either void or Promise<void>, matching the
existing type signature of the onConfirm prop.

---

Outside diff comments:
In `@apps/web/app/api/user/route.ts`:
- Around line 14-18: The logic for detecting the partners domain flow uses a
negative check on APP_HOSTNAMES that defaults unknown/spoofed hosts to partners
behavior, which is a security issue. Replace all instances of conditions that
use !APP_HOSTNAMES.has(hostName) throughout the file (including around lines
78-82 and 166-171) with explicit positive checks that only activate partners
behavior when hostName explicitly matches PARTNERS_DOMAIN. This ensures only
legitimate partners hosts can opt into the partners behavior while unknown hosts
default to the standard app flow.
- Around line 175-216: The issue is that the conditional spreads using the AND
operator (&&) skip null values, preventing image from being cleared. When image
is null, the spreads in the prisma.user.update data object, the
syncNameAndImageToPartner call, and the cleanup logic all skip the null value
because null is falsy. Instead of checking if the values are truthy, you need to
check if they were explicitly provided in the request. Refactor the conditional
logic to distinguish between properties that were not provided versus properties
that were intentionally set to null, so that null values for name, image,
source, and defaultWorkspace are properly included in the database update,
passed to syncNameAndImageToPartner, and accounted for in the old avatar cleanup
logic within the waitUntil function.

In `@apps/web/app/app.dub.co/`(auth)/auth/confirm-email-change/[token]/page.tsx:
- Around line 84-91: The Redis lookup for the email change request data is using
the current session's identifier when constructing the key
`email-change-request:user:${identifier}`, but it should use the identifier from
the verified token instead. After confirming that tokenFound exists and belongs
to the current session, replace the identifier variable in the Redis key
construction with tokenFound.identifier to ensure the correct pending request is
retrieved for the specific token being confirmed.
- Around line 194-201: The `isPartnerProfile` parameter passed to the
`EmailUpdated` component in the `sendEmail` call only checks
`data.isPartnerProfile`, but when `data.syncIdentity` is true, the partner email
is also updated in the transaction. Update the `isPartnerProfile` parameter in
the `EmailUpdated` function call to account for both conditions - it should be
true when either `data.isPartnerProfile` is true OR when `data.syncIdentity` is
true, ensuring the email template correctly identifies when a partner profile
email has been synced and updated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: efe9cbf6-a00a-49ee-a385-0f1cb3b4c033

📥 Commits

Reviewing files that changed from the base of the PR and between 518ea9a and ec657b9.

📒 Files selected for processing (10)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx
  • apps/web/app/api/user/route.ts
  • apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page-client.tsx
  • apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx
  • apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx
  • apps/web/lib/actions/partners/update-partner-profile.ts
  • apps/web/lib/auth/confirm-email-change.ts
  • apps/web/lib/partners/sync-partner-identity.ts
  • apps/web/ui/account/upload-avatar.tsx
  • apps/web/ui/modals/confirm-modal.tsx

Comment thread apps/web/lib/actions/partners/update-partner-profile.ts
Comment thread apps/web/lib/actions/partners/update-partner-profile.ts
Comment thread apps/web/lib/partners/sync-partner-identity.ts Outdated
Comment thread apps/web/ui/modals/confirm-modal.tsx
@pepeladeira

Copy link
Copy Markdown
Collaborator Author

@coderabbitai I've applied all the security changes. Please verify all security issues again. Any breaking changes?

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== sync-partner-identity.ts ==="
fd "sync-partner-identity.ts" --exec cat {}

echo ""
echo "=== update-partner-profile.ts ==="
fd "update-partner-profile.ts" --exec cat {}

echo ""
echo "=== user/route.ts ==="
fd "route.ts" apps/web/app/api/user --exec cat {}

echo ""
echo "=== confirm-email-change/page.tsx ==="
fd "page.tsx" apps/web/app/app.dub.co/\(auth\)/auth/confirm-email-change --exec cat {}

Length of output: 37360



@pepeladeira Here's the re-verification of all 6 issues:


Security Issues – Re-verification

🔴 High – Image null no-op in syncNameAndImageToPartner / syncNameAndImageToUser

Fixed. Both functions now use:

const hasNameUpdate = name !== undefined;
const hasImageUpdate = image !== undefined;
if (!hasNameUpdate && !hasImageUpdate) return;

null is no longer treated as falsy-skip; image deletions are properly propagated.


🟠 Medium – isPartnersDomain derived from spoofable host header

Significantly improved. The logic changed from a negative check (!APP_HOSTNAMES.has(hostName)) to a positive allowlist (PARTNERS_HOSTNAMES.has(hostName)). Previously, any unknown host would be treated as a partners domain. Now only explicitly known partners hostnames qualify. In a production deployment behind Vercel/a reverse proxy (where the host header is set by the infra), this is acceptable.


🟠 Medium – partner.email! non-null assertion in update-partner-profile.ts

Fixed. The code now explicitly guards before use:

if (!partner.email) {
  throw new DubApiError({
    code: "bad_request",
    message: "Your partner profile does not have an email address on file.",
  });
}

No more ! assertion.


🟡 Low – Token identifier not explicitly validated against session

Fixed. tokenFound.identifier is now used directly as tokenIdentifier. For pn_ tokens, a partnerUser lookup is performed with both userId and partnerId: tokenIdentifier (directly binding the token to the current user). For non-partner tokens, tokenIdentifier !== userId triggers rejection. The defaultPartnerId substitution that introduced fragility is gone.


🟡 Low – Role not re-checked at confirmation for syncIdentity

Fixed. The syncIdentity confirmation path now re-validates hasPermission(partnerUser.role, "partner_profile.update") before mutating any data.


🟡 Low – Notification email incorrectly labelled for synced email changes

Fixed. EmailUpdated is now called with syncIdentity: !!data.syncIdentity, causing it to render "login account and partner profile" for synced changes.


Breaking Changes

None. All new parameters (syncIdentity, partnerId, redirectTo) remain optional with backward-compatible defaults.


Summary Table

Severity Issue Status
🔴 High sync-partner-identity.ts – null image no-op ✅ Fixed
🟠 Medium api/user/route.ts – host header spoofing ✅ Mitigated (positive allowlist)
🟠 Medium update-partner-profile.tspartner.email! assertion ✅ Fixed
🟡 Low confirm-email-change/page.tsx – token identifier validation ✅ Fixed
🟡 Low confirm-email-change/page.tsx – role not re-checked at confirmation ✅ Fixed
🟡 Low confirm-email-change/page.tsx – sync change email notification ✅ Fixed

All issues are addressed. 🎉


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/web/ui/modals/identity-sync-confirm-modal.tsx (2)

53-74: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting shared Tailwind classes.

Lines 55–59 and 66–70 repeat base classes with only conditional modifiers. You can simplify with a template literal or cn() utility.

♻️ Optional refactor
         {showName && snapshot.name && (
           <div
-            className={
-              isNameContextOnly
-                ? "text-[14px] font-semibold leading-4 text-neutral-900 opacity-50"
-                : "text-[14px] font-semibold leading-4 text-neutral-900"
-            }
+            className={`text-[14px] font-semibold leading-4 text-neutral-900 ${
+              isNameContextOnly ? "opacity-50" : ""
+            }`}
           >
             {snapshot.name}
           </div>
         )}
         {showEmail && snapshot.email && (
           <div
-            className={
-              showName && snapshot.name
-                ? "mt-1 text-sm font-medium leading-5 text-neutral-500"
-                : "text-sm font-medium leading-5 text-neutral-900"
-            }
+            className={`text-sm font-medium leading-5 ${
+              showName && snapshot.name
+                ? "mt-1 text-neutral-500"
+                : "text-neutral-900"
+            }`}
           >
             {snapshot.email}
           </div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/ui/modals/identity-sync-confirm-modal.tsx` around lines 53 - 74, The
conditional className logic for both the name and email display sections
contains repeated patterns that can be simplified. Extract the className
definitions for both the name div (currently using isNameContextOnly condition)
and the email div (currently using the showName && snapshot.name condition) into
separate variables at the top of the component or inline, using either template
literals or a cn() utility function to build the conditional class strings more
cleanly. This will reduce verbosity and make the conditional logic easier to
maintain and read.

142-164: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider adding error handling to async event handlers.

Both handleConfirm and handleCancel await user-provided callbacks but don't catch errors. If onConfirm or onCancel throw, the error becomes an unhandled promise rejection. React error boundaries cannot catch async errors from event handlers. Either add try-catch blocks to log/display errors, or document that onConfirm/onCancel must handle their own errors.

♻️ Proposed error-handling pattern
   const handleConfirm = async () => {
     if (isLoading) return;
 
     setIsLoading(true);
     try {
       await onConfirm();
       setShowModal(false);
+    } catch (error) {
+      console.error("Identity sync confirmation failed:", error);
+      // Optionally: show toast notification or set error state
     } finally {
       setIsLoading(false);
     }
   };
 
   const handleCancel = async () => {
     if (isLoading) return;
 
     setIsLoading(true);
     try {
       await onCancel?.();
       setShowModal(false);
+    } catch (error) {
+      console.error("Identity sync cancellation failed:", error);
+      // Optionally: show toast notification or set error state
     } finally {
       setIsLoading(false);
     }
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/ui/modals/identity-sync-confirm-modal.tsx` around lines 142 - 164,
The handleConfirm and handleCancel functions currently have try-finally blocks
without catch blocks to handle errors from the onConfirm and onCancel callbacks
respectively. Add a catch block in both handlers after the try block to catch
and handle any errors thrown by these callbacks. In the catch block, you should
log the error and optionally display it to the user through an error state or
notification mechanism, ensuring proper error handling occurs before the loading
state is reset in the finally block.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/web/ui/modals/identity-sync-confirm-modal.tsx`:
- Around line 53-74: The conditional className logic for both the name and email
display sections contains repeated patterns that can be simplified. Extract the
className definitions for both the name div (currently using isNameContextOnly
condition) and the email div (currently using the showName && snapshot.name
condition) into separate variables at the top of the component or inline, using
either template literals or a cn() utility function to build the conditional
class strings more cleanly. This will reduce verbosity and make the conditional
logic easier to maintain and read.
- Around line 142-164: The handleConfirm and handleCancel functions currently
have try-finally blocks without catch blocks to handle errors from the onConfirm
and onCancel callbacks respectively. Add a catch block in both handlers after
the try block to catch and handle any errors thrown by these callbacks. In the
catch block, you should log the error and optionally display it to the user
through an error state or notification mechanism, ensuring proper error handling
occurs before the loading state is reset in the finally block.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ab47f51-3542-4c6c-8b27-2167ca8fd204

📥 Commits

Reviewing files that changed from the base of the PR and between 98a8500 and 4afba30.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx
  • apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx
  • apps/web/ui/modals/identity-sync-confirm-modal.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant