feat: assign custom roles (#22662)

Co-authored-by: sean@cal.com <Sean@brydon.io>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2025-07-24 15:42:09 +00:00
1 parent 3ab676794f
commit 29e1dcb43c
10 files changed
+400 -65

No files matched your search

@@ -1,7 +1,13 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import LegacyPage from "@calcom/features/ee/teams/pages/team-members-view";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { TeamMembersView } from "~/teams/team-members-view";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
@@ -12,12 +18,70 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/settings/teams/${(await params).id}/members`
);
const Page = async () => {
const getCachedTeamRoles = unstable_cache(
async (teamId: number, organizationId?: number) => {
if (!organizationId) return []; // Fallback to traditional roles
try {
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
return await roleManager.getTeamRoles(teamId);
} catch (error) {
// PBAC not enabled or error occurred, return empty array
return [];
}
},
undefined,
{ revalidate: 3600, tags: ["pbac.team.roles.list"] } // Cache for 1 hour
);
const getCachedTeamAttributes = unstable_cache(
async (organizationId?: number) => {
if (!organizationId) return [];
try {
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
} catch (error) {
return [];
}
},
undefined,
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
);
const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
const t = await getTranslate();
const { id } = await params;
const teamId = parseInt(id);
const teamCaller = await createRouterCaller(viewerTeamsRouter);
const team = await teamCaller.get({ teamId });
if (!team) {
throw new Error("Team not found");
}
// Get organization ID (either the team's parent or the team itself if it's an org)
const organizationId = team.parentId || teamId;
// Load PBAC roles and attributes if available
const [roles, attributes] = await Promise.all([
getCachedTeamRoles(teamId, organizationId),
getCachedTeamAttributes(organizationId),
]);
const facetedTeamValues = {
roles,
teams: [team],
attributes: attributes.map((attribute) => ({
id: attribute.id,
name: attribute.name,
options: Array.from(new Set(attribute.options.map((option) => option.value))).map((value) => ({
value,
})),
})),
};
return (
<SettingsHeader title={t("team_members")} description={t("members_team_description")}>
<LegacyPage />
<TeamMembersView team={team} facetedTeamValues={facetedTeamValues} attributes={attributes} />
</SettingsHeader>
);
};
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
import MemberList from "@calcom/features/ee/teams/components/MemberList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
interface TeamMembersViewProps {
team: NonNullable<RouterOutputs["viewer"]["teams"]["get"]>;
facetedTeamValues?: {
roles: { id: string; name: string }[];
teams: RouterOutputs["viewer"]["teams"]["get"][];
attributes: {
id: string;
name: string;
options: {
value: string;
}[];
}[];
};
attributes?: any[];
}
export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProps) => {
const { t } = useLocale();
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
const isTeamAdminOrOwner = checkAdminOrOwner(team.membership.role);
const canLoggedInUserSeeMembers = !team.isPrivate || isTeamAdminOrOwner;
return (
<LicenseRequired>
<div>
{canLoggedInUserSeeMembers && (
<div className="mb-6">
<MemberList
team={team}
isOrgAdminOrOwner={false}
setShowMemberInvitationModal={setShowMemberInvitationModal}
facetedTeamValues={facetedTeamValues}
/>
</div>
)}
{!canLoggedInUserSeeMembers && (
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
<h2 className="text-default">{t("only_admin_can_see_members_of_team")}</h2>
</div>
)}
{showMemberInvitationModal && team && team.id && (
<MemberInvitationModalWithoutMembers
hideInvitationModal={() => setShowMemberInvitationModal(false)}
showMemberInvitationModal={showMemberInvitationModal}
teamId={team.id}
token={team.inviteToken?.token}
onSettingsOpen={() => setShowInviteLinkSettingsModal(true)}
/>
)}
</div>
</LicenseRequired>
);
};
@@ -13,7 +13,7 @@ import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Form } from "@calcom/ui/components/form";
import { ToggleGroup } from "@calcom/ui/components/form";
import { ToggleGroup, Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetBody } from "@calcom/ui/components/sheet";
import { Skeleton, Loader } from "@calcom/ui/components/skeleton";
@@ -24,7 +24,7 @@ import { updateRoleInCache } from "./MemberChangeRoleModal";
import type { Action, State, User } from "./MemberList";
const formSchema = z.object({
role: z.enum([MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER]),
role: z.union([z.nativeEnum(MembershipRole), z.string()]), // Support both traditional roles and custom role IDs
});
type FormSchema = z.infer<typeof formSchema>;
@@ -47,7 +47,7 @@ export function EditMemberSheet({
(state) => [state.editMode, state.setEditMode, state.setMutationLoading],
shallow
);
const [role, setRole] = useState(selectedUser.role);
const [role, setRole] = useState<string>(selectedUser.customRoleId || selectedUser.role);
const name =
selectedUser.name ||
(() => {
@@ -60,7 +60,25 @@ export function EditMemberSheet({
const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, "");
const bookingLink = !!selectedUser.username ? `${bookerUrlWithoutProtocol}/${selectedUser.username}` : "";
// Load custom roles for the team
const { data: customRoles, isPending: isLoadingRoles } = trpc.viewer.pbac.getTeamRoles.useQuery(
{ teamId },
{
enabled: !!teamId,
retry: false, // Don't retry if PBAC is not enabled
}
);
const options = useMemo(() => {
// If we have custom roles, only show custom roles
if (customRoles && customRoles.length > 0) {
return customRoles.map((customRole) => ({
label: customRole.name,
value: customRole.id,
}));
}
// Otherwise, show traditional roles
return [
{
label: t("member"),
@@ -75,12 +93,16 @@ export function EditMemberSheet({
value: MembershipRole.OWNER,
},
].filter(({ value }) => value !== MembershipRole.OWNER || currentMember === MembershipRole.OWNER);
}, [t, currentMember]);
}, [t, currentMember, customRoles]);
// Determine if we should use Select (when custom roles exist) or ToggleGroup (traditional only)
const hasCustomRoles = customRoles && customRoles.length > 0;
const shouldUseSelect = hasCustomRoles; // Use Select for custom roles, ToggleGroup for traditional roles
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
role: selectedUser.role,
role: selectedUser.customRoleId || selectedUser.role, // Use custom role ID if available, otherwise traditional role
},
});
@@ -101,13 +123,20 @@ export function EditMemberSheet({
});
if (previousValue) {
updateRoleInCache({ utils, teamId, memberId, role, searchTerm: undefined });
updateRoleInCache({
utils,
teamId,
memberId,
role: role as MembershipRole | string,
searchTerm: undefined,
customRoles,
});
}
return { previousValue };
},
onSuccess: async (_data, { role }) => {
setRole(role);
setRole(role as string);
setMutationLoading(false);
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.listMembers.invalidate();
@@ -153,7 +182,7 @@ export function EditMemberSheet({
dispatch({ type: "CLOSE_MODAL" });
}}>
<SheetContent className="bg-muted">
{!isPending ? (
{!isPending && !isLoadingRoles ? (
<Form form={form} handleSubmit={changeRole} className="flex h-full flex-col">
<SheetHeader showCloseButton={false} className="w-full">
<div className="border-sublte bg-default w-full rounded-xl border p-4">
@@ -185,7 +214,11 @@ export function EditMemberSheet({
<DisplayInfo label="Cal" value={bookingLink} icon="external-link" />
<DisplayInfo label={t("email")} value={selectedUser.email} icon="at-sign" />
{!editMode ? (
<DisplayInfo label={t("role")} value={[role]} icon="fingerprint" />
<DisplayInfo
label={t("role")}
value={[selectedUser.customRole?.name || selectedUser.role]}
icon="fingerprint"
/>
) : (
<div className="flex items-center gap-6">
<div className="flex w-[110px] items-center gap-2">
@@ -193,15 +226,30 @@ export function EditMemberSheet({
<label className="text-sm font-medium">{t("role")}</label>
</div>
<div className="flex flex-1">
<ToggleGroup
isFullWidth
defaultValue={role}
value={form.watch("role")}
options={options}
onValueChange={(value: FormSchema["role"]) => {
form.setValue("role", value);
}}
/>
{shouldUseSelect ? (
<Select
value={options.find((option) => option.value === form.watch("role"))}
onChange={(selectedOption: any) => {
if (selectedOption) {
form.setValue("role", selectedOption.value);
}
}}
options={options}
isDisabled={isLoadingRoles}
placeholder={isLoadingRoles ? t("loading") : t("select_role")}
className="flex-1"
/>
) : (
<ToggleGroup
isFullWidth
defaultValue={role}
value={form.watch("role")}
options={options}
onValueChange={(value: FormSchema["role"]) => {
form.setValue("role", value);
}}
/>
)}
</div>
</div>
)}
@@ -20,12 +20,14 @@ export const updateRoleInCache = ({
searchTerm,
role,
memberId,
customRoles,
}: {
utils: ReturnType<typeof trpc.useUtils>;
teamId: number;
searchTerm: string | undefined;
role: MembershipRole;
role: MembershipRole | string;
memberId: number;
customRoles?: { id: string; name: string }[];
}) => {
utils.viewer.teams.listMembers.setInfiniteData(
{
@@ -45,10 +47,23 @@ export const updateRoleInCache = ({
...data,
pages: data.pages.map((page) => ({
...page,
members: page.members.map((member) => ({
...member,
role: member.id === memberId ? role : member.role,
})),
members: page.members.map((member) => {
if (member.id === memberId) {
const isTraditionalRole = Object.values(MembershipRole).includes(role as MembershipRole);
// Find the new custom role object if assigning a custom role
const newCustomRole =
!isTraditionalRole && customRoles ? customRoles.find((cr) => cr.id === role) || null : null;
return {
...member,
role: isTraditionalRole ? (role as MembershipRole) : member.role,
customRoleId: isTraditionalRole ? null : (role as string),
customRole: newCustomRole,
};
}
return member;
}),
})),
};
}
@@ -5,7 +5,6 @@ import {
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getFacetedUniqueValues,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
@@ -27,6 +26,7 @@ import {
useDataTable,
useFetchMoreOnBottomReached,
useColumnFilters,
convertFacetedValuesToMap,
} from "@calcom/features/data-table";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink";
@@ -152,6 +152,17 @@ interface Props {
team: NonNullable<RouterOutputs["viewer"]["teams"]["get"]>;
isOrgAdminOrOwner: boolean | undefined;
setShowMemberInvitationModal: Dispatch<SetStateAction<boolean>>;
facetedTeamValues?: {
roles: { id: string; name: string }[];
teams: RouterOutputs["viewer"]["teams"]["get"][];
attributes: {
id: string;
name: string;
options: {
value: string;
}[];
}[];
};
}
export default function MemberList(props: Props) {
@@ -163,6 +174,7 @@ export default function MemberList(props: Props) {
}
function MemberListContent(props: Props) {
const { facetedTeamValues } = props;
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
const { t, i18n } = useLocale();
const { data: session } = useSession();
@@ -364,7 +376,9 @@ function MemberListContent(props: Props) {
header: "Role",
size: 100,
cell: ({ row, table }) => {
const { role, accepted } = row.original;
const { role, accepted, customRole } = row.original;
const roleName = customRole?.name || role;
const roleIdentifier = customRole?.id || role;
return (
<div className="flex h-full flex-wrap items-center gap-2">
{!accepted && (
@@ -382,22 +396,25 @@ function MemberListContent(props: Props) {
data-testid="member-role"
variant={role === "MEMBER" ? "gray" : "blue"}
onClick={() => {
table.getColumn("role")?.setFilterValue([role]);
table.getColumn("role")?.setFilterValue([roleIdentifier]);
}}>
{role}
{roleName}
</Badge>
</div>
);
},
filterFn: (rows, id, filterValue) => {
const { data } = filterValue;
const { role, accepted, customRole } = rows.original;
const roleIdentifier = customRole?.id || role;
if (data.includes("PENDING")) {
if (data.length === 1) return !rows.original.accepted;
else return !rows.original.accepted || data.includes(rows.getValue(id));
if (data.length === 1) return !accepted;
else return !accepted || data.includes(roleIdentifier);
}
// Show only the selected roles
return data.includes(rows.getValue(id));
// Show only the selected roles (check both traditional role and custom role ID)
return data.includes(roleIdentifier);
},
},
{
@@ -644,7 +661,23 @@ function MemberListContent(props: Props) {
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedUniqueValues: (_, columnId) => () => {
if (facetedTeamValues) {
switch (columnId) {
case "role":
// Include both traditional roles and PBAC custom roles
const allRoles = facetedTeamValues.roles.map((role) => ({
label: role.name,
value: role.id,
}));
return convertFacetedValuesToMap(allRoles);
default:
return new Map();
}
}
return new Map();
},
getRowId: (row) => `${row.id}`,
});
@@ -18,6 +18,7 @@ interface IRoleManager {
membershipId: number
): Promise<void>;
getAllRoles(organizationId: number): Promise<{ id: string; name: string }[]>;
getTeamRoles(teamId: number): Promise<{ id: string; name: string }[]>;
}
class PBACRoleManager implements IRoleManager {
@@ -45,24 +46,33 @@ class PBACRoleManager implements IRoleManager {
}
async assignRole(
userId: number,
_userId: number,
organizationId: number,
role: MembershipRole,
role: MembershipRole | string,
membershipId: number
): Promise<void> {
// Check if role is one of the default MembershipRole enum values
const isDefaultRole = role in DEFAULT_ROLE_IDS;
// Also check if the role is a default role ID value
const isDefaultRoleId = Object.values(DEFAULT_ROLE_IDS).includes(role as any);
if (isDefaultRole) {
await this.roleService.assignRoleToMember(DEFAULT_ROLE_IDS[role], membershipId);
// Handle enum values like MembershipRole.ADMIN
await this.roleService.assignRoleToMember(DEFAULT_ROLE_IDS[role as MembershipRole], membershipId);
} else if (isDefaultRoleId) {
// Handle default role IDs like "admin_role"
await this.roleService.assignRoleToMember(role as string, membershipId);
} else {
const roleExists = await this.roleService.roleBelongsToTeam(role, organizationId);
// Handle custom roles
const roleExists = await this.roleService.roleBelongsToTeam(role as string, organizationId);
if (!roleExists) {
throw new RoleManagementError(
"You do not have access to this role",
RoleManagementErrorCode.INVALID_ROLE
);
}
await this.roleService.assignRoleToMember(role, membershipId);
await this.roleService.assignRoleToMember(role as string, membershipId);
}
}
@@ -73,6 +83,14 @@ class PBACRoleManager implements IRoleManager {
name: role.name,
}));
}
async getTeamRoles(teamId: number): Promise<{ id: string; name: string }[]> {
const roles = await this.roleService.getTeamRoles(teamId);
return roles.map((role) => ({
id: role.id,
name: role.name,
}));
}
}
class LegacyRoleManager implements IRoleManager {
@@ -115,6 +133,14 @@ class LegacyRoleManager implements IRoleManager {
{ id: MembershipRole.MEMBER, name: "Member" },
];
}
async getTeamRoles(_teamId: number): Promise<{ id: string; name: string }[]> {
return [
{ id: MembershipRole.OWNER, name: "Owner" },
{ id: MembershipRole.ADMIN, name: "Admin" },
{ id: MembershipRole.MEMBER, name: "Member" },
];
}
}
export class RoleManagementFactory {
@@ -107,9 +107,22 @@ export function EditForm({
}
);
const customRoles = teamRoles?.filter((role) => role.type === "CUSTOM") ?? [];
const membershipOptions = useMemo<MembershipOption[]>(() => {
// 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({
<TextAreaField label={t("about")} {...form.register("bio")} className="min-h-24 mb-6" />
<div className="mb-6">
<Label>{t("role")}</Label>
{customRoles.length > 0 ? (
{teamRoles?.length > 0 ? (
<SelectField
defaultValue={membershipOptions.find(
(option) => option.value === (selectedUser?.role ?? "MEMBER")
@@ -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;
@@ -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<typeof ZChangeMemberRoleInputSchema>;
@@ -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,