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 97adac481d..3cf44ce444 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
@@ -5,13 +5,11 @@ import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
-import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
-import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
+import { getTeamMemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import { prisma } from "@calcom/prisma";
-import { MembershipRole } from "@calcom/prisma/enums";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -33,7 +31,7 @@ const getCachedTeamRoles = unstable_cache(
try {
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
return await roleManager.getTeamRoles(teamId);
- } catch (error) {
+ } catch {
// PBAC not enabled or error occurred, return empty array
return [];
}
@@ -49,7 +47,7 @@ const getCachedTeamAttributes = unstable_cache(
try {
return await attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId });
- } catch (error) {
+ } catch {
return [];
}
},
@@ -79,65 +77,15 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
const organizationId = team.parentId || teamId;
// Load PBAC roles and attributes if available
- const [roles, attributes] = await Promise.all([
+ const [roles, attributes, memberPermissions] = await Promise.all([
getCachedTeamRoles(teamId, organizationId),
getCachedTeamAttributes(organizationId),
+ getTeamMemberPermissions({
+ userId: session.user.id,
+ team,
+ }),
]);
- const fallbackRolesCanListMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
-
- // If the team is not private we allow members to list other members
- if (!team.isPrivate) {
- fallbackRolesCanListMembers.push(MembershipRole.MEMBER);
- }
-
- // Get specific PBAC permissions for team member actions
- const permissions = await getSpecificPermissions({
- userId: session.user.id,
- teamId: teamId,
- resource: Resource.Team,
- userRole: team.membership.role,
- actions: [
- CustomAction.Invite,
- CustomAction.ChangeMemberRole,
- CustomAction.Remove,
- CustomAction.ListMembers,
- CustomAction.ListMembersPrivate,
- CustomAction.Impersonate,
- ],
- fallbackRoles: {
- [CustomAction.Invite]: {
- roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- [CustomAction.ChangeMemberRole]: {
- roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- [CustomAction.Remove]: {
- roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- [CustomAction.ListMembers]: {
- roles: fallbackRolesCanListMembers,
- },
- [CustomAction.Impersonate]: {
- roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- [CustomAction.ListMembersPrivate]: {
- roles: fallbackRolesCanListMembers,
- },
- },
- });
-
- // Map specific permissions to member actions
- const memberPermissions = {
- canListMembers: team.isPrivate
- ? permissions[CustomAction.ListMembersPrivate]
- : permissions[CustomAction.ListMembers],
- canInvite: permissions[CustomAction.Invite],
- canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
- canRemove: permissions[CustomAction.Remove],
- canImpersonate: permissions[CustomAction.Impersonate],
- };
-
const facetedTeamValues = {
roles,
teams: [team],
diff --git a/packages/features/ee/teams/components/EditMemberSheet.test.tsx b/packages/features/ee/teams/components/EditMemberSheet.test.tsx
new file mode 100644
index 0000000000..77f8e480db
--- /dev/null
+++ b/packages/features/ee/teams/components/EditMemberSheet.test.tsx
@@ -0,0 +1,327 @@
+import { render } from "@testing-library/react";
+import React, { type ReactNode } from "react";
+import { vi } from "vitest";
+
+import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { EditMemberSheet } from "./EditMemberSheet";
+import type { State, User } from "./MemberList";
+
+// Mock dependencies
+vi.mock("@calcom/lib/hooks/useLocale", () => ({
+ useLocale: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock("@calcom/trpc/react", () => ({
+ trpc: {
+ viewer: {
+ pbac: {
+ getTeamRoles: {
+ useQuery: () => ({
+ data: undefined,
+ isPending: false,
+ }),
+ },
+ },
+ teams: {
+ getUserConnectedApps: {
+ useQuery: () => ({
+ data: {},
+ isPending: false,
+ }),
+ },
+ changeMemberRole: {
+ useMutation: () => ({
+ mutate: vi.fn(),
+ mutateAsync: vi.fn(),
+ }),
+ },
+ listMembers: {
+ cancel: vi.fn(),
+ getInfiniteData: vi.fn(),
+ invalidate: vi.fn(),
+ },
+ get: {
+ setData: vi.fn(),
+ invalidate: vi.fn(),
+ },
+ },
+ },
+ useUtils: () => ({
+ viewer: {
+ teams: {
+ listMembers: {
+ cancel: vi.fn(),
+ getInfiniteData: vi.fn(),
+ invalidate: vi.fn(),
+ },
+ get: {
+ setData: vi.fn(),
+ invalidate: vi.fn(),
+ },
+ },
+ },
+ }),
+ },
+}));
+
+const mockSetEditMode = vi.fn();
+const mockSetMutationLoading = vi.fn();
+
+vi.mock("@calcom/features/users/components/UserTable/EditSheet/store", () => ({
+ useEditMode: vi.fn((selector) => {
+ const state = {
+ editMode: false,
+ setEditMode: mockSetEditMode,
+ mutationLoading: false,
+ setMutationLoading: mockSetMutationLoading,
+ };
+ if (typeof selector === "function") {
+ return selector(state);
+ }
+ return state;
+ }),
+}));
+
+// Mock SheetFooterControls to verify props
+let capturedProps: { canChangeMemberRole?: boolean; canEditAttributesForUser?: boolean }[] = [];
+vi.mock("@calcom/features/users/components/UserTable/EditSheet/SheetFooterControls", () => ({
+ SheetFooterControls: (props: { canChangeMemberRole?: boolean; canEditAttributesForUser?: boolean }) => {
+ capturedProps.push(props);
+ return React.createElement("div", { "data-testid": "sheet-footer-controls" }, "SheetFooterControls");
+ },
+}));
+
+// Mock other UI components
+vi.mock("@calcom/ui/components/sheet", () => ({
+ Sheet: ({ children }: { children: ReactNode }) =>
+ React.createElement("div", { "data-testid": "sheet" }, children),
+ SheetContent: ({ children }: { children: ReactNode }) =>
+ React.createElement("div", { "data-testid": "sheet-content" }, children),
+ SheetHeader: ({ children }: { children: ReactNode }) =>
+ React.createElement("div", { "data-testid": "sheet-header" }, children),
+ SheetBody: ({ children }: { children: ReactNode }) =>
+ React.createElement("div", { "data-testid": "sheet-body" }, children),
+ SheetFooter: ({ children }: { children: ReactNode }) =>
+ React.createElement("div", { "data-testid": "sheet-footer" }, children),
+}));
+
+vi.mock("@calcom/ui/components/form", () => ({
+ Form: ({ children }: { children: ReactNode }) => React.createElement("form", null, children),
+ ToggleGroup: () => React.createElement("div", { "data-testid": "toggle-group" }, "ToggleGroup"),
+ Select: () => React.createElement("div", { "data-testid": "select" }, "Select"),
+}));
+
+vi.mock("@calcom/ui/components/avatar", () => ({
+ Avatar: () => React.createElement("div", { "data-testid": "avatar" }, "Avatar"),
+}));
+
+vi.mock("@calcom/ui/components/skeleton", () => ({
+ Skeleton: ({ children }: { children: ReactNode }) => React.createElement("div", null, children),
+ Loader: () => React.createElement("div", { "data-testid": "loader" }, "Loading..."),
+}));
+
+vi.mock("@calcom/features/users/components/UserTable/EditSheet/DisplayInfo", () => ({
+ DisplayInfo: () =>
DisplayInfo
,
+}));
+
+describe("EditMemberSheet", () => {
+ const mockDispatch = vi.fn();
+
+ // Create a minimal mock user that satisfies the User type
+ const mockUser = {
+ id: 1,
+ name: "Test User",
+ email: "test@example.com",
+ username: "testuser",
+ role: MembershipRole.MEMBER,
+ accepted: true,
+ avatarUrl: "",
+ bookerUrl: "https://cal.com",
+ lastActiveAt: "2024-01-01",
+ customRoleId: null,
+ } as User;
+
+ const mockState: State = {
+ editSheet: {
+ user: mockUser,
+ showModal: true,
+ },
+ deleteMember: {
+ showModal: false,
+ },
+ impersonateMember: {
+ showModal: false,
+ },
+ teamAvailability: {
+ showModal: false,
+ },
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSetEditMode.mockClear();
+ mockSetMutationLoading.mockClear();
+ capturedProps = [];
+ });
+
+ describe("Fix verification: canChangeMemberRole prop passing", () => {
+ it("should pass canChangeMemberRole=true to SheetFooterControls when permissions.canChangeMemberRole is true", () => {
+ const permissions: MemberPermissions = {
+ canListMembers: false,
+ canInvite: false,
+ canChangeMemberRole: true,
+ canRemove: false,
+ canImpersonate: false,
+ };
+
+ render(
+
+ );
+
+ // Verify SheetFooterControls was called with canChangeMemberRole=true
+ expect(capturedProps).toContainEqual(
+ expect.objectContaining({
+ canChangeMemberRole: true,
+ })
+ );
+ });
+
+ it("should pass canChangeMemberRole=false to SheetFooterControls when permissions.canChangeMemberRole is false", () => {
+ const permissions: MemberPermissions = {
+ canListMembers: false,
+ canInvite: false,
+ canChangeMemberRole: false,
+ canRemove: false,
+ canImpersonate: false,
+ };
+
+ render(
+
+ );
+
+ // Verify SheetFooterControls was called with canChangeMemberRole=false
+ expect(capturedProps).toContainEqual(
+ expect.objectContaining({
+ canChangeMemberRole: false,
+ })
+ );
+ });
+
+ it("should pass canChangeMemberRole=undefined to SheetFooterControls when permissions is undefined", () => {
+ render(
+
+ );
+
+ // Verify SheetFooterControls was called with canChangeMemberRole=undefined
+ expect(capturedProps).toContainEqual(
+ expect.objectContaining({
+ canChangeMemberRole: undefined,
+ })
+ );
+ });
+
+ it("should pass canChangeMemberRole=undefined to SheetFooterControls when permissions object exists but canChangeMemberRole is undefined", () => {
+ const permissions: Partial = {
+ canListMembers: true,
+ canInvite: false,
+ // canChangeMemberRole is intentionally omitted
+ };
+
+ render(
+
+ );
+
+ // Verify SheetFooterControls was called with canChangeMemberRole=undefined
+ expect(capturedProps).toContainEqual(
+ expect.objectContaining({
+ canChangeMemberRole: undefined,
+ })
+ );
+ });
+
+ it("should pass canEditAttributesForUser from permissions to SheetFooterControls", () => {
+ const permissions: MemberPermissions = {
+ canListMembers: false,
+ canInvite: false,
+ canChangeMemberRole: true,
+ canRemove: false,
+ canImpersonate: false,
+ canEditAttributesForUser: true,
+ };
+
+ render(
+
+ );
+
+ // Verify SheetFooterControls was called with both props
+ expect(capturedProps).toContainEqual(
+ expect.objectContaining({
+ canChangeMemberRole: true,
+ canEditAttributesForUser: true,
+ })
+ );
+ });
+
+ it("should correctly extract canChangeMemberRole from permissions object (verifies fix)", () => {
+ // This test specifically verifies the fix where permissions?.canChangeMemberRole
+ // is now correctly passed to SheetFooterControls
+ const permissions: MemberPermissions = {
+ canListMembers: false,
+ canInvite: false,
+ canChangeMemberRole: true, // This should be passed to SheetFooterControls
+ canRemove: false,
+ canImpersonate: false,
+ };
+
+ render(
+
+ );
+
+ expect(capturedProps).toContainEqual(
+ expect.objectContaining({
+ canChangeMemberRole: true,
+ })
+ );
+ });
+ });
+});
diff --git a/packages/features/ee/teams/components/EditMemberSheet.tsx b/packages/features/ee/teams/components/EditMemberSheet.tsx
index 61bbb6693c..8611d8d9f3 100644
--- a/packages/features/ee/teams/components/EditMemberSheet.tsx
+++ b/packages/features/ee/teams/components/EditMemberSheet.tsx
@@ -8,6 +8,7 @@ import { shallow } from "zustand/shallow";
import { DisplayInfo } from "@calcom/features/users/components/UserTable/EditSheet/DisplayInfo";
import { SheetFooterControls } from "@calcom/features/users/components/UserTable/EditSheet/SheetFooterControls";
import { useEditMode } from "@calcom/features/users/components/UserTable/EditSheet/store";
+import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
@@ -34,11 +35,13 @@ export function EditMemberSheet({
dispatch,
currentMember,
teamId,
+ permissions,
}: {
state: State;
dispatch: Dispatch;
currentMember: MembershipRole;
teamId: number;
+ permissions?: MemberPermissions;
}) {
const { t } = useLocale();
const { user } = state.editSheet;
@@ -277,7 +280,10 @@ export function EditMemberSheet({
-
+
) : (
diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx
index b2be14f2ee..ff1ee1627c 100644
--- a/packages/features/ee/teams/components/MemberList.tsx
+++ b/packages/features/ee/teams/components/MemberList.tsx
@@ -830,6 +830,7 @@ function MemberListContent(props: Props) {
state={state}
currentMember={props.team.membership.role}
teamId={props.team.id}
+ permissions={props.permissions}
/>
)}
>
diff --git a/packages/features/pbac/lib/team-member-permissions.ts b/packages/features/pbac/lib/team-member-permissions.ts
new file mode 100644
index 0000000000..ad27cdf276
--- /dev/null
+++ b/packages/features/pbac/lib/team-member-permissions.ts
@@ -0,0 +1,80 @@
+import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { Resource, CustomAction } from "../domain/types/permission-registry";
+import { getSpecificPermissions } from "./resource-permissions";
+
+interface TeamWithMembership {
+ id: number;
+ isPrivate: boolean;
+ membership: {
+ role: MembershipRole;
+ accepted: boolean;
+ };
+}
+
+interface GetTeamMemberPermissionsOptions {
+ userId: number;
+ team: TeamWithMembership;
+}
+
+/**
+ * Gets team member permissions using PBAC or fallback to role-based permissions.
+ */
+export async function getTeamMemberPermissions({
+ userId,
+ team,
+}: GetTeamMemberPermissionsOptions): Promise {
+ // Determine fallback roles for ListMembers based on team privacy
+ const fallbackRolesCanListMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
+ if (!team.isPrivate) {
+ fallbackRolesCanListMembers.push(MembershipRole.MEMBER);
+ }
+
+ // Get specific PBAC permissions for team member actions
+ const permissions = await getSpecificPermissions({
+ userId,
+ teamId: team.id,
+ resource: Resource.Team,
+ userRole: team.membership.role,
+ actions: [
+ CustomAction.Invite,
+ CustomAction.ChangeMemberRole,
+ CustomAction.Remove,
+ CustomAction.ListMembers,
+ CustomAction.ListMembersPrivate,
+ CustomAction.Impersonate,
+ ],
+ fallbackRoles: {
+ [CustomAction.Invite]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.ChangeMemberRole]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.Remove]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.ListMembers]: {
+ roles: fallbackRolesCanListMembers,
+ },
+ [CustomAction.Impersonate]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.ListMembersPrivate]: {
+ roles: fallbackRolesCanListMembers,
+ },
+ },
+ });
+
+ // Map specific permissions to member actions
+ return {
+ canListMembers: team.isPrivate
+ ? permissions[CustomAction.ListMembersPrivate]
+ : permissions[CustomAction.ListMembers],
+ canInvite: permissions[CustomAction.Invite],
+ canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
+ canRemove: permissions[CustomAction.Remove],
+ canImpersonate: permissions[CustomAction.Impersonate],
+ };
+}