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 <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
sean-brydon
2024-02-07 10:31:00 +00:00
committed by GitHub
co-authored by Keith Williams Udit Takkar
parent e377997d49
commit 0e6f44e2b9
13 changed files with 619 additions and 20 deletions
@@ -42,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
data: {
password: hashedPassword,
emailVerified: new Date(),
},
});
} catch (e) {
+55 -6
View File
@@ -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"}`);
}
@@ -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 (
<div className="flex h-screen w-full items-center justify-center">
<Toaster position="bottom-right" />
</div>
);
}
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;
+33 -4
View File
@@ -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}>
<div className="mb-10">
<div className="mb-4 grid gap-2 md:grid-cols-2">
<div>
<span className="text-emphasis mb-2 block text-sm font-medium leading-none">
{t("old_email_address")}
</span>
<p className="text-subtle leading-none">{user.email}</p>
</div>
<div>
<span className="text-emphasis mb-2 block text-sm font-medium leading-none">
{t("new_email_address")}
</span>
<p className="text-subtle leading-none">{tempFormValues?.email}</p>
</div>
</div>
<PasswordField
data-testid="password"
name="password"
@@ -356,6 +374,7 @@ const ProfileView = () => {
</div>
<DialogFooter showDivider>
<Button
data-testId="profile-update-email-submit-button"
color="primary"
loading={confirmPasswordMutation.isPending}
onClick={(e) => onConfirmPassword(e)}>
@@ -489,7 +508,12 @@ const ProfileForm = ({
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-6">
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
<TextField
label={t("email")}
hint={t("change_email_hint")}
data-testId="profile-form-email"
{...formMethods.register("email")}
/>
</div>
<div className="mt-6">
<Label>{t("about")}</Label>
@@ -506,7 +530,12 @@ const ProfileForm = ({
</div>
</div>
<SectionBottomActions align="end">
<Button loading={isPending} disabled={isDisabled} color="primary" type="submit">
<Button
loading={isPending}
disabled={isDisabled}
color="primary"
type="submit"
data-testId="profile-submit-button">
{t("update")}
</Button>
</SectionBottomActions>
+162 -1
View File
@@ -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);
});
});
@@ -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.",
+6
View File
@@ -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 }
@@ -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<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={props.language("change_of_email", { appName: APP_NAME })}>
<p
style={{
fontWeight: 600,
fontSize: "24px",
lineHeight: "32px",
}}>
<>{props.language("change_of_email", { appName: APP_NAME })}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{props.language("hi_user_name", { name: props.user.name })}!</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("verify_email_change_description", { appName: APP_NAME })}</>
</p>
<div
style={{
marginTop: "2rem",
marginBottom: "2rem",
display: "flex",
justifyContent: "space-between",
}}>
<div
style={{
width: "100%",
}}>
<span
style={{
display: "block",
fontSize: "14px",
lineHeight: 0.5,
}}>
{props.language("old_email_address")}
</span>
<p
style={{
color: `#6B7280`,
lineHeight: 1,
fontWeight: 400,
}}>
{props.user.emailFrom}
</p>
</div>
<div
style={{
width: "100%",
}}>
<span
style={{
display: "block",
fontSize: "14px",
lineHeight: 0.5,
}}>
{props.language("new_email_address")}
</span>
<p
style={{
color: `#6B7280`,
lineHeight: 1,
fontWeight: 400,
}}>
{props.user.emailTo}
</p>
</div>
</div>
<CallToAction label={props.language("verify_email_email_button")} href={props.verificationEmailLink} />
<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>
);
};
+1
View File
@@ -32,3 +32,4 @@ export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
export { MonthlyDigestEmail } from "./MonthlyDigestEmail";
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
@@ -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<Record<string, unknown>> {
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, "");
}
}
+54 -1
View File
@@ -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 };
};
+1
View File
@@ -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(),
@@ -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"]) => {