-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Invite team members on partners.dub.co #2958
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds 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
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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
⏰ 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)
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. Comment |
…er role editing and removal modals.
…ity and modals for inviting and revoking members.
…abilities to the API and UI components.
… functionality in UI
…components for accepting invites via modal and page.
|
@coderabbitai full review |
There was a problem hiding this 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, useRecord<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
/profilepaths to the profile area and maintains proper precedence order.However, the
programSlugdependency is redundant sinceisEnrolledProgramPagealready captures changes toprogramSlug. 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
📒 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/notificationsendpoint, 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
RoleCellandRowMenuButtoninto 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: truefor the Profile item to prevent matching child routes- Logical grouping of Profile/Members vs Account items
- Consistent icon choices and naming conventions
There was a problem hiding this 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
📒 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 supportsimport { 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 defaultimport z from 'zod'remain in.ts/.tsxfiles.
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this 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 payloadYou’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
PartnerRoledirectly from@dub/prisma/clientcan 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 anonClose: () => setRole(user.role)touseUpdatePartnerUserModal, extend its signature to acceptonClose, and ensureUpdatePartnerUserModalinvokesonCloseon dismiss.
🧹 Nitpick comments (7)
apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx (4)
23-31: ImportIconas a type-only import to avoid bundling a non-value export
Iconis 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 changeChanging 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-100are overridden bybg-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 buttonProvide 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-insensitiveImprove 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/emailAdd 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 messageThe 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
📒 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.tsxapps/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.tsxapps/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 componentsMoving role UI into
RoleCellresolves 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 timestampcreatedAt 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 aProjectInvitemodel orPartnerRole/WorkspaceRoleenums 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),
There was a problem hiding this 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 unusedlogovariable.The
logovariable 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.payoutsEnabledAtassumespartneris available, but ifusePartnerProfile()returnsundefinedduring loading, the rest of the component will still render withpartnerbeingundefined, 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 WorkspaceRoleThis endpoint manages workspace invites, but the schema uses
PartnerRole. Besides type mismatches with Prisma (the column isWorkspaceRole), this will reject future workspace roles that don't exist in the partner enum. Please importWorkspaceRoleand use it inupdateInviteRoleSchema.-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. Ifprisma.partnerInvite.createfails 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
partnerprop (used for form defaults) but fetchescurrentPartnerseparately (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
partnerprop and use onlycurrentPartnerthroughout- Option B: Assert
partner.id === currentPartner.idat 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 bothnullandundefined.NextAuth sessions may have
defaultPartnerIdset toundefinedrather thannull. This strict equality check (=== null) will not trigger the update whendefaultPartnerIdisundefined, 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
Errorinstance, so accessing.messagedirectly 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
RoletoWorkspaceRoleare correct. However, a previous review identified thatapps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts(line 96) uses a string literal"owner"instead ofWorkspaceRole.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:
Enum identity split: Importing
WorkspaceRolefrom@prisma/clientinstead of@dub/prisma/clientcan cause TypeScript assignability issues if other files import from the latter.Prisma coupling: Using
z.nativeEnum(WorkspaceRole)couples these schemas to Prisma enums. The previous review suggested usingroleSchemafor 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 APIThe 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
errorisunknown, so this block fails TypeScript checks and never resets state if a non-Error is thrown. Guard the type and move the reset into afinally.- } 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
acceptingnever 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.messageand that caught values are alwaysError, so non‑JSON responses or thrown strings crash the handler and mask the real failure. Please move the state reset into afinallybranch 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
RoleCellupdates its local selection before opening this modal; without anonClosecallback it cannot revert the value when the user cancels, so the dropdown stays on an unconfirmed role. Please extenduseWorkspaceUserRoleModalto accept an optionalonClose, 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
useCallbackomitsuserandrole, 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 validationFiltering 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 failuresWe still assume the response body is JSON with
{ error: { message } }and that the caught value is anError. 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.deletethrowsP2025when 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 aDubApiErrorwithcode: "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
userback ontoreststill pulls every field from the account record into the response and relies on the trailingcreatedAtoverride to fix the timestamp. This reintroduces the leakage we flagged earlier—consumers will see unrelated properties (and the next edit may again overwritecreatedAt). Please revert to explicit mapping of the allowed fields (id,name,image,isMachine, plus the membershiprole/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 anonClosecallback 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 unusedpathnameor implement its intended logic.The
usePathnamehook is imported and called, but the resultingpathnamevariable is never used in the component. This adds unnecessary code and a small runtime cost.If
pathnameis 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.iddirectly. WhileisInviteguards 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 aspath === "/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
📒 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.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsxapps/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.tsapps/web/app/(ee)/api/partner-profile/invites/accept/route.tsapps/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.tsxapps/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/peoplereferences 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 theWorkspaceRoleenum and matches the Prisma schema’sProjectInvite.roledefault.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
Notificationsnav 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 onlyownerormember, 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
partnerdata (profile information) frompartnerUserdata (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
PartnerPropsand newPartnerUserPropscleanly separate partner profile data from user membership data, which aligns with the changes inapps/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
RoletoWorkspaceRoleis 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.deletepermission 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
useCallbackanduseMemo.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
useWorkspaceUsersaligns 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. Thepartner && !partner.payoutsEnabledAtchecks 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
ProgramEnrollmentmodel 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.updatepermission. 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
useUserstouseWorkspaceUsersis 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
throwIfNoPermissionprovides clear error feedback.apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts (1)
7-7: LGTM!The migration from
RoletoWorkspaceRoleis 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.updatepermission. The implementation is consistent with the similar guard inconnect-payout-button.tsx.packages/prisma/client.ts (1)
31-31: LGTM!The export change from
RoletoWorkspaceRolecorrectly 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 constantsThese 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
RoletoWorkspaceRolealigns 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
useUserstouseWorkspaceUsersaligns 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-errorcomment indicates the type issue has been resolved. The mapping now correctly extractsid,name,image, andcountproperties.
281-292: Verify the fallback behavior change is intentional.Previously, when
userswas unavailable, the function would build options fromusersCount. Now it returnsnullinstead. 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
nullstate.apps/web/app/(ee)/api/partner-profile/route.ts (1)
5-9: Confirm overlapping fields are intentionally overridden by this spread order. The schemas shareid,name,image, andcreatedAt; sincepartneris spread last, its values will overwrite those frompartnerUser. 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
startsWithis 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.updatepermission, 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
WorkspaceRoleenum usingz.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.updatepermission 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
WorkspaceRolefrom@prisma/clientaligns 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.WorkspaceRoledefines onlyownerandmember, so the hardcoded options are complete.apps/web/lib/swr/use-workspace-users.ts (1)
13-15: No remaining references touseUsershook
All imports and usages ofuseUsersare removed; renaming touseWorkspaceUsersis safe.
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx
Show resolved
Hide resolved
There was a problem hiding this 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
useUpdatePartnerUserModalinvoke anonClosecallback that callssetRole(user.role), or wrap the setter sosetShowUpdateUserModal(false)also restores the prop value. Based on learningsapps/web/ui/modals/update-workspace-user-role.tsx (1)
15-145: Handle modal cancellation viaonClose.Without an
onClosepathway 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 optionalonClosecallback 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
getRowIdfunction includesstatusandroleFilterin 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
📒 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.tsxapps/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.tsxapps/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.
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx
Show resolved
Hide resolved
There was a problem hiding this 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:
- Email trimming: Line 47 filters based on
email.trim()but doesn't actually trim the email values in the invite objects being sent- Unsafe error parsing: Lines 61-64 don't handle non-JSON responses or missing
error.message- Type-unsafe catch: Lines 71-73 access
error.messagewithout verifyingerroris an Error instanceApply 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
clientAccessCheckpattern withdisabledTooltipis 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
📒 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.createdAton line 42 ensures the membership timestamp is preserved after spreading the user object, addressing the previous concern. The fallback logic fornamealso 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 theWorkspaceRoleenum 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
PartnerRoleenum, 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
InviteTeammateModaltoInviteWorkspaceUserModalis 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.
Summary by CodeRabbit
New Features
Improvements
Bug Fixes