diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index 02d5cf9b35..8de2e224ab 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -204,6 +204,7 @@ function EventTypeSingleLayout({ activeWebhooksNumber, }: Props) { const { t } = useLocale(); + const eventTypesLockedByOrg = eventType.team?.parent?.organizationSettings?.lockEventTypeCreationForUsers; const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -341,6 +342,7 @@ function EventTypeSingleLayout({
{ formMethods.setValue("hidden", !e, { shouldDirty: true }); 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 f710535c18..409dcd3f23 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 @@ -26,6 +26,7 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { HttpError } from "@calcom/lib/http-error"; import type { User } from "@calcom/prisma/client"; +import type { MembershipRole } from "@calcom/prisma/enums"; import { SchedulingType } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc, TRPCClientError } from "@calcom/trpc/react"; @@ -109,6 +110,7 @@ interface EventTypeListProps { readOnly: boolean; bookerUrl: string | null; types: DeNormalizedEventType[]; + lockedByOrg?: boolean; } interface MobileTeamsTabProps { @@ -230,6 +232,7 @@ export const EventTypeList = ({ readOnly, types, bookerUrl, + lockedByOrg, }: EventTypeListProps): JSX.Element => { const { t } = useLocale(); const router = useRouter(); @@ -390,8 +393,10 @@ export const EventTypeList = ({ if (!types.length) { return group.teamId ? ( - ) : ( + ) : !group.profile.eventTypesLockedByOrg ? ( + ) : ( + <> ); } @@ -450,6 +455,7 @@ export const EventTypeList = ({
{ setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden }); @@ -803,21 +809,22 @@ const CreateFirstEventTypeView = ({ slug }: { slug: string }) => { ); }; -const CTA = ({ data, isOrganization }: { data: GetByViewerResponse; isOrganization: boolean }) => { +const CTA = ({ + profileOptions, + isOrganization, +}: { + profileOptions: { + teamId: number | null | undefined; + label: string | null; + image: string; + membershipRole: MembershipRole | null | undefined; + slug: string | null; + }[]; + isOrganization: boolean; +}) => { const { t } = useLocale(); - if (!data) return null; - const profileOptions = data.profiles - .filter((profile) => !profile.readOnly) - .map((profile) => { - return { - teamId: profile.teamId, - label: profile.name || profile.slug, - image: profile.image, - membershipRole: profile.membershipRole, - slug: profile.slug, - }; - }); + if (!profileOptions.length) return null; return ( { +const Actions = (props: { showDivider: boolean }) => { return (
- +
); }; @@ -885,7 +892,6 @@ const Main = ({ rawData.eventTypeGroups.length === 1; const data = denormalizePayload(rawData); - return ( <> {data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? ( @@ -893,33 +899,45 @@ const Main = ({ {isMobile ? ( ) : ( - data.eventTypeGroups.map((group, index: number) => ( -
- + data.eventTypeGroups.map((group, index: number) => { + const eventsLockedByOrg = group.profile.eventTypesLockedByOrg; + const userHasManagedOrHiddenEventTypes = group.eventTypes.find( + (event) => event.metadata?.managedEventConfig || event.hidden + ); + if (eventsLockedByOrg && !userHasManagedOrHiddenEventTypes) return null; + return ( +
+ {/* If the group is readonly and empty don't leave a floating header when the user cant see the create box due + to it being readonly for that user */} + {group.eventTypes.length === 0 && group.metadata.readOnly ? null : ( + + )} - {group.eventTypes.length ? ( - - ) : group.teamId ? ( - - ) : ( - - )} -
- )) + {group.eventTypes.length ? ( + + ) : group.teamId && !group.metadata.readOnly ? ( + + ) : !group.metadata.readOnly ? ( + + ) : null} +
+ ); + }) )} ) : ( @@ -974,6 +992,21 @@ const EventTypesPage: React.FC & { ); }, [orgBranding, user]); + const profileOptions = data + ? data?.profiles + .filter((profile) => !profile.readOnly) + .filter((profile) => !profile.eventTypesLockedByOrg) + .map((profile) => { + return { + teamId: profile.teamId, + label: profile.name || profile.slug, + image: profile.image, + membershipRole: profile.membershipRole, + slug: profile.slug, + }; + }) + : []; + return ( } - CTA={}> + beforeCTAactions={ 0} />} + CTA={}> {{email}}. Click the link in the email to verify this address.", "send_event_details_to": "Send event details to", + "lock_event_types_modal_header":"What should we do with your member's existing event types?", + "org_delete_event_types_org_admin":"All of your members individual event types (except managed ones) will be permanently deleted. They will not be able to create new ones", + "org_hide_event_types_org_admin":"Your members individual event types will be hidden (except managed ones) from profiles but the links will still be active. They will not be able to create new ones. ", + "hide_org_eventtypes":"Hide individual event types", + "delete_org_eventtypes":"Delete individual event types", + "lock_org_users_eventtypes":"Lock individual event type creation", + "lock_org_users_eventtypes_description":"Prevent members from creating their own event types.", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } \ No newline at end of file diff --git a/packages/features/ee/organizations/pages/components/LockEventTypeSwitch.tsx b/packages/features/ee/organizations/pages/components/LockEventTypeSwitch.tsx new file mode 100644 index 0000000000..0c91616dce --- /dev/null +++ b/packages/features/ee/organizations/pages/components/LockEventTypeSwitch.tsx @@ -0,0 +1,144 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterOutputs } from "@calcom/trpc"; +import { trpc } from "@calcom/trpc"; +import { + showToast, + Form, + SettingsToggle, + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogClose, + Button, + RadioGroup as RadioArea, +} from "@calcom/ui"; + +enum CurrentEventTypeOptions { + DELETE = "DELETE", + HIDE = "HIDE", +} + +interface GeneralViewProps { + currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"]; + isAdminOrOwner: boolean; +} + +interface FormValues { + currentEventTypeOptions: CurrentEventTypeOptions; +} + +export const LockEventTypeSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewProps) => { + const [lockEventTypeCreationForUsers, setLockEventTypeCreationForUsers] = useState( + !!currentOrg.organizationSettings.lockEventTypeCreationForUsers + ); + const [showModal, setShowModal] = useState(false); + const { t } = useLocale(); + + const mutation = trpc.viewer.organizations.update.useMutation({ + onSuccess: async () => { + reset(getValues()); + showToast(t("settings_updated_successfully"), "success"); + }, + onError: () => { + showToast(t("error_updating_settings"), "error"); + }, + }); + + const formMethods = useForm({ + defaultValues: { + currentEventTypeOptions: CurrentEventTypeOptions.HIDE, + }, + }); + + if (!isAdminOrOwner) return null; + + const currentLockedOption = formMethods.watch("currentEventTypeOptions"); + + const { reset, getValues } = formMethods; + + const onSubmit = (values: FormValues) => { + mutation.mutate({ + lockEventTypeCreation: lockEventTypeCreationForUsers, + lockEventTypeCreationOptions: values.currentEventTypeOptions, + }); + setShowModal(false); + }; + + return ( + <> + { + if (!checked) { + mutation.mutate({ + lockEventTypeCreation: checked, + }); + } else { + setShowModal(true); + } + setLockEventTypeCreationForUsers(checked); + }} + switchContainerClassName="mt-6" + /> + {showModal && ( + { + if (!e) { + setLockEventTypeCreationForUsers( + !!currentOrg.organizationSettings.lockEventTypeCreationForUsers + ); + setShowModal(false); + } + }}> + +
+
+
+ + { + formMethods.setValue("currentEventTypeOptions", val); + }} + className={classNames("min-h-24 mt-1 flex flex-col gap-4")}> + + {t("hide_org_eventtypes")} +

{t("org_hide_event_types_org_admin")}

+
+ + {t("delete_org_eventtypes")} +

{t("org_delete_event_types_org_admin")}

+
+
+ + + + + +
+
+
+
+
+ )} + + ); +}; diff --git a/packages/features/ee/organizations/pages/settings/general.tsx b/packages/features/ee/organizations/pages/settings/general.tsx index 83cc338a91..cfb4473ec2 100644 --- a/packages/features/ee/organizations/pages/settings/general.tsx +++ b/packages/features/ee/organizations/pages/settings/general.tsx @@ -25,6 +25,8 @@ import { TimezoneSelect, } from "@calcom/ui"; +import { LockEventTypeSwitch } from "../components/LockEventTypeSwitch"; + const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( @@ -81,6 +83,8 @@ const OrgGeneralView = () => { isAdminOrOwner={isAdminOrOwner} localeProp={user?.locale ?? "en"} /> + + ); }; diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 682efd3d51..23b224b78e 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -133,6 +133,11 @@ export default async function getEventTypeById({ parent: { select: { slug: true, + organizationSettings: { + select: { + lockEventTypeCreationForUsers: true, + }, + }, }, }, members: { diff --git a/packages/lib/server/repository/profile.ts b/packages/lib/server/repository/profile.ts index 8fbfc9f036..dbd4f533d1 100644 --- a/packages/lib/server/repository/profile.ts +++ b/packages/lib/server/repository/profile.ts @@ -349,6 +349,11 @@ export class ProfileRepository { movedFromUser: true, organization: { include: { + organizationSettings: { + select: { + lockEventTypeCreationForUsers: true, + }, + }, members: { select: membershipSelect, }, diff --git a/packages/prisma/migrations/20240304093822_lock_eventtype_creation_for_orgs/migration.sql b/packages/prisma/migrations/20240304093822_lock_eventtype_creation_for_orgs/migration.sql new file mode 100644 index 0000000000..583038d79e --- /dev/null +++ b/packages/prisma/migrations/20240304093822_lock_eventtype_creation_for_orgs/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OrganizationSettings" ADD COLUMN "lockEventTypeCreationForUsers" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f9642e0e33..428a48ca80 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -374,13 +374,14 @@ model Team { } model OrganizationSettings { - id Int @id @default(autoincrement()) - organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade) - organizationId Int @unique - isOrganizationConfigured Boolean @default(false) - isOrganizationVerified Boolean @default(false) - orgAutoAcceptEmail String - dSyncData DSyncData? + id Int @id @default(autoincrement()) + organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId Int @unique + isOrganizationConfigured Boolean @default(false) + isOrganizationVerified Boolean @default(false) + orgAutoAcceptEmail String + lockEventTypeCreationForUsers Boolean @default(false) + dSyncData DSyncData? } enum MembershipRole { diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 75c84c44a9..3d8aed80db 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -852,6 +852,51 @@ async function main() { }, ], }, + { + memberData: { + email: "member1-acme@example.com", + password: { + create: { + hash: "member1-acme", + }, + }, + username: "member1-acme", + name: "Member 1", + }, + orgMembership: { + role: "MEMBER", + accepted: true, + }, + orgProfile: { + username: "member1", + }, + inTeams: [ + { + slug: "team1", + role: "ADMIN", + }, + ], + }, + { + memberData: { + email: "member2-acme@example.com", + password: { + create: { + hash: "member2-acme", + }, + }, + username: "member2-acme", + name: "Member 2", + }, + orgMembership: { + role: "MEMBER", + accepted: true, + }, + orgProfile: { + username: "member2", + }, + inTeams: [], + }, ], }, teams: [ diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts index 2bf21f749c..16734d86af 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -28,6 +28,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { const userId = ctx.user.id; const isManagedEventType = schedulingType === SchedulingType.MANAGED; + const isOrgAdmin = !!ctx.user?.organization?.isOrgAdmin; // Get Users default conferencing app const defaultConferencingData = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp; @@ -70,8 +71,6 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { }, }); - const isOrgAdmin = !!ctx.user?.organization?.isOrgAdmin; - if (!hasMembership?.role || !(["ADMIN", "OWNER"].includes(hasMembership.role) || isOrgAdmin)) { console.warn(`User ${userId} does not have permission to create this new event type`); throw new TRPCError({ code: "UNAUTHORIZED" }); @@ -85,6 +84,27 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { data.schedulingType = schedulingType; } + // If we are in an organization & they are not admin & they are not creating an event on a teamID + // Check if evenTypes are locked. + if (ctx.user.organizationId && !ctx.user?.organization?.isOrgAdmin && !teamId) { + const orgSettings = await ctx.prisma.organizationSettings.findUnique({ + where: { + organizationId: ctx.user.organizationId, + }, + select: { + lockEventTypeCreationForUsers: true, + }, + }); + + const orgHasLockedEventTypes = !!orgSettings?.lockEventTypeCreationForUsers; + if (orgHasLockedEventTypes) { + console.warn( + `User ${userId} does not have permission to create this new event type - Locked status: ${orgHasLockedEventTypes}` + ); + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + const profile = ctx.user.profile; try { const eventType = await EventTypeRepository.create({ diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts index eff78baadb..a4f67422ac 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts @@ -44,8 +44,9 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => rateLimitingType: "common", }); const lightProfile = ctx.user.profile; - const profile = await ProfileRepository.findByUpId(lightProfile.upId); + const parentOrgHasLockedEventTypes = + profile?.organization?.organizationSettings?.lockEventTypeCreationForUsers; const isFilterSet = input?.filters && hasFilter(input.filters); const isUpIdInFilter = input?.filters?.upIds?.includes(lightProfile.upId); const shouldListUserEvents = !isFilterSet || isUpIdInFilter; @@ -150,6 +151,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => slug: (typeof profile)["username"] | null; name: (typeof profile)["name"]; image: string; + eventTypesLockedByOrg?: boolean; }; metadata: { membershipCount: number; @@ -188,6 +190,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => avatarUrl: profile.avatarUrl, profile: profile, }), + eventTypesLockedByOrg: parentOrgHasLockedEventTypes, }, eventTypes: orderBy(unmanagedEventTypes, ["position", "id"], ["desc", "asc"]), metadata: { diff --git a/packages/trpc/server/routers/viewer/organizations/list.handler.ts b/packages/trpc/server/routers/viewer/organizations/list.handler.ts index 6ee087f8c8..5ff3bb36fd 100644 --- a/packages/trpc/server/routers/viewer/organizations/list.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/list.handler.ts @@ -29,6 +29,15 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => { }, }); + const organizationSettings = await ctx.prisma.organizationSettings.findUnique({ + where: { + organizationId: ctx.user.organization.id, + }, + select: { + lockEventTypeCreationForUsers: true, + }, + }); + if (!membership) { throw new TRPCError({ code: "BAD_REQUEST", @@ -39,6 +48,9 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => { const metadata = teamMetadataSchema.parse(membership?.team.metadata); return { + organizationSettings: { + lockEventTypeCreationForUsers: organizationSettings?.lockEventTypeCreationForUsers, + }, user: { role: membership?.role, accepted: membership?.accepted, diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts index 3299ac305b..e6fd32dda9 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -116,9 +116,63 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } } - const updatedOrganisation = await prisma.team.update({ - where: { id: currentOrgId }, - data, + const updatedOrganisation = await prisma.$transaction(async (tx) => { + const updatedOrganisation = await tx.team.update({ + where: { id: currentOrgId }, + data, + }); + + await tx.organizationSettings.update({ + where: { + organizationId: currentOrgId, + }, + data: { + lockEventTypeCreationForUsers: !!input.lockEventTypeCreation, + }, + }); + + if (input.lockEventTypeCreation) { + switch (input.lockEventTypeCreationOptions) { + case "HIDE": + await tx.eventType.updateMany({ + where: { + teamId: null, // Not assigned to a team + parentId: null, // Not a managed event type + owner: { + profiles: { + some: { + organizationId: currentOrgId, + }, + }, + }, + }, + data: { + hidden: true, + }, + }); + + break; + case "DELETE": + await tx.eventType.deleteMany({ + where: { + teamId: null, // Not assigned to a team + parentId: null, // Not a managed event type + owner: { + profiles: { + some: { + organizationId: currentOrgId, + }, + }, + }, + }, + }); + break; + default: + break; + } + } + + return updatedOrganisation; }); // Sync Services: Close.com diff --git a/packages/trpc/server/routers/viewer/organizations/update.schema.ts b/packages/trpc/server/routers/viewer/organizations/update.schema.ts index 3cac63a882..4c17d05354 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.schema.ts @@ -33,6 +33,8 @@ export const ZUpdateInputSchema = z.object({ weekStart: z.string().optional(), timeFormat: z.number().optional(), metadata: teamMetadataSchema.unwrap().optional(), + lockEventTypeCreation: z.boolean().optional(), + lockEventTypeCreationOptions: z.enum(["DELETE", "HIDE"]).optional(), }); export type TUpdateInputSchema = z.infer;