From 92a919069d50b9bd1424809c92ad1e8437847bbc Mon Sep 17 00:00:00 2001 From: Anwar Sadath <37188392+asadath1395@users.noreply.github.com> Date: Sun, 3 Mar 2024 03:59:40 +0530 Subject: [PATCH] feat: Implement secondary email (#13678) * Implement secondary email * Fix already existing tests failing * Added tests for secondary email feature * Skip email verification if user is changing the primary email to a verified secondary email * Fix type errors in tests * Fix email becoming unverified when switched between primary and secondary email * Added a check to prevent prisma error from throwing up due to duplicate records * Improved error handling when adding a secondary email * Add test for resend verification email flow for secondary emails and validation of invite link * Add a new column to link secondary emails with verification tokens * Fix failing to update email to an unverified secondary email of the same user * Fix failing tests * Change text of resend verification email * Add ability to use the verified secondary emails to get the event details to * Fix type errors * Fix failing e2e tests * Fix failing unit tests * Fix failing type checks * Fix secondary verification email subject * Fix failing e2e tests --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Peer Richelsen --- .../components/eventtype/EventAdvancedTab.tsx | 34 +++ .../settings/CustomEmailTextField.tsx | 127 +++++++++ .../settings/SecondaryEmailConfirmModal.tsx | 31 +++ .../settings/SecondaryEmailModal.tsx | 77 ++++++ .../views/event-types-single-view.tsx | 2 + apps/web/pages/api/auth/verify-email.ts | 54 +++- .../web/pages/settings/my-account/profile.tsx | 194 +++++++++++++- apps/web/playwright/fixtures/users.ts | 1 + apps/web/playwright/profile.e2e.ts | 244 +++++++++++++++++- apps/web/public/static/locales/en/common.json | 7 + .../test/lib/handleChildrenEventTypes.test.ts | 5 + .../app-store/ics-feedcalendar/config.json | 2 +- .../emails/templates/account-verify-email.ts | 6 +- packages/features/auth/lib/verifyEmail.ts | 10 +- .../features/bookings/lib/handleNewBooking.ts | 19 +- packages/lib/defaultEvents.ts | 2 + packages/lib/getEventTypeById.ts | 1 + packages/lib/test/builder.ts | 1 + .../migration.sql | 21 ++ .../migration.sql | 8 + .../migration.sql | 8 + packages/prisma/schema.prisma | 44 +++- .../server/routers/loggedInViewer/_router.tsx | 16 ++ .../addSecondaryEmail.handler.ts | 57 ++++ .../addSecondaryEmail.schema.ts | 7 + .../routers/loggedInViewer/me.handler.ts | 14 + .../loggedInViewer/updateProfile.handler.ts | 130 ++++++++-- .../loggedInViewer/updateProfile.schema.ts | 9 + .../server/routers/viewer/auth/_router.tsx | 5 +- .../viewer/auth/resendVerifyEmail.handler.ts | 34 ++- .../viewer/auth/resendVerifyEmail.schema.ts | 9 + .../viewer/eventTypes/update.handler.ts | 24 ++ packages/ui/components/form/index.ts | 1 + .../components/form/inputs/HintOrErrors.tsx | 14 +- .../ui/components/form/inputs/InputError.tsx | 14 + packages/ui/index.tsx | 1 + 36 files changed, 1160 insertions(+), 73 deletions(-) create mode 100644 apps/web/components/settings/CustomEmailTextField.tsx create mode 100644 apps/web/components/settings/SecondaryEmailConfirmModal.tsx create mode 100644 apps/web/components/settings/SecondaryEmailModal.tsx create mode 100644 packages/prisma/migrations/20240213081819_add_secondary_email/migration.sql create mode 100644 packages/prisma/migrations/20240221051147_link_secondary_email_with_token/migration.sql create mode 100644 packages/prisma/migrations/20240222120917_link_secondary_emails_with_events/migration.sql create mode 100644 packages/trpc/server/routers/loggedInViewer/addSecondaryEmail.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/addSecondaryEmail.schema.ts create mode 100644 packages/trpc/server/routers/viewer/auth/resendVerifyEmail.schema.ts create mode 100644 packages/ui/components/form/inputs/InputError.tsx diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 78f4a5c37e..5795d3a426 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -27,8 +27,10 @@ import { trpc } from "@calcom/trpc/react"; import { Alert, Button, + Badge, CheckboxField, Label, + SelectField, SettingsToggle, showToast, Switch, @@ -122,6 +124,16 @@ export const EventAdvancedTab = ({ eventType, team }: Pick setShowEventNameTip(false); const displayDestinationCalendarSelector = !!connectedCalendarsQuery.data?.connectedCalendars.length && !team; + const verifiedSecondaryEmails = [ + { + label: user?.email || "", + value: -1, + }, + ...(user?.secondaryEmails || []) + .filter((secondaryEmail) => !!secondaryEmail.emailVerified) + .map((secondaryEmail) => ({ label: secondaryEmail.email, value: secondaryEmail.id })), + ]; + const selectedSecondaryEmailId = formMethods.getValues("secondaryEmailId") || -1; return (
@@ -198,6 +210,28 @@ export const EventAdvancedTab = ({ eventType, team }: Pick
)} + {!useEventTypeDestinationCalendarEmail && verifiedSecondaryEmails.length > 0 && ( +
+ + Default {user?.email || ""} + + ) + } + onChange={(option) => + formMethods.setValue("secondaryEmailId", option?.value, { shouldDirty: true }) + } + value={verifiedSecondaryEmails.find( + (secondaryEmail) => + selectedSecondaryEmailId !== -1 && secondaryEmail.value === selectedSecondaryEmailId + )} + options={verifiedSecondaryEmails} + /> +
+ )} diff --git a/apps/web/components/settings/CustomEmailTextField.tsx b/apps/web/components/settings/CustomEmailTextField.tsx new file mode 100644 index 0000000000..74ea0413dc --- /dev/null +++ b/apps/web/components/settings/CustomEmailTextField.tsx @@ -0,0 +1,127 @@ +import type { FormValues } from "@pages/settings/my-account/profile"; +import { useState } from "react"; +import type { UseFormReturn } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { + Badge, + TextField, + Dropdown, + DropdownItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Button, + InputError, +} from "@calcom/ui"; +import { MoreHorizontal, Flag, Trash, Send } from "@calcom/ui/components/icon"; + +type CustomEmailTextFieldProps = { + formMethods: UseFormReturn; + formMethodFieldName: keyof FormValues; + errorMessage: string; + emailVerified: boolean; + emailPrimary: boolean; + dataTestId: string; + handleChangePrimary: () => void; + handleVerifyEmail: () => void; + handleItemDelete: () => void; +}; + +const CustomEmailTextField = ({ + formMethods, + formMethodFieldName, + errorMessage, + emailVerified, + emailPrimary, + dataTestId, + handleChangePrimary, + handleVerifyEmail, + handleItemDelete, +}: CustomEmailTextFieldProps) => { + const { t } = useLocale(); + const [inputFocus, setInputFocus] = useState(false); + + return ( + <> +
+ setInputFocus(true)} + onBlur={() => setInputFocus(false)} + /> +
+ {emailPrimary && ( + + {t("primary")} + + )} + {!emailVerified && ( + + {t("unverified")} + + )} + + +
+
+ {errorMessage && } + + ); +}; + +export default CustomEmailTextField; diff --git a/apps/web/components/settings/SecondaryEmailConfirmModal.tsx b/apps/web/components/settings/SecondaryEmailConfirmModal.tsx new file mode 100644 index 0000000000..6ba0dcd955 --- /dev/null +++ b/apps/web/components/settings/SecondaryEmailConfirmModal.tsx @@ -0,0 +1,31 @@ +import { Trans } from "react-i18next"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui"; + +interface SecondaryEmailConfirmModalProps { + email: string; + onCancel: () => void; +} + +const SecondaryEmailConfirmModal = ({ email, onCancel }: SecondaryEmailConfirmModalProps) => { + const { t } = useLocale(); + + return ( + + } + type="creation" + data-testid="secondary-email-confirm-dialog"> + + + {t("done")} + + + + + ); +}; + +export default SecondaryEmailConfirmModal; diff --git a/apps/web/components/settings/SecondaryEmailModal.tsx b/apps/web/components/settings/SecondaryEmailModal.tsx new file mode 100644 index 0000000000..9bc540ed15 --- /dev/null +++ b/apps/web/components/settings/SecondaryEmailModal.tsx @@ -0,0 +1,77 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogClose, + Button, + TextField, + Form, + InputError, +} from "@calcom/ui"; + +interface SecondaryEmailModalProps { + isLoading: boolean; + errorMessage?: string; + handleAddEmail: (value: { email: string }) => void; + onCancel: () => void; + clearErrorMessage: () => void; +} + +const SecondaryEmailModal = ({ + isLoading, + errorMessage, + handleAddEmail, + onCancel, + clearErrorMessage, +}: SecondaryEmailModalProps) => { + const { t } = useLocale(); + type FormValues = { + email: string; + }; + const formMethods = useForm({ + resolver: zodResolver( + z.object({ + email: z.string().email(), + }) + ), + }); + + useEffect(() => { + // We will reset the errorMessage once the user starts modifying the email + const subscription = formMethods.watch(() => clearErrorMessage()); + return () => subscription.unsubscribe(); + }, [formMethods.watch]); + + return ( + + +
+ + {errorMessage && } + + {t("cancel")} + + + +
+
+ ); +}; + +export default SecondaryEmailModal; diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index f4c52844ee..792e12a569 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -149,6 +149,7 @@ export type FormValues = { users: EventTypeSetup["users"]; assignAllTeamMembers: boolean; useEventTypeDestinationCalendarEmail: boolean; + secondaryEmailId?: number; }; export type CustomInputParsed = typeof customInputSchema._output; @@ -302,6 +303,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { successRedirectUrl: eventType.successRedirectUrl || "", users: eventType.users, useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail, + secondaryEmailId: eventType?.secondaryEmailId || -1, children: eventType.children.map((ch) => ({ ...ch, created: true, diff --git a/apps/web/pages/api/auth/verify-email.ts b/apps/web/pages/api/auth/verify-email.ts index 1ebe572d72..1509f071f3 100644 --- a/apps/web/pages/api/auth/verify-email.ts +++ b/apps/web/pages/api/auth/verify-email.ts @@ -10,6 +10,8 @@ const verifySchema = z.object({ token: z.string(), }); +const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email"; + export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { token } = verifySchema.parse(req.query); @@ -27,6 +29,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "Token expired" }); } + // The user is verifying the secondary email + if (foundToken?.secondaryEmailId) { + await prisma.secondaryEmail.update({ + where: { + id: foundToken.secondaryEmailId, + email: foundToken?.identifier, + }, + data: { + emailVerified: new Date(), + }, + }); + + await cleanUpVerificationTokens(foundToken.id); + + return res.redirect(`${WEBAPP_URL}/settings/my-account/profile`); + } + const user = await prisma.user.findFirst({ where: { email: foundToken?.identifier, @@ -49,9 +68,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); if (existingUser) { - return res.status(401).json({ message: "A User already exists with this email" }); + return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE }); } + // Ensure this email isn't being added by another user as secondary email + const existingSecondaryUser = await prisma.secondaryEmail.findUnique({ + where: { + email: userMetadataParsed?.emailChangeWaitingForVerification, + }, + select: { + id: true, + userId: true, + }, + }); + + if (existingSecondaryUser && existingSecondaryUser.userId !== user.id) { + return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE }); + } + + const oldEmail = user.email; const updatedEmail = userMetadataParsed.emailChangeWaitingForVerification; delete userMetadataParsed.emailChangeWaitingForVerification; @@ -66,6 +101,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); + // The user is trying to update the email to an already existing unverified secondary email of his + // so we swap the emails and its verified status + if (existingSecondaryUser?.userId === user.id) { + await prisma.secondaryEmail.update({ + where: { + id: existingSecondaryUser.id, + userId: user.id, + }, + data: { + email: oldEmail, + emailVerified: user.emailVerified, + }, + }); + } + await cleanUpVerificationTokens(foundToken.id); return res.status(200).json({ @@ -87,7 +137,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`); } -async function cleanUpVerificationTokens(id: number) { +export async function cleanUpVerificationTokens(id: number) { // Delete token from DB after it has been used await prisma.verificationToken.delete({ where: { diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index a6a3cedb13..604a34a46c 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -1,10 +1,12 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +// eslint-disable-next-line no-restricted-imports +import { pick, get } from "lodash"; import { signOut, useSession } from "next-auth/react"; import type { BaseSyntheticEvent } from "react"; import React, { useRef, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, useForm, useFieldArray } from "react-hook-form"; import { z } from "zod"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; @@ -43,10 +45,13 @@ import { TextField, } from "@calcom/ui"; import { UserAvatar } from "@calcom/ui"; -import { AlertTriangle, Trash2 } from "@calcom/ui/components/icon"; +import { AlertTriangle, Trash2, Plus } from "@calcom/ui/components/icon"; import PageWrapper from "@components/PageWrapper"; import TwoFactor from "@components/auth/TwoFactor"; +import CustomEmailTextField from "@components/settings/CustomEmailTextField"; +import SecondaryEmailConfirmModal from "@components/settings/SecondaryEmailConfirmModal"; +import SecondaryEmailModal from "@components/settings/SecondaryEmailModal"; import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { @@ -72,12 +77,20 @@ interface DeleteAccountValues { totpCode: string; } -type FormValues = { +type Email = { + id: number; + email: string; + emailVerified: string | null; + emailPrimary: boolean; +}; + +export type FormValues = { username: string; avatar: string; name: string; email: string; bio: string; + secondaryEmails: Email[]; }; const ProfileView = () => { @@ -126,11 +139,27 @@ const ProfileView = () => { }, }); + const addSecondaryEmailMutation = trpc.viewer.addSecondaryEmail.useMutation({ + onSuccess: (res) => { + setShowSecondaryEmailModalOpen(false); + setNewlyAddedSecondaryEmail(res?.data?.email); + utils.viewer.me.invalidate(); + }, + onError: (error) => { + setSecondaryEmailAddErrorMessage(error?.message || ""); + }, + }); + + const resendVerifyEmailMutation = trpc.viewer.auth.resendVerifyEmail.useMutation(); + const [confirmPasswordOpen, setConfirmPasswordOpen] = useState(false); - const [tempFormValues, setTempFormValues] = useState(null); + const [tempFormValues, setTempFormValues] = useState(null); const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState(""); const [confirmAuthEmailChangeWarningDialogOpen, setConfirmAuthEmailChangeWarningDialogOpen] = useState(false); + const [showSecondaryEmailModalOpen, setShowSecondaryEmailModalOpen] = useState(false); + const [secondaryEmailAddErrorMessage, setSecondaryEmailAddErrorMessage] = useState(""); + const [newlyAddedSecondaryEmail, setNewlyAddedSecondaryEmail] = useState(undefined); const [deleteAccountOpen, setDeleteAccountOpen] = useState(false); const [hasDeleteErrors, setHasDeleteErrors] = useState(false); @@ -232,6 +261,7 @@ const ProfileView = () => { ); } + const userEmail = user.email || ""; const defaultValues = { username: user.username || "", avatar: getUserAvatarUrl({ @@ -239,8 +269,22 @@ const ProfileView = () => { profile: user.profile, }), name: user.name || "", - email: user.email || "", + email: userEmail, bio: user.bio || "", + // We add the primary email as the first item in the list + secondaryEmails: [ + { + id: 0, + email: userEmail, + emailVerified: user.emailVerified?.toString() || null, + emailPrimary: true, + }, + ...(user.secondaryEmails || []).map((secondaryEmail) => ({ + ...secondaryEmail, + emailVerified: secondaryEmail.emailVerified?.toString() || null, + emailPrimary: false, + })), + ], }; return ( @@ -269,6 +313,11 @@ const ProfileView = () => { updateProfileMutation.mutate(values); } }} + handleAddSecondaryEmail={() => setShowSecondaryEmailModalOpen(true)} + handleResendVerifyEmail={(email) => { + resendVerifyEmailMutation.mutate({ email }); + showToast(t("email_sent"), "success"); + }} extraField={
{ + + {showSecondaryEmailModalOpen && ( + { + setSecondaryEmailAddErrorMessage(""); + addSecondaryEmailMutation.mutate(values); + }} + onCancel={() => { + setSecondaryEmailAddErrorMessage(""); + setShowSecondaryEmailModalOpen(false); + }} + clearErrorMessage={() => { + addSecondaryEmailMutation.reset(); + setSecondaryEmailAddErrorMessage(""); + }} + /> + )} + {!!newlyAddedSecondaryEmail && ( + setNewlyAddedSecondaryEmail(undefined)} + /> + )} ); }; +type SecondaryEmailApiPayload = { + id: number; + email: string; + isDeleted: boolean; +}; + +type ExtendedFormValues = Omit & { + secondaryEmails: SecondaryEmailApiPayload[]; +}; + const ProfileForm = ({ defaultValues, onSubmit, + handleAddSecondaryEmail, + handleResendVerifyEmail, extraField, isPending = false, isFallbackImg, @@ -419,7 +505,9 @@ const ProfileForm = ({ userOrganization, }: { defaultValues: FormValues; - onSubmit: (values: FormValues) => void; + onSubmit: (values: ExtendedFormValues) => void; + handleAddSecondaryEmail: () => void; + handleResendVerifyEmail: (email: string) => void; extraField?: React.ReactNode; isPending: boolean; isFallbackImg: boolean; @@ -441,6 +529,14 @@ const ProfileForm = ({ }), email: z.string().email(), bio: z.string(), + secondaryEmails: z.array( + z.object({ + id: z.number(), + email: z.string().email(), + emailVerified: z.union([z.string(), z.null()]).optional(), + emailPrimary: z.boolean().optional(), + }) + ), }); const formMethods = useForm({ @@ -448,13 +544,61 @@ const ProfileForm = ({ resolver: zodResolver(profileFormSchema), }); + const { + fields: secondaryEmailFields, + remove: deleteSecondaryEmail, + replace: updateAllSecondaryEmailFields, + } = useFieldArray({ + control: formMethods.control, + name: "secondaryEmails", + keyName: "itemId", + }); + + const handleFormSubmit = (values: FormValues) => { + const changedFields = formMethods.formState.dirtyFields?.secondaryEmails || []; + const updatedValues: FormValues = { + ...values, + }; + + // If the primary email is changed, we will need to update + const primaryEmailIndex = updatedValues.secondaryEmails.findIndex( + (secondaryEmail) => secondaryEmail.emailPrimary + ); + if (primaryEmailIndex >= 0) { + // Add the new updated value as primary email + updatedValues.email = updatedValues.secondaryEmails[primaryEmailIndex].email; + } + + // We will only send the emails which have already changed + const updatedEmails: Email[] = []; + changedFields.map((field, index) => { + // If the email changed and if its only secondary email, we add it for updation, the first + // item in the list is always primary email + if (field?.email && updatedValues.secondaryEmails[index]?.id) { + updatedEmails.push(updatedValues.secondaryEmails[index]); + } + }); + + const deletedEmails = (user?.secondaryEmails || []).filter( + (secondaryEmail) => !updatedValues.secondaryEmails.find((val) => val.id && val.id === secondaryEmail.id) + ); + const secondaryEmails = [ + ...updatedEmails.map((email) => ({ ...email, isDeleted: false })), + ...deletedEmails.map((email) => ({ ...email, isDeleted: true })), + ].map((secondaryEmail) => pick(secondaryEmail, ["id", "email", "isDeleted"])); + onSubmit({ + ...updatedValues, + secondaryEmails, + }); + }; + const { formState: { isSubmitting, isDirty }, } = formMethods; const isDisabled = isSubmitting || !isDirty; return ( -
+
- + + {secondaryEmailFields.map((field, index) => ( + { + const fields = secondaryEmailFields.map((secondaryField, cIndex) => ({ + ...secondaryField, + emailPrimary: cIndex === index, + })); + updateAllSecondaryEmailFields(fields); + }} + handleVerifyEmail={() => handleResendVerifyEmail(field.email)} + handleItemDelete={() => deleteSecondaryEmail(index)} + /> + ))} +
{t("change_email_hint")}
+
diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index b90d72a929..b5f85b35b6 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -521,6 +521,7 @@ export const createUsersFixture = ( // Delete all users that were tracked by email(if they were created) await prisma.user.deleteMany({ where: { email: { in: store.trackedEmails.map((e) => e.email) } } }); await prisma.team.deleteMany({ where: { id: { in: store.teams.map((org) => org.id) } } }); + await prisma.secondaryEmail.deleteMany({ where: { userId: { in: ids } } }); store.users = []; store.teams = []; store.trackedEmails = []; diff --git a/apps/web/playwright/profile.e2e.ts b/apps/web/playwright/profile.e2e.ts index fae86387f3..eb3cb66d56 100644 --- a/apps/web/playwright/profile.e2e.ts +++ b/apps/web/playwright/profile.e2e.ts @@ -1,8 +1,14 @@ import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import type { createUsersFixture } from "playwright/fixtures/users"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import type { PrismaClient } from "@calcom/prisma"; +import type { createEmailsFixture } from "./fixtures/emails"; import { test } from "./lib/fixtures"; +import { getEmailsReceivedByUser } from "./lib/testUtils"; +import { expectInvitationEmailToBeReceived } from "./team/expects"; test.describe.configure({ mode: "parallel" }); @@ -48,7 +54,7 @@ test.describe("Update Profile", () => { await user.apiLogin(); await page.goto("/settings/my-account/profile"); - const emailInput = page.getByTestId("profile-form-email"); + const emailInput = page.getByTestId("profile-form-email-0"); await emailInput.fill(email); @@ -89,7 +95,7 @@ test.describe("Update Profile", () => { expect(errorLocator).toBeDefined(); await page.goto("/settings/my-account/profile"); - const emailInputUpdated = page.getByTestId("profile-form-email"); + const emailInputUpdated = page.getByTestId("profile-form-email-0"); expect(await emailInputUpdated.inputValue()).toEqual(user.email); }); @@ -108,7 +114,7 @@ test.describe("Update Profile", () => { await user.apiLogin(); await page.goto("/settings/my-account/profile"); - const emailInput = page.getByTestId("profile-form-email"); + const emailInput = page.getByTestId("profile-form-email-0"); await emailInput.fill(email); @@ -142,9 +148,10 @@ test.describe("Update Profile", () => { await page.waitForURL("/event-types"); await page.goto("/settings/my-account/profile"); - const emailInputUpdated = await page.getByTestId("profile-form-email"); + const emailInputUpdated = await page.getByTestId("profile-form-email-0"); 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 @@ -160,7 +167,7 @@ test.describe("Update Profile", () => { await user.apiLogin(); await page.goto("/settings/my-account/profile"); - const emailInput = page.getByTestId("profile-form-email"); + const emailInput = page.getByTestId("profile-form-email-0"); await emailInput.fill(email); @@ -172,8 +179,233 @@ test.describe("Update Profile", () => { expect(await page.getByTestId("toast-success").isVisible()).toBe(true); - const emailInputUpdated = page.getByTestId("profile-form-email"); + const emailInputUpdated = page.getByTestId("profile-form-email-0"); expect(await emailInputUpdated.inputValue()).toEqual(email); }); + + const testEmailVerificationLink = async ({ + page, + prisma, + emails, + secondaryEmail, + }: { + page: Page; + prisma: PrismaClient; + emails: ReturnType; + secondaryEmail: string; + }) => { + await test.step("the user receives the correct invitation link", async () => { + await page.waitForLoadState("networkidle"); + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + identifier: secondaryEmail, + }, + }); + const inviteLink = await expectInvitationEmailToBeReceived( + page, + emails, + secondaryEmail, + "Verify your email address", + "verify-email" + ); + expect(inviteLink).toEqual(`${WEBAPP_URL}/api/auth/verify-email?token=${verificationToken?.token}`); + }); + }; + + test("Can add a new email as a secondary email", async ({ page, users, prisma, emails }) => { + const user = await users.create({ + name: "update-profile-user", + }); + + const [emailInfo, emailDomain] = user.email.split("@"); + + await user.apiLogin(); + await page.goto("/settings/my-account/profile"); + + await page.getByTestId("add-secondary-email").click(); + + const secondaryEmailAddDialog = await page.waitForSelector('[data-testid="secondary-email-add-dialog"]'); + expect(await secondaryEmailAddDialog.isVisible()).toBe(true); + + const secondaryEmail = `${emailInfo}-secondary-email@${emailDomain}`; + const secondaryEmailInput = page.getByTestId("secondary-email-input"); + await secondaryEmailInput.fill(secondaryEmail); + + await page.getByTestId("add-secondary-email-button").click(); + + const secondaryEmailConfirmDialog = await page.waitForSelector( + '[data-testid="secondary-email-confirm-dialog"]' + ); + expect(await secondaryEmailConfirmDialog.isVisible()).toBe(true); + + const textContent = await secondaryEmailConfirmDialog.textContent(); + expect(textContent).toContain(secondaryEmail); + + await page.getByTestId("secondary-email-confirm-done-button").click(); + expect(await secondaryEmailConfirmDialog.isVisible()).toBe(false); + + await test.step("the user receives the correct invitation link", async () => { + await page.waitForLoadState("networkidle"); + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + identifier: secondaryEmail, + }, + }); + const inviteLink = await expectInvitationEmailToBeReceived( + page, + emails, + secondaryEmail, + "Verify your email address", + "verify-email" + ); + expect(inviteLink?.endsWith(`/api/auth/verify-email?token=${verificationToken?.token}`)).toEqual(true); + }); + + const primaryEmail = page.getByTestId("profile-form-email-0"); + expect(await primaryEmail.inputValue()).toEqual(user.email); + + const newlyAddedSecondaryEmail = page.getByTestId("profile-form-email-1"); + expect(await newlyAddedSecondaryEmail.inputValue()).toEqual(secondaryEmail); + }); + + const createSecondaryEmail = async ({ + page, + users, + }: { + page: Page; + users: ReturnType; + }) => { + const user = await users.create({ + name: "update-profile-user", + }); + + const [emailInfo, emailDomain] = user.email.split("@"); + const email = `${emailInfo}@${emailDomain}`; + + await user.apiLogin(); + await page.goto("/settings/my-account/profile"); + + await page.getByTestId("add-secondary-email").click(); + + const secondaryEmail = `${emailInfo}-secondary-email@${emailDomain}`; + const secondaryEmailInput = await page.getByTestId("secondary-email-input"); + await secondaryEmailInput.fill(secondaryEmail); + + await page.getByTestId("add-secondary-email-button").click(); + + await page.getByTestId("secondary-email-confirm-done-button").click(); + + return { user, email, secondaryEmail }; + }; + + test("Newly added secondary email should show as Unverified", async ({ page, users }) => { + await createSecondaryEmail({ page, users }); + + expect(await page.getByTestId("profile-form-email-0-primary-badge").isVisible()).toEqual(true); + expect(await page.getByTestId("profile-form-email-0-unverified-badge").isVisible()).toEqual(false); + + expect(await page.getByTestId("profile-form-email-1-primary-badge").isVisible()).toEqual(false); + expect(await page.getByTestId("profile-form-email-1-unverified-badge").isVisible()).toEqual(true); + }); + + test("Can verify the newly added secondary email", async ({ page, users, prisma }) => { + const { secondaryEmail } = await createSecondaryEmail({ page, users }); + + expect(await page.getByTestId("profile-form-email-1-primary-badge").isVisible()).toEqual(false); + expect(await page.getByTestId("profile-form-email-1-unverified-badge").isVisible()).toEqual(true); + // Instead of dealing with emails in e2e lets just get the token and navigate to it + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + identifier: secondaryEmail, + }, + }); + + const params = new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + token: verificationToken!.token, + }); + + const verifyUrl = `${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`; + + await page.goto(verifyUrl); + + expect(await page.getByTestId("profile-form-email-1-primary-badge").isVisible()).toEqual(false); + expect(await page.getByTestId("profile-form-email-1-unverified-badge").isVisible()).toEqual(false); + }); + + test("Can delete the newly added secondary email", async ({ page, users }) => { + await createSecondaryEmail({ page, users }); + + await page.getByTestId("secondary-email-action-group-button").nth(1).click(); + await page.getByTestId("secondary-email-delete-button").click(); + + expect(await page.getByTestId("profile-form-email-1").isVisible()).toEqual(false); + }); + + test("Can make the newly added secondary email as the primary email and login", async ({ + page, + users, + prisma, + }) => { + const { secondaryEmail } = await createSecondaryEmail({ page, users }); + + // Instead of dealing with emails in e2e lets just get the token and navigate to it + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + identifier: secondaryEmail, + }, + }); + + const params = new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + token: verificationToken!.token, + }); + + const verifyUrl = `${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`; + + await page.goto(verifyUrl); + await page.getByTestId("secondary-email-action-group-button").nth(1).click(); + await page.getByTestId("secondary-email-make-primary-button").click(); + + expect(await page.getByTestId("profile-form-email-1-primary-badge").isVisible()).toEqual(true); + expect(await page.getByTestId("profile-form-email-1-unverified-badge").isVisible()).toEqual(false); + }); + + test("Can resend verification link if the secondary email is unverified", async ({ + page, + users, + prisma, + emails, + }) => { + const { secondaryEmail } = await createSecondaryEmail({ page, users }); + // When a user is created a link is sent, we will delete it manually to make sure verification link works fine + await prisma.verificationToken.deleteMany({ + where: { + identifier: secondaryEmail, + }, + }); + const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail: secondaryEmail }); + if (receivedEmails?.items?.[0]?.ID) { + await emails.deleteMessage(receivedEmails.items[0].ID); + } + + expect(await page.getByTestId("profile-form-email-1-unverified-badge").isVisible()).toEqual(true); + await page.getByTestId("secondary-email-action-group-button").nth(1).click(); + expect(await page.locator("button[data-testid=resend-verify-email-button]").isDisabled()).toEqual(false); + await page.getByTestId("resend-verify-email-button").click(); + + await testEmailVerificationLink({ page, prisma, emails, secondaryEmail }); + + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + identifier: secondaryEmail, + }, + }); + await page.goto(`${WEBAPP_URL}/api/auth/verify-email?token=${verificationToken?.token}`); + + await page.getByTestId("secondary-email-action-group-button").nth(1).click(); + expect(await page.locator("button[data-testid=resend-verify-email-button]").isVisible()).toEqual(false); + expect(await page.getByTestId("profile-form-email-1-unverified-badge").isVisible()).toEqual(false); + }); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 039908543e..ca7af9bd39 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2266,5 +2266,12 @@ "field_identifiers_as_variables": "Use field identifiers as variables for your custom event redirect", "field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})", "account_already_linked": "Account is already linked", + "primary": "Primary", + "make_primary": "Make primary", + "add_email": "Add Email", + "add_email_description": "Add an email address to replace your primary or to use as an alternative email on your event types.", + "confirm_email": "Confirm your email", + "confirm_email_description": "We sent an email to {{email}}. Click the link in the email to verify this address.", + "send_event_details_to": "Send event details to", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 835dabdb76..5e9719b792 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -112,6 +112,7 @@ describe("handleChildrenEventTypes", () => { requiresBookerEmailVerification, lockTimeZoneToggleOnBookingPage, useEventTypeDestinationCalendarEmail, + secondaryEmailId, ...evType } = mockFindFirstEventType({ id: 123, @@ -162,6 +163,7 @@ describe("handleChildrenEventTypes", () => { requiresBookerEmailVerification, lockTimeZoneToggleOnBookingPage, useEventTypeDestinationCalendarEmail, + secondaryEmailId, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -260,6 +262,7 @@ describe("handleChildrenEventTypes", () => { requiresBookerEmailVerification, lockTimeZoneToggleOnBookingPage, useEventTypeDestinationCalendarEmail, + secondaryEmailId, ...evType } = mockFindFirstEventType({ id: 123, @@ -310,6 +313,7 @@ describe("handleChildrenEventTypes", () => { requiresBookerEmailVerification, lockTimeZoneToggleOnBookingPage, useEventTypeDestinationCalendarEmail, + secondaryEmailId, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -364,6 +368,7 @@ describe("handleChildrenEventTypes", () => { requiresBookerEmailVerification, lockTimeZoneToggleOnBookingPage, useEventTypeDestinationCalendarEmail, + secondaryEmailId, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, diff --git a/packages/app-store/ics-feedcalendar/config.json b/packages/app-store/ics-feedcalendar/config.json index af5a385d94..4496ebab2a 100644 --- a/packages/app-store/ics-feedcalendar/config.json +++ b/packages/app-store/ics-feedcalendar/config.json @@ -2,7 +2,7 @@ "name": "ICS Feed", "title": "ICS Feed", "slug": "ics-feed", - "dirName":"ics-feedcalendar", + "dirName": "ics-feedcalendar", "type": "ics-feed_calendar", "logo": "icon.svg", "variant": "calendar", diff --git a/packages/emails/templates/account-verify-email.ts b/packages/emails/templates/account-verify-email.ts index 6f692898aa..6c8871ec88 100644 --- a/packages/emails/templates/account-verify-email.ts +++ b/packages/emails/templates/account-verify-email.ts @@ -12,6 +12,7 @@ export type EmailVerifyLink = { email: string; }; verificationEmailLink: string; + isSecondaryEmailVerification?: boolean; }; export default class AccountVerifyEmail extends BaseEmail { @@ -24,10 +25,13 @@ export default class AccountVerifyEmail extends BaseEmail { } protected async getNodeMailerPayload(): Promise> { + const emailSubjectKey = this.verifyAccountInput.isSecondaryEmailVerification + ? "verify_email_email_header" + : "verify_email_subject"; return { to: `${this.verifyAccountInput.user.name} <${this.verifyAccountInput.user.email}>`, from: `${APP_NAME} <${this.getMailerOptions().from}>`, - subject: this.verifyAccountInput.language("verify_email_subject", { + subject: this.verifyAccountInput.language(emailSubjectKey, { appName: APP_NAME, }), html: await renderEmail("VerifyAccountEmail", this.verifyAccountInput), diff --git a/packages/features/auth/lib/verifyEmail.ts b/packages/features/auth/lib/verifyEmail.ts index 0f50818170..8bfc1f060b 100644 --- a/packages/features/auth/lib/verifyEmail.ts +++ b/packages/features/auth/lib/verifyEmail.ts @@ -19,9 +19,15 @@ interface VerifyEmailType { username?: string; email: string; language?: string; + secondaryEmailId?: number; } -export const sendEmailVerification = async ({ email, language, username }: VerifyEmailType) => { +export const sendEmailVerification = async ({ + email, + language, + username, + secondaryEmailId, +}: VerifyEmailType) => { const token = randomBytes(32).toString("hex"); const translation = await getTranslation(language ?? "en", "common"); const emailVerification = await getFeatureFlag(prisma, "email-verification"); @@ -41,6 +47,7 @@ export const sendEmailVerification = async ({ email, language, username }: Verif identifier: email, token, expires: new Date(Date.now() + 24 * 3600 * 1000), // +1 day + secondaryEmailId: secondaryEmailId || null, }, }); @@ -55,6 +62,7 @@ export const sendEmailVerification = async ({ email, language, username }: Verif email, name: username, }, + isSecondaryEmailVerification: !!secondaryEmailId, }); return { ok: true, skipped: false }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 222dcd9bd0..e5ffe5f01a 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -251,6 +251,13 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { days: true, }, }, + secondaryEmailId: true, + secondaryEmail: { + select: { + id: true, + email: true, + }, + }, }, }); @@ -1452,6 +1459,13 @@ async function handler( ? [organizerUser.destinationCalendar] : null; + let organizerEmail = organizerUser.email || "Email-less"; + if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) { + organizerEmail = destinationCalendar[0].primaryEmail; + } else if (eventType.secondaryEmailId && eventType.secondaryEmail?.email) { + organizerEmail = eventType.secondaryEmail.email; + } + let evt: CalendarEvent = { bookerUrl, type: eventType.slug, @@ -1464,10 +1478,7 @@ async function handler( organizer: { id: organizerUser.id, name: organizerUser.name || "Nameless", - email: - eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail - ? destinationCalendar[0].primaryEmail - : organizerUser.email || "Email-less", + email: organizerEmail, username: organizerUser.username || undefined, timeZone: organizerUser.timeZone, language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" }, diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 863c29df56..41165efa70 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -102,6 +102,8 @@ const commons = { bookingFields: [], assignAllTeamMembers: false, useEventTypeDestinationCalendarEmail: false, + secondaryEmailId: null, + secondaryEmail: null, }; const dynamicEvent = { diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 2741b3ac33..682efd3d51 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -235,6 +235,7 @@ export default async function getEventTypeById({ }, }, }, + secondaryEmailId: true, }, }); diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 6865cc3881..a021183164 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -117,6 +117,7 @@ export const buildEventType = (eventType?: Partial): EventType => { bookingFields: [], parentId: null, profileId: null, + secondaryEmailId: null, ...eventType, }; }; diff --git a/packages/prisma/migrations/20240213081819_add_secondary_email/migration.sql b/packages/prisma/migrations/20240213081819_add_secondary_email/migration.sql new file mode 100644 index 0000000000..f7b843c9fc --- /dev/null +++ b/packages/prisma/migrations/20240213081819_add_secondary_email/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "SecondaryEmail" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" TIMESTAMP(3), + + CONSTRAINT "SecondaryEmail_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SecondaryEmail_userId_idx" ON "SecondaryEmail"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SecondaryEmail_email_key" ON "SecondaryEmail"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "SecondaryEmail_userId_email_key" ON "SecondaryEmail"("userId", "email"); + +-- AddForeignKey +ALTER TABLE "SecondaryEmail" ADD CONSTRAINT "SecondaryEmail_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20240221051147_link_secondary_email_with_token/migration.sql b/packages/prisma/migrations/20240221051147_link_secondary_email_with_token/migration.sql new file mode 100644 index 0000000000..2098d32402 --- /dev/null +++ b/packages/prisma/migrations/20240221051147_link_secondary_email_with_token/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "VerificationToken" ADD COLUMN "secondaryEmailId" INTEGER; + +-- CreateIndex +CREATE INDEX "VerificationToken_secondaryEmailId_idx" ON "VerificationToken"("secondaryEmailId"); + +-- AddForeignKey +ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_secondaryEmailId_fkey" FOREIGN KEY ("secondaryEmailId") REFERENCES "SecondaryEmail"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20240222120917_link_secondary_emails_with_events/migration.sql b/packages/prisma/migrations/20240222120917_link_secondary_emails_with_events/migration.sql new file mode 100644 index 0000000000..e0609e275e --- /dev/null +++ b/packages/prisma/migrations/20240222120917_link_secondary_emails_with_events/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "secondaryEmailId" INTEGER; + +-- CreateIndex +CREATE INDEX "EventType_secondaryEmailId_idx" ON "EventType"("secondaryEmailId"); + +-- AddForeignKey +ALTER TABLE "EventType" ADD CONSTRAINT "EventType_secondaryEmailId_fkey" FOREIGN KEY ("secondaryEmailId") REFERENCES "SecondaryEmail"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 1372550a62..4946fe1617 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -128,12 +128,16 @@ model EventType { assignAllTeamMembers Boolean @default(false) useEventTypeDestinationCalendarEmail Boolean @default(false) + secondaryEmailId Int? + secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) + @@unique([userId, slug]) @@unique([teamId, slug]) @@unique([userId, parentId]) @@index([userId]) @@index([teamId]) @@index([scheduleId]) + @@index([secondaryEmailId]) } model Credential { @@ -281,10 +285,11 @@ model User { bookingRedirectsTo OutOfOfficeEntry[] @relation(name: "toUser") // Used to lock the user account - locked Boolean @default(false) + locked Boolean @default(false) profiles Profile[] movedToProfileId Int? - movedToProfile Profile? @relation("moved_to_profile", fields: [movedToProfileId], references: [id], onDelete: SetNull) + movedToProfile Profile? @relation("moved_to_profile", fields: [movedToProfileId], references: [id], onDelete: SetNull) + secondaryEmails SecondaryEmail[] @@unique([email]) @@unique([email, username]) @@ -396,19 +401,22 @@ model Membership { } model VerificationToken { - id Int @id @default(autoincrement()) - identifier String - token String @unique - expires DateTime - expiresInDays Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - teamId Int? - team Team? @relation(fields: [teamId], references: [id]) + id Int @id @default(autoincrement()) + identifier String + token String @unique + expires DateTime + expiresInDays Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + teamId Int? + team Team? @relation(fields: [teamId], references: [id]) + secondaryEmailId Int? + secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id]) @@unique([identifier, token]) @@index([token]) @@index([teamId]) + @@index([secondaryEmailId]) } model InstantMeetingToken { @@ -1135,3 +1143,17 @@ model OutOfOfficeEntry { @@index([toUserId]) @@index([start, end]) } + +model SecondaryEmail { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + email String + emailVerified DateTime? + verificationTokens VerificationToken[] + eventTypes EventType[] + + @@unique([email]) + @@unique([userId, email]) + @@index([userId]) +} diff --git a/packages/trpc/server/routers/loggedInViewer/_router.tsx b/packages/trpc/server/routers/loggedInViewer/_router.tsx index ab73b1eadc..6fef653a51 100644 --- a/packages/trpc/server/routers/loggedInViewer/_router.tsx +++ b/packages/trpc/server/routers/loggedInViewer/_router.tsx @@ -1,5 +1,6 @@ import authedProcedure from "../../procedures/authedProcedure"; import { router } from "../../trpc"; +import { ZAddSecondaryEmailInputSchema } from "./addSecondaryEmail.schema"; import { ZAppByIdInputSchema } from "./appById.schema"; import { ZAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema"; import { ZAwayInputSchema } from "./away.schema"; @@ -53,6 +54,7 @@ type AppsRouterHandlerCache = { outOfOfficeCreate?: typeof import("./outOfOffice.handler").outOfOfficeCreate; outOfOfficeEntriesList?: typeof import("./outOfOffice.handler").outOfOfficeEntriesList; outOfOfficeEntryDelete?: typeof import("./outOfOffice.handler").outOfOfficeEntryDelete; + addSecondaryEmail?: typeof import("./addSecondaryEmail.handler").addSecondaryEmailHandler; }; const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {}; @@ -468,4 +470,18 @@ export const loggedInViewerRouter = router({ return UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete({ ctx, input }); }), + addSecondaryEmail: authedProcedure.input(ZAddSecondaryEmailInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.addSecondaryEmail) { + UNSTABLE_HANDLER_CACHE.addSecondaryEmail = ( + await import("./addSecondaryEmail.handler") + ).addSecondaryEmailHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.addSecondaryEmail) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.addSecondaryEmail({ ctx, input }); + }), }); diff --git a/packages/trpc/server/routers/loggedInViewer/addSecondaryEmail.handler.ts b/packages/trpc/server/routers/loggedInViewer/addSecondaryEmail.handler.ts new file mode 100644 index 0000000000..f03b2fc7ce --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/addSecondaryEmail.handler.ts @@ -0,0 +1,57 @@ +import type { GetServerSidePropsContext, NextApiResponse } from "next"; + +import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import type { TAddSecondaryEmailInputSchema } from "./addSecondaryEmail.schema"; + +type AddSecondaryEmailOptions = { + ctx: { + user: NonNullable; + res?: NextApiResponse | GetServerSidePropsContext["res"]; + }; + input: TAddSecondaryEmailInputSchema; +}; + +export const addSecondaryEmailHandler = async ({ ctx, input }: AddSecondaryEmailOptions) => { + const { user } = ctx; + + const existingPrimaryEmail = await prisma.user.findUnique({ + where: { + email: input.email, + }, + }); + + if (existingPrimaryEmail) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Email already taken" }); + } + + const existingSecondaryEmail = await prisma.secondaryEmail.findUnique({ + where: { + email: input.email, + }, + }); + + if (existingSecondaryEmail) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Email already taken" }); + } + + const updatedData = await prisma.secondaryEmail.create({ + data: { ...input, userId: user.id }, + }); + + await sendEmailVerification({ + email: updatedData.email, + username: user?.username ?? undefined, + language: user.locale, + secondaryEmailId: updatedData.id, + }); + + return { + data: updatedData, + message: "Secondary email added successfully", + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/addSecondaryEmail.schema.ts b/packages/trpc/server/routers/loggedInViewer/addSecondaryEmail.schema.ts new file mode 100644 index 0000000000..06fe66ffc8 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/addSecondaryEmail.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZAddSecondaryEmailInputSchema = z.object({ + email: z.string(), +}); + +export type TAddSecondaryEmailInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/me.handler.ts b/packages/trpc/server/routers/loggedInViewer/me.handler.ts index 184b11134b..167625f1b6 100644 --- a/packages/trpc/server/routers/loggedInViewer/me.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/me.handler.ts @@ -3,6 +3,7 @@ import type { Session } from "next-auth"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { UserRepository } from "@calcom/lib/server/repository/user"; +import prisma from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; type MeOptions = { @@ -26,6 +27,17 @@ export const meHandler = async ({ ctx }: MeOptions) => { upId: session.upId, }); + const secondaryEmails = await prisma.secondaryEmail.findMany({ + where: { + userId: user.id, + }, + select: { + id: true, + email: true, + emailVerified: true, + }, + }); + // Destructuring here only makes it more illegible // pick only the part we want to expose in the API return { @@ -33,6 +45,7 @@ export const meHandler = async ({ ctx }: MeOptions) => { name: user.name, email: user.email, emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), + emailVerified: user.emailVerified, startTime: user.startTime, endTime: user.endTime, bufferTime: user.bufferTime, @@ -66,5 +79,6 @@ export const meHandler = async ({ ctx }: MeOptions) => { username: user.profile?.username ?? user.username ?? null, profile: user.profile ?? null, profiles: allUserEnrichedProfiles, + secondaryEmails, }; }; diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts index 512bbe4096..f9ddb11518 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -1,4 +1,6 @@ import { Prisma } from "@prisma/client"; +// eslint-disable-next-line no-restricted-imports +import { keyBy } from "lodash"; import type { GetServerSidePropsContext, NextApiResponse } from "next"; import { v4 as uuidv4 } from "uuid"; @@ -65,11 +67,15 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) const locale = input.locale || user.locale; const emailVerification = await getFeatureFlag(prisma, "email-verification"); + const secondaryEmails = input?.secondaryEmails || []; + delete input.secondaryEmails; + const data: Prisma.UserUpdateInput = { ...input, // DO NOT OVERWRITE AVATAR. avatar: undefined, metadata: userMetadata, + secondaryEmails: undefined, }; // some actions can invalidate a user session. @@ -135,17 +141,39 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) hasEmailBeenChanged && user.identityProvider !== IdentityProvider.CAL; const hasEmailChangedOnCalProvider = hasEmailBeenChanged && user.identityProvider === IdentityProvider.CAL; + let secondaryEmail: + | { + id: number; + emailVerified: Date | null; + } + | null + | undefined; + const primaryEmailVerified = user.emailVerified; if (hasEmailBeenChanged) { + secondaryEmail = await prisma.secondaryEmail.findUnique({ + where: { + email: input.email, + userId: user.id, + }, + select: { + id: true, + emailVerified: true, + }, + }); if (emailVerification && 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, - }; + if (secondaryEmail?.emailVerified) { + data.emailVerified = secondaryEmail.emailVerified; + } else { + // 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; + // 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; @@ -266,28 +294,92 @@ 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!, - }, - }); + // Skip sending verification email when user tries to change his primary email to a verified secondary email + if (secondaryEmail?.emailVerified) { + secondaryEmails.push({ + id: secondaryEmail.id, + email: user.email, + isDeleted: false, + }); + } else { + 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; + if (secondaryEmails.length) { + const recordsToDelete = secondaryEmails + .filter((secondaryEmail) => secondaryEmail.isDeleted) + .map((secondaryEmail) => secondaryEmail.id); + if (recordsToDelete.length) { + await prisma.secondaryEmail.deleteMany({ + where: { + id: { + in: recordsToDelete, + }, + userId: updatedUser.id, + }, + }); + } + + const modifiedRecords = secondaryEmails.filter((secondaryEmail) => !secondaryEmail.isDeleted); + if (modifiedRecords.length) { + const secondaryEmailsFromDB = await prisma.secondaryEmail.findMany({ + where: { + id: { + in: secondaryEmails.map((secondaryEmail) => secondaryEmail.id), + }, + userId: updatedUser.id, + }, + }); + + const keyedSecondaryEmailsFromDB = keyBy(secondaryEmailsFromDB, "id"); + + const recordsToModifyQueue = modifiedRecords.map((updated) => { + let emailVerified = keyedSecondaryEmailsFromDB[updated.id].emailVerified; + if (secondaryEmail?.id === updated.id) { + emailVerified = primaryEmailVerified; + } else if (updated.email !== keyedSecondaryEmailsFromDB[updated.id].email) { + emailVerified = null; + } + + return prisma.secondaryEmail.update({ + where: { + id: updated.id, + userId: updatedUser.id, + }, + data: { + email: updated.email, + emailVerified, + }, + }); + }); + + await prisma.$transaction(recordsToModifyQueue); + } + } + return { ...input, - email: emailVerification && hasEmailChangedOnCalProvider ? user.email : input.email, + email: + emailVerification && hasEmailChangedOnCalProvider && !secondaryEmail?.emailVerified + ? user.email + : input.email, signOutUser, passwordReset, avatarUrl: updatedUser.avatarUrl, hasEmailBeenChanged, - sendEmailVerification: emailVerification, + sendEmailVerification: emailVerification && !secondaryEmail?.emailVerified, }; }; diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts index 1c685b34f7..6875d0161a 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts @@ -29,6 +29,15 @@ export const ZUpdateProfileInputSchema = z.object({ timeFormat: z.number().optional(), disableImpersonation: z.boolean().optional(), metadata: userMetadata.optional(), + secondaryEmails: z + .array( + z.object({ + id: z.number(), + email: z.string(), + isDeleted: z.boolean().default(false), + }) + ) + .optional(), }); export type TUpdateProfileInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/auth/_router.tsx b/packages/trpc/server/routers/viewer/auth/_router.tsx index d69b9ab0e4..be4747bb7c 100644 --- a/packages/trpc/server/routers/viewer/auth/_router.tsx +++ b/packages/trpc/server/routers/viewer/auth/_router.tsx @@ -4,6 +4,7 @@ import authedProcedure from "../../../procedures/authedProcedure"; import publicProcedure from "../../../procedures/publicProcedure"; import { router } from "../../../trpc"; import { ZChangePasswordInputSchema } from "./changePassword.schema"; +import { ZResendVerifyEmailSchema } from "./resendVerifyEmail.schema"; import { ZSendVerifyEmailCodeSchema } from "./sendVerifyEmailCode.schema"; import { ZVerifyPasswordInputSchema } from "./verifyPassword.schema"; @@ -13,6 +14,7 @@ type AuthRouterHandlerCache = { verifyCodeUnAuthenticated?: typeof import("./verifyCodeUnAuthenticated.handler").verifyCodeUnAuthenticatedHandler; resendVerifyEmail?: typeof import("./resendVerifyEmail.handler").resendVerifyEmail; sendVerifyEmailCode?: typeof import("./sendVerifyEmailCode.handler").sendVerifyEmailCodeHandler; + resendVerifySecondaryEmail?: typeof import("./resendVerifyEmail.handler").resendVerifyEmail; }; const UNSTABLE_HANDLER_CACHE: AuthRouterHandlerCache = {}; @@ -89,7 +91,7 @@ export const authRouter = router({ }); }), - resendVerifyEmail: authedProcedure.mutation(async ({ ctx }) => { + resendVerifyEmail: authedProcedure.input(ZResendVerifyEmailSchema).mutation(async ({ input, ctx }) => { if (!UNSTABLE_HANDLER_CACHE.resendVerifyEmail) { UNSTABLE_HANDLER_CACHE.resendVerifyEmail = await import("./resendVerifyEmail.handler").then( (mod) => mod.resendVerifyEmail @@ -101,6 +103,7 @@ export const authRouter = router({ } return UNSTABLE_HANDLER_CACHE.resendVerifyEmail({ + input, ctx, }); }), diff --git a/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts b/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts index 89659b6b1e..7ddece6c1d 100644 --- a/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts +++ b/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts @@ -1,18 +1,45 @@ import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; import logger from "@calcom/lib/logger"; +import { prisma } from "@calcom/prisma"; +import { TRPCError } from "@calcom/trpc/server"; import type { TrpcSessionUser } from "../../../trpc"; +import type { TResendVerifyEmailSchema } from "./resendVerifyEmail.schema"; type ResendEmailOptions = { ctx: { user: NonNullable; }; + input: TResendVerifyEmailSchema; }; const log = logger.getSubLogger({ prefix: [`[[Auth] `] }); -export const resendVerifyEmail = async ({ ctx }: ResendEmailOptions) => { - if (ctx.user.emailVerified) { +export const resendVerifyEmail = async ({ input, ctx }: ResendEmailOptions) => { + let emailToVerify = ctx.user.email; + let emailVerified = Boolean(ctx.user.emailVerified); + let secondaryEmail; + // If the input which is coming is not the current user's email, it could be a secondary email + if (input?.email && input?.email !== ctx.user.email) { + secondaryEmail = await prisma.secondaryEmail.findUnique({ + where: { + email: input.email, + userId: ctx.user.id, + }, + }); + + if (!secondaryEmail) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Email not found" }); + } + + if (secondaryEmail.emailVerified) { + emailVerified = true; + } else { + emailToVerify = input.email; + emailVerified = false; + } + } + if (emailVerified) { log.info(`User ${ctx.user.id} already verified email`); return { ok: true, @@ -21,9 +48,10 @@ export const resendVerifyEmail = async ({ ctx }: ResendEmailOptions) => { } const email = await sendEmailVerification({ - email: ctx.user.email, + email: emailToVerify, username: ctx.user?.username ?? undefined, language: ctx.user.locale, + secondaryEmailId: secondaryEmail?.id, }); return email; diff --git a/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.schema.ts b/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.schema.ts new file mode 100644 index 0000000000..888ebe9d4d --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZResendVerifyEmailSchema = z + .object({ + email: z.string().email(), + }) + .optional(); + +export type TResendVerifyEmailSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index ce40d648d1..a3d8f03d92 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -48,6 +48,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { userId, bookingFields, offsetStart, + secondaryEmailId, ...rest } = input; @@ -321,6 +322,29 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { data.assignAllTeamMembers = assignAllTeamMembers ?? false; + // Validate the secondary email + if (secondaryEmailId) { + const secondaryEmail = await ctx.prisma.secondaryEmail.findUnique({ + where: { + id: secondaryEmailId, + userId: ctx.user.id, + }, + }); + // Make sure the secondary email id belongs to the current user and its a verified one + if (secondaryEmail && secondaryEmail.emailVerified) { + data.secondaryEmail = { + connect: { + id: secondaryEmailId, + }, + }; + // Delete the data if the user selected his original email to send the events to, which means the value coming will be -1 + } else if (secondaryEmailId === -1) { + data.secondaryEmail = { + disconnect: true, + }; + } + } + const updatedEventTypeSelect = Prisma.validator()({ slug: true, schedulingType: true, diff --git a/packages/ui/components/form/index.ts b/packages/ui/components/form/index.ts index e499edcfc3..1529186c81 100644 --- a/packages/ui/components/form/index.ts +++ b/packages/ui/components/form/index.ts @@ -17,6 +17,7 @@ export { export { InputFieldWithSelect } from "./inputs/InputFieldWithSelect"; export { InputField, Input, TextField } from "./inputs/TextField"; +export { InputError } from "./inputs/InputError"; export { Form } from "./inputs/Form"; export { Label } from "./inputs/Label"; export { Select, SelectField, SelectWithValidation, getReactSelectProps } from "./select"; diff --git a/packages/ui/components/form/inputs/HintOrErrors.tsx b/packages/ui/components/form/inputs/HintOrErrors.tsx index adc3ce6fca..3a32240e40 100644 --- a/packages/ui/components/form/inputs/HintOrErrors.tsx +++ b/packages/ui/components/form/inputs/HintOrErrors.tsx @@ -1,7 +1,8 @@ import type { FieldValues } from "react-hook-form"; import { useFormContext } from "react-hook-form"; -import { Check, Circle, Info, X } from "../../icon"; +import { Check, Circle, X } from "../../icon"; +import { InputError } from "./InputError"; type hintsOrErrorsProps = { hintErrors?: string[]; @@ -74,16 +75,7 @@ export function HintsOrErrors({ // errors exist, not custom ones, just show them as is if (fieldErrors) { - return ( -
-
- -
-

{fieldErrors.message}

-
- ); + return ; } if (!hintErrors) return null; diff --git a/packages/ui/components/form/inputs/InputError.tsx b/packages/ui/components/form/inputs/InputError.tsx new file mode 100644 index 0000000000..af06fda234 --- /dev/null +++ b/packages/ui/components/form/inputs/InputError.tsx @@ -0,0 +1,14 @@ +import { Info } from "../../icon"; + +type InputErrorProp = { + message: string; +}; + +export const InputError = ({ message }: InputErrorProp) => ( +
+
+ +
+

{message}

+
+); diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 50a2ac3cd4..19c8a9abc0 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -66,6 +66,7 @@ export { Stepper, Switch, NumberInput, + InputError, } from "./components/form"; export type { ITimezone, ITimezoneOption } from "./components/form"; export {