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:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user