(() => {
+ // Add custom roles if they exist
+ if (teamRoles?.length > 0) {
+ const roles: MembershipOption[] = [];
+ // Add custom roles
+ teamRoles.forEach((role) => {
+ roles.push({
+ value: role.id,
+ label: role.name,
+ isCustomRole: true,
+ });
+ });
+
+ return roles;
+ }
+
const options: MembershipOption[] = [
{
value: MembershipRole.MEMBER,
@@ -128,20 +141,8 @@ export function EditForm({
});
}
- // Add custom roles if they exist
- if (customRoles.length > 0) {
- // Add custom roles
- customRoles.forEach((role) => {
- options.push({
- value: role.id,
- label: role.name,
- isCustomRole: true,
- });
- });
- }
-
return options;
- }, [t, isOwner, customRoles]);
+ }, [t, isOwner, teamRoles]);
const mutation = trpc.viewer.organizations.updateUser.useMutation({
onSuccess: () => {
@@ -219,7 +220,7 @@ export function EditForm({
- {customRoles.length > 0 ? (
+ {teamRoles?.length > 0 ? (
option.value === (selectedUser?.role ?? "MEMBER")
diff --git a/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts b/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts
index feb151c988..297896548d 100644
--- a/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts
@@ -1,4 +1,6 @@
+import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
+import { TeamRepository } from "@calcom/lib/server/repository/team";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -15,10 +17,41 @@ type ChangeMemberRoleOptions = {
};
export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOptions) => {
- if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
- // Only owners can award owner role.
- if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
- throw new TRPCError({ code: "UNAUTHORIZED" });
+ // Get team info to check if it's part of an organization
+ const teamRepo = new TeamRepository(prisma);
+ const team = await teamRepo.findById({ id: input.teamId });
+ if (!team) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
+ }
+
+ // Get the organization ID (either the team's parent or the team itself if it's an org)
+ const organizationId = team.parentId || input.teamId;
+
+ // Create role manager for this organization/team
+ const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
+
+ // Check permission to change roles
+ try {
+ await roleManager.checkPermissionToChangeRole(ctx.user.id, organizationId);
+ } catch (error) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: error instanceof Error ? error.message : "Unauthorized",
+ });
+ }
+
+ // For traditional role checks, fall back to existing logic
+ if (
+ typeof input.role === "string" &&
+ Object.values(MembershipRole).includes(input.role as MembershipRole)
+ ) {
+ // Traditional role assignment logic
+ if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
+ // Only owners can award owner role.
+ if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
const memberships = await prisma.membership.findMany({
where: {
teamId: input.teamId,
@@ -30,6 +63,10 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
const teamOwners = memberships.filter((m) => m.role === MembershipRole.OWNER);
const teamHasMoreThanOneOwner = teamOwners.length > 1;
+ if (!targetMembership) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Target membership not found" });
+ }
+
if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) {
throw new TRPCError({
code: "FORBIDDEN",
@@ -44,9 +81,12 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
});
}
+ // TODO(SEAN): Remove this logic once PBAC is rolled out as there is no concept of higher roles for custom roles. Only default.
if (
myMembership?.role === MembershipRole.ADMIN &&
input.memberId === ctx.user.id &&
+ typeof input.role === "string" &&
+ Object.values(MembershipRole).includes(input.role as MembershipRole) &&
input.role !== MembershipRole.MEMBER
) {
throw new TRPCError({
@@ -55,18 +95,28 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
});
}
- const membership = await prisma.membership.update({
+ // Use role manager to assign the role
+ try {
+ await roleManager.assignRole(input.memberId, input.teamId, input.role, targetMembership.id);
+ } catch (error) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error instanceof Error ? error.message : "Failed to assign role",
+ });
+ }
+
+ // Return updated membership
+ const updatedMembership = await prisma.membership.findUnique({
where: {
userId_teamId: { userId: input.memberId, teamId: input.teamId },
},
- data: {
- role: input.role,
- },
include: {
team: true,
user: true,
},
});
+
+ return updatedMembership;
};
export default changeMemberRoleHandler;
diff --git a/packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts b/packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts
index 1b0ad481a2..d8eb41aec2 100644
--- a/packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts
+++ b/packages/trpc/server/routers/viewer/teams/changeMemberRole.schema.ts
@@ -5,7 +5,7 @@ import { MembershipRole } from "@calcom/prisma/enums";
export const ZChangeMemberRoleInputSchema = z.object({
teamId: z.number(),
memberId: z.number(),
- role: z.nativeEnum(MembershipRole),
+ role: z.union([z.nativeEnum(MembershipRole), z.string()]), // Support both traditional roles and custom role IDs
});
export type TChangeMemberRoleInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts
index ed2aeadbd5..eec2451fc5 100644
--- a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts
@@ -1,7 +1,9 @@
import type { Prisma } from "@prisma/client";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
+import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
+import { TeamRepository } from "@calcom/lib/server/repository/team";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -63,6 +65,7 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersHandlerOptio
role: true,
accepted: true,
teamId: true,
+ customRoleId: true,
user: { select: userSelect },
},
cursor: cursor ? { id: cursor } : undefined,
@@ -76,16 +79,45 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersHandlerOptio
nextCursor = nextItem?.id;
}
+ const teamRepo = new TeamRepository(prisma);
+ const team = await teamRepo.findById({ id: input.teamId });
+
+ const organizationId = team?.parentId || teamId;
+
+ // Get custom roles if PBAC is enabled
+ let customRoles: { [key: string]: { id: string; name: string } } = {};
+ try {
+ const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
+ if (roleManager.isPBACEnabled) {
+ const roles = await roleManager.getTeamRoles(teamId);
+ customRoles = roles.reduce((acc, role) => {
+ acc[role.id] = role;
+ return acc;
+ }, {} as { [key: string]: { id: string; name: string } });
+ }
+ } catch (error) {
+ // PBAC not enabled or error occurred, continue with traditional roles
+ }
+
const membersWithApps = await Promise.all(
teamMembers.map(async (member) => {
const user = await new UserRepository(prisma).enrichUserWithItsProfile({
user: member.user,
});
const { profile, ...restUser } = user;
+
+ // Determine the role to display
+ let customRole = null;
+
+ if (member.customRoleId && customRoles[member.customRoleId]) {
+ customRole = customRoles[member.customRoleId];
+ }
return {
...restUser,
username: profile?.username ?? restUser.username,
role: member.role,
+ customRoleId: member.customRoleId,
+ customRole,
profile: profile,
organizationId: profile?.organizationId ?? null,
organization: profile?.organization,