feat: show dialog when changing email after using Google Login (#9611)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Jaideep Guntupalli 2023-08-04 05:56:40 +05:30 committed by GitHub
parent 710bfc0d7e
commit 123ecf3700
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 169 additions and 103 deletions

View File

@ -1,13 +1,9 @@
import type { ResetPasswordRequest } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { sendPasswordResetEmail } from "@calcom/emails";
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { defaultHandler } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -37,63 +33,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
});
try {
const maybeUser = await prisma.user.findUnique({
where: {
email: email.data,
},
select: {
name: true,
identityProvider: true,
email: true,
locale: true,
},
const user = await prisma.user.findUnique({
where: { email: email.data },
select: { name: true, email: true, locale: true },
});
if (!maybeUser) {
// Don't leak information about whether an email is registered or not
return res
.status(200)
.json({ message: "If this email exists in our system, you should receive a Reset email." });
}
const t = await getTranslation(maybeUser.locale ?? "en", "common");
const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({
where: {
email: maybeUser.email,
expires: {
gt: new Date(),
},
},
});
let passwordRequest: ResetPasswordRequest;
if (maybePreviousRequest && maybePreviousRequest?.length >= 1) {
passwordRequest = maybePreviousRequest[0];
} else {
const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate();
const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({
data: {
email: maybeUser.email,
expires: expiry,
},
});
passwordRequest = createdResetPasswordRequest;
}
const resetLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/forgot-password/${passwordRequest.id}`;
await sendPasswordResetEmail({
language: t,
user: maybeUser,
resetLink,
});
return res
.status(201)
.json({ message: "If this email exists in our system, you should receive a Reset email." });
// Don't leak info about whether the user exists
if (!user) return res.status(201).json({ message: "password_reset_email_sent" });
await passwordResetRequest(user);
return res.status(201).json({ message: "password_reset_email_sent" });
} catch (reason) {
// console.error(reason);
console.error(reason);
return res.status(500).json({ message: "Unable to create password reset request" });
}
}

View File

@ -3,7 +3,6 @@ import type { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { CSSProperties, SyntheticEvent } from "react";
import React from "react";
@ -20,7 +19,6 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
const [error, setError] = React.useState<{ message: string } | null>(null);
const [success, setSuccess] = React.useState(false);
const [email, setEmail] = React.useState("");
const router = useRouter();
const handleChange = (e: SyntheticEvent) => {
const target = e.target as typeof e.target & { value: string };
@ -40,8 +38,6 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
const json = await res.json();
if (!res.ok) {
setError(json);
} else if ("resetLink" in json) {
router.push(json.resetLink);
} else {
setSuccess(true);
}

View File

@ -29,6 +29,12 @@ export function Logout(props: Props) {
}, [props.query?.survey]);
const { t } = useLocale();
const message = () => {
if (props.query?.passReset === "true") return "reset_your_password";
if (props.query?.emailChange === "true") return "email_change";
return "hope_to_see_you_soon";
};
return (
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
<div className="mb-4">
@ -40,7 +46,7 @@ export function Logout(props: Props) {
{t("youve_been_logged_out")}
</h3>
<div className="mt-2">
<p className="text-subtle text-sm">{t("hope_to_see_you_soon")}</p>
<p className="text-subtle text-sm">{t(message())}</p>
</div>
</div>
</div>

View File

@ -1,15 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { signOut } from "next-auth/react";
import { useSession } from "next-auth/react";
import { signOut, useSession } from "next-auth/react";
import type { BaseSyntheticEvent } from "react";
import { useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { APP_NAME } from "@calcom/lib/constants";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
@ -26,6 +24,7 @@ import {
DialogContent,
DialogFooter,
DialogTrigger,
Editor,
Form,
ImageUploader,
Label,
@ -37,7 +36,6 @@ import {
SkeletonContainer,
SkeletonText,
TextField,
Editor,
} from "@calcom/ui";
import { AlertTriangle, Trash2 } from "@calcom/ui/components/icon";
@ -83,12 +81,23 @@ const ProfileView = () => {
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const { data: avatar, isLoading: isLoadingAvatar } = trpc.viewer.avatar.useQuery();
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: (values) => {
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
showToast(t("settings_updated_successfully"), "success");
if (res.signOutUser && tempFormValues) {
if (res.passwordReset) {
showToast(t("password_reset_email", { email: tempFormValues.email }), "success");
// sign out the user to avoid unauthorized access error
await signOut({ callbackUrl: "/auth/logout?passReset=true" });
} else {
// sign out the user to avoid unauthorized access error
await signOut({ callbackUrl: "/auth/logout?emailChange=true" });
}
}
utils.viewer.me.invalidate();
utils.viewer.avatar.invalidate();
update(values);
setConfirmAuthEmailChangeWarningDialogOpen(false);
update(res);
setTempFormValues(null);
},
onError: () => {
@ -99,6 +108,8 @@ const ProfileView = () => {
const [confirmPasswordOpen, setConfirmPasswordOpen] = useState(false);
const [tempFormValues, setTempFormValues] = useState<FormValues | null>(null);
const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState("");
const [confirmAuthEmailChangeWarningDialogOpen, setConfirmAuthEmailChangeWarningDialogOpen] =
useState(false);
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
@ -119,9 +130,7 @@ const ProfileView = () => {
const confirmPasswordMutation = trpc.viewer.auth.verifyPassword.useMutation({
onSuccess() {
if (tempFormValues) {
mutation.mutate(tempFormValues);
}
if (tempFormValues) updateProfileMutation.mutate(tempFormValues);
setConfirmPasswordOpen(false);
},
onError() {
@ -148,7 +157,7 @@ const ProfileView = () => {
},
});
const isCALIdentityProviver = user?.identityProvider === IdentityProvider.CAL;
const isCALIdentityProvider = user?.identityProvider === IdentityProvider.CAL;
const onConfirmPassword = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
@ -157,9 +166,15 @@ const ProfileView = () => {
confirmPasswordMutation.mutate({ passwordInput: password });
};
const onConfirmAuthEmailChange = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
if (tempFormValues) updateProfileMutation.mutate(tempFormValues);
};
const onConfirmButton = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
if (isCALIdentityProviver) {
if (isCALIdentityProvider) {
const totpCode = form.getValues("totpCode");
const password = passwordRef.current.value;
deleteMeMutation.mutate({ password, totpCode });
@ -170,7 +185,7 @@ const ProfileView = () => {
const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => {
e?.preventDefault();
if (isCALIdentityProviver) {
if (isCALIdentityProvider) {
const password = passwordRef.current.value;
deleteMeMutation.mutate({ password, totpCode });
} else {
@ -209,13 +224,17 @@ const ProfileView = () => {
<ProfileForm
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
isLoading={mutation.isLoading}
isLoading={updateProfileMutation.isLoading}
onSubmit={(values) => {
if (values.email !== user.email && isCALIdentityProviver) {
if (values.email !== user.email && isCALIdentityProvider) {
setTempFormValues(values);
setConfirmPasswordOpen(true);
} else if (values.email !== user.email && !isCALIdentityProvider) {
setTempFormValues(values);
// Opens a dialog warning the change
setConfirmAuthEmailChangeWarningDialogOpen(true);
} else {
mutation.mutate(values);
updateProfileMutation.mutate(values);
}
}}
extraField={
@ -253,7 +272,7 @@ const ProfileView = () => {
<p className="text-default mb-4">
{t("delete_account_confirmation_message", { appName: APP_NAME })}
</p>
{isCALIdentityProviver && (
{isCALIdentityProvider && (
<PasswordField
data-testid="password"
name="password"
@ -265,7 +284,7 @@ const ProfileView = () => {
/>
)}
{user?.twoFactorEnabled && isCALIdentityProviver && (
{user?.twoFactorEnabled && isCALIdentityProvider && (
<Form handleSubmit={onConfirm} className="pb-4" form={form}>
<TwoFactor center={false} />
</Form>
@ -314,6 +333,27 @@ const ProfileView = () => {
</DialogFooter>
</DialogContent>
</Dialog>
{/* If changing email from !CAL Login */}
<Dialog
open={confirmAuthEmailChangeWarningDialogOpen}
onOpenChange={setConfirmAuthEmailChangeWarningDialogOpen}>
<DialogContent
title={t("confirm_auth_change")}
description={t("confirm_auth_email_change")}
type="creation"
Icon={AlertTriangle}>
<DialogFooter>
<Button
color="primary"
disabled={updateProfileMutation.isLoading}
onClick={(e) => onConfirmAuthEmailChange(e)}>
{t("confirm")}
</Button>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -84,6 +84,7 @@
"event_awaiting_approval_recurring": "A recurring event is waiting for your approval",
"someone_requested_an_event": "Someone has requested to schedule an event on your calendar.",
"someone_requested_password_reset": "Someone has requested a link to change your password.",
"password_reset_email_sent": "If this email exists in our system, you should receive a reset email.",
"password_reset_instructions": "If you didn't request this, you can safely ignore this email and your password will not be changed.",
"event_awaiting_approval_subject": "Awaiting Approval: {{title}} at {{date}}",
"event_still_awaiting_approval": "An event is still waiting for your approval",
@ -223,6 +224,10 @@
"already_have_an_account": "Already have an account?",
"create_account": "Create Account",
"confirm_password": "Confirm password",
"confirm_auth_change": "This will change the way you log in",
"confirm_auth_email_change": "Changing the email address will disconnect your current authentication method to log in to Cal.com. We will ask you to verify your new email address. Moving forward, you will be logged out and use your new email address to log in instead of your current authentication method after setting your password by following the instructions that will be sent to your mail.",
"reset_your_password": "Set your new password with the instructions sent to your email address.",
"email_change": "Log back in with your new email address and password.",
"create_your_account": "Create your account",
"sign_up": "Sign up",
"youve_been_logged_out": "You've been logged out",

View File

@ -1,4 +1,4 @@
import { TFunction } from "next-i18next";
import type { TFunction } from "next-i18next";
import { APP_NAME } from "@calcom/lib/constants";
@ -14,8 +14,6 @@ export type PasswordReset = {
resetLink: string;
};
export const PASSWORD_RESET_EXPIRY_HOURS = 6;
export default class ForgotPasswordEmail extends BaseEmail {
passwordEvent: PasswordReset;

View File

@ -0,0 +1,51 @@
import type { User } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import { sendPasswordResetEmail } from "@calcom/emails";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
export const PASSWORD_RESET_EXPIRY_HOURS = 6;
const RECENT_MAX_ATTEMPTS = 3;
const RECENT_PERIOD_IN_MINUTES = 5;
const createPasswordReset = async (email: string): Promise<string> => {
const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate();
const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({
data: {
email,
expires: expiry,
},
});
return `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/forgot-password/${createdResetPasswordRequest.id}`;
};
const guardAgainstTooManyPasswordResets = async (email: string) => {
const recentPasswordRequestsCount = await prisma.resetPasswordRequest.count({
where: {
email,
createdAt: {
gt: dayjs().subtract(RECENT_PERIOD_IN_MINUTES, "minutes").toDate(),
},
},
});
if (recentPasswordRequestsCount >= RECENT_MAX_ATTEMPTS) {
throw new Error("Too many password reset attempts. Please try again later.");
}
};
const passwordResetRequest = async (user: Pick<User, "email" | "name" | "locale">) => {
const { email } = user;
const t = await getTranslation(user.locale ?? "en", "common");
await guardAgainstTooManyPasswordResets(email);
const resetLink = await createPasswordReset(email);
// send email in user language
await sendPasswordResetEmail({
language: t,
user,
resetLink,
});
};
export { passwordResetRequest };

View File

@ -19,7 +19,7 @@ function detectTransport(): SendmailTransport.Options | SMTPConnection.Options |
},
secure: port === 465,
tls: {
rejectUnauthorized: isENVDev ? false : true,
rejectUnauthorized: !isENVDev,
},
};

View File

@ -1,8 +1,9 @@
import type { Prisma } from "@prisma/client";
import type { NextApiResponse, GetServerSidePropsContext } from "next";
import type { GetServerSidePropsContext, NextApiResponse } from "next";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils";
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { getTranslation } from "@calcom/lib/server";
import { checkUsername } from "@calcom/lib/server/checkUsername";
@ -11,6 +12,7 @@ import slugify from "@calcom/lib/slugify";
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import { prisma } from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@ -33,6 +35,9 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
metadata: input.metadata as Prisma.InputJsonValue,
};
// some actions can invalidate a user session.
let signOutUser = false;
let passwordReset = false;
let isPremiumUsername = false;
const layoutError = validateBookerLayouts(input?.metadata?.defaultBookerLayouts || null);
@ -56,16 +61,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
if (input.avatar) {
data.avatar = await resizeBase64Image(input.avatar);
}
const userToUpdate = await prisma.user.findUnique({
where: {
id: user.id,
},
});
if (!userToUpdate) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
const metadata = userMetadata.parse(userToUpdate.metadata);
const metadata = userMetadata.parse(user.metadata);
const isPremium = metadata?.isPremium;
if (isPremiumUsername) {
@ -98,12 +94,26 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
});
}
}
const hasEmailBeenChanged = userToUpdate.email !== data.email;
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;
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;
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
@ -113,12 +123,23 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
id: true,
username: true,
email: true,
identityProvider: true,
identityProviderId: true,
metadata: true,
name: true,
createdDate: true,
locale: true,
},
});
if (hasEmailChangedOnNonCalProvider) {
// Because the email has changed, we are now attempting to use the CAL provider-
// which has no password yet. We have to send the reset password email.
await passwordResetRequest(updatedUser);
signOutUser = true;
passwordReset = true;
}
// Sync Services
await syncServicesUpdateWebUser(updatedUser);
@ -154,5 +175,5 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
.then(() => console.info("Booking pages revalidated"))
.catch((e) => console.error(e));
}*/
return input;
return { ...input, signOutUser, passwordReset };
};

View File

@ -1,6 +1,6 @@
import dayjs from "@calcom/dayjs";
import { sendPasswordResetEmail } from "@calcom/emails";
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/features/auth/lib/passwordResetRequest";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";