diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx
index aadcc24c52..7ff65fc084 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx
@@ -22,6 +22,7 @@ interface AdvancedPermissionGroupProps {
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
+ isPrivate?: boolean;
}
const INTERNAL_DATAACCESS_KEY = "_resource";
@@ -32,10 +33,11 @@ export function AdvancedPermissionGroup({
onChange,
disabled,
scope = Scope.Organization,
+ isPrivate = false,
}: AdvancedPermissionGroupProps) {
const { t } = useLocale();
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
- const scopedRegistry = getPermissionsForScope(scope);
+ const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const resourceConfig = scopedRegistry[resource];
const [isExpanded, setIsExpanded] = useState(false);
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx
index 68360fdb30..b19013a4d6 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx
@@ -56,9 +56,17 @@ interface RoleSheetProps {
onOpenChange: (open: boolean) => void;
teamId: number;
scope?: Scope;
+ isPrivate?: boolean; // Add isPrivate prop to control permission visibility
}
-export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Organization }: RoleSheetProps) {
+export function RoleSheet({
+ role,
+ open,
+ onOpenChange,
+ teamId,
+ scope = Scope.Organization,
+ isPrivate = false
+}: RoleSheetProps) {
const { t } = useLocale();
const router = useRouter();
const isEditing = Boolean(role);
@@ -108,7 +116,8 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
const { isAdvancedMode, permissions, color } = form.watch();
const { filteredResources, scopedRegistry } = useMemo(() => {
- const scopedRegistry = getPermissionsForScope(scope);
+ // Use privacy-aware filtering if we have privacy information
+ const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const filteredResources = Object.keys(scopedRegistry).filter((resource) =>
t(
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]]
@@ -118,7 +127,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
.includes(searchQuery.toLowerCase())
);
return { filteredResources, scopedRegistry };
- }, [searchQuery, t, scope]);
+ }, [searchQuery, t, scope, isPrivate]);
const createMutation = trpc.viewer.pbac.createRole.useMutation({
onSuccess: async () => {
@@ -221,6 +230,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
+ isPrivate={isPrivate}
/>
))}{" "}
@@ -245,6 +255,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
+ isPrivate={isPrivate}
/>
))}
{" "}
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RolesList.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RolesList.tsx
index 93409a937b..8ebde70433 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RolesList.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RolesList.tsx
@@ -48,6 +48,7 @@ interface RolesListProps {
initialSheetOpen?: boolean;
teamId: number;
scope?: Scope;
+ isPrivate?: boolean;
}
export function RolesList({
@@ -57,6 +58,7 @@ export function RolesList({
initialSheetOpen,
teamId,
scope,
+ isPrivate,
}: RolesListProps) {
const { t } = useLocale();
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
@@ -113,6 +115,7 @@ export function RolesList({
onOpenChange={handleSheetOpenChange}
teamId={teamId}
scope={scope}
+ isPrivate={isPrivate}
/>
>
);
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx
index e658890c30..cc8e0aaebb 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx
@@ -18,6 +18,7 @@ interface SimplePermissionItemProps {
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
+ isPrivate?: boolean;
}
export function SimplePermissionItem({
@@ -26,10 +27,11 @@ export function SimplePermissionItem({
onChange,
disabled,
scope = Scope.Organization,
+ isPrivate = false,
}: SimplePermissionItemProps) {
const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
- const scopedRegistry = getPermissionsForScope(scope);
+ const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const registry = scopedRegistry || PERMISSION_REGISTRY;
const isAllResources = resource === "*";
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx
index 676a11bb02..97a0382843 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx
@@ -44,6 +44,18 @@ const getCachedResourcePermissions = unstable_cache(
{ revalidate: 3600 }
);
+const getCachedTeamPrivacy = unstable_cache(
+ async (teamId: number) => {
+ const team = await prisma.team.findUnique({
+ where: { id: teamId },
+ select: { isPrivate: true },
+ });
+ return team?.isPrivate ?? false;
+ },
+ ["team-privacy"],
+ { revalidate: 3600 }
+);
+
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("roles_and_permissions"),
@@ -69,9 +81,10 @@ const Page = async ({ searchParams }: { searchParams: Record
);
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 4d27a38b5b..97adac481d 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
@@ -102,6 +102,7 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.ListMembers,
+ CustomAction.ListMembersPrivate,
CustomAction.Impersonate,
],
fallbackRoles: {
@@ -120,12 +121,17 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
+ [CustomAction.ListMembersPrivate]: {
+ roles: fallbackRolesCanListMembers,
+ },
},
});
// Map specific permissions to member actions
const memberPermissions = {
- canListMembers: permissions[CustomAction.ListMembers],
+ canListMembers: team.isPrivate
+ ? permissions[CustomAction.ListMembersPrivate]
+ : permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx
index 626148aeae..1568df8f48 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx
@@ -4,6 +4,7 @@ import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
@@ -11,7 +12,6 @@ import { Resource, CrudAction, Scope } from "@calcom/features/pbac/domain/types/
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
-import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -51,6 +51,19 @@ const getCachedResourcePermissions = (userId: number, teamId: number, resource:
{ revalidate: 3600, tags: [`resource-permissions-${teamId}`] }
);
+const getCachedTeamPrivacy = (teamId: number) =>
+ unstable_cache(
+ async () => {
+ const team = await prisma.team.findUnique({
+ where: { id: teamId },
+ select: { isPrivate: true },
+ });
+ return team?.isPrivate ?? false;
+ },
+ [`team-privacy-${teamId}`],
+ { revalidate: 3600, tags: [`team-privacy-${teamId}`] }
+ );
+
const getCachedTeam = (teamId: string, userId: number) =>
unstable_cache(
async () => {
@@ -111,9 +124,10 @@ const Page = async ({
roleSearchParamsCache.parse(searchParams);
- const [roles, rolePermissions] = await Promise.all([
+ const [roles, rolePermissions, isPrivate] = await Promise.all([
getCachedTeamRoles(team.id)(),
getCachedResourcePermissions(session.user.id, team.id, Resource.Role)(),
+ getCachedTeamPrivacy(team.id)(),
]);
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
@@ -153,6 +167,7 @@ const Page = async ({
initialSelectedRole={selectedRole}
initialSheetOpen={isSheetOpen}
scope={Scope.Team}
+ isPrivate={isPrivate}
/>
);
diff --git a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx
index 588e5336df..c8b55bd3d1 100644
--- a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx
@@ -70,6 +70,7 @@ const Page = async () => {
userRole: session.user.org.role,
actions: [
CustomAction.ListMembers,
+ CustomAction.ListMembersPrivate,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
@@ -79,6 +80,9 @@ const Page = async () => {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
},
+ [CustomAction.ListMembersPrivate]: {
+ roles: fallbackRolesThatCanSeeMembers,
+ },
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
@@ -96,7 +100,9 @@ const Page = async () => {
// Map specific permissions to member actions
const memberPermissions = {
- canListMembers: permissions[CustomAction.ListMembers],
+ canListMembers: org.isPrivate
+ ? permissions[CustomAction.ListMembersPrivate]
+ : permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
diff --git a/apps/web/lib/team/[slug]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/getServerSideProps.tsx
index 3aa548b170..07bed34c24 100644
--- a/apps/web/lib/team/[slug]/getServerSideProps.tsx
+++ b/apps/web/lib/team/[slug]/getServerSideProps.tsx
@@ -5,13 +5,13 @@ import {
getOrganizationSettings,
getVerifiedDomain,
} from "@calcom/features/ee/organizations/lib/orgSettings";
+import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { IS_CALCOM } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
-import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import slugify from "@calcom/lib/slugify";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts
index 6c7608abf0..d4e61e2ce3 100644
--- a/packages/features/pbac/domain/types/permission-registry.ts
+++ b/packages/features/pbac/domain/types/permission-registry.ts
@@ -27,6 +27,7 @@ export enum CustomAction {
Remove = "remove",
ChangeMemberRole = "changeMemberRole",
ListMembers = "listMembers",
+ ListMembersPrivate = "listMembersPrivate",
ManageBilling = "manageBilling",
ReadTeamBookings = "readTeamBookings",
ReadOrgBookings = "readOrgBookings",
@@ -46,6 +47,9 @@ export interface PermissionDetails {
descriptionI18nKey: string;
scope?: Scope[]; // Optional for backward compatibility
dependsOn?: PermissionString[]; // Dependencies that must be enabled when this permission is enabled
+ visibleWhen?: {
+ teamPrivacy?: "private" | "public" | "both"; // Control visibility based on team privacy setting
+ };
}
export type ResourceConfig = {
@@ -122,12 +126,14 @@ export const filterResourceConfig = (config: ResourceConfig): Omit {
+export const getPermissionsForScope = (scope: Scope, isPrivate?: boolean): PermissionRegistry => {
const filteredRegistry: Partial = {};
+ const teamPrivacy = isPrivate !== undefined ? (isPrivate ? "private" : "public") : undefined;
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
const filteredConfig: ResourceConfig = { _resource: config._resource };
@@ -136,9 +142,17 @@ export const getPermissionsForScope = (scope: Scope): PermissionRegistry => {
if (action === "_resource") return;
const permissionDetails = details as PermissionDetails;
- // If no scope is defined, include in both Team and Organization (backward compatibility)
- // If scope is defined, only include if it matches the requested scope
- if (!permissionDetails.scope || permissionDetails.scope.includes(scope)) {
+
+ // Check scope
+ const scopeMatches = !permissionDetails.scope || permissionDetails.scope.includes(scope);
+
+ // Check privacy visibility (only if isPrivate is provided)
+ const privacyMatches = teamPrivacy === undefined ||
+ !permissionDetails.visibleWhen?.teamPrivacy ||
+ permissionDetails.visibleWhen.teamPrivacy === "both" ||
+ permissionDetails.visibleWhen.teamPrivacy === teamPrivacy;
+
+ if (scopeMatches && privacyMatches) {
filteredConfig[action as CrudAction | CustomAction] = permissionDetails;
}
});
@@ -282,6 +296,19 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
category: "team",
i18nKey: "pbac_action_list_members",
descriptionI18nKey: "pbac_desc_list_team_members",
+ visibleWhen: {
+ teamPrivacy: "public", // Only show for public teams
+ },
+ },
+ [CustomAction.ListMembersPrivate]: {
+ description: "List private team members",
+ category: "team",
+ i18nKey: "pbac_action_list_members", // Use same UI label as listMembers for consistency
+ descriptionI18nKey: "pbac_desc_list_team_members", // Use same description as listMembers
+ dependsOn: ["team.read"],
+ visibleWhen: {
+ teamPrivacy: "private", // Only show for private teams
+ },
},
[CustomAction.ChangeMemberRole]: {
description: "Change role of team members",
@@ -330,6 +357,20 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
descriptionI18nKey: "pbac_desc_list_organization_members",
scope: [Scope.Organization],
dependsOn: ["organization.read"],
+ visibleWhen: {
+ teamPrivacy: "public", // Only show for public orgs
+ },
+ },
+ [CustomAction.ListMembersPrivate]: {
+ description: "List private organization members",
+ category: "org",
+ i18nKey: "pbac_action_list_members", // Same UI label as listMembers for consistency
+ descriptionI18nKey: "pbac_desc_list_organization_members", // Same description as listMembers
+ scope: [Scope.Organization],
+ dependsOn: ["organization.read"],
+ visibleWhen: {
+ teamPrivacy: "private", // Only show for private orgs
+ },
},
[CustomAction.Invite]: {
description: "Invite organization members",
diff --git a/packages/prisma/migrations/20250923085350_add_list_members_private_permissions/migration.sql b/packages/prisma/migrations/20250923085350_add_list_members_private_permissions/migration.sql
new file mode 100644
index 0000000000..e74c8c7f88
--- /dev/null
+++ b/packages/prisma/migrations/20250923085350_add_list_members_private_permissions/migration.sql
@@ -0,0 +1,16 @@
+-- This permission allows admin users to view members of private teams and organizations
+
+INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
+SELECT
+ gen_random_uuid(), 'admin_role', resource, action, NOW()
+FROM (
+ VALUES
+ -- Team listMembersPrivate permission
+ ('team', 'listMembersPrivate'),
+
+ -- Organization listMembersPrivate permission
+ ('organization', 'listMembersPrivate')
+) AS permissions(resource, action)
+ON CONFLICT ("roleId", resource, action) DO NOTHING;
+
+-- Note: Owner role already has wildcard permissions (*.*) so it inherits all listMembersPrivate permissions automatically
diff --git a/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts
index 3df6206cb6..478253c59c 100644
--- a/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts
@@ -1,4 +1,6 @@
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "../../../types";
import type { TGetMembersInputSchema } from "./getMembers.schema";
@@ -16,9 +18,21 @@ export const getMembersHandler = async ({ input, ctx }: CreateOptions) => {
if (!ctx.user.organizationId) return [];
const isOrgPrivate = ctx.user.organization.isPrivate;
- const isOrgAdmin = ctx.user.organization.isOrgAdmin;
- if (isOrgPrivate && !isOrgAdmin) return [];
+ const permissionCheckService = new PermissionCheckService();
+
+ const hasPermissionToViewMembers = await permissionCheckService.checkPermission({
+ userId: ctx.user.id,
+ teamId: ctx.user.organizationId,
+ permission: ctx.user.organization.isPrivate
+ ? "organization.listMembersPrivate"
+ : "organization.listMembers",
+ fallbackRoles: ctx.user.organization.isPrivate
+ ? [MembershipRole.ADMIN, MembershipRole.OWNER]
+ : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
+ });
+
+ if (!hasPermissionToViewMembers) return [];
const teamQuery = await prisma.team.findUnique({
where: {
diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts
index dfaacf2fa3..1b09e367ec 100644
--- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts
+++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts
@@ -8,6 +8,7 @@ import { listMembersHandler } from "./listMembers.handler";
vi.mock("@calcom/prisma", () => ({
prisma,
+ default: prisma, // Add default export for db
}));
// Mock FeaturesRepository
@@ -22,9 +23,18 @@ vi.mock("@calcom/features/flags/features.repository", () => ({
vi.mock("@calcom/features/pbac/lib/resource-permissions", () => ({
getSpecificPermissions: vi.fn().mockResolvedValue({
listMembers: true,
+ listMembersPrivate: true,
}),
}));
+// Mock PermissionCheckService
+const mockCheckPermission = vi.fn().mockResolvedValue(true);
+vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
+ PermissionCheckService: vi.fn().mockImplementation(() => ({
+ checkPermission: mockCheckPermission,
+ })),
+}));
+
// Mock UserRepository
vi.mock("@calcom/lib/server/repository/user", () => ({
UserRepository: vi.fn().mockImplementation(() => ({
@@ -65,6 +75,8 @@ describe("listMembersHandler", () => {
prisma.membership.findMany.mockResolvedValue([]);
prisma.membership.count.mockResolvedValue(0);
prisma.membership.findFirst.mockResolvedValue({ role: "ADMIN" });
+ // Mock team.findUnique to return only isPrivate field since that's what the handler selects
+ prisma.team.findUnique.mockResolvedValue({ isPrivate: false } as any);
});
it("should filter by customRoleId when PBAC is enabled", async () => {
@@ -234,4 +246,72 @@ describe("listMembersHandler", () => {
})
);
});
+
+ it("should check listMembersPrivate permission for private organizations", async () => {
+ // Clear previous calls
+ mockCheckPermission.mockClear();
+ mockCheckPermission.mockResolvedValue(true);
+
+ // Mock private organization
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ prisma.team.findUnique.mockResolvedValue({ isPrivate: true } as any);
+
+ const privateOrgUser = {
+ ...mockUser,
+ organization: {
+ ...mockUser.organization,
+ isPrivate: true,
+ },
+ };
+
+ await listMembersHandler({
+ ctx: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ user: privateOrgUser as any,
+ },
+ input: {
+ limit: 25,
+ offset: 0,
+ filters: [],
+ },
+ });
+
+ // Verify that checkPermission was called with listMembersPrivate
+ expect(mockCheckPermission).toHaveBeenCalledWith(
+ expect.objectContaining({
+ permission: "organization.listMembersPrivate",
+ fallbackRoles: ["ADMIN", "OWNER"],
+ })
+ );
+ });
+
+ it("should check listMembers permission for public organizations", async () => {
+ // Clear previous calls
+ mockCheckPermission.mockClear();
+ mockCheckPermission.mockResolvedValue(true);
+
+ // Mock public organization (default)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ prisma.team.findUnique.mockResolvedValue({ isPrivate: false } as any);
+
+ await listMembersHandler({
+ ctx: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ user: mockUser as any,
+ },
+ input: {
+ limit: 25,
+ offset: 0,
+ filters: [],
+ },
+ });
+
+ // Verify that checkPermission was called with listMembers
+ expect(mockCheckPermission).toHaveBeenCalledWith(
+ expect.objectContaining({
+ permission: "organization.listMembers",
+ fallbackRoles: ["MEMBER", "ADMIN", "OWNER"],
+ })
+ );
+ });
});
diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
index c5e8196266..dea80c632c 100644
--- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
@@ -3,6 +3,7 @@ import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data-
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -87,23 +88,20 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not a member of this organization." });
}
- // Check PBAC permissions for listing organization members
- const permissions = await getSpecificPermissions({
+ const permissionCheckService = new PermissionCheckService();
+
+ const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: organizationId,
- resource: Resource.Organization,
- userRole: membership.role,
- actions: [CustomAction.ListMembers],
- fallbackRoles: {
- [CustomAction.ListMembers]: {
- roles: ctx.user.organization.isPrivate
- ? [MembershipRole.ADMIN, MembershipRole.OWNER]
- : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- },
+ permission: ctx.user.organization.isPrivate
+ ? "organization.listMembersPrivate"
+ : "organization.listMembers",
+ fallbackRoles: ctx.user.organization.isPrivate
+ ? [MembershipRole.ADMIN, MembershipRole.OWNER]
+ : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
- if (!permissions[CustomAction.ListMembers]) {
+ if (!hasPermission) {
return {
canUserGetMembers: false,
rows: [],
diff --git a/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts
index 51f3e01d0f..24df19a53f 100644
--- a/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts
@@ -1,3 +1,6 @@
+import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
+import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -16,14 +19,18 @@ type ListMembersOptions = {
export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => {
const { prisma } = ctx;
- const { isOrgAdmin } = ctx.user.organization;
- const hasPermsToView = !ctx.user.organization.isPrivate || isOrgAdmin;
+ const orgId = ctx.user.organizationId;
- if (!hasPermsToView) {
- return {
- members: [],
- nextCursor: undefined,
- };
+ // Check PBAC permissions for the organization if it's private
+ if (orgId) {
+ const hasPermsToView = await checkCanAccessOrgMembers(ctx, orgId);
+
+ if (!hasPermsToView) {
+ return {
+ members: [],
+ nextCursor: undefined,
+ };
+ }
}
const limit = input.limit ?? 10;
@@ -129,4 +136,30 @@ export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => {
};
};
+const checkCanAccessOrgMembers = async (ctx: ListMembersOptions["ctx"], orgId: number): Promise => {
+ const { prisma } = ctx;
+
+ // Get organization info to verify it's private
+ const org = await prisma.team.findUnique({
+ where: { id: orgId },
+ select: { isPrivate: true },
+ });
+
+ if (!org) return false;
+
+ // Check PBAC permissions for listing members
+ const permissionCheckService = new PermissionCheckService();
+
+ const hasPermission = await permissionCheckService.checkPermission({
+ userId: ctx.user.id,
+ teamId: orgId,
+ permission: org.isPrivate ? "organization.listMembersPrivate" : "organization.listMembers",
+ fallbackRoles: org.isPrivate
+ ? [MembershipRole.ADMIN, MembershipRole.OWNER]
+ : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
+ });
+
+ return hasPermission;
+};
+
export default legacyListMembers;
diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts
index b0daff94fa..dbcb26670b 100644
--- a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts
@@ -1,5 +1,10 @@
-import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
+import {
+ Resource,
+ CustomAction,
+ PermissionString,
+} from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
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";
@@ -157,37 +162,22 @@ const checkCanAccessMembers = async (ctx: ListMembersHandlerOptions["ctx"], team
if (!team) return false;
- // Get user's membership in the team
- const membership = await prisma.membership.findFirst({
- where: {
- teamId,
- userId: ctx.user.id,
- accepted: true,
- },
- });
-
- if (!membership) return false;
-
// Determine the resource type based on whether this is an org or team
const resource = isTargetingOrg ? Resource.Organization : Resource.Team;
+ const targetAction = team.isPrivate ? CustomAction.ListMembersPrivate : CustomAction.ListMembers;
+ const permissionString = `${resource}.${targetAction}` as PermissionString;
// Check PBAC permissions for listing members
- const permissions = await getSpecificPermissions({
+ const permissionCheckService = new PermissionCheckService();
+
+ return permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: teamId,
- resource: resource,
- userRole: membership.role,
- actions: [CustomAction.ListMembers],
- fallbackRoles: {
- [CustomAction.ListMembers]: {
- roles: team.isPrivate
- ? [MembershipRole.ADMIN, MembershipRole.OWNER]
- : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- },
+ permission: permissionString,
+ fallbackRoles: team.isPrivate
+ ? [MembershipRole.ADMIN, MembershipRole.OWNER]
+ : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
-
- return permissions[CustomAction.ListMembers];
};
export default listMembersHandler;