diff --git a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx index 43f400905c..116c8ff97f 100644 --- a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx @@ -8,6 +8,10 @@ import { z } from "zod"; import { EventTypeWebWrapper } from "@calcom/atoms/event-types/wrappers/EventTypeWebWrapper"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -40,6 +44,95 @@ const getCachedEventType = unstable_cache( { revalidate: 3600 } // Cache for 1 hour ); +const getEventPermissions = async (userId: number, teamId: number | null) => { + // Personal event - has all perms + if (!teamId) + return { + eventTypes: { + canRead: true, + canCreate: true, + canUpdate: true, + canDelete: true, + }, + workflows: { + canRead: true, + canCreate: true, + canUpdate: true, + canDelete: true, + }, + }; + + const membership = await prisma.membership.findFirst({ + where: { + userId, + teamId, + }, + select: { + role: true, + }, + }); + + if (!membership) throw new Error("Membership not found"); + + const [eventTypePermissions, workflowPermissions] = await Promise.all([ + getResourcePermissions({ + userId, + teamId, + resource: Resource.EventType, + userRole: membership.role, + fallbackRoles: { + read: { + roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + delete: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + create: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }), + getResourcePermissions({ + userId, + teamId, + resource: Resource.Workflow, + userRole: membership.role, + fallbackRoles: { + read: { + roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + delete: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + create: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }), + ]); + + return { + eventTypes: { + canRead: eventTypePermissions.canRead, + canCreate: eventTypePermissions.canCreate, + canUpdate: eventTypePermissions.canEdit, + canDelete: eventTypePermissions.canDelete, + }, + workflows: { + canRead: workflowPermissions.canRead, + canCreate: workflowPermissions.canCreate, + canUpdate: workflowPermissions.canEdit, + canDelete: workflowPermissions.canDelete, + }, + }; +}; + const ServerPage = async ({ params }: PageProps) => { const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); if (!session?.user?.id) { @@ -59,7 +152,10 @@ const ServerPage = async ({ params }: PageProps) => { throw new Error("This event type does not exist"); } - return ; + // Fetch permissions for the event type's team + const permissions = await getEventPermissions(session.user.id, data.eventType.teamId); + + return ; }; export default ServerPage; 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 df10530954..68360fdb30 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 @@ -107,9 +107,9 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga const { isAdvancedMode, permissions, color } = form.watch(); - const filteredResources = useMemo(() => { + const { filteredResources, scopedRegistry } = useMemo(() => { const scopedRegistry = getPermissionsForScope(scope); - return Object.keys(scopedRegistry).filter((resource) => + const filteredResources = Object.keys(scopedRegistry).filter((resource) => t( scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]] ?.i18nKey || "" @@ -117,6 +117,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga .toLowerCase() .includes(searchQuery.toLowerCase()) ); + return { filteredResources, scopedRegistry }; }, [searchQuery, t, scope]); const createMutation = trpc.viewer.pbac.createRole.useMutation({ @@ -212,7 +213,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga disabled={isSystemRole} /> - {filteredResources.map((resource) => ( - ))} + ))}{" "} ) : (
@@ -237,7 +237,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
- {Object.keys(getPermissionsForScope(scope)).map((resource) => ( + {Object.keys(scopedRegistry).map((resource) => ( ))} -
+ {" "} )} 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 5ba1ec0d86..e658890c30 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,7 +1,11 @@ "use client"; -import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry"; -import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry"; +import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { + Scope, + PERMISSION_REGISTRY, + getPermissionsForScope, +} from "@calcom/features/pbac/domain/types/permission-registry"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { ToggleGroup } from "@calcom/ui/components/form"; @@ -21,11 +25,13 @@ export function SimplePermissionItem({ permissions, onChange, disabled, - scope, + scope = Scope.Organization, }: SimplePermissionItemProps) { const { t } = useLocale(); const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope); + const scopedRegistry = getPermissionsForScope(scope); + const registry = scopedRegistry || PERMISSION_REGISTRY; const isAllResources = resource === "*"; const options = isAllResources ? [ @@ -41,7 +47,7 @@ export function SimplePermissionItem({ return (
- {t(PERMISSION_REGISTRY[resource as Resource]._resource?.i18nKey || resource)} + {t(registry[resource as Resource]._resource?.i18nKey || resource)} { 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 d7d5c2ea36..176186a29f 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,8 +1,5 @@ import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry"; -import { - PERMISSION_REGISTRY, - getPermissionsForScope, -} from "@calcom/features/pbac/domain/types/permission-registry"; +import { getPermissionsForScope } from "@calcom/features/pbac/domain/types/permission-registry"; import { getTransitiveDependencies, getTransitiveDependents, @@ -52,7 +49,8 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission return permissions.includes("*.*") ? "all" : "none"; } - 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 "none"; // Check if global all permissions (*.*) is present @@ -111,7 +109,19 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission allResourcePerms = Object.keys(resourceConfig) .filter((action) => !action.startsWith("_")) .map((action) => `${resource}.${action}`); + + // Add the resource permissions newPermissions.push(...allResourcePerms); + + // Add all transitive dependencies for each permission + allResourcePerms.forEach((perm) => { + const dependencies = getTransitiveDependencies(perm, scope); + dependencies.forEach((dep) => { + if (!newPermissions.includes(dep)) { + newPermissions.push(dep); + } + }); + }); break; } @@ -132,15 +142,12 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission // First, remove *.* since we're modifying individual permissions let newPermissions = currentPermissions.filter((p) => p !== "*.*"); - // Parse the permission to get resource and action - const [resource, action] = permission.split("."); - if (enabled) { // Add the requested permission newPermissions.push(permission); // Add all transitive dependencies - const dependencies = getTransitiveDependencies(permission); + const dependencies = getTransitiveDependencies(permission, scope); dependencies.forEach((dependency) => { if (!newPermissions.includes(dependency)) { newPermissions.push(dependency); @@ -151,7 +158,7 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission newPermissions = newPermissions.filter((p) => p !== permission); // Remove all transitive dependents - const dependents = getTransitiveDependents(permission); + const dependents = getTransitiveDependents(permission, scope); dependents.forEach((dependent) => { newPermissions = newPermissions.filter((p) => p !== dependent); }); diff --git a/apps/web/modules/event-types/views/event-types-listing-view.tsx b/apps/web/modules/event-types/views/event-types-listing-view.tsx index 6ca2619060..6fe670651a 100644 --- a/apps/web/modules/event-types/views/event-types-listing-view.tsx +++ b/apps/web/modules/event-types/views/event-types-listing-view.tsx @@ -27,7 +27,7 @@ import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { HttpError } from "@calcom/lib/http-error"; import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor"; import { localStorage } from "@calcom/lib/webstorage"; -import type { MembershipRole } from "@calcom/prisma/enums"; +import { MembershipRole } from "@calcom/prisma/enums"; import { SchedulingType } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; @@ -954,6 +954,24 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit) => { userEventGroupsData?.profiles ?.filter((profile) => !profile.readOnly) ?.filter((profile) => !profile.eventTypesLockedByOrg) + ?.filter((profile) => { + // For personal profiles (teamId is null), always allow creation + if (!profile.teamId) { + return true; + } + + // For team profiles, check if user has eventType.create permission + // This will be populated by the server-side PBAC check + // Fallback to role-based check (admin/owner) if canCreateEventTypes is not set + if (profile.canCreateEventTypes !== undefined) { + return profile.canCreateEventTypes; + } + + // Fallback: allow admin and owner roles + return ( + profile.membershipRole === MembershipRole.ADMIN || profile.membershipRole === MembershipRole.OWNER + ); + }) ?.map((profile) => { return { teamId: profile.teamId, diff --git a/packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx b/packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx index 87e27a2200..0cae9b1b9a 100644 --- a/packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx +++ b/packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx @@ -25,7 +25,10 @@ import { showToast } from "@calcom/ui/components/toast"; import { Tooltip } from "@calcom/ui/components/tooltip"; import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions"; -type PartialWorkflowType = Pick; +type PartialWorkflowType = Pick< + WorkflowType, + "name" | "activeOn" | "isOrg" | "steps" | "id" | "readOnly" | "permissions" +>; type ItemProps = { workflow: PartialWorkflowType; @@ -42,14 +45,6 @@ const WorkflowListItem = (props: ItemProps) => { const { workflow, eventType, isActive } = props; const { t } = useLocale(); - const [activeEventTypeIds, setActiveEventTypeIds] = useState( - workflow.activeOn?.map((active) => { - if (active.eventType) { - return active.eventType.id; - } - }) ?? [] - ); - const utils = trpc.useUtils(); const activateEventTypeMutation = trpc.viewer.workflows.activateEventType.useMutation({ @@ -137,7 +132,7 @@ const WorkflowListItem = (props: ItemProps) => {
- {!workflow.readOnly && ( + {workflow.permissions?.canUpdate && (