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:
co-authored by
Keith Williams
Udit Takkar
parent
e377997d49
commit
0e6f44e2b9
@@ -42,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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"]) => {
|
||||
|
||||
Reference in New Issue
Block a user