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 <udit.07814802719@cse.mait.ac.in>

* fix: book event form

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

* fix: type error

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

* fix: type errors

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

* fix: unit tests

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

* refactor: move verifycodedialog from ui and to features/bookings

* fix: type error

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: rkreddy99 <rreddy@e2clouds.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
This commit is contained in:
Rama Krishna Reddy 2023-07-31 23:21:11 +05:30 committed by GitHub
parent fcd8de43d6
commit 5a430df5d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 546 additions and 190 deletions

View File

@ -204,6 +204,21 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
onRequiresConfirmation={setRequiresConfirmation}
/>
<hr className="border-subtle" />
<Controller
name="requiresBookerEmailVerification"
control={formMethods.control}
defaultValue={eventType.requiresBookerEmailVerification}
render={({ field: { value, onChange } }) => (
<SettingsToggle
title={t("requires_booker_email_verification")}
{...shouldLockDisableProps("requiresBookerEmailVerification")}
description={t("description_requires_booker_email_verification")}
checked={value}
onCheckedChange={(e) => onChange(e)}
/>
)}
/>
<hr className="border-subtle" />
<Controller
name="hideCalendarNotes"
control={formMethods.control}

View File

@ -217,6 +217,7 @@ export type UserPageProps = {
| "length"
| "hidden"
| "requiresConfirmation"
| "requiresBookerEmailVerification"
| "price"
| "currency"
| "recurringEvent"

View File

@ -87,6 +87,7 @@ export type FormValues = {
description: string;
disableGuests: boolean;
requiresConfirmation: boolean;
requiresBookerEmailVerification: boolean;
recurringEvent: RecurringEvent | null;
schedulingType: SchedulingType | null;
hidden: boolean;

View File

@ -19,8 +19,13 @@
"verify_email_email_button": "Verify email",
"copy_somewhere_safe": "Save this API key somewhere safe. You will not be able to view it again.",
"verify_email_email_body": "Please verify your email address by clicking the button below.",
"verify_email_by_code_email_body": "Please verify your email address by using the below code.",
"verify_email_email_link_text": "Here's the link in case you don't like clicking buttons:",
"email_verification_code": "Enter verification code",
"email_verification_code_placeholder": "Enter verification code sent to your mail",
"incorrect_email_verification_code": "Verification code is incorrect.",
"email_sent": "Email sent successfully",
"email_not_sent": "Error occurred while sending email",
"event_declined_subject": "Declined: {{title}} at {{date}}",
"event_cancelled_subject": "Canceled: {{title}} at {{date}}",
"event_request_declined": "Your event request has been declined",
@ -1965,5 +1970,7 @@
"org_team_names_example_5": "e.g. Data Analytics Team",
"org_max_team_warnings": "You will be able to add more teams later on.",
"what_is_this_meeting_about": "What is this meeting about?",
"requires_booker_email_verification": "Requires booker email verification",
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -97,7 +97,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: [],
@ -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: {} },

View File

@ -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 }

View File

@ -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<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={props.language("verify_email_subject", { appName: APP_NAME })}>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
}}>
<>{props.language("verify_email_email_header")}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{props.language("hi_user_name", { name: props.user.name })}!</>
</p>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("verify_email_by_code_email_body")}</>
<br />
<p>{props.verificationEmailCode}</p>
</p>
</div>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("happy_scheduling")}, <br />
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("the_calcom_team", { companyName: SENDER_NAME })}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@ -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";

View File

@ -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<string, unknown> {
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, "");
}
}

View File

@ -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",

View File

@ -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 };
};

View File

@ -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 <Alert severity="warning" message={t("error_booking_event")} />;
if (event.isLoading || !event.data) return <FormSkeleton />;
if (!timeslot)
@ -338,6 +372,9 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
return <Alert severity="warning" message={t("error_booking_event")} />;
}
const renderConfirmNotVerifyEmailButtonCond =
!eventType?.requiresBookerEmailVerification || (email && verifiedEmail && verifiedEmail === email);
return (
<div className="flex h-full flex-col">
<Form
@ -350,7 +387,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
setFormValues(values);
}}
form={bookingForm}
handleSubmit={bookEvent}
handleSubmit={renderConfirmNotVerifyEmailButtonCond ? bookEvent : verifyEmail}
noValidate>
<BookingFields
isDynamicGroupBooking={!!(username && username.indexOf("+") > -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")}
</Button>
</div>
</Form>
<VerifyCodeDialog
isOpenDialog={isEmailVerificationModalVisible}
setIsOpenDialog={setEmailVerificationModalVisible}
email={email}
onSuccess={() => {
setVerifiedEmail(email);
setEmailVerificationModalVisible(false);
}}
isUserSessionRequiredToVerify={false}
/>
</div>
);
};

View File

@ -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<BookerStore>((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,
]);
};

View File

