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 }); };