feat: lock event types for org users (#14000)

* chroe: org settings schema

* wip: UI work + modal on toggle

* chore: update i18n

* fix: list handler

* fix: update handler on untoggle

* chore: extract

* feat: update handler for hide and delete logic

* chore: radio translations

* chore: fix modal state from flickering

* feat: update copy & add danger colour on delete focus

* chore: update copy

* design: remove modal icon

* chore: include more seeds

* fix: update handler

* feat: hide create for profile user

* feat: use readonly instead

* feat: hide personal event type header when read only

* chore: move away form readOnly as it was used in other ways

* chore: fix read-only bug

* fix: hide readonly create screen

* fix: show managed event types when events are locked

* fix: hide if not admin

* Update packages/trpc/server/routers/viewer/eventTypes/create.handler.ts

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* fix: feedback

* fix: lock eventtype single switch

* fix: empty state create button

* fix: more profileGroup check outside CTA to pass value to filter too

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
sean-brydon
2024-03-14 09:07:37 +00:00
committed by GitHub
co-authored by Carina Wollendorfer Udit Takkar CarinaWolli
parent 3aff79e7d1
commit 09ca75eb1f
15 changed files with 397 additions and 58 deletions
@@ -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({
<div className="self-center rounded-md p-2">
<Switch
id="hiddenSwitch"
disabled={eventTypesLockedByOrg}
checked={!formMethods.watch("hidden")}
onCheckedChange={(e) => {
formMethods.setValue("hidden", !e, { shouldDirty: true });
@@ -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 ? (
<EmptyEventTypeList group={group} />
) : (
) : !group.profile.eventTypesLockedByOrg ? (
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} />
) : (
<></>
);
}
@@ -450,6 +455,7 @@ export const EventTypeList = ({
<div className="self-center rounded-md p-2">
<Switch
name="Hidden"
disabled={lockedByOrg}
checked={!type.hidden}
onCheckedChange={() => {
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 (
<CreateButton
@@ -831,10 +838,10 @@ const CTA = ({ data, isOrganization }: { data: GetByViewerResponse; isOrganizati
);
};
const Actions = () => {
const Actions = (props: { showDivider: boolean }) => {
return (
<div className="hidden items-center md:flex">
<TeamsFilter useProfileFilter popoverTriggerClassNames="mb-0" showVerticalDivider={true} />
<TeamsFilter useProfileFilter popoverTriggerClassNames="mb-0" showVerticalDivider={props.showDivider} />
</div>
);
};
@@ -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 ? (
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
) : (
data.eventTypeGroups.map((group, index: number) => (
<div
className="mt-4 flex flex-col"
data-testid={`slug-${group.profile.slug}`}
key={group.profile.slug}>
<EventTypeListHeading
profile={group.profile}
membershipCount={group.metadata.membershipCount}
teamId={group.teamId}
bookerUrl={group.bookerUrl}
/>
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 (
<div
className="mt-4 flex flex-col"
data-testid={`slug-${group.profile.slug}`}
key={group.profile.slug}>
{/* 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 : (
<EventTypeListHeading
profile={group.profile}
membershipCount={group.metadata.membershipCount}
teamId={group.teamId}
bookerUrl={group.bookerUrl}
/>
)}
{group.eventTypes.length ? (
<EventTypeList
types={group.eventTypes}
group={group}
bookerUrl={group.bookerUrl}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
) : group.teamId ? (
<EmptyEventTypeList group={group} />
) : (
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
)}
</div>
))
{group.eventTypes.length ? (
<EventTypeList
types={group.eventTypes}
group={group}
bookerUrl={group.bookerUrl}
groupIndex={index}
readOnly={group.metadata.readOnly}
lockedByOrg={eventsLockedByOrg}
/>
) : group.teamId && !group.metadata.readOnly ? (
<EmptyEventTypeList group={group} />
) : !group.metadata.readOnly ? (
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
) : null}
</div>
);
})
)}
</>
) : (
@@ -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 (
<Shell
withoutMain={false}
@@ -983,8 +1016,8 @@ const EventTypesPage: React.FC & {
heading={t("event_types_page_title")}
hideHeadingOnMobile
subtitle={t("event_types_page_subtitle")}
beforeCTAactions={<Actions />}
CTA={<CTA data={data} isOrganization={!!user?.organizationId} />}>
beforeCTAactions={<Actions showDivider={profileOptions.length > 0} />}
CTA={<CTA profileOptions={profileOptions} isOrganization={!!user?.organizationId} />}>
<HeadSeo
title="Event Types"
description="Create events to share for people to book on your calendar."
@@ -2313,5 +2313,12 @@
"confirm_email": "Confirm your email",
"confirm_email_description": "We sent an email to <strong>{{email}}</strong>. 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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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<FormValues>({
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 (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("lock_org_users_eventtypes")}
disabled={mutation?.isPending || !isAdminOrOwner}
description={t("lock_org_users_eventtypes_description")}
checked={lockEventTypeCreationForUsers}
onCheckedChange={(checked) => {
if (!checked) {
mutation.mutate({
lockEventTypeCreation: checked,
});
} else {
setShowModal(true);
}
setLockEventTypeCreationForUsers(checked);
}}
switchContainerClassName="mt-6"
/>
{showModal && (
<Dialog
open={showModal}
onOpenChange={(e) => {
if (!e) {
setLockEventTypeCreationForUsers(
!!currentOrg.organizationSettings.lockEventTypeCreationForUsers
);
setShowModal(false);
}
}}>
<DialogContent enableOverflow>
<Form form={formMethods} handleSubmit={onSubmit}>
<div className="flex flex-row space-x-3">
<div className="w-full pt-1">
<DialogHeader title={t("lock_event_types_modal_header")} />
<RadioArea.Group
id="currentEventTypeOptions"
onValueChange={(val: CurrentEventTypeOptions) => {
formMethods.setValue("currentEventTypeOptions", val);
}}
className={classNames("min-h-24 mt-1 flex flex-col gap-4")}>
<RadioArea.Item
checked={currentLockedOption === CurrentEventTypeOptions.HIDE}
value={CurrentEventTypeOptions.HIDE}
className={classNames("h-full text-sm")}>
<strong className="mb-1 block">{t("hide_org_eventtypes")}</strong>
<p>{t("org_hide_event_types_org_admin")}</p>
</RadioArea.Item>
<RadioArea.Item
checked={currentLockedOption === CurrentEventTypeOptions.DELETE}
value={CurrentEventTypeOptions.DELETE}
className={classNames("[&:has(input:checked)]:border-error h-full text-sm")}>
<strong className="mb-1 block">{t("delete_org_eventtypes")}</strong>
<p>{t("org_delete_event_types_org_admin")}</p>
</RadioArea.Item>
</RadioArea.Group>
<DialogFooter>
<DialogClose />
<Button disabled={!isAdminOrOwner} type="submit">
{t("submit")}
</Button>
</DialogFooter>
</div>
</div>
</Form>
</DialogContent>
</Dialog>
)}
</>
);
};
@@ -25,6 +25,8 @@ import {
TimezoneSelect,
} from "@calcom/ui";
import { LockEventTypeSwitch } from "../components/LockEventTypeSwitch";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
@@ -81,6 +83,8 @@ const OrgGeneralView = () => {
isAdminOrOwner={isAdminOrOwner}
localeProp={user?.locale ?? "en"}
/>
<LockEventTypeSwitch currentOrg={currentOrg} isAdminOrOwner={isAdminOrOwner} />
</LicenseRequired>
);
};
+5
View File
@@ -133,6 +133,11 @@ export default async function getEventTypeById({
parent: {
select: {
slug: true,
organizationSettings: {
select: {
lockEventTypeCreationForUsers: true,
},
},
},
},
members: {
@@ -349,6 +349,11 @@ export class ProfileRepository {
movedFromUser: true,
organization: {
include: {
organizationSettings: {
select: {
lockEventTypeCreationForUsers: true,
},
},
members: {
select: membershipSelect,
},
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OrganizationSettings" ADD COLUMN "lockEventTypeCreationForUsers" BOOLEAN NOT NULL DEFAULT false;
+8 -7
View File
@@ -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 {
+45
View File
@@ -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: [
@@ -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({
@@ -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: {
@@ -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,
@@ -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
@@ -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<typeof ZUpdateInputSchema>;