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}
+
+
+
+
+ );
+};
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 (
+ {
+ 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 (
+
+ );
+};
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 (
-
- );
-};
-
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;