first commit
This commit is contained in:
239
calcom/packages/features/settings/BookerLayoutSelector.tsx
Normal file
239
calcom/packages/features/settings/BookerLayoutSelector.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
|
||||
import { bookerLayoutOptions, type BookerLayoutSettings } from "@calcom/prisma/zod-utils";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Label, CheckboxField, Button } from "@calcom/ui";
|
||||
|
||||
import SectionBottomActions from "./SectionBottomActions";
|
||||
|
||||
type BookerLayoutSelectorProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
name?: string;
|
||||
/**
|
||||
* If this boolean is set, it will show the user settings if the event does not have any settings (is null).
|
||||
* In that case it also will NOT register itself in the form, so that way when submitting the form, the
|
||||
* values won't be overridden. Because as long as the event's value is null, it will fallback to the user's
|
||||
* settings.
|
||||
*/
|
||||
fallbackToUserSettings?: boolean;
|
||||
/**
|
||||
* isDark boolean should be passed in when the user selected 'dark mode' in the theme settings in profile/appearance.
|
||||
* So it's not based on the user's system settings, but on the user's preference for the booker.
|
||||
* This boolean is then used to show a dark version of the layout image. It's only easthetic, no functionality is attached
|
||||
* to this boolean.
|
||||
*/
|
||||
isDark?: boolean;
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isOuterBorder?: boolean;
|
||||
};
|
||||
|
||||
const defaultFieldName = "metadata.bookerLayouts";
|
||||
|
||||
export const BookerLayoutSelector = ({
|
||||
title,
|
||||
description,
|
||||
name,
|
||||
fallbackToUserSettings,
|
||||
isDark,
|
||||
isDisabled = false,
|
||||
isOuterBorder = false,
|
||||
isLoading = false,
|
||||
}: BookerLayoutSelectorProps) => {
|
||||
const { control, getValues } = useFormContext();
|
||||
const { t } = useLocale();
|
||||
// Only fallback if event current does not have any settings, and the fallbackToUserSettings boolean is set.
|
||||
const shouldShowUserSettings = (fallbackToUserSettings && !getValues(name || defaultFieldName)) || false;
|
||||
|
||||
return (
|
||||
<div className={classNames(isOuterBorder && "border-subtle rounded-lg border p-6")}>
|
||||
<div className={classNames(isOuterBorder ? "pb-5" : "border-subtle rounded-t-xl border p-6")}>
|
||||
<Label className={classNames("mb-1 font-semibold", isOuterBorder ? "text-sm" : "text-base")}>
|
||||
{title ? title : t("layout")}
|
||||
</Label>
|
||||
<p className="text-subtle max-w-full break-words text-sm leading-tight">
|
||||
{description ? description : t("bookerlayout_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Controller
|
||||
// If the event does not have any settings, we don't want to register this field in the form.
|
||||
// That way the settings won't get saved into the event on save, but remain null. Thus keep using
|
||||
// the global user's settings.
|
||||
control={shouldShowUserSettings ? undefined : control}
|
||||
name={name || defaultFieldName}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<BookerLayoutFields
|
||||
showUserSettings={shouldShowUserSettings}
|
||||
settings={value}
|
||||
onChange={onChange}
|
||||
isDark={isDark}
|
||||
isOuterBorder={isOuterBorder}
|
||||
/>
|
||||
{!isOuterBorder && (
|
||||
<SectionBottomActions align="end">
|
||||
<Button loading={isLoading} type="submit" disabled={isDisabled} color="primary">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type BookerLayoutFieldsProps = {
|
||||
settings: BookerLayoutSettings;
|
||||
onChange: (settings: BookerLayoutSettings) => void;
|
||||
showUserSettings: boolean;
|
||||
isDark?: boolean;
|
||||
isOuterBorder?: boolean;
|
||||
};
|
||||
|
||||
type BookerLayoutState = { [key in BookerLayouts]: boolean };
|
||||
|
||||
const BookerLayoutFields = ({
|
||||
settings,
|
||||
onChange,
|
||||
showUserSettings,
|
||||
isDark,
|
||||
isOuterBorder,
|
||||
}: BookerLayoutFieldsProps) => {
|
||||
const { t } = useLocale();
|
||||
const { isPending: isUserLoading, data: user } = useMeQuery();
|
||||
const [isOverridingSettings, setIsOverridingSettings] = useState(false);
|
||||
|
||||
const disableFields = showUserSettings && !isOverridingSettings;
|
||||
const shownSettings = disableFields ? user?.defaultBookerLayouts : settings;
|
||||
const defaultLayout = shownSettings?.defaultLayout || BookerLayouts.MONTH_VIEW;
|
||||
|
||||
// Converts the settings array into a boolean object, which can be used as form values.
|
||||
const toggleValues: BookerLayoutState = bookerLayoutOptions.reduce((layouts, layout) => {
|
||||
layouts[layout] = !shownSettings?.enabledLayouts
|
||||
? defaultBookerLayoutSettings.enabledLayouts.indexOf(layout) > -1
|
||||
: shownSettings.enabledLayouts.indexOf(layout) > -1;
|
||||
return layouts;
|
||||
}, {} as BookerLayoutState);
|
||||
|
||||
const onLayoutToggleChange = useCallback(
|
||||
(changedLayout: BookerLayouts, checked: boolean) => {
|
||||
const newEnabledLayouts = Object.keys(toggleValues).filter((layout) => {
|
||||
if (changedLayout === layout) return checked === true;
|
||||
return toggleValues[layout as BookerLayouts] === true;
|
||||
}) as BookerLayouts[];
|
||||
|
||||
const isDefaultLayoutToggledOff = newEnabledLayouts.indexOf(defaultLayout) === -1;
|
||||
const firstEnabledLayout = newEnabledLayouts[0];
|
||||
|
||||
onChange({
|
||||
enabledLayouts: newEnabledLayouts,
|
||||
// If default layout is toggled off, we set the default layout to the first enabled layout
|
||||
// if there's none enabled, we set it to month view.
|
||||
defaultLayout: isDefaultLayoutToggledOff
|
||||
? firstEnabledLayout || BookerLayouts.MONTH_VIEW
|
||||
: defaultLayout,
|
||||
});
|
||||
},
|
||||
[defaultLayout, onChange, toggleValues]
|
||||
);
|
||||
|
||||
const onDefaultLayoutChange = useCallback(
|
||||
(newDefaultLayout: BookerLayouts) => {
|
||||
onChange({
|
||||
enabledLayouts: Object.keys(toggleValues).filter(
|
||||
(layout) => toggleValues[layout as BookerLayouts] === true
|
||||
) as BookerLayouts[],
|
||||
defaultLayout: newDefaultLayout,
|
||||
});
|
||||
},
|
||||
[toggleValues, onChange]
|
||||
);
|
||||
|
||||
const onOverrideSettings = () => {
|
||||
setIsOverridingSettings(true);
|
||||
// Sent default layout settings to form, otherwise it would still have 'null' as it's value.
|
||||
if (user?.defaultBookerLayouts) onChange(user.defaultBookerLayouts);
|
||||
};
|
||||
return (
|
||||
<div className={classNames("space-y-5", !isOuterBorder && "border-subtle border-x px-6 py-8")}>
|
||||
<div
|
||||
className={classNames(
|
||||
"flex flex-col gap-5 transition-opacity sm:flex-row sm:gap-3",
|
||||
disableFields && "pointer-events-none opacity-40",
|
||||
disableFields && isUserLoading && "animate-pulse"
|
||||
)}>
|
||||
{bookerLayoutOptions.map((layout) => (
|
||||
<div className="w-full" key={layout}>
|
||||
<label>
|
||||
<img
|
||||
className="mb-3 w-full max-w-none cursor-pointer"
|
||||
src={`/bookerlayout_${layout}${isDark ? "_dark" : ""}.svg`}
|
||||
alt="Layout preview"
|
||||
/>
|
||||
<CheckboxField
|
||||
value={layout}
|
||||
name={`bookerlayout_${layout}`}
|
||||
description={t(`bookerlayout_${layout}`)}
|
||||
checked={toggleValues[layout]}
|
||||
onChange={(ev) => onLayoutToggleChange(layout, ev.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
hidden={Object.values(toggleValues).filter((value) => value === true).length <= 1}
|
||||
className={classNames(
|
||||
"transition-opacity",
|
||||
disableFields && "pointer-events-none opacity-40",
|
||||
disableFields && isUserLoading && "animate-pulse"
|
||||
)}>
|
||||
<Label>{t("bookerlayout_default_title")}</Label>
|
||||
<RadioGroup.Root
|
||||
key={defaultLayout}
|
||||
className="border-subtle flex w-full gap-2 rounded-md border p-1"
|
||||
defaultValue={defaultLayout}
|
||||
onValueChange={(layout: BookerLayouts) => onDefaultLayoutChange(layout)}>
|
||||
{bookerLayoutOptions.map((layout) => (
|
||||
<RadioGroup.Item
|
||||
className="aria-checked:bg-emphasis hover:[&:not(:disabled)]:bg-subtle focus:[&:not(:disabled)]:bg-subtle w-full rounded-[4px] p-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={toggleValues[layout] === false}
|
||||
key={layout}
|
||||
value={layout}>
|
||||
{t(`bookerlayout_${layout}`)}
|
||||
<RadioGroup.Indicator />
|
||||
</RadioGroup.Item>
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
{disableFields && (
|
||||
<p className="text-sm">
|
||||
<Trans i18nKey="bookerlayout_override_global_settings">
|
||||
You can manage this for all your event types in Settings {"-> "}
|
||||
<Link href="/settings/my-account/appearance" className="underline">
|
||||
Appearance
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Button
|
||||
onClick={onOverrideSettings}
|
||||
color="minimal"
|
||||
className="h-fit p-0 font-normal underline hover:bg-transparent focus-visible:bg-transparent">
|
||||
Override
|
||||
</Button>{" "}
|
||||
for this event only.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
26
calcom/packages/features/settings/SectionBottomActions.tsx
Normal file
26
calcom/packages/features/settings/SectionBottomActions.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
const SectionBottomActions = ({
|
||||
align = "start",
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
align?: "start" | "end";
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle bg-muted flex rounded-b-lg border px-6 py-4",
|
||||
align === "end" && "justify-end",
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionBottomActions;
|
||||
37
calcom/packages/features/settings/ThemeLabel.tsx
Normal file
37
calcom/packages/features/settings/ThemeLabel.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
interface ThemeLabelProps {
|
||||
variant: "light" | "dark" | "system";
|
||||
value?: "light" | "dark" | null;
|
||||
label: string;
|
||||
defaultChecked?: boolean;
|
||||
register: any;
|
||||
fieldName?: string;
|
||||
}
|
||||
|
||||
export default function ThemeLabel(props: ThemeLabelProps) {
|
||||
const { variant, label, value, defaultChecked, register, fieldName = "theme" } = props;
|
||||
|
||||
return (
|
||||
<label
|
||||
className="relative mb-4 flex-1 cursor-pointer text-center last:mb-0 last:mr-0 sm:mb-0 sm:mr-4"
|
||||
htmlFor={`${fieldName}-${variant}`}
|
||||
data-testid={`${fieldName}-${variant}`}>
|
||||
<input
|
||||
className="peer absolute left-8 top-8"
|
||||
type="radio"
|
||||
value={value}
|
||||
id={`${fieldName}-${variant}`}
|
||||
defaultChecked={defaultChecked}
|
||||
{...register(fieldName)}
|
||||
/>
|
||||
<div className="ring-inverted relative z-10 rounded-lg ring-offset-2 transition-all peer-checked:ring-2">
|
||||
<img
|
||||
aria-hidden="true"
|
||||
className="cover w-full rounded-lg"
|
||||
src={`/theme-${variant}.svg`}
|
||||
alt={`theme ${variant}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="peer-checked:text-emphasis text-default mt-2 text-sm font-medium">{label}</p>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
112
calcom/packages/features/settings/TimezoneChangeDialog.tsx
Normal file
112
calcom/packages/features/settings/TimezoneChangeDialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, showToast } from "@calcom/ui";
|
||||
|
||||
const TimezoneChangeDialogContent = ({
|
||||
onAction,
|
||||
browserTimezone,
|
||||
}: {
|
||||
browserTimezone: string;
|
||||
onAction: (action?: "update" | "cancel") => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const formattedCurrentTz = browserTimezone.replace("_", " ");
|
||||
|
||||
// save cookie to not show again
|
||||
function onCancel(hideFor: [number, dayjs.ManipulateType], toast: boolean) {
|
||||
onAction("cancel");
|
||||
document.cookie = `calcom-timezone-dialog=1;max-age=${
|
||||
dayjs().add(hideFor[0], hideFor[1]).unix() - dayjs().unix()
|
||||
}`;
|
||||
toast && showToast(t("we_wont_show_again"), "success");
|
||||
}
|
||||
|
||||
const onSuccessMutation = async () => {
|
||||
showToast(t("updated_timezone_to", { formattedCurrentTz }), "success");
|
||||
await utils.viewer.me.invalidate();
|
||||
};
|
||||
|
||||
const onErrorMutation = () => {
|
||||
showToast(t("couldnt_update_timezone"), "error");
|
||||
};
|
||||
|
||||
// update timezone in db
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: onSuccessMutation,
|
||||
onError: onErrorMutation,
|
||||
});
|
||||
|
||||
function updateTimezone() {
|
||||
onAction("update");
|
||||
mutation.mutate({
|
||||
timeZone: browserTimezone,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
title={t("update_timezone_question")}
|
||||
description={t("update_timezone_description", { formattedCurrentTz })}
|
||||
type="creation"
|
||||
onInteractOutside={() => onCancel([1, "day"], false) /* 1 day expire */}>
|
||||
{/* todo: save this in db and auto-update when timezone changes (be able to disable??? if yes, /settings)
|
||||
<Checkbox description="Always update timezone" />
|
||||
*/}
|
||||
<div className="mb-8" />
|
||||
<DialogFooter showDivider>
|
||||
<DialogClose onClick={() => onCancel([3, "months"], true)} color="secondary">
|
||||
{t("dont_update")}
|
||||
</DialogClose>
|
||||
<DialogClose onClick={() => updateTimezone()} color="primary">
|
||||
{t("update_timezone")}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
};
|
||||
|
||||
export function useOpenTimezoneDialog() {
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const browserTimezone = dayjs.tz.guess() || "Europe/London";
|
||||
const { isLocaleReady } = useLocale();
|
||||
const { data: userSession, status } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLocaleReady ||
|
||||
!user?.timeZone ||
|
||||
status !== "authenticated" ||
|
||||
userSession?.user?.impersonatedBy
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const cookie = document.cookie
|
||||
.split(";")
|
||||
.find((cookie) => cookie.trim().startsWith("calcom-timezone-dialog"));
|
||||
if (
|
||||
!cookie &&
|
||||
dayjs.tz(undefined, browserTimezone).utcOffset() !== dayjs.tz(undefined, user.timeZone).utcOffset()
|
||||
) {
|
||||
setShowDialog(true);
|
||||
}
|
||||
}, [user, isLocaleReady, status, browserTimezone, userSession?.user?.impersonatedBy]);
|
||||
|
||||
return { open: showDialog, setOpen: setShowDialog, browserTimezone };
|
||||
}
|
||||
|
||||
export default function TimezoneChangeDialog() {
|
||||
const { open, setOpen, browserTimezone } = useOpenTimezoneDialog();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<TimezoneChangeDialogContent browserTimezone={browserTimezone} onAction={() => setOpen(false)} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
765
calcom/packages/features/settings/layouts/SettingsLayout.tsx
Normal file
765
calcom/packages/features/settings/layouts/SettingsLayout.tsx
Normal file
@@ -0,0 +1,765 @@
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React, { Suspense, useEffect, useState, useMemo } from "react";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { VerticalTabItemProps } from "@calcom/ui";
|
||||
import { Badge, Button, ErrorBoundary, Icon, Skeleton, useMeta, VerticalTabItem } from "@calcom/ui";
|
||||
|
||||
const tabs: VerticalTabItemProps[] = [
|
||||
{
|
||||
name: "my_account",
|
||||
href: "/settings/my-account",
|
||||
icon: "user",
|
||||
children: [
|
||||
{ name: "profile", href: "/settings/my-account/profile" },
|
||||
{ name: "general", href: "/settings/my-account/general" },
|
||||
{ name: "calendars", href: "/settings/my-account/calendars" },
|
||||
{ name: "conferencing", href: "/settings/my-account/conferencing" },
|
||||
{ name: "appearance", href: "/settings/my-account/appearance" },
|
||||
{ name: "out_of_office", href: "/settings/my-account/out-of-office" },
|
||||
// TODO
|
||||
// { name: "referrals", href: "/settings/my-account/referrals" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "security",
|
||||
href: "/settings/security",
|
||||
icon: "key",
|
||||
children: [
|
||||
{ name: "password", href: "/settings/security/password" },
|
||||
{ name: "impersonation", href: "/settings/security/impersonation" },
|
||||
{ name: "2fa_auth", href: "/settings/security/two-factor-auth" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/billing",
|
||||
icon: "credit-card",
|
||||
children: [{ name: "manage_billing", href: "/settings/billing" }],
|
||||
},
|
||||
{
|
||||
name: "developer",
|
||||
href: "/settings/developer",
|
||||
icon: "terminal",
|
||||
children: [
|
||||
//
|
||||
{ name: "webhooks", href: "/settings/developer/webhooks" },
|
||||
//{ name: "api_keys", href: "/settings/developer/api-keys" },
|
||||
//{ name: "admin_api", href: "/settings/organizations/admin-api" },
|
||||
// TODO: Add profile level for embeds
|
||||
// { name: "embeds", href: "/v2/settings/developer/embeds" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "organization",
|
||||
href: "/settings/organizations",
|
||||
children: [
|
||||
{
|
||||
name: "profile",
|
||||
href: "/settings/organizations/profile",
|
||||
},
|
||||
{
|
||||
name: "general",
|
||||
href: "/settings/organizations/general",
|
||||
},
|
||||
{
|
||||
name: "members",
|
||||
href: "/settings/organizations/members",
|
||||
},
|
||||
{
|
||||
name: "privacy",
|
||||
href: "/settings/organizations/privacy",
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
},
|
||||
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
|
||||
{
|
||||
name: "SSO",
|
||||
href: "/settings/organizations/sso",
|
||||
},
|
||||
{
|
||||
name: "directory_sync",
|
||||
href: "/settings/organizations/dsync",
|
||||
},
|
||||
{
|
||||
name: "admin_api",
|
||||
href: "https://cal.com/docs/enterprise-features/api/api-reference/bookings#admin-access",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "teams",
|
||||
href: "/teams",
|
||||
icon: "users",
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
name: "other_teams",
|
||||
href: "/settings/organizations/teams/other",
|
||||
icon: "users",
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
href: "/settings/admin",
|
||||
icon: "lock",
|
||||
children: [
|
||||
//
|
||||
{ name: "features", href: "/settings/admin/flags" },
|
||||
{ name: "license", href: "/auth/setup?step=1" },
|
||||
{ name: "impersonation", href: "/settings/admin/impersonation" },
|
||||
{ name: "apps", href: "/settings/admin/apps/calendar" },
|
||||
{ name: "users", href: "/settings/admin/users" },
|
||||
{ name: "organizations", href: "/settings/admin/organizations" },
|
||||
{ name: "lockedSMS", href: "/settings/admin/lockedSMS" },
|
||||
{ name: "oAuth", href: "/settings/admin/oAuth" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
tabs.find((tab) => {
|
||||
if (tab.name === "security" && !HOSTED_CAL_FEATURES) {
|
||||
tab.children?.push({ name: "sso_configuration", href: "/settings/security/sso" });
|
||||
// TODO: Enable dsync for self hosters
|
||||
// tab.children?.push({ name: "directory_sync", href: "/settings/security/dsync" });
|
||||
}
|
||||
});
|
||||
|
||||
// The following keys are assigned to admin only
|
||||
const adminRequiredKeys = ["admin"];
|
||||
const organizationRequiredKeys = ["organization"];
|
||||
const organizationAdminKeys = ["privacy", "billing", "OAuth Clients", "SSO", "directory_sync"];
|
||||
|
||||
const useTabs = () => {
|
||||
const session = useSession();
|
||||
const { data: user } = trpc.viewer.me.useQuery({ includePasswordAdded: true });
|
||||
const orgBranding = useOrgBranding();
|
||||
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;
|
||||
const isOrgAdminOrOwner =
|
||||
orgBranding?.role === MembershipRole.ADMIN || orgBranding?.role === MembershipRole.OWNER;
|
||||
|
||||
const processTabsMemod = useMemo(() => {
|
||||
const processedTabs = tabs.map((tab) => {
|
||||
if (tab.href === "/settings/my-account") {
|
||||
if (!!session.data?.user?.org?.id) {
|
||||
tab.children = tab?.children?.filter((child) => child.href !== "/settings/my-account/appearance");
|
||||
}
|
||||
return {
|
||||
...tab,
|
||||
name: user?.name || "my_account",
|
||||
icon: undefined,
|
||||
avatar: getUserAvatarUrl(user),
|
||||
};
|
||||
} else if (tab.href === "/settings/organizations") {
|
||||
const newArray = (tab?.children ?? []).filter(
|
||||
(child) => isOrgAdminOrOwner || !organizationAdminKeys.includes(child.name)
|
||||
);
|
||||
return {
|
||||
...tab,
|
||||
children: newArray,
|
||||
name: orgBranding?.name || "organization",
|
||||
avatar: getPlaceholderAvatar(orgBranding?.logoUrl, orgBranding?.name),
|
||||
};
|
||||
} else if (
|
||||
tab.href === "/settings/security" &&
|
||||
user?.identityProvider === IdentityProvider.GOOGLE &&
|
||||
!user?.twoFactorEnabled &&
|
||||
!user?.passwordAdded
|
||||
) {
|
||||
const filtered = tab?.children?.filter(
|
||||
(childTab) => childTab.href !== "/settings/security/two-factor-auth"
|
||||
);
|
||||
return { ...tab, children: filtered };
|
||||
} else if (tab.href === "/settings/developer" && !!orgBranding) {
|
||||
const filtered = tab?.children?.filter((childTab) => childTab.name !== "admin_api");
|
||||
return { ...tab, children: filtered };
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
|
||||
// check if name is in adminRequiredKeys
|
||||
return processedTabs.filter((tab) => {
|
||||
if (organizationRequiredKeys.includes(tab.name)) return !!orgBranding;
|
||||
if (tab.name === "other_teams" && !isOrgAdminOrOwner) return false;
|
||||
|
||||
if (isAdmin) return true;
|
||||
return !adminRequiredKeys.includes(tab.name);
|
||||
});
|
||||
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user]);
|
||||
|
||||
return processTabsMemod;
|
||||
};
|
||||
|
||||
const BackButtonInSidebar = ({ name }: { name: string }) => {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:bg-subtle todesktop:mt-10 [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-emphasis group my-6 flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2 text-sm font-medium leading-4 transition"
|
||||
data-testid={`vertical-tab-${name}`}>
|
||||
<Icon
|
||||
name="arrow-left"
|
||||
className="h-4 w-4 stroke-[2px] ltr:mr-[10px] rtl:ml-[10px] rtl:rotate-180 md:mt-0"
|
||||
/>
|
||||
<Skeleton title={name} as="p" className="max-w-36 min-h-4 truncate" loadingClassName="ms-3">
|
||||
{name}
|
||||
</Skeleton>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsSidebarContainerProps {
|
||||
className?: string;
|
||||
navigationIsOpenedOnMobile?: boolean;
|
||||
bannersHeight?: number;
|
||||
}
|
||||
|
||||
const TeamListCollapsible = () => {
|
||||
const { data: teams } = trpc.viewer.teams.list.useQuery();
|
||||
const { t } = useLocale();
|
||||
const [teamMenuState, setTeamMenuState] =
|
||||
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
|
||||
const searchParams = useCompatSearchParams();
|
||||
useEffect(() => {
|
||||
if (teams) {
|
||||
const teamStates = teams?.map((team) => ({
|
||||
teamId: team.id,
|
||||
teamMenuOpen: String(team.id) === searchParams?.get("id"),
|
||||
}));
|
||||
setTeamMenuState(teamStates);
|
||||
setTimeout(() => {
|
||||
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
|
||||
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
|
||||
)[1];
|
||||
tabMembers?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
}
|
||||
}, [searchParams?.get("id"), teams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{teams &&
|
||||
teamMenuState &&
|
||||
teams.map((team, index: number) => {
|
||||
if (!teamMenuState[index]) {
|
||||
return null;
|
||||
}
|
||||
if (teamMenuState.some((teamState) => teamState.teamId === team.id))
|
||||
return (
|
||||
<Collapsible
|
||||
className="cursor-pointer"
|
||||
key={team.id}
|
||||
open={teamMenuState[index].teamMenuOpen}
|
||||
onOpenChange={() =>
|
||||
setTeamMenuState([
|
||||
...teamMenuState,
|
||||
(teamMenuState[index] = {
|
||||
...teamMenuState[index],
|
||||
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-left text-sm font-medium leading-none"
|
||||
onClick={() =>
|
||||
setTeamMenuState([
|
||||
...teamMenuState,
|
||||
(teamMenuState[index] = {
|
||||
...teamMenuState[index],
|
||||
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
<div className="me-3">
|
||||
{teamMenuState[index].teamMenuOpen ? (
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
) : (
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{!team.parentId && (
|
||||
<img
|
||||
src={getPlaceholderAvatar(team.logoUrl, team.name)}
|
||||
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
|
||||
alt={team.name || "Team logo"}
|
||||
/>
|
||||
)}
|
||||
<p className="w-1/2 truncate leading-normal">{team.name}</p>
|
||||
{!team.accepted && (
|
||||
<Badge className="ms-3" variant="orange">
|
||||
Inv.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-0.5">
|
||||
{team.accepted && (
|
||||
<VerticalTabItem
|
||||
name={t("profile")}
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
<VerticalTabItem
|
||||
name={t("members")}
|
||||
href={`/settings/teams/${team.id}/members`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
<VerticalTabItem
|
||||
name={t("event_types_page_title")}
|
||||
href={`/event-types?teamIds=${team.id}`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
{(team.role === MembershipRole.OWNER ||
|
||||
team.role === MembershipRole.ADMIN ||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore this exists wtf?
|
||||
(team.isOrgAdmin && team.isOrgAdmin)) && (
|
||||
<>
|
||||
{/* TODO */}
|
||||
{/* <VerticalTabItem
|
||||
name={t("general")}
|
||||
href={`${WEBAPP_URL}/settings/my-account/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/> */}
|
||||
<VerticalTabItem
|
||||
name={t("appearance")}
|
||||
href={`/settings/teams/${team.id}/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
{/* Hide if there is a parent ID */}
|
||||
{!team.parentId ? (
|
||||
<>
|
||||
<VerticalTabItem
|
||||
name={t("billing")}
|
||||
href={`/settings/teams/${team.id}/billing`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsSidebarContainer = ({
|
||||
className = "",
|
||||
navigationIsOpenedOnMobile,
|
||||
bannersHeight,
|
||||
}: SettingsSidebarContainerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t } = useLocale();
|
||||
const tabsWithPermissions = useTabs();
|
||||
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
|
||||
{
|
||||
teamId: number | undefined;
|
||||
teamMenuOpen: boolean;
|
||||
}[]
|
||||
>();
|
||||
const session = useSession();
|
||||
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org,
|
||||
});
|
||||
|
||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org,
|
||||
});
|
||||
|
||||
// Same as above but for otherTeams
|
||||
useEffect(() => {
|
||||
if (otherTeams) {
|
||||
const otherTeamStates = otherTeams?.map((team) => ({
|
||||
teamId: team.id,
|
||||
teamMenuOpen: String(team.id) === searchParams?.get("id"),
|
||||
}));
|
||||
setOtherTeamMenuState(otherTeamStates);
|
||||
setTimeout(() => {
|
||||
// @TODO: test if this works for 2 dataset testids
|
||||
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
|
||||
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
|
||||
)[1];
|
||||
tabMembers?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
}
|
||||
}, [searchParams?.get("id"), otherTeams]);
|
||||
|
||||
const isOrgAdminOrOwner =
|
||||
currentOrg && currentOrg?.user?.role && ["OWNER", "ADMIN"].includes(currentOrg?.user?.role);
|
||||
|
||||
return (
|
||||
<nav
|
||||
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
|
||||
className={classNames(
|
||||
"no-scrollbar bg-muted fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col space-y-1 overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
|
||||
className,
|
||||
navigationIsOpenedOnMobile
|
||||
? "translate-x-0 opacity-100"
|
||||
: "-translate-x-full opacity-0 lg:translate-x-0 lg:opacity-100"
|
||||
)}
|
||||
aria-label="Tabs">
|
||||
<>
|
||||
<BackButtonInSidebar name={t("back")} />
|
||||
{tabsWithPermissions.map((tab) => {
|
||||
return (
|
||||
<React.Fragment key={tab.href}>
|
||||
{!["teams", "other_teams"].includes(tab.name) && (
|
||||
<React.Fragment key={tab.href}>
|
||||
<div className={`${!tab.children?.length ? "!mb-3" : ""}`}>
|
||||
<div className="[&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default group flex h-7 w-full flex-row items-center rounded-md px-2 text-sm font-medium leading-none">
|
||||
{tab && tab.icon && (
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
|
||||
/>
|
||||
)}
|
||||
{!tab.icon && tab?.avatar && (
|
||||
<img
|
||||
className="h-4 w-4 rounded-full ltr:mr-3 rtl:ml-3"
|
||||
src={tab?.avatar}
|
||||
alt="Organization Logo"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
title={tab.name}
|
||||
as="p"
|
||||
className="text-subtle truncate text-sm font-medium leading-5"
|
||||
loadingClassName="ms-3">
|
||||
{t(tab.name)}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-3 space-y-px">
|
||||
{tab.children?.map((child, index) => (
|
||||
<VerticalTabItem
|
||||
key={child.href}
|
||||
name={t(child.name)}
|
||||
isExternalLink={child.isExternalLink}
|
||||
href={child.href || "/"}
|
||||
textClassNames="text-emphasis font-medium text-sm"
|
||||
className={`me-5 h-7 !px-2 ${
|
||||
tab.children && index === tab.children?.length - 1 && "!mb-3"
|
||||
}`}
|
||||
disableChevron
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{tab.name === "teams" && (
|
||||
<React.Fragment key={tab.href}>
|
||||
<div data-testid="tab-teams" className={`${!tab.children?.length ? "mb-3" : ""}`}>
|
||||
<Link href={tab.href}>
|
||||
<div className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-default group flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-sm font-medium leading-none">
|
||||
{tab && tab.icon && (
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
title={tab.name}
|
||||
as="p"
|
||||
className="text-subtle truncate text-sm font-medium leading-5"
|
||||
loadingClassName="ms-3">
|
||||
{t(isOrgAdminOrOwner ? "my_teams" : tab.name)}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</Link>
|
||||
<TeamListCollapsible />
|
||||
{(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
|
||||
<VerticalTabItem
|
||||
name={t("add_a_team")}
|
||||
href={`${WEBAPP_URL}/settings/teams/new`}
|
||||
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
|
||||
icon="plus"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{tab.name === "other_teams" && (
|
||||
<React.Fragment key={tab.href}>
|
||||
<div className={`${!tab.children?.length ? "mb-3" : ""}`}>
|
||||
<Link href={tab.href}>
|
||||
<div className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-default group flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-sm font-medium leading-none">
|
||||
{tab && tab.icon && (
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
title={t("org_admin_other_teams")}
|
||||
as="p"
|
||||
className="text-subtle truncate text-sm font-medium leading-5"
|
||||
loadingClassName="ms-3">
|
||||
{t("org_admin_other_teams")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</Link>
|
||||
{otherTeams &&
|
||||
otherTeamMenuState &&
|
||||
otherTeams.map((otherTeam, index: number) => {
|
||||
if (!otherTeamMenuState[index]) {
|
||||
return null;
|
||||
}
|
||||
if (otherTeamMenuState.some((teamState) => teamState.teamId === otherTeam.id))
|
||||
return (
|
||||
<Collapsible
|
||||
className="cursor-pointer"
|
||||
key={otherTeam.id}
|
||||
open={otherTeamMenuState[index].teamMenuOpen}
|
||||
onOpenChange={() =>
|
||||
setOtherTeamMenuState([
|
||||
...otherTeamMenuState,
|
||||
(otherTeamMenuState[index] = {
|
||||
...otherTeamMenuState[index],
|
||||
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-left text-sm font-medium leading-none"
|
||||
onClick={() =>
|
||||
setOtherTeamMenuState([
|
||||
...otherTeamMenuState,
|
||||
(otherTeamMenuState[index] = {
|
||||
...otherTeamMenuState[index],
|
||||
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
<div className="me-3">
|
||||
{otherTeamMenuState[index].teamMenuOpen ? (
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
) : (
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{!otherTeam.parentId && (
|
||||
<img
|
||||
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
|
||||
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
|
||||
alt={otherTeam.name || "Team logo"}
|
||||
/>
|
||||
)}
|
||||
<p className="w-1/2 truncate leading-normal">{otherTeam.name}</p>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-0.5">
|
||||
<VerticalTabItem
|
||||
name={t("profile")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
<VerticalTabItem
|
||||
name={t("members")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
<>
|
||||
{/* TODO: enable appearance edit */}
|
||||
{/* <VerticalTabItem
|
||||
name={t("appearance")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/> */}
|
||||
</>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="bg-muted border-muted sticky top-0 z-20 flex w-full items-center justify-between border-b px-2 py-2 sm:relative lg:hidden">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button StartIcon="menu" color="minimal" variant="icon" onClick={props.onSideContainerOpen}>
|
||||
<span className="sr-only">{t("show_navigation")}</span>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
className="hover:bg-emphasis flex items-center space-x-2 rounded-md px-3 py-1 rtl:space-x-reverse"
|
||||
onClick={() => router.back()}>
|
||||
<Icon name="arrow-left" className="text-default h-4 w-4" />
|
||||
<p className="text-emphasis font-semibold">{t("settings")}</p>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
const pathname = usePathname();
|
||||
const state = useState(false);
|
||||
const { t } = useLocale();
|
||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||
|
||||
useEffect(() => {
|
||||
const closeSideContainer = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
setSideContainerOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", closeSideContainer);
|
||||
return () => {
|
||||
window.removeEventListener("resize", closeSideContainer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sideContainerOpen) {
|
||||
setSideContainerOpen(!sideContainerOpen);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<Shell
|
||||
withoutSeo={true}
|
||||
flexChildrenContainer
|
||||
hideHeadingOnMobile
|
||||
{...rest}
|
||||
SidebarContainer={
|
||||
<SidebarContainerElement
|
||||
sideContainerOpen={sideContainerOpen}
|
||||
setSideContainerOpen={setSideContainerOpen}
|
||||
/>
|
||||
}
|
||||
drawerState={state}
|
||||
MobileNavigationContainer={null}
|
||||
TopNavContainer={
|
||||
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
|
||||
}>
|
||||
<div className="flex flex-1 [&>*]:flex-1">
|
||||
<div className="mx-auto max-w-full justify-center lg:max-w-3xl">
|
||||
<ShellHeader />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarContainerElement = ({
|
||||
sideContainerOpen,
|
||||
bannersHeight,
|
||||
setSideContainerOpen,
|
||||
}: SidebarContainerElementProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{sideContainerOpen && (
|
||||
<button
|
||||
onClick={() => setSideContainerOpen(false)}
|
||||
className="fixed left-0 top-0 z-10 h-full w-full bg-black/50">
|
||||
<span className="sr-only">{t("hide_navigation")}</span>
|
||||
</button>
|
||||
)}
|
||||
<SettingsSidebarContainer
|
||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||
bannersHeight={bannersHeight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarContainerElementProps = {
|
||||
sideContainerOpen: boolean;
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const getLayout = (page: React.ReactElement) => <SettingsLayout>{page}</SettingsLayout>;
|
||||
|
||||
export function ShellHeader() {
|
||||
const { meta } = useMeta();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={classNames(
|
||||
"border-subtle mx-auto block justify-between sm:flex",
|
||||
meta.borderInShellHeader && "rounded-t-lg border px-4 py-6 sm:px-6",
|
||||
meta.borderInShellHeader === undefined && "mb-8 border-b pb-8"
|
||||
)}>
|
||||
<div className="flex w-full items-center">
|
||||
{meta.backButton && (
|
||||
<a href="javascript:history.back()">
|
||||
<Icon name="arrow-left" className="mr-7" />
|
||||
</a>
|
||||
)}
|
||||
<div>
|
||||
{meta.title && isLocaleReady ? (
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
{t(meta.title)}
|
||||
</h1>
|
||||
) : (
|
||||
<div className="bg-emphasis mb-1 h-5 w-24 animate-pulse rounded-lg" />
|
||||
)}
|
||||
{meta.description && isLocaleReady ? (
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">{t(meta.description)}</p>
|
||||
) : (
|
||||
<div className="bg-emphasis h-5 w-32 animate-pulse rounded-lg" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ms-auto flex-shrink-0">{meta.CTA}</div>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
"use client";
|
||||
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { VerticalTabItemProps } from "@calcom/ui";
|
||||
import { Badge, Button, ErrorBoundary, Icon, Skeleton, useMeta, VerticalTabItem } from "@calcom/ui";
|
||||
|
||||
const tabs: VerticalTabItemProps[] = [
|
||||
{
|
||||
name: "my_account",
|
||||
href: "/settings/my-account",
|
||||
icon: "user",
|
||||
children: [
|
||||
{ name: "profile", href: "/settings/my-account/profile" },
|
||||
{ name: "general", href: "/settings/my-account/general" },
|
||||
{ name: "calendars", href: "/settings/my-account/calendars" },
|
||||
{ name: "conferencing", href: "/settings/my-account/conferencing" },
|
||||
{ name: "appearance", href: "/settings/my-account/appearance" },
|
||||
// TODO
|
||||
// { name: "referrals", href: "/settings/my-account/referrals" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "security",
|
||||
href: "/settings/security",
|
||||
icon: "key",
|
||||
children: [
|
||||
{ name: "password", href: "/settings/security/password" },
|
||||
{ name: "impersonation", href: "/settings/security/impersonation" },
|
||||
{ name: "2fa_auth", href: "/settings/security/two-factor-auth" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/billing",
|
||||
icon: "credit-card",
|
||||
children: [{ name: "manage_billing", href: "/settings/billing" }],
|
||||
},
|
||||
{
|
||||
name: "developer",
|
||||
href: "/settings/developer",
|
||||
icon: "terminal",
|
||||
children: [
|
||||
//
|
||||
{ name: "webhooks", href: "/settings/developer/webhooks" },
|
||||
{ name: "api_keys", href: "/settings/developer/api-keys" },
|
||||
// TODO: Add profile level for embeds
|
||||
// { name: "embeds", href: "/v2/settings/developer/embeds" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "organization",
|
||||
href: "/settings/organizations",
|
||||
children: [
|
||||
{
|
||||
name: "profile",
|
||||
href: "/settings/organizations/profile",
|
||||
},
|
||||
{
|
||||
name: "general",
|
||||
href: "/settings/organizations/general",
|
||||
},
|
||||
{
|
||||
name: "members",
|
||||
href: "/settings/organizations/members",
|
||||
},
|
||||
{
|
||||
name: "appearance",
|
||||
href: "/settings/organizations/appearance",
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "teams",
|
||||
href: "/teams",
|
||||
icon: "users",
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
href: "/settings/admin",
|
||||
icon: "lock",
|
||||
children: [
|
||||
//
|
||||
{ name: "features", href: "/settings/admin/flags" },
|
||||
{ name: "license", href: "/auth/setup?step=1" },
|
||||
{ name: "impersonation", href: "/settings/admin/impersonation" },
|
||||
{ name: "apps", href: "/settings/admin/apps/calendar" },
|
||||
{ name: "users", href: "/settings/admin/users" },
|
||||
{ name: "organizations", href: "/settings/admin/organizations" },
|
||||
{ name: "lockedSMS", href: "/settings/admin/lockedSMS" },
|
||||
{ name: "oAuth", href: "/settings/admin/oAuth" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
tabs.find((tab) => {
|
||||
// Add "SAML SSO" to the tab
|
||||
if (tab.name === "security" && !HOSTED_CAL_FEATURES) {
|
||||
tab.children?.push({ name: "sso_configuration", href: "/settings/security/sso" });
|
||||
}
|
||||
});
|
||||
|
||||
// The following keys are assigned to admin only
|
||||
const adminRequiredKeys = ["admin"];
|
||||
const organizationRequiredKeys = ["organization"];
|
||||
|
||||
const useTabs = () => {
|
||||
const session = useSession();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;
|
||||
|
||||
tabs.map((tab) => {
|
||||
if (tab.href === "/settings/my-account") {
|
||||
tab.name = user?.name || "my_account";
|
||||
tab.icon = undefined;
|
||||
tab.avatar = getUserAvatarUrl(user);
|
||||
} else if (tab.href === "/settings/organizations") {
|
||||
tab.name = orgBranding?.name || "organization";
|
||||
tab.avatar = `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`;
|
||||
} else if (
|
||||
tab.href === "/settings/security" &&
|
||||
user?.identityProvider === IdentityProvider.GOOGLE &&
|
||||
!user?.twoFactorEnabled
|
||||
) {
|
||||
tab.children = tab?.children?.filter(
|
||||
(childTab) => childTab.href !== "/settings/security/two-factor-auth"
|
||||
);
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
|
||||
// check if name is in adminRequiredKeys
|
||||
return tabs.filter((tab) => {
|
||||
if (organizationRequiredKeys.includes(tab.name)) return !!session.data?.user?.org;
|
||||
|
||||
if (isAdmin) return true;
|
||||
return !adminRequiredKeys.includes(tab.name);
|
||||
});
|
||||
};
|
||||
|
||||
const BackButtonInSidebar = ({ name }: { name: string }) => {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-emphasis group my-6 flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2 text-sm font-medium leading-4 transition"
|
||||
data-testid={`vertical-tab-${name}`}>
|
||||
<Icon
|
||||
name="arrow-left"
|
||||
className="h-4 w-4 stroke-[2px] ltr:mr-[10px] rtl:ml-[10px] rtl:rotate-180 md:mt-0"
|
||||
/>
|
||||
<Skeleton title={name} as="p" className="max-w-36 min-h-4 truncate" loadingClassName="ms-3">
|
||||
{name}
|
||||
</Skeleton>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsSidebarContainerProps {
|
||||
className?: string;
|
||||
navigationIsOpenedOnMobile?: boolean;
|
||||
bannersHeight?: number;
|
||||
}
|
||||
|
||||
const SettingsSidebarContainer = ({
|
||||
className = "",
|
||||
navigationIsOpenedOnMobile,
|
||||
bannersHeight,
|
||||
}: SettingsSidebarContainerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t } = useLocale();
|
||||
const tabsWithPermissions = useTabs();
|
||||
const [teamMenuState, setTeamMenuState] =
|
||||
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
|
||||
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
|
||||
{
|
||||
teamId: number | undefined;
|
||||
teamMenuOpen: boolean;
|
||||
}[]
|
||||
>();
|
||||
const { data: teams } = trpc.viewer.teams.list.useQuery();
|
||||
const session = useSession();
|
||||
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org,
|
||||
});
|
||||
|
||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (teams) {
|
||||
const teamStates = teams?.map((team) => ({
|
||||
teamId: team.id,
|
||||
teamMenuOpen: String(team.id) === searchParams?.get("id"),
|
||||
}));
|
||||
setTeamMenuState(teamStates);
|
||||
setTimeout(() => {
|
||||
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
|
||||
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
|
||||
)[1];
|
||||
tabMembers?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
}
|
||||
}, [searchParams?.get("id"), teams]);
|
||||
|
||||
// Same as above but for otherTeams
|
||||
useEffect(() => {
|
||||
if (otherTeams) {
|
||||
const otherTeamStates = otherTeams?.map((team) => ({
|
||||
teamId: team.id,
|
||||
teamMenuOpen: String(team.id) === searchParams?.get("id"),
|
||||
}));
|
||||
setOtherTeamMenuState(otherTeamStates);
|
||||
setTimeout(() => {
|
||||
// @TODO: test if this works for 2 dataset testids
|
||||
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
|
||||
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
|
||||
)[1];
|
||||
tabMembers?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
}
|
||||
}, [searchParams?.get("id"), otherTeams]);
|
||||
|
||||
const isOrgAdminOrOwner =
|
||||
currentOrg && currentOrg?.user?.role && ["OWNER", "ADMIN"].includes(currentOrg?.user?.role);
|
||||
|
||||
if (isOrgAdminOrOwner) {
|
||||
const teamsIndex = tabsWithPermissions.findIndex((tab) => tab.name === "teams");
|
||||
|
||||
tabsWithPermissions.splice(teamsIndex + 1, 0, {
|
||||
name: "other_teams",
|
||||
href: "/settings/organizations/teams/other",
|
||||
icon: "users",
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
|
||||
className={classNames(
|
||||
"no-scrollbar bg-muted fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col space-y-1 overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
|
||||
className,
|
||||
navigationIsOpenedOnMobile
|
||||
? "translate-x-0 opacity-100"
|
||||
: "-translate-x-full opacity-0 lg:translate-x-0 lg:opacity-100"
|
||||
)}
|
||||
aria-label="Tabs">
|
||||
<>
|
||||
<BackButtonInSidebar name={t("back")} />
|
||||
{tabsWithPermissions.map((tab) => {
|
||||
return (
|
||||
<React.Fragment key={tab.href}>
|
||||
{!["teams", "other_teams"].includes(tab.name) && (
|
||||
<React.Fragment key={tab.href}>
|
||||
<div className={`${!tab.children?.length ? "!mb-3" : ""}`}>
|
||||
<div className="[&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default group flex h-9 w-full flex-row items-center rounded-md px-2 text-sm font-medium leading-none">
|
||||
{tab && tab.icon && (
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
className="h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
|
||||
/>
|
||||
)}
|
||||
{!tab.icon && tab?.avatar && (
|
||||
<img
|
||||
className="h-4 w-4 rounded-full ltr:mr-3 rtl:ml-3"
|
||||
src={tab?.avatar}
|
||||
alt="User Avatar"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
title={tab.name}
|
||||
as="p"
|
||||
className="truncate text-sm font-medium leading-5"
|
||||
loadingClassName="ms-3">
|
||||
{t(tab.name)}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-3 space-y-0.5">
|
||||
{tab.children?.map((child, index) => (
|
||||
<VerticalTabItem
|
||||
key={child.href}
|
||||
name={t(child.name)}
|
||||
isExternalLink={child.isExternalLink}
|
||||
href={child.href || "/"}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className={`my-0.5 me-5 h-7 ${
|
||||
tab.children && index === tab.children?.length - 1 && "!mb-3"
|
||||
}`}
|
||||
disableChevron
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{tab.name === "teams" && (
|
||||
<React.Fragment key={tab.href}>
|
||||
<div className={`${!tab.children?.length ? "mb-3" : ""}`}>
|
||||
<Link href={tab.href}>
|
||||
<div className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-default group flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-sm font-medium leading-none">
|
||||
{tab && tab.icon && (
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
className="h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
title={tab.name}
|
||||
as="p"
|
||||
className="truncate text-sm font-medium leading-5"
|
||||
loadingClassName="ms-3">
|
||||
{t(isOrgAdminOrOwner ? "my_teams" : tab.name)}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</Link>
|
||||
{teams &&
|
||||
teamMenuState &&
|
||||
teams.map((team, index: number) => {
|
||||
if (!teamMenuState[index]) {
|
||||
return null;
|
||||
}
|
||||
if (teamMenuState.some((teamState) => teamState.teamId === team.id))
|
||||
return (
|
||||
<Collapsible
|
||||
className="cursor-pointer"
|
||||
key={team.id}
|
||||
open={teamMenuState[index].teamMenuOpen}
|
||||
onOpenChange={() =>
|
||||
setTeamMenuState([
|
||||
...teamMenuState,
|
||||
(teamMenuState[index] = {
|
||||
...teamMenuState[index],
|
||||
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-3 py-[10px] text-left text-sm font-medium leading-none"
|
||||
onClick={() =>
|
||||
setTeamMenuState([
|
||||
...teamMenuState,
|
||||
(teamMenuState[index] = {
|
||||
...teamMenuState[index],
|
||||
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
<div className="me-3">
|
||||
{teamMenuState[index].teamMenuOpen ? (
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
) : (
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{!team.parentId && (
|
||||
<img
|
||||
src={getPlaceholderAvatar(team.logoUrl, team.name)}
|
||||
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
|
||||
alt={team.name || "Team logo"}
|
||||
/>
|
||||
)}
|
||||
<p className="w-1/2 truncate leading-normal">{team.name}</p>
|
||||
{!team.accepted && (
|
||||
<Badge className="ms-3" variant="orange">
|
||||
Inv.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-0.5">
|
||||
{team.accepted && (
|
||||
<VerticalTabItem
|
||||
name={t("profile")}
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
<VerticalTabItem
|
||||
name={t("members")}
|
||||
href={`/settings/teams/${team.id}/members`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
<VerticalTabItem
|
||||
name={t("event_types_page_title")}
|
||||
href={`/event-types?teamIds=${team.id}`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
{(team.role === MembershipRole.OWNER ||
|
||||
team.role === MembershipRole.ADMIN ||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore this exists wtf?
|
||||
(team.isOrgAdmin && team.isOrgAdmin)) && (
|
||||
<>
|
||||
{/* TODO */}
|
||||
{/* <VerticalTabItem
|
||||
name={t("general")}
|
||||
href={`${WEBAPP_URL}/settings/my-account/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/> */}
|
||||
<VerticalTabItem
|
||||
name={t("appearance")}
|
||||
href={`/settings/teams/${team.id}/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
{/* Hide if there is a parent ID */}
|
||||
{!team.parentId ? (
|
||||
<>
|
||||
<VerticalTabItem
|
||||
name={t("billing")}
|
||||
href={`/settings/teams/${team.id}/billing`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
{HOSTED_CAL_FEATURES && (
|
||||
<VerticalTabItem
|
||||
name={t("saml_config")}
|
||||
href={`/settings/teams/${team.id}/sso`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
{(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
|
||||
<VerticalTabItem
|
||||
name={t("add_a_team")}
|
||||
href={`${WEBAPP_URL}/settings/teams/new`}
|
||||
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
|
||||
icon="plus"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{tab.name === "other_teams" && (
|
||||
<React.Fragment key={tab.href}>
|
||||
<div className={`${!tab.children?.length ? "mb-3" : ""}`}>
|
||||
<Link href={tab.href}>
|
||||
<div className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-default group flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-sm font-medium leading-none">
|
||||
{tab && tab.icon && (
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
className="h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
title={t("org_admin_other_teams")}
|
||||
as="p"
|
||||
className="truncate text-sm font-medium leading-5"
|
||||
loadingClassName="ms-3">
|
||||
{t("org_admin_other_teams")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</Link>
|
||||
{otherTeams &&
|
||||
otherTeamMenuState &&
|
||||
otherTeams.map((otherTeam, index: number) => {
|
||||
if (!otherTeamMenuState[index]) {
|
||||
return null;
|
||||
}
|
||||
if (otherTeamMenuState.some((teamState) => teamState.teamId === otherTeam.id))
|
||||
return (
|
||||
<Collapsible
|
||||
className="cursor-pointer"
|
||||
key={otherTeam.id}
|
||||
open={otherTeamMenuState[index].teamMenuOpen}
|
||||
onOpenChange={() =>
|
||||
setOtherTeamMenuState([
|
||||
...otherTeamMenuState,
|
||||
(otherTeamMenuState[index] = {
|
||||
...otherTeamMenuState[index],
|
||||
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-3 py-[10px] text-left text-sm font-medium leading-none"
|
||||
onClick={() =>
|
||||
setOtherTeamMenuState([
|
||||
...otherTeamMenuState,
|
||||
(otherTeamMenuState[index] = {
|
||||
...otherTeamMenuState[index],
|
||||
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
<div className="me-3">
|
||||
{otherTeamMenuState[index].teamMenuOpen ? (
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
) : (
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{!otherTeam.parentId && (
|
||||
<img
|
||||
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
|
||||
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
|
||||
alt={otherTeam.name || "Team logo"}
|
||||
/>
|
||||
)}
|
||||
<p className="w-1/2 truncate leading-normal">{otherTeam.name}</p>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-0.5">
|
||||
<VerticalTabItem
|
||||
name={t("profile")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
<VerticalTabItem
|
||||
name={t("members")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
|
||||
<>
|
||||
{/* TODO: enable appearance edit */}
|
||||
{/* <VerticalTabItem
|
||||
name={t("appearance")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/> */}
|
||||
</>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="bg-muted border-muted sticky top-0 z-20 flex w-full items-center justify-between border-b py-2 sm:relative lg:hidden">
|
||||
<div className="flex items-center space-x-3 ">
|
||||
<Button StartIcon="menu" color="minimal" variant="icon" onClick={props.onSideContainerOpen}>
|
||||
<span className="sr-only">{t("show_navigation")}</span>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
className="hover:bg-emphasis flex items-center space-x-2 rounded-md px-3 py-1 rtl:space-x-reverse"
|
||||
onClick={() => router.back()}>
|
||||
<Icon name="arrow-left" className="text-default h-4 w-4" />
|
||||
<p className="text-emphasis font-semibold">{t("settings")}</p>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
const pathname = usePathname();
|
||||
const state = useState(false);
|
||||
const { t } = useLocale();
|
||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||
|
||||
useEffect(() => {
|
||||
const closeSideContainer = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
setSideContainerOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", closeSideContainer);
|
||||
return () => {
|
||||
window.removeEventListener("resize", closeSideContainer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sideContainerOpen) {
|
||||
setSideContainerOpen(!sideContainerOpen);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<Shell
|
||||
withoutSeo={true}
|
||||
flexChildrenContainer
|
||||
hideHeadingOnMobile
|
||||
{...rest}
|
||||
SidebarContainer={
|
||||
<SidebarContainerElement
|
||||
sideContainerOpen={sideContainerOpen}
|
||||
setSideContainerOpen={setSideContainerOpen}
|
||||
/>
|
||||
}
|
||||
drawerState={state}
|
||||
MobileNavigationContainer={null}
|
||||
TopNavContainer={
|
||||
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
|
||||
}>
|
||||
<div className="flex flex-1 [&>*]:flex-1">
|
||||
<div className="mx-auto max-w-full justify-center lg:max-w-4xl">
|
||||
<ShellHeader />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarContainerElement = ({
|
||||
sideContainerOpen,
|
||||
bannersHeight,
|
||||
setSideContainerOpen,
|
||||
}: SidebarContainerElementProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{sideContainerOpen && (
|
||||
<button
|
||||
onClick={() => setSideContainerOpen(false)}
|
||||
className="fixed left-0 top-0 z-10 h-full w-full bg-black/50">
|
||||
<span className="sr-only">{t("hide_navigation")}</span>
|
||||
</button>
|
||||
)}
|
||||
<SettingsSidebarContainer
|
||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||
bannersHeight={bannersHeight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarContainerElementProps = {
|
||||
sideContainerOpen: boolean;
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const getLayout = (page: React.ReactElement) => <SettingsLayout>{page}</SettingsLayout>;
|
||||
|
||||
export function ShellHeader() {
|
||||
const { meta } = useMeta();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={classNames(
|
||||
"border-subtle mx-auto block justify-between sm:flex",
|
||||
meta.borderInShellHeader && "rounded-t-lg border px-4 py-6 sm:px-6",
|
||||
meta.borderInShellHeader === undefined && "mb-8 border-b pb-8"
|
||||
)}>
|
||||
<div className="flex w-full items-center">
|
||||
{meta.backButton && (
|
||||
<a href="javascript:history.back()">
|
||||
<Icon name="arrow-left" className="mr-7" />
|
||||
</a>
|
||||
)}
|
||||
<div>
|
||||
{meta.title && isLocaleReady ? (
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
{t(meta.title)}
|
||||
</h1>
|
||||
) : (
|
||||
<div className="bg-emphasis mb-1 h-5 w-24 animate-pulse rounded-lg" />
|
||||
)}
|
||||
{meta.description && isLocaleReady ? (
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">{t(meta.description)}</p>
|
||||
) : (
|
||||
<div className="bg-emphasis h-5 w-32 animate-pulse rounded-lg" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ms-auto flex-shrink-0">{meta.CTA}</div>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user