Compare commits

...
20 Commits
Author SHA1 Message Date
Keith Williams 828ef8580e v3.0.5
release.yml / release (push) Canceled after 0s
2023-06-21 18:39:37 +02:00
Jeroen Reumkens a348c3a20a CAL-1892: Show booker layout dark mode preview based on theme the user selects (#9664) 2023-06-21 17:14:23 +02:00
Omar López 3c68c7f7cb chore: Upgrades prisma to 4.16 (#9670)
* Upgrades prisma to 4.16

* typefix
2023-06-21 17:07:54 +02:00
Hariom Balhara 820b2da16d test: Add RegExp tests for Next.config.js rewrites (#9681)
* Add RegExp tests

* Update apps/web/test/lib/next-config.test.ts

* Dont avoid rewrite for /booked event slug

* Add /embed/embed.js test

* Add dynamic group booking link test
2023-06-21 17:06:44 +02:00
sean-brydon 6bdb2b94eb feat: Adds sheet component (#9680)
* Adds sheet component

* Fix overlay

* Calc math
2023-06-21 15:29:23 +01:00
Hariom Balhara 3c3db0b3a6 fix: Hotfix/ Dynamic Group Booking Links not working in new booker (#9685)
* Debuglogs

* Derive group usernames correctly

* Revert "Debuglogs"

This reverts commit d93f031589b34bb7fee32a34f96eb89805c20275.
2023-06-21 15:22:13 +01:00
Hariom Balhara 4748641c22 fix: Hotfix/main - 404 workflows (#9678) 2023-06-21 15:39:03 +05:30
Jeroen Reumkens 804787f2a3 Get brand colors from DB for owner, so it's reflected in booker (#9679) 2023-06-21 09:04:15 +00:00
Alex van Andel b7c3b31e0c fix: eventType-getByViewer subselect on children->users[] (#9668) 2023-06-21 08:56:42 +00:00
Jeroen ReumkensandHariom Balhara ab6781cd72 fix: Booker fix for team + user with same event + event slug (#9654)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2023-06-21 13:01:55 +05:30
Crowdin Bot b070cfb227 New Crowdin translations by Github Action 2023-06-21 07:29:17 +00:00
Hariom Balhara 5acb3a09e5 fix: Hide EventTypeDetails when configured (#9633) 2023-06-21 12:58:21 +05:30
Crowdin Bot 69c9f690cb New Crowdin translations by Github Action 2023-06-20 17:55:04 +00:00
Ayush Mainali d2a12ea834 refactor: Convert card to cva (#9651) 2023-06-20 19:54:08 +02:00
16f24b4831 feat: [CAL-1435] Emptyscreen for App Store if no external app has been configured (CALCOM-8154) (#9516)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-06-20 19:53:33 +02:00
sean-brydon a3eeeab259 feat: Handle deleting vercel domain when org is deleted (#9661) 2023-06-20 17:57:21 +02:00
Alex van Andel f69c254fbc v3.0.4
release.yml / release (push) Canceled after 0s
2023-06-20 17:37:53 +02:00
Hariom Balhara f0f6bd4846 fix: Team links and username starting with team rewrites (#9665)
* Fix

* Fix private link /book page as well
2023-06-20 16:48:15 +02:00
Udit TakkarandPeer Richelsen 8816a18854 feat: team filters (#9543)
* feat: team filers

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: remove filter from mobile

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: show heading when one item selected

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-06-20 16:39:18 +02:00
sean-brydon 4e529e5c6c test: Adds permission tests to impersonation (#9660)
* Adds permission tests to impersonation

* NIT + new test for none numbers
2023-06-20 13:42:37 +00:00
63 changed files with 1383 additions and 406 deletions
@@ -2,7 +2,7 @@ import { CAL_URL } from "@calcom/lib/constants";
export default function getCalLink(eventType: {
team?: { slug: string | null } | null;
owner: { username: string | null } | null;
owner?: { username: string | null } | null;
users?: { username: string | null }[];
slug: string;
}) {
@@ -28,8 +28,8 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
{emptyStore ? (
<EmptyScreen
Icon={AlertCircle}
headline={t("no_apps")}
description={isAdmin ? "You can enable apps in the settings" : ""}
headline={isAdmin ? t("no_apps") : t("no_apps_configured")}
description={isAdmin ? t("enable_in_settings") : t("please_contact_admin")}
buttonText={isAdmin ? t("apps_settings") : ""}
buttonOnClick={() => router.push("/settings/admin/apps/calendar")}
/>
@@ -38,6 +38,7 @@ const generateHashedLink = (id: number) => {
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
const { data: user } = trpc.viewer.me.useQuery();
const formMethods = useFormContext<FormValues>();
const { t } = useLocale();
@@ -49,6 +50,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
const bookingFields: Prisma.JsonObject = {};
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
const selectedThemeIsDark =
user?.theme === "dark" ||
(!user?.theme && typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
eventType.bookingFields.forEach(({ name }) => {
bookingFields[name] = name + " input";
@@ -162,7 +166,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
</div>
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
<div>
<BookerLayoutSelector fallbackToUserSettings />
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
</div>
<hr className="border-subtle" />
<FormBuilder
+20 -23
View File
@@ -1,10 +1,10 @@
require("dotenv").config({ path: "../../.env" });
const CopyWebpackPlugin = require("copy-webpack-plugin");
const os = require("os");
const glob = require("glob");
const englishTranslation = require("./public/static/locales/en/common.json");
const { withAxiom } = require("next-axiom");
const { i18n } = require("./next-i18next.config");
const { pages } = require("./pages");
if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
@@ -82,16 +82,19 @@ if (process.env.ANALYZE === "true") {
plugins.push(withAxiom);
/** Needed to rewrite public booking page, gets all static pages but [user] */
const pages = glob
.sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname })
.map((filename) =>
filename
.substr(6)
.replace(/(\.tsx|\.js|\.ts)/, "")
.replace(/\/.*/, "")
)
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]"));
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
// It would also not match /free/30min/embed because we are ensuring just two slashes
// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page.
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked
// Important Note: When modifying these RegExps update apps/web/test/lib/next-config.test.ts as well
const userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`;
const teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)";
const privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)";
const embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`;
const embedTeamTypeRouteRegExp = "/team/:slug/:type/embed";
/** @type {import("next").NextConfig} */
const nextConfig = {
@@ -183,12 +186,6 @@ const nextConfig = {
return config;
},
async rewrites() {
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
// It would also not match /free/30min/embed because we are ensuring just two slashes
// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page.
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
const userTypeRouteRegExp = `/:user((?!${pages.join("|")})[^/]*)/:type((?!book)[^/]+)`;
let rewrites = [
{
source: "/org/:slug",
@@ -233,12 +230,12 @@ const nextConfig = {
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
{
source: "/team/:slug/:type",
source: teamTypeRouteRegExp,
destination: "/new-booker/team/:slug/:type",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
{
source: "/d/:link/:slug",
source: privateLinkRouteRegExp,
destination: "/new-booker/d/:link/:slug",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
@@ -246,12 +243,12 @@ const nextConfig = {
// Keep cookie based booker enabled to test new-booker embed in production
...[
{
source: `/:user((?!${pages.join("|")}).*)/:type/embed`,
source: embedUserTypeRouteRegExp,
destination: "/new-booker/:user/:type/embed",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
{
source: "/team/:slug/:type/embed",
source: embedTeamTypeRouteRegExp,
destination: "/new-booker/team/:slug/:type/embed",
has: [{ type: "cookie", key: "new-booker-enabled" }],
},
@@ -293,11 +290,11 @@ const nextConfig = {
destination: "/new-booker/:user/:type",
},
{
source: "/team/:slug/:type",
source: teamTypeRouteRegExp,
destination: "/new-booker/team/:slug/:type",
},
{
source: "/d/:link/:slug",
source: privateLinkRouteRegExp,
destination: "/new-booker/d/:link/:slug",
},
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.0.3",
"version": "3.0.5",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+14
View File
@@ -0,0 +1,14 @@
const glob = require("glob");
/** Needed to rewrite public booking page, gets all static pages but [user] */
const pages = glob
.sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname })
.map((filename) =>
filename
.substr(6)
.replace(/(\.tsx|\.js|\.ts)/, "")
.replace(/\/.*/, "")
)
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]"));
exports.pages = pages;
+55 -47
View File
@@ -13,7 +13,8 @@ import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import { OrganizationEventTypeFilter } from "@calcom/features/eventtypes/components/OrganizationEventTypeFilter";
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import Shell from "@calcom/features/shell/Shell";
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -47,7 +48,6 @@ import {
HeadSeo,
Skeleton,
Label,
VerticalDivider,
Alert,
} from "@calcom/ui";
import {
@@ -420,7 +420,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
truncateAfter={4}
items={type.children
.flatMap((ch) => ch.users)
.map((user: User) => ({
.map((user: Pick<User, "name" | "username">) => ({
alt: user.name || "",
image: `${WEBAPP_URL}/${user.username}/avatar.png`,
title: user.name || "",
@@ -800,8 +800,7 @@ const CTA = () => {
const Actions = () => {
return (
<div className="hidden items-center md:flex">
<OrganizationEventTypeFilter />
<VerticalDivider />
<TeamsFilter popoverTriggerClassNames="mb-0" showVerticalDivider={true} />
</div>
);
};
@@ -834,9 +833,6 @@ const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer as any);
const EventTypesPage = () => {
const { t } = useLocale();
const router = useRouter();
@@ -862,6 +858,10 @@ const EventTypesPage = () => {
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filters = getTeamsFiltersFromQuery(router.query);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer as any, { filters });
return (
<div>
@@ -879,47 +879,55 @@ const EventTypesPage = () => {
CTA={<CTA />}>
<WithQuery
customLoader={<SkeletonLoader />}
success={({ data }) => (
<>
{data.eventTypeGroups.length > 1 ? (
<>
{isMobile ? (
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
) : (
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
<div className="flex flex-col" key={group.profile.slug}>
<EventTypeListHeading
profile={group.profile}
membershipCount={group.metadata.membershipCount}
teamId={group.teamId}
orgSlug={orgBranding?.slug}
/>
success={({ data }) => {
const isFilteredByOnlyOneItem =
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) &&
data.eventTypeGroups.length === 1;
<EventTypeList
types={group.eventTypes}
group={group}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
</div>
))
)}
</>
) : data.eventTypeGroups.length === 1 ? (
<EventTypeList
types={data.eventTypeGroups[0].eventTypes}
group={data.eventTypeGroups[0]}
groupIndex={0}
readOnly={data.eventTypeGroups[0].metadata.readOnly}
/>
) : (
<CreateFirstEventTypeView />
)}
return (
<>
{data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? (
<>
{isMobile ? (
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
) : (
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
<div className="flex flex-col" key={group.profile.slug}>
<EventTypeListHeading
profile={group.profile}
membershipCount={group.metadata.membershipCount}
teamId={group.teamId}
orgSlug={orgBranding?.slug}
/>
<EmbedDialog />
{router.query.dialog === "duplicate" && <DuplicateDialog />}
</>
)}
<EventTypeList
types={group.eventTypes}
group={group}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
</div>
))
)}
</>
) : (
data.eventTypeGroups.length === 1 && (
<EventTypeList
types={data.eventTypeGroups[0].eventTypes}
group={data.eventTypeGroups[0]}
groupIndex={0}
readOnly={data.eventTypeGroups[0].metadata.readOnly}
/>
)
)}
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
<EmbedDialog />
{router.query.dialog === "duplicate" && <DuplicateDialog />}
</>
);
}}
/>
</Shell>
</div>
+13 -2
View File
@@ -14,7 +14,7 @@ import PageWrapper from "@components/PageWrapper";
type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
export default function Type({ slug, user, booking, away, isBrandingHidden, isTeamEvent }: PageProps) {
return (
<main className="flex h-full min-h-[100dvh] items-center justify-center">
<BookerSeo
@@ -29,6 +29,7 @@ export default function Type({ slug, user, booking, away, isBrandingHidden }: Pa
rescheduleBooking={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent={isTeamEvent}
/>
</main>
);
@@ -57,6 +58,11 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
username: true,
},
},
team: {
select: {
id: true,
},
},
},
},
},
@@ -96,9 +102,11 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
}
const isTeamEvent = !!hashedLink.eventType?.team?.id;
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug });
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug, isTeamEvent });
if (!eventData) {
return {
@@ -114,6 +122,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
// Sending the team event from the server, because this template file
// is reused for both team and user events.
isTeamEvent,
},
};
}
@@ -30,6 +30,7 @@ export default function Type({ slug, user, booking, away, isBrandingHidden }: Pa
rescheduleBooking={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent
/>
</main>
);
@@ -71,7 +72,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({ username: teamSlug, eventSlug: meetingSlug });
const eventData = await ssr.viewer.public.event.fetch({
username: teamSlug,
eventSlug: meetingSlug,
isTeamEvent: true,
});
if (!eventData) {
return {
@@ -70,6 +70,13 @@ const AppearanceView = () => {
},
});
const selectedTheme = formMethods.watch("theme");
const selectedThemeIsDark =
selectedTheme === "dark" ||
(selectedTheme === "" &&
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark"));
const {
formState: { isSubmitting, isDirty },
reset,
@@ -144,6 +151,7 @@ const AppearanceView = () => {
<hr className="border-subtle my-8 border [&:has(+hr)]:hidden" />
<BookerLayoutSelector
isDark={selectedThemeIsDark}
name="metadata.defaultBookerLayouts"
title={t("bookerlayout_user_settings_title")}
description={t("bookerlayout_user_settings_description")}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

@@ -1408,7 +1408,6 @@
"team_url_required": "يجب إدخال عنوان URL للفريق",
"team_url_taken": "عنوان URL هذا مأخوذ بالفعل",
"team_publish": "نشر فريق",
"number_sms_notifications": "رقم الهاتف (إشعارات الرسائل النصية)",
"attendee_email_variable": "اسم الحاضر",
"attendee_email_info": "البريد الإلكتروني للشخص الحجز",
"kbar_search_placeholder": "اكتب أمرًا أو بحثًا...",
@@ -1408,7 +1408,6 @@
"team_url_required": "Musíte zadat adresu URL týmu",
"team_url_taken": "Tato adresa URL je už obsazená",
"team_publish": "Zveřejnit tým",
"number_sms_notifications": "Číslo telefonu (SMS oznámení)",
"attendee_email_variable": "E-mail účastníka",
"attendee_email_info": "E-mail osoby provádějící rezervaci",
"kbar_search_placeholder": "Zadejte příkaz nebo vyhledejte...",
@@ -1344,7 +1344,6 @@
"team_url_required": "Der skal indtastes et team URL",
"team_url_taken": "Denne URL er allerede i brug",
"team_publish": "Publicer team",
"number_sms_notifications": "Telefonnummer (SMS notifikationer)",
"attendee_email_variable": "Deltagers e-mail",
"attendee_email_info": "Personen der booker's e-mail",
"kbar_search_placeholder": "Indtast en kommando eller søgning...",
@@ -1408,7 +1408,6 @@
"team_url_required": "Team-URL muss eingegeben werden",
"team_url_taken": "Diese URL ist bereits vergeben",
"team_publish": "Team veröffentlichen",
"number_sms_notifications": "Telefonnummer (SMS-Benachrichtigungen)",
"attendee_email_variable": "Teilnehmer E-Mail",
"attendee_email_info": "Die E-Mail-Adresse der buchenden Person",
"kbar_search_placeholder": "Geben Sie einen Befehl ein oder suchen Sie ...",
@@ -1466,7 +1466,7 @@
"team_url_required": "Must enter a team URL",
"team_url_taken": "This URL is already taken",
"team_publish": "Publish team",
"number_sms_notifications": "Phone number (SMS notifications)",
"number_sms_notifications": "Phone number (SMS notifications)",
"attendee_email_variable": "Attendee email",
"attendee_email_info": "The person booking's email",
"kbar_search_placeholder": "Type a command or search...",
@@ -1523,6 +1523,9 @@
"no_available_apps": "There are no available apps",
"no_available_apps_description": "Please ensure there are apps in your deployment under 'packages/app-store'",
"no_apps": "There are no apps enabled in this instance of Cal",
"no_apps_configured": "No app has been configured yet",
"enable_in_settings": "You can enable apps in the settings",
"please_contact_admin": "Please contact your admin",
"apps_settings": "Apps settings",
"fill_this_field": "Please fill in this field",
"options": "Options",
@@ -1408,7 +1408,6 @@
"team_url_required": "Debe introducir una URL del equipo",
"team_url_taken": "Esta URL ya está en uso",
"team_publish": "Publicar equipo",
"number_sms_notifications": "Número de teléfono (notificaciones por SMS)",
"attendee_email_variable": "Correo electrónico del asistente",
"attendee_email_info": "El correo electrónico de la persona que reserva",
"kbar_search_placeholder": "Escriba un comando o búsqueda...",
@@ -1523,6 +1523,9 @@
"no_available_apps": "Il n'y a aucune application disponible",
"no_available_apps_description": "Veuillez vous assurer qu'il y a des applications dans votre déploiement sous \"packages/app-store\"",
"no_apps": "Aucune application n'est activée dans cette instance de Cal",
"no_apps_configured": "Aucune application n'a encore été configurée",
"enable_in_settings": "Vous pouvez activer les applications dans les paramètres",
"please_contact_admin": "Veuillez contacter votre administrateur",
"apps_settings": "Paramètres des applications",
"fill_this_field": "Veuillez remplir ce champ",
"options": "Options",
@@ -1421,7 +1421,6 @@
"team_url_required": "יש להזין כתובת URL של הצוות",
"team_url_taken": "כתובת ה-URL הזו כבר תפוסה",
"team_publish": "פרסום צוות",
"number_sms_notifications": "מספר טלפון (התראות SMS)",
"attendee_email_variable": "כתובת הדוא\"ל של המשתתף",
"attendee_email_info": "כתובת הדוא\"ל של האדם שביצע את ההזמנה",
"kbar_search_placeholder": "הקלד/י פקודה או חפש/י...",
@@ -1438,7 +1438,6 @@
"team_url_required": "Immettere un URL per il team",
"team_url_taken": "URL già occupato",
"team_publish": "Pubblica team",
"number_sms_notifications": "Numero di telefono (notifiche SMS)",
"attendee_email_variable": "E-mail partecipante",
"attendee_email_info": "E-mail della persona che prenota",
"kbar_search_placeholder": "Digita un comando o esegui una ricerca...",
@@ -1408,7 +1408,6 @@
"team_url_required": "チームの URL の入力は必須です",
"team_url_taken": "この URL は既に使用されています",
"team_publish": "チームを公開",
"number_sms_notifications": "電話番号(SMS 通知)",
"attendee_email_variable": "出席者のメールアドレス",
"attendee_email_info": "予約者のメールアドレス",
"kbar_search_placeholder": "コマンドを入力するか、検索してください...",
@@ -1409,7 +1409,6 @@
"team_url_required": "팀 URL을 입력하세요",
"team_url_taken": "이미 사용 중인 URL입니다",
"team_publish": "팀 게시",
"number_sms_notifications": "전화번호 (SMS notifications)",
"attendee_email_variable": "참석자 이메일",
"attendee_email_info": "예약자 이메일",
"kbar_search_placeholder": "명령어를 입력하거나 검색하세요...",
@@ -1408,7 +1408,6 @@
"team_url_required": "Moet een team-URL invoeren",
"team_url_taken": "Deze URL is al in gebruik",
"team_publish": "Team publiceren",
"number_sms_notifications": "Telefoonnummer (sms-meldingen)",
"attendee_email_variable": "E-mailadres deelnemer",
"attendee_email_info": "Het e-mailadres van de persoon die boekt",
"kbar_search_placeholder": "Typ een opdracht of zoek...",
@@ -1318,7 +1318,6 @@
"team_url_required": "Må angi en team URL",
"team_url_taken": "Denne URL'en er allerede tatt",
"team_publish": "Publiser team",
"number_sms_notifications": "Telefonnummer (SMS varsler)",
"attendee_email_variable": "Deltakerens e-post",
"attendee_email_info": "Den som booker sin e-post",
"kbar_search_placeholder": "Skriv inn en kommando eller søk...",
@@ -1408,7 +1408,6 @@
"team_url_required": "Musisz wprowadzić adres URL zespołu",
"team_url_taken": "Ten adres URL jest już zajęty",
"team_publish": "Opublikuj zespół",
"number_sms_notifications": "Numer telefonu (powiadomienia SMS)",
"attendee_email_variable": "Adres e-mail uczestnika",
"attendee_email_info": "Adres e-mail osoby rezerwującej",
"kbar_search_placeholder": "Wpisz polecenie lub wyszukaj...",
@@ -1408,7 +1408,6 @@
"team_url_required": "É preciso inserir uma URL de equipe",
"team_url_taken": "Este URL já está em uso",
"team_publish": "Publicar equipe",
"number_sms_notifications": "Número de telefone (notificações de SMS)",
"attendee_email_variable": "E-mail do participante",
"attendee_email_info": "O e-mail da pessoa que fez a reserva",
"kbar_search_placeholder": "Digite um comando ou procure...",
@@ -1408,7 +1408,6 @@
"team_url_required": "Deve especificar um endereço para a equipa",
"team_url_taken": "Este endereço já está a ser utilizado",
"team_publish": "Publicar equipa",
"number_sms_notifications": "Número de telefone (notificações SMS)",
"attendee_email_variable": "E-mail do participante",
"attendee_email_info": "O e-mail do responsável pela reserva",
"kbar_search_placeholder": "Digite um comando ou pesquise...",
@@ -1408,7 +1408,6 @@
"team_url_required": "Trebuie să introduceți un URL de echipă",
"team_url_taken": "Acest URL este folosit deja",
"team_publish": "Publicați echipa",
"number_sms_notifications": "Număr de telefon (notificări SMS)",
"attendee_email_variable": "E-mail participant",
"attendee_email_info": "E-mailul persoanei care efectuează rezervarea",
"kbar_search_placeholder": "Scrie o comandă sau caută...",
@@ -1408,7 +1408,6 @@
"team_url_required": "Необходимо ввести URL-адрес команды",
"team_url_taken": "Этот URL-адрес уже занят",
"team_publish": "Опубликовать команду",
"number_sms_notifications": "Номер телефона (SMS-уведомления)",
"attendee_email_variable": "Электронная почта участника",
"attendee_email_info": "Электронная почта участника, на которого оформляется бронирование",
"kbar_search_placeholder": "Введите команду или начните поиск...",
@@ -1435,7 +1435,6 @@
"team_url_required": "Morate da unesete URL tima",
"team_url_taken": "Ovaj URL je zauzet",
"team_publish": "Objavite tim",
"number_sms_notifications": "Broj telefona (obaveštenja putem SMS-a)",
"attendee_email_variable": "Imejl polaznika",
"attendee_email_info": "Imejl osobe koja rezerviše",
"kbar_search_placeholder": "Unesite komandu ili pretražite...",
@@ -1408,7 +1408,6 @@
"team_url_required": "Team-URL måste anges",
"team_url_taken": "Denna URL används redan",
"team_publish": "Publicera team",
"number_sms_notifications": "Telefonnummer (SMS-notiser)",
"attendee_email_variable": "Deltagarens e-postadress",
"attendee_email_info": "Personens e-postadress för bokning",
"kbar_search_placeholder": "Ange ett kommando eller sök ...",
@@ -1408,7 +1408,6 @@
"team_url_required": "Ekip URL'si girilmelidir",
"team_url_taken": "Bu URL zaten kullanılıyor",
"team_publish": "Ekibi yayınla",
"number_sms_notifications": "Telefon numarası (SMS bildirimleri)",
"attendee_email_variable": "Katılımcı e-postası",
"attendee_email_info": "Rezervasyon yaptıran kişinin e-postası",
"kbar_search_placeholder": "Komut girin veya arayın...",
@@ -1408,7 +1408,6 @@
"team_url_required": "Потрібно ввести URL-адресу команди",
"team_url_taken": "Цю URL-адресу вже зайнято",
"team_publish": "Опублікувати команду",
"number_sms_notifications": "Номер телефону (SMS-сповіщення)",
"attendee_email_variable": "Адреса ел. пошти учасника",
"attendee_email_info": "Адреса ел. пошти особи, яка бронює",
"kbar_search_placeholder": "Введіть команду або пошуковий запит…",
@@ -1408,7 +1408,6 @@
"team_url_required": "Cần phải nhập một URL của nhóm",
"team_url_taken": "URL này đã có người lấy rồi",
"team_publish": "Đăng nhóm",
"number_sms_notifications": "Số điện thoại (thông báo SMS)",
"attendee_email_variable": "Email người tham dự",
"attendee_email_info": "Email người tham gia lịch hẹn",
"kbar_search_placeholder": "Nhập một lệnh hoặc tìm kiếm...",
@@ -1421,7 +1421,6 @@
"team_url_required": "必须输入团队链接",
"team_url_taken": "此链接已被占用",
"team_publish": "发布团队",
"number_sms_notifications": "电话号码(短信通知)",
"attendee_email_variable": "参与者电子邮件",
"attendee_email_info": "预约人的电子邮件",
"kbar_search_placeholder": "输入命令或搜索...",
@@ -1408,7 +1408,6 @@
"team_url_required": "團隊網址為必填",
"team_url_taken": "已有人使用此網址",
"team_publish": "發佈團隊",
"number_sms_notifications": "電話號碼 (簡訊通知)",
"attendee_email_variable": "與會者電子郵件",
"attendee_email_info": "預約人電子郵件",
"kbar_search_placeholder": "輸入指令或搜尋…",
+157
View File
@@ -0,0 +1,157 @@
import { it, expect, describe, beforeAll, afterAll } from "vitest";
let userTypeRouteRegExp: RegExp;
let teamTypeRouteRegExp:RegExp;
let privateLinkRouteRegExp:RegExp
let embedUserTypeRouteRegExp:RegExp
let embedTeamTypeRouteRegExp:RegExp
const getRegExpFromNextJsRewriteRegExp = (nextJsRegExp:string) => {
// const parts = nextJsRegExp.split(':');
// const validNamedGroupRegExp = parts.map((part, index)=>{
// if (index === 0) {
// return part;
// }
// if (part.match(/^[a-zA-Z0-9]+$/)) {
// return `(?<${part}>[^/]+)`
// }
// part = part.replace(new RegExp('([^(]+)(.*)'), '(?<$1>$2)');
// return part
// }).join('');
// TODO: If we can easily convert the exported rewrite regexes from next.config.js to a valid named capturing group regex, it would be best
// Next.js does an exact match as per my testing.
return new RegExp(`^${nextJsRegExp}$`)
}
describe('next.config.js - RegExp', ()=>{
beforeAll(async()=>{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_URL = process.env.CALENDSO_ENCRYPTION_KEY = 1
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pages = require("../../pages").pages
// How to convert a Next.js rewrite RegExp/wildcard to a valid JS named capturing Group RegExp?
// - /:user/ -> (?<user>[^/]+)
// - /:user(?!404)[^/]+/ -> (?<user>((?!404)[^/]+))
// userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`;
userTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?<user>((?!${pages.join("/|")})[^/]*))/(?<type>((?!book$)[^/]+))`);
// teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)";
teamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?<slug>[^/]+)/(?<type>((?!book$)[^/]+))");
// privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)";
privateLinkRouteRegExp = getRegExpFromNextJsRewriteRegExp("/d/(?<link>[^/]+)/(?<slug>((?!book$)[^/]+))");
// embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`;
embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?<user>((?!${pages.join("|")}).*))/(?<type>[^/]+)/embed`);
// embedTeamTypeRouteRegExp = "/team/:slug/:type/embed";
embedTeamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?<slug>[^/]+)/(?<type>[^/]+)/embed");
});
afterAll(()=>{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_URL = process.env.CALENDSO_ENCRYPTION_KEY = undefined
})
it("Booking Urls", async () => {
expect(userTypeRouteRegExp.exec('/free/30')?.groups).toContain({
user: 'free',
type: '30'
})
// Edgecase of username starting with team also works
expect(userTypeRouteRegExp.exec('/teampro/30')?.groups).toContain({
user: 'teampro',
type: '30'
})
expect(userTypeRouteRegExp.exec('/teampro+pro/30')?.groups).toContain({
user: 'teampro+pro',
type: '30'
})
expect(userTypeRouteRegExp.exec('/teampro+pro/book')).toEqual(null)
// Because /book doesn't have a corresponding new-booker route.
expect(userTypeRouteRegExp.exec('/free/book')).toEqual(null)
// Because /booked is a normal event name
expect(userTypeRouteRegExp.exec('/free/booked')?.groups).toEqual({
user: 'free',
type: 'booked'
})
expect(embedUserTypeRouteRegExp.exec('/free/30/embed')?.groups).toContain({
user: 'free',
type:'30'
})
expect(teamTypeRouteRegExp.exec('/team/seeded/30')?.groups).toContain({
slug: 'seeded',
type: '30'
})
// Because /book doesn't have a corresponding new-booker route.
expect(teamTypeRouteRegExp.exec('/team/seeded/book')).toEqual(null)
expect(teamTypeRouteRegExp.exec('/team/seeded/30/embed')).toEqual(null)
expect(embedTeamTypeRouteRegExp.exec('/team/seeded/30/embed')?.groups).toContain({
slug: 'seeded',
type:'30'
})
expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/30min')?.groups).toContain({
link: '3v4s321CXRJZx5TFxkpPvd',
slug: '30min'
})
expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/30min')?.groups).toContain({
link: '3v4s321CXRJZx5TFxkpPvd',
slug: '30min'
})
// Because /book doesn't have a corresponding new-booker route.
expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/book')).toEqual(null)
});
it('Non booking Urls', ()=>{
expect(userTypeRouteRegExp.exec('/404')).toEqual(null)
expect(teamTypeRouteRegExp.exec('/404')).toEqual(null)
expect(userTypeRouteRegExp.exec('/404/30')).toEqual(null)
expect(teamTypeRouteRegExp.exec('/404/30')).toEqual(null)
expect(userTypeRouteRegExp.exec('/api')).toEqual(null)
expect(teamTypeRouteRegExp.exec('/api')).toEqual(null)
expect(userTypeRouteRegExp.exec('/api/30')).toEqual(null)
expect(teamTypeRouteRegExp.exec('/api/30')).toEqual(null)
expect(userTypeRouteRegExp.exec('/workflows/30')).toEqual(null)
expect(teamTypeRouteRegExp.exec('/workflows/30')).toEqual(null)
expect(userTypeRouteRegExp.exec('/event-types/30')).toEqual(null)
expect(teamTypeRouteRegExp.exec('/event-types/30')).toEqual(null)
expect(userTypeRouteRegExp.exec('/teams/1')).toEqual(null)
expect(teamTypeRouteRegExp.exec('/teams/1')).toEqual(null)
expect(userTypeRouteRegExp.exec('/teams')).toEqual(null)
expect(teamTypeRouteRegExp.exec('/teams')).toEqual(null)
// Note that even though it matches /embed/embed.js, but it's served from /public and the regexes are in afterEach, it won't hit the flow.
// expect(userTypeRouteRegExp.exec('/embed/embed.js')).toEqual(null)
// expect(teamTypeRouteRegExp.exec('/embed/embed.js')).toEqual(null)
})
})
+7 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useMemo } from "react";
import StickyBox from "react-sticky-box";
import { shallow } from "zustand/shallow";
import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
import classNames from "@calcom/lib/classNames";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
@@ -35,6 +36,7 @@ const BookerComponent = ({
month,
rescheduleBooking,
hideBranding = false,
isTeamEvent,
}: BookerProps) => {
const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)");
@@ -80,6 +82,7 @@ const BookerComponent = ({
rescheduleUid,
rescheduleBooking,
layout: defaultLayout,
isTeamEvent,
});
useEffect(() => {
@@ -103,6 +106,9 @@ const BookerComponent = ({
}
}, [layout]);
const embedUiConfig = useEmbedUiConfig();
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
if (event.isSuccess && !event.data) {
return <NotFound />;
}
@@ -114,7 +120,7 @@ const BookerComponent = ({
ref={animationScope}
className={classNames(
// Sets booker size css variables for the size of all the columns.
...getBookerSizeClassNames(layout, bookerState),
...getBookerSizeClassNames(layout, bookerState, hideEventTypeDetails),
"bg-default dark:bg-muted grid max-w-full items-start dark:[color-scheme:dark] sm:transition-[width] sm:duration-300 sm:motion-reduce:transition-none md:flex-row",
layout === BookerLayouts.MONTH_VIEW && "border-subtle rounded-md border",
!isEmbed && "sm:transition-[width] sm:duration-300",
@@ -1,6 +1,7 @@
import { m } from "framer-motion";
import dynamic from "next/dynamic";
import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { EventDetails, EventMembers, EventMetaSkeleton, EventTitle } from "@calcom/features/bookings";
import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/Details";
import { useTimePreferences } from "@calcom/features/bookings/lib";
@@ -25,6 +26,13 @@ export const EventMeta = () => {
const rescheduleBooking = useBookerStore((state) => state.rescheduleBooking);
const { i18n, t } = useLocale();
const { data: event, isLoading } = useEvent();
const embedUiConfig = useEmbedUiConfig();
const isEmbed = useIsEmbed();
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
if (hideEventTypeDetails) {
return null;
}
return (
<div className="relative z-10 p-6">
+19 -6
View File
@@ -112,25 +112,38 @@ export const resizeAnimationConfig: ResizeAnimationConfig = {
},
};
export const getBookerSizeClassNames = (layout: BookerLayout, bookerState: BookerState) => {
export const getBookerSizeClassNames = (
layout: BookerLayout,
bookerState: BookerState,
hideEventTypeDetails = false
) => {
const getBookerMetaClass = (className: string) => {
if (hideEventTypeDetails) {
return "";
}
return className;
};
return [
// Size settings are abstracted on their own lines purely for readbility.
// Size settings are abstracted on their own lines purely for readability.
// General sizes, used always
"[--booker-timeslots-width:240px] lg:[--booker-timeslots-width:280px]",
// Small calendar defaults
layout === BookerLayouts.MONTH_VIEW && "[--booker-meta-width:240px]",
layout === BookerLayouts.MONTH_VIEW && getBookerMetaClass("[--booker-meta-width:240px]"),
// Meta column get's wider in booking view to fit the full date on a single row in case
// of a multi occurance event. Also makes form less wide, which also looks better.
layout === BookerLayouts.MONTH_VIEW &&
bookerState === "booking" &&
"[--booker-main-width:420px] lg:[--booker-meta-width:340px]",
`[--booker-main-width:420px] ${getBookerMetaClass("lg:[--booker-meta-width:340px]")}`,
// Smaller meta when not in booking view.
layout === BookerLayouts.MONTH_VIEW &&
bookerState !== "booking" &&
"[--booker-main-width:480px] lg:[--booker-meta-width:280px]",
`[--booker-main-width:480px] ${getBookerMetaClass("lg:[--booker-meta-width:280px]")}`,
// Fullscreen view settings.
layout !== BookerLayouts.MONTH_VIEW &&
"[--booker-main-width:480px] [--booker-meta-width:340px] lg:[--booker-meta-width:424px]",
`[--booker-main-width:480px] [--booker-meta-width:340px] ${getBookerMetaClass(
"lg:[--booker-meta-width:424px]"
)}`,
];
};
+32 -2
View File
@@ -22,6 +22,7 @@ type StoreInitializeType = {
rescheduleUid: string | null;
rescheduleBooking: GetBookingType | null | undefined;
layout: BookerLayout;
isTeamEvent?: boolean;
};
export type BookerStore = {
@@ -89,6 +90,12 @@ export type BookerStore = {
*/
formValues: Record<string, any>;
setFormValues: (values: Record<string, any>) => void;
/**
* Force event being a team event, so we only query for team events instead
* of also include 'user' events and return the first event that matches with
* both the slug and the event slug.
*/
isTeamEvent: boolean;
};
/**
@@ -137,6 +144,7 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
updateQueryParam("month", month ?? "");
get().setSelectedDate(null);
},
isTeamEvent: false,
initialize: ({
username,
eventSlug,
@@ -145,6 +153,7 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
rescheduleUid = null,
rescheduleBooking = null,
layout,
isTeamEvent,
}: StoreInitializeType) => {
const selectedDateInStore = get().selectedDate;
@@ -165,6 +174,7 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
rescheduleUid,
rescheduleBooking,
layout: layout || BookerLayouts.MONTH_VIEW,
isTeamEvent: isTeamEvent || false,
// Preselect today's date in week / column view, since they use this to show the week title.
selectedDate:
selectedDateInStore ||
@@ -207,9 +217,29 @@ export const useInitializeBookerStore = ({
rescheduleUid = null,
rescheduleBooking = null,
layout,
isTeamEvent,
}: StoreInitializeType) => {
const initializeStore = useBookerStore((state) => state.initialize);
useEffect(() => {
initializeStore({ username, eventSlug, month, eventId, rescheduleUid, rescheduleBooking, layout });
}, [initializeStore, username, eventSlug, month, eventId, rescheduleUid, rescheduleBooking, layout]);
initializeStore({
username,
eventSlug,
month,
eventId,
rescheduleUid,
rescheduleBooking,
layout,
isTeamEvent,
});
}, [
initializeStore,
username,
eventSlug,
month,
eventId,
rescheduleUid,
rescheduleBooking,
layout,
isTeamEvent,
]);
};
@@ -41,6 +41,13 @@ export interface BookerProps {
* within the atom (i.e. without a server side component).
*/
rescheduleBooking?: GetBookingType;
/**
* If this boolean is passed, we will only check team events with this slug and event slug.
* If it's not passed, we will first query a generic user event, and only if that doesn't exist
* fetch the team event. In case there's both a team + user with the same slug AND same event slug,
* that will always result in the user event being returned.
*/
isTeamEvent?: boolean;
}
export type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking";
@@ -16,9 +16,10 @@ import { useBookerStore } from "../store";
*/
export const useEvent = () => {
const [username, eventSlug] = useBookerStore((state) => [state.username, state.eventSlug], shallow);
const isTeamEvent = useBookerStore((state) => state.isTeamEvent);
return trpc.viewer.public.event.useQuery(
{ username: username ?? "", eventSlug: eventSlug ?? "" },
{ username: username ?? "", eventSlug: eventSlug ?? "", isTeamEvent },
{ refetchOnWindowFocus: false, enabled: Boolean(username) && Boolean(eventSlug) }
);
};
@@ -0,0 +1,81 @@
import type { Session } from "next-auth";
import { describe, expect, it } from "vitest";
import { UserPermissionRole } from "@calcom/prisma/enums";
import {
parseTeamId,
checkSelfImpersonation,
checkUserIdentifier,
checkPermission,
} from "./ImpersonationProvider";
const session: Session = {
expires: "2021-08-31T15:00:00.000Z",
hasValidLicense: true,
user: {
id: 123,
username: "test",
role: UserPermissionRole.USER,
email: "test@example.com",
},
};
describe("parseTeamId", () => {
it("should return undefined if no teamId is provided", () => {
expect(parseTeamId(undefined)).toBeUndefined();
});
it("should return the parsed teamId if a teamId is provided", () => {
expect(parseTeamId({ username: "test", teamId: "123" })).toBe(123);
});
it("should throw an error if the provided teamId is not a positive number", () => {
expect(() => parseTeamId({ username: "test", teamId: "-123" })).toThrow();
});
it("should throw an error if the provided teamId is not a number", () => {
expect(() => parseTeamId({ username: "test", teamId: "notanumber" })).toThrow();
});
});
describe("checkSelfImpersonation", () => {
it("should throw an error if the provided username is the same as the session user's username", () => {
expect(() => checkSelfImpersonation(session, { username: "test" })).toThrow();
});
it("should throw an error if the provided username is the same as the session user's email", () => {
expect(() => checkSelfImpersonation(session, { username: "test@example.com" })).toThrow();
});
it("should not throw an error if the provided username is different from the session user's username and email", () => {
expect(() => checkSelfImpersonation(session, { username: "other" })).not.toThrow();
});
});
describe("checkUserIdentifier", () => {
it("should throw an error if no username is provided", () => {
expect(() => checkUserIdentifier(undefined)).toThrow();
});
it("should not throw an error if a username is provided", () => {
expect(() => checkUserIdentifier({ username: "test" })).not.toThrow();
});
});
describe("checkPermission", () => {
it("should throw an error if the user is not an admin and team impersonation is disabled", () => {
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false";
expect(() => checkPermission(session)).toThrow();
});
it("should not throw an error if the user is an admin and team impersonation is disabled", () => {
const modifiedSession = { ...session, user: { ...session.user, role: UserPermissionRole.ADMIN } };
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false";
expect(() => checkPermission(modifiedSession)).not.toThrow();
});
it("should not throw an error if the user is not an admin but team impersonation is enabled", () => {
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "true";
expect(() => checkPermission(session)).not.toThrow();
});
});
@@ -1,4 +1,5 @@
import type { User } from "@prisma/client";
import type { Session } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { z } from "zod";
@@ -44,6 +45,28 @@ const auditAndReturnNextUser = async (
return obj;
};
type Credentials = Record<"username" | "teamId", string> | undefined;
export function parseTeamId(creds: Partial<Credentials>) {
return creds?.teamId ? teamIdschema.parse({ teamId: creds.teamId }).teamId : undefined;
}
export function checkSelfImpersonation(session: Session | null, creds: Partial<Credentials>) {
if (session?.user.username === creds?.username || session?.user.email === creds?.username) {
throw new Error("You cannot impersonate yourself.");
}
}
export function checkUserIdentifier(creds: Partial<Credentials>) {
if (!creds?.username) throw new Error("User identifier must be present");
}
export function checkPermission(session: Session | null) {
if (session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") {
throw new Error("You do not have permission to do this.");
}
}
const ImpersonationProvider = CredentialsProvider({
id: "impersonation-auth",
name: "Impersonation",
@@ -56,18 +79,10 @@ const ImpersonationProvider = CredentialsProvider({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore need to figure out how to correctly type this
const session = await getSession({ req });
// If teamId is present -> parse the teamId and throw error itn ot number. If not present teamId is set to undefined
const teamId = creds?.teamId ? teamIdschema.parse({ teamId: creds.teamId }).teamId : undefined;
if (session?.user.username === creds?.username || session?.user.email === creds?.username) {
throw new Error("You cannot impersonate yourself.");
}
if (!creds?.username) throw new Error("User identifier must be present");
// If you are an ADMIN we return way before team impersonation logic is executed, so NEXT_PUBLIC_TEAM_IMPERSONATION certainly true
if (session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") {
throw new Error("You do not have permission to do this.");
}
const teamId = parseTeamId(creds);
checkSelfImpersonation(session, creds);
checkUserIdentifier(creds);
checkPermission(session);
// Get user who is being impersonated
const impersonatedUser = await prisma.user.findFirst({
@@ -1,118 +0,0 @@
import { useSession } from "next-auth/react";
import type { ReactNode, InputHTMLAttributes } from "react";
import { useState, forwardRef, Fragment } from "react";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar } from "@calcom/ui";
import { Layers, User } from "@calcom/ui/components/icon";
export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"];
export type IEventTypeFilter = IEventTypesFilters[0];
export const OrganizationEventTypeFilter = () => {
const { t } = useLocale();
const session = useSession();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
const [dropdownTitle, setDropdownTitle] = useState<string>(t("all_apps"));
const { data: teams, status } = trpc.viewer.teams.list.useQuery();
const isNotEmpty = !!teams?.length;
return status === "success" ? (
<AnimatedPopover text={dropdownTitle} popoverTriggerClassNames="!mb-0">
<CheckboxFieldContainer>
<CheckboxField
id="all-eventtypes-checkbox"
icon={<Layers className="h-4 w-4" />}
checked={dropdownTitle === t("all_apps")}
onChange={(e) => {
removeAllQueryParams();
setDropdownTitle(t("all_apps"));
// TODO: What to do when all event types is unchecked
}}
label={t("all_apps")}
/>
</CheckboxFieldContainer>
<CheckboxFieldContainer>
<CheckboxField
id="all-eventtypes-checkbox"
icon={<User className="h-4 w-4" />}
checked={query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
setDropdownTitle(t("yours"));
if (e.target.checked) {
pushItemToKey("userIds", session.data?.user.id || 0);
} else if (!e.target.checked) {
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
}
}}
label={t("yours")}
/>
</CheckboxFieldContainer>
{isNotEmpty && (
<Fragment>
<div className="text-subtle px-4 py-2.5 text-xs font-medium uppercase leading-none">TEAMS</div>
{teams?.map((team) => (
<CheckboxFieldContainer key={team.id}>
<CheckboxField
id={team.name}
label={team.name}
icon={
<Avatar
alt={team?.name}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
size="xs"
/>
}
checked={query.teamIds?.includes(team.id)}
onChange={(e) => {
setDropdownTitle(team.name);
if (e.target.checked) {
pushItemToKey("teamIds", team.id);
} else if (!e.target.checked) {
removeItemByKeyAndValue("teamIds", team.id);
}
}}
/>
</CheckboxFieldContainer>
))}
</Fragment>
)}
</AnimatedPopover>
) : null;
};
type Props = InputHTMLAttributes<HTMLInputElement> & {
label: string;
icon: ReactNode;
};
const CheckboxField = forwardRef<HTMLInputElement, Props>(({ label, icon, ...rest }, ref) => {
return (
<label className="flex w-full items-center justify-between">
<div className="flex items-center">
<div className="text-default flex h-6 w-6 items-center justify-center ltr:mr-2 rtl:ml-2">{icon}</div>
<span className="text-sm">{label}</span>
</div>
<div className="flex h-5 items-center">
<input
{...rest}
ref={ref}
type="checkbox"
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded hover:cursor-pointer"
/>
</div>
</label>
);
});
const CheckboxFieldContainer = ({ children }: { children: ReactNode }) => {
return <div className="flex items-center px-3 py-2">{children}</div>;
};
CheckboxField.displayName = "CheckboxField";
@@ -7,7 +7,7 @@ import { getAppFromSlug } from "@calcom/app-store/utils";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { isRecurringEvent, parseRecurringEvent } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { PrismaClient } from "@calcom/prisma/client";
import type { BookerLayoutSettings } from "@calcom/prisma/zod-utils";
@@ -71,13 +71,20 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
name: true,
theme: true,
metadata: true,
brandColor: true,
darkBrandColor: true,
},
},
hidden: true,
});
export const getPublicEvent = async (username: string, eventSlug: string, prisma: PrismaClient) => {
const usernameList = username.split("+");
export const getPublicEvent = async (
username: string,
eventSlug: string,
isTeamEvent: boolean | undefined,
prisma: PrismaClient
) => {
const usernameList = getUsernameList(username);
// In case of dynamic group event, we fetch user's data and use the default event.
if (usernameList.length > 1) {
@@ -141,24 +148,26 @@ export const getPublicEvent = async (username: string, eventSlug: string, prisma
};
}
const usersOrTeamQuery = isTeamEvent
? {
team: {
slug: username,
},
}
: {
users: {
some: {
username,
},
},
team: null,
};
// In case it's not a group event, it's either a single user or a team, and we query that data.
const event = await prisma.eventType.findFirst({
where: {
slug: eventSlug,
OR: [
{
users: {
some: {
username,
},
},
},
{
team: {
slug: username,
},
},
],
...usersOrTeamQuery,
},
select: publicEventSelect,
});
@@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar, Divider } from "@calcom/ui";
import { AnimatedPopover, Avatar, Divider, VerticalDivider } from "@calcom/ui";
import { Layers, User } from "@calcom/ui/components/icon";
import { filterQuerySchema } from "../lib/getTeamsFiltersFromQuery";
@@ -20,7 +20,13 @@ function useFilterQuery() {
return useTypedQuery(filterQuerySchema.passthrough());
}
export const TeamsFilter = () => {
export const TeamsFilter = ({
popoverTriggerClassNames,
showVerticalDivider = false,
}: {
popoverTriggerClassNames?: string;
showVerticalDivider?: boolean;
}) => {
const { t } = useLocale();
const session = useSession();
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
@@ -40,7 +46,7 @@ export const TeamsFilter = () => {
if (selectedTeamsNames) {
checkedOptions.push(...selectedTeamsNames);
}
return checkedOptions.join(",");
return `${t("team")}: ${checkedOptions.join(",")}`;
}
if (query.userIds) {
return t("yours");
@@ -51,56 +57,59 @@ export const TeamsFilter = () => {
if (!teams || !teams.length) return null;
return (
<AnimatedPopover text={getCheckedOptionsNames()}>
<FilterCheckboxFieldsContainer>
<FilterCheckboxField
id="all"
icon={<Layers className="h-4 w-4" />}
checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
removeAllQueryParams();
}}
label={t("all_apps")}
/>
<FilterCheckboxField
id="yours"
icon={<User className="h-4 w-4" />}
checked={!!query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("userIds", session.data?.user.id || 0);
} else if (!e.target.checked) {
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
}
}}
label={t("yours")}
/>
<Divider />
{teams?.map((team) => (
<div className="flex items-center">
<AnimatedPopover text={getCheckedOptionsNames()} popoverTriggerClassNames={popoverTriggerClassNames}>
<FilterCheckboxFieldsContainer>
<FilterCheckboxField
key={team.id}
id={team.name}
label={team.name}
checked={!!query.teamIds?.includes(team.id)}
id="all"
icon={<Layers className="h-4 w-4" />}
checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
removeAllQueryParams();
}}
label={t("all_apps")}
/>
<FilterCheckboxField
id="yours"
icon={<User className="h-4 w-4" />}
checked={!!query.userIds?.includes(session.data?.user.id || 0)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("teamIds", team.id);
pushItemToKey("userIds", session.data?.user.id || 0);
} else if (!e.target.checked) {
removeItemByKeyAndValue("teamIds", team.id);
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
}
}}
icon={
<Avatar
alt={team?.name}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
size="xs"
/>
}
label={t("yours")}
/>
))}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
<Divider />
{teams?.map((team) => (
<FilterCheckboxField
key={team.id}
id={team.name}
label={team.name}
checked={!!query.teamIds?.includes(team.id)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("teamIds", team.id);
} else if (!e.target.checked) {
removeItemByKeyAndValue("teamIds", team.id);
}
}}
icon={
<Avatar
alt={team?.name}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
size="xs"
/>
}
/>
))}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
{showVerticalDivider && <VerticalDivider />}
</div>
);
};
@@ -1,4 +1,5 @@
import dayjs from "@calcom/dayjs";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { trpc } from "@calcom/trpc/react";
type UseScheduleWithCacheArgs = {
@@ -22,13 +23,12 @@ export const useSchedule = ({
}: UseScheduleWithCacheArgs) => {
const monthDayjs = month ? dayjs(month) : dayjs();
const nextMonthDayjs = monthDayjs.add(1, "month");
// Why the non-null assertions? All of these arguments are checked in the enabled condition,
// and the query will not run if they are null. However, the check in `enabled` does
// no satisfy typscript.
return trpc.viewer.public.slots.getSchedule.useQuery(
{
usernameList: username && username.indexOf("+") > -1 ? username.split("+") : [username!],
usernameList: getUsernameList(username ?? ""),
eventTypeSlug: eventSlug!,
// @TODO: Old code fetched 2 days ago if we were fetching the current month.
// Do we want / need to keep that behavior?
@@ -23,6 +23,13 @@ type BookerLayoutSelectorProps = {
* settings.
*/
fallbackToUserSettings?: boolean;
/**
* isDark boolean should be passed in when the user selected 'dark mode' in the theme settings in profile/appearance.
* So it's not based on the user's system settings, but on the user's preference for the booker.
* This boolean is then used to show a dark version of the layout image. It's only easthetic, no functionality is attached
* to this boolean.
*/
isDark?: boolean;
};
const defaultFieldName = "metadata.bookerLayouts";
@@ -32,6 +39,7 @@ export const BookerLayoutSelector = ({
description,
name,
fallbackToUserSettings,
isDark,
}: BookerLayoutSelectorProps) => {
const { control, getValues } = useFormContext();
const { t } = useLocale();
@@ -58,6 +66,7 @@ export const BookerLayoutSelector = ({
showUserSettings={shouldShowUserSettings}
settings={value}
onChange={onChange}
isDark={isDark}
/>
)}
/>
@@ -69,11 +78,12 @@ type BookerLayoutFieldsProps = {
settings: BookerLayoutSettings;
onChange: (settings: BookerLayoutSettings) => void;
showUserSettings: boolean;
isDark?: boolean;
};
type BookerLayoutState = { [key in BookerLayouts]: boolean };
const BookerLayoutFields = ({ settings, onChange, showUserSettings }: BookerLayoutFieldsProps) => {
const BookerLayoutFields = ({ settings, onChange, showUserSettings, isDark }: BookerLayoutFieldsProps) => {
const { t } = useLocale();
const { isLoading: isUserLoading, data: user } = useMeQuery();
const [isOverridingSettings, setIsOverridingSettings] = useState(false);
@@ -143,7 +153,7 @@ const BookerLayoutFields = ({ settings, onChange, showUserSettings }: BookerLayo
<label>
<img
className="mb-3 w-full max-w-none cursor-pointer"
src={`/bookerlayout_${layout}.svg`}
src={`/bookerlayout_${layout}${isDark ? "_dark" : ""}.svg`}
alt="Layout preview"
/>
<Checkbox
+2 -2
View File
@@ -24,9 +24,9 @@
},
"dependencies": {
"@calcom/lib": "*",
"@prisma/client": "^4.13.0",
"@prisma/client": "^4.16.0",
"@prisma/generator-helper": "^4.13.0",
"prisma": "^4.13.0",
"prisma": "^4.16.0",
"ts-node": "^10.9.1",
"zod": "^3.20.2",
"zod-prisma": "^0.5.4"
@@ -10,6 +10,6 @@ interface EventHandlerOptions {
}
export const eventHandler = async ({ ctx, input }: EventHandlerOptions) => {
const event = await getPublicEvent(input.username, input.eventSlug, ctx.prisma);
const event = await getPublicEvent(input.username, input.eventSlug, input.isTeamEvent, ctx.prisma);
return event;
};
@@ -3,6 +3,7 @@ import z from "zod";
export const ZEventInputSchema = z.object({
username: z.string(),
eventSlug: z.string(),
isTeamEvent: z.boolean().optional(),
});
export type TEventInputSchema = z.infer<typeof ZEventInputSchema>;
@@ -8,6 +8,7 @@ import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZDuplicateInputSchema } from "./duplicate.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZEventTypeInputSchema } from "./getByViewer.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { eventOwnerProcedure } from "./util";
@@ -28,7 +29,7 @@ const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {};
export const eventTypesRouter = router({
// REVIEW: What should we name this procedure?
getByViewer: authedProcedure.query(async ({ ctx }) => {
getByViewer: authedProcedure.input(ZEventTypeInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
(mod) => mod.getByViewerHandler
@@ -44,6 +45,7 @@ export const eventTypesRouter = router({
const result = await UNSTABLE_HANDLER_CACHE.getByViewer({
ctx,
input,
});
timer();
@@ -1,6 +1,7 @@
import { type PrismaClient, Prisma } from "@prisma/client";
import { orderBy } from "lodash";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { CAL_URL } from "@calcom/lib/constants";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma";
@@ -11,53 +12,58 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TEventTypeInputSchema } from "./getByViewer.schema";
type GetByViewerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TEventTypeInputSchema;
};
export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
// Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering
position: true,
hashedLink: true,
locations: true,
destinationCalendar: true,
userId: true,
team: {
select: {
id: true,
name: true,
slug: true,
// logo: true, // Skipping to avoid 4mb limit
bio: true,
hideBranding: true,
},
},
metadata: true,
users: {
select: baseUserSelect,
},
children: {
include: {
users: {
select: baseUserSelect,
},
},
},
parentId: true,
hosts: {
select: {
user: {
select: baseUserSelect,
},
},
},
seatsPerTimeSlot: true,
...baseEventTypeSelect,
});
export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => {
const { prisma } = ctx;
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
// Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering
position: true,
hashedLink: true,
locations: true,
destinationCalendar: true,
userId: true,
team: {
select: {
id: true,
name: true,
slug: true,
// logo: true, // Skipping to avoid 4mb limit
bio: true,
hideBranding: true,
},
},
metadata: true,
users: {
select: baseUserSelect,
},
children: {
include: {
users: true,
},
},
parentId: true,
hosts: {
select: {
user: {
select: baseUserSelect,
},
},
},
seatsPerTimeSlot: true,
...baseEventTypeSelect,
});
const user = await prisma.user.findUnique({
where: {
@@ -107,6 +113,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
eventTypes: {
where: {
team: null,
userId: getPrismaWhereUserIdFromFilter(ctx.user.id, input?.filters),
},
select: eventTypeSelect,
orderBy: [
@@ -137,7 +144,8 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
const typesRaw = (
await prisma.eventType.findMany({
where: {
userId: ctx.user.id,
userId: getPrismaWhereUserIdFromFilter(ctx.user.id, input?.filters),
team: null,
},
select: eventTypeSelect,
orderBy: [
@@ -167,11 +175,13 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
};
let eventTypeGroups: EventTypeGroup[] = [];
const eventTypesHashMap = userEventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
const oldItem = hashMap[newItem.id];
hashMap[newItem.id] = { ...oldItem, ...newItem };
return hashMap;
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
const mergedEventTypes = Object.values(eventTypesHashMap)
.map((eventType) => eventType)
.filter((evType) => evType.schedulingType !== SchedulingType.MANAGED);
@@ -204,6 +214,13 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
return mshipToNumber(mship1) > mshipToNumber(mship2);
};
const filterTeamsEventTypesBasedOnInput = (eventType: ReturnType<typeof mapEventType>) => {
if (!input?.filters || !hasFilter(input?.filters)) {
return true;
}
return input?.filters?.teamIds?.includes(eventType?.team?.id || 0) ?? false;
};
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
user.teams
@@ -242,6 +259,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
},
eventTypes: membership.team.eventTypes
.map(mapEventType)
.filter(filterTeamsEventTypesBasedOnInput)
.filter((evType) => evType.userId === null || evType.userId === ctx.user.id)
.filter((evType) =>
membership.role === MembershipRole.MEMBER
@@ -251,6 +269,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
};
})
);
return {
// don't display event teams without event types,
eventTypeGroups: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length),
@@ -264,3 +283,15 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
})),
};
};
export function getPrismaWhereUserIdFromFilter(
userId: number,
filters: NonNullable<TEventTypeInputSchema>["filters"]
) {
if (!filters || !hasFilter(filters)) {
return userId;
} else if (filters.userIds?.[0] === userId) {
return userId;
}
return null;
}
@@ -1 +1,15 @@
export {};
import { z } from "zod";
export const filterQuerySchemaStrict = z.object({
teamIds: z.number().array().optional(),
// A user can only filter by only his userId
userIds: z.number().array().max(1).optional(),
});
export const ZEventTypeInputSchema = z
.object({
filters: filterQuerySchemaStrict.optional(),
})
.nullish();
export type TEventTypeInputSchema = z.infer<typeof ZEventTypeInputSchema>;
@@ -1,8 +1,10 @@
import { subdomainSuffix } from "@calcom/ee/organizations/lib/orgDomains";
import { cancelTeamSubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { IS_PRODUCTION, IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
import { closeComDeleteTeam } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@@ -16,6 +18,41 @@ type DeleteOptions = {
input: TDeleteInputSchema;
};
const deleteVercelDomain = async ({
slug,
isOrganization,
}: {
slug?: string | null;
isOrganization?: boolean | null;
}) => {
if (!isOrganization || !slug) {
return false;
}
const fullDomain = `${slug}.${subdomainSuffix()}`;
const response = await fetch(
`https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${fullDomain}?teamId=${process.env.TEAM_ID_VERCEL}`,
{
headers: {
Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN_VERCEL}`,
},
method: "DELETE",
}
);
const data = await response.json();
// Domain is already owned by another team but you can request delegation to access it
if (data.error?.code === "forbidden")
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_team" });
// Domain is already being used by a different project
if (data.error?.code === "domain_taken")
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_project" });
return true;
};
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -34,6 +71,14 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
},
});
const deletedTeamMetadata = teamMetadataSchema.parse(deletedTeam.metadata);
if (IS_PRODUCTION)
deleteVercelDomain({
slug: deletedTeam.slug,
isOrganization: deletedTeamMetadata?.isOrganization,
});
// Sync Services: Close.cm
closeComDeleteTeam(deletedTeam);
};
+98 -30
View File
@@ -1,4 +1,6 @@
// @TODO: turn this into a more generic component that has the same Props API as MUI https://mui.com/material-ui/react-card/
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import Link from "next/link";
import type { ReactNode } from "react";
import React from "react";
@@ -8,10 +10,96 @@ import { ArrowRight } from "@calcom/ui/components/icon";
import { Button } from "../button";
export type BaseCardProps = {
const cvaCardTypeByVariant = cva("", {
// Variants won't have any style by default. Style will only be applied if the variants are combined.
// So, style is defined in compoundVariants.
variants: {
variant: {
basic: "",
ProfileCard: "",
SidebarCard: "",
},
structure: {
image: "",
card: "",
title: "",
description: "",
},
},
compoundVariants: [
// Style for Basic Variants types
{
variant: "basic",
structure: "image",
className: "w-10 h-auto",
},
{
variant: "basic",
structure: "card",
className: "p-5",
},
{
variant: "basic",
structure: "title",
className: "text-base mt-4",
},
{
variant: "basic",
structure: "description",
className: "text-sm leading-[18px] text-subtle font-normal",
},
// Style for ProfileCard Variant Types
{
variant: "ProfileCard",
structure: "image",
className: "w-9 h-auto rounded-full mb-4s",
},
{
variant: "ProfileCard",
structure: "card",
className: "w-80 p-4 hover:bg-subtle",
},
{
variant: "ProfileCard",
structure: "title",
className: "text-base",
},
{
variant: "ProfileCard",
structure: "description",
className: "text-sm leading-[18px] text-subtle font-normal",
},
// Style for SidebarCard Variant Types
{
variant: "SidebarCard",
structure: "image",
className: "w-9 h-auto rounded-full mb-4s",
},
{
variant: "SidebarCard",
structure: "card",
className: "w-full p-3 border border-subtle",
},
{
variant: "SidebarCard",
structure: "title",
className: "text-sm font-cal",
},
{
variant: "SidebarCard",
structure: "description",
className: "text-xs text-default line-clamp-2",
},
],
});
type CVACardType = Required<Pick<VariantProps<typeof cvaCardTypeByVariant>, "variant">>;
export interface BaseCardProps extends CVACardType {
image?: string;
icon?: ReactNode;
variant: keyof typeof cardTypeByVariant;
imageProps?: JSX.IntrinsicElements["img"];
title: string;
description: ReactNode;
@@ -27,30 +115,7 @@ export type BaseCardProps = {
};
mediaLink?: string;
thumbnailUrl?: string;
};
// @TODO: use CVA
const cardTypeByVariant = {
basic: {
image: "w-10 h-auto",
card: "p-5",
title: "text-base mt-4",
description: "text-sm leading-[18px] text-subtle font-normal",
},
ProfileCard: {
image: "w-9 h-auto rounded-full mb-4s",
card: "w-80 p-4 hover:bg-subtle",
title: "text-base",
description: "text-sm leading-[18px] text-subtle font-normal",
},
SidebarCard: {
image: "w-9 h-auto rounded-full mb-4s",
card: "w-full p-3 border border-subtle",
title: "text-sm font-cal",
description: "text-xs text-default line-clamp-2",
},
};
}
export function Card({
image,
@@ -70,7 +135,7 @@ export function Card({
<div
className={classNames(
containerProps?.className,
cardTypeByVariant[variant].card,
cvaCardTypeByVariant({ variant, structure: "card" }),
"bg-default border-subtle text-default flex flex-col justify-between rounded-md border"
)}
{...containerProps}>
@@ -81,14 +146,17 @@ export function Card({
src={image}
// Stops eslint complaining - not smart enough to realise it comes from ...imageProps
alt={imageProps?.alt}
className={classNames(imageProps?.className, cardTypeByVariant[variant].image)}
className={classNames(
imageProps?.className,
cvaCardTypeByVariant({ variant, structure: "image" })
)}
{...imageProps}
/>
)}
<h5
title={title}
className={classNames(
cardTypeByVariant[variant].title,
cvaCardTypeByVariant({ variant, structure: "title" }),
"line-clamp-1 text-emphasis font-bold leading-5"
)}>
{title}
@@ -96,7 +164,7 @@ export function Card({
{description && (
<p
title={description.toString()}
className={classNames(cardTypeByVariant[variant].description, "pt-1")}>
className={classNames(cvaCardTypeByVariant({ variant, structure: "description" }), "pt-1")}>
{description}
</p>
)}
@@ -0,0 +1,57 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
VariantsTable,
VariantColumn,
RowTitles,
CustomArgsTable,
} from "@calcom/storybook/components";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "./sheet";
import {Button} from "../button";
<Meta title="UI/Sheet" />
<Title title="Sheet" suffix="Brief" subtitle="Version 2.0 — Last Update: 21 June 2023" />
## Definition
A sheet is a container that can be used to display content on differnt sides of the current page.
## Structure
<Examples title="Spacing">
<Sheet>
<SheetTrigger asChild>
<Button>Open default sheet</Button>
</SheetTrigger>
<SheetContent position="right" size={"default"}>
<SheetHeader>
<SheetTitle>Edit profile</SheetTitle>
<SheetDescription>
Make changes to your profile here. Click save when you're done.
</SheetDescription>
</SheetHeader>
<SheetFooter>
<SheetClose asChild>
<Button type="submit">Save changes</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
</Examples>
+207
View File
@@ -0,0 +1,207 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import classNames from "@calcom/lib/classNames";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const portalVariants = cva("fixed inset-0 z-50 flex", {
variants: {
position: {
top: "items-start",
bottom: "items-end",
left: "justify-start",
right: "justify-end",
},
},
defaultVariants: { position: "right" },
});
interface SheetPortalProps extends SheetPrimitive.DialogPortalProps, VariantProps<typeof portalVariants> {}
const SheetPortal = ({ position, className, children, ...props }: SheetPortalProps) => (
<SheetPrimitive.Portal className={classNames(className)} {...props}>
<div className={portalVariants({ position })}>{children}</div>
</SheetPrimitive.Portal>
);
SheetPortal.displayName = SheetPrimitive.Portal.displayName;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, children, ...props }, ref) => (
<SheetPrimitive.Overlay
className={classNames(
"bg-inverted data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 opacity-60 backdrop-blur-sm transition-all duration-100",
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 scale-100 gap-4 bg-default m-4 rounded-xl p-6 opacity-100 shadow-lg border",
{
variants: {
position: {
top: "animate-in slide-in-from-top w-full duration-300",
bottom: "animate-in slide-in-from-bottom w-full duration-300",
left: "animate-in slide-in-from-left h-full duration-300",
right: "animate-in slide-in-from-right h-full duration-300",
},
size: {
content: "",
default: "",
sm: "",
lg: "",
xl: "",
full: "",
},
},
compoundVariants: [
{
position: ["top", "bottom"],
size: "content",
class: "max-h-screen",
},
{
position: ["top", "bottom"],
size: "default",
class: "h-1/3",
},
{
position: ["top", "bottom"],
size: "sm",
class: "h-1/4",
},
{
position: ["top", "bottom"],
size: "lg",
class: "h-1/2",
},
{
position: ["top", "bottom"],
size: "xl",
class: "h-5/6",
},
{
position: ["top", "bottom"],
size: "full",
class: "h-screen",
},
{
position: ["right", "left"],
size: "content",
class: "max-w-screen",
},
{
position: ["right", "left"],
size: "default",
class: "w-1/3 h-[calc(100vh-2rem)]",
},
{
position: ["right", "left"],
size: "sm",
class: "w-1/4 h-[calc(100vh-2rem)]",
},
{
position: ["right", "left"],
size: "lg",
class: "w-1/2 h-[calc(100vh-2rem)]",
},
{
position: ["right", "left"],
size: "xl",
class: "w-5/6 h-[calc(100vh-2rem)]",
},
{
position: ["right", "left"],
size: "full",
class: "w-screen h-[calc(100vh-2rem)]",
},
],
defaultVariants: {
position: "right",
size: "default",
},
}
);
export interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, DialogContentProps>(
({ position, size, className, children, ...props }, ref) => (
<SheetPortal position={position}>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={classNames(sheetVariants({ position, size }), className)}
{...props}>
{children}
<SheetPrimitive.Close className="focus:ring-emphasis data-[state=open]:bg-deafult absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={classNames("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={classNames("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={classNames("text-foreground text-lg font-semibold", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={classNames("text-muted-foreground text-sm", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+22 -22
View File
@@ -4062,7 +4062,7 @@ __metadata:
"@calcom/ui": "*"
"@headlessui/react": ^1.5.0
"@heroicons/react": ^1.0.6
"@prisma/client": ^4.13.0
"@prisma/client": ^4.16.0
"@tailwindcss/forms": ^0.5.2
"@types/node": 16.9.1
"@types/react": 18.0.26
@@ -4074,7 +4074,7 @@ __metadata:
next-auth: ^4.20.1
next-i18next: ^11.3.0
postcss: ^8.4.18
prisma: ^4.13.0
prisma: ^4.16.0
prisma-field-encryption: ^1.4.0
react: ^18.2.0
react-chartjs-2: ^4.0.1
@@ -4525,10 +4525,10 @@ __metadata:
resolution: "@calcom/prisma@workspace:packages/prisma"
dependencies:
"@calcom/lib": "*"
"@prisma/client": ^4.13.0
"@prisma/client": ^4.16.0
"@prisma/generator-helper": ^4.13.0
npm-run-all: ^4.1.5
prisma: ^4.13.0
prisma: ^4.16.0
ts-node: ^10.9.1
zod: ^3.20.2
zod-prisma: ^0.5.4
@@ -7835,17 +7835,17 @@ __metadata:
languageName: node
linkType: hard
"@prisma/client@npm:^4.13.0":
version: 4.13.0
resolution: "@prisma/client@npm:4.13.0"
"@prisma/client@npm:^4.16.0":
version: 4.16.0
resolution: "@prisma/client@npm:4.16.0"
dependencies:
"@prisma/engines-version": 4.13.0-50.1e7af066ee9cb95cf3a403c78d9aab3e6b04f37a
"@prisma/engines-version": 4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c
peerDependencies:
prisma: "*"
peerDependenciesMeta:
prisma:
optional: true
checksum: 74e2ede6308d6f70dc6244c34d13cf5f6c8da4f1a1ffe42a26192675ecac02733cb5482518d2a37fa057f160873fe8d42f5e3a08fc45d52b1eb386522bdbec0b
checksum: 67fbf8e0bd672d597433b61b65a39c58cbb2eb82e7bcac33f31215d8ca5ba48df790a20aa990c47d6dde53e0d84d1a45d07d872f772d3dd8271613abe879b5d0
languageName: node
linkType: hard
@@ -7882,17 +7882,17 @@ __metadata:
languageName: node
linkType: hard
"@prisma/engines-version@npm:4.13.0-50.1e7af066ee9cb95cf3a403c78d9aab3e6b04f37a":
version: 4.13.0-50.1e7af066ee9cb95cf3a403c78d9aab3e6b04f37a
resolution: "@prisma/engines-version@npm:4.13.0-50.1e7af066ee9cb95cf3a403c78d9aab3e6b04f37a"
checksum: cc583fe956b65fd4548082917f14ccf1c3c54d6b26623583f5e63298a8b4b5a492ba90597123af1fc272115621cb7340e2d708d5b5e252f59139b35f0df00abe
"@prisma/engines-version@npm:4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c":
version: 4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c
resolution: "@prisma/engines-version@npm:4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c"
checksum: 23183cab33f95e0711cd6cee7642f4beeeb77b8b0aa54b419f55097bfb96863a11a28f6509ab27fef0ea794522e188044ce850993a25c341333f8305711a255f
languageName: node
linkType: hard
"@prisma/engines@npm:4.13.0":
version: 4.13.0
resolution: "@prisma/engines@npm:4.13.0"
checksum: 6c2315dc1e1a8a07f02a074ee6898a66a96a3bbbb4341224cb1dc966da3959c2ee7557131a2ce37fd559c53d210b904b72c4559474e017438e6753d480c3cfe1
"@prisma/engines@npm:4.16.0":
version: 4.16.0
resolution: "@prisma/engines@npm:4.16.0"
checksum: 60ef1c3720720cf5a9d23f52c668669736716afd1862c4ac61e8ebcd3ca896e3453f80dfab77f3b68b001c5d499a895f28e54cc5025eae61878b40c1ec5d99c3
languageName: node
linkType: hard
@@ -29665,15 +29665,15 @@ __metadata:
languageName: node
linkType: hard
"prisma@npm:^4.13.0":
version: 4.13.0
resolution: "prisma@npm:4.13.0"
"prisma@npm:^4.16.0":
version: 4.16.0
resolution: "prisma@npm:4.16.0"
dependencies:
"@prisma/engines": 4.13.0
"@prisma/engines": 4.16.0
bin:
prisma: build/index.js
prisma2: build/index.js
checksum: b735dd19842da1dfe5b3411551665db5f2a419ee5e67136dfc2c8cae3c7fe0f885b472b0f9decb3adb4af83260c51a605ecca8f2194805804e020c382096a75a
checksum: 831a4d14a48474fa144ae0648ed5e3766756b991f1997ecca813a044ef9bd7f2a5794c603adc9d23d867d8f4a0f51cedadaebfd8a841e6923ef9b804ed8bcbbb
languageName: node
linkType: hard