diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
index cd5ecfe83650b5..93d4fbee275ad8 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
@@ -181,14 +181,18 @@ const organizationAdminKeys = [
"delegation_credential",
];
+export interface SettingsPermissions {
+ canViewRoles?: boolean;
+}
+
const useTabs = ({
isDelegationCredentialEnabled,
isPbacEnabled,
- canViewRoles,
+ permissions,
}: {
isDelegationCredentialEnabled: boolean;
isPbacEnabled: boolean;
- canViewRoles?: boolean;
+ permissions?: SettingsPermissions;
}) => {
const session = useSession();
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
@@ -227,7 +231,7 @@ const useTabs = ({
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
// This prevents showing the menu item when user has no organization permissions
- if (isPbacEnabled && canViewRoles) {
+ if (isPbacEnabled && permissions?.canViewRoles) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
@@ -294,7 +298,7 @@ interface SettingsSidebarContainerProps {
navigationIsOpenedOnMobile?: boolean;
bannersHeight?: number;
teamFeatures?: Record;
- canViewRoles?: boolean;
+ permissions?: SettingsPermissions;
}
const TeamRolesNavItem = ({
@@ -487,7 +491,7 @@ const SettingsSidebarContainer = ({
navigationIsOpenedOnMobile,
bannersHeight,
teamFeatures,
- canViewRoles,
+ permissions,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
@@ -517,7 +521,7 @@ const SettingsSidebarContainer = ({
const tabsWithPermissions = useTabs({
isDelegationCredentialEnabled,
isPbacEnabled,
- canViewRoles,
+ permissions,
});
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
@@ -792,13 +796,13 @@ export type SettingsLayoutProps = {
children: React.ReactNode;
containerClassName?: string;
teamFeatures?: Record;
- canViewRoles?: boolean;
+ permissions?: SettingsPermissions;
} & ComponentProps;
export default function SettingsLayoutAppDirClient({
children,
teamFeatures,
- canViewRoles,
+ permissions,
...rest
}: SettingsLayoutProps) {
const pathname = usePathname();
@@ -833,7 +837,7 @@ export default function SettingsLayoutAppDirClient({
sideContainerOpen={sideContainerOpen}
setSideContainerOpen={setSideContainerOpen}
teamFeatures={teamFeatures}
- canViewRoles={canViewRoles}
+ permissions={permissions}
/>
}
drawerState={state}
@@ -856,7 +860,7 @@ type SidebarContainerElementProps = {
bannersHeight?: number;
setSideContainerOpen: React.Dispatch>;
teamFeatures?: Record;
- canViewRoles?: boolean;
+ permissions?: SettingsPermissions;
};
const SidebarContainerElement = ({
@@ -864,7 +868,7 @@ const SidebarContainerElement = ({
bannersHeight,
setSideContainerOpen,
teamFeatures,
- canViewRoles,
+ permissions,
}: SidebarContainerElementProps) => {
const { t } = useLocale();
return (
@@ -881,7 +885,7 @@ const SidebarContainerElement = ({
navigationIsOpenedOnMobile={sideContainerOpen}
bannersHeight={bannersHeight}
teamFeatures={teamFeatures}
- canViewRoles={canViewRoles}
+ permissions={permissions}
/>
>
);
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx
index e767079a314198..0f1d7ead7960ee 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx
@@ -67,7 +67,11 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
return (
<>
-
+
>
);
}
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 4c8cc8f382eba6..aadcc24c520b3f 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
@@ -3,7 +3,11 @@
import { useState } from "react";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
-import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
+import {
+ Scope,
+ CrudAction,
+ getPermissionsForScope,
+} from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Checkbox, Label } from "@calcom/ui/components/form";
@@ -17,6 +21,7 @@ interface AdvancedPermissionGroupProps {
selectedPermissions: string[];
onChange: (permissions: string[]) => void;
disabled?: boolean;
+ scope?: Scope;
}
const INTERNAL_DATAACCESS_KEY = "_resource";
@@ -26,21 +31,31 @@ export function AdvancedPermissionGroup({
selectedPermissions,
onChange,
disabled,
+ scope = Scope.Organization,
}: AdvancedPermissionGroupProps) {
const { t } = useLocale();
- const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions();
- const resourceConfig = PERMISSION_REGISTRY[resource];
+ const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
+ const scopedRegistry = getPermissionsForScope(scope);
+ const resourceConfig = scopedRegistry[resource];
const [isExpanded, setIsExpanded] = useState(false);
const isAllResources = resource === "*";
+
+ // Early return if resource is not in the scoped registry (and not the special "*" resource)
+ if (!isAllResources && !resourceConfig) {
+ return null;
+ }
+
const allResourcesSelected = selectedPermissions.includes("*.*");
// Get all possible permissions for this resource
const allPermissions = isAllResources
? ["*.*"]
- : Object.entries(resourceConfig)
+ : resourceConfig
+ ? Object.entries(resourceConfig)
.filter(([action]) => action !== INTERNAL_DATAACCESS_KEY)
- .map(([action]) => `${resource}.${action}`);
+ .map(([action]) => `${resource}.${action}`)
+ : [];
// Check if all permissions for this resource are selected
const isAllSelected = isAllResources
@@ -99,7 +114,7 @@ export function AdvancedPermissionGroup({
setIsExpanded(!isExpanded)}>
- {t(resourceConfig._resource?.i18nKey || "")}
+ {t(resourceConfig?._resource?.i18nKey || "")}
e.stopPropagation()} // Stop clicks in the permission list from affecting parent
>
- {Object.entries(resourceConfig).map(([action, actionConfig]) => {
- const permission = `${resource}.${action}`;
-
- if (action === INTERNAL_DATAACCESS_KEY) {
- return null;
- }
-
- const isChecked = selectedPermissions.includes(permission);
- const isReadPermission = action === CrudAction.Read;
- const isAutoEnabled = isReadAutoEnabled(action);
-
- return (
-
-
{
- if (!disabled) {
- onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
- }
- }}
- onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
- disabled={disabled}
- />
- e.stopPropagation()} // Stop label clicks from affecting parent
- >
-
- );
- })}{" "}
+ );
+ })}{" "}
)}
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 10ad3c90b40e40..df1053095435a2 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
@@ -145,10 +145,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
});
const onSubmit = (values: FormValues) => {
- // Store the color in localStorage
- const roleKey = isEditing && role ? role.id : `new_role_${values.name}`;
- localStorage.setItem(`role_color_${roleKey}`, values.color);
-
if (isEditing && role) {
updateMutation.mutate({
teamId,
@@ -224,6 +220,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
selectedPermissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
+ scope={scope}
/>
))}
@@ -247,6 +244,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
permissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
+ scope={scope}
/>
))}
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 c7e055fdc8d06e..5ba1ec0d86114e 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
@@ -1,6 +1,6 @@
"use client";
-import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
+import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ToggleGroup } from "@calcom/ui/components/form";
@@ -13,6 +13,7 @@ interface SimplePermissionItemProps {
permissions: string[];
onChange: (permissions: string[]) => void;
disabled?: boolean;
+ scope?: Scope;
}
export function SimplePermissionItem({
@@ -20,9 +21,10 @@ export function SimplePermissionItem({
permissions,
onChange,
disabled,
+ scope,
}: SimplePermissionItemProps) {
const { t } = useLocale();
- const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions();
+ const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
const isAllResources = resource === "*";
const options = isAllResources
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts
index 7c261eac0c9d3f..d7d5c2ea36c746 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts
@@ -1,5 +1,8 @@
-import { CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
-import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
+import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
+import {
+ PERMISSION_REGISTRY,
+ getPermissionsForScope,
+} from "@calcom/features/pbac/domain/types/permission-registry";
import {
getTransitiveDependencies,
getTransitiveDependents,
@@ -18,10 +21,11 @@ interface UsePermissionsReturn {
toggleSinglePermission: (permission: string, enabled: boolean, currentPermissions: string[]) => string[];
}
-export function usePermissions(): UsePermissionsReturn {
+export function usePermissions(scope: Scope = Scope.Organization): UsePermissionsReturn {
const getAllPossiblePermissions = () => {
const permissions: string[] = [];
- Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
+ const scopedRegistry = getPermissionsForScope(scope);
+ Object.entries(scopedRegistry).forEach(([resource, config]) => {
if (resource !== "*") {
Object.keys(config)
.filter((action) => !action.startsWith("_"))
@@ -34,7 +38,8 @@ export function usePermissions(): UsePermissionsReturn {
};
const hasAllPermissions = (permissions: string[]) => {
- return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
+ const scopedRegistry = getPermissionsForScope(scope);
+ return Object.entries(scopedRegistry).every(([resource, config]) => {
if (resource === "*") return true;
return Object.keys(config)
.filter((action) => !action.startsWith("_"))
@@ -85,7 +90,8 @@ export function usePermissions(): UsePermissionsReturn {
} else {
// Filter out current resource permissions
newPermissions = newPermissions.filter((p) => !p.startsWith(`${resource}.`));
- const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
+ const scopedRegistry = getPermissionsForScope(scope);
+ const resourceConfig = scopedRegistry[resource as keyof typeof scopedRegistry];
if (!resourceConfig) return currentPermissions;
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 815d54f2bfbcdc..4d27a38b5bf775 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,13 +1,21 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
+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 { 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 { 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";
+
import { TeamMembersView } from "~/teams/team-members-view";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
@@ -54,6 +62,12 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const teamId = parseInt(id);
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user.id) {
+ return redirect("/auth/login");
+ }
+
const teamCaller = await createRouterCaller(viewerTeamsRouter);
const team = await teamCaller.get({ teamId });
@@ -70,6 +84,54 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
getCachedTeamAttributes(organizationId),
]);
+ 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.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],
+ },
+ },
+ });
+
+ // Map specific permissions to member actions
+ const memberPermissions = {
+ canListMembers: permissions[CustomAction.ListMembers],
+ canInvite: permissions[CustomAction.Invite],
+ canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
+ canRemove: permissions[CustomAction.Remove],
+ canImpersonate: permissions[CustomAction.Impersonate],
+ };
+
const facetedTeamValues = {
roles,
teams: [team],
@@ -84,7 +146,12 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
return (
-
+
);
};
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 910607ab8eb659..588e5336dfdff4 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
@@ -1,12 +1,20 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
+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 { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
-import prisma from "@calcom/prisma";
+import { prisma } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+
import { MembersView } from "~/members/members-view";
export const generateMetadata = async () =>
@@ -38,10 +46,63 @@ const getCachedRoles = unstable_cache(
);
const Page = async () => {
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
+ return redirect("/settings/profile");
+ }
+
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
const [attributes, roles] = await Promise.all([getCachedAttributes(org.id), getCachedRoles(org.id)]);
+ const fallbackRolesThatCanSeeMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
+
+ if (!org?.isPrivate) {
+ fallbackRolesThatCanSeeMembers.push(MembershipRole.MEMBER);
+ }
+
+ // Get specific PBAC permissions for organization member actions
+ const permissions = await getSpecificPermissions({
+ userId: session.user.id,
+ teamId: session.user.profile.organizationId,
+ resource: Resource.Organization,
+ userRole: session.user.org.role,
+ actions: [
+ CustomAction.ListMembers,
+ CustomAction.Invite,
+ CustomAction.ChangeMemberRole,
+ CustomAction.Remove,
+ CustomAction.Impersonate,
+ ],
+ fallbackRoles: {
+ [CustomAction.ListMembers]: {
+ roles: fallbackRolesThatCanSeeMembers,
+ },
+ [CustomAction.Invite]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.ChangeMemberRole]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.Remove]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.Impersonate]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ });
+
+ // Map specific permissions to member actions
+ const memberPermissions = {
+ canListMembers: permissions[CustomAction.ListMembers],
+ canInvite: permissions[CustomAction.Invite],
+ canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
+ canRemove: permissions[CustomAction.Remove],
+ canImpersonate: permissions[CustomAction.Impersonate],
+ };
+
const facetedTeamValues = {
roles,
teams,
@@ -55,7 +116,13 @@ const Page = async () => {
};
return (
-
+
);
};
diff --git a/apps/web/modules/members/members-view.tsx b/apps/web/modules/members/members-view.tsx
index 123e562419b404..874bfc300cd698 100644
--- a/apps/web/modules/members/members-view.tsx
+++ b/apps/web/modules/members/members-view.tsx
@@ -4,17 +4,22 @@ import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
import type { UserListTableProps } from "@calcom/features/users/components/UserTable/UserListTable";
+import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
-export const MembersView = (props: UserListTableProps) => {
+export const MembersView = (props: UserListTableProps & { permissions?: MemberPermissions }) => {
const { t } = useLocale();
+ const { permissions, ...tableProps } = props;
+
+ // Use PBAC permissions if available, otherwise fall back to role-based check
const isOrgAdminOrOwner = props.org && checkAdminOrOwner(props.org.user.role);
const canLoggedInUserSeeMembers =
- (props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate;
+ permissions?.canListMembers ??
+ ((props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate);
return (
- {canLoggedInUserSeeMembers && }
+ {canLoggedInUserSeeMembers && }
{!canLoggedInUserSeeMembers && (
{t("only_admin_can_see_members_of_org")}
diff --git a/apps/web/modules/teams/team-members-view.tsx b/apps/web/modules/teams/team-members-view.tsx
index ed378e46164509..061cbe4b493b82 100644
--- a/apps/web/modules/teams/team-members-view.tsx
+++ b/apps/web/modules/teams/team-members-view.tsx
@@ -6,6 +6,7 @@ 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 type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -23,15 +24,17 @@ interface TeamMembersViewProps {
}[];
};
attributes?: any[];
+ permissions: MemberPermissions;
}
-export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProps) => {
+export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMembersViewProps) => {
const { t } = useLocale();
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
+ // Use PBAC permissions if available, otherwise fall back to role-based check
const isTeamAdminOrOwner = checkAdminOrOwner(team.membership.role);
- const canLoggedInUserSeeMembers = !team.isPrivate || isTeamAdminOrOwner;
+ const canLoggedInUserSeeMembers = permissions?.canListMembers ?? (!team.isPrivate || isTeamAdminOrOwner);
return (
@@ -43,6 +46,7 @@ export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProp
isOrgAdminOrOwner={false}
setShowMemberInvitationModal={setShowMemberInvitationModal}
facetedTeamValues={facetedTeamValues}
+ permissions={permissions}
/>
)}
diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts
index f3ac22c070633e..d6702d7f532c36 100644
--- a/apps/web/playwright/fixtures/users.ts
+++ b/apps/web/playwright/fixtures/users.ts
@@ -1004,8 +1004,13 @@ export async function apiLogin(
*/
await page.goto(navigateToUrl || "/settings/my-account/profile");
- // Wait for the session to be fully established
- await page.waitForLoadState();
+ // Wait for the session API call to complete to ensure session is fully established
+ // Only wait if we're on a protected page that would trigger the session API call
+ try {
+ await page.waitForResponse("/api/auth/session", { timeout: 2000 });
+ } catch (error) {
+ // Session API call not made (likely on a public page), continue anyway
+ }
return response;
}
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 3623d3c70830bd..a12acdd06a0258 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -3403,6 +3403,7 @@
"pbac_action_read_team_bookings": "View Team Bookings",
"pbac_action_read_org_bookings": "View Organization Bookings",
"pbac_action_read_recordings": "View Recordings",
+ "pbac_action_impersonate": "Impersonate",
"role_created_successfully": "Role created successfully",
"role_updated_successfully": "Role updated successfully",
"delete_role": "Delete role",
@@ -3437,7 +3438,8 @@
"pbac_desc_invite_team_members": "Invite team members",
"pbac_desc_remove_team_members": "Remove team members",
"pbac_desc_change_team_member_role": "Change role of team members",
- "pbac_desc_manage_teams": "All actions on teams",
+ "pbac_desc_impersonate_team_members": "Impersonate team members",
+ "pbac_desc_manage_teams": "All actions on teams across organization teams",
"pbac_desc_create_organization": "Create organization",
"pbac_desc_view_organization_details": "View organization details",
"pbac_desc_list_organization_members": "List organization members",
@@ -3445,6 +3447,7 @@
"pbac_desc_remove_organization_members": "Remove organization members",
"pbac_desc_manage_organization_billing": "Manage organization billing",
"pbac_desc_change_organization_member_role": "Change role of organization members",
+ "pbac_desc_impersonate_organization_members": "Impersonate organization members",
"pbac_desc_edit_organization_settings": "Edit organization settings",
"pbac_desc_manage_organizations": "All actions on organizations",
"pbac_desc_view_bookings": "View bookings",
diff --git a/packages/features/ee/impersonation/lib/ImpersonationProvider.test.ts b/packages/features/ee/impersonation/lib/ImpersonationProvider.test.ts
index 1c246947b8f699..1fd3e1a9750f7c 100644
--- a/packages/features/ee/impersonation/lib/ImpersonationProvider.test.ts
+++ b/packages/features/ee/impersonation/lib/ImpersonationProvider.test.ts
@@ -2,12 +2,14 @@ import type { Session } from "next-auth";
import { describe, expect, it } from "vitest";
import { UserPermissionRole } from "@calcom/prisma/enums";
+import { MembershipRole } from "@calcom/prisma/enums";
import {
parseTeamId,
checkSelfImpersonation,
checkUserIdentifier,
checkGlobalPermission,
+ checkPBACImpersonationPermission,
} from "./ImpersonationProvider";
const session: Session = {
@@ -79,3 +81,41 @@ describe("checkPermission", () => {
expect(() => checkGlobalPermission(session)).not.toThrow();
});
});
+
+describe("checkPBACImpersonationPermission", () => {
+ it("should return true for admin users", async () => {
+ const result = await checkPBACImpersonationPermission({
+ userId: 123,
+ teamId: 456,
+ userRole: MembershipRole.ADMIN,
+ });
+ expect(result).toBe(true);
+ });
+
+ it("should return true for owner users", async () => {
+ const result = await checkPBACImpersonationPermission({
+ userId: 123,
+ teamId: 456,
+ userRole: MembershipRole.OWNER,
+ });
+ expect(result).toBe(true);
+ });
+
+ it("should return false for member users", async () => {
+ const result = await checkPBACImpersonationPermission({
+ userId: 123,
+ teamId: 456,
+ userRole: MembershipRole.MEMBER,
+ });
+ expect(result).toBe(false);
+ });
+
+ it("should handle organization context", async () => {
+ const result = await checkPBACImpersonationPermission({
+ userId: 123,
+ organizationId: 789,
+ userRole: MembershipRole.ADMIN,
+ });
+ expect(result).toBe(true);
+ });
+});
diff --git a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts
index f3b33d2c5aa8b4..7ecf6a1bb45b63 100644
--- a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts
+++ b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts
@@ -5,12 +5,16 @@ import { z } from "zod";
import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensureOrganizationIsReviewed";
import { getSession } from "@calcom/features/auth/lib/getSession";
+import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { Membership } from "@calcom/prisma/client";
+import { MembershipRole } from "@calcom/prisma/enums";
import type { OrgProfile, PersonalProfile, UserAsPersonalProfile } from "@calcom/types/UserProfile";
+import { Resource, CustomAction } from "../../../pbac/domain/types/permission-registry";
+
const teamIdschema = z.object({
teamId: z.preprocess((a) => parseInt(z.string().parse(a), 10), z.number().positive()),
});
@@ -113,6 +117,69 @@ export function checkGlobalPermission(session: Session | null) {
}
}
+/**
+ * Check PBAC permissions for impersonation
+ * This function integrates with the new PBAC system to determine impersonation permissions
+ */
+export async function checkPBACImpersonationPermission({
+ userId,
+ teamId,
+ userRole,
+ organizationId,
+}: {
+ userId: number;
+ teamId?: number;
+ userRole: MembershipRole;
+ organizationId?: number | null;
+}): Promise {
+ try {
+ // For organization-level impersonation
+ if (organizationId) {
+ const orgPermissions = await getSpecificPermissions({
+ userId,
+ teamId: organizationId,
+ resource: Resource.Organization,
+ userRole,
+ actions: [CustomAction.Impersonate],
+ fallbackRoles: {
+ [CustomAction.Impersonate]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ });
+
+ if (orgPermissions[CustomAction.Impersonate]) {
+ return true;
+ }
+ }
+
+ // For team-level impersonation
+ if (teamId) {
+ const teamPermissions = await getSpecificPermissions({
+ userId,
+ teamId,
+ resource: Resource.Team,
+ userRole,
+ actions: [CustomAction.Impersonate],
+ fallbackRoles: {
+ [CustomAction.Impersonate]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ });
+
+ return teamPermissions[CustomAction.Impersonate] ?? false;
+ }
+
+ // Fallback to role-based check if no team/org context
+ return userRole === MembershipRole.ADMIN || userRole === MembershipRole.OWNER;
+ } catch (error) {
+ console.error("Error checking PBAC impersonation permission:", error);
+ // Fallback to role-based check on error
+ return userRole === MembershipRole.ADMIN || userRole === MembershipRole.OWNER;
+ }
+}
+
async function getImpersonatedUser({
session,
teamId,
@@ -295,11 +362,6 @@ const ImpersonationProvider = CredentialsProvider({
teams: {
where: {
AND: [
- {
- role: {
- in: ["ADMIN", "OWNER"],
- },
- },
{
team: {
id: teamId,
@@ -318,6 +380,19 @@ const ImpersonationProvider = CredentialsProvider({
throw new Error("Error-UserHasNoTeams: You do not have permission to do this.");
}
+ // Check PBAC permissions for impersonation
+ const hasImpersonationPermission = await checkPBACImpersonationPermission({
+ userId: session?.user.id as number,
+ teamId,
+ userRole: sessionUserFromDb?.teams[0].role as MembershipRole,
+ organizationId: session?.user.org?.id,
+ });
+
+ if (!hasImpersonationPermission) {
+ throw new Error("You do not have permission to impersonate this user.");
+ }
+
+ // Legacy role check as additional safeguard (PBAC should handle this but keeping for backwards compatibility)
// We find team by ID so we know there is only one team in the array
if (sessionUserFromDb?.teams[0].role === "ADMIN" && impersonatedUser.teams[0].role === "OWNER") {
throw new Error("You do not have permission to do this.");
diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx
index 3d69918beda506..929eec88af8557 100644
--- a/packages/features/ee/teams/components/MemberList.tsx
+++ b/packages/features/ee/teams/components/MemberList.tsx
@@ -15,7 +15,6 @@ import { useQueryState, parseAsBoolean } from "nuqs";
import { useMemo, useReducer, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
-import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import {
DataTableProvider,
@@ -30,10 +29,10 @@ import {
} from "@calcom/features/data-table";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink";
+import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
@@ -163,6 +162,7 @@ interface Props {
}[];
}[];
};
+ permissions: MemberPermissions;
}
export default function MemberList(props: Props) {
@@ -292,8 +292,6 @@ function MemberListContent(props: Props) {
// return owners.length;
// };
- const isAdminOrOwner = checkAdminOrOwner(props.team.membership.role);
-
const removeMember = () =>
removeMemberMutation.mutate({
teamIds: [props.team?.id],
@@ -429,17 +427,19 @@ function MemberListContent(props: Props) {
cell: ({ row }) => {
const user = row.original;
const isSelf = user.id === session?.user.id;
+ // TODO(SEAN) In a follow up can we rename canChangeMemberRole to canEditMembers - role is a bit specific.
+ const canChangeRole = props.permissions?.canChangeMemberRole ?? false;
+ const canRemove = props.permissions?.canRemove ?? false;
+ const canImpersonate = props.permissions?.canImpersonate ?? false;
+ const canResendInvitation = props.permissions?.canInvite ?? false;
const editMode =
- (props.team.membership?.role === MembershipRole.OWNER &&
- (user.role !== MembershipRole.OWNER || !isSelf)) ||
- (props.team.membership?.role === MembershipRole.ADMIN && user.role !== MembershipRole.OWNER) ||
- props.isOrgAdminOrOwner;
+ [canChangeRole, canRemove, canImpersonate, canResendInvitation].some(Boolean) && !isSelf;
+
const impersonationMode =
- editMode &&
+ canImpersonate &&
!user.disableImpersonation &&
user.accepted &&
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true";
- const resendInvitation = editMode && !user.accepted;
return (
<>
{props.team.membership?.accepted && (
@@ -495,22 +495,24 @@ function MemberListContent(props: Props) {
-
-
- dispatch({
- type: "EDIT_USER_SHEET",
- payload: {
- user,
- showModal: true,
- },
- })
- }
- StartIcon="pencil">
- {t("edit")}
-
-
+ {canChangeRole ? (
+
+
+ dispatch({
+ type: "EDIT_USER_SHEET",
+ payload: {
+ user,
+ showModal: true,
+ },
+ })
+ }
+ StartIcon="pencil">
+ {t("edit")}
+
+
+ ) : null}
{impersonationMode && (
<>
@@ -532,7 +534,7 @@ function MemberListContent(props: Props) {
>
)}
- {resendInvitation && (
+ {canResendInvitation && (
)}
-
-
- dispatch({
- type: "SET_DELETE_ID",
- payload: {
- user,
- showModal: true,
- },
- })
- }
- color="destructive"
- StartIcon="user-x">
- {t("remove")}
-
-
+ {canRemove ? (
+
+
+ dispatch({
+ type: "SET_DELETE_ID",
+ payload: {
+ user,
+ showModal: true,
+ },
+ })
+ }
+ color="destructive"
+ StartIcon="user-x">
+ {t("remove")}
+
+
+ ) : null}
@@ -713,7 +717,7 @@ function MemberListContent(props: Props) {
ToolbarRight={
<>
- {isAdminOrOwner && (
+ {props.permissions.canInvite && (
> => {
+ const featureRepo = new FeaturesRepository(prisma);
+ const permissionService = new PermissionCheckService();
+
+ const pbacEnabled = await featureRepo.checkIfTeamHasFeature(teamId, "pbac");
+
+ // If PBAC is disabled, use fallback role configuration
+ if (!pbacEnabled) {
+ const permissions: Record = {};
+ for (const action of actions) {
+ permissions[action] = checkRoleAccess(userRole, fallbackRoles[action]);
+ }
+ return permissions;
+ }
+
+ // PBAC is enabled, get permissions from the service
+ const resourcePermissions = await permissionService.getResourcePermissions({
+ userId,
+ teamId,
+ resource,
+ });
+
+ const roleActions = PermissionMapper.toActionMap(resourcePermissions, resource);
+
+ const permissions: Record = {};
+ for (const action of actions) {
+ permissions[action] = roleActions[action] ?? false;
+ }
+
+ return permissions;
+};
diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx
index 9343c17eeadec8..6e3f80cddf78a6 100644
--- a/packages/features/users/components/UserTable/UserListTable.tsx
+++ b/packages/features/users/components/UserTable/UserListTable.tsx
@@ -50,7 +50,7 @@ import { EditUserSheet } from "./EditSheet/EditUserSheet";
import { ImpersonationMemberModal } from "./ImpersonationMemberModal";
import { InviteMemberModal } from "./InviteMemberModal";
import { TableActions } from "./UserTableActions";
-import type { UserTableState, UserTableAction, UserTableUser } from "./types";
+import type { UserTableState, UserTableAction, UserTableUser, MemberPermissions } from "./types";
const initialState: UserTableState = {
changeMemberRole: {
@@ -122,6 +122,7 @@ export type UserListTableProps = {
}[];
}[];
};
+ permissions?: MemberPermissions;
};
export function UserListTable(props: UserListTableProps) {
@@ -132,7 +133,13 @@ export function UserListTable(props: UserListTableProps) {
);
}
-function UserListTableContent({ org, attributes, teams, facetedTeamValues }: UserListTableProps) {
+function UserListTableContent({
+ org,
+ attributes,
+ teams,
+ facetedTeamValues,
+ permissions,
+}: UserListTableProps) {
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
const orgBranding = useOrgBranding();
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
@@ -169,11 +176,12 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
const flatData = useMemo(() => data?.rows ?? [], [data]);
const memorisedColumns = useMemo(() => {
- const permissions = {
- canEdit: adminOrOwner,
- canRemove: adminOrOwner,
- canResendInvitation: adminOrOwner,
- canImpersonate: false,
+ // Use PBAC permissions if available, otherwise fall back to role-based check
+ const tablePermissions = {
+ canEdit: permissions?.canChangeMemberRole ?? adminOrOwner,
+ canRemove: permissions?.canRemove ?? adminOrOwner,
+ canResendInvitation: permissions?.canInvite ?? adminOrOwner,
+ canImpersonate: permissions?.canImpersonate ?? adminOrOwner,
};
const generateAttributeColumns = () => {
if (!attributes?.length) {
@@ -445,14 +453,18 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
size: 80,
cell: ({ row }) => {
const user = row.original;
- const permissionsRaw = permissions;
+ const permissionsRaw = tablePermissions;
const isSelf = user.id === session?.user.id;
const permissionsForUser = {
canEdit: permissionsRaw.canEdit && user.accepted && !isSelf,
canRemove: permissionsRaw.canRemove && !isSelf,
canImpersonate:
- user.accepted && !user.disableImpersonation && !isSelf && !!org?.canAdminImpersonate,
+ user.accepted &&
+ !user.disableImpersonation &&
+ !isSelf &&
+ !!org?.canAdminImpersonate &&
+ permissionsRaw.canImpersonate,
canLeave: user.accepted && isSelf,
canResendInvitation: permissionsRaw.canResendInvitation && !user.accepted,
};
@@ -470,7 +482,7 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
];
return cols;
- }, [session?.user.id, adminOrOwner, dispatch, domain, attributes, org?.canAdminImpersonate]);
+ }, [session?.user.id, adminOrOwner, dispatch, domain, attributes, org?.canAdminImpersonate, permissions]);
const table = useReactTable({
data: flatData,
@@ -619,7 +631,7 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
{!isPlatformUser ? (
<>
- {adminOrOwner && }
+ {permissions?.canChangeMemberRole && }
{numberOfSelectedRows >= 2 && (
)}
- {adminOrOwner && }
- {adminOrOwner && }
+ {(permissions?.canChangeMemberRole ?? adminOrOwner) && (
+
+ )}
+ {(permissions?.canChangeMemberRole ?? adminOrOwner) && (
+
+ )}
>
) : null}
- {adminOrOwner && (
+ {(permissions?.canRemove ?? adminOrOwner) && (
row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
@@ -660,7 +676,7 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
data-testid="export-members-button">
{t("download")}
- {adminOrOwner && (
+ {(permissions?.canInvite ?? adminOrOwner) && (
({
})),
}));
+// Mock PBAC permissions
+vi.mock("@calcom/features/pbac/lib/resource-permissions", () => ({
+ getSpecificPermissions: vi.fn().mockResolvedValue({
+ listMembers: true,
+ }),
+}));
+
+// Mock UserRepository
+vi.mock("@calcom/lib/server/repository/user", () => ({
+ UserRepository: vi.fn().mockImplementation(() => ({
+ enrichUserWithItsProfile: vi.fn().mockImplementation(({ user }) => user),
+ })),
+}));
+
const ORGANIZATION_ID = 123;
const mockUser = {
diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
index fc661b0b76dccc..c5e81962667a8e 100644
--- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
@@ -1,9 +1,12 @@
import { makeWhereClause } from "@calcom/features/data-table/lib/server";
import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data-table/lib/types";
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 { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
+import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -69,7 +72,38 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." });
}
- if (ctx.user.organization.isPrivate && !ctx.user.organization.isOrgAdmin) {
+ // Get user's membership role in the organization
+ const membership = await prisma.membership.findFirst({
+ where: {
+ userId: ctx.user.id,
+ teamId: organizationId,
+ },
+ select: {
+ role: true,
+ },
+ });
+
+ if (!membership) {
+ 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({
+ 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],
+ },
+ },
+ });
+
+ if (!permissions[CustomAction.ListMembers]) {
return {
canUserGetMembers: false,
rows: [],
@@ -78,7 +112,6 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
},
};
}
-
const { limit, offset } = input;
const roleFilter = filters.find((filter) => filter.id === "role") as
diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts
index eec2451fc5446d..415abe9be86a24 100644
--- a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts
@@ -1,11 +1,13 @@
import type { Prisma } from "@prisma/client";
-import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
+import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
+import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
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 { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -146,26 +148,17 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersHandlerOptio
};
const checkCanAccessMembers = async (ctx: ListMembersHandlerOptions["ctx"], teamId: number) => {
- const isOrgPrivate = ctx.user.profile?.organization?.isPrivate;
- const isOrgAdminOrOwner = ctx.user.organization?.isOrgAdmin;
- const orgId = ctx.user.organizationId;
const isTargetingOrg = teamId === ctx.user.organizationId;
- if (isTargetingOrg) {
- return isOrgAdminOrOwner || !isOrgPrivate;
- }
+ // Get team info to check if it's private
const team = await prisma.team.findUnique({
- where: {
- id: teamId,
- },
+ where: { id: teamId },
+ select: { isPrivate: true },
});
if (!team) return false;
- if (isOrgAdminOrOwner && team?.parentId === orgId) {
- return true;
- }
-
+ // Get user's membership in the team
const membership = await prisma.membership.findFirst({
where: {
teamId,
@@ -176,12 +169,26 @@ const checkCanAccessMembers = async (ctx: ListMembersHandlerOptions["ctx"], team
if (!membership) return false;
- const isTeamAdminOrOwner = checkAdminOrOwner(membership?.role);
+ // Determine the resource type based on whether this is an org or team
+ const resource = isTargetingOrg ? Resource.Organization : Resource.Team;
+
+ // Check PBAC permissions for listing members
+ const permissions = await getSpecificPermissions({
+ 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],
+ },
+ },
+ });
- if (team?.isPrivate && !isTeamAdminOrOwner) {
- return false;
- }
- return true;
+ return permissions[CustomAction.ListMembers];
};
export default listMembersHandler;
diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts
index 1ce45d95f79b43..82323bfa91cc2a 100644
--- a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts
@@ -1,6 +1,11 @@
+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 { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
-import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
+import { isTeamOwner } from "@calcom/lib/server/queries/teams";
import { TeamService } from "@calcom/lib/server/service/teamService";
+import { prisma } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -22,42 +27,93 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) =
const { memberIds, teamIds, isOrg } = input;
- const isAdmin = await Promise.all(
- teamIds.map(async (teamId) => await isTeamAdmin(ctx.user.id, teamId))
+ // Check PBAC permissions for each team
+ const hasRemovePermission = await Promise.all(
+ teamIds.map(async (teamId) => {
+ // Get user's membership role in this team
+ const membership = await prisma.membership.findFirst({
+ where: {
+ userId: ctx.user.id,
+ teamId: teamId,
+ },
+ select: {
+ role: true,
+ },
+ });
+
+ if (!membership) return false;
+
+ // Check PBAC permissions for removing team members
+ const permissions = await getSpecificPermissions({
+ userId: ctx.user.id,
+ teamId: teamId,
+ resource: isOrg ? Resource.Organization : Resource.Team,
+ userRole: membership.role,
+ actions: [CustomAction.Remove],
+ fallbackRoles: {
+ [CustomAction.Remove]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ });
+
+ return permissions[CustomAction.Remove];
+ })
).then((results) => results.every((result) => result));
- const isOrgAdmin = ctx.user.profile?.organizationId
- ? await isTeamAdmin(ctx.user.id, ctx.user.profile?.organizationId)
- : false;
+ // Check if user is trying to remove themselves (allowed for non-owners)
+ const isRemovingSelf = memberIds.length === 1 && memberIds[0] === ctx.user.id;
- if (!(isAdmin || isOrgAdmin) && memberIds.every((memberId) => ctx.user.id !== memberId))
+ // Allow if user has remove permission OR if they're removing themselves
+ if (!hasRemovePermission && !isRemovingSelf) {
throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
- // Only a team owner can remove another team owner.
- const isAnyMemberOwnerAndCurrentUserNotOwner = await Promise.all(
- memberIds.map(async (memberId) => {
- const isAnyTeamOwnerAndCurrentUserNotOwner = await Promise.all(
- teamIds.map(async (teamId) => {
- return (await isTeamOwner(memberId, teamId)) && !(await isTeamOwner(ctx.user.id, teamId));
- })
- ).then((results) => results.some((result) => result));
+ // TODO(SEAN): Remove this after PBAC is rolled out.
+ // Check if any team has PBAC enabled
+ const featuresRepository = new FeaturesRepository(prisma);
+ const pbacEnabledForTeams = await Promise.all(
+ teamIds.map(async (teamId) => await featuresRepository.checkIfTeamHasFeature(teamId, "pbac"))
+ );
+ const isAnyTeamPBACEnabled = pbacEnabledForTeams.some((enabled) => enabled);
- return isAnyTeamOwnerAndCurrentUserNotOwner;
- })
- ).then((results) => results.some((result) => result));
+ // Only apply traditional owner-based logic if PBAC is not enabled for any teams
+ if (!isAnyTeamPBACEnabled) {
+ // Only a team owner can remove another team owner.
+ const isAnyMemberOwnerAndCurrentUserNotOwner = await Promise.all(
+ memberIds.map(async (memberId) => {
+ const isAnyTeamOwnerAndCurrentUserNotOwner = await Promise.all(
+ teamIds.map(async (teamId) => {
+ return (await isTeamOwner(memberId, teamId)) && !(await isTeamOwner(ctx.user.id, teamId));
+ })
+ ).then((results) => results.some((result) => result));
- if (isAnyMemberOwnerAndCurrentUserNotOwner) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "Only a team owner can remove another team owner.",
- });
- }
+ return isAnyTeamOwnerAndCurrentUserNotOwner;
+ })
+ ).then((results) => results.some((result) => result));
+
+ if (isAnyMemberOwnerAndCurrentUserNotOwner) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Only a team owner can remove another team owner.",
+ });
+ }
- if (memberIds.some((memberId) => ctx.user.id === memberId) && isAdmin && !isOrgAdmin)
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "You can not remove yourself from a team you own.",
- });
+ // Check if user is trying to remove themselves from a team they own (prevent this)
+ if (isRemovingSelf && hasRemovePermission) {
+ // Additional check: ensure they're not an owner trying to remove themselves
+ const isOwnerOfAnyTeam = await Promise.all(
+ teamIds.map(async (teamId) => await isTeamOwner(ctx.user.id, teamId))
+ ).then((results) => results.some((result) => result));
+
+ if (isOwnerOfAnyTeam) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You can not remove yourself from a team you own.",
+ });
+ }
+ }
+ }
await TeamService.removeMembers({ teamIds, userIds: memberIds, isOrg });
};