refactor: Setting redesign (#11124)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Udit Takkar
2023-09-29 15:10:13 +01:00
committed by Alex van Andel
co-authored by Peer Richelsen
parent 85a1713897
commit 685be1663e
33 changed files with 1184 additions and 782 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ export default function AppListCard(props: AppListCardProps) {
}, []);
return (
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
<div className="flex items-center gap-x-3 px-5 py-4">
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
{logo ? (
<img
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
+3 -2
View File
@@ -29,9 +29,10 @@ interface AppListProps {
variant?: AppCategories;
data: RouterOutputs["viewer"]["integrations"];
handleDisconnect: (credentialId: number) => 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 (
<>
<List>
<List className={listClassName}>
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
{data.items
.filter((item) => item.invalidCredentialIds)
+5 -4
View File
@@ -22,12 +22,11 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
<>
<section className={classNames("text-default flex flex-col sm:flex-row", className)}>
<div>
<h2 className="font-medium">{title}</h2>
<h2 className="text-base font-semibold">{title}</h2>
<p>{description}</p>
</div>
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pl-3 sm:pt-0">{children}</div>
</section>
<hr className="border-subtle" />
</>
);
};
@@ -45,14 +44,16 @@ const BillingView = () => {
return (
<>
<Meta title={t("billing")} description={t("manage_billing_description")} />
<div className="space-y-6 text-sm sm:space-y-8">
<Meta title={t("billing")} description={t("manage_billing_description")} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-8 text-sm sm:space-y-8">
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
{t("billing_portal")}
</Button>
</CtaRow>
<hr className="border-subtle" />
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
<Button color="secondary" onClick={onContactSupportClick}>
{t("contact_support")}
+53 -32
View File
@@ -14,12 +14,25 @@ import {
DialogContent,
EmptyScreen,
Meta,
AppSkeletonLoader as SkeletonLoader,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import { Link as LinkIcon, Plus } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
const ApiKeysView = () => {
const { t } = useLocale();
@@ -39,49 +52,57 @@ const ApiKeysView = () => {
setApiKeyToEdit(undefined);
setApiKeyModal(true);
}}>
{t("new_api_key")}
{t("add")}
</Button>
);
};
if (isLoading || !data) {
return (
<SkeletonLoader
title={t("api_keys")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
/>
);
}
return (
<>
<Meta
title={t("api_keys")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
CTA={<NewApiKeyButton />}
borderInShellHeader={true}
/>
<LicenseRequired>
<>
{isLoading && <SkeletonLoader />}
<div>
{isLoading ? null : data?.length ? (
<>
<div className="border-subtle mb-8 mt-6 rounded-md border">
{data.map((apiKey, index) => (
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
lastItem={data.length === index + 1}
onEditClick={() => {
setApiKeyToEdit(apiKey);
setApiKeyModal(true);
}}
/>
))}
</div>
<NewApiKeyButton />
</>
) : (
<EmptyScreen
Icon={LinkIcon}
headline={t("create_first_api_key")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
buttonRaw={<NewApiKeyButton />}
/>
)}
</div>
</>
<div>
{data?.length ? (
<>
<div className="border-subtle rounded-b-md border border-t-0">
{data.map((apiKey, index) => (
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
lastItem={data.length === index + 1}
onEditClick={() => {
setApiKeyToEdit(apiKey);
setApiKeyModal(true);
}}
/>
))}
</div>
</>
) : (
<EmptyScreen
Icon={LinkIcon}
headline={t("create_first_api_key")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
className="rounded-b-md rounded-t-none border-t-0"
buttonRaw={<NewApiKeyButton />}
/>
)}
</div>
</LicenseRequired>
<Dialog open={apiKeyModal} onOpenChange={setApiKeyModal}>
+250 -183
View File
@@ -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 (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mb-8 mt-6 space-y-6">
<div className="flex items-center">
<Meta title={title} description={description} borderInShellHeader={false} />
<div className="border-subtle mt-6 space-y-6 rounded-t-xl border border-b-0 px-4 py-6 sm:px-6">
<div className="flex items-center justify-center">
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
@@ -44,49 +47,83 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
<div className="rounded-b-xl">
<SectionBottomActions align="end">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</SectionBottomActions>
</div>
</SkeletonContainer>
);
};
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 formMethods = useForm({
const userThemeFormMethods = useForm({
defaultValues: {
theme: user?.theme,
brandColor: user?.brandColor || "#292929",
darkBrandColor: user?.darkBrandColor || "#fafafa",
hideBranding: user?.hideBranding,
metadata: user?.metadata as z.infer<typeof userMetadata>,
theme: user.theme,
},
});
const selectedTheme = formMethods.watch("theme");
const {
formState: { isSubmitting: isUserThemeSubmitting, isDirty: isUserThemeDirty },
reset: resetUserThemeReset,
} = userThemeFormMethods;
const bookerLayoutFormMethods = useForm({
defaultValues: {
metadata: user.metadata as z.infer<typeof userMetadata>,
},
});
const {
formState: { isSubmitting: isBookerLayoutFormSubmitting, isDirty: isBookerLayoutFormDirty },
reset: resetBookerLayoutThemeReset,
} = bookerLayoutFormMethods;
const brandColorsFormMethods = useForm({
defaultValues: {
brandColor: user.brandColor || DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor: user.darkBrandColor || DEFAULT_DARK_BRAND_COLOR,
},
});
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 <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
if (!user) return null;
const isDisabled = isSubmitting || !isDirty;
return (
<Form
form={formMethods}
handleSubmit={(values) => {
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,
});
}}>
<Meta title={t("appearance")} description={t("appearance_description")} />
<div className="mb-6 flex items-center text-sm">
<div>
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<div>
<p className="text-default font-semibold">{t("theme")}</p>
<p className="text-default text-base font-semibold">{t("theme")}</p>
<p className="text-default">{t("theme_applies_note")}</p>
</div>
</div>
<div className="flex flex-col justify-between sm:flex-row">
<ThemeLabel
variant="system"
value={null}
label={t("theme_system")}
defaultChecked={user.theme === null}
register={formMethods.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={user.theme === "light"}
register={formMethods.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={user.theme === "dark"}
register={formMethods.register}
/>
</div>
<hr className="border-subtle my-8 border [&:has(+hr)]:hidden" />
<BookerLayoutSelector
isDark={selectedThemeIsDark}
name="metadata.defaultBookerLayouts"
title={t("bookerlayout_user_settings_title")}
description={t("bookerlayout_user_settings_description")}
/>
<hr className="border-subtle my-8 border" />
<div className="mb-6 flex items-center text-sm">
<div>
<p className="text-default font-semibold">{t("custom_brand_colors")}</p>
<p className="text-default mt-0.5 leading-5">{t("customize_your_brand_colors")}</p>
<Form
form={userThemeFormMethods}
handleSubmit={(values) => {
mutation.mutate({
// Radio values don't support null as values, therefore we convert an empty string
// back to null here.
theme: values.theme || null,
});
}}>
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
<ThemeLabel
variant="system"
value={null}
label={t("theme_system")}
defaultChecked={user.theme === null}
register={userThemeFormMethods.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={user.theme === "light"}
register={userThemeFormMethods.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={user.theme === "dark"}
register={userThemeFormMethods.register}
/>
</div>
</div>
<SectionBottomActions className="mb-6" align="end">
<Button
disabled={isUserThemeSubmitting || !isUserThemeDirty}
type="submit"
data-testid="update-theme-btn"
color="primary">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
<div className="block justify-between sm:flex">
<Controller
name="brandColor"
control={formMethods.control}
defaultValue={user.brandColor}
render={() => (
<div>
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
<Form
form={bookerLayoutFormMethods}
handleSubmit={(values) => {
const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null);
if (layoutError) {
showToast(t(layoutError), "error");
return;
} else {
mutation.mutate(values);
}
}}>
<BookerLayoutSelector
isDark={selectedThemeIsDark}
name="metadata.defaultBookerLayouts"
title={t("bookerlayout_user_settings_title")}
description={t("bookerlayout_user_settings_description")}
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
/>
</Form>
<Form
form={brandColorsFormMethods}
handleSubmit={(values) => {
mutation.mutate(values);
}}>
<div className="mt-6">
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("custom_brand_colors")}
description={t("customize_your_brand_colors")}
checked={isCustomBrandColorChecked}
onCheckedChange={(checked) => {
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"
)}>
<div className="border-subtle flex flex-col gap-6 border-x p-6">
<Controller
name="brandColor"
control={brandColorsFormMethods.control}
defaultValue={user.brandColor}
resetDefaultValue="#292929"
onChange={(value) => {
if (!checkWCAGContrastColor("#ffffff", value)) {
setLightModeError(true);
} else {
setLightModeError(false);
}
formMethods.setValue("brandColor", value, { shouldDirty: true });
}}
render={() => (
<div>
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={user.brandColor}
resetDefaultValue="#292929"
onChange={(value) => {
try {
checkWCAGContrastColor("#ffffff", value);
setLightModeError(false);
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
} catch (err) {
setLightModeError(false);
}
}}
/>
{lightModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</div>
)}
/>
</div>
)}
/>
<Controller
name="darkBrandColor"
control={formMethods.control}
defaultValue={user.darkBrandColor}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
<Controller
name="darkBrandColor"
control={brandColorsFormMethods.control}
defaultValue={user.darkBrandColor}
resetDefaultValue="#fafafa"
onChange={(value) => {
if (!checkWCAGContrastColor("#101010", value)) {
setDarkModeError(true);
} else {
setDarkModeError(false);
}
formMethods.setValue("darkBrandColor", value, { shouldDirty: true });
}}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={user.darkBrandColor}
resetDefaultValue="#fafafa"
onChange={(value) => {
try {
checkWCAGContrastColor("#101010", value);
setDarkModeError(false);
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
} catch (err) {
setDarkModeError(true);
}
}}
/>
{darkModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</div>
)}
/>
</div>
)}
/>
</div>
{darkModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
<SectionBottomActions align="end">
<Button
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
color="primary"
type="submit">
{t("update")}
</Button>
</SectionBottomActions>
</SettingsToggle>
</div>
) : null}
{lightModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</Form>
{/* TODO future PR to preview brandColors */}
{/* <Button
color="secondary"
@@ -235,51 +316,37 @@ const AppearanceView = () => {
onClick={() => window.open(`${WEBAPP_URL}/${user.username}/${user.eventTypes[0].title}`, "_blank")}>
Preview
</Button> */}
<hr className="border-subtle my-8 border" />
<Controller
name="hideBranding"
control={formMethods.control}
defaultValue={user.hideBranding}
render={({ field: { value } }) => (
<>
<div className="flex w-full text-sm">
<div className="mr-1 flex-grow">
<div className="flex items-center">
<p className="text-default font-semibold ltr:mr-2 rtl:ml-2">
{t("disable_cal_branding", { appName: APP_NAME })}
</p>
<UpgradeTeamsBadge />
</div>
<p className="text-default mt-0.5">{t("removes_cal_branding", { appName: APP_NAME })}</p>
</div>
<div className="flex-none">
<Switch
id="hideBranding"
disabled={!hasPaidPlan}
onCheckedChange={(checked) =>
formMethods.setValue("hideBranding", checked, { shouldDirty: true })
}
checked={hasPaidPlan ? value : false}
/>
</div>
</div>
</>
)}
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("disable_cal_branding", { appName: APP_NAME })}
disabled={!hasPaidPlan || mutation?.isLoading}
description={t("removes_cal_branding", { appName: APP_NAME })}
checked={hasPaidPlan ? hideBrandingValue : false}
Badge={<UpgradeTeamsBadge />}
onCheckedChange={(checked) => {
setHideBrandingValue(checked);
mutation.mutate({ hideBranding: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
<Button
disabled={isDisabled}
type="submit"
loading={mutation.isLoading}
color="primary"
className="mt-8"
data-testid="update-theme-btn">
{t("update")}
</Button>
</Form>
</div>
);
};
AppearanceView.getLayout = getLayout;
AppearanceView.PageWrapper = PageWrapper;
const AppearanceViewWrapper = () => {
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan();
export default AppearanceView;
const { t } = useLocale();
if (isLoading || isTeamPlanStatusLoading || !user)
return <SkeletonLoader title={t("appearance")} description={t("appearance_description")} />;
return <AppearanceView user={user} hasPaidPlan={hasPaidPlan} />;
};
AppearanceViewWrapper.getLayout = getLayout;
AppearanceViewWrapper.PageWrapper = PageWrapper;
export default AppearanceViewWrapper;
@@ -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 (
<SkeletonContainer>
<div className="mb-8 mt-6 space-y-6">
<div className="border-subtle mt-8 space-y-6 rounded-xl border px-4 py-6 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
@@ -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 (
<>
<Meta title={t("calendars")} description={t("calendars_description")} CTA={<AddCalendarButton />} />
<Meta
title={t("calendars")}
description={t("calendars_description")}
CTA={<AddCalendarButton />}
borderInShellHeader={false}
/>
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => {
const isDestinationUpdateBtnDisabled =
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
return data.connectedCalendars.length ? (
<div>
<div className="bg-muted border-subtle mt-4 flex space-x-4 rounded-md p-2 sm:mx-0 sm:p-10 md:border md:p-6 xl:mt-0">
<div className=" bg-default border-subtle flex h-9 w-9 items-center justify-center rounded-md border-2 p-[6px]">
<Calendar className="text-default h-6 w-6" />
</div>
<div className="flex w-full flex-col space-y-3">
<div>
<h4 className=" text-emphasis pb-2 text-base font-semibold leading-5">
{t("add_to_calendar")}
</h4>
<p className=" text-default text-sm leading-5">
<Trans i18nKey="add_to_calendar_description">
Where to add events when you re booked. You can override this on a per-event basis in
advanced settings in the event type.
</Trans>
</p>
</div>
<DestinationCalendarSelector
hidePlaceholder
value={data.destinationCalendar?.externalId}
onChange={mutation.mutate}
isLoading={mutation.isLoading}
/>
</div>
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
{t("add_to_calendar")}
</h2>
<p className="text-default text-sm">{t("add_to_calendar_description")}</p>
</div>
<h4 className="text-emphasis mt-12 text-base font-semibold leading-5">
{t("check_for_conflicts")}
</h4>
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
<List className="flex flex-col gap-6" noBorderTreatment>
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
<DestinationCalendarSelector
hidePlaceholder
value={selectedDestinationCalendarOption?.externalId}
onChange={(option) => {
setSelectedDestinationCalendar(option);
}}
isLoading={mutation.isLoading}
/>
</div>
<SectionBottomActions align="end">
<Button
loading={mutation.isLoading}
disabled={isDestinationUpdateBtnDisabled}
color="primary"
onClick={() => {
if (selectedDestinationCalendarOption) mutation.mutate(selectedDestinationCalendarOption);
}}>
{t("update")}
</Button>
</SectionBottomActions>
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
<h4 className="text-emphasis text-base font-semibold leading-5">
{t("check_for_conflicts")}
</h4>
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
</div>
<List
className="border-subtle flex flex-col gap-6 rounded-b-xl border border-t-0 p-6"
noBorderTreatment>
{data.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{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"
/>
);
}}
@@ -15,8 +15,8 @@ import { AppList } from "@components/apps/AppList";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="divide-subtle mb-8 mt-6 space-y-6">
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
@@ -28,11 +28,9 @@ const AddConferencingButton = () => {
const { t } = useLocale();
return (
<>
<Button color="secondary" StartIcon={Plus} href="/apps/categories/conferencing">
{t("add_conferencing_app")}
</Button>
</>
<Button color="secondary" StartIcon={Plus} href="/apps/categories/conferencing">
{t("add")}
</Button>
);
};
@@ -72,6 +70,7 @@ const ConferencingLayout = () => {
title={t("conferencing")}
description={t("conferencing_description")}
CTA={<AddConferencingButton />}
borderInShellHeader={true}
/>
<QueryCell
query={query}
@@ -93,13 +92,20 @@ const ConferencingLayout = () => {
color="secondary"
data-testid="connect-conferencing-apps"
href="/apps/categories/conferencing">
{t("connect_conferencing_apps")}
{t("connect_conference_apps")}
</Button>
}
/>
);
}
return <AppList handleDisconnect={handleDisconnect} data={data} variant="conferencing" />;
return (
<AppList
listClassName="rounded-xl rounded-t-none border-t-0"
handleDisconnect={handleDisconnect}
data={data}
variant="conferencing"
/>
);
}}
/>
</div>
+148 -148
View File
@@ -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 (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mb-8 mt-6 space-y-6">
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
<SkeletonButton className="ml-auto h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
@@ -59,6 +61,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
const utils = trpc.useContext();
const { t } = useLocale();
const { update } = useSession();
const [isUpdateBtnLoading, setIsUpdateBtnLoading] = useState<boolean>(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 (
<Form
form={formMethods}
handleSubmit={(values) => {
mutation.mutate({
...values,
locale: values.locale.value,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<Meta title={t("general")} description={t("general_description")} />
<Controller
name="locale"
render={({ field: { value, onChange } }) => (
<>
<Label className="text-emphasis">
<>{t("language")}</>
</Label>
<Select<{ label: string; value: string }>
className="capitalize"
options={localeOptions}
value={value}
onChange={onChange}
/>
</>
)}
/>
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
</>
)}
/>
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="text-gray text-default mt-2 flex items-center text-sm">
{t("timeformat_profile_hint")}
</div>
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="mt-8">
<Controller
name="allowDynamicBooking"
control={formMethods.control}
render={() => (
<SettingsToggle
title={t("dynamic_booking")}
description={t("allow_dynamic_booking")}
checked={formMethods.getValues("allowDynamicBooking")}
onCheckedChange={(checked) => {
formMethods.setValue("allowDynamicBooking", checked, { shouldDirty: true });
}}
/>
)}
/>
</div>
<div>
<Form
form={formMethods}
handleSubmit={(values) => {
setIsUpdateBtnLoading(true);
mutation.mutate({
...values,
locale: values.locale.value,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<Meta title={t("general")} description={t("general_description")} borderInShellHeader={true} />
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
<Controller
name="locale"
render={({ field: { value, onChange } }) => (
<>
<Label className="text-emphasis">
<>{t("language")}</>
</Label>
<Select<{ label: string; value: string }>
className="capitalize"
options={localeOptions}
value={value}
onChange={onChange}
/>
</>
)}
/>
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
</>
)}
/>
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="text-gray text-default mt-2 flex items-center text-sm">
{t("timeformat_profile_hint")}
</div>
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-6">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
</div>
<div className="mt-8">
<Controller
name="allowSEOIndexing"
control={formMethods.control}
render={() => (
<SettingsToggle
title={t("seo_indexing")}
description={t("allow_seo_indexing")}
checked={formMethods.getValues("allowSEOIndexing")}
onCheckedChange={(checked) => {
formMethods.setValue("allowSEOIndexing", checked, { shouldDirty: true });
}}
/>
)}
/>
</div>
<SectionBottomActions align="end">
<Button loading={isUpdateBtnLoading} disabled={isDisabled} color="primary" type="submit">
<>{t("update")}</>
</Button>
</SectionBottomActions>
</Form>
<div className="mt-8">
<Controller
name="receiveMonthlyDigestEmail"
control={formMethods.control}
render={() => (
<SettingsToggle
title={t("monthly_digest_email")}
description={t("monthly_digest_email_for_teams")}
checked={formMethods.getValues("receiveMonthlyDigestEmail")}
onCheckedChange={(checked) => {
formMethods.setValue("receiveMonthlyDigestEmail", checked, { shouldDirty: true });
}}
/>
)}
/>
</div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("dynamic_booking")}
description={t("allow_dynamic_booking")}
disabled={mutation.isLoading}
checked={isAllowDynamicBookingChecked}
onCheckedChange={(checked) => {
setIsAllowDynamicBookingChecked(checked);
mutation.mutate({ allowDynamicBooking: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
<Button
loading={mutation.isLoading}
disabled={isDisabled}
color="primary"
type="submit"
className="mt-8">
<>{t("update")}</>
</Button>
</Form>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("seo_indexing")}
description={t("allow_seo_indexing")}
disabled={mutation.isLoading}
checked={isAllowSEOIndexingChecked}
onCheckedChange={(checked) => {
setIsAllowSEOIndexingChecked(checked);
mutation.mutate({ allowSEOIndexing: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("monthly_digest_email")}
description={t("monthly_digest_email_for_teams")}
disabled={mutation.isLoading}
checked={isReceiveMonthlyDigestEmailChecked}
onCheckedChange={(checked) => {
setIsReceiveMonthlyDigestEmailChecked(checked);
mutation.mutate({ receiveMonthlyDigestEmail: checked });
}}
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
/>
</div>
);
};
+113 -65
View File
@@ -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 (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mb-8 space-y-6">
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
<div className="flex items-center">
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
@@ -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<string | undefined>();
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 (
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
);
@@ -219,11 +233,17 @@ const ProfileView = () => {
return (
<>
<Meta title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
<Meta
title={t("profile")}
description={t("profile_description", { appName: APP_NAME })}
borderInShellHeader={true}
/>
<ProfileForm
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
isLoading={updateProfileMutation.isLoading}
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
userAvatar={user.avatar}
userOrganization={user.organization}
onSubmit={(values) => {
if (values.email !== user.email && isCALIdentityProvider) {
@@ -238,7 +258,7 @@ const ProfileView = () => {
}
}}
extraField={
<div className="mt-8">
<div className="mt-6">
<UsernameAvailabilityField
onSuccessMutation={async () => {
showToast(t("settings_updated_successfully"), "success");
@@ -252,16 +272,19 @@ const ProfileView = () => {
}
/>
<hr className="border-subtle my-6" />
<Label>{t("danger_zone")}</Label>
<div className="border-subtle mt-6 rounded-xl rounded-b-none border border-b-0 p-6">
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
</div>
{/* Delete account Dialog */}
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<DialogTrigger asChild>
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
{t("delete_account")}
</Button>
</DialogTrigger>
<SectionBottomActions align="end">
<DialogTrigger asChild>
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon={Trash2}>
{t("delete_account")}
</Button>
</DialogTrigger>
</SectionBottomActions>
<DialogContent
title={t("delete_account_modal_title")}
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
@@ -364,12 +387,16 @@ const ProfileForm = ({
onSubmit,
extraField,
isLoading = false,
isFallbackImg,
userAvatar,
userOrganization,
}: {
defaultValues: FormValues;
onSubmit: (values: FormValues) => 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 (
<Form form={formMethods} handleSubmit={onSubmit}>
<div className="flex items-center">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => (
<>
<OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
/>
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
/>
</div>
</>
)}
/>
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
<div className="flex items-center">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => {
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
return (
<>
<OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
/>
<div className="ms-4">
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
<div className="flex gap-2">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload_avatar")}
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
/>
{showRemoveAvatarButton && (
<Button
color="secondary"
onClick={() => {
formMethods.setValue("avatar", null, { shouldDirty: true });
}}>
{t("remove")}
</Button>
)}
</div>
</div>
</>
);
}}
/>
</div>
{extraField}
<div className="mt-6">
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-6">
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
</div>
<div className="mt-6">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(formMethods.getValues("bio") || "")}
setText={(value: string) => {
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
}}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
</div>
{extraField}
<div className="mt-8">
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-8">
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
</div>
<div className="mt-8">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(formMethods.getValues("bio") || "")}
setText={(value: string) => {
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
}}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
<Button loading={isLoading} disabled={isDisabled} color="primary" className="mt-8" type="submit">
{t("update")}
</Button>
<SectionBottomActions align="end">
<Button loading={isLoading} disabled={isDisabled} color="primary" type="submit">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
);
};
@@ -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 (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 border border-t-0 px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
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<boolean | undefined>(
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 (
<>
<Meta title={t("impersonation")} description={t("impersonation_description")} />
<Form
form={formMethods}
handleSubmit={({ disableImpersonation }) => {
mutation.mutate({ disableImpersonation });
}}>
<div className="flex space-x-3">
<Switch
onCheckedChange={(e) => {
setValue("disableImpersonation", !e, { shouldDirty: true });
}}
fitToHeight={true}
checked={!watch("disableImpersonation")}
/>
<div className="flex flex-col">
<Skeleton as={Label} className="text-emphasis text-sm font-semibold leading-none">
{t("user_impersonation_heading")}
</Skeleton>
<Skeleton as="p" className="text-default -mt-2 text-sm leading-normal">
{t("user_impersonation_description")}
</Skeleton>
</div>
</div>
<Button
color="primary"
loading={mutation.isLoading}
className="mt-8"
type="submit"
disabled={isDisabled}>
{t("update")}
</Button>
</Form>
<Meta
title={t("impersonation")}
description={t("impersonation_description")}
borderInShellHeader={true}
/>
<div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("user_impersonation_heading")}
description={t("user_impersonation_description")}
checked={!disableImpersonation}
onCheckedChange={(checked) => {
mutation.mutate({ disableImpersonation: !checked });
}}
disabled={mutation.isLoading}
switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0"
/>
</div>
</>
);
};
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 <SkeletonLoader title={t("impersonation")} description={t("impersonation_description")} />;
return <ProfileImpersonationView user={user} />;
};
export default ProfileImpersonationView;
ProfileImpersonationViewWrapper.getLayout = getLayout;
ProfileImpersonationViewWrapper.PageWrapper = PageWrapper;
export default ProfileImpersonationViewWrapper;
+174 -84
View File
@@ -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 (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 border-x px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
<div className="rounded-b-xl">
<SectionBottomActions align="end">
<SkeletonButton className="ml-auto h-8 w-20 rounded-md" />
</SectionBottomActions>
</div>
</SkeletonContainer>
);
};
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<number | undefined>(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 (
<>
<Meta title={t("password")} description={t("password_description")} />
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
{user && user.identityProvider !== IdentityProvider.CAL ? (
<div>
<div className="mt-6">
@@ -130,87 +180,127 @@ const PasswordView = () => {
</div>
) : (
<Form form={formMethods} handleSubmit={handleSubmit}>
{formMethods.formState.errors.apiError && (
<div className="pb-6">
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
</div>
)}
<div className="max-w-[38rem] sm:grid sm:grid-cols-2 sm:gap-x-4">
<div>
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
</div>
<div>
<PasswordField
{...formMethods.register("newPassword", {
minLength: {
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
value: passwordMinLength,
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
/>
<div className="border-x px-4 py-6 sm:px-6">
{formMethods.formState.errors.apiError && (
<div className="pb-6">
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
</div>
)}
<div className="w-full sm:grid sm:grid-cols-2 sm:gap-x-6">
<div>
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
</div>
<div>
<PasswordField
{...formMethods.register("newPassword", {
minLength: {
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
value: passwordMinLength,
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
/>
</div>
</div>
<p className="text-default mt-4 w-full text-sm">
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
</p>
</div>
<p className="text-default mt-4 max-w-[38rem] text-sm">
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
</p>
<div className="border-subtle mt-8 border-t py-8">
<SectionBottomActions align="end">
<Button
color="primary"
type="submit"
loading={passwordMutation.isLoading}
onClick={() => formMethods.clearErrors("apiError")}
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
{t("update")}
</Button>
</SectionBottomActions>
<div className="mt-6">
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("session_timeout")}
description={t("session_timeout_description")}
checked={sessionTimeoutWatch !== undefined}
checked={sessionTimeout !== undefined}
data-testid="session-check"
onCheckedChange={(e) => {
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 && (
<div className="mt-4 text-sm">
<div className="flex items-center">
<p className="text-default ltr:mr-2 rtl:ml-2">{t("session_timeout_after")}</p>
<Select
options={timeoutOptions}
defaultValue={
sessionTimeout
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
: timeoutOptions[1]
}
isSearchable={false}
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
onChange={(event) => {
formMethods.setValue("sessionTimeout", event?.value, { shouldDirty: true });
}}
/>
childrenClassName="lg:ml-0"
switchContainerClassName={classNames(
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
!!sessionTimeout && "rounded-b-none"
)}>
<>
<div className="border-subtle border-x p-6 pb-8">
<div className="flex flex-col">
<p className="text-default mb-2 font-medium">{t("session_timeout_after")}</p>
<Select
options={timeoutOptions}
defaultValue={
sessionTimeout
? timeoutOptions.find((tmo) => 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);
}}
/>
</div>
</div>
</div>
)}
<SectionBottomActions align="end">
<Button
color="primary"
loading={sessionMutation.isLoading}
onClick={() => {
sessionMutation.mutate({
metadata: { ...metadata, sessionTimeout },
});
formMethods.clearErrors("apiError");
}}
disabled={
initialSessionTimeout === sessionTimeout ||
passwordMutation.isLoading ||
sessionMutation.isLoading
}>
{t("update")}
</Button>
</SectionBottomActions>
</>
</SettingsToggle>
</div>
{/* TODO: Why is this Form not submitting? Hacky fix but works */}
<Button
color="primary"
className="mt-8"
type="submit"
loading={passwordMutation.isLoading || sessionMutation.isLoading}
onClick={() => formMethods.clearErrors("apiError")}
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
{t("update")}
</Button>
</Form>
)}
</>
);
};
PasswordView.getLayout = getLayout;
PasswordView.PageWrapper = PageWrapper;
const PasswordViewWrapper = () => {
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const { t } = useLocale();
if (isLoading || !user)
return <SkeletonLoader title={t("password")} description={t("password_description")} />;
export default PasswordView;
return <PasswordView user={user} />;
};
PasswordViewWrapper.getLayout = getLayout;
PasswordViewWrapper.PageWrapper = PageWrapper;
export default PasswordViewWrapper;
@@ -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 (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="mb-8 mt-6 space-y-6">
<div className="flex items-center">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
@@ -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<boolean>(false);
const [disableModalOpen, setDisableModalOpen] = useState<boolean>(false);
if (isLoading) return <SkeletonLoader />;
if (isLoading)
return <SkeletonLoader title={t("2fa")} description={t("set_up_two_factor_authentication")} />;
const isCalProvider = user?.identityProvider === "CAL";
const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled;
return (
<>
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} />
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} borderInShellHeader={true} />
{canSetupTwoFactor && <Alert severity="neutral" message={t("2fa_disabled")} />}
<div className="mt-6 flex items-start space-x-4">
<Switch
data-testid="two-factor-switch"
disabled={canSetupTwoFactor}
checked={user?.twoFactorEnabled}
onCheckedChange={() =>
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
}
/>
<div className="!mx-4">
<div className="flex">
<p className="text-default font-semibold">{t("two_factor_auth")}</p>
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
</Badge>
</div>
<p className="text-default text-sm">{t("add_an_extra_layer_of_security")}</p>
</div>
</div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
data-testid="two-factor-switch"
title={t("two_factor_auth")}
description={t("add_an_extra_layer_of_security")}
checked={user?.twoFactorEnabled ?? false}
onCheckedChange={() =>
user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true)
}
Badge={
<Badge className="mx-2 text-xs" variant={user?.twoFactorEnabled ? "success" : "gray"}>
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
</Badge>
}
switchContainerClassName="border-subtle rounded-b-xl border border-t-0 px-5 py-6 sm:px-6"
/>
<EnableTwoFactorModal
open={enableModalOpen}
@@ -288,6 +288,7 @@
"when": "When",
"where": "Where",
"add_to_calendar": "Add to calendar",
"add_to_calendar_description":"Select where to add events when youre booked.",
"add_another_calendar": "Add another calendar",
"other": "Other",
"email_sign_in_subject": "Your sign-in link for {{appName}}",
@@ -599,6 +600,7 @@
"hide_book_a_team_member": "Hide Book a Team Member Button",
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
"danger_zone": "Danger zone",
"account_deletion_cannot_be_undone":"Careful. Account deletion cannot be undone.",
"back": "Back",
"cancel": "Cancel",
"cancel_all_remaining": "Cancel all remaining",
@@ -688,6 +690,7 @@
"people": "People",
"your_email": "Your Email",
"change_avatar": "Change Avatar",
"upload_avatar": "Upload Avatar",
"language": "Language",
"timezone": "Timezone",
"first_day_of_week": "First Day of Week",
@@ -1293,7 +1296,7 @@
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
"pro": "Pro",
"removes_cal_branding": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}.'",
"profile_picture": "Profile picture",
"profile_picture": "Profile Picture",
"upload": "Upload",
"add_profile_photo": "Add profile photo",
"web3": "Web3",
@@ -1880,6 +1883,7 @@
"edit_invite_link": "Edit link settings",
"invite_link_copied": "Invite link copied",
"invite_link_deleted": "Invite link deleted",
"api_key_deleted":"API Key deleted",
"invite_link_updated": "Invite link settings saved",
"link_expires_after": "Links set to expire after...",
"one_day": "1 day",