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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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>
This commit is contained in:
Eunjae Lee
2025-09-26 13:40:27 +02:00
committed by GitHub
co-authored by Devin AI
parent ae6b1d402a
commit 43dd24c480
8 changed files with 121 additions and 46 deletions
@@ -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 (
<LayoutWrapper>
<CreateTeamEventType />
<CreateTeamEventType permissions={{ canCreateEventType }} />
</LayoutWrapper>
);
};
@@ -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<Props, "user">) => {
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<Props, "user">) => {
);
})
?.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,
};
}) ?? [];
@@ -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 = () => {
<TeamEventTypeForm
teamSlug={team?.slug}
teamId={teamId}
isTeamAdminOrOwner={true}
permissions={permissions}
urlPrefix={urlPrefix}
isPending={createMutation.isPending}
form={form}
@@ -82,5 +86,3 @@ export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
</WizardLayout>
);
};
export default CreateTeamEventType;
@@ -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 (
<Form form={form} handleSubmit={handleSubmit}>
@@ -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")}>
<RadioArea.Item
{...register("schedulingType")}
value={SchedulingType.COLLECTIVE}
className={classNames("w-full text-sm", !isTeamAdminOrOwner && "w-1/2")}
classNames={{ container: classNames(isTeamAdminOrOwner && "w-full") }}>
className={classNames("w-full text-sm", !canCreateEventType && "w-1/2")}
classNames={{ container: classNames(canCreateEventType && "w-full") }}>
<strong className="mb-1 block">{t("collective")}</strong>
<p>{t("collective_description")}</p>
</RadioArea.Item>
<RadioArea.Item
{...register("schedulingType")}
value={SchedulingType.ROUND_ROBIN}
className={classNames("text-sm", !isTeamAdminOrOwner && "w-1/2")}
classNames={{ container: classNames(isTeamAdminOrOwner && "w-full") }}>
className={classNames("text-sm", !canCreateEventType && "w-1/2")}
classNames={{ container: classNames(canCreateEventType && "w-full") }}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p>
</RadioArea.Item>
{isTeamAdminOrOwner && (
{canCreateEventType && (
<RadioArea.Item
{...register("schedulingType")}
value={SchedulingType.MANAGED}
className={classNames("text-sm", !isTeamAdminOrOwner && "w-1/2")}
classNames={{ container: classNames(isTeamAdminOrOwner && "w-full") }}
className={classNames("text-sm", !canCreateEventType && "w-1/2")}
classNames={{ container: classNames(canCreateEventType && "w-full") }}
data-testid="managed-event-type">
<strong className="mb-1 block">{t("managed_event")}</strong>
<p>{t("managed_event_description")}</p>
@@ -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({
<TeamEventTypeForm
teamSlug={team?.slug}
teamId={teamId}
isTeamAdminOrOwner={isTeamAdminOrOwner}
permissions={permissions}
urlPrefix={urlPrefix}
isPending={createMutation.isPending}
form={form}
@@ -1,7 +1,7 @@
import dynamic from "next/dynamic";
export { default as CheckedTeamSelect } from "./CheckedTeamSelect";
export { default as CreateEventTypeDialog } from "./CreateEventTypeDialog";
export { CreateEventTypeDialog } from "./CreateEventTypeDialog";
export { default as EventTypeDescription } from "./EventTypeDescription";
export { MultiplePrivateLinksController } from "./MultiplePrivateLinksController";
export const EventTypeDescriptionLazy = dynamic(() => import("./EventTypeDescription"));
@@ -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 ? (
<AtomsWrapper customClassName={customClassNames?.atomsWrapper}>
<TeamEventTypeForm
teamSlug={team?.slug}
teamId={teamId}
isTeamAdminOrOwner={team.role === "ADMIN" || team.role === "OWNER"}
permissions={permissions}
urlPrefix=""
isPending={createTeamEventTypeQuery.isPending}
form={form}
@@ -1,7 +1,9 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -59,9 +61,37 @@ export const getUserEventGroups = async ({ ctx, input }: GetByViewerOptions) =>
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<number, { canCreateEventType: boolean }>);
return {
eventTypeGroups: filteredEventTypeGroups,
profiles,
teamPermissions,
};
};