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 <peeroke@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-03-02 22:29:40 +00:00
1 parent 0071d16575
commit 92a919069d
36 files changed
+1160 -73

No files matched your search

@@ -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<EventTypeSetupProps,
const closeEventNameTip = () => 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 (
<div className="flex flex-col space-y-4">
@@ -198,6 +210,28 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
</div>
)}
{!useEventTypeDestinationCalendarEmail && verifiedSecondaryEmails.length > 0 && (
<div className="w-full">
<SelectField
label={t("send_event_details_to")}
placeholder={
selectedSecondaryEmailId === -1 && (
<span className="text-default min-w-0 overflow-hidden truncate whitespace-nowrap">
<Badge variant="blue">Default</Badge> {user?.email || ""}
</span>
)
}
onChange={(option) =>
formMethods.setValue("secondaryEmailId", option?.value, { shouldDirty: true })
}
value={verifiedSecondaryEmails.find(
(secondaryEmail) =>
selectedSecondaryEmailId !== -1 && secondaryEmail.value === selectedSecondaryEmailId
)}
options={verifiedSecondaryEmails}
/>
</div>
)}
</div>
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} isOuterBorder={true} />
@@ -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<FormValues>;
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 (
<>
<div
className={`border-default mt-2 flex items-center rounded-md border ${
inputFocus ? "ring-brand-default border-neutral-300 ring-2" : ""
}`}>
<TextField
{...formMethods.register(formMethodFieldName)}
label=""
containerClassName="flex flex-1 items-center"
className="mb-0 border-none outline-none focus:ring-0"
data-testid={dataTestId}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
/>
<div className="flex items-center pr-2">
{emailPrimary && (
<Badge variant="blue" size="sm" data-testid={`${dataTestId}-primary-badge`}>
{t("primary")}
</Badge>
)}
{!emailVerified && (
<Badge variant="orange" size="sm" className="ml-2" data-testid={`${dataTestId}-unverified-badge`}>
{t("unverified")}
</Badge>
)}
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
StartIcon={MoreHorizontal}
variant="icon"
size="sm"
color="secondary"
className="ml-2 rounded-md"
data-testid="secondary-email-action-group-button"
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
StartIcon={Flag}
color="secondary"
className="disabled:opacity-40"
onClick={handleChangePrimary}
disabled={!emailVerified || emailPrimary}
data-testid="secondary-email-make-primary-button">
{t("make_primary")}
</DropdownItem>
</DropdownMenuItem>
{!emailVerified && (
<DropdownMenuItem>
<DropdownItem
StartIcon={Send}
color="secondary"
className="disabled:opacity-40"
onClick={handleVerifyEmail}
disabled={emailVerified}
data-testid="resend-verify-email-button">
{t("resend_email")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<DropdownItem
StartIcon={Trash}
color="destructive"
className="disabled:opacity-40"
onClick={handleItemDelete}
disabled={emailPrimary}
data-testid="secondary-email-delete-button">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</div>
{errorMessage && <InputError message={errorMessage} />}
</>
);
};
export default CustomEmailTextField;
@@ -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 (
<Dialog open={true}>
<DialogContent
title={t("confirm_email")}
description={<Trans i18nKey="confirm_email_description" values={{ email }} />}
type="creation"
data-testid="secondary-email-confirm-dialog">
<DialogFooter>
<DialogClose color="primary" onClick={onCancel} data-testid="secondary-email-confirm-done-button">
{t("done")}
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default SecondaryEmailConfirmModal;
@@ -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<FormValues>({
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 (
<Dialog open={true}>
<DialogContent
title={t("add_email")}
description={t("add_email_description")}
type="creation"
data-testid="secondary-email-add-dialog">
<Form form={formMethods} handleSubmit={handleAddEmail}>
<TextField
label={t("email_address")}
data-testid="secondary-email-input"
{...formMethods.register("email")}
/>
{errorMessage && <InputError message={errorMessage} />}
<DialogFooter showDivider className="mt-10">
<DialogClose onClick={onCancel}>{t("cancel")}</DialogClose>
<Button type="submit" data-testid="add-secondary-email-button" disabled={isLoading}>
{t("add_email")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};
export default SecondaryEmailModal;
@@ -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,
+52 -2
View File
@@ -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: {
+181 -13
View File
@@ -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<FormValues | null>(null);
const [tempFormValues, setTempFormValues] = useState<ExtendedFormValues | null>(null);
const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState("");
const [confirmAuthEmailChangeWarningDialogOpen, setConfirmAuthEmailChangeWarningDialogOpen] =
useState(false);
const [showSecondaryEmailModalOpen, setShowSecondaryEmailModalOpen] = useState(false);
const [secondaryEmailAddErrorMessage, setSecondaryEmailAddErrorMessage] = useState("");
const [newlyAddedSecondaryEmail, setNewlyAddedSecondaryEmail] = useState<undefined | string>(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={
<div className="mt-6">
<UsernameAvailabilityField
@@ -405,13 +454,50 @@ const ProfileView = () => {
</DialogFooter>
</DialogContent>
</Dialog>
{showSecondaryEmailModalOpen && (
<SecondaryEmailModal
isLoading={addSecondaryEmailMutation.isPending}
errorMessage={secondaryEmailAddErrorMessage}
handleAddEmail={(values) => {
setSecondaryEmailAddErrorMessage("");
addSecondaryEmailMutation.mutate(values);
}}
onCancel={() => {
setSecondaryEmailAddErrorMessage("");
setShowSecondaryEmailModalOpen(false);
}}
clearErrorMessage={() => {
addSecondaryEmailMutation.reset();
setSecondaryEmailAddErrorMessage("");
}}
/>
)}
{!!newlyAddedSecondaryEmail && (
<SecondaryEmailConfirmModal
email={newlyAddedSecondaryEmail}
onCancel={() => setNewlyAddedSecondaryEmail(undefined)}
/>
)}
</>
);
};
type SecondaryEmailApiPayload = {
id: number;
email: string;
isDeleted: boolean;
};
type ExtendedFormValues = Omit<FormValues, "secondaryEmails"> & {
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<FormValues>({
@@ -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 (
<Form form={formMethods} handleSubmit={onSubmit}>
<Form form={formMethods} handleSubmit={handleFormSubmit}>
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
<div className="flex items-center">
<Controller
@@ -508,12 +652,36 @@ const ProfileForm = ({
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-6">
<TextField
label={t("email")}
hint={t("change_email_hint")}
data-testid="profile-form-email"
{...formMethods.register("email")}
/>
<Label>{t("email")}</Label>
{secondaryEmailFields.map((field, index) => (
<CustomEmailTextField
key={field.itemId}
formMethods={formMethods}
formMethodFieldName={`secondaryEmails.${index}.email` as keyof FormValues}
errorMessage={get(formMethods.formState.errors, `secondaryEmails.${index}.email.message`)}
emailVerified={Boolean(field.emailVerified)}
emailPrimary={field.emailPrimary}
dataTestId={`profile-form-email-${index}`}
handleChangePrimary={() => {
const fields = secondaryEmailFields.map((secondaryField, cIndex) => ({
...secondaryField,
emailPrimary: cIndex === index,
}));
updateAllSecondaryEmailFields(fields);
}}
handleVerifyEmail={() => handleResendVerifyEmail(field.email)}
handleItemDelete={() => deleteSecondaryEmail(index)}
/>
))}
<div className="text-default mt-2 flex items-center text-sm">{t("change_email_hint")}</div>
<Button
color="minimal"
StartIcon={Plus}
className="mt-2"
onClick={() => handleAddSecondaryEmail()}
data-testid="add-secondary-email">
{t("add_email")}
</Button>
</div>
<div className="mt-6">
<Label>{t("about")}</Label>
+1
View File
@@ -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 = [];
+238 -6
View File
@@ -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<typeof createEmailsFixture>;
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<typeof createUsersFixture>;
}) => {
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);
});
});
@@ -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 <strong>{{email}}</strong>. 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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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: {} },
@@ -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",
@@ -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<Record<string, unknown>> {
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),
+9 -1
View File
@@ -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 };
@@ -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" },
+2
View File
@@ -102,6 +102,8 @@ const commons = {
bookingFields: [],
assignAllTeamMembers: false,
useEventTypeDestinationCalendarEmail: false,
secondaryEmailId: null,
secondaryEmail: null,
};
const dynamicEvent = {
+1
View File
@@ -235,6 +235,7 @@ export default async function getEventTypeById({
},
},
},
secondaryEmailId: true,
},
});
+1
View File
@@ -117,6 +117,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
bookingFields: [],
parentId: null,
profileId: null,
secondaryEmailId: null,
...eventType,
};
};
@@ -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;
@@ -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;
@@ -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;
+33 -11
View File
@@ -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])
}
@@ -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 });
}),
});
@@ -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<TrpcSessionUser>;
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",
};
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZAddSecondaryEmailInputSchema = z.object({
email: z.string(),
});
export type TAddSecondaryEmailInputSchema = z.infer<typeof ZAddSecondaryEmailInputSchema>;
@@ -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,
};
};
@@ -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,
};
};
@@ -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<typeof ZUpdateProfileInputSchema>;
@@ -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,
});
}),
@@ -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<TrpcSessionUser>;
};
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;
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZResendVerifyEmailSchema = z
.object({
email: z.string().email(),
})
.optional();
export type TResendVerifyEmailSchema = z.infer<typeof ZResendVerifyEmailSchema>;
@@ -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<Prisma.EventTypeSelect>()({
slug: true,
schedulingType: true,
+1
View File
@@ -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";
@@ -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<T extends FieldValues = FieldValues>({
// errors exist, not custom ones, just show them as is
if (fieldErrors) {
return (
<div
data-testid="field-error"
className="text-gray mt-2 flex items-center gap-x-2 text-sm text-red-700">
<div>
<Info className="h-3 w-3" />
</div>
<p>{fieldErrors.message}</p>
</div>
);
return <InputError message={fieldErrors.message} />;
}
if (!hintErrors) return null;
@@ -0,0 +1,14 @@
import { Info } from "../../icon";
type InputErrorProp = {
message: string;
};
export const InputError = ({ message }: InputErrorProp) => (
<div data-testid="field-error" className="text-gray mt-2 flex items-center gap-x-2 text-sm text-red-700">
<div>
<Info className="h-3 w-3" />
</div>
<p>{message}</p>
</div>
);
+1
View File
@@ -66,6 +66,7 @@ export {
Stepper,
Switch,
NumberInput,
InputError,
} from "./components/form";
export type { ITimezone, ITimezoneOption } from "./components/form";
export {