From 5a430df5d91f6f241216964772a463d974099cd8 Mon Sep 17 00:00:00 2001 From: Rama Krishna Reddy <49095575+rkreddy99@users.noreply.github.com> Date: Mon, 31 Jul 2023 23:21:11 +0530 Subject: [PATCH] feat: Verify email of people setting a meeting with you (#10317) * booker email verification changes * name type fix * use totp and code * prisma schema styling * refactor: code Signed-off-by: Udit Takkar * fix: book event form Signed-off-by: Udit Takkar * fix: type error Signed-off-by: Udit Takkar * fix: type errors Signed-off-by: Udit Takkar * fix: unit tests Signed-off-by: Udit Takkar * refactor: move verifycodedialog from ui and to features/bookings * fix: type error --------- Signed-off-by: Udit Takkar Co-authored-by: rkreddy99 Co-authored-by: Udit Takkar --- .../components/eventtype/EventAdvancedTab.tsx | 15 ++ apps/web/pages/[user].tsx | 1 + apps/web/pages/event-types/[type]/index.tsx | 1 + apps/web/public/static/locales/en/common.json | 7 + .../test/lib/handleChildrenEventTypes.test.ts | 9 +- packages/emails/email-manager.ts | 5 + .../src/templates/VerifyEmailByCode.tsx | 45 ++++++ packages/emails/src/templates/index.ts | 1 + .../emails/templates/attendee-verify-email.ts | 51 +++++++ packages/features/auth/lib/ErrorCode.ts | 1 + packages/features/auth/lib/verifyEmail.ts | 26 +++- .../BookEventForm/BookEventForm.tsx | 59 +++++++- packages/features/bookings/Booker/store.ts | 14 ++ .../bookings/components/VerifyCodeDialog.tsx | 143 ++++++++++++++++++ .../features/bookings/lib/handleNewBooking.ts | 2 + .../components/CreateANewOrganizationForm.tsx | 120 +-------------- .../features/eventtypes/lib/getPublicEvent.ts | 1 + packages/lib/defaultEvents.ts | 1 + packages/lib/getEventTypeById.ts | 1 + packages/lib/test/builder.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 105 ++++++------- packages/prisma/selects/event-types.ts | 2 + packages/prisma/zod-utils.ts | 7 + .../server/routers/viewer/auth/_router.tsx | 41 +++++ .../auth/sendVerifyEmailCode.handler.ts | 20 +++ .../viewer/auth/sendVerifyEmailCode.schema.ts | 9 ++ .../auth/verifyCodeUnAuthenticated.handler.ts | 27 ++++ .../routers/viewer/organizations/_router.tsx | 3 +- .../organizations/verifyCode.handler.ts | 8 +- .../viewer/organizations/verifyCode.schema.ts | 8 - 31 files changed, 546 insertions(+), 190 deletions(-) create mode 100644 packages/emails/src/templates/VerifyEmailByCode.tsx create mode 100644 packages/emails/templates/attendee-verify-email.ts create mode 100644 packages/features/bookings/components/VerifyCodeDialog.tsx create mode 100644 packages/prisma/migrations/20230717175901_add_booker_email_verification/migration.sql create mode 100644 packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.handler.ts create mode 100644 packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.schema.ts create mode 100644 packages/trpc/server/routers/viewer/auth/verifyCodeUnAuthenticated.handler.ts delete mode 100644 packages/trpc/server/routers/viewer/organizations/verifyCode.schema.ts diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 4d13b31352..9d5d63fe18 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -204,6 +204,21 @@ export const EventAdvancedTab = ({ eventType, team }: Pick
+ ( + onChange(e)} + /> + )} + /> +
{ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // eslint-disable-next-line - const { schedulingType, id, teamId, timeZone, users, ...evType } = mockFindFirstEventType({ + const { schedulingType, id, teamId, timeZone, users,requiresBookerEmailVerification, ...evType } = mockFindFirstEventType({ id: 123, metadata: { managedEventConfig: {} }, locations: [], @@ -133,7 +133,7 @@ describe("handleChildrenEventTypes", () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // eslint-disable-next-line - const { schedulingType, id, teamId, timeZone, locations, parentId, userId, scheduleId, ...evType } = + const { schedulingType, id, teamId, timeZone, locations, parentId, userId, scheduleId,requiresBookerEmailVerification, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, locations: [], @@ -218,7 +218,7 @@ describe("handleChildrenEventTypes", () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // eslint-disable-next-line - const { schedulingType, id, teamId, timeZone, users, ...evType } = mockFindFirstEventType({ + const { schedulingType, id, teamId, timeZone, users,requiresBookerEmailVerification, ...evType } = mockFindFirstEventType({ id: 123, metadata: { managedEventConfig: {} }, locations: [], @@ -255,7 +255,7 @@ describe("handleChildrenEventTypes", () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // eslint-disable-next-line - const { schedulingType, id, teamId, timeZone, users, locations, parentId, userId, ...evType } = + const { schedulingType, id, teamId, timeZone, users, locations, parentId, userId,requiresBookerEmailVerification, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, locations: [], @@ -303,6 +303,7 @@ describe("handleChildrenEventTypes", () => { timeZone: _timeZone, parentId: _parentId, userId: _userId, + requiresBookerEmailVerification, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 1c032dd91c..541da301ff 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -42,6 +42,7 @@ import OrganizerScheduledEmail from "./templates/organizer-scheduled-email"; import SlugReplacementEmail from "./templates/slug-replacement-email"; import type { TeamInvite } from "./templates/team-invite-email"; import TeamInviteEmail from "./templates/team-invite-email"; +import AttendeeVerifyEmail, { EmailVerifyCode } from "./templates/attendee-verify-email"; const sendEmail = (prepare: () => BaseEmail) => { return new Promise((resolve, reject) => { @@ -275,6 +276,10 @@ export const sendEmailVerificationLink = async (verificationInput: EmailVerifyLi await sendEmail(() => new AccountVerifyEmail(verificationInput)); }; +export const sendEmailVerificationCode = async (verificationInput: EmailVerifyCode) => { + await sendEmail(() => new AttendeeVerifyEmail(verificationInput)); +}; + export const sendRequestRescheduleEmail = async ( calEvent: CalendarEvent, metadata: { rescheduleLink: string } diff --git a/packages/emails/src/templates/VerifyEmailByCode.tsx b/packages/emails/src/templates/VerifyEmailByCode.tsx new file mode 100644 index 0000000000..6ce7947c82 --- /dev/null +++ b/packages/emails/src/templates/VerifyEmailByCode.tsx @@ -0,0 +1,45 @@ +import { APP_NAME, SENDER_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants"; + +import type { EmailVerifyCode } from "../../templates/attendee-verify-email"; +import { BaseEmailHtml } from "../components"; + +export const VerifyEmailByCode = ( + props: EmailVerifyCode & Partial> +) => { + return ( + +

+ <>{props.language("verify_email_email_header")} +

+

+ <>{props.language("hi_user_name", { name: props.user.name })}! +

+
+

+ <>{props.language("verify_email_by_code_email_body")} +
+

{props.verificationEmailCode}

+

+
+
+

+ <> + {props.language("happy_scheduling")},
+ + <>{props.language("the_calcom_team", { companyName: SENDER_NAME })} + + +

+
+
+ ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index eb44d10278..83947fc9c6 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -24,6 +24,7 @@ export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail"; export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail"; export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail"; export { VerifyAccountEmail } from "./VerifyAccountEmail"; +export { VerifyEmailByCode } from "./VerifyEmailByCode" export * from "@calcom/app-store/routing-forms/emails/components"; export { DailyVideoDownloadRecordingEmail } from "./DailyVideoDownloadRecordingEmail"; export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail"; diff --git a/packages/emails/templates/attendee-verify-email.ts b/packages/emails/templates/attendee-verify-email.ts new file mode 100644 index 0000000000..26719fcb58 --- /dev/null +++ b/packages/emails/templates/attendee-verify-email.ts @@ -0,0 +1,51 @@ +import type { TFunction } from "next-i18next"; + +import { APP_NAME, COMPANY_NAME } from "@calcom/lib/constants"; + +import { renderEmail } from "../"; +import BaseEmail from "./_base-email"; + +export type EmailVerifyCode = { + language: TFunction; + user: { + name?: string | null; + email: string; + }; + verificationEmailCode: string; +}; + +export default class AttendeeVerifyEmail extends BaseEmail { + verifyAccountInput: EmailVerifyCode; + + constructor(passwordEvent: EmailVerifyCode) { + super(); + this.name = "SEND_ACCOUNT_VERIFY_EMAIL"; + this.verifyAccountInput = passwordEvent; + } + + protected getNodeMailerPayload(): Record { + return { + to: `${this.verifyAccountInput.user.name} <${this.verifyAccountInput.user.email}>`, + from: `${APP_NAME} <${this.getMailerOptions().from}>`, + subject: this.verifyAccountInput.language("verify_email_subject", { + appName: APP_NAME, + }), + html: renderEmail("VerifyEmailByCode", this.verifyAccountInput), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return ` +${this.verifyAccountInput.language("verify_email_subject", { appName: APP_NAME })} +${this.verifyAccountInput.language("verify_email_email_header")} +${this.verifyAccountInput.language("hi_user_name", { name: this.verifyAccountInput.user.name })}, +${this.verifyAccountInput.language("verify_email_by_code_email_body")} +${this.verifyAccountInput.verificationEmailCode} +${this.verifyAccountInput.language("happy_scheduling")} ${this.verifyAccountInput.language( + "the_calcom_team", + { companyName: COMPANY_NAME } + )} +`.replace(/(<([^>]+)>)/gi, ""); + } +} \ No newline at end of file diff --git a/packages/features/auth/lib/ErrorCode.ts b/packages/features/auth/lib/ErrorCode.ts index d052349164..ae054721e4 100644 --- a/packages/features/auth/lib/ErrorCode.ts +++ b/packages/features/auth/lib/ErrorCode.ts @@ -8,6 +8,7 @@ export enum ErrorCode { TwoFactorSetupRequired = "two-factor-setup-required", SecondFactorRequired = "second-factor-required", IncorrectTwoFactorCode = "incorrect-two-factor-code", + IncorrectEmailVerificationCode = "incorrect_email_verification_code", InternalServerError = "internal-server-error", NewPasswordMatchesOld = "new-password-matches-old", ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled", diff --git a/packages/features/auth/lib/verifyEmail.ts b/packages/features/auth/lib/verifyEmail.ts index cdf2fd08aa..f73dd9c877 100644 --- a/packages/features/auth/lib/verifyEmail.ts +++ b/packages/features/auth/lib/verifyEmail.ts @@ -1,6 +1,7 @@ -import { randomBytes } from "crypto"; +import { randomBytes, createHash } from "crypto"; +import { totp } from "otplib"; -import { sendEmailVerificationLink } from "@calcom/emails/email-manager"; +import { sendEmailVerificationCode, sendEmailVerificationLink } from "@calcom/emails/email-manager"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -54,3 +55,24 @@ export const sendEmailVerification = async ({ email, language, username }: Verif return { ok: true, skipped: false }; }; + +export const sendEmailVerificationByCode = async ({ email, language, username }: VerifyEmailType) => { + const translation = await getTranslation(language ?? "en", "common"); + const secret = createHash("md5") + .update(email + process.env.CALENDSO_ENCRYPTION_KEY) + .digest("hex"); + + totp.options = { step: 900 }; + const code = totp.generate(secret); + + await sendEmailVerificationCode({ + language: translation, + verificationEmailCode: code, + user: { + email, + name: username, + }, + }); + + return { ok: true, skipped: false }; +}; diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index baf84e1947..5e8700ee9b 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -4,7 +4,7 @@ import { useMutation } from "@tanstack/react-query"; import { useSession } from "next-auth/react"; import type { TFunction } from "next-i18next"; import { useRouter } from "next/router"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { FieldError } from "react-hook-form"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -12,6 +12,7 @@ import { z } from "zod"; import type { EventLocationType } from "@calcom/app-store/locations"; import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client"; import dayjs from "@calcom/dayjs"; +import { VerifyCodeDialog } from "@calcom/features/bookings/components/VerifyCodeDialog"; import { useTimePreferences, mapBookingToMutationInput, @@ -29,7 +30,7 @@ import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; import { trpc } from "@calcom/trpc"; -import { Form, Button, Alert, EmptyScreen } from "@calcom/ui"; +import { Form, Button, Alert, EmptyScreen, showToast } from "@calcom/ui"; import { Calendar } from "@calcom/ui/components/icon"; import { useBookerStore } from "../../store"; @@ -63,6 +64,8 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { const formValues = useBookerStore((state) => state.formValues); const setFormValues = useBookerStore((state) => state.setFormValues); const seatedEventData = useBookerStore((state) => state.seatedEventData); + const verifiedEmail = useBookerStore((state) => state.verifiedEmail); + const setVerifiedEmail = useBookerStore((state) => state.setVerifiedEmail); const isRescheduling = !!rescheduleUid && !!bookingData; const event = useEvent(); const eventType = event.data; @@ -269,6 +272,37 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { }, }); + const [isEmailVerificationModalVisible, setEmailVerificationModalVisible] = useState(false); + const email = bookingForm.watch("responses.email"); + + const sendEmailVerificationByCodeMutation = trpc.viewer.auth.sendVerifyEmailCode.useMutation({ + onSuccess() { + showToast(t("email_sent"), "success"); + }, + onError() { + showToast(t("email_not_sent"), "error"); + }, + }); + + const verifyEmail = () => { + bookingForm.clearErrors(); + + // It shouldn't be possible that this method is fired without having event data, + // but since in theory (looking at the types) it is possible, we still handle that case. + if (!event?.data) { + bookingForm.setError("globalError", { message: t("error_booking_event") }); + return; + } + + const name = bookingForm.getValues("responses.name"); + + sendEmailVerificationByCodeMutation.mutate({ + email, + username: typeof name === "string" ? name : name.firstName, + }); + setEmailVerificationModalVisible(true); + }; + if (event.isError) return ; if (event.isLoading || !event.data) return ; if (!timeslot) @@ -338,6 +372,9 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { return ; } + const renderConfirmNotVerifyEmailButtonCond = + !eventType?.requiresBookerEmailVerification || (email && verifiedEmail && verifiedEmail === email); + return (
{ setFormValues(values); }} form={bookingForm} - handleSubmit={bookEvent} + handleSubmit={renderConfirmNotVerifyEmailButtonCond ? bookEvent : verifyEmail} noValidate> -1)} @@ -387,10 +424,24 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { color="primary" loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading} data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}> - {rescheduleUid ? t("reschedule") : t("confirm")} + {rescheduleUid + ? t("reschedule") + : renderConfirmNotVerifyEmailButtonCond + ? t("confirm") + : t("verify_email_email_button")}
+ { + setVerifiedEmail(email); + setEmailVerificationModalVisible(false); + }} + isUserSessionRequiredToVerify={false} + /> ); }; diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 24fbf7de5a..dcc2e1fba5 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -22,6 +22,7 @@ type StoreInitializeType = { bookingUid?: string | null; isTeamEvent?: boolean; bookingData?: GetBookingType | null | undefined; + verifiedEmail?: string | null; rescheduleUid?: string | null; seatReferenceUid?: string; org?: string | null; @@ -41,6 +42,12 @@ export type BookerStore = { username: string | null; eventSlug: string | null; eventId: number | null; + /** + * Verified booker email. + * Needed in case user turns on Requires Booker Email Verification for an event + */ + verifiedEmail: string | null; + setVerifiedEmail: (email: string | null) => void; /** * Current month being viewed. Format is YYYY-MM. */ @@ -173,6 +180,10 @@ export const useBookerStore = create((set, get) => ({ username: null, eventSlug: null, eventId: null, + verifiedEmail: null, + setVerifiedEmail: (email: string | null) => { + set({ verifiedEmail: email }); + }, month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"), setMonth: (month: string | null) => { set({ month, selectedTimeslot: null }); @@ -268,6 +279,7 @@ export const useInitializeBookerStore = ({ eventId, rescheduleUid = null, bookingData = null, + verifiedEmail = null, layout, isTeamEvent, org, @@ -284,6 +296,7 @@ export const useInitializeBookerStore = ({ layout, isTeamEvent, org, + verifiedEmail, }); }, [ initializeStore, @@ -296,5 +309,6 @@ export const useInitializeBookerStore = ({ bookingData, layout, isTeamEvent, + verifiedEmail, ]); }; diff --git a/packages/features/bookings/components/VerifyCodeDialog.tsx b/packages/features/bookings/components/VerifyCodeDialog.tsx new file mode 100644 index 0000000000..ffa7077aaf --- /dev/null +++ b/packages/features/bookings/components/VerifyCodeDialog.tsx @@ -0,0 +1,143 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useState, useEffect } from "react"; +import useDigitInput from "react-digit-input"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + Label, + Input, +} from "@calcom/ui"; +import { Info } from "@calcom/ui/components/icon"; + +export const VerifyCodeDialog = ({ + isOpenDialog, + setIsOpenDialog, + email, + onSuccess, + isUserSessionRequiredToVerify = true, +}: { + isOpenDialog: boolean; + setIsOpenDialog: Dispatch>; + email: string; + onSuccess: (isVerified: boolean) => void; + isUserSessionRequiredToVerify?: boolean; +}) => { + const { t } = useLocale(); + // Not using the mutation isLoading flag because after verifying we submit the underlying org creation form + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [value, onChange] = useState(""); + + const digits = useDigitInput({ + acceptedCharacters: /^[0-9]$/, + length: 6, + value, + onChange, + }); + + const verifyCodeMutationUserSessionRequired = trpc.viewer.organizations.verifyCode.useMutation({ + onSuccess: (data) => { + setIsLoading(false); + onSuccess(data); + }, + onError: (err) => { + setIsLoading(false); + if (err.message === "invalid_code") { + setError(t("code_provided_invalid")); + } + }, + }); + + const verifyCodeMutationUserSessionNotRequired = trpc.viewer.auth.verifyCodeUnAuthenticated.useMutation({ + onSuccess: (data) => { + setIsLoading(false); + onSuccess(data); + }, + onError: (err) => { + setIsLoading(false); + if (err.message === "invalid_code") { + setError(t("code_provided_invalid")); + } + }, + }); + + useEffect(() => onChange(""), [isOpenDialog]); + + const digitClassName = "h-12 w-12 !text-xl text-center"; + + return ( + { + onChange(""); + setError(""); + setIsOpenDialog(open); + }}> + +
+
+ + +
+ + + + + + +
+ {error && ( +
+
+ +
+

{error}

+
+ )} + + + + +
+
+
+
+ ); +}; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 2b69eaf69f..255cf959f2 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -259,6 +259,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => { periodDays: true, periodCountCalendarDays: true, requiresConfirmation: true, + requiresBookerEmailVerification: true, userId: true, price: true, currency: true, @@ -2384,6 +2385,7 @@ const findBookingQuery = async (bookingId: number) => { currency: true, length: true, requiresConfirmation: true, + requiresBookerEmailVerification: true, price: true, }, }, diff --git a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx index 22825a1f02..989db7d408 100644 --- a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx +++ b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx @@ -1,29 +1,16 @@ import { signIn } from "next-auth/react"; import { useRouter } from "next/router"; -import type { Dispatch, SetStateAction } from "react"; import { useState } from "react"; -import useDigitInput from "react-digit-input"; import { Controller, useForm } from "react-hook-form"; +import { VerifyCodeDialog } from "@calcom/features/bookings/components/VerifyCodeDialog"; import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import slugify from "@calcom/lib/slugify"; import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { trpc } from "@calcom/trpc/react"; -import { - Button, - Form, - TextField, - Alert, - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - Label, - Input, -} from "@calcom/ui"; -import { ArrowRight, Info } from "@calcom/ui/components/icon"; +import { Button, Form, TextField, Alert } from "@calcom/ui"; +import { ArrowRight } from "@calcom/ui/components/icon"; function extractDomainFromEmail(email: string) { let out = ""; @@ -34,107 +21,6 @@ function extractDomainFromEmail(email: string) { return out.split(".")[0]; } -export const VerifyCodeDialog = ({ - isOpenDialog, - setIsOpenDialog, - email, - onSuccess, -}: { - isOpenDialog: boolean; - setIsOpenDialog: Dispatch>; - email: string; - onSuccess: (isVerified: boolean) => void; -}) => { - const { t } = useLocale(); - // Not using the mutation isLoading flag because after verifying we submit the underlying org creation form - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(""); - const [value, onChange] = useState(""); - - const digits = useDigitInput({ - acceptedCharacters: /^[0-9]$/, - length: 6, - value, - onChange, - }); - - const verifyCodeMutation = trpc.viewer.organizations.verifyCode.useMutation({ - onSuccess: (data) => { - setIsLoading(false); - onSuccess(data); - }, - onError: (err) => { - setIsLoading(false); - if (err.message === "invalid_code") { - setError(t("code_provided_invalid")); - } - }, - }); - - const digitClassName = "h-12 w-12 !text-xl text-center"; - - return ( - { - onChange(""); - setError(""); - setIsOpenDialog(open); - }}> - -
-
- - -
- - - - - - -
- {error && ( -
-
- -
-

{error}

-
- )} - - - - -
-
-
-
- ); -}; - export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => { const { t, i18n } = useLocale(); const router = useRouter(); diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index 547870d141..2dc5bd0331 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -32,6 +32,7 @@ const publicEventSelect = Prisma.validator()({ disableGuests: true, metadata: true, requiresConfirmation: true, + requiresBookerEmailVerification: true, recurringEvent: true, price: true, currency: true, diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 2a71c28d9c..8a78331496 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -86,6 +86,7 @@ const commons = { destinationCalendar: null, team: null, requiresConfirmation: false, + requiresBookerEmailVerification: false, bookingLimits: null, durationLimits: null, hidden: false, diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index ae8d200605..ddba0aabe7 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -91,6 +91,7 @@ export default async function getEventTypeById({ periodEndDate: true, periodCountCalendarDays: true, requiresConfirmation: true, + requiresBookerEmailVerification: true, recurringEvent: true, hideCalendarNotes: true, disableGuests: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 8a909be107..d47ff444ac 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -76,6 +76,7 @@ export const buildEventType = (eventType?: Partial): EventType => { hidden: false, userId: null, teamId: null, + requiresBookerEmailVerification: false, eventName: faker.lorem.words(), timeZone: null, periodType: "UNLIMITED", diff --git a/packages/prisma/migrations/20230717175901_add_booker_email_verification/migration.sql b/packages/prisma/migrations/20230717175901_add_booker_email_verification/migration.sql new file mode 100644 index 0000000000..6dac0fd651 --- /dev/null +++ b/packages/prisma/migrations/20230717175901_add_booker_email_verification/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "requiresBookerEmailVerification" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index e687645573..c99686633e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -47,70 +47,71 @@ model Host { } model EventType { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @zod.min(1) - title String + title String /// @zod.custom(imports.eventTypeSlug) - slug String - description String? - position Int @default(0) + slug String + description String? + position Int @default(0) /// @zod.custom(imports.eventTypeLocations) - locations Json? - length Int - offsetStart Int @default(0) - hidden Boolean @default(false) - hosts Host[] - users User[] @relation("user_eventtype") - owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) - userId Int? - team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) - teamId Int? - hashedLink HashedLink? - bookings Booking[] - availability Availability[] - webhooks Webhook[] - destinationCalendar DestinationCalendar? - eventName String? - customInputs EventTypeCustomInput[] - parentId Int? - parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade) - children EventType[] @relation("managed_eventtype") + locations Json? + length Int + offsetStart Int @default(0) + hidden Boolean @default(false) + hosts Host[] + users User[] @relation("user_eventtype") + owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) + userId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + hashedLink HashedLink? + bookings Booking[] + availability Availability[] + webhooks Webhook[] + destinationCalendar DestinationCalendar? + eventName String? + customInputs EventTypeCustomInput[] + parentId Int? + parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade) + children EventType[] @relation("managed_eventtype") /// @zod.custom(imports.eventTypeBookingFields) - bookingFields Json? - timeZone String? - periodType PeriodType @default(UNLIMITED) - periodStartDate DateTime? - periodEndDate DateTime? - periodDays Int? - periodCountCalendarDays Boolean? - requiresConfirmation Boolean @default(false) + bookingFields Json? + timeZone String? + periodType PeriodType @default(UNLIMITED) + periodStartDate DateTime? + periodEndDate DateTime? + periodDays Int? + periodCountCalendarDays Boolean? + requiresConfirmation Boolean @default(false) + requiresBookerEmailVerification Boolean @default(false) /// @zod.custom(imports.recurringEventType) - recurringEvent Json? - disableGuests Boolean @default(false) - hideCalendarNotes Boolean @default(false) + recurringEvent Json? + disableGuests Boolean @default(false) + hideCalendarNotes Boolean @default(false) /// @zod.min(0) - minimumBookingNotice Int @default(120) - beforeEventBuffer Int @default(0) - afterEventBuffer Int @default(0) - seatsPerTimeSlot Int? - seatsShowAttendees Boolean? @default(false) - schedulingType SchedulingType? - schedule Schedule? @relation(fields: [scheduleId], references: [id]) - scheduleId Int? + minimumBookingNotice Int @default(120) + beforeEventBuffer Int @default(0) + afterEventBuffer Int @default(0) + seatsPerTimeSlot Int? + seatsShowAttendees Boolean? @default(false) + schedulingType SchedulingType? + schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? // price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column. - price Int @default(0) + price Int @default(0) // currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column. - currency String @default("usd") - slotInterval Int? + currency String @default("usd") + slotInterval Int? /// @zod.custom(imports.EventTypeMetaDataSchema) - metadata Json? + metadata Json? /// @zod.custom(imports.successRedirectUrl) - successRedirectUrl String? - workflows WorkflowsOnEventTypes[] + successRedirectUrl String? + workflows WorkflowsOnEventTypes[] /// @zod.custom(imports.intervalLimitsType) - bookingLimits Json? + bookingLimits Json? /// @zod.custom(imports.intervalLimitsType) - durationLimits Json? + durationLimits Json? @@unique([userId, slug]) @@unique([teamId, slug]) diff --git a/packages/prisma/selects/event-types.ts b/packages/prisma/selects/event-types.ts index 62e267ed38..c47e148dde 100644 --- a/packages/prisma/selects/event-types.ts +++ b/packages/prisma/selects/event-types.ts @@ -12,6 +12,7 @@ export const baseEventTypeSelect = Prisma.validator()({ price: true, currency: true, requiresConfirmation: true, + requiresBookerEmailVerification: true, }); export const bookEventTypeSelect = Prisma.validator()({ @@ -28,6 +29,7 @@ export const bookEventTypeSelect = Prisma.validator()({ periodEndDate: true, recurringEvent: true, requiresConfirmation: true, + requiresBookerEmailVerification: true, metadata: true, periodCountCalendarDays: true, price: true, diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 49f431dbd5..5132994a2f 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -598,3 +598,10 @@ export const emailSchemaRefinement = (value: string) => { const emailRegex = /^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i; return emailRegex.test(value); }; + +export const ZVerifyCodeInputSchema = z.object({ + email: z.string().email(), + code: z.string(), +}); + +export type ZVerifyCodeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/auth/_router.tsx b/packages/trpc/server/routers/viewer/auth/_router.tsx index 2636e77d14..b82b797d3c 100644 --- a/packages/trpc/server/routers/viewer/auth/_router.tsx +++ b/packages/trpc/server/routers/viewer/auth/_router.tsx @@ -1,12 +1,18 @@ +import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils"; + import authedProcedure from "../../../procedures/authedProcedure"; +import publicProcedure from "../../../procedures/publicProcedure"; import { router } from "../../../trpc"; import { ZChangePasswordInputSchema } from "./changePassword.schema"; +import { ZSendVerifyEmailCodeSchema } from "./sendVerifyEmailCode.schema"; import { ZVerifyPasswordInputSchema } from "./verifyPassword.schema"; type AuthRouterHandlerCache = { changePassword?: typeof import("./changePassword.handler").changePasswordHandler; verifyPassword?: typeof import("./verifyPassword.handler").verifyPasswordHandler; + verifyCodeUnAuthenticated?: typeof import("./verifyCodeUnAuthenticated.handler").verifyCodeUnAuthenticatedHandler; resendVerifyEmail?: typeof import("./resendVerifyEmail.handler").resendVerifyEmail; + sendVerifyEmailCode?: typeof import("./sendVerifyEmailCode.handler").sendVerifyEmailCodeHandler; }; const UNSTABLE_HANDLER_CACHE: AuthRouterHandlerCache = {}; @@ -47,6 +53,41 @@ export const authRouter = router({ input, }); }), + + verifyCodeUnAuthenticated: publicProcedure.input(ZVerifyCodeInputSchema).mutation(async ({ input }) => { + if (!UNSTABLE_HANDLER_CACHE.verifyCodeUnAuthenticated) { + UNSTABLE_HANDLER_CACHE.verifyCodeUnAuthenticated = await import( + "./verifyCodeUnAuthenticated.handler" + ).then((mod) => mod.verifyCodeUnAuthenticatedHandler); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.verifyCodeUnAuthenticated) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.verifyCodeUnAuthenticated({ + input, + }); + }), + + sendVerifyEmailCode: publicProcedure.input(ZSendVerifyEmailCodeSchema).mutation(async ({ input }) => { + if (!UNSTABLE_HANDLER_CACHE.sendVerifyEmailCode) { + UNSTABLE_HANDLER_CACHE.sendVerifyEmailCode = await import("./sendVerifyEmailCode.handler").then( + (mod) => mod.sendVerifyEmailCodeHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.sendVerifyEmailCode) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.sendVerifyEmailCode({ + input, + }); + }), + resendVerifyEmail: authedProcedure.mutation(async ({ ctx }) => { if (!UNSTABLE_HANDLER_CACHE.resendVerifyEmail) { UNSTABLE_HANDLER_CACHE.resendVerifyEmail = await import("./resendVerifyEmail.handler").then( diff --git a/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.handler.ts b/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.handler.ts new file mode 100644 index 0000000000..6168b527a0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.handler.ts @@ -0,0 +1,20 @@ +import { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail"; +import logger from "@calcom/lib/logger"; + +import { TSendVerifyEmailCodeSchema } from "./sendVerifyEmailCode.schema" + +type SendVerifyEmailCode = { + input: TSendVerifyEmailCodeSchema; +}; + +const log = logger.getChildLogger({ prefix: [`[[Auth] `] }); + +export const sendVerifyEmailCodeHandler = async ({ input }: SendVerifyEmailCode) => { + const email = await sendEmailVerificationByCode({ + email: input.email, + username: input.username, + language: input.language, + }); + + return email; +}; diff --git a/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.schema.ts b/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.schema.ts new file mode 100644 index 0000000000..c744178036 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/sendVerifyEmailCode.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZSendVerifyEmailCodeSchema = z.object({ + email: z.string().min(1), + username: z.string().optional(), + language: z.string().optional() +}); + +export type TSendVerifyEmailCodeSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/auth/verifyCodeUnAuthenticated.handler.ts b/packages/trpc/server/routers/viewer/auth/verifyCodeUnAuthenticated.handler.ts new file mode 100644 index 0000000000..467c936683 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/verifyCodeUnAuthenticated.handler.ts @@ -0,0 +1,27 @@ +import { createHash } from "crypto"; +import { totp } from "otplib"; + +import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +type VerifyTokenOptions = { + input: ZVerifyCodeInputSchema; +}; + +export const verifyCodeUnAuthenticatedHandler = async ({ input }: VerifyTokenOptions) => { + const { email, code } = input; + + if (!email || !code) throw new TRPCError({ code: "BAD_REQUEST" }); + + const secret = createHash("md5") + .update(email + process.env.CALENDSO_ENCRYPTION_KEY) + .digest("hex"); + + totp.options = { step: 900 }; + const isValidToken = totp.check(code, secret); + + if (!isValidToken) throw new TRPCError({ code: "BAD_REQUEST", message: "invalid_code" }); + + return isValidToken; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 4462eb4b64..b98fc38512 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -1,3 +1,5 @@ +import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils"; + import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; import { ZAdminVerifyInput } from "./adminVerify.schema"; @@ -7,7 +9,6 @@ import { ZGetMembersInput } from "./getMembers.schema"; import { ZListMembersSchema } from "./listMembers.schema"; import { ZSetPasswordSchema } from "./setPassword.schema"; import { ZUpdateInputSchema } from "./update.schema"; -import { ZVerifyCodeInputSchema } from "./verifyCode.schema"; type OrganizationsRouterHandlerCache = { create?: typeof import("./create.handler").createHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts index 2b5af78dae..e2f3a10f8c 100644 --- a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts @@ -2,12 +2,12 @@ import { createHash } from "crypto"; import { totp } from "otplib"; import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; -import type { ZVerifyCodeInputSchema } from "./verifyCode.schema"; - type VerifyCodeOptions = { ctx: { user: NonNullable; @@ -22,6 +22,10 @@ export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => { if (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" }); if (!IS_PRODUCTION) return true; + await checkRateLimitAndThrowError({ + rateLimitingType: "core", + identifier: email, + }); const secret = createHash("md5") .update(email + process.env.CALENDSO_ENCRYPTION_KEY) diff --git a/packages/trpc/server/routers/viewer/organizations/verifyCode.schema.ts b/packages/trpc/server/routers/viewer/organizations/verifyCode.schema.ts deleted file mode 100644 index 0ce16116b3..0000000000 --- a/packages/trpc/server/routers/viewer/organizations/verifyCode.schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from "zod"; - -export const ZVerifyCodeInputSchema = z.object({ - email: z.string().email(), - code: z.string(), -}); - -export type ZVerifyCodeInputSchema = z.infer;