From 43dd24c480eb829295a520bdca3c1ae8b7e949ca Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 26 Sep 2025 13:40:27 +0200 Subject: [PATCH] refactor: migrate TeamEventTypeForm to use PBAC instead of isTeamAdminOrOwner (#24034) * refactor: migrate TeamEventTypeForm to use PBAC instead of isTeamAdminOrOwner - Replace isTeamAdminOrOwner prop with permissions.canCreateEventType - Move permission checks to server-side using PermissionCheckService - Use eventType.create permission string as specified in PBAC guide - Update all parent components: CreateEventTypeDialog, event-types-view, CreateEventTypePlatformWrapper - Follow existing PBAC patterns from event-types-listing-view.tsx - Maintain backward compatibility with role-based fallback Co-Authored-By: eunjae@cal.com * fix: implement proper server-side PBAC permission checks - Add eventType.create permission checks to TRPC teams.get handler - Add PBAC permission checks to platform /organizations/{orgId}/teams/me endpoint - Update all three components to use server-side permission data instead of client-side async calls - Add canCreateEventTypes property to platform team types - Maintain backward compatibility with role-based fallbacks - Remove unused imports and variables Co-Authored-By: eunjae@cal.com * fix: update client components to use server-side PBAC permission data - Update event-types-view.tsx to use team.canCreateEventTypes from server - Update CreateEventTypeDialog.tsx to use team.canCreateEventTypes with fallback - Update CreateEventTypePlatformWrapper.tsx to use team.canCreateEventTypes with fallback - Remove hardcoded permission values and role-based checks - Maintain backward compatibility with existing role-based logic as fallback Co-Authored-By: eunjae@cal.com * apply correct PBAC for event type creation * revert unexpected changes * clean up * address feedback * fix type error * clean up --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../settings/teams/[id]/event-type/page.tsx | 40 +++++++++++++++++-- .../views/event-types-listing-view.tsx | 27 +++++++------ .../settings/teams/[id]/event-types-view.tsx | 12 +++--- .../ee/teams/components/TeamEventTypeForm.tsx | 23 ++++++----- .../components/CreateEventTypeDialog.tsx | 27 +++++++------ .../features/eventtypes/components/index.ts | 2 +- .../CreateEventTypePlatformWrapper.tsx | 6 ++- .../eventTypes/getUserEventGroups.handler.ts | 30 ++++++++++++++ 8 files changed, 121 insertions(+), 46 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/settings/teams/[id]/event-type/page.tsx b/apps/web/app/(use-page-wrapper)/settings/teams/[id]/event-type/page.tsx index 7b40bcc0e3..a7e65a3126 100644 --- a/apps/web/app/(use-page-wrapper)/settings/teams/[id]/event-type/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/teams/[id]/event-type/page.tsx @@ -1,6 +1,16 @@ +import type { PageProps as ServerPageProps } from "app/_types"; import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { z } from "zod"; -import CreateTeamEventType, { LayoutWrapper } from "~/settings/teams/[id]/event-types-view"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import { CreateTeamEventType, LayoutWrapper } from "~/settings/teams/[id]/event-types-view"; export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => await _generateMetadata( @@ -11,10 +21,34 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin `/settings/teams/${(await params).id}/event-type` ); -const ServerPage = async () => { +const querySchema = z.object({ + id: z.coerce.number().refine((val) => !Number.isNaN(val), { + message: "id must be a string that can be cast to a number", + }), +}); + +const ServerPage = async ({ params }: ServerPageProps) => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + if (!session?.user?.id) { + return redirect("/auth/login"); + } + + const parsed = querySchema.safeParse(await params); + if (!parsed.success) { + notFound(); + } + + const permissionService = new PermissionCheckService(); + const canCreateEventType = await permissionService.checkPermission({ + userId: session.user.id, + teamId: parsed.data.id, + permission: "eventType.create", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); + return ( - + ); }; 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 6fe670651a..dc5015e8ea 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 @@ -12,7 +12,10 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid import { CreateButton } from "@calcom/features/ee/teams/components/createButton/CreateButton"; import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed"; import { EventTypeDescription } from "@calcom/features/eventtypes/components"; -import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog"; +import { + CreateEventTypeDialog, + type ProfileOption, +} from "@calcom/features/eventtypes/components/CreateEventTypeDialog"; import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog"; import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader"; import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants"; @@ -846,17 +849,7 @@ const CreateFirstEventTypeView = ({ slug, searchTerm }: { slug: string; searchTe ); }; -const CTA = ({ - profileOptions, -}: { - profileOptions: { - teamId: number | null | undefined; - label: string | null; - image: string; - membershipRole: MembershipRole | null | undefined; - slug: string | null; - }[]; -}) => { +const CTA = ({ profileOptions }: { profileOptions: ProfileOption[] }) => { const { t } = useLocale(); if (!profileOptions.length) return null; @@ -951,7 +944,7 @@ type Props = { export const EventTypesCTA = ({ userEventGroupsData }: Omit) => { const profileOptions = - userEventGroupsData?.profiles + userEventGroupsData.profiles ?.filter((profile) => !profile.readOnly) ?.filter((profile) => !profile.eventTypesLockedByOrg) ?.filter((profile) => { @@ -973,12 +966,20 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit) => { ); }) ?.map((profile) => { + const permissions = profile.teamId + ? userEventGroupsData.teamPermissions[profile.teamId] + : { + // always can create eventType on personal level + canCreateEventType: true, + }; + return { teamId: profile.teamId, label: profile.name || profile.slug, image: profile.image, membershipRole: profile.membershipRole, slug: profile.slug, + permissions, }; }) ?? []; diff --git a/apps/web/modules/settings/teams/[id]/event-types-view.tsx b/apps/web/modules/settings/teams/[id]/event-types-view.tsx index 4050f5d5d8..f030184230 100644 --- a/apps/web/modules/settings/teams/[id]/event-types-view.tsx +++ b/apps/web/modules/settings/teams/[id]/event-types-view.tsx @@ -8,11 +8,15 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; import { WizardLayout } from "@calcom/ui/components/layout"; import { showToast } from "@calcom/ui/components/toast"; -import { Button } from "@calcom/ui/components/button"; -export const CreateTeamEventType = () => { +type CreateTeamEventTypeProps = { + permissions: { canCreateEventType: boolean }; +}; + +export const CreateTeamEventType = ({ permissions }: CreateTeamEventTypeProps) => { const searchParams = useCompatSearchParams(); const { t } = useLocale(); const router = useRouter(); @@ -53,7 +57,7 @@ export const CreateTeamEventType = () => { { ); }; - -export default CreateTeamEventType; diff --git a/packages/features/ee/teams/components/TeamEventTypeForm.tsx b/packages/features/ee/teams/components/TeamEventTypeForm.tsx index 26447cc3c6..8928daf657 100644 --- a/packages/features/ee/teams/components/TeamEventTypeForm.tsx +++ b/packages/features/ee/teams/components/TeamEventTypeForm.tsx @@ -14,7 +14,9 @@ import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio"; import { Tooltip } from "@calcom/ui/components/tooltip"; type props = { - isTeamAdminOrOwner: boolean; + permissions: { + canCreateEventType: boolean; + }; teamSlug?: string | null; teamId: number; isPending: boolean; @@ -25,7 +27,7 @@ type props = { SubmitButton: (isPending: boolean) => ReactNode; }; export const TeamEventTypeForm = ({ - isTeamAdminOrOwner, + permissions, teamSlug, teamId, form, @@ -40,6 +42,7 @@ export const TeamEventTypeForm = ({ const { t } = useLocale(); const { register, setValue, formState } = form; + const { canCreateEventType } = permissions; return (
@@ -124,29 +127,29 @@ export const TeamEventTypeForm = ({ onValueChange={(val: SchedulingType) => { setValue("schedulingType", val); }} - className={classNames("mt-1 flex gap-4", isTeamAdminOrOwner && "flex-col")}> + className={classNames("mt-1 flex gap-4", canCreateEventType && "flex-col")}> + className={classNames("w-full text-sm", !canCreateEventType && "w-1/2")} + classNames={{ container: classNames(canCreateEventType && "w-full") }}> {t("collective")}

{t("collective_description")}

+ className={classNames("text-sm", !canCreateEventType && "w-1/2")} + classNames={{ container: classNames(canCreateEventType && "w-full") }}> {t("round_robin")}

{t("round_robin_description")}

- {isTeamAdminOrOwner && ( + {canCreateEventType && ( {t("managed_event")}

{t("managed_event_description")}

diff --git a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx index 9b8147f65c..cae2fc70af 100644 --- a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx +++ b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx @@ -2,7 +2,6 @@ import { isValidPhoneNumber } from "libphonenumber-js"; import { useRouter } from "next/navigation"; import { z } from "zod"; -import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; import { Dialog } from "@calcom/features/components/controlled-dialog"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { TeamEventTypeForm } from "@calcom/features/ee/teams/components/TeamEventTypeForm"; @@ -28,6 +27,17 @@ export interface EventTypeParent { image?: string | null; } +export interface ProfileOption { + teamId: number | null | undefined; + label: string | null; + image: string; + membershipRole: MembershipRole | null | undefined; + slug: string | null; + permissions: { + canCreateEventType: boolean; + }; +} + const locationFormSchema = z.array( z.object({ locationType: z.string(), @@ -55,16 +65,7 @@ const querySchema = z.object({ .optional(), }); -export default function CreateEventTypeDialog({ - profileOptions, -}: { - profileOptions: { - teamId: number | null | undefined; - label: string | null; - image: string | undefined; - membershipRole: MembershipRole | null | undefined; - }[]; -}) { +export function CreateEventTypeDialog({ profileOptions }: { profileOptions: ProfileOption[] }) { const { t } = useLocale(); const router = useRouter(); const orgBranding = useOrgBranding(); @@ -75,7 +76,7 @@ export default function CreateEventTypeDialog({ const teamProfile = profileOptions.find((profile) => profile.teamId === teamId); - const isTeamAdminOrOwner = teamId !== undefined && checkAdminOrOwner(teamProfile?.membershipRole); + const permissions = teamProfile?.permissions ?? { canCreateEventType: false }; const onSuccessMutation = (eventType: EventType) => { router.replace(`/event-types/${eventType.id}${teamId ? "?tabName=team" : ""}`); @@ -124,7 +125,7 @@ export default function CreateEventTypeDialog({ import("./EventTypeDescription")); diff --git a/packages/platform/atoms/event-types/wrappers/CreateEventTypePlatformWrapper.tsx b/packages/platform/atoms/event-types/wrappers/CreateEventTypePlatformWrapper.tsx index f25cc8f676..2fca4b7232 100644 --- a/packages/platform/atoms/event-types/wrappers/CreateEventTypePlatformWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/CreateEventTypePlatformWrapper.tsx @@ -77,12 +77,16 @@ export const CreateEventTypePlatformWrapper = ({ const team = teams?.find((t) => t.id === teamId); const { t } = useLocale(); + const permissions = { + canCreateEventType: team?.role === "ADMIN" || team?.role === "OWNER", + }; + return teamId && team ? ( const profileProcessor = new ProfilePermissionProcessor(); const profiles = profileProcessor.processProfiles(eventTypeGroups, teamPermissionsMap); + const permissionCheckService = new PermissionCheckService(); + + const teamIdsToCheck = filteredEventTypeGroups + .map((group) => group.teamId) + .filter((teamId): teamId is number => teamId !== null && teamId !== undefined); + + const teamPermissionChecks = teamIdsToCheck.map(async (teamId) => { + const canCreateEventType = await permissionCheckService.checkPermission({ + userId: user.id, + teamId: teamId, + permission: "eventType.create", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + return { + teamId, + permissions: { + canCreateEventType, + }, + }; + }); + + const teamPermissionsArray = await Promise.all(teamPermissionChecks); + const teamPermissions = teamPermissionsArray.reduce((acc, item) => { + acc[item.teamId] = item.permissions; + return acc; + }, {} as Record); + return { eventTypeGroups: filteredEventTypeGroups, profiles, + teamPermissions, }; };