Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Oct 14, 2025

Summary by CodeRabbit

  • New Features

    • Partner invites: send, batch-manage, update, revoke, and accept invites with a dedicated acceptance page and email template.
    • Members pages: partner and workspace members views with role management, per-row actions, and modals for invite, update, and removal.
  • Improvements

    • Permission-aware UI: hides/disables payout, profile, and connect controls when not permitted.
    • Sidebar & redirects: new “Partner profile” area and updated routes for settings/people → settings/members and notifications → profile/notifications.
  • Bug Fixes

    • Minor UI and formatting cleanups.

@vercel
Copy link
Contributor

vercel bot commented Oct 14, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 15, 2025 4:08am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 14, 2025

Walkthrough

Adds partner invite and membership management: invite CRUD, invite acceptance, member list/update/remove APIs and UI, permission model for partner-user roles, many UI modals/pages, middleware/redirect changes, WorkspaceRole migration, Prisma schema tweaks, and email template for partner invites.

Changes

Cohort / File(s) Summary
Partner invites API (CRUD + accept)
apps/web/app/(ee)/api/partner-profile/invites/route.ts, apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts
New GET/POST/PATCH/DELETE for partner invites with validation, limits, dedupe, transactional deletes; new POST accept route that creates PartnerUser, prefs, deletes invite, and sets defaultPartnerId within a transaction.
Partner users API (list/update/remove)
apps/web/app/(ee)/api/partner-profile/users/route.ts
Adds GET, PATCH, DELETE endpoints to list partner users, update roles, and remove users with permission checks and transactional safeguards.
Partner profile API payload
apps/web/app/(ee)/api/partner-profile/route.ts
GET handler now accepts and returns merged partnerUser + partner payload.
Partner dashboard pages & client flows
apps/web/app/(ee)/partners.dub.co/(auth)/invite/page.tsx, apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx, .../page.tsx
New Accept Invite page and Profile Members client/server pages with members/invites table, filters, role management, leave/revoke/remove actions, SWR integration and keyboard shortcut.
UI modals & hooks (invite/update/remove)
apps/web/ui/modals/invite-partner-user-modal.tsx, .../update-partner-user-modal.tsx, .../remove-partner-user-modal.tsx
New modal components and hooks to invite partner users, update roles (invites/users), and revoke/remove/leave with API calls, toasts, and cache invalidation.
Permission model & partner context
apps/web/lib/auth/partner-user-permissions.ts, apps/web/lib/auth/partner.ts, apps/web/lib/actions/safe-action.ts, apps/web/lib/types.ts, apps/web/lib/zod/schemas/partner-profile.ts
Introduces hasPermission/throwIfNoPermission, includes partnerUser in partner context, extends PartnerProps types and adds partner-user schemas and types.
Runtime permission guards (payout/profile flows & UI gating)
apps/web/lib/actions/partners/*, apps/web/app/(ee)/partners.dub.co/(dashboard)/*, apps/web/ui/partners/connect-payout-button.tsx, apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-button.tsx
Adds permission checks using partnerUser.role across payout and profile-related actions; UI components hide/disable controls when unauthorized.
Navigation, middleware, redirects
apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx, apps/web/lib/middleware/partners.ts, apps/web/lib/middleware/utils/partners-redirect.ts, apps/web/lib/middleware/utils/app-redirect.ts
Adds "Profile" NAV area (Profile/Members/Notifications), treats /invite as onboarding-exempt, adjusts onboarding redirect, and adds path redirects (e.g., /settings/people → /settings/members, notifications mapping).
Workspace role/type migration
packages/prisma/schema/workspace.prisma, packages/prisma/client.ts, apps/web/lib/zod/schemas/*, apps/web/lib/api/*, apps/web/lib/swr/use-workspace-users.ts, apps/web/app/api/workspaces/[idOrSlug]/*, apps/web/ui/modals/*, apps/web/app/app.dub.co/.../settings/members/page-client.tsx, apps/web/ui/...
Renames Role → WorkspaceRole in Prisma/schema/types and updates schemas, hooks, APIs, modals, and UI to use WorkspaceRole; adds query-based filtering and PATCH endpoints for workspace invites/users; replaces old People page with Members page and adds redirect.
Email template
packages/email/src/templates/partner-user-invited.tsx
New PartnerUserInvited email template generating invitation email with inviter context and join URL.
Partner constants
apps/web/lib/partners/constants.ts
Adds MAX_INVITES_PER_REQUEST and MAX_PARTNER_USERS constants.
Prisma and formatting tweaks
packages/prisma/schema/partner.prisma, packages/prisma/schema/program.prisma
Index directive syntax tweak for partner models and minor formatting changes in program model.
Zod import style changes
multiple files under apps/web/app/(ee)/api/... and apps/web/lib/actions/*
Replaced default zod import with named import { z } in several files.
Removed/renamed workspace pages & modals
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/people/page-client.tsx and various modal renames
Removed legacy People page; added/renamed Members page and workspace modal components/hooks (invite/update/remove) and added redirect.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant UI as Accept Invite Page
  participant API as POST /api/partner-profile/invites/accept
  participant DB as Prisma (tx)
  User->>UI: Click "Accept invite"
  UI->>API: POST accept
  API->>DB: begin tx, find invite by email + partnerId (include partner)
  alt invite missing / expired / already member
    DB-->>API: error
    API-->>UI: Error (not_found / invite_expired / conflict)
  else valid invite
    DB->>DB: create PartnerUser and notification prefs
    DB->>DB: delete PartnerInvite (email, partnerId)
    DB->>DB: fetch user, set defaultPartnerId if null
    DB-->>API: commit
    API-->>UI: 200 { joined }
    UI->>UI: refresh session, mutate cache, redirect
  end
Loading
sequenceDiagram
  autonumber
  actor Owner as Partner Owner
  participant UI as Members Page (Invite flow)
  participant API as POST /api/partner-profile/invites
  participant DB as Prisma
  participant Mail as Email Service
  Owner->>UI: Submit batch invites (emails + roles)
  UI->>API: POST invites
  API->>DB: validate limits, dedupe, check existing users/invites
  par per invite
    API->>DB: create partnerInvite + verification token (tx per invite or batched)
    API->>Mail: send PartnerUserInvited(email, url)
  and
  end
  API-->>UI: 200 success (or aggregated errors)
  UI->>UI: mutate lists, show toast
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

I nibble code beneath the moon,
Invites and roles — a tidy tune.
I stitch the sidebar, hop through flows,
Send carrot links where member grows.
Thump-thump, accept — the warren glows. 🥕🐇

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title “Invite team members on partners.dub.co” accurately summarizes the core addition of the feature—allowing users to invite team members through the partners.dub.co interface—and aligns directly with the extensive invite-related API and UI changes in this pull request. It is concise, clear, and specific enough for a teammate to understand the primary purpose at a glance. There is no extraneous detail or ambiguity, and it correctly reflects the main change introduced.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch partner-invite

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 952ffab and f237c3d.

📒 Files selected for processing (1)
  • apps/web/app/(ee)/api/partner-profile/invites/route.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/(ee)/api/partner-profile/invites/route.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build

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 and usage tips.

…components for accepting invites via modal and page.
@steven-tey
Copy link
Collaborator

@coderabbitai full review

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (1)

56-68: Consider stricter typing for query parameters.

Line 62 uses as Record<string, any>, which is too permissive. Since URLSearchParams accepts strings, use Record<string, string> instead.

Apply this diff to improve type safety:

     } as Record<string, any>,
+    } as Record<string, string>,
apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (1)

296-308: Approve the currentArea logic; consider simplifying dependencies.

The currentArea resolution correctly maps /profile paths to the profile area and maintains proper precedence order.

However, the programSlug dependency is redundant since isEnrolledProgramPage already captures changes to programSlug. This causes unnecessary re-computation but does not affect correctness.

Apply this diff to simplify the dependencies:

 const currentArea = useMemo(() => {
   return pathname.startsWith("/account/settings")
     ? "userSettings"
     : pathname.startsWith("/settings")
       ? "partnerSettings"
       : pathname.startsWith("/profile")
         ? "profile"
         : ["/payouts", "/messages"].some((p) => pathname.startsWith(p))
           ? null
           : isEnrolledProgramPage
             ? "program"
             : "programs";
-}, [pathname, programSlug, isEnrolledProgramPage]);
+}, [pathname, isEnrolledProgramPage]);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 05f9ad8 and 326016e.

📒 Files selected for processing (4)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (1 hunks)
  • apps/web/lib/middleware/utils/partners-redirect.ts (1 hunks)
  • apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (3 hunks)
  • apps/web/ui/modals/remove-partner-user-modal.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/ui/modals/remove-partner-user-modal.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-10-15T01:05:43.230Z
Learnt from: steven-tey
PR: dubinc/dub#2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.230Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.

Applied to files:

  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx
🧬 Code graph analysis (1)
apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (8)
apps/web/lib/swr/use-partner-profile.ts (1)
  • usePartnerProfile (6-30)
apps/web/lib/types.ts (1)
  • PartnerUserProps (445-445)
apps/web/ui/modals/invite-partner-member-modal.tsx (1)
  • useInvitePartnerMemberModal (164-184)
packages/utils/src/functions/time-ago.ts (1)
  • timeAgo (3-32)
packages/ui/src/table/table.tsx (2)
  • useTable (49-241)
  • Table (339-662)
apps/web/ui/modals/update-partner-user-modal.tsx (1)
  • useUpdatePartnerUserModal (114-141)
apps/web/ui/modals/remove-partner-user-modal.tsx (1)
  • useRemovePartnerUserModal (149-174)
packages/ui/src/popover.tsx (1)
  • Popover (25-102)
🔇 Additional comments (6)
apps/web/lib/middleware/utils/partners-redirect.ts (1)

4-5: LGTM! Consolidation of notification settings paths.

The updated and new redirect entries consolidate notification settings from different legacy paths to a unified /profile/notifications endpoint, aligning with the partner profile restructuring.

apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (3)

126-181: LGTM: Column definitions are well-structured.

The column definitions are clean, and the extraction of RoleCell and RowMenuButton into separate components correctly addresses the previous hooks violations.


261-317: LGTM: State management and permissions handled correctly.

The component properly:

  • Uses hooks at the top level (fixing the previous Rules of Hooks violation)
  • Syncs local state with the prop via useEffect (addressing the previous state sync concern)
  • Enforces role-change permissions with clear user feedback

The minor issue where the dropdown doesn't revert on modal cancel (noted in past reviews) remains but is acceptable as it self-recovers on data refresh.


319-384: LGTM: Permission checks properly gate menu visibility.

The visibility logic correctly ensures only owners or the current user (for self-removal) can access the menu actions. The contextual labels ("Leave partner team" vs "Remove member" vs "Revoke invitation") provide clear UX.

apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (2)

22-22: LGTM!

The Users2 icon import is correctly added and appropriately used for the Members navigation item.


114-144: Well-structured profile navigation area.

The profile area implementation follows established patterns:

  • Appropriate use of exact: true for the Profile item to prevent matching child routes
  • Logical grouping of Profile/Members vs Account items
  • Consistent icon choices and naming conventions

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 326016e and 07758a9.

📒 Files selected for processing (11)
  • apps/web/app/(ee)/api/cron/payouts/process/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/invites/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/payouts/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/users/route.ts (1 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts (1 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts (1 hunks)
  • apps/web/lib/actions/create-oauth-url.ts (1 hunks)
  • apps/web/lib/actions/partners/confirm-payouts.ts (1 hunks)
  • apps/web/ui/modals/edit-role-modal.tsx (2 hunks)
  • apps/web/ui/modals/remove-partner-user-modal.tsx (1 hunks)
  • apps/web/ui/modals/update-partner-user-modal.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/ui/modals/update-partner-user-modal.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-10-06T15:48:14.205Z
Learnt from: TWilson023
PR: dubinc/dub#2935
File: apps/web/lib/actions/partners/invite-partner-from-network.ts:21-28
Timestamp: 2025-10-06T15:48:14.205Z
Learning: For the network invites limit check in apps/web/lib/actions/partners/invite-partner-from-network.ts, the team accepts that concurrent invites may bypass the limit due to race conditions. Perfect atomicity is not required for this feature.

Applied to files:

  • apps/web/app/(ee)/api/partner-profile/invites/route.ts
🧬 Code graph analysis (3)
apps/web/ui/modals/remove-partner-user-modal.tsx (2)
apps/web/lib/types.ts (1)
  • PartnerUserProps (445-445)
apps/web/lib/swr/use-partner-profile.ts (1)
  • usePartnerProfile (6-30)
apps/web/app/(ee)/api/partner-profile/users/route.ts (5)
apps/web/lib/auth/partner.ts (1)
  • withPartnerProfile (29-117)
apps/web/lib/zod/schemas/partner-profile.ts (2)
  • getPartnerUsersQuerySchema (165-168)
  • partnerUserSchema (170-177)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
apps/web/lib/auth/partner-user-permissions.ts (1)
  • throwIfNoPermission (35-52)
apps/web/app/(ee)/api/partner-profile/invites/route.ts (7)
apps/web/lib/auth/partner.ts (1)
  • withPartnerProfile (29-117)
apps/web/lib/zod/schemas/partner-profile.ts (3)
  • getPartnerUsersQuerySchema (165-168)
  • partnerUserSchema (170-177)
  • invitePartnerMemberSchema (157-163)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/auth/partner-user-permissions.ts (1)
  • throwIfNoPermission (35-52)
apps/web/lib/partners/constants.ts (2)
  • MAX_INVITES_PER_REQUEST (17-17)
  • MAX_PARTNER_USERS (18-18)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
apps/web/lib/api/partners/invite-partner-user.ts (1)
  • invitePartnerUser (11-70)
🔇 Additional comments (7)
apps/web/lib/actions/create-oauth-url.ts (1)

3-3: Named import for zod is compatible
Project uses zod v3.x, which supports import { z } from "zod". No further changes needed.

apps/web/app/(ee)/api/cron/payouts/process/route.ts (1)

7-7: LGTM: Import style updated.

The switch to named import aligns with modern zod best practices.

apps/web/lib/actions/partners/confirm-payouts.ts (1)

14-14: LGTM: Consistent import style.

Named import matches the pattern used across other updated files.

apps/web/app/(ee)/api/partner-profile/payouts/route.ts (1)

8-8: LGTM: Import standardized.

apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts (1)

11-11: LGTM: Import style aligned.

apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts (2)

10-10: LGTM: Import consistently updated.


10-10: Zod import consistency verified
No default import z from 'zod' remain in .ts/.tsx files.

@steven-tey
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 15, 2025

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (1)
apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (1)

147-177: DELETE returns PromiseSettledResult instead of deletion payload

You’re returning the settled wrapper, not the deleted record. Also no error handling if the deletion failed.

-    const [response] = await Promise.allSettled([
+    const [userDeletion, _tokenDeletion] = await Promise.allSettled([
       prisma.projectUsers.delete({
         where: {
           userId_projectId: {
             projectId: workspace.id,
             userId,
           },
         },
       }),
 
       // Remove tokens associated with the user from the workspace
       prisma.restrictedToken.deleteMany({
         where: {
           projectId: workspace.id,
           userId,
         },
       }),
     ]);
 
-    return NextResponse.json(response);
+    if (userDeletion.status !== "fulfilled") {
+      throw new DubApiError({
+        code: "bad_request",
+        message: "Failed to remove user from workspace.",
+      });
+    }
+    return NextResponse.json(userDeletion.value);
♻️ Duplicate comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (1)

13-13: Use schema-derived type instead of Prisma enum for role annotations.

Importing PartnerRole directly from @dub/prisma/client can cause type incompatibility issues if the workspace schema uses a different enum definition from @prisma/client. This was flagged in a previous review, and the issue remains unresolved.

Apply this diff to use the schema-derived type:

-import { PartnerRole } from "@dub/prisma/client";
 import {
   Avatar,
   Button,

Then update the type annotations on lines 58, 303, and 338:

-  const roleFilter = searchParams.get("role") as PartnerRole | null;
+  const roleFilter = searchParams.get("role") as WorkspaceUserProps["role"] | null;
-  const [role, setRole] = useState<PartnerRole>(user.role);
+  const [role, setRole] = useState<WorkspaceUserProps["role"]>(user.role);
-          const newRole = e.target.value as PartnerRole;
+          const newRole = e.target.value as WorkspaceUserProps["role"];

Also applies to: 58-58, 303-303, 338-338

apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (1)

278-283: Reset role on modal cancel by forwarding onClose
Pass an onClose: () => setRole(user.role) to useUpdatePartnerUserModal, extend its signature to accept onClose, and ensure UpdatePartnerUserModal invokes onClose on dismiss.

🧹 Nitpick comments (7)
apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (4)

23-31: Import Icon as a type-only import to avoid bundling a non-value export

Icon is a type; importing it as a value may bloat bundles. Use a type-only import.

Apply this diff:

-import {
-  CircleCheck,
-  CircleDotted,
-  Dots,
-  EnvelopeArrowRight,
-  Icon,
-  User,
-  UserCrown,
-} from "@dub/ui/icons";
+import {
+  CircleCheck,
+  CircleDotted,
+  Dots,
+  EnvelopeArrowRight,
+  User,
+  UserCrown,
+} from "@dub/ui/icons";

Then add this import (type-only):

import type { Icon } from "@dub/ui/icons";

200-206: Reset pagination when filters change

Changing filters while keeping the old page can show empty results. Clear "page" on filter select.

  const onSelect = (key: string, value: any) => {
    queryParams({
      set: {
        [key]: value,
      },
+     del: ["page"],
    });
  };

95-105: Minor: Remove conflicting bg- classes on status icons*

bg-green-100/bg-blue-100 are overridden by bg-transparent. Drop the redundant class.

-  icon: (
-    <CircleCheck className="size-4 bg-green-100 bg-transparent text-green-600" />
-  ),
+  icon: <CircleCheck className="size-4 text-green-600" />,
-  icon: (
-    <EnvelopeArrowRight className="size-4 bg-blue-100 bg-transparent text-blue-600" />
-  ),
+  icon: <EnvelopeArrowRight className="size-4 text-blue-600" />,

377-383: Add accessible label to the row actions button

Provide an aria-label/title for screen readers.

 <Button
   type="button"
   className="h-8 whitespace-nowrap px-2 disabled:border-transparent disabled:bg-transparent"
   variant="outline"
+  aria-label="Member actions"
+  title="Member actions"
   icon={<Dots className="h-4 w-4 shrink-0" />}
 />
apps/web/app/api/workspaces/[idOrSlug]/invites/route.ts (1)

24-26: Make email search case-insensitive

Improve UX and consistency by adding mode: "insensitive".

-        ...(search && {
-          email: { contains: search },
-        }),
+        ...(search && {
+          email: { contains: search, mode: "insensitive" },
+        }),
apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (2)

23-27: Use case-insensitive search for name/email

Add mode: "insensitive" to both conditions.

-              { name: { contains: search } },
-              { email: { contains: search } },
+              { name: { contains: search, mode: "insensitive" } },
+              { email: { contains: search, mode: "insensitive" } },

51-58: Avoid hard-coded role names in validation message

The errorMap text (“owner”/“member”) will drift if WorkspaceRole changes. Either derive from enum values or use a generic message.

Example:

-  role: z.nativeEnum(WorkspaceRole, {
-    errorMap: () => ({
-      message: `Role must be either "owner" or "member".`,
-    }),
-  }),
+  role: z.nativeEnum(WorkspaceRole, {
+    errorMap: (_issue, _ctx) => ({
+      // Keep generic to avoid drift; or compute from Object.values(WorkspaceRole)
+      message: "Invalid role.",
+    }),
+  }),
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 07758a9 and 0d8ac01.

📒 Files selected for processing (7)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (1 hunks)
  • apps/web/app/api/workspaces/[idOrSlug]/invites/route.ts (2 hunks)
  • apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (1 hunks)
  • apps/web/ui/modals/remove-partner-user-modal.tsx (1 hunks)
  • apps/web/ui/modals/update-partner-user-modal.tsx (1 hunks)
  • apps/web/ui/modals/update-workspace-user-role.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/ui/modals/update-partner-user-modal.tsx
  • apps/web/ui/modals/remove-partner-user-modal.tsx
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-10-15T01:05:43.230Z
Learnt from: steven-tey
PR: dubinc/dub#2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.230Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.

Applied to files:

  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx
📚 Learning: 2025-10-15T01:52:37.030Z
Learnt from: steven-tey
PR: dubinc/dub#2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.030Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.

Applied to files:

  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx
  • apps/web/ui/modals/update-workspace-user-role.tsx
🧬 Code graph analysis (5)
apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (10)
apps/web/lib/swr/use-partner-profile.ts (1)
  • usePartnerProfile (6-30)
apps/web/lib/types.ts (1)
  • PartnerUserProps (445-445)
apps/web/ui/modals/invite-partner-member-modal.tsx (1)
  • useInvitePartnerMemberModal (164-184)
packages/utils/src/functions/time-ago.ts (1)
  • timeAgo (3-32)
packages/ui/src/table/table.tsx (2)
  • useTable (49-241)
  • Table (339-662)
apps/web/ui/modals/update-partner-user-modal.tsx (1)
  • useUpdatePartnerUserModal (117-144)
apps/web/ui/modals/remove-partner-user-modal.tsx (1)
  • useRemovePartnerUserModal (151-176)
packages/ui/src/popover.tsx (1)
  • Popover (25-102)
packages/ui/src/menu-item.tsx (1)
  • MenuItem (43-86)
packages/ui/src/icons/index.tsx (1)
  • Icon (80-80)
apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (3)
apps/web/app/api/workspaces/[idOrSlug]/invites/route.ts (1)
  • GET (16-43)
apps/web/app/(ee)/api/partner-profile/users/route.ts (1)
  • GET (15-55)
apps/web/lib/zod/schemas/workspaces.ts (2)
  • getWorkspaceUsersQuerySchema (200-203)
  • workspaceUserSchema (205-213)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (11)
apps/web/ui/modals/invite-teammate-modal.tsx (1)
  • useInviteTeammateModal (73-97)
apps/web/ui/modals/invite-code-modal.tsx (1)
  • useInviteCodeModal (66-85)
apps/web/lib/types.ts (1)
  • WorkspaceUserProps (235-235)
packages/utils/src/functions/time-ago.ts (1)
  • timeAgo (3-32)
packages/ui/src/table/table.tsx (2)
  • useTable (49-241)
  • Table (339-662)
apps/web/lib/api/tokens/permissions.ts (1)
  • clientAccessCheck (41-65)
apps/web/ui/modals/update-workspace-user-role.tsx (1)
  • useWorkspaceUserRoleModal (118-146)
apps/web/ui/modals/remove-teammate-modal.tsx (1)
  • useRemoveTeammateModal (171-198)
packages/ui/src/popover.tsx (1)
  • Popover (25-102)
packages/ui/src/menu-item.tsx (1)
  • MenuItem (43-86)
packages/ui/src/icons/index.tsx (1)
  • Icon (80-80)
apps/web/ui/modals/update-workspace-user-role.tsx (1)
apps/web/lib/types.ts (1)
  • UserProps (237-249)
apps/web/app/api/workspaces/[idOrSlug]/invites/route.ts (6)
apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (2)
  • GET (14-49)
  • PATCH (61-83)
apps/web/app/(ee)/api/partner-profile/invites/route.ts (2)
  • GET (22-44)
  • PATCH (172-213)
apps/web/lib/auth/workspace.ts (1)
  • withWorkspace (42-436)
apps/web/lib/zod/schemas/workspaces.ts (2)
  • getWorkspaceUsersQuerySchema (200-203)
  • workspaceUserSchema (205-213)
packages/prisma/client.ts (1)
  • PartnerRole (21-21)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (3)
apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (1)

156-169: LGTM: Hooks extracted to proper components

Moving role UI into RoleCell resolves the Rules of Hooks violations in cell renderers. Solid.

Also applies to: 263-283

apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (1)

36-41: Good fix preserving membership timestamp

createdAt now correctly reflects when the user joined the workspace. LGTM.

apps/web/app/api/workspaces/[idOrSlug]/invites/route.ts (1)

12-12: Verify invite model and role enum in Prisma schema
I can’t locate a ProjectInvite model or PartnerRole/WorkspaceRole enums in your Prisma schema. Confirm the actual invite model name and its role enum (e.g. WorkspaceInvite + WorkspaceRole), then update the import:

-import { PartnerRole } from "@prisma/client";
+import { WorkspaceRole } from "@prisma/client";

and the Zod schema:

- role: z.nativeEnum(PartnerRole),
+ role: z.nativeEnum(WorkspaceRole),

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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 (3)
apps/web/ui/modals/invite-teammate-modal.tsx (1)

25-25: Remove unused logo variable.

The logo variable is destructured but never used in the component. Since the logo is no longer rendered in the header, this variable should be removed to avoid confusion.

Apply this diff to remove the unused variable:

-  const { id: workspaceId, plan, logo } = useWorkspace();
+  const { id: workspaceId, plan } = useWorkspace();
apps/web/ui/layout/sidebar/payout-stats.tsx (1)

45-60: Potential null pointer exception.

The condition partner && !partner.payoutsEnabledAt assumes partner is available, but if usePartnerProfile() returns undefined during loading, the rest of the component will still render with partner being undefined, which could cause issues.

Consider adding a loading state check at the component level:

 export const PayoutStats = memo(() => {
   const { partner } = usePartnerProfile();
+  
+  if (!partner) {
+    return (
+      <AnimatedSizeContainer height>
+        <div className="border-border-default grid gap-3 border-t p-3">
+          <div className="h-20 animate-pulse rounded-md bg-neutral-200" />
+        </div>
+      </AnimatedSizeContainer>
+    );
+  }

   const { payoutsCount } = usePartnerPayoutsCount<PayoutsCount[]>({
apps/web/app/api/workspaces/[idOrSlug]/invites/route.ts (1)

12-140: Validate workspace invites against WorkspaceRole

This endpoint manages workspace invites, but the schema uses PartnerRole. Besides type mismatches with Prisma (the column is WorkspaceRole), this will reject future workspace roles that don't exist in the partner enum. Please import WorkspaceRole and use it in updateInviteRoleSchema.

-import { PartnerRole } from "@prisma/client";
+import { WorkspaceRole } from "@prisma/client";
@@
-  role: z.nativeEnum(PartnerRole),
+  role: z.nativeEnum(WorkspaceRole),
♻️ Duplicate comments (16)
apps/web/lib/api/partners/invite-partner-user.ts (1)

25-41: Re-throw non-P2002 Prisma errors to prevent broken invites.

The catch block only handles the unique-constraint error (P2002) but silently swallows all other Prisma errors. If prisma.partnerInvite.create fails for any other reason (connection loss, constraint violation, etc.), the code will still create a verification token and send the email, leaving the recipient with a broken invite link.

Apply this diff to safely check the error type and re-throw unexpected errors:

   } catch (error) {
-    if (error.code === "P2002") {
+    if (error && typeof error === "object" && "code" in error && error.code === "P2002") {
       throw new DubApiError({
         code: "conflict",
         message: "User has already been invited to this partner profile.",
       });
     }
+    // Re-throw unexpected errors to abort the operation
+    throw error;
   }
apps/web/ui/partners/online-presence-form.tsx (1)

104-108: The partner/currentPartner inconsistency remains unresolved.

This concern was raised in a previous review: the component receives a partner prop (used for form defaults) but fetches currentPartner separately (used for permissions). If these refer to different entities, the permission check and form data will be out of sync.

Consider using a single source of truth:

  • Option A: Remove the partner prop and use only currentPartner throughout
  • Option B: Assert partner.id === currentPartner.id at mount and throw/warn if they differ
  • Option C: Document why both are needed if this is intentional
apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts (1)

68-68: Use loose equality to handle both null and undefined.

NextAuth sessions may have defaultPartnerId set to undefined rather than null. This strict equality check (=== null) will not trigger the update when defaultPartnerId is undefined, leaving the user without a default partner ID after accepting the invite.

Apply this diff:

-    if (session.user["defaultPartnerId"] === null) {
+    if (session.user["defaultPartnerId"] == null) {
       const currentUser = await tx.user.findUnique({
apps/web/ui/modals/update-partner-user-modal.tsx (1)

60-61: Add type guard for error handling.

The caught error might not be an Error instance, so accessing .message directly can fail at runtime.

Apply this diff to add proper type checking:

     } catch (error) {
-      toast.error(error.message || "Failed to update role.");
+      toast.error(error instanceof Error ? error.message : "Failed to update role.");
     } finally {
apps/web/lib/api/get-workspace-users.ts (1)

2-2: Verify that all callers use WorkspaceRole enum values.

The type changes from Role to WorkspaceRole are correct. However, a previous review identified that apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts (line 96) uses a string literal "owner" instead of WorkspaceRole.owner. Ensure all callers have been updated to use the enum.

Also applies to: 10-10, 16-16

apps/web/lib/zod/schemas/workspaces.ts (1)

3-3: Address enum identity and coupling concerns from previous review.

Previous reviews identified two issues that remain unaddressed:

  1. Enum identity split: Importing WorkspaceRole from @prisma/client instead of @dub/prisma/client can cause TypeScript assignability issues if other files import from the latter.

  2. Prisma coupling: Using z.nativeEnum(WorkspaceRole) couples these schemas to Prisma enums. The previous review suggested using roleSchema for better decoupling and consistency.

Please review and apply the previously suggested fixes to avoid these issues.

Based on past review comments, consider this refactor:

-import { WorkspaceRole } from "@prisma/client";
+// Use @dub/prisma/client for consistent enum identity, or use roleSchema for decoupling

 export const getWorkspaceUsersQuerySchema = z.object({
   search: z.string().optional(),
-  role: z.nativeEnum(WorkspaceRole).optional(),
+  role: roleSchema.optional(),
 });

 export const workspaceUserSchema = z.object({
   id: z.string(),
   name: z.string(),
   email: z.string().nullish(),
   image: z.string().nullish(),
-  role: z.nativeEnum(WorkspaceRole),
+  role: roleSchema,
   isMachine: z.boolean().default(false),
   createdAt: z.date(),
 });

Also applies to: 200-203, 205-213

apps/web/app/(ee)/partners.dub.co/(auth)/invite/page.tsx (2)

22-40: Propagate the invite token to the accept API

The backend cannot verify the invite without the URL token, so the acceptance POST will fail for email flows. Thread the token through the request and guard the button until it exists.

-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
@@
 export default function AcceptPartnerInvitePage() {
   const router = useRouter();
+  const searchParams = useSearchParams();
+  const token = searchParams.get("token");
@@
     try {
-      const response = await fetch("/api/partner-profile/invites/accept", {
+      const acceptUrl = token
+        ? `/api/partner-profile/invites/accept?token=${encodeURIComponent(
+            token,
+          )}`
+        : "/api/partner-profile/invites/accept";
+      const response = await fetch(acceptUrl, {
         method: "POST",
         headers: {
           "Content-Type": "application/json",
         },
       });
@@
         <div className="mt-8">
           <Button
             text="Accept invite"
             onClick={acceptInvite}
             loading={accepting}
-            className="w-full"
+            disabled={!token}
+            className="w-full"
           />
         </div>

38-41: Handle unknown errors in catch properly

error is unknown, so this block fails TypeScript checks and never resets state if a non-Error is thrown. Guard the type and move the reset into a finally.

-    } catch (error) {
-      setAccepting(false);
-      toast.error(error.message || "Failed to accept invite.");
-    }
+    } catch (err) {
+      toast.error(
+        err instanceof Error ? err.message : "Failed to accept invite.",
+      );
+    } finally {
+      setAccepting(false);
+    }
apps/web/ui/modals/accept-partner-invite-modal.tsx (1)

25-48: Reset loading and harden error handling for invite acceptance

accepting never resets after a successful accept, so the modal reopens stuck in a loading state. On top of that, we assume the error body is JSON with .message and that caught values are always Error, so non‑JSON responses or thrown strings crash the handler and mask the real failure. Please move the state reset into a finally branch and narrow the caught error while parsing the response defensively.

   const handleAcceptInvite = async () => {
     setAccepting(true);

     try {
       const response = await fetch(`/api/partner-profile/invites/accept`, {
         method: "POST",
         headers: {
           "Content-Type": "application/json",
         },
       });

       if (!response.ok) {
-        const error = await response.json();
-        throw new Error(error.message);
+        const data = await response.json().catch(() => null);
+        throw new Error(data?.message ?? response.statusText);
       }

       await mutatePrefix("/api/partner-profile");
       router.replace("/programs");
       setShowAcceptPartnerInviteModal(false);
       toast.success("You are now a member of this partner profile!");
-    } catch (error) {
-      setAccepting(false);
-      toast.error(error.message || "Failed to accept invite.");
-    }
+    } catch (error: unknown) {
+      const message =
+        error instanceof Error ? error.message : "Failed to accept invite.";
+      toast.error(message);
+    } finally {
+      setAccepting(false);
+    }
   };
apps/web/ui/modals/update-workspace-user-role.tsx (2)

118-145: Expose an onClose hook to revert parent state

RoleCell updates its local selection before opening this modal; without an onClose callback it cannot revert the value when the user cancels, so the dropdown stays on an unconfirmed role. Please extend useWorkspaceUserRoleModal to accept an optional onClose, track when the modal closes, and invoke the callback so parents can reset their local state.
Based on learnings

-export function useWorkspaceUserRoleModal({
-  user,
-  role,
-}: {
-  user: UserProps;
-  role: "owner" | "member";
-}) {
+export function useWorkspaceUserRoleModal({
+  user,
+  role,
+  onClose,
+}: {
+  user: UserProps;
+  role: "owner" | "member";
+  onClose?: () => void;
+}) {
   const [showWorkspaceUserRoleModal, setShowWorkspaceUserRoleModal] =
     useState(false);
+  const wasOpen = useRef(false);
+
+  useEffect(() => {
+    if (showWorkspaceUserRoleModal) {
+      wasOpen.current = true;
+      return;
+    }
+
+    if (wasOpen.current) {
+      wasOpen.current = false;
+      onClose?.();
+    }
+  }, [showWorkspaceUserRoleModal, onClose]);

128-137: Fix stale props in modal callback

useCallback omits user and role, so the memoised component keeps rendering stale data when its inputs change. Add both to the dependency list to ensure the modal reflects the latest subject and patch payload.

-  }, [showWorkspaceUserRoleModal, setShowWorkspaceUserRoleModal]);
+  }, [showWorkspaceUserRoleModal, setShowWorkspaceUserRoleModal, user, role]);
apps/web/ui/modals/invite-partner-member-modal.tsx (2)

46-75: Trim outgoing emails before validation

Filtering with email.trim() keeps whitespace-only rows out of the payload, but you still send the original, untrimmed strings—so leading/trailing spaces hit the API and fail Zod validation. Map the entries to trimmed values before filtering so the server always receives clean addresses.

-    const invites = data.invites.filter(({ email }) => email.trim());
+    const invites = data.invites
+      .map(({ email, role }) => ({ email: email.trim(), role }))
+      .filter(({ email }) => email);

55-75: Make error handling resilient to non-JSON failures

We still assume the response body is JSON with { error: { message } } and that the caught value is an Error. When the backend returns plain text or throws something else, this path crashes and hides the real failure. Please guard the JSON parsing and narrow the caught value before accessing .message.

-      if (!response.ok) {
-        const { error } = await response.json();
-        throw new Error(error.message);
-      }
+      if (!response.ok) {
+        const fallback = "Failed to send invitations.";
+        const data = await response.json().catch(() => null);
+        throw new Error(
+          data?.error?.message ?? response.statusText ?? fallback,
+        );
+      }
@@
-    } catch (error) {
-      toast.error(error.message || "Failed to send invitations.");
+    } catch (error: unknown) {
+      const message =
+        error instanceof Error ? error.message : "Failed to send invitations.";
+      toast.error(message);
     } finally {
       setIsSubmitting(false);
     }
apps/web/app/(ee)/api/partner-profile/invites/route.ts (1)

215-247: Handle missing invite deletes gracefully

prisma.partnerInvite.delete throws P2025 when the invite was already removed (double-click, concurrent revoke). The uncaught error bubbles as a 500, making the route non-idempotent. Catch that case, convert it to a DubApiError with code: "not_found", and skip token deletion when nothing remains to delete.

-import { PartnerRole } from "@dub/prisma/client";
+import { PartnerRole, Prisma } from "@dub/prisma/client";
@@
-    await prisma.$transaction([
-      prisma.partnerInvite.delete({
-        where: {
-          email_partnerId: {
-            email,
-            partnerId: partner.id,
-          },
-        },
-      }),
-
-      prisma.verificationToken.deleteMany({
-        where: {
-          identifier: email,
-        },
-      }),
-    ]);
+    try {
+      await prisma.$transaction([
+        prisma.partnerInvite.delete({
+          where: {
+            email_partnerId: {
+              email,
+              partnerId: partner.id,
+            },
+          },
+        }),
+        prisma.verificationToken.deleteMany({
+          where: {
+            identifier: email,
+          },
+        }),
+      ]);
+    } catch (error) {
+      if (
+        error instanceof Prisma.PrismaClientKnownRequestError &&
+        error.code === "P2025"
+      ) {
+        throw new DubApiError({
+          code: "not_found",
+          message: "The invitation you're trying to revoke was not found.",
+        });
+      }
+      throw error;
+    }
apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (1)

36-41: Map the membership payload explicitly.

Spreading user back onto rest still pulls every field from the account record into the response and relies on the trailing createdAt override to fix the timestamp. This reintroduces the leakage we flagged earlier—consumers will see unrelated properties (and the next edit may again overwrite createdAt). Please revert to explicit mapping of the allowed fields (id, name, email, image, isMachine, plus the membership role/createdAt).

Apply this diff:

-    const parsedUsers = users.map(({ user, ...rest }) =>
-      workspaceUserSchema.parse({
-        ...rest,
-        ...user,
-        createdAt: rest.createdAt, // preserve the createdAt field from ProjectUsers
-      }),
-    );
+    const parsedUsers = users.map(({ user, role, createdAt }) =>
+      workspaceUserSchema.parse({
+        id: user.id,
+        name: user.name,
+        email: user.email,
+        image: user.image,
+        isMachine: user.isMachine,
+        role,
+        createdAt,
+      }),
+    );
apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (1)

278-305: Reset the RoleCell state when the modal closes.

After selecting a different role, canceling the confirmation modal leaves the dropdown showing the unconfirmed value. This mismatch hangs around until SWR revalidates, which confuses users. Wire the modal’s close/cancel path to setRole(user.role) (e.g., via an onClose callback or by watching the modal’s visibility) so the UI always reflects the confirmed backend state.
Based on learnings

🧹 Nitpick comments (5)
apps/web/app/(ee)/partners.dub.co/(dashboard)/auth.tsx (1)

6-6: Remove unused pathname or implement its intended logic.

The usePathname hook is imported and called, but the resulting pathname variable is never used in the component. This adds unnecessary code and a small runtime cost.

If pathname is needed for future redirect or conditional logic (e.g., invite flows), please complete the implementation. Otherwise, remove both the import and the variable declaration to keep the code clean.

Apply this diff to remove the unused code:

-import { redirect, usePathname, useSearchParams } from "next/navigation";
+import { redirect, useSearchParams } from "next/navigation";
 export function PartnerProfileAuth({ children }: { children: ReactNode }) {
-  const pathname = usePathname();
   const searchParams = useSearchParams();

Also applies to: 21-21

packages/email/src/templates/partner-user-invited.tsx (1)

18-22: Consider using more obviously fake default values.

The default values contain realistic-looking email addresses and a token string. For better security hygiene and to avoid confusion, consider using:

  • Standard test email formats (e.g., [email protected])
  • Clearly placeholder token values (e.g., EXAMPLE_TOKEN_HERE)

Example:

 export default function PartnerUserInvited({
-  email = "[email protected]",
-  url = "http://localhost:8888/api/auth/callback/email?callbackUrl=http%3A%2F%2Fapp.localhost%3A3000%2Flogin&token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&[email protected]",
+  email = "[email protected]",
+  url = "http://localhost:8888/api/auth/callback/email?callbackUrl=http%3A%2F%2Fapp.localhost%3A3000%2Flogin&token=EXAMPLE_TOKEN&[email protected]",
   partnerName = "Acme",
   partnerUser = "Brendon Urie",
-  partnerUserEmail = "[email protected]",
+  partnerUserEmail = "[email protected]",
 }: {
apps/web/ui/modals/remove-partner-user-modal.tsx (2)

40-40: Encode the userId query parameter.

Line 40 constructs the query string with user.id directly. While isInvite guards against null, encoding is still required for safe URL construction.

Apply this diff:

-          : `/api/partner-profile/users?userId=${user.id}`,
+          : `/api/partner-profile/users?userId=${encodeURIComponent(user.id!)}`,

47-50: Defensive error parsing could prevent runtime failures.

The error response parsing assumes the JSON structure includes error.message. If the response doesn't match this shape, line 49 will throw a TypeError.

Consider this more defensive approach:

       if (!response.ok) {
-        const { error } = await response.json();
-        throw new Error(error.message);
+        let message = response.statusText;
+        try {
+          const data = await response.json();
+          message = data?.error?.message ?? data?.message ?? message;
+        } catch {}
+        throw new Error(message || "Operation failed");
       }
apps/web/lib/middleware/partners.ts (1)

23-23: Refine the partner invite detection logic.

The current check req.nextUrl.pathname.endsWith("/invite") may match unintended paths like /some/other/invite. Consider using a more precise check such as path === "/invite" or a regex pattern to ensure it only matches the intended partner invite route.

Apply this diff to make the check more precise:

-  const isPartnerInvite = req.nextUrl.pathname.endsWith("/invite");
+  const isPartnerInvite = path === "/invite";
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ab2daec and 0d8ac01.

📒 Files selected for processing (74)
  • apps/web/app/(ee)/api/cron/payouts/process/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/invites/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/payouts/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/users/route.ts (1 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts (1 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(auth)/invite/page.tsx (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/auth.tsx (2 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-button.tsx (2 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx (7 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/how-you-work-form.tsx (7 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page.tsx (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx (4 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx (12 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/page.tsx (0 hunks)
  • apps/web/app/api/workspaces/[idOrSlug]/invites/route.ts (2 hunks)
  • apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/plan-usage.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/people/page-client.tsx (0 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (1 hunks)
  • apps/web/lib/actions/create-oauth-url.ts (1 hunks)
  • apps/web/lib/actions/partners/confirm-payouts.ts (1 hunks)
  • apps/web/lib/actions/partners/create-bounty-submission.ts (2 hunks)
  • apps/web/lib/actions/partners/generate-paypal-oauth-url.ts (1 hunks)
  • apps/web/lib/actions/partners/generate-stripe-account-link.ts (2 hunks)
  • apps/web/lib/actions/partners/retry-failed-paypal-payouts.ts (2 hunks)
  • apps/web/lib/actions/partners/update-partner-payout-settings.ts (2 hunks)
  • apps/web/lib/actions/partners/update-partner-profile.ts (2 hunks)
  • apps/web/lib/actions/safe-action.ts (2 hunks)
  • apps/web/lib/api/get-workspace-users.ts (1 hunks)
  • apps/web/lib/api/partners/invite-partner-user.ts (1 hunks)
  • apps/web/lib/api/rbac/permissions.ts (3 hunks)
  • apps/web/lib/api/tokens/permissions.ts (3 hunks)
  • apps/web/lib/api/tokens/scopes.ts (6 hunks)
  • apps/web/lib/api/users.ts (2 hunks)
  • apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts (2 hunks)
  • apps/web/lib/auth/partner-user-permissions.ts (1 hunks)
  • apps/web/lib/auth/partner.ts (3 hunks)
  • apps/web/lib/middleware/partners.ts (3 hunks)
  • apps/web/lib/middleware/utils/app-redirect.ts (1 hunks)
  • apps/web/lib/middleware/utils/partners-redirect.ts (1 hunks)
  • apps/web/lib/partners/constants.ts (1 hunks)
  • apps/web/lib/swr/use-workspace-users.ts (1 hunks)
  • apps/web/lib/types.ts (5 hunks)
  • apps/web/lib/zod/schemas/invites.ts (1 hunks)
  • apps/web/lib/zod/schemas/misc.ts (1 hunks)
  • apps/web/lib/zod/schemas/partner-profile.ts (2 hunks)
  • apps/web/lib/zod/schemas/partners.ts (1 hunks)
  • apps/web/lib/zod/schemas/workspaces.ts (2 hunks)
  • apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (3 hunks)
  • apps/web/ui/layout/sidebar/payout-stats.tsx (1 hunks)
  • apps/web/ui/layout/toolbar/onboarding/onboarding-button.tsx (2 hunks)
  • apps/web/ui/links/use-link-filters.tsx (4 hunks)
  • apps/web/ui/modals/accept-partner-invite-modal.tsx (1 hunks)
  • apps/web/ui/modals/edit-role-modal.tsx (0 hunks)
  • apps/web/ui/modals/invite-code-modal.tsx (2 hunks)
  • apps/web/ui/modals/invite-partner-member-modal.tsx (1 hunks)
  • apps/web/ui/modals/invite-teammate-modal.tsx (3 hunks)
  • apps/web/ui/modals/remove-partner-user-modal.tsx (1 hunks)
  • apps/web/ui/modals/remove-teammate-modal.tsx (1 hunks)
  • apps/web/ui/modals/update-partner-user-modal.tsx (1 hunks)
  • apps/web/ui/modals/update-workspace-user-role.tsx (1 hunks)
  • apps/web/ui/partners/connect-payout-button.tsx (2 hunks)
  • apps/web/ui/partners/online-presence-form.tsx (20 hunks)
  • apps/web/ui/workspaces/invite-teammates-form.tsx (3 hunks)
  • packages/email/src/templates/partner-user-invited.tsx (1 hunks)
  • packages/prisma/client.ts (1 hunks)
  • packages/prisma/schema/partner.prisma (2 hunks)
  • packages/prisma/schema/program.prisma (1 hunks)
  • packages/prisma/schema/workspace.prisma (1 hunks)
💤 Files with no reviewable changes (3)
  • apps/web/ui/modals/edit-role-modal.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/people/page-client.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/page.tsx
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-10-08T21:33:23.531Z
Learnt from: TWilson023
PR: dubinc/dub#2936
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/add-hostname-modal.tsx:28-34
Timestamp: 2025-10-08T21:33:23.531Z
Learning: In the dub/ui Button component, when the `disabledTooltip` prop is set to a non-undefined value (e.g., a string), the button is automatically disabled. Therefore, it's not necessary to also add the same condition to the `disabled` prop—setting `disabledTooltip={permissionsError || undefined}` is sufficient to disable the button when there's a permissions error.

Applied to files:

  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx
📚 Learning: 2025-10-15T01:52:37.030Z
Learnt from: steven-tey
PR: dubinc/dub#2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.030Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.

Applied to files:

  • apps/web/ui/modals/update-workspace-user-role.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx
  • apps/web/ui/modals/update-partner-user-modal.tsx
📚 Learning: 2025-10-06T15:48:14.205Z
Learnt from: TWilson023
PR: dubinc/dub#2935
File: apps/web/lib/actions/partners/invite-partner-from-network.ts:21-28
Timestamp: 2025-10-06T15:48:14.205Z
Learning: For the network invites limit check in apps/web/lib/actions/partners/invite-partner-from-network.ts, the team accepts that concurrent invites may bypass the limit due to race conditions. Perfect atomicity is not required for this feature.

Applied to files:

  • apps/web/lib/partners/constants.ts
  • apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts
  • apps/web/app/(ee)/api/partner-profile/invites/route.ts
📚 Learning: 2025-09-24T16:13:00.387Z
Learnt from: TWilson023
PR: dubinc/dub#2872
File: packages/prisma/schema/partner.prisma:151-153
Timestamp: 2025-09-24T16:13:00.387Z
Learning: In the Dub codebase, Prisma schemas use single-column indexes without brackets (e.g., `@index(partnerId)`) and multi-column indexes with brackets (e.g., `@index([programId, partnerId])`). This syntax pattern is consistently used throughout their schema files and works correctly with their Prisma version.

Applied to files:

  • packages/prisma/schema/partner.prisma
📚 Learning: 2025-10-15T01:05:43.230Z
Learnt from: steven-tey
PR: dubinc/dub#2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.230Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.

Applied to files:

  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx
🔇 Additional comments (57)
apps/web/ui/modals/invite-teammate-modal.tsx (1)

44-46: LGTM! Styling improvements enhance modal consistency.

The padding and alignment adjustments create a more compact, readable modal layout:

  • Header padding now matches form padding horizontally
  • Left-aligned descriptive text improves readability
  • Reduced form padding creates better visual balance

Also applies to: 65-65

apps/web/lib/middleware/utils/app-redirect.ts (1)

49-52: Hardcoded /settings/people references removed; redirect logic is correct.

packages/email/src/templates/partner-user-invited.tsx (1)

30-81: LGTM! Well-structured email template.

The email template implementation is solid:

  • Proper conditional rendering for personalized vs. generic invitation messages
  • Clean URL display by stripping the protocol for readability
  • Accessible with appropriate alt text and descriptive link text
  • Consistent styling using Tailwind classes

The logic correctly handles cases where inviter information may or may not be available.

apps/web/lib/api/users.ts (1)

6-6: Approve WorkspaceRole default "member" is defined in the WorkspaceRole enum and matches the Prisma schema’s ProjectInvite.role default.

apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (3)

22-22: LGTM!

Icon import added correctly for the Members navigation item.


296-308: Members route (/profile/members) verified; corresponding page exists and routing logic is correct.


114-144: Clarify multiple Notifications routes are intentional.

There are distinct Notifications nav items at:

  • /profile/notifications (profile area)
  • /settings/notifications (partner settings)
  • /account/settings/notifications (user settings)

Confirm each serves a different context and consider renaming labels (e.g. “Profile Notifications” / “Partner Settings Notifications” / “Account Notifications”).

apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (1)

318-318: PartnerRole assertion is safe. Prisma schema defines PartnerRole as only owner or member, so the type assertion covers all possible values.

apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts (1)

10-10: LGTM: Zod import style updated.

The change from default to named import (import { z } from "zod") aligns with Zod v4 conventions and matches the project-wide migration pattern observed across multiple files.

apps/web/app/(ee)/api/cron/payouts/process/route.ts (1)

7-7: LGTM: Consistent with project-wide zod import migration.

Named import aligns with Zod v4 patterns and maintains consistency across the codebase.

apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts (1)

11-11: LGTM.

apps/web/lib/actions/create-oauth-url.ts (1)

3-3: LGTM.

apps/web/lib/actions/partners/confirm-payouts.ts (1)

14-14: LGTM.

apps/web/ui/modals/invite-code-modal.tsx (1)

2-2: LGTM: UI refinements improve modal consistency.

The removal of the Logo component and adjustments to spacing/padding create a more compact, consistent modal layout. The changes are purely presentational with no functional impact.

Also applies to: 31-40

apps/web/lib/auth/partner.ts (1)

24-25: Clean separation of partner data and user membership.

The refactor to separate partner data (profile information) from partnerUser data (role and userId) improves the API surface and makes the authorization model clearer.

apps/web/lib/auth/partner-user-permissions.ts (1)

16-27: Simple and clear permission model.

The role-permission mapping is straightforward: owners have full permissions, members have none. This minimal model is appropriate for the initial implementation.

Note: If you plan to add granular member permissions later (e.g., "member can view but not edit"), this structure will scale well.

apps/web/lib/types.ts (1)

440-445: Type definitions align with the partner-user separation model.

The updated PartnerProps and new PartnerUserProps cleanly separate partner profile data from user membership data, which aligns with the changes in apps/web/lib/auth/partner.ts.

apps/web/lib/api/tokens/permissions.ts (1)

1-1: LGTM! Type update aligns with WorkspaceRole refactor.

The change from Role to WorkspaceRole is consistent with the broader type system refactor across the codebase.

apps/web/app/(ee)/api/partner-profile/users/route.ts (2)

82-131: Robust role update with transaction and permission checks.

The PATCH handler correctly:

  • Enforces permissions before allowing role changes
  • Prevents self-role changes
  • Wraps the owner count check and update in a transaction to prevent TOCTOU races
  • Validates that the last owner cannot be demoted

147-196: Proper DELETE logic with transaction and permission enforcement.

The DELETE handler correctly:

  • Allows self-removal without permission checks
  • Enforces the users.delete permission for removing other users
  • Uses a transaction to prevent TOCTOU races on the last-owner check
  • Prevents deletion of the last owner
apps/web/ui/modals/update-partner-user-modal.tsx (2)

55-57: Cache invalidation correctly implemented.

Good fix! The SWR cache now properly invalidates the correct endpoint based on whether it's an invite or user update, ensuring the UI stays in sync.


117-144: LGTM!

The hook properly manages modal state and memoizes the component callback. The dependencies are correctly specified for both useCallback and useMemo.

apps/web/ui/modals/remove-teammate-modal.tsx (1)

152-152: LGTM!

Good use of fallback pattern to ensure a display name is always shown, consistent with similar patterns elsewhere in the codebase (e.g., update-partner-user-modal.tsx).

apps/web/app/(ee)/api/partner-profile/payouts/route.ts (1)

8-8: LGTM!

The change to a named import is consistent with Zod v4 conventions and makes the import more explicit. No functional impact.

apps/web/lib/actions/partners/update-partner-profile.ts (1)

51-54: LGTM!

The permission check is correctly placed before any update logic, properly gates access to profile updates, and aligns with the broader authorization model introduced in this PR.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/plan-usage.tsx (1)

6-6: LGTM!

The hook rename to useWorkspaceUsers aligns with the broader refactor to workspace-scoped user data handling while maintaining backward compatibility by preserving the local variable name.

Also applies to: 64-64

apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page.tsx (1)

1-5: LGTM!

Standard Next.js page component pattern that properly delegates to a client component, maintaining the separation between server and client rendering boundaries.

apps/web/ui/layout/sidebar/payout-stats.tsx (1)

21-21: No partner-null issues – guard prevents undefined access. The partner && !partner.payoutsEnabledAt checks already handle loading/undefined cases, so no runtime errors will occur.

packages/prisma/schema/program.prisma (1)

87-133: LGTM!

The formatting adjustments to the ProgramEnrollment model improve readability while preserving all field definitions, types, defaults, relations, and constraints.

apps/web/ui/partners/connect-payout-button.tsx (1)

88-90: LGTM!

The permission guard correctly hides the payout button for users without the payout_settings.update permission. The early return placement is appropriate and consistent with similar permission checks across payout-related flows.

apps/web/ui/layout/toolbar/onboarding/onboarding-button.tsx (1)

37-40: LGTM!

The migration from useUsers to useWorkspaceUsers is correctly implemented, including separate calls for users and invites lists. The loading states and task completion logic are properly updated to handle both data sources.

apps/web/lib/actions/partners/generate-stripe-account-link.ts (1)

18-21: LGTM!

The permission check is correctly placed before the Stripe account link generation logic, ensuring that only authorized users can proceed with payout settings updates. The use of throwIfNoPermission provides clear error feedback.

apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts (1)

7-7: LGTM!

The migration from Role to WorkspaceRole is correctly implemented. The import and usage at line 202 align with the broader workspace role refactoring across the codebase.

Also applies to: 202-202

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-button.tsx (1)

15-17: LGTM!

The permission guard correctly hides the payout settings UI for users without the payout_settings.update permission. The implementation is consistent with the similar guard in connect-payout-button.tsx.

packages/prisma/client.ts (1)

31-31: LGTM!

The export change from Role to WorkspaceRole correctly updates the public type surface to align with the Prisma schema enum rename and completes the workspace role refactoring across the codebase.

apps/web/lib/partners/constants.ts (1)

17-18: Approve invite and member limit constants

These limits are enforced in apps/web/ui/modals/invite-partner-member-modal.tsx and apps/web/app/(ee)/api/partner-profile/invites/route.ts; no further changes needed.

apps/web/lib/actions/partners/update-partner-payout-settings.ts (1)

3-3: LGTM! Permission gating implemented correctly.

The permission check is appropriately placed as an early gate before constructing invoice settings, preventing unauthorized access to payout configuration.

Also applies to: 15-15, 18-21

apps/web/lib/actions/partners/generate-paypal-oauth-url.ts (1)

3-3: LGTM! Consistent permission gating.

The permission check is correctly positioned before PayPal-specific validations, ensuring unauthorized users cannot proceed with OAuth URL generation.

Also applies to: 10-10, 12-15

apps/web/lib/actions/partners/retry-failed-paypal-payouts.ts (1)

3-3: LGTM! Permission check correctly gates retry operation.

The permission check appropriately gates the retry operation before validating payout state, preventing unauthorized retry attempts.

Also applies to: 19-19, 22-25

apps/web/lib/actions/partners/create-bounty-submission.ts (1)

16-16: LGTM! Enum migration is consistent.

The change from Role to WorkspaceRole aligns with the PR-wide refactor and is correctly applied.

Also applies to: 191-191

apps/web/ui/links/use-link-filters.tsx (3)

5-5: LGTM! Migration to workspace-scoped users is correct.

The switch from useUsers to useWorkspaceUsers aligns with the workspace-scoped user model used throughout the application.

Also applies to: 265-265


86-88: Type safety improvement: @ts-expect-error removed.

The removal of the @ts-expect-error comment indicates the type issue has been resolved. The mapping now correctly extracts id, name, image, and count properties.


281-292: Verify the fallback behavior change is intentional.

Previously, when users was unavailable, the function would build options from usersCount. Now it returns null instead. This changes the UI behavior when workspace users haven't loaded yet—the user filter will show no options instead of count-based placeholders.

Please confirm this behavior change is intentional and that the UI gracefully handles the null state.

apps/web/app/(ee)/api/partner-profile/route.ts (1)

5-9: Confirm overlapping fields are intentionally overridden by this spread order. The schemas share id, name, email, image, and createdAt; since partner is spread last, its values will overwrite those from partnerUser. Ensure this merge order matches the intended behavior.

apps/web/lib/middleware/partners.ts (2)

34-34: LGTM!

Good catch fixing the template literal. Using a regular string for startsWith is correct here.


48-54: LGTM!

The onboarding redirect logic properly considers the partner invite flow. Users in the invite flow will skip the onboarding redirect, which is the correct behavior.

apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/how-you-work-form.tsx (2)

25-27: LGTM!

The permission check is properly implemented. The disabled state correctly reflects whether the user has the partner_profile.update permission, providing appropriate access control for the form.


93-115: LGTM!

The checkbox handling correctly respects the disabled state by guarding the onChange handler with !disabled &&. The conditional styling also provides clear visual feedback to users.

apps/web/lib/zod/schemas/misc.ts (1)

11-13: LGTM!

The migration from a custom enum to Prisma's native WorkspaceRole enum using z.nativeEnum() is correct and ensures type consistency across the codebase.

apps/web/lib/zod/schemas/partner-profile.ts (1)

157-177: LGTM!

The new schemas for partner member management are well-structured and follow the existing patterns in this file. The use of z.nativeEnum(PartnerRole) is consistent with the codebase's migration to Prisma-native enums.

apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx (3)

81-83: LGTM!

The permission check is correctly implemented, ensuring that only users with the partner_profile.update permission can modify the profile settings.


137-157: LGTM!

The Discoverable toggle correctly respects the disabled state, both in the Switch component and the onChange handler guard. This provides consistent permission-based access control.


166-180: LGTM!

The merge accounts functionality is properly gated behind the permission check, with appropriate disabled styling and cursor behavior when the user lacks permissions.

apps/web/lib/zod/schemas/invites.ts (2)

1-1: LGTM!

The import of WorkspaceRole from @prisma/client aligns with the codebase's migration to Prisma-native enums.


8-8: LGTM!

The use of z.nativeEnum(WorkspaceRole) is consistent with the role type migration across the codebase and ensures proper type safety.

apps/web/ui/workspaces/invite-teammates-form.tsx (1)

137-141: Hardcoded role list matches Prisma enum. WorkspaceRole defines only owner and member, so the hardcoded options are complete.

apps/web/lib/swr/use-workspace-users.ts (1)

13-15: No remaining references to useUsers hook
All imports and usages of useUsers are removed; renaming to useWorkspaceUsers is safe.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (1)

302-306: Revert dropdown state when update modal closes.

After canceling the role-change modal, the select keeps the new value even though nothing was saved. That desynchronizes the UI from the real user.role and confuses users. Please reset the local role state when the modal closes—e.g., have useUpdatePartnerUserModal invoke an onClose callback that calls setRole(user.role), or wrap the setter so setShowUpdateUserModal(false) also restores the prop value. Based on learnings

apps/web/ui/modals/update-workspace-user-role.tsx (1)

15-145: Handle modal cancellation via onClose.

Without an onClose pathway the parent has no signal to revert its local role selection when the modal is dismissed, so the dropdown keeps showing an unconfirmed value. Please reintroduce the optional onClose callback and invoke it whenever the modal closes without saving, while preventing it from firing after a confirmed update.

-import {
-  Dispatch,
-  SetStateAction,
-  useCallback,
-  useMemo,
-  useState,
-} from "react";
+import {
+  Dispatch,
+  SetStateAction,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";-function WorkspaceUserRoleModal({
-  showWorkspaceUserRoleModal,
-  setShowWorkspaceUserRoleModal,
-  user,
-  role,
-}: {
-  showWorkspaceUserRoleModal: boolean;
-  setShowWorkspaceUserRoleModal: Dispatch<SetStateAction<boolean>>;
-  user: UserProps;
-  role: "owner" | "member";
-}) {
+function WorkspaceUserRoleModal({
+  showWorkspaceUserRoleModal,
+  setShowWorkspaceUserRoleModal,
+  user,
+  role,
+  onClose,
+}: {
+  showWorkspaceUserRoleModal: boolean;
+  setShowWorkspaceUserRoleModal: Dispatch<SetStateAction<boolean>>;
+  user: UserProps;
+  role: "owner" | "member";
+  onClose?: () => void;
+}) {
   const [editing, setEditing] = useState(false);
+  const lastActionRef = useRef<"confirm" | null>(null);-      await mutatePrefix(
-        `/api/workspaces/${id}/${isInvite ? "invites" : "users"}`,
-      );
-      setShowWorkspaceUserRoleModal(false);
+      await mutatePrefix(
+        `/api/workspaces/${id}/${isInvite ? "invites" : "users"}`,
+      );
+      lastActionRef.current = "confirm";
+      setShowWorkspaceUserRoleModal(false);
       toast.success(`Successfully updated the role to ${role}.`);
     } catch (error) {
       toast.error(error instanceof Error ? error.message : "An error occurred");
     } finally {
       setEditing(false);
+      lastActionRef.current = null;
     }
   };
 
+  useEffect(() => {
+    if (!showWorkspaceUserRoleModal) {
+      if (lastActionRef.current === "confirm") {
+        lastActionRef.current = null;
+        return;
+      }
+      onClose?.();
+    }
+  }, [showWorkspaceUserRoleModal, onClose]);-export function useWorkspaceUserRoleModal({
-  user,
-  role,
-}: {
-  user: UserProps;
-  role: "owner" | "member";
-}) {
+export function useWorkspaceUserRoleModal({
+  user,
+  role,
+  onClose,
+}: {
+  user: UserProps;
+  role: "owner" | "member";
+  onClose?: () => void;
+}) {-      <WorkspaceUserRoleModal
+      <WorkspaceUserRoleModal
         showWorkspaceUserRoleModal={showWorkspaceUserRoleModal}
         setShowWorkspaceUserRoleModal={setShowWorkspaceUserRoleModal}
         user={user}
         role={role}
+        onClose={onClose}
       />
     );
-  }, [showWorkspaceUserRoleModal, setShowWorkspaceUserRoleModal, user, role]);
+  }, [
+    showWorkspaceUserRoleModal,
+    setShowWorkspaceUserRoleModal,
+    user,
+    role,
+    onClose,
+  ]);
 
   return useMemo(
     () => ({
       setShowWorkspaceUserRoleModal,
       WorkspaceUserRoleModal: WorkspaceUserRoleModalCallback,
     }),
-    [setShowWorkspaceUserRoleModal, WorkspaceUserRoleModalCallback],
+    [setShowWorkspaceUserRoleModal, WorkspaceUserRoleModalCallback],
   );
 }

This keeps the dropdown in sync with confirmed roles while letting the parent reset on cancellation. Based on learnings.

🧹 Nitpick comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (1)

193-207: Remove filter state from getRowId to maintain stable row identity.

The getRowId function includes status and roleFilter in the row ID computation (line 199). This causes all row IDs to change whenever filters are modified, which defeats the purpose of stable row identities and leads to unnecessary component remounts and re-renders.

Row IDs should be based solely on the row's intrinsic identity.

Apply this diff:

     getRowId: (row) =>
-      `${row.id || row.email}-${status}-${roleFilter || "all"}`,
+      row.id || row.email,
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0d8ac01 and 565e6a6.

📒 Files selected for processing (5)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (1 hunks)
  • apps/web/ui/modals/remove-partner-user-modal.tsx (1 hunks)
  • apps/web/ui/modals/remove-workspace-user-modal.tsx (6 hunks)
  • apps/web/ui/modals/update-workspace-user-role.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/ui/modals/remove-partner-user-modal.tsx
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-10-15T01:05:43.230Z
Learnt from: steven-tey
PR: dubinc/dub#2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.230Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx
📚 Learning: 2025-10-15T01:52:37.030Z
Learnt from: steven-tey
PR: dubinc/dub#2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.030Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.

Applied to files:

  • apps/web/ui/modals/update-workspace-user-role.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx
🧬 Code graph analysis (4)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (11)
apps/web/ui/modals/invite-teammate-modal.tsx (1)
  • useInviteTeammateModal (73-97)
apps/web/ui/modals/invite-code-modal.tsx (1)
  • useInviteCodeModal (66-85)
packages/prisma/client.ts (1)
  • PartnerRole (21-21)
apps/web/lib/types.ts (1)
  • WorkspaceUserProps (235-235)
packages/utils/src/functions/time-ago.ts (1)
  • timeAgo (3-32)
apps/web/lib/api/tokens/permissions.ts (1)
  • clientAccessCheck (41-65)
apps/web/ui/modals/update-workspace-user-role.tsx (1)
  • useWorkspaceUserRoleModal (118-146)
apps/web/ui/modals/remove-workspace-user-modal.tsx (1)
  • useRemoveWorkspaceUserModal (172-199)
packages/ui/src/popover.tsx (1)
  • Popover (25-102)
packages/ui/src/menu-item.tsx (1)
  • MenuItem (43-86)
packages/ui/src/icons/index.tsx (1)
  • Icon (80-80)
apps/web/ui/modals/remove-workspace-user-modal.tsx (1)
apps/web/lib/types.ts (1)
  • UserProps (237-249)
apps/web/ui/modals/update-workspace-user-role.tsx (1)
apps/web/lib/types.ts (1)
  • UserProps (237-249)
apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (8)
apps/web/lib/swr/use-partner-profile.ts (1)
  • usePartnerProfile (6-30)
apps/web/lib/types.ts (1)
  • PartnerUserProps (445-445)
apps/web/ui/modals/invite-partner-member-modal.tsx (1)
  • useInvitePartnerMemberModal (164-184)
packages/utils/src/functions/time-ago.ts (1)
  • timeAgo (3-32)
packages/ui/src/table/table.tsx (2)
  • useTable (49-241)
  • Table (339-662)
apps/web/ui/modals/update-partner-user-modal.tsx (1)
  • useUpdatePartnerUserModal (117-144)
apps/web/ui/modals/remove-partner-user-modal.tsx (1)
  • useRemovePartnerUserModal (154-179)
packages/ui/src/icons/index.tsx (1)
  • Icon (80-80)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (1)

358-393: LGTM: Permission checks correctly implemented.

The menu visibility logic (lines 390-393) correctly restricts access:

  • Workspace owners can access all member menus (to remove members)
  • Non-owners can only access their own menu (to leave workspace)

The permission model aligns with expected workspace role semantics.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
apps/web/ui/modals/invite-partner-user-modal.tsx (1)

46-76: The issues flagged in the previous review remain unaddressed.

The submit handler still has the following problems:

  1. Email trimming: Line 47 filters based on email.trim() but doesn't actually trim the email values in the invite objects being sent
  2. Unsafe error parsing: Lines 61-64 don't handle non-JSON responses or missing error.message
  3. Type-unsafe catch: Lines 71-73 access error.message without verifying error is an Error instance

Apply the previously suggested fix:

-  const onSubmit = async (data: FormData) => {
-    const invites = data.invites.filter(({ email }) => email.trim());
+  const onSubmit = async (data: FormData) => {
+    const invites = data.invites
+      .map(({ email, role }) => ({ email: email.trim(), role }))
+      .filter(({ email }) => email);
     setIsSubmitting(true);
 
     try {
       if (invites.length === 0) {
         throw new Error("Please enter at least one email address.");
       }
 
       const response = await fetch("/api/partner-profile/invites", {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify(invites),
       });
 
       if (!response.ok) {
-        const { error } = await response.json();
-        throw new Error(error.message);
+        let message = response.statusText || "Failed to send invitations.";
+        try {
+          const data = await response.json();
+          message = data?.error?.message ?? message;
+        } catch {
+          // ignore non‑JSON error bodies
+        }
+        throw new Error(message);
       }
 
       await mutatePrefix("/api/partner-profile/invites");
       toast.success(
         `${pluralize("Invitation", invites.length)} sent successfully!`,
       );
       setShowInvitePartnerUserModal(false);
-    } catch (error) {
-      toast.error(error.message || "Failed to send invitations.");
+    } catch (error: unknown) {
+      const message =
+        error instanceof Error ? error.message : "Failed to send invitations.";
+      toast.error(message);
     } finally {
       setIsSubmitting(false);
     }
   };
🧹 Nitpick comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (1)

244-262: Consider extracting the permission check pattern.

The clientAccessCheck pattern with disabledTooltip is duplicated for both buttons. While not critical, extracting this could improve maintainability.

Example helper:

const getPermissionTooltip = (action: string, description: string) => {
  return clientAccessCheck({
    action: action as PermissionAction,
    role,
    customPermissionDescription: description,
  }).error || undefined;
};

Then use:

                 disabledTooltip={
-                  clientAccessCheck({
-                    action: "workspaces.write",
-                    role,
-                    customPermissionDescription: "invite new teammates",
-                  }).error || undefined
+                  getPermissionTooltip("workspaces.write", "invite new teammates")
                 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 565e6a6 and 952ffab.

📒 Files selected for processing (6)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (1 hunks)
  • apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (1 hunks)
  • apps/web/lib/zod/schemas/partner-profile.ts (2 hunks)
  • apps/web/ui/modals/invite-partner-user-modal.tsx (1 hunks)
  • apps/web/ui/modals/invite-workspace-user-modal.tsx (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-10-15T01:05:43.230Z
Learnt from: steven-tey
PR: dubinc/dub#2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.230Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx
🧬 Code graph analysis (5)
apps/web/ui/modals/invite-partner-user-modal.tsx (3)
apps/web/lib/zod/schemas/partner-profile.ts (1)
  • invitePartnerUserSchema (157-163)
apps/web/lib/swr/use-partner-profile.ts (1)
  • usePartnerProfile (6-30)
apps/web/lib/partners/constants.ts (1)
  • MAX_INVITES_PER_REQUEST (17-17)
apps/web/lib/zod/schemas/partner-profile.ts (1)
packages/prisma/client.ts (1)
  • PartnerRole (21-21)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx (8)
apps/web/ui/modals/invite-workspace-user-modal.tsx (1)
  • useInviteWorkspaceUserModal (73-102)
apps/web/ui/modals/invite-code-modal.tsx (1)
  • useInviteCodeModal (66-85)
apps/web/lib/types.ts (1)
  • WorkspaceUserProps (235-235)
packages/utils/src/functions/time-ago.ts (1)
  • timeAgo (3-32)
apps/web/lib/api/tokens/permissions.ts (1)
  • clientAccessCheck (41-65)
apps/web/ui/modals/update-workspace-user-role.tsx (1)
  • useWorkspaceUserRoleModal (118-146)
apps/web/ui/modals/remove-workspace-user-modal.tsx (1)
  • useRemoveWorkspaceUserModal (172-199)
packages/ui/src/icons/index.tsx (1)
  • Icon (80-80)
apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (2)
apps/web/app/api/workspaces/[idOrSlug]/invites/route.ts (1)
  • GET (16-43)
apps/web/lib/zod/schemas/workspaces.ts (2)
  • getWorkspaceUsersQuerySchema (200-203)
  • workspaceUserSchema (205-213)
apps/web/ui/modals/invite-workspace-user-modal.tsx (1)
apps/web/lib/zod/schemas/invites.ts (1)
  • Invite (13-13)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (6)
apps/web/app/api/workspaces/[idOrSlug]/users/route.ts (3)

37-44: LGTM! Timestamp preservation implemented correctly.

The explicit createdAt: rest.createdAt on line 42 ensures the membership timestamp is preserved after spreading the user object, addressing the previous concern. The fallback logic for name also handles edge cases appropriately.


62-85: LGTM!

The PATCH handler correctly updates user roles with proper validation, compound key usage, machine user filtering, and permission checks.


53-60: No changes needed. The error message already matches the WorkspaceRole enum values (owner, member).

apps/web/lib/zod/schemas/partner-profile.ts (1)

157-177: LGTM! Well-structured schemas.

The three new schemas follow established patterns in the codebase and provide appropriate validation. The use of PartnerRole enum, email validation, and nullable fields is consistent with similar schemas.

apps/web/ui/modals/invite-workspace-user-modal.tsx (1)

16-101: LGTM! Clean refactoring.

The renaming from InviteTeammateModal to InviteWorkspaceUserModal is consistent throughout, with all props, state variables, and hook exports updated appropriately. The UI simplifications in the header are also well-executed.

apps/web/ui/modals/invite-partner-user-modal.tsx (1)

123-127: Hardcoded roles align with Prisma PartnerRole enum; no changes needed.

@steven-tey steven-tey merged commit ed07b4f into main Oct 15, 2025
7 of 8 checks passed
@steven-tey steven-tey deleted the partner-invite branch October 15, 2025 04:27
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.

3 participants