diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9a2710e6ac..9e58c5da24 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,9 @@ Fixes # (issue) + +Loom Video: https://www.loom.com/ + ## Type of change diff --git a/apps/web/components/AppsShell.tsx b/apps/web/components/AppsShell.tsx index 810bc843c1..51f55c7448 100644 --- a/apps/web/components/AppsShell.tsx +++ b/apps/web/components/AppsShell.tsx @@ -24,7 +24,7 @@ export default function AppsShell({ children }: { children: React.ReactNode }) {
{status === "authenticated" && }
-
{children}
+
{children}
); } diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index d3edca60ad..296eb0da17 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -172,7 +172,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage )}> {/* mobile: details */}
-
+
-
+

{profile.name}

-
+

{eventType.title}

@@ -203,7 +203,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage

)}

- + {eventType.length} {t("minutes")}

{eventType.price > 0 && ( diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index 78805642de..27882cc8e6 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -5,6 +5,7 @@ import { ExclamationIcon, InformationCircleIcon, } from "@heroicons/react/solid"; +import { zodResolver } from "@hookform/resolvers/zod"; import { EventTypeCustomInputType } from "@prisma/client"; import { useContracts } from "contexts/contractsContext"; import dayjs from "dayjs"; @@ -17,6 +18,7 @@ import { Controller, useForm, useWatch } from "react-hook-form"; import { FormattedNumber, IntlProvider } from "react-intl"; import { ReactMultiEmail } from "react-multi-email"; import { useMutation } from "react-query"; +import { z } from "zod"; import { useIsEmbed, @@ -47,6 +49,7 @@ import AvatarGroup from "@components/ui/AvatarGroup"; import type PhoneInputType from "@components/ui/form/PhoneInput"; import { BookPageProps } from "../../../pages/[user]/book"; +import { HashLinkPageProps } from "../../../pages/d/[link]/book"; import { TeamBookingPageProps } from "../../../pages/team/[slug]/book"; /** These are like 40kb that not every user needs */ @@ -54,7 +57,7 @@ const PhoneInput = dynamic( () => import("@components/ui/form/PhoneInput") ) as unknown as typeof PhoneInputType; -type BookingPageProps = BookPageProps | TeamBookingPageProps; +type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps; type BookingFormValues = { name: string; @@ -74,6 +77,8 @@ const BookingPage = ({ profile, isDynamicGroupBooking, locationLabels, + hasHashedBookingLink, + hashedLink, }: BookingPageProps) => { const { t, i18n } = useLocale(); const isEmbed = useIsEmbed(); @@ -195,8 +200,16 @@ const BookingPage = ({ }; }; + const bookingFormSchema = z + .object({ + name: z.string().min(1), + email: z.string().email(), + }) + .passthrough(); + const bookingForm = useForm({ defaultValues: defaultValues(), + resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema }); const selectedLocation = useWatch({ @@ -280,6 +293,8 @@ const BookingPage = ({ label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label, value: booking.customInputs![inputId], })), + hasHashedBookingLink, + hashedLink, }); }; @@ -389,7 +404,7 @@ const BookingPage = ({
{locationLabels[location.type]} diff --git a/apps/web/components/integrations/CalendarListContainer.tsx b/apps/web/components/integrations/CalendarListContainer.tsx index dbb1f0366b..9204762023 100644 --- a/apps/web/components/integrations/CalendarListContainer.tsx +++ b/apps/web/components/integrations/CalendarListContainer.tsx @@ -139,7 +139,7 @@ function ConnectedCalendarsList(props: Props) { ) : ( - +
diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 51ee2e606b..94f25ea0fd 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -7,6 +7,7 @@ import { useRouter } from "next/router"; import { useState } from "react"; import { useForm } from "react-hook-form"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { Alert } from "@calcom/ui/Alert"; import Button from "@calcom/ui/Button"; import { EmailField, Form, PasswordField } from "@calcom/ui/form/fields"; @@ -61,12 +62,15 @@ export default function Login({ let callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : ""; - // If not absolute URL, make it absolute if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1); + + // If not absolute URL, make it absolute if (!/^https?:\/\//.test(callbackUrl)) { callbackUrl = `${WEBAPP_URL}/${callbackUrl}`; } + callbackUrl = getSafeRedirectUrl(callbackUrl); + const LoginFooter = ( {t("dont_have_an_account")}{" "} diff --git a/apps/web/pages/cancel/[uid].tsx b/apps/web/pages/cancel/[uid].tsx index 538d81f69c..3f64966e18 100644 --- a/apps/web/pages/cancel/[uid].tsx +++ b/apps/web/pages/cancel/[uid].tsx @@ -101,8 +101,8 @@ export default function Type(props: inferSSRProps) { className="mb-5 sm:mb-6" />
- + +
+ + The URL will regenerate after each use + +
+
+ )} + + )} + /> +
) => { -
@@ -1941,6 +2011,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => beforeEventBuffer: true, afterEventBuffer: true, slotInterval: true, + hashedLink: true, successRedirectUrl: true, team: { select: { diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 75c366f570..ba010aee22 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -18,6 +18,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import React, { Fragment, useEffect, useState } from "react"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; import { Button } from "@calcom/ui"; @@ -452,45 +453,48 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL ); }; -const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => ( -
- - - - - -
+const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => { + console.log(profile.slug); + return ( + -
-); + ); +}; const CreateFirstEventTypeView = ({ canAddEvents, profiles }: CreateEventTypeProps) => { const { t } = useLocale(); diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index faa0831132..24c46d5464 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -98,6 +98,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { eventType: eventTypeObject, booking, isDynamicGroupBooking: false, + hasHashedBookingLink: false, + hashedLink: null, }, }; } diff --git a/apps/web/playwright/event-types.test.ts b/apps/web/playwright/event-types.test.ts index 06a4ab2a82..f277a3cab1 100644 --- a/apps/web/playwright/event-types.test.ts +++ b/apps/web/playwright/event-types.test.ts @@ -69,6 +69,25 @@ test.describe("Event Types tests", () => { await expect(formTitle).toBe(firstTitle); await expect(formSlug).toBe(firstSlug); }); + test("edit first event", async ({ page }) => { + const $eventTypes = await page.locator("[data-testid=event-types] > *"); + const firstEventTypeElement = await $eventTypes.first(); + await firstEventTypeElement.click(); + await page.waitForNavigation({ + url: (url) => { + return !!url.pathname.match(/\/event-types\/.+/); + }, + }); + await expect(page.locator("[data-testid=advanced-settings-content]")).not.toBeVisible(); + await page.locator("[data-testid=show-advanced-settings]").click(); + await expect(page.locator("[data-testid=advanced-settings-content]")).toBeVisible(); + await page.locator("[data-testid=update-eventtype]").click(); + await page.waitForNavigation({ + url: (url) => { + return url.pathname.endsWith("/event-types"); + }, + }); + }); }); test.describe("free user", () => { @@ -88,5 +107,25 @@ test.describe("Event Types tests", () => { test("can not add new event type", async ({ page }) => { await expect(page.locator("[data-testid=new-event-type]")).toBeDisabled(); }); + + test("edit first event", async ({ page }) => { + const $eventTypes = await page.locator("[data-testid=event-types] > *"); + const firstEventTypeElement = await $eventTypes.first(); + await firstEventTypeElement.click(); + await page.waitForNavigation({ + url: (url) => { + return !!url.pathname.match(/\/event-types\/.+/); + }, + }); + await expect(page.locator("[data-testid=advanced-settings-content]")).not.toBeVisible(); + await page.locator("[data-testid=show-advanced-settings]").click(); + await expect(page.locator("[data-testid=advanced-settings-content]")).toBeVisible(); + await page.locator("[data-testid=update-eventtype]").click(); + await page.waitForNavigation({ + url: (url) => { + return url.pathname.endsWith("/event-types"); + }, + }); + }); }); }); diff --git a/apps/web/playwright/hash-my-url.test.ts b/apps/web/playwright/hash-my-url.test.ts new file mode 100644 index 0000000000..74bb136d88 --- /dev/null +++ b/apps/web/playwright/hash-my-url.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@playwright/test"; + +import { deleteAllBookingsByEmail } from "./lib/teardown"; +import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; + +test.describe("hash my url", () => { + test.use({ storageState: "playwright/artifacts/proStorageState.json" }); + let $url = ""; + test.beforeEach(async ({ page }) => { + await deleteAllBookingsByEmail("pro@example.com"); + await page.goto("/event-types"); + // We wait until loading is finished + await page.waitForSelector('[data-testid="event-types"]'); + }); + + test.afterAll(async () => { + // delete test bookings + await deleteAllBookingsByEmail("pro@example.com"); + }); + + test("generate url hash", async ({ page }) => { + // await page.pause(); + await page.goto("/event-types"); + // We wait until loading is finished + await page.waitForSelector('[data-testid="event-types"]'); + await page.click('//ul[@data-testid="event-types"]/li[1]'); + // We wait for the page to load + await page.waitForSelector('//*[@data-testid="show-advanced-settings"]'); + await page.click('//*[@data-testid="show-advanced-settings"]'); + // we wait for the hashedLink setting to load + await page.waitForSelector('//*[@id="hashedLink"]'); + await page.click('//*[@id="hashedLink"]'); + // click update + await page.focus('//button[@type="submit"]'); + await page.keyboard.press("Enter"); + }); + + test("book using generated url hash", async ({ page }) => { + // await page.pause(); + await page.goto("/event-types"); + // We wait until loading is finished + await page.waitForSelector('[data-testid="event-types"]'); + await page.click('//ul[@data-testid="event-types"]/li[1]'); + // We wait for the page to load + await page.waitForSelector('//*[@data-testid="show-advanced-settings"]'); + await page.click('//*[@data-testid="show-advanced-settings"]'); + // we wait for the hashedLink setting to load + await page.waitForSelector('//*[@data-testid="generated-hash-url"]'); + $url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue(); + await page.goto($url); + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + + // Make sure we're navigated to the success page + await page.waitForNavigation({ + url(url) { + return url.pathname.endsWith("/success"); + }, + }); + }); + + test("hash regenerates after successful booking", async ({ page }) => { + await page.goto("/event-types"); + // We wait until loading is finished + await page.waitForSelector('[data-testid="event-types"]'); + await page.click('//ul[@data-testid="event-types"]/li[1]'); + // We wait for the page to load + await page.waitForSelector('//*[@data-testid="show-advanced-settings"]'); + await page.click('//*[@data-testid="show-advanced-settings"]'); + // we wait for the hashedLink setting to load + await page.waitForSelector('//*[@data-testid="generated-hash-url"]'); + const $newUrl = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue(); + expect($url !== $newUrl).toBeTruthy(); + }); +}); diff --git a/apps/web/playwright/integrations.test.ts-snapshots/webhookResponse-chromium.txt b/apps/web/playwright/integrations.test.ts-snapshots/webhookResponse-chromium.txt index 2d8e6d9203..07fe93f9cc 100644 --- a/apps/web/playwright/integrations.test.ts-snapshots/webhookResponse-chromium.txt +++ b/apps/web/playwright/integrations.test.ts-snapshots/webhookResponse-chromium.txt @@ -1 +1 @@ -{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30min","title":"30min between Pro Example and Test Testson","description":null,"additionalNotes":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Pro Example","email":"pro@example.com","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}} \ No newline at end of file +{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30min","title":"30min between Pro Example and Test Testson","description":"","additionalNotes":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Pro Example","email":"pro@example.com","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}} \ No newline at end of file diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 68bf5b56a6..9f0ba50c35 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -500,6 +500,7 @@ "url": "URL", "hidden": "Hidden", "readonly": "Readonly", + "one_time_link": "One-time link", "plan_description": "You're currently on the {{plan}} plan.", "plan_upgrade_invitation": "Upgrade your account to the pro plan to unlock all of the features we have to offer.", "plan_upgrade": "You need to upgrade your plan to have more than one active event type.", @@ -583,6 +584,8 @@ "opt_in_booking_description": "The booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.", "disable_guests": "Disable Guests", "disable_guests_description": "Disable adding additional guests while booking.", + "hashed_link": "Generate hashed URL", + "hashed_link_description": "Generate a hashed URL to share without exposing your Cal username", "invitees_can_schedule": "Invitees can schedule", "date_range": "Date Range", "calendar_days": "calendar days", @@ -745,6 +748,7 @@ "success_api_key_created_bold_tagline": "Save this API key somewhere safe.", "you_will_only_view_it_once": "You will not be able to view it again once you close this modal.", "copy_to_clipboard": "Copy to clipboard", + "enabled_after_update": "Enabled after update", "confirm_delete_api_key": "Revoke this API key", "revoke_api_key": "Revoke API key", "api_key_copied": "API key copied!", @@ -778,5 +782,6 @@ "test_your_trigger": "4. Test your Trigger.", "you_are_set": "5. You're set!", "install_zapier_app": "Please first install the Zapier App in the app store.", - "go_to_app_store": "Go to App Store" + "go_to_app_store": "Go to App Store", + "calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions" } diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index 7719da5d90..2894d3ce27 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -133,11 +133,11 @@ const loggedInViewerRouter = createProtectedRouter() currency: true, position: true, successRedirectUrl: true, + hashedLink: true, users: { select: { id: true, username: true, - avatar: true, name: true, }, }, @@ -154,7 +154,6 @@ const loggedInViewerRouter = createProtectedRouter() startTime: true, endTime: true, bufferTime: true, - avatar: true, plan: true, teams: { where: { @@ -230,7 +229,6 @@ const loggedInViewerRouter = createProtectedRouter() profile: { slug: typeof user["username"]; name: typeof user["name"]; - image: typeof user["avatar"]; }; metadata: { membershipCount: number; @@ -255,7 +253,6 @@ const loggedInViewerRouter = createProtectedRouter() profile: { slug: user.username, name: user.name, - image: user.avatar, }, eventTypes: _.orderBy(mergedEventTypes, ["position", "id"], ["desc", "asc"]), metadata: { diff --git a/apps/web/server/routers/viewer/eventTypes.tsx b/apps/web/server/routers/viewer/eventTypes.tsx index 48c3d04de4..dd165ced6a 100644 --- a/apps/web/server/routers/viewer/eventTypes.tsx +++ b/apps/web/server/routers/viewer/eventTypes.tsx @@ -1,4 +1,6 @@ import { EventTypeCustomInput, MembershipRole, PeriodType, Prisma } from "@prisma/client"; +import short from "short-uuid"; +import { v5 as uuidv5 } from "uuid"; import { z } from "zod"; import { @@ -89,6 +91,7 @@ const EventTypeUpdateInput = _EventTypeModel }), users: z.array(stringOrNumber).optional(), schedule: z.number().optional(), + hashedLink: z.boolean(), }) .partial() .merge( @@ -117,6 +120,7 @@ export const eventTypesRouter = createProtectedRouter() const data: Prisma.EventTypeCreateInput = { ...rest, + userId: teamId ? undefined : userId, users: { connect: { id: userId, @@ -213,8 +217,17 @@ export const eventTypesRouter = createProtectedRouter() .mutation("update", { input: EventTypeUpdateInput.strict(), async resolve({ ctx, input }) { - const { schedule, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } = - input; + const { + schedule, + periodType, + locations, + destinationCalendar, + customInputs, + users, + id, + hashedLink, + ...rest + } = input; assertValidUrl(input.successRedirectUrl); const data: Prisma.EventTypeUpdateInput = rest; data.locations = locations ?? undefined; @@ -249,6 +262,48 @@ export const eventTypesRouter = createProtectedRouter() }; } + const connectedLink = await ctx.prisma.hashedLink.findFirst({ + where: { + eventTypeId: input.id, + }, + select: { + id: true, + }, + }); + + if (hashedLink) { + // check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection + if (!connectedLink) { + const translator = short(); + const seed = `${input.eventName}:${input.id}:${new Date().getTime()}`; + const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL)); + // create a hashed link + await ctx.prisma.hashedLink.upsert({ + where: { + eventTypeId: input.id, + }, + update: { + link: uid, + }, + create: { + link: uid, + eventType: { + connect: { id: input.id }, + }, + }, + }); + } + } else { + // check if hashed connection exists. If it does, disconnect + if (connectedLink) { + await ctx.prisma.hashedLink.delete({ + where: { + eventTypeId: input.id, + }, + }); + } + } + const eventType = await ctx.prisma.eventType.update({ where: { id }, data, diff --git a/apps/website b/apps/website index ac4ce5571f..300d090ebe 160000 --- a/apps/website +++ b/apps/website @@ -1 +1 @@ -Subproject commit ac4ce5571f91b49c05cb19e71f8c58b5d3f6d131 +Subproject commit 300d090ebe5772b2b22432931ba1a837b4e5e759 diff --git a/packages/app-store/googlecalendar/api/callback.ts b/packages/app-store/googlecalendar/api/callback.ts index c5e1a69c7f..ff9863f91b 100644 --- a/packages/app-store/googlecalendar/api/callback.ts +++ b/packages/app-store/googlecalendar/api/callback.ts @@ -2,6 +2,7 @@ import { google } from "googleapis"; import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; import { decodeOAuthState } from "../../_utils/decodeOAuthState"; @@ -10,7 +11,6 @@ const credentials = process.env.GOOGLE_API_CREDENTIALS; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; - if (code && typeof code !== "string") { res.status(400).json({ message: "`code` must be a string" }); return; @@ -19,7 +19,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.status(400).json({ message: "There are no Google Credentials installed." }); return; } - const { client_secret, client_id } = JSON.parse(credentials).web; const redirect_uri = WEBAPP_URL + "/api/integrations/googlecalendar/callback"; @@ -41,5 +40,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); const state = decodeOAuthState(req); - res.redirect(state?.returnTo ?? "/apps/installed"); + res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed"); } diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 1e742aa35c..20fd3acc7a 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -72,21 +72,21 @@ export default class GoogleCalendarService implements Calendar { }; }; - async createEvent(event: CalendarEvent): Promise { + async createEvent(calEventRaw: CalendarEvent): Promise { return new Promise((resolve, reject) => this.auth.getToken().then((myGoogleAuth) => { const payload: calendar_v3.Schema$Event = { - summary: event.title, - description: getRichDescription(event), + summary: calEventRaw.title, + description: getRichDescription(calEventRaw), start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, + dateTime: calEventRaw.startTime, + timeZone: calEventRaw.organizer.timeZone, }, end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, + dateTime: calEventRaw.endTime, + timeZone: calEventRaw.organizer.timeZone, }, - attendees: event.attendees.map((attendee) => ({ + attendees: calEventRaw.attendees.map((attendee) => ({ ...attendee, responseStatus: "accepted", })), @@ -95,23 +95,21 @@ export default class GoogleCalendarService implements Calendar { }, }; - if (event.location) { - payload["location"] = getLocation(event); + if (calEventRaw.location) { + payload["location"] = getLocation(calEventRaw); } - if (event.conferenceData && event.location === "integrations:google:meet") { - payload["conferenceData"] = event.conferenceData; + if (calEventRaw.conferenceData && calEventRaw.location === "integrations:google:meet") { + payload["conferenceData"] = calEventRaw.conferenceData; } - const calendar = google.calendar({ version: "v3", - auth: myGoogleAuth, }); calendar.events.insert( { auth: myGoogleAuth, - calendarId: event.destinationCalendar?.externalId - ? event.destinationCalendar.externalId + calendarId: calEventRaw.destinationCalendar?.externalId + ? calEventRaw.destinationCalendar.externalId : "primary", requestBody: payload, conferenceDataVersion: 1, @@ -121,6 +119,22 @@ export default class GoogleCalendarService implements Calendar { console.error("There was an error contacting google calendar service: ", err); return reject(err); } + + calendar.events.patch({ + // Update the same event but this time we know the hangout link + calendarId: calEventRaw.destinationCalendar?.externalId + ? calEventRaw.destinationCalendar.externalId + : "primary", + auth: myGoogleAuth, + eventId: event.data.id || "", + requestBody: { + description: getRichDescription({ + ...calEventRaw, + additionInformation: { hangoutLink: event.data.hangoutLink || "" }, + }), + }, + }); + return resolve({ uid: "", ...event.data, diff --git a/packages/app-store/hubspotothercalendar/api/callback.ts b/packages/app-store/hubspotothercalendar/api/callback.ts index a99a7ee7d4..b3483e45b9 100644 --- a/packages/app-store/hubspotothercalendar/api/callback.ts +++ b/packages/app-store/hubspotothercalendar/api/callback.ts @@ -3,6 +3,7 @@ import { TokenResponseIF } from "@hubspot/api-client/lib/codegen/oauth/models/To import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; import { decodeOAuthState } from "../../_utils/decodeOAuthState"; @@ -52,5 +53,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); const state = decodeOAuthState(req); - res.redirect(state?.returnTo ?? "/apps/installed"); + res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed"); } diff --git a/packages/app-store/office365calendar/api/callback.ts b/packages/app-store/office365calendar/api/callback.ts index 940f74f062..91e04b4c6e 100644 --- a/packages/app-store/office365calendar/api/callback.ts +++ b/packages/app-store/office365calendar/api/callback.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { BASE_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; import { decodeOAuthState } from "../../_utils/decodeOAuthState"; @@ -62,5 +63,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); const state = decodeOAuthState(req); - return res.redirect(state?.returnTo ?? "/apps/installed"); + return res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed"); } diff --git a/packages/app-store/office365video/api/callback.ts b/packages/app-store/office365video/api/callback.ts index 2ef3f1a127..94e26cbd0d 100644 --- a/packages/app-store/office365video/api/callback.ts +++ b/packages/app-store/office365video/api/callback.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { BASE_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; import { decodeOAuthState } from "../../_utils/decodeOAuthState"; @@ -63,5 +64,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); const state = decodeOAuthState(req); - return res.redirect(state?.returnTo ?? "/apps/installed"); + return res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed"); } diff --git a/packages/app-store/slackmessaging/api/add.ts b/packages/app-store/slackmessaging/api/add.ts index a276d93134..6796e0aff3 100644 --- a/packages/app-store/slackmessaging/api/add.ts +++ b/packages/app-store/slackmessaging/api/add.ts @@ -4,7 +4,7 @@ import { stringify } from "querystring"; import prisma from "@calcom/prisma"; const client_id = process.env.SLACK_CLIENT_ID; -const scopes = ["commands", "users:read", "users:read.email", "chat:write.public"]; +const scopes = ["commands", "users:read", "users:read.email", "chat:write", "chat:write.public"]; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (!req.session?.user?.id) { diff --git a/packages/core/builders/CalendarEvent/builder.ts b/packages/core/builders/CalendarEvent/builder.ts index d6ff81084d..11c55cdce6 100644 --- a/packages/core/builders/CalendarEvent/builder.ts +++ b/packages/core/builders/CalendarEvent/builder.ts @@ -5,6 +5,7 @@ import { v5 as uuidv5 } from "uuid"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; +import { CalendarEvent } from "@calcom/types/Calendar"; import { CalendarEventClass } from "./class"; @@ -124,6 +125,7 @@ export class CalendarEventBuilder implements ICalendarEventBuilder { slug: true, }, }, + description: true, slug: true, teamId: true, title: true, @@ -263,6 +265,10 @@ export class CalendarEventBuilder implements ICalendarEventBuilder { this.calendarEvent.description = description; } + public setNotes(notes: CalendarEvent["additionalNotes"]) { + this.calendarEvent.additionalNotes = notes; + } + public setCancellationReason(cancellationReason: CalendarEventClass["cancellationReason"]) { this.calendarEvent.cancellationReason = cancellationReason; } diff --git a/packages/core/builders/CalendarEvent/class.ts b/packages/core/builders/CalendarEvent/class.ts index d433bda236..29e8b963dc 100644 --- a/packages/core/builders/CalendarEvent/class.ts +++ b/packages/core/builders/CalendarEvent/class.ts @@ -21,6 +21,7 @@ class CalendarEventClass implements CalendarEvent { cancellationReason?: string | null; rejectionReason?: string | null; hideCalendarNotes?: boolean; + additionalNotes?: string | null | undefined; constructor(initProps?: CalendarEvent) { // If more parameters are given we update this diff --git a/packages/core/builders/CalendarEvent/director.ts b/packages/core/builders/CalendarEvent/director.ts index 0eda82f90c..b33311b8bf 100644 --- a/packages/core/builders/CalendarEvent/director.ts +++ b/packages/core/builders/CalendarEvent/director.ts @@ -27,6 +27,8 @@ export class CalendarEventDirector { this.builder.setLocation(this.existingBooking.location); this.builder.setUId(this.existingBooking.uid); this.builder.setCancellationReason(this.cancellationReason); + this.builder.setDescription(this.builder.eventType.description); + this.builder.setNotes(this.existingBooking.description); this.builder.buildRescheduleLink(this.existingBooking.uid); } else { throw new Error("buildForRescheduleEmail.missing.params.required"); diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index 888a3748ca..69b9d14ba3 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -46,6 +46,9 @@ ${organizer + attendees} }; export const getAdditionalNotes = (calEvent: CalendarEvent) => { + if (!calEvent.additionalNotes) { + return ""; + } return ` ${calEvent.organizer.language.translate("additional_notes")}: ${calEvent.additionalNotes} @@ -53,6 +56,9 @@ ${calEvent.additionalNotes} }; export const getDescription = (calEvent: CalendarEvent) => { + if (!calEvent.description) { + return ""; + } return `\n${calEvent.attendees[0].language.translate("description")} ${calEvent.description} `; diff --git a/packages/lib/getSafeRedirectUrl.ts b/packages/lib/getSafeRedirectUrl.ts new file mode 100644 index 0000000000..c7beb6cbd9 --- /dev/null +++ b/packages/lib/getSafeRedirectUrl.ts @@ -0,0 +1,16 @@ +import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; + +// It ensures that redirection URL safe where it is accepted through a query params or other means where user can change it. +export const getSafeRedirectUrl = (url: string | undefined) => { + url = url || ""; + if (url.search(/^https?:\/\//) === -1) { + throw new Error("Pass an absolute URL"); + } + + // Avoid open redirection security vulnerability + if (!url.startsWith(WEBAPP_URL) && !url.startsWith(WEBSITE_URL)) { + url = `${WEBAPP_URL}/`; + } + + return url; +}; diff --git a/packages/prisma/migrations/20220420152505_add_hashed_event_url/migration.sql b/packages/prisma/migrations/20220420152505_add_hashed_event_url/migration.sql new file mode 100644 index 0000000000..6890eb7b3d --- /dev/null +++ b/packages/prisma/migrations/20220420152505_add_hashed_event_url/migration.sql @@ -0,0 +1,23 @@ +-- DropForeignKey +ALTER TABLE "BookingReference" DROP CONSTRAINT "BookingReference_bookingId_fkey"; + +-- CreateTable +CREATE TABLE "HashedLink" ( + "id" SERIAL NOT NULL, + "link" TEXT NOT NULL, + "eventTypeId" INTEGER NOT NULL, + + CONSTRAINT "HashedLink_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "HashedLink_link_key" ON "HashedLink"("link"); + +-- CreateIndex +CREATE UNIQUE INDEX "HashedLink_eventTypeId_key" ON "HashedLink"("eventTypeId"); + +-- AddForeignKey +ALTER TABLE "BookingReference" ADD CONSTRAINT "BookingReference_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HashedLink" ADD CONSTRAINT "HashedLink_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f468da8e3f..a3e68dc8fa 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -45,6 +45,7 @@ model EventType { userId Int? team Team? @relation(fields: [teamId], references: [id]) teamId Int? + hashedLink HashedLink? bookings Booking[] availability Availability[] webhooks Webhook[] @@ -418,6 +419,13 @@ model ApiKey { user User? @relation(fields: [userId], references: [id], onDelete: Cascade) } +model HashedLink { + id Int @id @default(autoincrement()) + link String @unique() + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int @unique +} + model Account { id String @id @default(cuid()) userId Int