refactor: Setting redesign (#11124)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
committed by
Alex van Andel
co-authored by
Peer Richelsen
parent
85a1713897
commit
685be1663e
@@ -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")}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 you’re 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",
|
||||
|
||||
Reference in New Issue
Block a user