From 23450b61e0877e39e2df17ca9235ec0206cdd9b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Wed, 21 Dec 2022 17:15:51 -0700 Subject: [PATCH] Refactor/email confirm links (#6151) * Booking confirmation link improvements * Cleanup * Update webhooks payload * Removing unneeded dependency * Feedback * Matches enum style to rest of codebase Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Leo Giovanetti --- apps/web/next.config.js | 5 + apps/web/package.json | 1 - apps/web/pages/api/link.ts | 63 +++ apps/web/pages/booking/direct/[...link].tsx | 450 ------------------ apps/web/playwright/webhook.e2e.ts | 1 + .../webhookResponse--calcom-web.txt | 2 +- .../src/templates/OrganizerRequestEmail.tsx | 20 +- .../features/bookings/lib/handleNewBooking.ts | 1 + .../lib/server/queries/bookings/confirm.ts | 446 ----------------- .../trpc/server/routers/viewer/bookings.tsx | 415 +++++++++++++++- yarn.lock | 5 - 11 files changed, 485 insertions(+), 924 deletions(-) create mode 100644 apps/web/pages/api/link.ts delete mode 100644 apps/web/pages/booking/direct/[...link].tsx delete mode 100644 packages/lib/server/queries/bookings/confirm.ts diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 902d6289e4..5bf9ad5eb4 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -227,6 +227,11 @@ const nextConfig = { destination: "/404", permanent: false, }, + { + source: "/booking/direct/:action/:email/:bookingUid/:oldToken", + destination: "/api/link?action=:action&email=:email&bookingUid=:bookingUid&oldToken=:oldToken", + permanent: true, + }, ]; if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") { diff --git a/apps/web/package.json b/apps/web/package.json index 60d6b96f1c..e02c5a7a05 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -64,7 +64,6 @@ "@vercel/og": "^0.0.21", "accept-language-parser": "^1.5.0", "async": "^3.2.4", - "base64url": "^3.0.1", "bcryptjs": "^2.4.3", "classnames": "^2.3.1", "dotenv-cli": "^6.0.0", diff --git a/apps/web/pages/api/link.ts b/apps/web/pages/api/link.ts new file mode 100644 index 0000000000..a25c3958d6 --- /dev/null +++ b/apps/web/pages/api/link.ts @@ -0,0 +1,63 @@ +import { UserPermissionRole } from "@prisma/client"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; + +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { createContext } from "@calcom/trpc/server/createContext"; +import { viewerRouter } from "@calcom/trpc/server/routers/viewer"; + +enum DirectAction { + ACCEPT = "accept", + REJECT = "reject", +} + +const querySchema = z.object({ + action: z.nativeEnum(DirectAction), + token: z.string(), + reason: z.string().optional(), +}); + +const decryptedSchema = z.object({ + bookingUid: z.string(), + userId: z.number().int(), +}); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { action, token, reason } = querySchema.parse(req.query); + const { bookingUid, userId } = decryptedSchema.parse( + JSON.parse(symmetricDecrypt(token, process.env.CALENDSO_ENCRYPTION_KEY || "")) + ); + + /** We shape the session as required by tRPC router */ + async function sessionGetter() { + return { + user: { + id: userId, + username: "" /* Not used in this context */, + role: UserPermissionRole.USER, + }, + hasValidLicense: true, + expires: "" /* Not used in this context */, + }; + } + + const booking = await prisma.booking.findUniqueOrThrow({ + where: { uid: bookingUid }, + }); + + /** @see https://trpc.io/docs/server-side-calls */ + const ctx = await createContext({ req, res }, sessionGetter); + const caller = viewerRouter.createCaller(ctx); + await caller.bookings.confirm({ + bookingId: booking.id, + recurringEventId: booking.recurringEventId || undefined, + confirmed: action === DirectAction.ACCEPT, + reason, + }); + + return res.redirect(`/booking/${bookingUid}`); +} + +export default defaultResponder(handler); diff --git a/apps/web/pages/booking/direct/[...link].tsx b/apps/web/pages/booking/direct/[...link].tsx deleted file mode 100644 index 5069c43e32..0000000000 --- a/apps/web/pages/booking/direct/[...link].tsx +++ /dev/null @@ -1,450 +0,0 @@ -import { BookingStatus } from "@prisma/client"; -import base64url from "base64url"; -import { createHmac } from "crypto"; -import { GetServerSidePropsContext } from "next"; -import { useRouter } from "next/router"; -import { useState } from "react"; -import z from "zod"; - -import { getEventLocationValue, getSuccessPageLocationMessage } from "@calcom/app-store/locations"; -import dayjs from "@calcom/dayjs"; -import { getRecurringWhen } from "@calcom/emails/src/components/WhenInfo"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; -import { processBookingConfirmation } from "@calcom/lib/server/queries/bookings/confirm"; -import prisma from "@calcom/prisma"; -import { TRPCError } from "@calcom/trpc/server"; -import { inferSSRProps } from "@calcom/types/inferSSRProps"; -import { Button, Icon, TextArea } from "@calcom/ui"; - -import { HeadSeo } from "@components/seo/head-seo"; - -enum DirectAction { - "accept" = "accept", - "reject" = "reject", -} -const actionSchema = z.nativeEnum(DirectAction); - -const refineParse = (result: z.SafeParseReturnType, context: z.RefinementCtx) => { - if (result.success === false) { - result.error.issues.map((issue) => context.addIssue(issue)); - } -}; - -const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || ""; - -const pageErrors = { - signature_mismatch: "Direct link signature doesn't match signed data", - booking_not_found: "Direct link booking not found", - user_not_found: "Direct link booking user not found", -}; - -const requestSchema = z.object({ - link: z - .array(z.string()) - .max(4) - .superRefine((data, ctx) => { - refineParse(actionSchema.safeParse(data[0]), ctx); - const signedData = `${data[1]}/${data[2]}`; - const sha1 = createHmac("sha1", CALENDSO_ENCRYPTION_KEY).update(signedData).digest(); - const sig = base64url(sha1); - if (data[3] !== sig) { - ctx.addIssue({ - message: pageErrors.signature_mismatch, - code: "custom", - }); - } - }), - reason: z.string().optional(), -}); - -function bookingContent(status: BookingStatus | undefined | null) { - switch (status) { - case BookingStatus.PENDING: - // Trying to reject booking without reason - return { - iconColor: "gray", - Icon: Icon.FiCalendar, - titleKey: "event_awaiting_approval", - subtitleKey: "someone_requested_an_event", - }; - case BookingStatus.ACCEPTED: - // Booking was acepted successfully - return { - iconColor: "green", - Icon: Icon.FiCheck, - titleKey: "booking_confirmed", - subtitleKey: "emailed_you_and_any_other_attendees", - }; - case BookingStatus.REJECTED: - // Booking was rejected successfully - return { - iconColor: "red", - Icon: Icon.FiX, - titleKey: "booking_rejection_success", - subtitleKey: "emailed_you_and_any_other_attendees", - }; - default: - // Booking was already accepted or rejected - return { - iconColor: "yellow", - Icon: Icon.FiAlertTriangle, - titleKey: "booking_already_accepted_rejected", - }; - } -} - -export default function Directlink({ booking, reason, status }: inferSSRProps) { - const { t } = useLocale(); - const router = useRouter(); - const acceptPath = router.asPath.replace("reject", "accept"); - const rejectPath = router.asPath.replace("accept", "reject"); - const [cancellationReason, setCancellationReason] = useState(""); - function getRecipientStart(format: string) { - return dayjs(booking.startTime).tz(booking?.user?.timeZone).format(format); - } - - function getRecipientEnd(format: string) { - return dayjs(booking.endTime).tz(booking?.user?.timeZone).format(format); - } - const organizer = { - ...booking.attendees[0], - language: { - translate: t, - locale: booking.attendees[0].locale ?? "en", - }, - }; - const location: ReturnType = Array.isArray(booking.location) - ? booking.location[0] - : // If there is no location set then we default to Cal Video - "integrations:daily"; - const locationToDisplay = getSuccessPageLocationMessage(location, t); - const content = bookingContent(status); - const recurringInfo = getRecurringWhen({ - recurringEvent: booking.eventType?.recurringEvent, - attendee: organizer, - }); - return ( - <> - -
-
-
-
-