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:
parent
fcd8de43d6
commit
5a430df5d9
|
@ -204,6 +204,21 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
onRequiresConfirmation={setRequiresConfirmation}
|
onRequiresConfirmation={setRequiresConfirmation}
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
<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
|
<Controller
|
||||||
name="hideCalendarNotes"
|
name="hideCalendarNotes"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
|
|
|
@ -217,6 +217,7 @@ export type UserPageProps = {
|
||||||
| "length"
|
| "length"
|
||||||
| "hidden"
|
| "hidden"
|
||||||
| "requiresConfirmation"
|
| "requiresConfirmation"
|
||||||
|
| "requiresBookerEmailVerification"
|
||||||
| "price"
|
| "price"
|
||||||
| "currency"
|
| "currency"
|
||||||
| "recurringEvent"
|
| "recurringEvent"
|
||||||
|
|
|
@ -87,6 +87,7 @@ export type FormValues = {
|
||||||
description: string;
|
description: string;
|
||||||
disableGuests: boolean;
|
disableGuests: boolean;
|
||||||
requiresConfirmation: boolean;
|
requiresConfirmation: boolean;
|
||||||
|
requiresBookerEmailVerification: boolean;
|
||||||
recurringEvent: RecurringEvent | null;
|
recurringEvent: RecurringEvent | null;
|
||||||
schedulingType: SchedulingType | null;
|
schedulingType: SchedulingType | null;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
|
|
|
@ -19,8 +19,13 @@
|
||||||
"verify_email_email_button": "Verify email",
|
"verify_email_email_button": "Verify email",
|
||||||
"copy_somewhere_safe": "Save this API key somewhere safe. You will not be able to view it again.",
|
"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_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:",
|
"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_sent": "Email sent successfully",
|
||||||
|
"email_not_sent": "Error occurred while sending email",
|
||||||
"event_declined_subject": "Declined: {{title}} at {{date}}",
|
"event_declined_subject": "Declined: {{title}} at {{date}}",
|
||||||
"event_cancelled_subject": "Canceled: {{title}} at {{date}}",
|
"event_cancelled_subject": "Canceled: {{title}} at {{date}}",
|
||||||
"event_request_declined": "Your event request has been declined",
|
"event_request_declined": "Your event request has been declined",
|
||||||
|
@ -1965,5 +1970,7 @@
|
||||||
"org_team_names_example_5": "e.g. Data Analytics Team",
|
"org_team_names_example_5": "e.g. Data Analytics Team",
|
||||||
"org_max_team_warnings": "You will be able to add more teams later on.",
|
"org_max_team_warnings": "You will be able to add more teams later on.",
|
||||||
"what_is_this_meeting_about": "What is this meeting about?",
|
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,7 +97,7 @@ describe("handleChildrenEventTypes", () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const { schedulingType, id, teamId, timeZone, users, ...evType } = mockFindFirstEventType({
|
const { schedulingType, id, teamId, timeZone, users,requiresBookerEmailVerification, ...evType } = mockFindFirstEventType({
|
||||||
id: 123,
|
id: 123,
|
||||||
metadata: { managedEventConfig: {} },
|
metadata: { managedEventConfig: {} },
|
||||||
locations: [],
|
locations: [],
|
||||||
|
@ -133,7 +133,7 @@ describe("handleChildrenEventTypes", () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
// eslint-disable-next-line
|
// 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({
|
mockFindFirstEventType({
|
||||||
metadata: { managedEventConfig: {} },
|
metadata: { managedEventConfig: {} },
|
||||||
locations: [],
|
locations: [],
|
||||||
|
@ -218,7 +218,7 @@ describe("handleChildrenEventTypes", () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const { schedulingType, id, teamId, timeZone, users, ...evType } = mockFindFirstEventType({
|
const { schedulingType, id, teamId, timeZone, users,requiresBookerEmailVerification, ...evType } = mockFindFirstEventType({
|
||||||
id: 123,
|
id: 123,
|
||||||
metadata: { managedEventConfig: {} },
|
metadata: { managedEventConfig: {} },
|
||||||
locations: [],
|
locations: [],
|
||||||
|
@ -255,7 +255,7 @@ describe("handleChildrenEventTypes", () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
// eslint-disable-next-line
|
// 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({
|
mockFindFirstEventType({
|
||||||
metadata: { managedEventConfig: {} },
|
metadata: { managedEventConfig: {} },
|
||||||
locations: [],
|
locations: [],
|
||||||
|
@ -303,6 +303,7 @@ describe("handleChildrenEventTypes", () => {
|
||||||
timeZone: _timeZone,
|
timeZone: _timeZone,
|
||||||
parentId: _parentId,
|
parentId: _parentId,
|
||||||
userId: _userId,
|
userId: _userId,
|
||||||
|
requiresBookerEmailVerification,
|
||||||
...evType
|
...evType
|
||||||
} = mockFindFirstEventType({
|
} = mockFindFirstEventType({
|
||||||
metadata: { managedEventConfig: {} },
|
metadata: { managedEventConfig: {} },
|
||||||
|
|
|
@ -42,6 +42,7 @@ import OrganizerScheduledEmail from "./templates/organizer-scheduled-email";
|
||||||
import SlugReplacementEmail from "./templates/slug-replacement-email";
|
import SlugReplacementEmail from "./templates/slug-replacement-email";
|
||||||
import type { TeamInvite } from "./templates/team-invite-email";
|
import type { TeamInvite } from "./templates/team-invite-email";
|
||||||
import TeamInviteEmail from "./templates/team-invite-email";
|
import TeamInviteEmail from "./templates/team-invite-email";
|
||||||
|
import AttendeeVerifyEmail, { EmailVerifyCode } from "./templates/attendee-verify-email";
|
||||||
|
|
||||||
const sendEmail = (prepare: () => BaseEmail) => {
|
const sendEmail = (prepare: () => BaseEmail) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
@ -275,6 +276,10 @@ export const sendEmailVerificationLink = async (verificationInput: EmailVerifyLi
|
||||||
await sendEmail(() => new AccountVerifyEmail(verificationInput));
|
await sendEmail(() => new AccountVerifyEmail(verificationInput));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sendEmailVerificationCode = async (verificationInput: EmailVerifyCode) => {
|
||||||
|
await sendEmail(() => new AttendeeVerifyEmail(verificationInput));
|
||||||
|
};
|
||||||
|
|
||||||
export const sendRequestRescheduleEmail = async (
|
export const sendRequestRescheduleEmail = async (
|
||||||
calEvent: CalendarEvent,
|
calEvent: CalendarEvent,
|
||||||
metadata: { rescheduleLink: string }
|
metadata: { rescheduleLink: string }
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -24,6 +24,7 @@ export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
|
||||||
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
|
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
|
||||||
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
|
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
|
||||||
export { VerifyAccountEmail } from "./VerifyAccountEmail";
|
export { VerifyAccountEmail } from "./VerifyAccountEmail";
|
||||||
|
export { VerifyEmailByCode } from "./VerifyEmailByCode"
|
||||||
export * from "@calcom/app-store/routing-forms/emails/components";
|
export * from "@calcom/app-store/routing-forms/emails/components";
|
||||||
export { DailyVideoDownloadRecordingEmail } from "./DailyVideoDownloadRecordingEmail";
|
export { DailyVideoDownloadRecordingEmail } from "./DailyVideoDownloadRecordingEmail";
|
||||||
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";
|
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";
|
||||||
|
|
|
@ -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, "");
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ export enum ErrorCode {
|
||||||
TwoFactorSetupRequired = "two-factor-setup-required",
|
TwoFactorSetupRequired = "two-factor-setup-required",
|
||||||
SecondFactorRequired = "second-factor-required",
|
SecondFactorRequired = "second-factor-required",
|
||||||
IncorrectTwoFactorCode = "incorrect-two-factor-code",
|
IncorrectTwoFactorCode = "incorrect-two-factor-code",
|
||||||
|
IncorrectEmailVerificationCode = "incorrect_email_verification_code",
|
||||||
InternalServerError = "internal-server-error",
|
InternalServerError = "internal-server-error",
|
||||||
NewPasswordMatchesOld = "new-password-matches-old",
|
NewPasswordMatchesOld = "new-password-matches-old",
|
||||||
ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled",
|
ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled",
|
||||||
|
|
|
@ -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 { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
@ -54,3 +55,24 @@ export const sendEmailVerification = async ({ email, language, username }: Verif
|
||||||
|
|
||||||
return { ok: true, skipped: false };
|
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 };
|
||||||
|
};
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import type { TFunction } from "next-i18next";
|
import type { TFunction } from "next-i18next";
|
||||||
import { useRouter } from "next/router";
|
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 type { FieldError } from "react-hook-form";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
@ -12,6 +12,7 @@ import { z } from "zod";
|
||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { VerifyCodeDialog } from "@calcom/features/bookings/components/VerifyCodeDialog";
|
||||||
import {
|
import {
|
||||||
useTimePreferences,
|
useTimePreferences,
|
||||||
mapBookingToMutationInput,
|
mapBookingToMutationInput,
|
||||||
|
@ -29,7 +30,7 @@ import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { trpc } from "@calcom/trpc";
|
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 { Calendar } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { useBookerStore } from "../../store";
|
import { useBookerStore } from "../../store";
|
||||||
|
@ -63,6 +64,8 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
|
||||||
const formValues = useBookerStore((state) => state.formValues);
|
const formValues = useBookerStore((state) => state.formValues);
|
||||||
const setFormValues = useBookerStore((state) => state.setFormValues);
|
const setFormValues = useBookerStore((state) => state.setFormValues);
|
||||||
const seatedEventData = useBookerStore((state) => state.seatedEventData);
|
const seatedEventData = useBookerStore((state) => state.seatedEventData);
|
||||||
|
const verifiedEmail = useBookerStore((state) => state.verifiedEmail);
|
||||||
|
const setVerifiedEmail = useBookerStore((state) => state.setVerifiedEmail);
|
||||||
const isRescheduling = !!rescheduleUid && !!bookingData;
|
const isRescheduling = !!rescheduleUid && !!bookingData;
|
||||||
const event = useEvent();
|
const event = useEvent();
|
||||||
const eventType = event.data;
|
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.isError) return <Alert severity="warning" message={t("error_booking_event")} />;
|
||||||
if (event.isLoading || !event.data) return <FormSkeleton />;
|
if (event.isLoading || !event.data) return <FormSkeleton />;
|
||||||
if (!timeslot)
|
if (!timeslot)
|
||||||
|
@ -338,6 +372,9 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
|
||||||
return <Alert severity="warning" message={t("error_booking_event")} />;
|
return <Alert severity="warning" message={t("error_booking_event")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderConfirmNotVerifyEmailButtonCond =
|
||||||
|
!eventType?.requiresBookerEmailVerification || (email && verifiedEmail && verifiedEmail === email);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<Form
|
<Form
|
||||||
|
@ -350,7 +387,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
|
||||||
setFormValues(values);
|
setFormValues(values);
|
||||||
}}
|
}}
|
||||||
form={bookingForm}
|
form={bookingForm}
|
||||||
handleSubmit={bookEvent}
|
handleSubmit={renderConfirmNotVerifyEmailButtonCond ? bookEvent : verifyEmail}
|
||||||
noValidate>
|
noValidate>
|
||||||
<BookingFields
|
<BookingFields
|
||||||
isDynamicGroupBooking={!!(username && username.indexOf("+") > -1)}
|
isDynamicGroupBooking={!!(username && username.indexOf("+") > -1)}
|
||||||
|
@ -387,10 +424,24 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
|
||||||
color="primary"
|
color="primary"
|
||||||
loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading}
|
loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading}
|
||||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}>
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
<VerifyCodeDialog
|
||||||
|
isOpenDialog={isEmailVerificationModalVisible}
|
||||||
|
setIsOpenDialog={setEmailVerificationModalVisible}
|
||||||
|
email={email}
|
||||||
|
onSuccess={() => {
|
||||||
|
setVerifiedEmail(email);
|
||||||
|
setEmailVerificationModalVisible(false);
|
||||||
|
}}
|
||||||
|
isUserSessionRequiredToVerify={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -22,6 +22,7 @@ type StoreInitializeType = {
|
||||||
bookingUid?: string | null;
|
bookingUid?: string | null;
|
||||||
isTeamEvent?: boolean;
|
isTeamEvent?: boolean;
|
||||||
bookingData?: GetBookingType | null | undefined;
|
bookingData?: GetBookingType | null | undefined;
|
||||||
|
verifiedEmail?: string | null;
|
||||||
rescheduleUid?: string | null;
|
rescheduleUid?: string | null;
|
||||||
seatReferenceUid?: string;
|
seatReferenceUid?: string;
|
||||||
org?: string | null;
|
org?: string | null;
|
||||||
|
@ -41,6 +42,12 @@ export type BookerStore = {
|
||||||
username: string | null;
|
username: string | null;
|
||||||
eventSlug: string | null;
|
eventSlug: string | null;
|
||||||
eventId: number | 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.
|
* Current month being viewed. Format is YYYY-MM.
|
||||||
*/
|
*/
|
||||||
|
@ -173,6 +180,10 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
|
||||||
username: null,
|
username: null,
|
||||||
eventSlug: null,
|
eventSlug: null,
|
||||||
eventId: null,
|
eventId: null,
|
||||||
|
verifiedEmail: null,
|
||||||
|
setVerifiedEmail: (email: string | null) => {
|
||||||
|
set({ verifiedEmail: email });
|
||||||
|
},
|
||||||
month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"),
|
month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"),
|
||||||
setMonth: (month: string | null) => {
|
setMonth: (month: string | null) => {
|
||||||
set({ month, selectedTimeslot: null });
|
set({ month, selectedTimeslot: null });
|
||||||
|
@ -268,6 +279,7 @@ export const useInitializeBookerStore = ({
|
||||||
eventId,
|
eventId,
|
||||||
rescheduleUid = null,
|
rescheduleUid = null,
|
||||||
bookingData = null,
|
bookingData = null,
|
||||||
|
verifiedEmail = null,
|
||||||
layout,
|
layout,
|
||||||
isTeamEvent,
|
isTeamEvent,
|
||||||
org,
|
org,
|
||||||
|
@ -284,6 +296,7 @@ export const useInitializeBookerStore = ({
|
||||||
layout,
|
layout,
|
||||||
isTeamEvent,
|
isTeamEvent,
|
||||||
org,
|
org,
|
||||||
|
verifiedEmail,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
initializeStore,
|
initializeStore,
|
||||||
|
@ -296,5 +309,6 @@ export const useInitializeBookerStore = ({
|
||||||
bookingData,
|
bookingData,
|
||||||
layout,
|
layout,
|
||||||
isTeamEvent,
|
isTeamEvent,
|
||||||
|
verifiedEmail,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -259,6 +259,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||||
periodDays: true,
|
periodDays: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
|
requiresBookerEmailVerification: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
|
@ -2384,6 +2385,7 @@ const findBookingQuery = async (bookingId: number) => {
|
||||||
currency: true,
|
currency: true,
|
||||||
length: true,
|
length: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
|
requiresBookerEmailVerification: true,
|
||||||
price: true,
|
price: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,29 +1,16 @@
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import useDigitInput from "react-digit-input";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
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 { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import {
|
import { Button, Form, TextField, Alert } from "@calcom/ui";
|
||||||
Button,
|
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||||
Form,
|
|
||||||
TextField,
|
|
||||||
Alert,
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
Label,
|
|
||||||
Input,
|
|
||||||
} from "@calcom/ui";
|
|
||||||
import { ArrowRight, Info } from "@calcom/ui/components/icon";
|
|
||||||
|
|
||||||
function extractDomainFromEmail(email: string) {
|
function extractDomainFromEmail(email: string) {
|
||||||
let out = "";
|
let out = "";
|
||||||
|
@ -34,107 +21,6 @@ function extractDomainFromEmail(email: string) {
|
||||||
return out.split(".")[0];
|
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 }) => {
|
export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
|
@ -32,6 +32,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
|
requiresBookerEmailVerification: true,
|
||||||
recurringEvent: true,
|
recurringEvent: true,
|
||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
|
|
|
@ -86,6 +86,7 @@ const commons = {
|
||||||
destinationCalendar: null,
|
destinationCalendar: null,
|
||||||
team: null,
|
team: null,
|
||||||
requiresConfirmation: false,
|
requiresConfirmation: false,
|
||||||
|
requiresBookerEmailVerification: false,
|
||||||
bookingLimits: null,
|
bookingLimits: null,
|
||||||
durationLimits: null,
|
durationLimits: null,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
|
|
|
@ -91,6 +91,7 @@ export default async function getEventTypeById({
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
|
requiresBookerEmailVerification: true,
|
||||||
recurringEvent: true,
|
recurringEvent: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
|
|
|
@ -76,6 +76,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||||
hidden: false,
|
hidden: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
teamId: null,
|
teamId: null,
|
||||||
|
requiresBookerEmailVerification: false,
|
||||||
eventName: faker.lorem.words(),
|
eventName: faker.lorem.words(),
|
||||||
timeZone: null,
|
timeZone: null,
|
||||||
periodType: "UNLIMITED",
|
periodType: "UNLIMITED",
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "EventType" ADD COLUMN "requiresBookerEmailVerification" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -47,70 +47,71 @@ model Host {
|
||||||
}
|
}
|
||||||
|
|
||||||
model EventType {
|
model EventType {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
/// @zod.min(1)
|
/// @zod.min(1)
|
||||||
title String
|
title String
|
||||||
/// @zod.custom(imports.eventTypeSlug)
|
/// @zod.custom(imports.eventTypeSlug)
|
||||||
slug String
|
slug String
|
||||||
description String?
|
description String?
|
||||||
position Int @default(0)
|
position Int @default(0)
|
||||||
/// @zod.custom(imports.eventTypeLocations)
|
/// @zod.custom(imports.eventTypeLocations)
|
||||||
locations Json?
|
locations Json?
|
||||||
length Int
|
length Int
|
||||||
offsetStart Int @default(0)
|
offsetStart Int @default(0)
|
||||||
hidden Boolean @default(false)
|
hidden Boolean @default(false)
|
||||||
hosts Host[]
|
hosts Host[]
|
||||||
users User[] @relation("user_eventtype")
|
users User[] @relation("user_eventtype")
|
||||||
owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade)
|
owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
userId Int?
|
userId Int?
|
||||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
teamId Int?
|
teamId Int?
|
||||||
hashedLink HashedLink?
|
hashedLink HashedLink?
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
availability Availability[]
|
availability Availability[]
|
||||||
webhooks Webhook[]
|
webhooks Webhook[]
|
||||||
destinationCalendar DestinationCalendar?
|
destinationCalendar DestinationCalendar?
|
||||||
eventName String?
|
eventName String?
|
||||||
customInputs EventTypeCustomInput[]
|
customInputs EventTypeCustomInput[]
|
||||||
parentId Int?
|
parentId Int?
|
||||||
parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade)
|
parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
children EventType[] @relation("managed_eventtype")
|
children EventType[] @relation("managed_eventtype")
|
||||||
/// @zod.custom(imports.eventTypeBookingFields)
|
/// @zod.custom(imports.eventTypeBookingFields)
|
||||||
bookingFields Json?
|
bookingFields Json?
|
||||||
timeZone String?
|
timeZone String?
|
||||||
periodType PeriodType @default(UNLIMITED)
|
periodType PeriodType @default(UNLIMITED)
|
||||||
periodStartDate DateTime?
|
periodStartDate DateTime?
|
||||||
periodEndDate DateTime?
|
periodEndDate DateTime?
|
||||||
periodDays Int?
|
periodDays Int?
|
||||||
periodCountCalendarDays Boolean?
|
periodCountCalendarDays Boolean?
|
||||||
requiresConfirmation Boolean @default(false)
|
requiresConfirmation Boolean @default(false)
|
||||||
|
requiresBookerEmailVerification Boolean @default(false)
|
||||||
/// @zod.custom(imports.recurringEventType)
|
/// @zod.custom(imports.recurringEventType)
|
||||||
recurringEvent Json?
|
recurringEvent Json?
|
||||||
disableGuests Boolean @default(false)
|
disableGuests Boolean @default(false)
|
||||||
hideCalendarNotes Boolean @default(false)
|
hideCalendarNotes Boolean @default(false)
|
||||||
/// @zod.min(0)
|
/// @zod.min(0)
|
||||||
minimumBookingNotice Int @default(120)
|
minimumBookingNotice Int @default(120)
|
||||||
beforeEventBuffer Int @default(0)
|
beforeEventBuffer Int @default(0)
|
||||||
afterEventBuffer Int @default(0)
|
afterEventBuffer Int @default(0)
|
||||||
seatsPerTimeSlot Int?
|
seatsPerTimeSlot Int?
|
||||||
seatsShowAttendees Boolean? @default(false)
|
seatsShowAttendees Boolean? @default(false)
|
||||||
schedulingType SchedulingType?
|
schedulingType SchedulingType?
|
||||||
schedule Schedule? @relation(fields: [scheduleId], references: [id])
|
schedule Schedule? @relation(fields: [scheduleId], references: [id])
|
||||||
scheduleId Int?
|
scheduleId Int?
|
||||||
// price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column.
|
// 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 is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column.
|
||||||
currency String @default("usd")
|
currency String @default("usd")
|
||||||
slotInterval Int?
|
slotInterval Int?
|
||||||
/// @zod.custom(imports.EventTypeMetaDataSchema)
|
/// @zod.custom(imports.EventTypeMetaDataSchema)
|
||||||
metadata Json?
|
metadata Json?
|
||||||
/// @zod.custom(imports.successRedirectUrl)
|
/// @zod.custom(imports.successRedirectUrl)
|
||||||
successRedirectUrl String?
|
successRedirectUrl String?
|
||||||
workflows WorkflowsOnEventTypes[]
|
workflows WorkflowsOnEventTypes[]
|
||||||
/// @zod.custom(imports.intervalLimitsType)
|
/// @zod.custom(imports.intervalLimitsType)
|
||||||
bookingLimits Json?
|
bookingLimits Json?
|
||||||
/// @zod.custom(imports.intervalLimitsType)
|
/// @zod.custom(imports.intervalLimitsType)
|
||||||
durationLimits Json?
|
durationLimits Json?
|
||||||
|
|
||||||
@@unique([userId, slug])
|
@@unique([userId, slug])
|
||||||
@@unique([teamId, slug])
|
@@unique([teamId, slug])
|
||||||
|
|
|
@ -12,6 +12,7 @@ export const baseEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
|
requiresBookerEmailVerification: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||||
|
@ -28,6 +29,7 @@ export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
recurringEvent: true,
|
recurringEvent: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
|
requiresBookerEmailVerification: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
price: true,
|
price: true,
|
||||||
|
|
|
@ -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;
|
const emailRegex = /^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i;
|
||||||
return emailRegex.test(value);
|
return emailRegex.test(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ZVerifyCodeInputSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
code: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ZVerifyCodeInputSchema = z.infer<typeof ZVerifyCodeInputSchema>;
|
||||||
|
|
|
@ -1,12 +1,18 @@
|
||||||
|
import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import authedProcedure from "../../../procedures/authedProcedure";
|
import authedProcedure from "../../../procedures/authedProcedure";
|
||||||
|
import publicProcedure from "../../../procedures/publicProcedure";
|
||||||
import { router } from "../../../trpc";
|
import { router } from "../../../trpc";
|
||||||
import { ZChangePasswordInputSchema } from "./changePassword.schema";
|
import { ZChangePasswordInputSchema } from "./changePassword.schema";
|
||||||
|
import { ZSendVerifyEmailCodeSchema } from "./sendVerifyEmailCode.schema";
|
||||||
import { ZVerifyPasswordInputSchema } from "./verifyPassword.schema";
|
import { ZVerifyPasswordInputSchema } from "./verifyPassword.schema";
|
||||||
|
|
||||||
type AuthRouterHandlerCache = {
|
type AuthRouterHandlerCache = {
|
||||||
changePassword?: typeof import("./changePassword.handler").changePasswordHandler;
|
changePassword?: typeof import("./changePassword.handler").changePasswordHandler;
|
||||||
verifyPassword?: typeof import("./verifyPassword.handler").verifyPasswordHandler;
|
verifyPassword?: typeof import("./verifyPassword.handler").verifyPasswordHandler;
|
||||||
|
verifyCodeUnAuthenticated?: typeof import("./verifyCodeUnAuthenticated.handler").verifyCodeUnAuthenticatedHandler;
|
||||||
resendVerifyEmail?: typeof import("./resendVerifyEmail.handler").resendVerifyEmail;
|
resendVerifyEmail?: typeof import("./resendVerifyEmail.handler").resendVerifyEmail;
|
||||||
|
sendVerifyEmailCode?: typeof import("./sendVerifyEmailCode.handler").sendVerifyEmailCodeHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UNSTABLE_HANDLER_CACHE: AuthRouterHandlerCache = {};
|
const UNSTABLE_HANDLER_CACHE: AuthRouterHandlerCache = {};
|
||||||
|
@ -47,6 +53,41 @@ export const authRouter = router({
|
||||||
input,
|
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 }) => {
|
resendVerifyEmail: authedProcedure.mutation(async ({ ctx }) => {
|
||||||
if (!UNSTABLE_HANDLER_CACHE.resendVerifyEmail) {
|
if (!UNSTABLE_HANDLER_CACHE.resendVerifyEmail) {
|
||||||
UNSTABLE_HANDLER_CACHE.resendVerifyEmail = await import("./resendVerifyEmail.handler").then(
|
UNSTABLE_HANDLER_CACHE.resendVerifyEmail = await import("./resendVerifyEmail.handler").then(
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -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>;
|
|
@ -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;
|
||||||
|
};
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure";
|
import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure";
|
||||||
import { router } from "../../../trpc";
|
import { router } from "../../../trpc";
|
||||||
import { ZAdminVerifyInput } from "./adminVerify.schema";
|
import { ZAdminVerifyInput } from "./adminVerify.schema";
|
||||||
|
@ -7,7 +9,6 @@ import { ZGetMembersInput } from "./getMembers.schema";
|
||||||
import { ZListMembersSchema } from "./listMembers.schema";
|
import { ZListMembersSchema } from "./listMembers.schema";
|
||||||
import { ZSetPasswordSchema } from "./setPassword.schema";
|
import { ZSetPasswordSchema } from "./setPassword.schema";
|
||||||
import { ZUpdateInputSchema } from "./update.schema";
|
import { ZUpdateInputSchema } from "./update.schema";
|
||||||
import { ZVerifyCodeInputSchema } from "./verifyCode.schema";
|
|
||||||
|
|
||||||
type OrganizationsRouterHandlerCache = {
|
type OrganizationsRouterHandlerCache = {
|
||||||
create?: typeof import("./create.handler").createHandler;
|
create?: typeof import("./create.handler").createHandler;
|
||||||
|
|
|
@ -2,12 +2,12 @@ import { createHash } from "crypto";
|
||||||
import { totp } from "otplib";
|
import { totp } from "otplib";
|
||||||
|
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
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 type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import type { ZVerifyCodeInputSchema } from "./verifyCode.schema";
|
|
||||||
|
|
||||||
type VerifyCodeOptions = {
|
type VerifyCodeOptions = {
|
||||||
ctx: {
|
ctx: {
|
||||||
user: NonNullable<TrpcSessionUser>;
|
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 (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
|
||||||
|
|
||||||
if (!IS_PRODUCTION) return true;
|
if (!IS_PRODUCTION) return true;
|
||||||
|
await checkRateLimitAndThrowError({
|
||||||
|
rateLimitingType: "core",
|
||||||
|
identifier: email,
|
||||||
|
});
|
||||||
|
|
||||||
const secret = createHash("md5")
|
const secret = createHash("md5")
|
||||||
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
|
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
|
||||||
|
|
|
@ -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>;
|
|
Loading…
Reference in New Issue
Block a user