From 0e6f44e2b9d2cee274ed7bdb016319add9bb18db Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 7 Feb 2024 18:31:00 +0800 Subject: [PATCH] feat: email confirmation on change (#13443) * feat:show old and new email in confirmation dialog * fix: update i18n * chore: update styles * wip: change email upon verification * fix: email being changed w/o need to logout * chore: tidy up - remove update sessison (WIP) will try do this serverside * wip: try new approach of serverside call to nextjs then client update * fix: tests * fix: update without logout * i18n: use i18n in toast * fix: locale ready toast * fix: restore email api template * fix: revert changes from wrong branch * fix: restore yarn.lock * feat: happy e2e path * fix: verification disabled e2e tests * fix:move toast * chore: cleanup early return * fix: await input selector * Update apps/web/pages/auth/verify-email-change.tsx * fix: feedback * fix: update the way we update session * fix: reset password -> email verified * fix: email change toast failure * tests: add tests for error path --------- Co-authored-by: Keith Williams Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- apps/web/pages/api/auth/reset-password.ts | 1 + apps/web/pages/api/auth/verify-email.ts | 61 ++++++- apps/web/pages/auth/verify-email-change.tsx | 96 +++++++++++ .../web/pages/settings/my-account/profile.tsx | 37 +++- apps/web/playwright/profile.e2e.ts | 163 +++++++++++++++++- apps/web/public/static/locales/en/common.json | 7 + packages/emails/email-manager.ts | 6 + .../src/templates/VerifyEmailChangeEmail.tsx | 103 +++++++++++ packages/emails/src/templates/index.ts | 1 + .../templates/change-account-email-verify.ts | 55 ++++++ packages/features/auth/lib/verifyEmail.ts | 55 +++++- packages/prisma/zod-utils.ts | 1 + .../loggedInViewer/updateProfile.handler.ts | 53 +++++- 13 files changed, 619 insertions(+), 20 deletions(-) create mode 100644 apps/web/pages/auth/verify-email-change.tsx create mode 100644 packages/emails/src/templates/VerifyEmailChangeEmail.tsx create mode 100644 packages/emails/templates/change-account-email-verify.ts diff --git a/apps/web/pages/api/auth/reset-password.ts b/apps/web/pages/api/auth/reset-password.ts index 57466aa203..e2d1109c68 100644 --- a/apps/web/pages/api/auth/reset-password.ts +++ b/apps/web/pages/api/auth/reset-password.ts @@ -42,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, data: { password: hashedPassword, + emailVerified: new Date(), }, }); } catch (e) { diff --git a/apps/web/pages/api/auth/verify-email.ts b/apps/web/pages/api/auth/verify-email.ts index 3890cdd6a7..1ebe572d72 100644 --- a/apps/web/pages/api/auth/verify-email.ts +++ b/apps/web/pages/api/auth/verify-email.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import dayjs from "@calcom/dayjs"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { prisma } from "@calcom/prisma"; +import { userMetadata } from "@calcom/prisma/zod-utils"; const verifySchema = z.object({ token: z.string(), @@ -26,23 +27,71 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "Token expired" }); } - const user = await prisma.user.update({ + const user = await prisma.user.findFirst({ where: { email: foundToken?.identifier, }, + }); + + if (!user) { + return res.status(401).json({ message: "Cannot find a user attached to this token" }); + } + + const userMetadataParsed = userMetadata.parse(user.metadata); + // Attach the new email and verify + if (userMetadataParsed?.emailChangeWaitingForVerification) { + // Ensure this email isnt in use + const existingUser = await prisma.user.findUnique({ + where: { email: userMetadataParsed?.emailChangeWaitingForVerification }, + select: { + id: true, + }, + }); + + if (existingUser) { + return res.status(401).json({ message: "A User already exists with this email" }); + } + + const updatedEmail = userMetadataParsed.emailChangeWaitingForVerification; + delete userMetadataParsed.emailChangeWaitingForVerification; + + // Update and re-verify + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + email: updatedEmail, + metadata: userMetadataParsed, + }, + }); + + await cleanUpVerificationTokens(foundToken.id); + + return res.status(200).json({ + updatedEmail, + }); + } + + await prisma.user.update({ + where: { + id: user.id, + }, data: { emailVerified: new Date(), }, }); + const hasCompletedOnboarding = user.completedOnboarding; + + return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`); +} + +async function cleanUpVerificationTokens(id: number) { // Delete token from DB after it has been used await prisma.verificationToken.delete({ where: { - id: foundToken?.id, + id, }, }); - - const hasCompletedOnboarding = user.completedOnboarding; - - res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`); } diff --git a/apps/web/pages/auth/verify-email-change.tsx b/apps/web/pages/auth/verify-email-change.tsx new file mode 100644 index 0000000000..e5b9d49df7 --- /dev/null +++ b/apps/web/pages/auth/verify-email-change.tsx @@ -0,0 +1,96 @@ +"use client"; + +import type { GetServerSidePropsContext } from "next"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { Toaster } from "react-hot-toast"; +import { z } from "zod"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { showToast } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +interface PageProps { + token: string; + updateSession: string; + updatedEmail: string; +} + +function VerifyEmailChange(props: PageProps) { + const { update } = useSession(); + const { t, isLocaleReady } = useLocale(); + const router = useRouter(); + + useEffect(() => { + async function updateSession() { + await update({ email: props.updatedEmail }); + router.push("/event-types"); + } + if (props.updateSession) { + updateSession(); + if (isLocaleReady) { + showToast(t("verify_email_change_success_toast", { email: props.updatedEmail }), "success"); + } + } else { + if (isLocaleReady) { + showToast(t("verify_email_change_failure_toast"), "error"); + } + } + // We only need this to run on inital mount. These props can't and won't change due to it being fetched serveside. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ +
+ ); +} + +const tokenSchema = z.object({ + token: z.string(), +}); + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const { token } = tokenSchema.parse(context.query); + + if (!token) { + return { + notFound: true, + }; + } + + const params = new URLSearchParams({ + token, + }); + + const response = await fetch(`${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`, { + method: "POST", + }); + + if (!response.ok) { + return { + props: { + updateSession: false, + token, + updatedEmail: false, + }, + }; + } + + const data = await response.json(); + + return { + props: { + updateSession: true, + token, + updatedEmail: data.updatedEmail ?? null, + }, + }; +} + +export default VerifyEmailChange; +VerifyEmailChange.PageWrapper = PageWrapper; diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index 986a2d5979..0a95da3305 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -93,8 +93,6 @@ const ProfileView = () => { const updateProfileMutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (res) => { await update(res); - showToast(t("settings_updated_successfully"), "success"); - // signout user only in case of password reset if (res.signOutUser && tempFormValues && res.passwordReset) { showToast(t("password_reset_email", { email: tempFormValues.email }), "success"); @@ -105,6 +103,12 @@ const ProfileView = () => { utils.viewer.shouldVerifyEmail.invalidate(); } + if (res.hasEmailBeenChanged && res.sendEmailVerification) { + showToast(t("change_of_email_toast", { email: tempFormValues?.email }), "success"); + } else { + showToast(t("settings_updated_successfully"), "success"); + } + setConfirmAuthEmailChangeWarningDialogOpen(false); setTempFormValues(null); }, @@ -342,6 +346,20 @@ const ProfileView = () => { type="creation" Icon={AlertTriangle}>
+
+
+ + {t("old_email_address")} + +

{user.email}

+
+
+ + {t("new_email_address")} + +

{tempFormValues?.email}

+
+
{
diff --git a/apps/web/playwright/profile.e2e.ts b/apps/web/playwright/profile.e2e.ts index fa98d1deae..967c40c585 100644 --- a/apps/web/playwright/profile.e2e.ts +++ b/apps/web/playwright/profile.e2e.ts @@ -1,8 +1,14 @@ +import { expect } from "@playwright/test"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; + import { test } from "./lib/fixtures"; test.describe.configure({ mode: "parallel" }); -test.afterEach(({ users }) => users.deleteAll()); +test.afterEach(async ({ users }) => { + await users.deleteAll(); +}); test.describe("Teams", () => { test("Profile page is loaded for users in Organization", async ({ page, users }) => { @@ -20,3 +26,158 @@ test.describe("Teams", () => { await page.getByTestId("profile-upload-avatar").isVisible(); }); }); + +test.describe("Update Profile", () => { + test("Cannot update a users email when existing user has same email (verification enabled)", async ({ + page, + users, + prisma, + features, + }) => { + const emailVerificationEnabled = features.get("email-verification"); + // eslint-disable-next-line playwright/no-conditional-in-test, playwright/no-skipped-test + if (!emailVerificationEnabled?.enabled) test.skip(); + + const user = await users.create({ + name: "update-profile-user", + }); + + const [emailInfo, emailDomain] = user.email.split("@"); + const email = `${emailInfo}-updated@${emailDomain}`; + + await user.apiLogin(); + await page.goto("/settings/my-account/profile"); + + const emailInput = page.getByTestId("profile-form-email"); + + await emailInput.fill(email); + + await page.getByTestId("profile-submit-button").click(); + + await page.getByTestId("password").fill(user?.username ?? "Nameless User"); + + await page.getByTestId("profile-update-email-submit-button").click(); + + const toastLocator = await page.waitForSelector('[data-testId="toast-success"]'); + + const textContent = await toastLocator.textContent(); + + expect(textContent).toContain(email); + + // Instead of dealing with emails in e2e lets just get the token and navigate to it + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + identifier: user.email, + }, + }); + + const params = new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + token: verificationToken!.token, + }); + + await users.create({ + email, + }); + + const verifyUrl = `${WEBAPP_URL}/auth/verify-email-change?${params.toString()}`; + + await page.goto(verifyUrl); + + const errorLocator = await page.waitForSelector('[data-testId="toast-error"]'); + + expect(errorLocator).toBeDefined(); + + await page.goto("/settings/my-account/profile"); + const emailInputUpdated = page.getByTestId("profile-form-email"); + expect(await emailInputUpdated.inputValue()).toEqual(user.email); + }); + + test("Can update a users email (verification enabled)", async ({ page, users, prisma, features }) => { + const emailVerificationEnabled = features.get("email-verification"); + // eslint-disable-next-line playwright/no-conditional-in-test, playwright/no-skipped-test + if (!emailVerificationEnabled?.enabled) test.skip(); + + const user = await users.create({ + name: "update-profile-user", + }); + + const [emailInfo, emailDomain] = user.email.split("@"); + const email = `${emailInfo}-updated@${emailDomain}`; + + await user.apiLogin(); + await page.goto("/settings/my-account/profile"); + + const emailInput = page.getByTestId("profile-form-email"); + + await emailInput.fill(email); + + await page.getByTestId("profile-submit-button").click(); + + await page.getByTestId("password").fill(user?.username ?? "Nameless User"); + + await page.getByTestId("profile-update-email-submit-button").click(); + + const toastLocator = await page.waitForSelector('[data-testId="toast-success"]'); + + const textContent = await toastLocator.textContent(); + + expect(textContent).toContain(email); + + // Instead of dealing with emails in e2e lets just get the token and navigate to it + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + identifier: user.email, + }, + }); + + const params = new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + token: verificationToken!.token, + }); + + const verifyUrl = `${WEBAPP_URL}/auth/verify-email-change?${params.toString()}`; + + await page.goto(verifyUrl); + + const successLocator = await page.waitForSelector('[data-testId="toast-success"]'); + + expect(await successLocator.textContent()).toContain(email); + await page.goto("/settings/my-account/profile"); + const emailInputUpdated = page.getByTestId("profile-form-email"); + expect(await emailInputUpdated.inputValue()).toEqual(email); + }); + test("Can update a users email (verification disabled)", async ({ page, users, prisma, features }) => { + const emailVerificationEnabled = features.get("email-verification"); + // eslint-disable-next-line playwright/no-conditional-in-test, playwright/no-skipped-test + if (emailVerificationEnabled?.enabled) test.skip(); + + const user = await users.create({ + name: "update-profile-user", + }); + + const [emailInfo, emailDomain] = user.email.split("@"); + const email = `${emailInfo}-updated@${emailDomain}`; + + await user.apiLogin(); + await page.goto("/settings/my-account/profile"); + + const emailInput = page.getByTestId("profile-form-email"); + + await emailInput.fill(email); + + await page.getByTestId("profile-submit-button").click(); + + await page.getByTestId("password").fill(user?.username ?? "Nameless User"); + + await page.getByTestId("profile-update-email-submit-button").click(); + + const toastLocator = await page.waitForSelector('[data-testId="toast-success"]'); + + expect(await toastLocator.isVisible()).toBe(true); + + const emailInputUpdated = page.getByTestId("profile-form-email"); + + expect(await emailInputUpdated.inputValue()).toEqual(email); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3cd8c121b9..c07d8e82ed 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -13,10 +13,17 @@ "reset_password_subject": "{{appName}}: Reset password instructions", "verify_email_subject": "{{appName}}: Verify your account", "check_your_email": "Check your email", + "old_email_address":"Old Email", + "new_email_address":"New Email", "verify_email_page_body": "We've sent an email to {{email}}. It is important to verify your email address to guarantee the best email and calendar deliverability from {{appName}}.", "verify_email_banner_body": "Verify your email address to guarantee the best email and calendar deliverability", "verify_email_email_header": "Verify your email address", "verify_email_email_button": "Verify email", + "verify_email_change_description":"You have recently requested to change the email address you use to log into your {{appName}} account. Please click the button below to confirm your new email address.", + "verify_email_change_success_toast":"Updated your email to {{email}}", + "verify_email_change_failure_toast":"Failed to update email.", + "change_of_email":"Verify your new {{appName}} email address", + "change_of_email_toast":"We have sent a verification link to {{email}}. We will update your email address once you click this link.", "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.", diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 987e0b49f1..dd3b765e42 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -27,6 +27,8 @@ import AttendeeWasRequestedToRescheduleEmail from "./templates/attendee-was-requ import BookingRedirectEmailNotification from "./templates/booking-redirect-notification"; import type { IBookingRedirect } from "./templates/booking-redirect-notification"; import BrokenIntegrationEmail from "./templates/broken-integration-email"; +import type { ChangeOfEmailVerifyLink } from "./templates/change-account-email-verify"; +import ChangeOfEmailVerifyEmail from "./templates/change-account-email-verify"; import DisabledAppEmail from "./templates/disabled-app-email"; import type { Feedback } from "./templates/feedback-email"; import FeedbackEmail from "./templates/feedback-email"; @@ -337,6 +339,10 @@ export const sendEmailVerificationCode = async (verificationInput: EmailVerifyCo await sendEmail(() => new AttendeeVerifyEmail(verificationInput)); }; +export const sendChangeOfEmailVerificationLink = async (verificationInput: ChangeOfEmailVerifyLink) => { + await sendEmail(() => new ChangeOfEmailVerifyEmail(verificationInput)); +}; + export const sendRequestRescheduleEmail = async ( calEvent: CalendarEvent, metadata: { rescheduleLink: string } diff --git a/packages/emails/src/templates/VerifyEmailChangeEmail.tsx b/packages/emails/src/templates/VerifyEmailChangeEmail.tsx new file mode 100644 index 0000000000..f6f8bc739e --- /dev/null +++ b/packages/emails/src/templates/VerifyEmailChangeEmail.tsx @@ -0,0 +1,103 @@ +import type { TFunction } from "next-i18next"; + +import { APP_NAME, SENDER_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants"; + +import { BaseEmailHtml, CallToAction } from "../components"; + +export type EmailVerifyLink = { + language: TFunction; + user: { + name?: string | null; + emailFrom: string; + emailTo: string; + }; + verificationEmailLink: string; +}; + +export const VerifyEmailChangeEmail = ( + props: EmailVerifyLink & Partial> +) => { + return ( + +

+ <>{props.language("change_of_email", { appName: APP_NAME })} +

+

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

+

+ <>{props.language("verify_email_change_description", { appName: APP_NAME })} +

+
+
+ + {props.language("old_email_address")} + +

+ {props.user.emailFrom} +

+
+
+ + {props.language("new_email_address")} + +

+ {props.user.emailTo} +

+
+
+ +
+

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

+
+
+ ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index 072401fb86..070f8e66dd 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -32,3 +32,4 @@ export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail"; export { MonthlyDigestEmail } from "./MonthlyDigestEmail"; export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail"; export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification"; +export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail"; diff --git a/packages/emails/templates/change-account-email-verify.ts b/packages/emails/templates/change-account-email-verify.ts new file mode 100644 index 0000000000..5184fb057f --- /dev/null +++ b/packages/emails/templates/change-account-email-verify.ts @@ -0,0 +1,55 @@ +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 ChangeOfEmailVerifyLink = { + language: TFunction; + user: { + name?: string | null; + emailFrom: string; + emailTo: string; + }; + verificationEmailLink: string; +}; + +export default class ChangeOfEmailVerifyEmail extends BaseEmail { + changeEvent: ChangeOfEmailVerifyLink; + + constructor(changeEvent: ChangeOfEmailVerifyLink) { + super(); + this.name = "SEND_ACCOUNT_VERIFY_EMAIL"; + this.changeEvent = changeEvent; + } + + protected async getNodeMailerPayload(): Promise> { + return { + to: `${this.changeEvent.user.name} <${this.changeEvent.user.emailTo}>`, + from: `${APP_NAME} <${this.getMailerOptions().from}>`, + subject: this.changeEvent.language("change_of_email", { + appName: APP_NAME, + }), + html: await renderEmail("VerifyEmailChangeEmail", this.changeEvent), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return ` +${this.changeEvent.language("verify_email_subject", { appName: APP_NAME })} +${this.changeEvent.language("verify_email_email_header")} +${this.changeEvent.language("hi_user_name", { name: this.changeEvent.user.name })}, +${this.changeEvent.language("verify_email_change_description", { appName: APP_NAME })} +${this.changeEvent.language("old_email_address")} +${this.changeEvent.user.emailFrom}, +${this.changeEvent.language("new_email_address")} +${this.changeEvent.user.emailTo}, +${this.changeEvent.verificationEmailLink} +${this.changeEvent.language("happy_scheduling")} ${this.changeEvent.language("the_calcom_team", { + companyName: COMPANY_NAME, + })} +`.replace(/(<([^>]+)>)/gi, ""); + } +} diff --git a/packages/features/auth/lib/verifyEmail.ts b/packages/features/auth/lib/verifyEmail.ts index 01c186ba49..10bf7b35cd 100644 --- a/packages/features/auth/lib/verifyEmail.ts +++ b/packages/features/auth/lib/verifyEmail.ts @@ -1,7 +1,11 @@ import { randomBytes, createHash } from "crypto"; import { totp } from "otplib"; -import { sendEmailVerificationCode, sendEmailVerificationLink } from "@calcom/emails/email-manager"; +import { + sendEmailVerificationCode, + sendEmailVerificationLink, + sendChangeOfEmailVerificationLink, +} 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"; @@ -76,3 +80,52 @@ export const sendEmailVerificationByCode = async ({ email, language, username }: return { ok: true, skipped: false }; }; + +interface ChangeOfEmail { + user: { + username: string; + emailFrom: string; + emailTo: string; + }; + language?: string; +} + +export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOfEmail) => { + const token = randomBytes(32).toString("hex"); + const translation = await getTranslation(language ?? "en", "common"); + const flags = await getFeatureFlagMap(prisma); + + if (!flags["email-verification"]) { + log.warn("Email verification is disabled - Skipping"); + return { ok: true, skipped: true }; + } + + await checkRateLimitAndThrowError({ + rateLimitingType: "core", + identifier: user.emailFrom, + }); + + await prisma.verificationToken.create({ + data: { + identifier: user.emailFrom, // We use from as this is the email use to get the metadata from + token, + expires: new Date(Date.now() + 24 * 3600 * 1000), // +1 day + }, + }); + + const params = new URLSearchParams({ + token, + }); + + await sendChangeOfEmailVerificationLink({ + language: translation, + verificationEmailLink: `${WEBAPP_URL}/auth/verify-email-change?${params.toString()}`, + user: { + emailFrom: user.emailFrom, + emailTo: user.emailTo, + name: user.username, + }, + }); + + return { ok: true, skipped: false }; +}; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 56b9a87a16..00beaf3645 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -331,6 +331,7 @@ export const userMetadata = z }) .optional(), defaultBookerLayouts: bookerLayouts.optional(), + emailChangeWaitingForVerification: z.string().optional(), migratedToOrgFrom: z .object({ username: z.string().or(z.null()).optional(), diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts index 16f46360ea..0f0a444b05 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -5,6 +5,8 @@ import { v4 as uuidv4 } from "uuid"; import stripe from "@calcom/app-store/stripepayment/lib/server"; import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils"; import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest"; +import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; +import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; @@ -61,6 +63,8 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) const { user } = ctx; const userMetadata = handleUserMetadata({ ctx, input }); const locale = input.locale || user.locale; + const flags = await getFeatureFlagMap(prisma); + const data: Prisma.UserUpdateInput = { ...input, // DO NOT OVERWRITE AVATAR. @@ -126,23 +130,36 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) } const hasEmailBeenChanged = data.email && user.email !== data.email; - if (hasEmailBeenChanged) { - data.emailVerified = null; - } - // check if we are changing email and identity provider is not CAL const hasEmailChangedOnNonCalProvider = hasEmailBeenChanged && user.identityProvider !== IdentityProvider.CAL; const hasEmailChangedOnCalProvider = hasEmailBeenChanged && user.identityProvider === IdentityProvider.CAL; + const sendEmailVerification = flags["email-verification"]; + + if (hasEmailBeenChanged) { + if (sendEmailVerification && hasEmailChangedOnCalProvider) { + // Set metadata of the user so we can set it to this updated email once it is confirmed + data.metadata = { + ...userMetadata, + emailChangeWaitingForVerification: input.email, + }; + + // Check to ensure this email isnt in use + // Don't include email in the data payload if we need to verify + delete data.email; + } else { + log.warn("Profile Update - Email verification is disabled - Skipping"); + data.emailVerified = null; + } + } + if (hasEmailChangedOnNonCalProvider) { // Only validate if we're changing email data.identityProvider = IdentityProvider.CAL; data.identityProviderId = null; - } else if (hasEmailChangedOnCalProvider) { - // when the email changes, the user needs to sign in again. - signOutUser = true; } + // if defined AND a base 64 string, upload and set the avatar URL if (input.avatar && input.avatar.startsWith("data:image/png;base64,")) { const avatar = await resizeBase64Image(input.avatar); @@ -250,10 +267,30 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) }); } + if (updatedUser && hasEmailChangedOnCalProvider) { + await sendChangeOfEmailVerification({ + user: { + username: updatedUser.username ?? "Nameless User", + emailFrom: user.email, + // We know email has been changed here so we can use input + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + emailTo: input.email!, + }, + }); + } + // don't return avatar, we don't need it anymore. delete input.avatar; - return { ...input, signOutUser, passwordReset, avatarUrl: updatedUser.avatarUrl }; + return { + ...input, + email: sendEmailVerification && hasEmailChangedOnCalProvider ? user.email : input.email, + signOutUser, + passwordReset, + avatarUrl: updatedUser.avatarUrl, + hasEmailBeenChanged, + sendEmailVerification, + }; }; const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => {