"use client"; import { zodResolver } from "@hookform/resolvers/zod"; // eslint-disable-next-line no-restricted-imports import { get, pick } from "lodash"; import { signOut, useSession } from "next-auth/react"; import type { BaseSyntheticEvent } from "react"; import React, { useRef, useState } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { z } from "zod"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { DisplayInfo } from "@calcom/features/users/components/UserTable/EditSheet/DisplayInfo"; import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; import { emailSchema } from "@calcom/lib/emailSchema"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import turndown from "@calcom/lib/turndownService"; import { IdentityProvider } from "@calcom/prisma/enums"; import type { TRPCClientErrorLike } from "@calcom/trpc/client"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import type { AppRouter } from "@calcom/trpc/server/routers/_app"; import { Alert, Button, Dialog, DialogClose, DialogContent, DialogFooter, DialogTrigger, Editor, Form, ImageUploader, Label, PasswordField, showToast, SkeletonAvatar, SkeletonButton, SkeletonContainer, SkeletonText, TextField, UserAvatar, } from "@calcom/ui"; 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 = () => { return ( ); }; interface DeleteAccountValues { totpCode: string; } type Email = { id: number; email: string; emailVerified: string | null; emailPrimary: boolean; }; export type FormValues = { username: string; avatarUrl: string | null; name: string; email: string; bio: string; secondaryEmails: Email[]; }; const ProfileView = () => { const { t } = useLocale(); const utils = trpc.useUtils(); const { update } = useSession(); const { data: user, isPending } = trpc.viewer.me.useQuery({ includePasswordAdded: true }); const updateProfileMutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (res) => { await update(res); utils.viewer.me.invalidate(); utils.viewer.shouldVerifyEmail.invalidate(); if (res.hasEmailBeenChanged && res.sendEmailVerification) { showToast(t("change_of_email_toast", { email: tempFormValues?.email }), "success"); } else { showToast(t("settings_updated_successfully"), "success"); } setTempFormValues(null); }, onError: (e) => { switch (e.message) { // TODO: Add error codes. case "email_already_used": { showToast(t(e.message), "error"); } return; default: showToast(t("error_updating_settings"), "error"); } }, }); const unlinkConnectedAccountMutation = trpc.viewer.unlinkConnectedAccount.useMutation({ onSuccess: async (res) => { showToast(t(res.message), "success"); utils.viewer.me.invalidate(); }, onError: (e) => { showToast(t(e.message), "error"); }, }); const addSecondaryEmailMutation = trpc.viewer.addSecondaryEmail.useMutation({ onSuccess: (res) => { setShowSecondaryEmailModalOpen(false); setNewlyAddedSecondaryEmail(res?.data?.email); utils.viewer.me.invalidate(); }, onError: (error) => { setSecondaryEmailAddErrorMessage(error?.message || ""); }, }); const resendVerifyEmailMutation = trpc.viewer.auth.resendVerifyEmail.useMutation(); const [confirmPasswordOpen, setConfirmPasswordOpen] = useState(false); const [tempFormValues, setTempFormValues] = useState(null); const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState(""); const [showCreateAccountPasswordDialog, setShowCreateAccountPasswordDialog] = useState(false); const [showAccountDisconnectWarning, setShowAccountDisconnectWarning] = useState(false); const [showSecondaryEmailModalOpen, setShowSecondaryEmailModalOpen] = useState(false); const [secondaryEmailAddErrorMessage, setSecondaryEmailAddErrorMessage] = useState(""); const [newlyAddedSecondaryEmail, setNewlyAddedSecondaryEmail] = useState(undefined); const [deleteAccountOpen, setDeleteAccountOpen] = useState(false); const [hasDeleteErrors, setHasDeleteErrors] = useState(false); const [deleteErrorMessage, setDeleteErrorMessage] = useState(""); const form = useForm(); const onDeleteMeSuccessMutation = async () => { await utils.viewer.me.invalidate(); showToast(t("Your account was deleted"), "success"); setHasDeleteErrors(false); // dismiss any open errors if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") { signOut({ callbackUrl: "/auth/logout?survey=true" }); } else { signOut({ callbackUrl: "/auth/logout" }); } }; const confirmPasswordMutation = trpc.viewer.auth.verifyPassword.useMutation({ onSuccess() { if (tempFormValues) updateProfileMutation.mutate(tempFormValues); setConfirmPasswordOpen(false); }, onError() { setConfirmPasswordDeleteErrorMessage(t("incorrect_password")); }, }); const onDeleteMeErrorMutation = (error: TRPCClientErrorLike) => { setHasDeleteErrors(true); setDeleteErrorMessage(errorMessages[error.message]); }; const deleteMeMutation = trpc.viewer.deleteMe.useMutation({ onSuccess: onDeleteMeSuccessMutation, onError: onDeleteMeErrorMutation, async onSettled() { await utils.viewer.me.invalidate(); }, }); const deleteMeWithoutPasswordMutation = trpc.viewer.deleteMeWithoutPassword.useMutation({ onSuccess: onDeleteMeSuccessMutation, onError: onDeleteMeErrorMutation, async onSettled() { await utils.viewer.me.invalidate(); }, }); const isCALIdentityProvider = user?.identityProvider === IdentityProvider.CAL; const onConfirmPassword = (e: Event | React.MouseEvent) => { e.preventDefault(); const password = passwordRef.current.value; confirmPasswordMutation.mutate({ passwordInput: password }); }; const onConfirmButton = (e: Event | React.MouseEvent) => { e.preventDefault(); if (isCALIdentityProvider) { const totpCode = form.getValues("totpCode"); const password = passwordRef.current.value; deleteMeMutation.mutate({ password, totpCode }); } else { deleteMeWithoutPasswordMutation.mutate(); } }; const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => { e?.preventDefault(); if (isCALIdentityProvider) { const password = passwordRef.current.value; deleteMeMutation.mutate({ password, totpCode }); } else { deleteMeWithoutPasswordMutation.mutate(); } }; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const passwordRef = useRef(null!); const errorMessages: { [key: string]: string } = { [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"), [ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`, [ErrorCode.UserNotFound]: t("no_account_exists"), [ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`, [ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`, [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"), }; if (isPending || !user) { return ; } const userEmail = user.email || ""; const defaultValues = { username: user.username || "", avatarUrl: user.avatarUrl, name: user.name || "", 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 ( <> { if (values.email !== user.email && isCALIdentityProvider) { setTempFormValues(values); setConfirmPasswordOpen(true); } else { updateProfileMutation.mutate(values); } }} handleAddSecondaryEmail={() => setShowSecondaryEmailModalOpen(true)} handleResendVerifyEmail={(email) => { resendVerifyEmailMutation.mutate({ email }); showToast(t("email_sent"), "success"); }} handleAccountDisconnect={(values) => { if (isCALIdentityProvider) return; if (user?.passwordAdded) { setTempFormValues(values); setShowAccountDisconnectWarning(true); return; } setShowCreateAccountPasswordDialog(true); }} extraField={ { showToast(t("settings_updated_successfully"), "success"); await utils.viewer.me.invalidate(); }} onErrorMutation={() => { showToast(t("error_updating_settings"), "error"); }} /> } isCALIdentityProvider={isCALIdentityProvider} /> {t("danger_zone")} {t("account_deletion_cannot_be_undone")} {/* Delete account Dialog */} {t("delete_account")} <> {t("delete_account_confirmation_message")} {isCALIdentityProvider && ( )} {user?.twoFactorEnabled && isCALIdentityProvider && ( )} {hasDeleteErrors && } onConfirmButton(e)} loading={deleteMeMutation.isPending}> {t("delete_my_account")} > {/* If changing email, confirm password */} {t("old_email_address")} {user.email} {t("new_email_address")} {tempFormValues?.email} {confirmPasswordErrorMessage && } onConfirmPassword(e)}> {t("confirm")} { unlinkConnectedAccountMutation.mutate(); setShowAccountDisconnectWarning(false); }}> {t("confirm")} {showSecondaryEmailModalOpen && ( { setSecondaryEmailAddErrorMessage(""); addSecondaryEmailMutation.mutate(values); }} onCancel={() => { setSecondaryEmailAddErrorMessage(""); setShowSecondaryEmailModalOpen(false); }} clearErrorMessage={() => { addSecondaryEmailMutation.reset(); setSecondaryEmailAddErrorMessage(""); }} /> )} {!!newlyAddedSecondaryEmail && ( setNewlyAddedSecondaryEmail(undefined)} /> )} > ); }; type SecondaryEmailApiPayload = { id: number; email: string; isDeleted: boolean; }; type ExtendedFormValues = Omit & { secondaryEmails: SecondaryEmailApiPayload[]; }; const ProfileForm = ({ defaultValues, onSubmit, handleAddSecondaryEmail, handleResendVerifyEmail, handleAccountDisconnect, extraField, isPending = false, isFallbackImg, user, userOrganization, isCALIdentityProvider, }: { defaultValues: FormValues; onSubmit: (values: ExtendedFormValues) => void; handleAddSecondaryEmail: () => void; handleResendVerifyEmail: (email: string) => void; handleAccountDisconnect: (values: ExtendedFormValues) => void; extraField?: React.ReactNode; isPending: boolean; isFallbackImg: boolean; user: RouterOutputs["viewer"]["me"]; userOrganization: RouterOutputs["viewer"]["me"]["organization"]; isCALIdentityProvider: boolean; }) => { const { t } = useLocale(); const [firstRender, setFirstRender] = useState(true); const profileFormSchema = z.object({ username: z.string(), avatarUrl: z.string().nullable(), name: z .string() .trim() .min(1, t("you_need_to_add_a_name")) .max(FULL_NAME_LENGTH_MAX_LIMIT, { message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }), }), email: emailSchema, bio: z.string(), secondaryEmails: z.array( z.object({ id: z.number(), email: emailSchema, emailVerified: z.union([z.string(), z.null()]).optional(), emailPrimary: z.boolean().optional(), }) ), }); const formMethods = useForm({ defaultValues, resolver: zodResolver(profileFormSchema), }); const { fields: secondaryEmailFields, remove: deleteSecondaryEmail, replace: updateAllSecondaryEmailFields, } = useFieldArray({ control: formMethods.control, name: "secondaryEmails", keyName: "itemId", }); const getUpdatedFormValues = (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"])); return { ...updatedValues, secondaryEmails, }; }; const handleFormSubmit = (values: FormValues) => { onSubmit(getUpdatedFormValues(values)); }; const onDisconnect = () => { handleAccountDisconnect(getUpdatedFormValues(formMethods.getValues())); }; const { data: usersAttributes, isPending: usersAttributesPending } = trpc.viewer.attributes.getByUserId.useQuery({ userId: user.id, }); const { formState: { isSubmitting, isDirty }, } = formMethods; const isDisabled = isSubmitting || !isDirty; return ( { const showRemoveAvatarButton = value !== null; return ( <> {t("profile_picture")} { onChange(newAvatar); }} imageSrc={getUserAvatarUrl({ avatarUrl: value })} triggerButtonColor={showRemoveAvatarButton ? "secondary" : "secondary"} /> {showRemoveAvatarButton && ( { onChange(null); }}> {t("remove")} )} > ); }} /> {extraField} {t("email")} {secondaryEmailFields.map((field, index) => ( { const fields = secondaryEmailFields.map((secondaryField, cIndex) => ({ ...secondaryField, emailPrimary: cIndex === index, })); updateAllSecondaryEmailFields(fields); }} handleVerifyEmail={() => handleResendVerifyEmail(field.email)} handleItemDelete={() => deleteSecondaryEmail(index)} /> ))} handleAddSecondaryEmail()} data-testid="add-secondary-email"> {t("add_email")} {t("about")} md.render(formMethods.getValues("bio") || "")} setText={(value: string) => { formMethods.setValue("bio", turndown(value), { shouldDirty: true }); }} excludedToolbarItems={["blockType"]} disableLists firstRender={firstRender} setFirstRender={setFirstRender} height="80px" /> {usersAttributes && usersAttributes?.length > 0 && ( {t("attributes")} {usersAttributes.map((attribute, index) => ( <> option.value) } /> > ))} )} {/* // For Non-Cal identities, we merge the values from DB and the user logging in, so essentially there's no point in allowing them to disconnect, since when they log in they will get logged into the same account */} {!isCALIdentityProvider && user.email !== user.identityProviderEmail && ( Connected accounts {user.identityProvider.toLowerCase()} {user.identityProviderEmail && ( {user.identityProviderEmail} )} {t("disconnect")} )} {t("update")} ); }; export default ProfileView;
{t("account_deletion_cannot_be_undone")}
{t("delete_account_confirmation_message")}
{user.email}
{tempFormValues?.email}