diff --git a/apps/web/components/AppListCard.tsx b/apps/web/components/AppListCard.tsx index b41cddd1bef7bb..7252a8ffc66823 100644 --- a/apps/web/components/AppListCard.tsx +++ b/apps/web/components/AppListCard.tsx @@ -79,8 +79,8 @@ export default function AppListCard(props: AppListCardProps) { }, []); return ( -
-
+
+
{logo ? ( void; + listClassName?: string; } -export const AppList = ({ data, handleDisconnect, variant }: AppListProps) => { +export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppListProps) => { const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery(); const utils = trpc.useContext(); const [bulkUpdateModal, setBulkUpdateModal] = useState(false); @@ -155,7 +156,7 @@ export const AppList = ({ data, handleDisconnect, variant }: AppListProps) => { const { t } = useLocale(); return ( <> - + {cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))} {data.items .filter((item) => item.invalidCredentialIds) diff --git a/apps/web/pages/settings/billing/index.tsx b/apps/web/pages/settings/billing/index.tsx index 2f3f2a9a6faa4e..acf3b30578aa72 100644 --- a/apps/web/pages/settings/billing/index.tsx +++ b/apps/web/pages/settings/billing/index.tsx @@ -22,12 +22,11 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => { <>
-

{title}

+

{title}

{description}

{children}
-
); }; @@ -45,14 +44,16 @@ const BillingView = () => { return ( <> - -
+ +
+
+ ); }; + if (isLoading || !data) { + return ( + + ); + } + return ( <> } + borderInShellHeader={true} /> - <> - {isLoading && } -
- {isLoading ? null : data?.length ? ( - <> -
- {data.map((apiKey, index) => ( - { - setApiKeyToEdit(apiKey); - setApiKeyModal(true); - }} - /> - ))} -
- - - ) : ( - } - /> - )} -
- +
+ {data?.length ? ( + <> +
+ {data.map((apiKey, index) => ( + { + setApiKeyToEdit(apiKey); + setApiKeyModal(true); + }} + /> + ))} +
+ + ) : ( + } + /> + )} +
diff --git a/apps/web/pages/settings/my-account/appearance.tsx b/apps/web/pages/settings/my-account/appearance.tsx index 1b2269824b57f8..43ec340a979ab4 100644 --- a/apps/web/pages/settings/my-account/appearance.tsx +++ b/apps/web/pages/settings/my-account/appearance.tsx @@ -3,8 +3,10 @@ import { Controller, useForm } from "react-hook-form"; import type { z } from "zod"; import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import ThemeLabel from "@calcom/features/settings/ThemeLabel"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; +import { classNames } from "@calcom/lib"; import { APP_NAME } from "@calcom/lib/constants"; import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan"; @@ -12,6 +14,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import type { userMetadata } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; import { Alert, Button, @@ -22,7 +25,7 @@ import { SkeletonButton, SkeletonContainer, SkeletonText, - Switch, + SettingsToggle, UpgradeTeamsBadge, } from "@calcom/ui"; @@ -31,9 +34,9 @@ import PageWrapper from "@components/PageWrapper"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( - -
-
+ +
+
@@ -44,49 +47,83 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
- - +
+
+ + +
); }; -const AppearanceView = () => { +const DEFAULT_LIGHT_BRAND_COLOR = "#292929"; +const DEFAULT_DARK_BRAND_COLOR = "#fafafa"; + +const AppearanceView = ({ + user, + hasPaidPlan, +}: { + user: RouterOutputs["viewer"]["me"]; + hasPaidPlan: boolean; +}) => { const { t } = useLocale(); const utils = trpc.useContext(); - const { data: user, isLoading } = trpc.viewer.me.useQuery(); const [darkModeError, setDarkModeError] = useState(false); const [lightModeError, setLightModeError] = useState(false); + const [isCustomBrandColorChecked, setIsCustomBranColorChecked] = useState( + user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR + ); + const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false); - const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan(); + const userThemeFormMethods = useForm({ + defaultValues: { + theme: user.theme, + }, + }); + + const { + formState: { isSubmitting: isUserThemeSubmitting, isDirty: isUserThemeDirty }, + reset: resetUserThemeReset, + } = userThemeFormMethods; + + const bookerLayoutFormMethods = useForm({ + defaultValues: { + metadata: user.metadata as z.infer, + }, + }); - const formMethods = useForm({ + const { + formState: { isSubmitting: isBookerLayoutFormSubmitting, isDirty: isBookerLayoutFormDirty }, + reset: resetBookerLayoutThemeReset, + } = bookerLayoutFormMethods; + + const brandColorsFormMethods = useForm({ defaultValues: { - theme: user?.theme, - brandColor: user?.brandColor || "#292929", - darkBrandColor: user?.darkBrandColor || "#fafafa", - hideBranding: user?.hideBranding, - metadata: user?.metadata as z.infer, + brandColor: user.brandColor || DEFAULT_LIGHT_BRAND_COLOR, + darkBrandColor: user.darkBrandColor || DEFAULT_DARK_BRAND_COLOR, }, }); - const selectedTheme = formMethods.watch("theme"); + const { + formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty }, + reset: resetBrandColorsThemeReset, + } = brandColorsFormMethods; + + const selectedTheme = userThemeFormMethods.watch("theme"); const selectedThemeIsDark = selectedTheme === "dark" || (selectedTheme === "" && typeof document !== "undefined" && document.documentElement.classList.contains("dark")); - const { - formState: { isSubmitting, isDirty }, - reset, - } = formMethods; - const mutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (data) => { await utils.viewer.me.invalidate(); showToast(t("settings_updated_successfully"), "success"); - reset(data); + resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor }); + resetBookerLayoutThemeReset({ metadata: data.metadata }); + resetUserThemeReset({ theme: data.theme }); }, onError: (error) => { if (error.message) { @@ -97,136 +134,180 @@ const AppearanceView = () => { }, }); - if (isLoading || isTeamPlanStatusLoading) - return ; - - if (!user) return null; - - const isDisabled = isSubmitting || !isDirty; - return ( -
{ - const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null); - if (layoutError) throw new Error(t(layoutError)); - - mutation.mutate({ - ...values, - // Radio values don't support null as values, therefore we convert an empty string - // back to null here. - theme: values.theme || null, - }); - }}> - -
+
+ +
-

{t("theme")}

+

{t("theme")}

{t("theme_applies_note")}

-
- - - -
- -
- - -
-
-
-

{t("custom_brand_colors")}

-

{t("customize_your_brand_colors")}

+ { + mutation.mutate({ + // Radio values don't support null as values, therefore we convert an empty string + // back to null here. + theme: values.theme || null, + }); + }}> +
+ + +
-
+ + + + -
- ( -
-

{t("light_brand_color")}

- { + const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null); + if (layoutError) { + showToast(t(layoutError), "error"); + return; + } else { + mutation.mutate(values); + } + }}> + + + +
{ + mutation.mutate(values); + }}> +
+ { + setIsCustomBranColorChecked(checked); + if (!checked) { + mutation.mutate({ + brandColor: DEFAULT_LIGHT_BRAND_COLOR, + darkBrandColor: DEFAULT_DARK_BRAND_COLOR, + }); + } + }} + childrenClassName="lg:ml-0" + switchContainerClassName={classNames( + "py-6 px-4 sm:px-6 border-subtle rounded-xl border", + isCustomBrandColorChecked && "rounded-b-none" + )}> +
+ { - if (!checkWCAGContrastColor("#ffffff", value)) { - setLightModeError(true); - } else { - setLightModeError(false); - } - formMethods.setValue("brandColor", value, { shouldDirty: true }); - }} + render={() => ( +
+

{t("light_brand_color")}

+ { + try { + checkWCAGContrastColor("#ffffff", value); + setLightModeError(false); + brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true }); + } catch (err) { + setLightModeError(false); + } + }} + /> + {lightModeError ? ( +
+ +
+ ) : null} +
+ )} /> -
- )} - /> - ( -
-

{t("dark_brand_color")}

- { - if (!checkWCAGContrastColor("#101010", value)) { - setDarkModeError(true); - } else { - setDarkModeError(false); - } - formMethods.setValue("darkBrandColor", value, { shouldDirty: true }); - }} + render={() => ( +
+

{t("dark_brand_color")}

+ { + try { + checkWCAGContrastColor("#101010", value); + setDarkModeError(false); + brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true }); + } catch (err) { + setDarkModeError(true); + } + }} + /> + {darkModeError ? ( +
+ +
+ ) : null} +
+ )} />
- )} - /> -
- {darkModeError ? ( -
- -
- ) : null} - {lightModeError ? ( -
- + + + +
- ) : null} +
+ {/* TODO future PR to preview brandColors */} {/* */} -
- ( - <> -
-
-
-

- {t("disable_cal_branding", { appName: APP_NAME })} -

- -
-

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

-
-
- - formMethods.setValue("hideBranding", checked, { shouldDirty: true }) - } - checked={hasPaidPlan ? value : false} - /> -
-
- - )} + + } + onCheckedChange={(checked) => { + setHideBrandingValue(checked); + mutation.mutate({ hideBranding: checked }); + }} + switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6" /> - - +
); }; -AppearanceView.getLayout = getLayout; -AppearanceView.PageWrapper = PageWrapper; +const AppearanceViewWrapper = () => { + const { data: user, isLoading } = trpc.viewer.me.useQuery(); + const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan(); + + const { t } = useLocale(); + + if (isLoading || isTeamPlanStatusLoading || !user) + return ; + + return ; +}; + +AppearanceViewWrapper.getLayout = getLayout; +AppearanceViewWrapper.PageWrapper = PageWrapper; -export default AppearanceView; +export default AppearanceViewWrapper; diff --git a/apps/web/pages/settings/my-account/calendars.tsx b/apps/web/pages/settings/my-account/calendars.tsx index f9c630d4738daf..71ca36af79fc2e 100644 --- a/apps/web/pages/settings/my-account/calendars.tsx +++ b/apps/web/pages/settings/my-account/calendars.tsx @@ -1,11 +1,12 @@ import { Trans } from "next-i18next"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Fragment } from "react"; +import { Fragment, useState, useEffect } from "react"; import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration"; import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch"; import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -34,13 +35,13 @@ import PageWrapper from "@components/PageWrapper"; const SkeletonLoader = () => { return ( -
+
- +
); @@ -65,6 +66,21 @@ const CalendarsView = () => { const utils = trpc.useContext(); const query = trpc.viewer.connectedCalendars.useQuery(); + + const [selectedDestinationCalendarOption, setSelectedDestinationCalendar] = useState<{ + integration: string; + externalId: string; + } | null>(null); + + useEffect(() => { + if (query?.data?.destinationCalendar) { + setSelectedDestinationCalendar({ + integration: query.data.destinationCalendar.integration, + externalId: query.data.destinationCalendar.externalId, + }); + } + }, [query?.isLoading, query?.data?.destinationCalendar]); + const mutation = trpc.viewer.setDestinationCalendar.useMutation({ async onSettled() { await utils.viewer.connectedCalendars.invalidate(); @@ -79,43 +95,58 @@ const CalendarsView = () => { return ( <> - } /> + } + borderInShellHeader={false} + /> } success={({ data }) => { + const isDestinationUpdateBtnDisabled = + selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId; return data.connectedCalendars.length ? (
-
-
- -
- -
-
-

- {t("add_to_calendar")} -

-

- - Where to add events when you re booked. You can override this on a per-event basis in - advanced settings in the event type. - -

-
- -
+
+

+ {t("add_to_calendar")} +

+

{t("add_to_calendar_description")}

-

- {t("check_for_conflicts")} -

-

{t("select_calendars")}

- +
+ { + setSelectedDestinationCalendar(option); + }} + isLoading={mutation.isLoading} + /> +
+ + + + +
+

+ {t("check_for_conflicts")} +

+

{t("select_calendars")}

+
+ + {data.connectedCalendars.map((item) => ( {item.error && item.error.message && ( @@ -207,6 +238,7 @@ const CalendarsView = () => { description={t("no_calendar_installed_description")} buttonText={t("add_a_calendar")} buttonOnClick={() => router.push("/apps/categories/calendar")} + className="mt-6" /> ); }} diff --git a/apps/web/pages/settings/my-account/conferencing.tsx b/apps/web/pages/settings/my-account/conferencing.tsx index be48afc6ca352c..96173690377aa1 100644 --- a/apps/web/pages/settings/my-account/conferencing.tsx +++ b/apps/web/pages/settings/my-account/conferencing.tsx @@ -15,8 +15,8 @@ import { AppList } from "@components/apps/AppList"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( - -
+ +
@@ -28,11 +28,9 @@ const AddConferencingButton = () => { const { t } = useLocale(); return ( - <> - - + ); }; @@ -72,6 +70,7 @@ const ConferencingLayout = () => { title={t("conferencing")} description={t("conferencing_description")} CTA={} + borderInShellHeader={true} /> { color="secondary" data-testid="connect-conferencing-apps" href="/apps/categories/conferencing"> - {t("connect_conferencing_apps")} + {t("connect_conference_apps")} } /> ); } - return ; + return ( + + ); }} />
diff --git a/apps/web/pages/settings/my-account/general.tsx b/apps/web/pages/settings/my-account/general.tsx index 688a79ff6a1c41..131ce856b47b86 100644 --- a/apps/web/pages/settings/my-account/general.tsx +++ b/apps/web/pages/settings/my-account/general.tsx @@ -1,6 +1,8 @@ import { useSession } from "next-auth/react"; +import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localeOptions } from "@calcom/lib/i18n"; @@ -13,12 +15,12 @@ import { Label, Meta, Select, - SettingsToggle, showToast, SkeletonButton, SkeletonContainer, SkeletonText, TimezoneSelect, + SettingsToggle, } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; @@ -26,14 +28,14 @@ import PageWrapper from "@components/PageWrapper"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( - -
+ +
- +
); @@ -59,6 +61,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => { const utils = trpc.useContext(); const { t } = useLocale(); const { update } = useSession(); + const [isUpdateBtnLoading, setIsUpdateBtnLoading] = useState(false); const mutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (res) => { @@ -72,6 +75,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => { }, onSettled: async () => { await utils.viewer.me.invalidate(); + setIsUpdateBtnLoading(false); }, }); @@ -105,9 +109,6 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => { value: user.weekStart, label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1), }, - allowDynamicBooking: user.allowDynamicBooking ?? true, - allowSEOIndexing: user.allowSEOIndexing ?? true, - receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail ?? true, }, }); const { @@ -117,151 +118,150 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => { } = formMethods; const isDisabled = isSubmitting || !isDirty; + const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] = useState( + !!user.allowDynamicBooking + ); + const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState(!!user.allowSEOIndexing); + const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState( + !!user.receiveMonthlyDigestEmail + ); + return ( -
{ - mutation.mutate({ - ...values, - locale: values.locale.value, - timeFormat: values.timeFormat.value, - weekStart: values.weekStart.value, - }); - }}> - - ( - <> - - - className="capitalize" - options={localeOptions} - value={value} - onChange={onChange} - /> - - )} - /> - ( - <> - - { - if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true }); - }} - /> - - )} - /> - ( - <> - - { - if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true }); - }} - /> - - )} - /> -
- ( - { - formMethods.setValue("allowDynamicBooking", checked, { shouldDirty: true }); - }} - /> - )} - /> -
+
+ { + setIsUpdateBtnLoading(true); + mutation.mutate({ + ...values, + locale: values.locale.value, + timeFormat: values.timeFormat.value, + weekStart: values.weekStart.value, + }); + }}> + +
+ ( + <> + + + className="capitalize" + options={localeOptions} + value={value} + onChange={onChange} + /> + + )} + /> + ( + <> + + { + if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true }); + }} + /> + + )} + /> + ( + <> + + { + if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true }); + }} + /> + + )} + /> +
-
- ( - { - formMethods.setValue("allowSEOIndexing", checked, { shouldDirty: true }); - }} - /> - )} - /> -
+ + + + -
- ( - { - formMethods.setValue("receiveMonthlyDigestEmail", checked, { shouldDirty: true }); - }} - /> - )} - /> -
+ { + setIsAllowDynamicBookingChecked(checked); + mutation.mutate({ allowDynamicBooking: checked }); + }} + switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6" + /> + + { + setIsAllowSEOIndexingChecked(checked); + mutation.mutate({ allowSEOIndexing: checked }); + }} + switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6" + /> - - + { + setIsReceiveMonthlyDigestEmailChecked(checked); + mutation.mutate({ receiveMonthlyDigestEmail: checked }); + }} + switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6" + /> +
); }; diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index 2b738408e5434b..f2c86de11b7a03 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -7,8 +7,10 @@ import { z } from "zod"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar"; +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"; @@ -47,8 +49,8 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( - -
+ +
@@ -69,18 +71,30 @@ interface DeleteAccountValues { type FormValues = { username: string; - avatar: string; + avatar: string | null; name: string; email: string; bio: string; }; +const checkIfItFallbackImage = (fetchedImgSrc: string) => { + return fetchedImgSrc.endsWith(AVATAR_FALLBACK); +}; + const ProfileView = () => { const { t } = useLocale(); const utils = trpc.useContext(); const { update } = useSession(); - const { data: user, isLoading } = trpc.viewer.me.useQuery(); + const [fetchedImgSrc, setFetchedImgSrc] = useState(); + + const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, { + onSuccess: (userData) => { + fetch(userData.avatar).then((res) => { + if (res.url) setFetchedImgSrc(res.url); + }); + }, + }); const updateProfileMutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (res) => { await update(res); @@ -204,7 +218,7 @@ const ProfileView = () => { [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"), }; - if (isLoading || !user) + if (isLoading || !user || !fetchedImgSrc) return ( ); @@ -219,11 +233,17 @@ const ProfileView = () => { return ( <> - + { if (values.email !== user.email && isCALIdentityProvider) { @@ -238,7 +258,7 @@ const ProfileView = () => { } }} extraField={ -
+
{ showToast(t("settings_updated_successfully"), "success"); @@ -252,16 +272,19 @@ const ProfileView = () => { } /> -
- - +
+ +

{t("account_deletion_cannot_be_undone")}

+
{/* Delete account Dialog */} - - - + + + + + void; extraField?: React.ReactNode; isLoading: boolean; + isFallbackImg: boolean; + userAvatar: string; userOrganization: RouterOutputs["viewer"]["me"]["organization"]; }) => { const { t } = useLocale(); @@ -377,7 +404,7 @@ const ProfileForm = ({ const profileFormSchema = z.object({ username: z.string(), - avatar: z.string(), + avatar: z.string().nullable(), name: z .string() .trim() @@ -402,56 +429,77 @@ const ProfileForm = ({ return (
-
- ( - <> - -
- { - formMethods.setValue("avatar", newAvatar, { shouldDirty: true }); - }} - imageSrc={value || undefined} - /> -
- - )} - /> -
- {extraField} -
- -
-
- -
-
- - md.render(formMethods.getValues("bio") || "")} - setText={(value: string) => { - formMethods.setValue("bio", turndown(value), { shouldDirty: true }); - }} - excludedToolbarItems={["blockType"]} - disableLists - firstRender={firstRender} - setFirstRender={setFirstRender} - /> +
+
+ { + const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value); + 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} + /> +
- + + + ); }; diff --git a/apps/web/pages/settings/security/impersonation.tsx b/apps/web/pages/settings/security/impersonation.tsx index fbe31be02ba233..d3afab267ee364 100644 --- a/apps/web/pages/settings/security/impersonation.tsx +++ b/apps/web/pages/settings/security/impersonation.tsx @@ -1,23 +1,34 @@ -import type { GetServerSidePropsContext } from "next"; -import { useForm } from "react-hook-form"; +import { useState } from "react"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { Button, Form, Label, Meta, showToast, Skeleton, Switch } from "@calcom/ui"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Meta, showToast, SettingsToggle, SkeletonContainer, SkeletonText } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; -import { ssrInit } from "@server/lib/ssr"; +const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { + return ( + + +
+ +
+
+ ); +}; -const ProfileImpersonationView = () => { +const ProfileImpersonationView = ({ user }: { user: RouterOutputs["viewer"]["me"] }) => { const { t } = useLocale(); const utils = trpc.useContext(); - const { data: user } = trpc.viewer.me.useQuery(); + const [disableImpersonation, setDisableImpersonation] = useState( + user?.disableImpersonation + ); + const mutation = trpc.viewer.updateProfile.useMutation({ onSuccess: () => { showToast(t("profile_updated_successfully"), "success"); - reset(getValues()); }, onSettled: () => { utils.viewer.me.invalidate(); @@ -26,83 +37,54 @@ const ProfileImpersonationView = () => { await utils.viewer.me.cancel(); const previousValue = utils.viewer.me.getData(); - if (previousValue && disableImpersonation) { - utils.viewer.me.setData(undefined, { ...previousValue, disableImpersonation }); - } + setDisableImpersonation(disableImpersonation); + return { previousValue }; }, onError: (error, variables, context) => { if (context?.previousValue) { utils.viewer.me.setData(undefined, context.previousValue); + setDisableImpersonation(context.previousValue?.disableImpersonation); } showToast(`${t("error")}, ${error.message}`, "error"); }, }); - const formMethods = useForm<{ disableImpersonation: boolean }>({ - defaultValues: { - disableImpersonation: user?.disableImpersonation, - }, - }); - - const { - formState: { isSubmitting, isDirty }, - setValue, - reset, - getValues, - watch, - } = formMethods; - - const isDisabled = isSubmitting || !isDirty; return ( <> - -
{ - mutation.mutate({ disableImpersonation }); - }}> -
- { - setValue("disableImpersonation", !e, { shouldDirty: true }); - }} - fitToHeight={true} - checked={!watch("disableImpersonation")} - /> -
- - {t("user_impersonation_heading")} - - - {t("user_impersonation_description")} - -
-
- -
+ +
+ { + mutation.mutate({ disableImpersonation: !checked }); + }} + disabled={mutation.isLoading} + switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0" + /> +
); }; -ProfileImpersonationView.getLayout = getLayout; -ProfileImpersonationView.PageWrapper = PageWrapper; +const ProfileImpersonationViewWrapper = () => { + const { data: user, isLoading } = trpc.viewer.me.useQuery(); + const { t } = useLocale(); -export const getServerSideProps = async (context: GetServerSidePropsContext) => { - const ssr = await ssrInit(context); - await ssr.viewer.me.prefetch(); - return { - props: { - trpcState: ssr.dehydrate(), - }, - }; + if (isLoading || !user) + return ; + + return ; }; -export default ProfileImpersonationView; +ProfileImpersonationViewWrapper.getLayout = getLayout; +ProfileImpersonationViewWrapper.PageWrapper = PageWrapper; + +export default ProfileImpersonationViewWrapper; diff --git a/apps/web/pages/settings/security/password.tsx b/apps/web/pages/settings/security/password.tsx index 6da897b4301cb4..71077c944725ac 100644 --- a/apps/web/pages/settings/security/password.tsx +++ b/apps/web/pages/settings/security/password.tsx @@ -1,13 +1,29 @@ import { signOut, useSession } from "next-auth/react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; +import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { IdentityProvider } from "@calcom/prisma/enums"; -import { userMetadata } from "@calcom/prisma/zod-utils"; +import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; -import { Alert, Button, Form, Meta, PasswordField, Select, SettingsToggle, showToast } from "@calcom/ui"; +import { + Alert, + Button, + Form, + Meta, + PasswordField, + Select, + SettingsToggle, + showToast, + SkeletonButton, + SkeletonContainer, + SkeletonText, +} from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; @@ -18,34 +34,58 @@ type ChangePasswordSessionFormValues = { apiError: string; }; -const PasswordView = () => { +interface PasswordViewProps { + user: RouterOutputs["viewer"]["me"]; +} + +const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { + return ( + + +
+ + + +
+
+ + + +
+
+ ); +}; + +const PasswordView = ({ user }: PasswordViewProps) => { const { data } = useSession(); const { t } = useLocale(); const utils = trpc.useContext(); - const { data: user } = trpc.viewer.me.useQuery(); - const metadata = userMetadata.safeParse(user?.metadata); - const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined; + const metadata = userMetadataSchema.safeParse(user?.metadata); + const initialSessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined; + + const [sessionTimeout, setSessionTimeout] = useState(initialSessionTimeout); const sessionMutation = trpc.viewer.updateProfile.useMutation({ - onSuccess: () => { + onSuccess: (data) => { showToast(t("session_timeout_changed"), "success"); formMethods.reset(formMethods.getValues()); + setSessionTimeout(data.metadata?.sessionTimeout); }, onSettled: () => { utils.viewer.me.invalidate(); }, onMutate: async () => { await utils.viewer.me.cancel(); - const previousValue = utils.viewer.me.getData(); - const previousMetadata = userMetadata.parse(previousValue?.metadata); + const previousValue = await utils.viewer.me.getData(); + const previousMetadata = userMetadataSchema.safeParse(previousValue?.metadata); - if (previousValue && sessionTimeout) { + if (previousValue && sessionTimeout && previousMetadata.success) { utils.viewer.me.setData(undefined, { ...previousValue, - metadata: { ...previousMetadata, sessionTimeout: sessionTimeout }, + metadata: { ...previousMetadata?.data, sessionTimeout: sessionTimeout }, }); + return { previousValue }; } - return { previousValue }; }, onError: (error, _, context) => { if (context?.previousValue) { @@ -84,20 +124,30 @@ const PasswordView = () => { defaultValues: { oldPassword: "", newPassword: "", - sessionTimeout, }, }); - const sessionTimeoutWatch = formMethods.watch("sessionTimeout"); - const handleSubmit = (values: ChangePasswordSessionFormValues) => { - const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values; + const { oldPassword, newPassword } = values; + + if (!oldPassword.length) { + formMethods.setError( + "oldPassword", + { type: "required", message: t("error_required_field") }, + { shouldFocus: true } + ); + } + if (!newPassword.length) { + formMethods.setError( + "newPassword", + { type: "required", message: t("error_required_field") }, + { shouldFocus: true } + ); + } + if (oldPassword && newPassword) { passwordMutation.mutate({ oldPassword, newPassword }); } - if (sessionTimeout !== newSessionTimeout) { - sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } }); - } }; const timeoutOptions = [5, 10, 15].map((mins) => ({ @@ -112,7 +162,7 @@ const PasswordView = () => { return ( <> - + {user && user.identityProvider !== IdentityProvider.CAL ? (
@@ -130,87 +180,127 @@ const PasswordView = () => {
) : (
- {formMethods.formState.errors.apiError && ( -
- -
- )} - -
-
- -
-
- +
+ {formMethods.formState.errors.apiError && ( +
+ +
+ )} +
+
+ +
+
+ +
+

+ {t("invalid_password_hint", { passwordLength: passwordMinLength })} +

-

- {t("invalid_password_hint", { passwordLength: passwordMinLength })} -

-
+ + + +
{ if (!e) { - formMethods.setValue("sessionTimeout", undefined, { shouldDirty: true }); + setSessionTimeout(undefined); + + if (metadata.success) { + sessionMutation.mutate({ + metadata: { ...metadata.data, sessionTimeout: undefined }, + }); + } } else { - formMethods.setValue("sessionTimeout", 10, { shouldDirty: true }); + setSessionTimeout(10); } }} - /> - {sessionTimeoutWatch && ( -
-
-

{t("session_timeout_after")}

- tmo.value === sessionTimeout) + : timeoutOptions[1] + } + isSearchable={false} + className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm" + onChange={(event) => { + setSessionTimeout(event?.value); + }} + /> +
-
- )} + + + + +
- {/* TODO: Why is this Form not submitting? Hacky fix but works */} - )} ); }; -PasswordView.getLayout = getLayout; -PasswordView.PageWrapper = PageWrapper; +const PasswordViewWrapper = () => { + const { data: user, isLoading } = trpc.viewer.me.useQuery(); + const { t } = useLocale(); + if (isLoading || !user) + return ; + + return ; +}; + +PasswordViewWrapper.getLayout = getLayout; +PasswordViewWrapper.PageWrapper = PageWrapper; -export default PasswordView; +export default PasswordViewWrapper; diff --git a/apps/web/pages/settings/security/two-factor-auth.tsx b/apps/web/pages/settings/security/two-factor-auth.tsx index fd5c1a7e202d69..7cd47f33175a37 100644 --- a/apps/web/pages/settings/security/two-factor-auth.tsx +++ b/apps/web/pages/settings/security/two-factor-auth.tsx @@ -3,15 +3,24 @@ import { useState } from "react"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { Badge, Meta, Switch, SkeletonButton, SkeletonContainer, SkeletonText, Alert } from "@calcom/ui"; +import { + Badge, + Meta, + SkeletonButton, + SkeletonContainer, + SkeletonText, + Alert, + SettingsToggle, +} from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; import DisableTwoFactorModal from "@components/settings/DisableTwoFactorModal"; import EnableTwoFactorModal from "@components/settings/EnableTwoFactorModal"; -const SkeletonLoader = () => { +const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( +
@@ -28,36 +37,34 @@ const TwoFactorAuthView = () => { const { t } = useLocale(); const { data: user, isLoading } = trpc.viewer.me.useQuery(); - const [enableModalOpen, setEnableModalOpen] = useState(false); - const [disableModalOpen, setDisableModalOpen] = useState(false); + const [enableModalOpen, setEnableModalOpen] = useState(false); + const [disableModalOpen, setDisableModalOpen] = useState(false); - if (isLoading) return ; + if (isLoading) + return ; const isCalProvider = user?.identityProvider === "CAL"; const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled; return ( <> - + {canSetupTwoFactor && } -
- - user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true) - } - /> -
-
-

{t("two_factor_auth")}

- - {user?.twoFactorEnabled ? t("enabled") : t("disabled")} - -
-

{t("add_an_extra_layer_of_security")}

-
-
+ + user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true) + } + Badge={ + + {user?.twoFactorEnabled ? t("enabled") : t("disabled")} + + } + switchContainerClassName="border-subtle rounded-b-xl border border-t-0 px-5 py-6 sm:px-6" + /> + className={classNames( + "flex w-full justify-between px-4 py-4 sm:px-6", + lastItem ? "" : "border-subtle border-b" + )}>
-

{apiKey?.note ? apiKey.note : t("api_key_no_note")}

-
+
+

{apiKey?.note ? apiKey.note : t("api_key_no_note")}

{!neverExpires && isExpired && {t("expired")}} {!isExpired && {t("active")}} -

- {" "} +

+
+

{neverExpires ? ( -

{t("api_key_never_expires")}
+
{t("api_key_never_expires")}
) : ( `${isExpired ? t("expired") : t("expires")} ${dayjs(apiKey?.expiresAt?.toString()).fromNow()}` )} @@ -71,6 +81,8 @@ const ApiKeyListItem = ({ deleteApiKey.mutate({ id: apiKey.id, diff --git a/packages/features/ee/sso/components/OIDCConnection.tsx b/packages/features/ee/sso/components/OIDCConnection.tsx index 245b651c19e308..daef6902a77ba2 100644 --- a/packages/features/ee/sso/components/OIDCConnection.tsx +++ b/packages/features/ee/sso/components/OIDCConnection.tsx @@ -145,7 +145,7 @@ const CreateConnectionDialog = ({ )} />
- + + + )} /> @@ -141,7 +155,7 @@ const BookerLayoutFields = ({ settings, onChange, showUserSettings, isDark }: Bo }; return ( -
+
{ + return ( +
+ {children} +
+ ); +}; + +export default SectionBottomActions; diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 44c6986c7171a2..2ffe84f7f429f7 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -632,7 +632,7 @@ export default function SettingsLayout({ setSideContainerOpen(!sideContainerOpen)} /> }>
-
+
}>{children} @@ -675,33 +675,40 @@ type SidebarContainerElementProps = { export const getLayout = (page: React.ReactElement) => {page}; -function ShellHeader() { +export function ShellHeader() { const { meta } = useMeta(); const { t, isLocaleReady } = useLocale(); return ( -
-
- {meta.backButton && ( - - - - )} -
- {meta.title && isLocaleReady ? ( -

- {t(meta.title)} -

- ) : ( -
- )} - {meta.description && isLocaleReady ? ( -

{t(meta.description)}

- ) : ( -
+ <> +
+
+ {meta.backButton && ( + + + )} +
+ {meta.title && isLocaleReady ? ( +

+ {t(meta.title)} +

+ ) : ( +
+ )} + {meta.description && isLocaleReady ? ( +

{t(meta.description)}

+ ) : ( +
+ )} +
+
{meta.CTA}
-
{meta.CTA}
-
-
+
+ ); } diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 773a93c39d398e..ab32c2a5fc46b0 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -1008,7 +1008,7 @@ function MainContainer({
{/* show top navigation for md and smaller (tablet and phones) */} {TopNavContainerProp} -
+
{!props.withoutMain ? {props.children} : props.children} diff --git a/packages/features/webhooks/components/WebhookForm.tsx b/packages/features/webhooks/components/WebhookForm.tsx index 3f34feabb819b9..ca3e924fb066f6 100644 --- a/packages/features/webhooks/components/WebhookForm.tsx +++ b/packages/features/webhooks/components/WebhookForm.tsx @@ -5,18 +5,9 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; -import { - Button, - Form, - Label, - Select, - Switch, - TextArea, - TextField, - ToggleGroup, - DialogFooter, -} from "@calcom/ui"; +import { Button, Form, Label, Select, Switch, TextArea, TextField, ToggleGroup } from "@calcom/ui"; +import SectionBottomActions from "../../settings/SectionBottomActions"; import customTemplate, { hasTemplateIntegration } from "../lib/integrationTemplate"; import WebhookTestDisclosure from "./WebhookTestDisclosure"; @@ -87,7 +78,7 @@ const WebhookForm = (props: { const [useCustomTemplate, setUseCustomTemplate] = useState(false); const [newSecret, setNewSecret] = useState(""); - const [changeSecret, setChangeSecret] = useState(false); + const [changeSecret, setChangeSecret] = useState(false); const hasSecretKey = !!props?.webhook?.secret; // const currentSecret = props?.webhook?.secret; @@ -98,10 +89,10 @@ const WebhookForm = (props: { }, [changeSecret, formMethods]); return ( - <> -
props.onSubmit({ ...values, changeSecret, newSecret })}> + props.onSubmit({ ...values, changeSecret, newSecret })}> +
{ - formMethods.setValue("subscriberUrl", e?.target.value); + formMethods.setValue("subscriberUrl", e?.target.value, { shouldDirty: true }); if (hasTemplateIntegration({ url: e.target.value })) { setUseCustomTemplate(true); - formMethods.setValue("payloadTemplate", customTemplate({ url: e.target.value })); + formMethods.setValue("payloadTemplate", customTemplate({ url: e.target.value }), { + shouldDirty: true, + }); } }} /> @@ -129,13 +122,13 @@ const WebhookForm = (props: { name="active" control={formMethods.control} render={({ field: { value } }) => ( -
+
{ - formMethods.setValue("active", value); + formMethods.setValue("active", value, { shouldDirty: true }); }} />
@@ -147,8 +140,8 @@ const WebhookForm = (props: { render={({ field: { onChange, value } }) => { const selectValue = translatedTriggerOptions.filter((option) => value.includes(option.value)); return ( -
-