diff --git a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx index c1945aec94..c048f76e77 100644 --- a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx @@ -35,23 +35,7 @@ interface ICustomUsernameProps { setInputUsernameValue: (value: string) => void; onSuccessMutation?: () => void; onErrorMutation?: (error: TRPCClientErrorLike) => void; - user: Pick< - User, - | "username" - | "name" - | "email" - | "bio" - | "avatar" - | "timeZone" - | "weekStart" - | "hideBranding" - | "theme" - | "plan" - | "brandColor" - | "darkBrandColor" - | "timeFormat" - | "metadata" - >; + user: Pick; readonly?: boolean; } diff --git a/apps/web/components/ui/UsernameAvailability/index.tsx b/apps/web/components/ui/UsernameAvailability/index.tsx index f0c5f49819..899ea4472f 100644 --- a/apps/web/components/ui/UsernameAvailability/index.tsx +++ b/apps/web/components/ui/UsernameAvailability/index.tsx @@ -14,23 +14,7 @@ export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : Premium interface UsernameAvailabilityFieldProps { onSuccessMutation?: () => void; onErrorMutation?: (error: TRPCClientErrorLike) => void; - user: Pick< - User, - | "username" - | "name" - | "email" - | "bio" - | "avatar" - | "timeZone" - | "weekStart" - | "hideBranding" - | "theme" - | "plan" - | "brandColor" - | "darkBrandColor" - | "timeFormat" - | "metadata" - >; + user: Pick; } export const UsernameAvailabilityField = ({ onSuccessMutation, diff --git a/apps/web/pages/api/user/avatar.ts b/apps/web/pages/api/user/avatar.ts index 7b751f4147..6a99a802c6 100644 --- a/apps/web/pages/api/user/avatar.ts +++ b/apps/web/pages/api/user/avatar.ts @@ -1,86 +1,72 @@ import crypto from "crypto"; import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; -import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar"; import prisma from "@calcom/prisma"; import { defaultAvatarSrc } from "@lib/profile"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - // const username = req.url?.substring(1, req.url.lastIndexOf("/")); - const username = req.query.username as string; - const teamname = req.query.teamname as string; - let identity; - let linksToThisRoute = false; +const querySchema = z + .object({ + username: z.string(), + teamname: z.string(), + }) + .partial(); + +async function getIdentityData(req: NextApiRequest) { + const { username, teamname } = querySchema.parse(req.query); + if (username) { const user = await prisma.user.findUnique({ - where: { - username: username, - }, - select: { - avatar: true, - email: true, - }, + where: { username }, + select: { avatar: true, email: true }, }); - identity = { + return { name: username, email: user?.email, avatar: user?.avatar, }; - linksToThisRoute = - identity.avatar === `${CAL_URL}/${username}/avatar.png` || - identity.avatar === `${WEBAPP_URL}/${username}/avatar.png`; - } else if (teamname) { - const team = await prisma.team.findUnique({ - where: { - slug: teamname, - }, - select: { - logo: true, - }, - }); - identity = { - name: teamname, - shouldDefaultBeNameBased: true, - avatar: team?.logo, - }; - linksToThisRoute = - identity.avatar === `${CAL_URL}/team/${teamname}/avatar.png` || - identity.avatar === `${WEBAPP_URL}/team/${teamname}/avatar.png`; } - - const emailMd5 = crypto - .createHash("md5") - .update((identity?.email as string) || "guest@example.com") - .digest("hex"); - const img = identity?.avatar; - // If image isn't set or links to this route itself, use default avatar - if (!img || linksToThisRoute) { - let defaultSrc = defaultAvatarSrc({ md5: emailMd5 }); - if (identity?.shouldDefaultBeNameBased) { - defaultSrc = getPlaceholderAvatar(null, identity.name); - } - res.writeHead(302, { - Location: defaultSrc, + if (teamname) { + const team = await prisma.team.findUnique({ + where: { slug: teamname }, + select: { logo: true }, }); - - res.end(); - } else if (!img.includes("data:image")) { - res.writeHead(302, { - Location: img, - }); - res.end(); - } else { - const decoded = img - .toString() - .replace("data:image/png;base64,", "") - .replace("data:image/jpeg;base64,", ""); - const imageResp = Buffer.from(decoded, "base64"); - res.writeHead(200, { - "Content-Type": "image/png", - "Content-Length": imageResp.length, - }); - res.end(imageResp); + return { + name: teamname, + email: null, + avatar: team?.logo || getPlaceholderAvatar(null, teamname), + }; } } + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const identity = await getIdentityData(req); + const img = identity?.avatar; + // If image isn't set or links to this route itself, use default avatar + if (!img) { + res.writeHead(302, { + Location: defaultAvatarSrc({ + md5: crypto + .createHash("md5") + .update(identity?.email || "guest@example.com") + .digest("hex"), + }), + }); + return res.end(); + } + + if (!img.includes("data:image")) { + res.writeHead(302, { Location: img }); + return res.end(); + } + + const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", ""); + const imageResp = Buffer.from(decoded, "base64"); + res.writeHead(200, { + "Content-Type": "image/png", + "Content-Length": imageResp.length, + }); + res.end(imageResp); +} diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index bf1e8b8fa8..e893f258bb 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -2,7 +2,7 @@ import { IdentityProvider } from "@prisma/client"; import crypto from "crypto"; import { GetServerSidePropsContext } from "next"; import { signOut } from "next-auth/react"; -import { BaseSyntheticEvent, useEffect, useRef, useState } from "react"; +import { BaseSyntheticEvent, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { ErrorCode } from "@calcom/lib/auth"; @@ -63,14 +63,25 @@ interface DeleteAccountValues { totpCode: string; } +type FormValues = { + username: string; + avatar: string; + name: string; + email: string; + bio: string; +}; + const ProfileView = () => { const { t } = useLocale(); const utils = trpc.useContext(); - const { data: user, isLoading } = trpc.viewer.me.useQuery(); + const { data: avatar, isLoading: isLoadingAvatar } = trpc.viewer.avatar.useQuery(); const mutation = trpc.viewer.updateProfile.useMutation({ onSuccess: () => { showToast(t("settings_updated_successfully"), "success"); + utils.viewer.me.invalidate(); + utils.viewer.avatar.invalidate(); + setTempFormValues(null); }, onError: () => { showToast(t("error_updating_settings"), "error"); @@ -78,18 +89,14 @@ const ProfileView = () => { }); const [confirmPasswordOpen, setConfirmPasswordOpen] = useState(false); + const [tempFormValues, setTempFormValues] = useState(null); const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState(""); + const [deleteAccountOpen, setDeleteAccountOpen] = useState(false); const [hasDeleteErrors, setHasDeleteErrors] = useState(false); const [deleteErrorMessage, setDeleteErrorMessage] = useState(""); - const form = useForm(); - const emailMd5 = crypto - .createHash("md5") - .update(user?.email || "example@example.com") - .digest("hex"); - const onDeleteMeSuccessMutation = async () => { await utils.viewer.me.invalidate(); showToast(t("Your account was deleted"), "success"); @@ -104,7 +111,7 @@ const ProfileView = () => { const confirmPasswordMutation = trpc.viewer.auth.verifyPassword.useMutation({ onSuccess() { - mutation.mutate(formMethods.getValues()); + if (tempFormValues) mutation.mutate(tempFormValues); setConfirmPasswordOpen(false); }, onError() { @@ -150,6 +157,7 @@ const ProfileView = () => { deleteMeWithoutPasswordMutation.mutate(); } }; + const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => { e?.preventDefault(); if (isCALIdentityProviver) { @@ -160,38 +168,6 @@ const ProfileView = () => { } }; - const formMethods = useForm({ - defaultValues: { - username: "", - avatar: "", - name: "", - email: "", - bio: "", - }, - }); - - const { - reset, - formState: { isSubmitting, isDirty }, - } = formMethods; - - useEffect(() => { - if (user) { - reset( - { - avatar: user?.avatar || "", - username: user?.username || "", - name: user?.name || "", - email: user?.email || "", - bio: user?.bio || "", - }, - { - keepDirtyValues: true, - } - ); - } - }, [reset, user]); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const passwordRef = useRef(null!); @@ -203,133 +179,100 @@ const ProfileView = () => { [ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`, [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"), }; - const onSuccessfulUsernameUpdate = async () => { - showToast(t("settings_updated_successfully"), "success"); - await utils.viewer.me.invalidate(); - }; - const onErrorInUsernameUpdate = () => { - showToast(t("error_updating_settings"), "error"); - }; - - if (isLoading || !user) + if (isLoading || !user || isLoadingAvatar || !avatar) return ; - const isDisabled = isSubmitting || !isDirty; + const defaultValues = { + username: user.username || "", + avatar: avatar.avatar || "", + name: user.name || "", + email: user.email || "", + bio: user.bio || "", + }; return ( <> -
{ - if (values.email !== user?.email && isCALIdentityProviver) { + { + if (values.email !== user.email && isCALIdentityProviver) { + setTempFormValues(values); setConfirmPasswordOpen(true); } else { mutation.mutate(values); } - }}> -
- ( - <> - -
- { - formMethods.setValue("avatar", newAvatar, { shouldDirty: true }); - }} - imageSrc={value} - /> -
- + }} + extraField={ +
+ { + showToast(t("settings_updated_successfully"), "success"); + await utils.viewer.me.invalidate(); + }} + onErrorMutation={() => { + showToast(t("error_updating_settings"), "error"); + }} + /> +
+ } + /> + +
+ + + {/* Delete account Dialog */} + + + + + + <> +

{t("delete_account_confirmation_message", { appName: APP_NAME })}

+ {isCALIdentityProviver && ( + )} - /> -
-
- -
-
- -
-
- -
-
- -
- + {user?.twoFactorEnabled && isCALIdentityProviver && ( + + + + )} -
- - - {/* Delete account Dialog */} - - - - - - <> -

{t("delete_account_confirmation_message", { appName: APP_NAME })}

- {isCALIdentityProviver && ( - - )} - - {user?.twoFactorEnabled && isCALIdentityProviver && ( -
- - - )} - - {hasDeleteErrors && } - - - - - -
-
- + {hasDeleteErrors && } + + + + + + + {/* If changing email, confirm password */} @@ -363,6 +306,73 @@ const ProfileView = () => { ); }; +const ProfileForm = ({ + defaultValues, + onSubmit, + extraField, +}: { + defaultValues: FormValues; + onSubmit: (values: FormValues) => void; + extraField?: React.ReactNode; +}) => { + const { t } = useLocale(); + const emailMd5 = crypto + .createHash("md5") + .update(defaultValues.email || "example@example.com") + .digest("hex"); + + const formMethods = useForm({ + defaultValues, + }); + + const { + formState: { isSubmitting, isDirty }, + } = formMethods; + + const isDisabled = isSubmitting || !isDirty; + + return ( +
+
+ ( + <> + +
+ { + formMethods.setValue("avatar", newAvatar, { shouldDirty: true }); + }} + imageSrc={value || undefined} + /> +
+ + )} + /> +
+ {extraField} +
+ +
+
+ +
+
+ +
+ + +
+ ); +}; + ProfileView.getLayout = getLayout; export const getServerSideProps = async (context: GetServerSidePropsContext) => { diff --git a/packages/trpc/server/createContext.ts b/packages/trpc/server/createContext.ts index 10bec69e98..eeaf1b9c3c 100644 --- a/packages/trpc/server/createContext.ts +++ b/packages/trpc/server/createContext.ts @@ -3,6 +3,7 @@ import type { Session } from "next-auth"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { getSession } from "@calcom/lib/auth"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { getLocaleFromHeaders } from "@calcom/lib/i18n"; import { defaultAvatarSrc } from "@calcom/lib/profile"; import prisma from "@calcom/prisma"; @@ -87,15 +88,14 @@ async function getUserFromSession({ if (!email) { return null; } + const rawAvatar = user.avatar; // This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url - // TODO: Setting avatar value to /avatar.png(which is a dynamic route) would actually reset the avatar because /avatar.png is supposed to return the value of user.avatar - // if (user.avatar) user.avatar = `${CAL_URL}/${user.username}/avatar.png`; - const avatar = user.avatar || defaultAvatarSrc({ email }); + user.avatar = rawAvatar ? `${WEBAPP_URL}/${user.username}/avatar.png` : defaultAvatarSrc({ email }); const locale = user.locale || getLocaleFromHeaders(req); return { ...user, - avatar, + rawAvatar, email, username, locale, diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index 0f68d751dd..aa13ba2a2a 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -6,7 +6,6 @@ import z from "zod"; import app_RoutingForms from "@calcom/app-store/ee/routing-forms/trpc-router"; import ethRouter from "@calcom/app-store/rainbow/trpc/router"; import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; -import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession"; import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server"; import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils"; import getApps, { getLocationGroupedOptions } from "@calcom/app-store/utils"; @@ -19,7 +18,6 @@ import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; import { ErrorCode, verifyPassword } from "@calcom/lib/auth"; -import { CAL_URL } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import getStripeAppData from "@calcom/lib/getStripeAppData"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; @@ -160,7 +158,7 @@ const loggedInViewerRouter = router({ locale: user.locale, timeFormat: user.timeFormat, timeZone: user.timeZone, - avatar: `${CAL_URL}/${user.username}/avatar.png`, + avatar: user.avatar, createdDate: user.createdDate, trialEndsAt: user.trialEndsAt, completedOnboarding: user.completedOnboarding, @@ -178,6 +176,9 @@ const loggedInViewerRouter = router({ metadata: user.metadata, }; }), + avatar: authedProcedure.query(({ ctx }) => ({ + avatar: ctx.user.rawAvatar, + })), deleteMe: authedProcedure .input( z.object({ diff --git a/packages/ui/components/form/inputs/Input.tsx b/packages/ui/components/form/inputs/Input.tsx index d79c642c30..0f0075077e 100644 --- a/packages/ui/components/form/inputs/Input.tsx +++ b/packages/ui/components/form/inputs/Input.tsx @@ -1,12 +1,12 @@ import React, { forwardRef, ReactElement, ReactNode, Ref, useCallback, useId, useState } from "react"; -import { Edit2, Eye, EyeOff } from "react-feather"; +import { Eye, EyeOff } from "react-feather"; import { FieldValues, FormProvider, SubmitHandler, useFormContext, UseFormReturn } from "react-hook-form"; import classNames from "@calcom/lib/classNames"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Alert, showToast, Icon, Skeleton, Tooltip, UnstyledSelect } from "../../.."; +import { Alert, Icon, showToast, Skeleton, Tooltip, UnstyledSelect } from "../../.."; import { HintsOrErrors } from "./HintOrErrors"; import { Label } from "./Label";