@ -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<SetStateAction<boolean>>;
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 (
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
onChange("");
setError("");
setIsOpenDialog(open);
}}>
<DialogContent className="sm:max-w-md">
<div className="flex flex-row">
<div className="w-full">
<DialogHeader title={t("verify_your_email")} subtitle={t("enter_digit_code", { email })} />
<Label htmlFor="code">{t("code")}</Label>
<div className="flex flex-row justify-between">
<Input
className={digitClassName}
name="2fa1"
inputMode="decimal"
{...digits[0]}
autoFocus
autoComplete="one-time-code"
/>
<Input className={digitClassName} name="2fa2" inputMode="decimal" {...digits[1]} />
<Input className={digitClassName} name="2fa3" inputMode="decimal" {...digits[2]} />
<Input className={digitClassName} name="2fa4" inputMode="decimal" {...digits[3]} />
<Input className={digitClassName} name="2fa5" inputMode="decimal" {...digits[4]} />
<Input className={digitClassName} name="2fa6" inputMode="decimal" {...digits[5]} />
</div>
{error && (
<div className="mt-2 flex items-center gap-x-2 text-sm text-red-700">
<div>
<Info className="h-3 w-3" />
</div>
<p>{error}</p>
</div>
)}
<DialogFooter>
<DialogClose />
<Button
loading={isLoading}
disabled={isLoading}
onClick={() => {
setError("");
if (value === "") {
setError("The code is a required field");
} else {
setIsLoading(true);
if (isUserSessionRequiredToVerify) {
verifyCodeMutationUserSessionRequired.mutate({
code: value,
email,
});
} else {
verifyCodeMutationUserSessionNotRequired.mutate({
code: value,
email,
});
}
}
}}>
{t("verify")}
</Button>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -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,
},
},

View File

@ -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<SetStateAction<boolean>>;
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 (
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
onChange("");
setError("");
setIsOpenDialog(open);
}}>
<DialogContent className="sm:max-w-md">
<div className="flex flex-row">
<div className="w-full">
<DialogHeader title={t("verify_your_email")} subtitle={t("enter_digit_code", { email })} />
<Label htmlFor="code">{t("code")}</Label>
<div className="flex flex-row justify-between">
<Input
className={digitClassName}
name="2fa1"
inputMode="decimal"
{...digits[0]}
autoFocus
autoComplete="one-time-code"
/>
<Input className={digitClassName} name="2fa2" inputMode="decimal" {...digits[1]} />
<Input className={digitClassName} name="2fa3" inputMode="decimal" {...digits[2]} />
<Input className={digitClassName} name="2fa4" inputMode="decimal" {...digits[3]} />
<Input className={digitClassName} name="2fa5" inputMode="decimal" {...digits[4]} />
<Input className={digitClassName} name="2fa6" inputMode="decimal" {...digits[5]} />
</div>
{error && (
<div className="mt-2 flex items-center gap-x-2 text-sm text-red-700">
<div>
<Info className="h-3 w-3" />
</div>
<p>{error}</p>
</div>
)}
<DialogFooter>
<DialogClose />
<Button
disabled={isLoading}
onClick={() => {
setError("");
if (value === "") {
setError("The code is a required field");
} else {
setIsLoading(true);
verifyCodeMutation.mutate({
code: value,
email,
});
}
}}>
{t("verify")}
</Button>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => {
const { t, i18n } = useLocale();
const router = useRouter();

View File

@ -32,6 +32,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
disableGuests: true,
metadata: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
recurringEvent: true,
price: true,
currency: true,

View File

@ -86,6 +86,7 @@ const commons = {
destinationCalendar: null,
team: null,
requiresConfirmation: false,
requiresBookerEmailVerification: false,
bookingLimits: null,
durationLimits: null,
hidden: false,

View File

@ -91,6 +91,7 @@ export default async function getEventTypeById({
periodEndDate: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
recurringEvent: true,
hideCalendarNotes: true,
disableGuests: true,

View File

@ -76,6 +76,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
hidden: false,
userId: null,
teamId: null,
requiresBookerEmailVerification: false,
eventName: faker.lorem.words(),
timeZone: null,
periodType: "UNLIMITED",

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "requiresBookerEmailVerification" BOOLEAN NOT NULL DEFAULT false;

View File

@ -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])

View File

@ -12,6 +12,7 @@ export const baseEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
price: true,
currency: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
});
export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
@ -28,6 +29,7 @@ export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
periodEndDate: true,
recurringEvent: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
metadata: true,
periodCountCalendarDays: true,
price: true,

View File

@ -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<typeof ZVerifyCodeInputSchema>;

View File

@ -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(

View File

@ -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;
};

View File

@ -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<typeof ZSendVerifyEmailCodeSchema>;

View File

@ -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;
};

View File

@ -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;

View File

@ -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<TrpcSessionUser>;
@ -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)

View File

@ -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<typeof ZVerifyCodeInputSchema>;