feat: PBAC on team members page + org members page to use specific permissi… (#23004)
* PBAC on team members page + org members page to use specific permissions with fallbacks * Implement checks on edit sheet fetch to check for permisisons * WIP permission fixes * fix tests * WIP * fix edit mode perms * fix remove and list view permissions * fix add * feat: add pbac permissions for impersonation (#23035) * registery + migration + i18n * UI for can impersonate * impersonation backend * remove permission from db query * add missing prisma import * add resend invitation and edit mode checks for delete/changeRole * fix canChangeRole * canChangeRole gate * fix permission logic * fix depends on * push migration * fix migration * fix scoped advanced permissions * use listMembers instead of invite proxy * remove isOrgAdmin check to rely on pbac * wait for session to load before navigating * use event-types testId to ensure session loaded * use profile page instead of eventTypes * use correct roles * fix wait for session * correct orgs private isFallBackRoles * set a timeout on waiting for session * remove adminOrOwner check since its in fallpack --------- Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com> Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
co-authored by
Volnei Munhoz
Eunjae Lee
parent
2dbd0b3832
commit
75465ac4bf
+16
-12
@@ -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<number, TeamFeatures>;
|
||||
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<number, TeamFeatures>;
|
||||
canViewRoles?: boolean;
|
||||
permissions?: SettingsPermissions;
|
||||
} & ComponentProps<typeof Shell>;
|
||||
|
||||
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<React.SetStateAction<boolean>>;
|
||||
teamFeatures?: Record<number, TeamFeatures>;
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,11 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} canViewRoles={canViewRoles} />
|
||||
<SettingsLayoutAppDirClient
|
||||
{...props}
|
||||
teamFeatures={teamFeatures ?? {}}
|
||||
permissions={{ canViewRoles }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+66
-50
@@ -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({
|
||||
<span
|
||||
className="text-default cursor-pointer text-sm font-medium leading-none"
|
||||
onClick={() => setIsExpanded(!isExpanded)}>
|
||||
{t(resourceConfig._resource?.i18nKey || "")}
|
||||
{t(resourceConfig?._resource?.i18nKey || "")}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted cursor-pointer text-sm font-medium leading-none"
|
||||
@@ -113,56 +128,57 @@ export function AdvancedPermissionGroup({
|
||||
className="bg-default border-muted m-1 flex flex-col gap-2.5 rounded-xl border p-3"
|
||||
onClick={(e) => e.stopPropagation()} // Stop clicks in the permission list from affecting parent
|
||||
>
|
||||
{Object.entries(resourceConfig).map(([action, actionConfig]) => {
|
||||
const permission = `${resource}.${action}`;
|
||||
{resourceConfig &&
|
||||
Object.entries(resourceConfig).map(([action, actionConfig]) => {
|
||||
const permission = `${resource}.${action}`;
|
||||
|
||||
if (action === INTERNAL_DATAACCESS_KEY) {
|
||||
return null;
|
||||
}
|
||||
if (action === INTERNAL_DATAACCESS_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isChecked = selectedPermissions.includes(permission);
|
||||
const isReadPermission = action === CrudAction.Read;
|
||||
const isAutoEnabled = isReadAutoEnabled(action);
|
||||
const isChecked = selectedPermissions.includes(permission);
|
||||
const isReadPermission = action === CrudAction.Read;
|
||||
const isAutoEnabled = isReadAutoEnabled(action);
|
||||
|
||||
return (
|
||||
<div key={action} className="flex items-center">
|
||||
<Checkbox
|
||||
id={permission}
|
||||
checked={isChecked}
|
||||
className="mr-2"
|
||||
onCheckedChange={(checked) => {
|
||||
if (!disabled) {
|
||||
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
|
||||
>
|
||||
<Label htmlFor={permission} className="mb-0">
|
||||
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
|
||||
{t(actionConfig?.i18nKey || "")}
|
||||
return (
|
||||
<div key={action} className="flex items-center">
|
||||
<Checkbox
|
||||
id={permission}
|
||||
checked={isChecked}
|
||||
className="mr-2"
|
||||
onCheckedChange={(checked) => {
|
||||
if (!disabled) {
|
||||
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
|
||||
>
|
||||
<Label htmlFor={permission} className="mb-0">
|
||||
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
|
||||
{t(actionConfig?.i18nKey || "")}
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-sm text-gray-500">
|
||||
{t(
|
||||
actionConfig && "descriptionI18nKey" in actionConfig
|
||||
? actionConfig.descriptionI18nKey
|
||||
: ""
|
||||
)}
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-sm text-gray-500">
|
||||
{t(
|
||||
actionConfig && "descriptionI18nKey" in actionConfig
|
||||
? actionConfig.descriptionI18nKey
|
||||
: ""
|
||||
{isAutoEnabled && (
|
||||
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
|
||||
<Icon name="info" className="text-muted-foreground h-3 w-3" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
{isAutoEnabled && (
|
||||
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
|
||||
<Icon name="info" className="text-muted-foreground h-3 w-3" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}{" "}
|
||||
);
|
||||
})}{" "}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+2
-4
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+12
-6
@@ -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;
|
||||
|
||||
|
||||
+69
-2
@@ -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 (
|
||||
<SettingsHeader title={t("team_members")} description={t("members_team_description")}>
|
||||
<TeamMembersView team={team} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||
<TeamMembersView
|
||||
team={team}
|
||||
facetedTeamValues={facetedTeamValues}
|
||||
attributes={attributes}
|
||||
permissions={memberPermissions}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+69
-2
@@ -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 (
|
||||
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||
<MembersView
|
||||
org={org}
|
||||
teams={teams}
|
||||
facetedTeamValues={facetedTeamValues}
|
||||
attributes={attributes}
|
||||
permissions={memberPermissions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<LicenseRequired>
|
||||
<div>{canLoggedInUserSeeMembers && <UserListTable {...props} />}</div>
|
||||
<div>{canLoggedInUserSeeMembers && <UserListTable {...tableProps} permissions={permissions} />}</div>
|
||||
{!canLoggedInUserSeeMembers && (
|
||||
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
|
||||
<h2 className="text-default">{t("only_admin_can_see_members_of_org")}</h2>
|
||||
|
||||
@@ -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 (
|
||||
<LicenseRequired>
|
||||
@@ -43,6 +46,7 @@ export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProp
|
||||
isOrgAdminOrOwner={false}
|
||||
setShowMemberInvitationModal={setShowMemberInvitationModal}
|
||||
facetedTeamValues={facetedTeamValues}
|
||||
permissions={permissions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean> {
|
||||
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.");
|
||||
|
||||
@@ -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) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "EDIT_USER_SHEET",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{canChangeRole ? (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "EDIT_USER_SHEET",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{impersonationMode && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
@@ -532,7 +534,7 @@ function MemberListContent(props: Props) {
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{resendInvitation && (
|
||||
{canResendInvitation && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
@@ -548,23 +550,25 @@ function MemberListContent(props: Props) {
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_DELETE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
color="destructive"
|
||||
StartIcon="user-x">
|
||||
{t("remove")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{canRemove ? (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_DELETE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
color="destructive"
|
||||
StartIcon="user-x">
|
||||
{t("remove")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
@@ -713,7 +717,7 @@ function MemberListContent(props: Props) {
|
||||
ToolbarRight={
|
||||
<>
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
{isAdminOrOwner && (
|
||||
{props.permissions.canInvite && (
|
||||
<DataTableToolbar.CTA
|
||||
type="button"
|
||||
color="primary"
|
||||
|
||||
@@ -28,6 +28,7 @@ export enum CustomAction {
|
||||
ReadTeamBookings = "readTeamBookings",
|
||||
ReadOrgBookings = "readOrgBookings",
|
||||
ReadRecordings = "readRecordings",
|
||||
Impersonate = "impersonate",
|
||||
}
|
||||
|
||||
export enum Scope {
|
||||
@@ -263,21 +264,34 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_invite",
|
||||
descriptionI18nKey: "pbac_desc_invite_team_members",
|
||||
dependsOn: ["team.read"],
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
},
|
||||
[CustomAction.Remove]: {
|
||||
description: "Remove team members",
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_remove",
|
||||
descriptionI18nKey: "pbac_desc_remove_team_members",
|
||||
dependsOn: ["team.read"],
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
},
|
||||
[CustomAction.ListMembers]: {
|
||||
description: "List team members",
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_list_members",
|
||||
descriptionI18nKey: "pbac_desc_list_team_members",
|
||||
},
|
||||
[CustomAction.ChangeMemberRole]: {
|
||||
description: "Change role of team members",
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_change_member_role",
|
||||
descriptionI18nKey: "pbac_desc_change_team_member_role",
|
||||
dependsOn: ["team.read"],
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
description: "Impersonate team members",
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_impersonate",
|
||||
descriptionI18nKey: "pbac_desc_impersonate_team_members",
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
},
|
||||
},
|
||||
[Resource.Organization]: {
|
||||
@@ -338,6 +352,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
|
||||
scope: [Scope.Organization],
|
||||
dependsOn: ["organization.listMembers", "role.read"],
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
description: "Impersonate organization members",
|
||||
category: "org",
|
||||
i18nKey: "pbac_action_impersonate",
|
||||
descriptionI18nKey: "pbac_desc_impersonate_organization_members",
|
||||
scope: [Scope.Organization],
|
||||
},
|
||||
[CrudAction.Update]: {
|
||||
description: "Edit organization settings",
|
||||
category: "org",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { PermissionMapper } from "../domain/mappers/PermissionMapper";
|
||||
import type { Resource } from "../domain/types/permission-registry";
|
||||
import { CrudAction } from "../domain/types/permission-registry";
|
||||
import { CrudAction, CustomAction } from "../domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "../services/permission-check.service";
|
||||
|
||||
interface RoleMapping {
|
||||
@@ -85,3 +85,58 @@ export const getResourcePermissions = async ({
|
||||
canCreate: roleActions[CrudAction.Create] ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
// Enhanced function to get specific custom action permissions
|
||||
type ActionType = CrudAction | CustomAction;
|
||||
|
||||
interface SpecificActionMapping {
|
||||
[key: string]: RoleMapping;
|
||||
}
|
||||
|
||||
interface SpecificPermissionsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
resource: Resource;
|
||||
userRole: MembershipRole;
|
||||
actions: ActionType[];
|
||||
fallbackRoles?: SpecificActionMapping;
|
||||
}
|
||||
|
||||
export const getSpecificPermissions = async ({
|
||||
userId,
|
||||
teamId,
|
||||
resource,
|
||||
userRole,
|
||||
actions,
|
||||
fallbackRoles = {},
|
||||
}: SpecificPermissionsOptions): Promise<Record<string, boolean>> => {
|
||||
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<string, boolean> = {};
|
||||
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<string, boolean> = {};
|
||||
for (const action of actions) {
|
||||
permissions[action] = roleActions[action] ?? false;
|
||||
}
|
||||
|
||||
return permissions;
|
||||
};
|
||||
|
||||
@@ -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<UserTableUser[]>(() => 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
|
||||
</p>
|
||||
{!isPlatformUser ? (
|
||||
<>
|
||||
{adminOrOwner && <TeamListBulkAction table={table} />}
|
||||
{permissions?.canChangeMemberRole && <TeamListBulkAction table={table} />}
|
||||
{numberOfSelectedRows >= 2 && (
|
||||
<DataTableSelectionBar.Button
|
||||
color="secondary"
|
||||
@@ -628,11 +640,15 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
|
||||
{t("group_meeting")}
|
||||
</DataTableSelectionBar.Button>
|
||||
)}
|
||||
{adminOrOwner && <MassAssignAttributesBulkAction table={table} filters={columnFilters} />}
|
||||
{adminOrOwner && <EventTypesList table={table} orgTeams={teams} />}
|
||||
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
|
||||
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
|
||||
)}
|
||||
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
|
||||
<EventTypesList table={table} orgTeams={teams} />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{adminOrOwner && (
|
||||
{(permissions?.canRemove ?? adminOrOwner) && (
|
||||
<DeleteBulkUsers
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
|
||||
onRemove={() => table.toggleAllPageRowsSelected(false)}
|
||||
@@ -660,7 +676,7 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
|
||||
data-testid="export-members-button">
|
||||
{t("download")}
|
||||
</DataTableToolbar.CTA>
|
||||
{adminOrOwner && (
|
||||
{(permissions?.canInvite ?? adminOrOwner) && (
|
||||
<DataTableToolbar.CTA
|
||||
type="button"
|
||||
color="primary"
|
||||
|
||||
@@ -52,3 +52,11 @@ export type PlatformManagedUserTableAction =
|
||||
| {
|
||||
type: "CLOSE_MODAL";
|
||||
};
|
||||
|
||||
export interface MemberPermissions {
|
||||
canListMembers: boolean;
|
||||
canInvite: boolean;
|
||||
canChangeMemberRole: boolean;
|
||||
canRemove: boolean;
|
||||
canImpersonate: boolean;
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
-- Add team.listMembers permission to admin role
|
||||
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
|
||||
SELECT
|
||||
gen_random_uuid(), 'admin_role', resource, action, NOW()
|
||||
FROM (
|
||||
VALUES
|
||||
('team', 'listMembers')
|
||||
) AS permissions(resource, action)
|
||||
ON CONFLICT ("roleId", resource, action) DO NOTHING;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
-- Add impersonate permissions to admin role
|
||||
-- These permissions allow administrators to impersonate team and organization members
|
||||
|
||||
-- Insert impersonate permissions for admin role
|
||||
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
|
||||
SELECT
|
||||
gen_random_uuid(), 'admin_role', resource, action, NOW()
|
||||
FROM (
|
||||
VALUES
|
||||
-- Team impersonate permission
|
||||
('team', 'impersonate'),
|
||||
|
||||
-- Organization impersonate permission
|
||||
('organization', 'impersonate')
|
||||
) AS permissions(resource, action)
|
||||
ON CONFLICT ("roleId", resource, action) DO NOTHING;
|
||||
|
||||
-- Note: Owner role already has wildcard permissions (*.*) so it inherits all impersonate permissions automatically
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
|
||||
SELECT
|
||||
gen_random_uuid(), 'member_role', resource, action, NOW()
|
||||
FROM (
|
||||
VALUES
|
||||
('team', 'listMembers')
|
||||
) AS permissions(resource, action);
|
||||
@@ -1,7 +1,9 @@
|
||||
import { TeamBilling } from "@calcom/ee/billing/teams";
|
||||
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
|
||||
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -21,9 +23,38 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand
|
||||
|
||||
if (!currentUserOrgId) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
// check if user is admin of organization
|
||||
if (!(await isOrganisationAdmin(currentUser?.id, currentUserOrgId)))
|
||||
// Get user's membership role in the organization
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: currentUser.id,
|
||||
teamId: currentUserOrgId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not a member of this organization." });
|
||||
}
|
||||
|
||||
// Check PBAC permissions for removing organization members
|
||||
const permissions = await getSpecificPermissions({
|
||||
userId: currentUser.id,
|
||||
teamId: currentUserOrgId,
|
||||
resource: Resource.Organization,
|
||||
userRole: membership.role,
|
||||
actions: [CustomAction.Remove],
|
||||
fallbackRoles: {
|
||||
[CustomAction.Remove]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!permissions[CustomAction.Remove]) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
// Loop over all users in input.userIds and remove all memberships for the organization including child teams
|
||||
const deleteMany = prisma.membership.deleteMany({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
|
||||
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -18,9 +20,42 @@ export async function getUserHandler({ input, ctx }: AdminVerifyOptions) {
|
||||
|
||||
if (!currentUser.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
// check if user is admin of organization
|
||||
if (!(await isOrganisationAdmin(currentUser?.id, currentUser.organizationId)))
|
||||
// Get user's membership role in the organization
|
||||
const currentUserMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: currentUser.id,
|
||||
teamId: currentUser.organizationId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentUserMembership) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not a member of this organization." });
|
||||
}
|
||||
|
||||
// Check PBAC permissions for viewing/editing organization members
|
||||
const permissions = await getSpecificPermissions({
|
||||
userId: currentUser.id,
|
||||
teamId: currentUser.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: currentUserMembership.role,
|
||||
actions: [CustomAction.ListMembers, CustomAction.ChangeMemberRole],
|
||||
fallbackRoles: {
|
||||
[CustomAction.ListMembers]: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ChangeMemberRole]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// User needs either ListMembers (to view) or ChangeMemberRole (to edit) permission
|
||||
if (!permissions[CustomAction.ListMembers] || !permissions[CustomAction.ChangeMemberRole]) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
// get requested user from database and ensure they are in the same organization
|
||||
const [requestedUser, membership, teams] = await prisma.$transaction([
|
||||
|
||||
@@ -9,6 +9,7 @@ const prismaMock = {
|
||||
membership: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
findFirst: vi.fn().mockResolvedValue({ role: "ADMIN" }),
|
||||
},
|
||||
attributeOption: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
@@ -16,11 +17,16 @@ const prismaMock = {
|
||||
{ id: "2", value: "value2", isGroup: false },
|
||||
]),
|
||||
},
|
||||
attributeToUser: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(prisma.membership, "findMany").mockImplementation(prismaMock.membership.findMany);
|
||||
vi.spyOn(prisma.membership, "count").mockImplementation(prismaMock.membership.count);
|
||||
vi.spyOn(prisma.membership, "findFirst").mockImplementation(prismaMock.membership.findFirst);
|
||||
vi.spyOn(prisma.attributeOption, "findMany").mockImplementation(prismaMock.attributeOption.findMany);
|
||||
vi.spyOn(prisma.attributeToUser, "findMany").mockImplementation(prismaMock.attributeToUser.findMany);
|
||||
|
||||
// Mock FeaturesRepository
|
||||
const mockCheckIfTeamHasFeature = vi.fn();
|
||||
@@ -30,6 +36,20 @@ vi.mock("@calcom/features/flags/features.repository", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// 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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
if (team?.isPrivate && !isTeamAdminOrOwner) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// 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],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return permissions[CustomAction.ListMembers];
|
||||
};
|
||||
|
||||
export default listMembersHandler;
|
||||
|
||||
@@ -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));
|
||||
|
||||
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.",
|
||||
});
|
||||
// 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);
|
||||
|
||||
// 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));
|
||||
|
||||
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.",
|
||||
});
|
||||
}
|
||||
|
||||
// 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 });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user