diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx index e5672b4be2..3db242a724 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx @@ -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 ( - + ); }; diff --git a/apps/web/modules/teams/team-members-view.tsx b/apps/web/modules/teams/team-members-view.tsx new file mode 100644 index 0000000000..ed378e4616 --- /dev/null +++ b/apps/web/modules/teams/team-members-view.tsx @@ -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; + 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 ( + +
+ {canLoggedInUserSeeMembers && ( +
+ +
+ )} + {!canLoggedInUserSeeMembers && ( +
+

{t("only_admin_can_see_members_of_team")}

+
+ )} + {showMemberInvitationModal && team && team.id && ( + setShowMemberInvitationModal(false)} + showMemberInvitationModal={showMemberInvitationModal} + teamId={team.id} + token={team.inviteToken?.token} + onSettingsOpen={() => setShowInviteLinkSettingsModal(true)} + /> + )} +
+
+ ); +}; diff --git a/packages/features/ee/teams/components/EditMemberSheet.tsx b/packages/features/ee/teams/components/EditMemberSheet.tsx index 7cdddecf57..63d99e3b78 100644 --- a/packages/features/ee/teams/components/EditMemberSheet.tsx +++ b/packages/features/ee/teams/components/EditMemberSheet.tsx @@ -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; @@ -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(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" }); }}> - {!isPending ? ( + {!isPending && !isLoadingRoles ? (
@@ -185,7 +214,11 @@ export function EditMemberSheet({ {!editMode ? ( - + ) : (
@@ -193,15 +226,30 @@ export function EditMemberSheet({
- { - form.setValue("role", value); - }} - /> + {shouldUseSelect ? ( +