Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: lock event types for org users #14000

Merged
merged 34 commits into from Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
358ca6d
chroe: org settings schema
sean-brydon Mar 4, 2024
3d10da4
wip: UI work + modal on toggle
sean-brydon Mar 4, 2024
a7902c8
chore: update i18n
sean-brydon Mar 4, 2024
0260f34
fix: list handler
sean-brydon Mar 4, 2024
9acfe36
fix: update handler on untoggle
sean-brydon Mar 4, 2024
4a96fe9
chore: extract
sean-brydon Mar 5, 2024
4cb3f35
feat: update handler for hide and delete logic
sean-brydon Mar 5, 2024
70cd8de
chore: radio translations
sean-brydon Mar 5, 2024
3442d1a
chore: fix modal state from flickering
sean-brydon Mar 5, 2024
938c244
feat: update copy & add danger colour on delete focus
sean-brydon Mar 5, 2024
cab5657
chore: update copy
sean-brydon Mar 5, 2024
84f4e47
design: remove modal icon
sean-brydon Mar 5, 2024
0997649
Merge remote-tracking branch 'origin/main' into feat/lock-event-types…
sean-brydon Mar 5, 2024
716bf80
chore: include more seeds
sean-brydon Mar 5, 2024
5c5618c
fix: update handler
sean-brydon Mar 6, 2024
733ca25
feat: hide create for profile user
sean-brydon Mar 6, 2024
17d4655
feat: use readonly instead
sean-brydon Mar 6, 2024
fd7a839
feat: hide personal event type header when read only
sean-brydon Mar 6, 2024
c63c6c1
chore: move away form readOnly as it was used in other ways
sean-brydon Mar 6, 2024
ea2955c
chore: fix read-only bug
sean-brydon Mar 6, 2024
39d3b4a
fix: hide readonly create screen
sean-brydon Mar 6, 2024
c05f78f
fix: show managed event types when events are locked
sean-brydon Mar 7, 2024
5d6c709
fix: hide if not admin
sean-brydon Mar 7, 2024
9af9adb
Merge branch 'main' into feat/lock-event-types-orgs
Udit-takkar Mar 7, 2024
17531a0
Update packages/trpc/server/routers/viewer/eventTypes/create.handler.ts
sean-brydon Mar 7, 2024
66c6e41
fix: feedback
sean-brydon Mar 8, 2024
feb38d5
Merge remote-tracking branch 'refs/remotes/origin/feat/lock-event-typ…
sean-brydon Mar 11, 2024
e13ddd0
Merge branch 'main' into feat/lock-event-types-orgs
Mar 11, 2024
fd64f88
fix: lock eventtype single switch
sean-brydon Mar 11, 2024
1709fde
fix: empty state create button
sean-brydon Mar 11, 2024
74203b3
Merge remote-tracking branch 'refs/remotes/origin/feat/lock-event-typ…
sean-brydon Mar 11, 2024
cd8a55a
fix: more profileGroup check outside CTA to pass value to filter too
sean-brydon Mar 11, 2024
b709b08
Merge remote-tracking branch 'origin/main' into feat/lock-event-types…
sean-brydon Mar 12, 2024
9a2f0d6
Merge branch 'main' into feat/lock-event-types-orgs
sean-brydon Mar 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/components/eventtype/EventTypeSingleLayout.tsx
Expand Up @@ -204,6 +204,7 @@
activeWebhooksNumber,
}: Props) {
const { t } = useLocale();
const eventTypesLockedByOrg = eventType.team?.parent?.organizationSettings?.lockEventTypeCreationForUsers;

const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);

Expand Down Expand Up @@ -283,18 +284,18 @@
});
}
return navigation;
}, [
t,
enabledAppsNumber,
installedAppsNumber,
enabledWorkflowsNumber,
availability,
isManagedEventType,
isChildrenManagedEventType,
team,
length,
multipleDuration,
formMethods.getValues("id"),

Check warning on line 298 in apps/web/components/eventtype/EventTypeSingleLayout.tsx

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

apps/web/components/eventtype/EventTypeSingleLayout.tsx#L298

[react-hooks/exhaustive-deps] React Hook useMemo has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked.
watchSchedulingType,
watchChildrenCount,
activeWebhooksNumber,
Expand Down Expand Up @@ -341,6 +342,7 @@
<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 });
Expand Down
123 changes: 78 additions & 45 deletions apps/web/modules/event-types/views/event-types-listing-view.tsx
Expand Up @@ -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";
Expand Down Expand Up @@ -109,6 +110,7 @@ interface EventTypeListProps {
readOnly: boolean;
bookerUrl: string | null;
types: DeNormalizedEventType[];
lockedByOrg?: boolean;
}

interface MobileTeamsTabProps {
Expand Down Expand Up @@ -230,6 +232,7 @@ export const EventTypeList = ({
readOnly,
types,
bookerUrl,
lockedByOrg,
}: EventTypeListProps): JSX.Element => {
const { t } = useLocale();
const router = useRouter();
Expand Down Expand Up @@ -390,8 +393,10 @@ export const EventTypeList = ({
if (!types.length) {
return group.teamId ? (
<EmptyEventTypeList group={group} />
) : (
) : !group.profile.eventTypesLockedByOrg ? (
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} />
) : (
<></>
);
}

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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
Expand All @@ -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>
);
};
Expand Down Expand Up @@ -885,41 +892,52 @@ const Main = ({
rawData.eventTypeGroups.length === 1;

const data = denormalizePayload(rawData);

return (
<>
{data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? (
<>
{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} />
CarinaWolli marked this conversation as resolved.
Show resolved Hide resolved
) : !group.metadata.readOnly ? (
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
) : null}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This last null fixes the bug where currently MEMBERS see the option to create events on teams that are empty even tho they dont have permission

</div>
);
})
)}
</>
) : (
Expand Down Expand Up @@ -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}
Expand All @@ -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."
Expand Down
7 changes: 7 additions & 0 deletions apps/web/public/static/locales/en/common.json
Expand Up @@ -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>
)}
</>
);
};
4 changes: 4 additions & 0 deletions packages/features/ee/organizations/pages/settings/general.tsx
Expand Up @@ -25,6 +25,8 @@ import {
TimezoneSelect,
} from "@calcom/ui";

import { LockEventTypeSwitch } from "../components/LockEventTypeSwitch";

const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
Expand Down Expand Up @@ -81,6 +83,8 @@ const OrgGeneralView = () => {
isAdminOrOwner={isAdminOrOwner}
localeProp={user?.locale ?? "en"}
/>

<LockEventTypeSwitch currentOrg={currentOrg} isAdminOrOwner={isAdminOrOwner} />
</LicenseRequired>
);
};
Expand Down