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:
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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -102,6 +102,8 @@ const commons = {
|
||||
bookingFields: [],
|
||||
assignAllTeamMembers: false,
|
||||
useEventTypeDestinationCalendarEmail: false,
|
||||
secondaryEmailId: null,
|
||||
secondaryEmail: null,
|
||||
};
|
||||
|
||||
const dynamicEvent = {
|
||||
|
||||
@@ -235,6 +235,7 @@ export default async function getEventTypeById({
|
||||
},
|
||||
},
|
||||
},
|
||||
secondaryEmailId: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
+8
@@ -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;
|
||||
+8
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -66,6 +66,7 @@ export {
|
||||
Stepper,
|
||||
Switch,
|
||||
NumberInput,
|
||||
InputError,
|
||||
} from "./components/form";
|
||||
export type { ITimezone, ITimezoneOption } from "./components/form";
|
||||
export {
|
||||
|
||||
Reference in new issue
Block a user