From 123ecf370033d58b0433614d6bc16d38ef7aefdc Mon Sep 17 00:00:00 2001 From: Jaideep Guntupalli <63718527+JaideepGuntupalli@users.noreply.github.com> Date: Fri, 4 Aug 2023 05:56:40 +0530 Subject: [PATCH] feat: show dialog when changing email after using Google Login (#9611) Co-authored-by: Peer Richelsen Co-authored-by: Alex van Andel Co-authored-by: alannnc Co-authored-by: zomars --- apps/web/pages/api/auth/forgot-password.ts | 69 +++------------- apps/web/pages/auth/forgot-password/index.tsx | 4 - apps/web/pages/auth/logout.tsx | 8 +- .../web/pages/settings/my-account/profile.tsx | 80 ++++++++++++++----- apps/web/public/static/locales/en/common.json | 5 ++ .../emails/templates/forgot-password-email.ts | 4 +- .../features/auth/lib/passwordResetRequest.ts | 51 ++++++++++++ packages/lib/serverConfig.ts | 2 +- .../loggedInViewer/updateProfile.handler.ts | 47 ++++++++--- .../viewer/admin/sendPasswordReset.handler.ts | 2 +- 10 files changed, 169 insertions(+), 103 deletions(-) create mode 100644 packages/features/auth/lib/passwordResetRequest.ts diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index 1125a49678..6cbe28bd20 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -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" }); } } diff --git a/apps/web/pages/auth/forgot-password/index.tsx b/apps/web/pages/auth/forgot-password/index.tsx index 445be08be7..2a6717087b 100644 --- a/apps/web/pages/auth/forgot-password/index.tsx +++ b/apps/web/pages/auth/forgot-password/index.tsx @@ -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); } diff --git a/apps/web/pages/auth/logout.tsx b/apps/web/pages/auth/logout.tsx index 154d85f9b6..59a259ad2a 100644 --- a/apps/web/pages/auth/logout.tsx +++ b/apps/web/pages/auth/logout.tsx @@ -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 (
@@ -40,7 +46,7 @@ export function Logout(props: Props) { {t("youve_been_logged_out")}
-

{t("hope_to_see_you_soon")}

+

{t(message())}

diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index 9ffa5d7c25..95dfb3249b 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -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(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) => { e.preventDefault(); @@ -157,9 +166,15 @@ const ProfileView = () => { confirmPasswordMutation.mutate({ passwordInput: password }); }; + const onConfirmAuthEmailChange = (e: Event | React.MouseEvent) => { + e.preventDefault(); + + if (tempFormValues) updateProfileMutation.mutate(tempFormValues); + }; + const onConfirmButton = (e: Event | React.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 = () => { { - 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 = () => {

{t("delete_account_confirmation_message", { appName: APP_NAME })}

- {isCALIdentityProviver && ( + {isCALIdentityProvider && ( { /> )} - {user?.twoFactorEnabled && isCALIdentityProviver && ( + {user?.twoFactorEnabled && isCALIdentityProvider && (
@@ -314,6 +333,27 @@ const ProfileView = () => { + + {/* If changing email from !CAL Login */} + + + + + + + + ); }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index cf190582f4..3e02c52e1b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/emails/templates/forgot-password-email.ts b/packages/emails/templates/forgot-password-email.ts index 15e134c9a0..6c21606cc6 100644 --- a/packages/emails/templates/forgot-password-email.ts +++ b/packages/emails/templates/forgot-password-email.ts @@ -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; diff --git a/packages/features/auth/lib/passwordResetRequest.ts b/packages/features/auth/lib/passwordResetRequest.ts new file mode 100644 index 0000000000..3c4f3509a7 --- /dev/null +++ b/packages/features/auth/lib/passwordResetRequest.ts @@ -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 => { + 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) => { + 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 }; diff --git a/packages/lib/serverConfig.ts b/packages/lib/serverConfig.ts index 33684521ba..23bf7343dd 100644 --- a/packages/lib/serverConfig.ts +++ b/packages/lib/serverConfig.ts @@ -19,7 +19,7 @@ function detectTransport(): SendmailTransport.Options | SMTPConnection.Options | }, secure: port === 465, tls: { - rejectUnauthorized: isENVDev ? false : true, + rejectUnauthorized: !isENVDev, }, }; diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts index 089fd3ad37..6431557e4a 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -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 }; }; diff --git a/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts b/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts index 69559249aa..7eb2041be1 100644 --- a/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts @@ -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";