import { zodResolver } from "@hookform/resolvers/zod"; 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 { z } from "zod"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar"; import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; import { AVATAR_FALLBACK } from "@calcom/lib/constants"; 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 { trpc } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { AppRouter } from "@calcom/trpc/server/routers/_app"; import type { Ensure } from "@calcom/types/utils"; import { Alert, Button, Dialog, DialogClose, DialogContent, DialogFooter, DialogTrigger, Editor, Form, ImageUploader, Label, Meta, PasswordField, showToast, SkeletonAvatar, SkeletonButton, SkeletonContainer, SkeletonText, TextField, } from "@calcom/ui"; import { AlertTriangle, Trash2 } from "@calcom/ui/components/icon"; import PageWrapper from "@components/PageWrapper"; import TwoFactor from "@components/auth/TwoFactor"; import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return (
); }; interface DeleteAccountValues { totpCode: string; } type FormValues = { username: string; avatar: string | null; name: string; email: string; bio: string; }; const checkIfItFallbackImage = (fetchedImgSrc?: string) => { return !fetchedImgSrc || fetchedImgSrc.endsWith(AVATAR_FALLBACK); }; const ProfileView = () => { const { t } = useLocale(); const utils = trpc.useContext(); const { update } = useSession(); const [fetchedImgSrc, setFetchedImgSrc] = useState(undefined); const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, { onSuccess: async (userData) => { try { if (!userData.organization) { const res = await fetch(userData.avatar); if (res.url) setFetchedImgSrc(res.url); } else { setFetchedImgSrc(""); } } catch (err) { setFetchedImgSrc(""); } }, }); const updateProfileMutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (res) => { await update(res); showToast(t("settings_updated_successfully"), "success"); // signout user only in case of password reset if (res.signOutUser && tempFormValues && res.passwordReset) { showToast(t("password_reset_email", { email: tempFormValues.email }), "success"); await signOut({ callbackUrl: "/auth/logout?passReset=true" }); } else { utils.viewer.me.invalidate(); utils.viewer.avatar.invalidate(); utils.viewer.shouldVerifyEmail.invalidate(); } setConfirmAuthEmailChangeWarningDialogOpen(false); setTempFormValues(null); }, onError: () => { showToast(t("error_updating_settings"), "error"); }, }); const [confirmPasswordOpen, setConfirmPasswordOpen] = useState(false); const [tempFormValues, setTempFormValues] = useState(null); const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState(""); const [confirmAuthEmailChangeWarningDialogOpen, setConfirmAuthEmailChangeWarningDialogOpen] = useState(false); const [deleteAccountOpen, setDeleteAccountOpen] = useState(false); const [hasDeleteErrors, setHasDeleteErrors] = useState(false); 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 onConfirmAuthEmailChange = (e: Event | React.MouseEvent) => { e.preventDefault(); if (tempFormValues) updateProfileMutation.mutate(tempFormValues); }; 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 (isLoading || !user) { return ( ); } const defaultValues = { username: user.username || "", avatar: user.avatar || "", name: user.name || "", email: user.email || "", bio: user.bio || "", }; return ( <> { if (values.email !== user.email && isCALIdentityProvider) { setTempFormValues(values); setConfirmPasswordOpen(true); } else if (values.email !== user.email && !isCALIdentityProvider) { setTempFormValues(values); // Opens a dialog warning the change setConfirmAuthEmailChangeWarningDialogOpen(true); } else { updateProfileMutation.mutate(values); } }} extraField={
{ showToast(t("settings_updated_successfully"), "success"); await utils.viewer.me.invalidate(); }} onErrorMutation={() => { showToast(t("error_updating_settings"), "error"); }} />
} />

{t("account_deletion_cannot_be_undone")}

{/* Delete account Dialog */} <>

{t("delete_account_confirmation_message")}

{isCALIdentityProvider && ( )} {user?.twoFactorEnabled && isCALIdentityProvider && (
)} {hasDeleteErrors && }
{/* If changing email, confirm password */}
{confirmPasswordErrorMessage && }
{/* If changing email from !CAL Login */} ); }; const ProfileForm = ({ defaultValues, onSubmit, extraField, isLoading = false, isFallbackImg, userAvatar, user, userOrganization, }: { defaultValues: FormValues; onSubmit: (values: FormValues) => void; extraField?: React.ReactNode; isLoading: boolean; isFallbackImg: boolean; userAvatar: string; user: RouterOutputs["viewer"]["me"]; userOrganization: RouterOutputs["viewer"]["me"]["organization"]; }) => { const { t } = useLocale(); const [firstRender, setFirstRender] = useState(true); const profileFormSchema = z.object({ username: z.string(), avatar: 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: z.string().email(), bio: z.string(), }); const formMethods = useForm({ defaultValues, resolver: zodResolver(profileFormSchema), }); const { formState: { isSubmitting, isDirty }, } = formMethods; const isDisabled = isSubmitting || !isDirty; return (
{ const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value); const organization = userOrganization && userOrganization.id ? { ...(userOrganization as Ensure), slug: userOrganization.slug || null, requestedSlug: userOrganization.metadata?.requestedSlug || null, } : null; return ( <>

{t("profile_picture")}

{ formMethods.setValue("avatar", newAvatar, { shouldDirty: true }); }} imageSrc={value || undefined} triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"} /> {showRemoveAvatarButton && ( )}
); }} />
{extraField}
md.render(formMethods.getValues("bio") || "")} setText={(value: string) => { formMethods.setValue("bio", turndown(value), { shouldDirty: true }); }} excludedToolbarItems={["blockType"]} disableLists firstRender={firstRender} setFirstRender={setFirstRender} />
); }; ProfileView.getLayout = getLayout; ProfileView.PageWrapper = PageWrapper; export default ProfileView;