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:
10 files changed
+400
-65
No files matched your search
+67
-3
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user