perf: Server-Side Data Fetching in App Router: SettingsLayout / AdminLayout (#16537)

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Benny Joo
2024-09-09 14:38:51 -07:00
committed by GitHub
co-authored by Joe Au-Yeung
parent 71443ef23d
commit 706d7dff42
25 changed files with 929 additions and 874 deletions
+3 -9
View File
@@ -1,11 +1,10 @@
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import PageWrapper from "@components/PageWrapperAppDir";
import { ssrInit } from "@server/lib/ssr";
import Logout from "~/auth/logout-view";
@@ -20,7 +19,6 @@ export const generateMetadata = async () => {
const Page = async ({ params, searchParams }: PageProps) => {
// cookie will be cleared in `/apps/web/middleware.ts`
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
const context = buildLegacyCtx(h, cookies(), params, searchParams);
const ssr = await ssrInit(context);
const props = {
@@ -28,11 +26,7 @@ const Page = async ({ params, searchParams }: PageProps) => {
query: context.query,
};
return (
<PageWrapper requiresLicense={false} getLayout={null} nonce={nonce} themeBasis={null}>
<Logout {...props} />
</PageWrapper>
);
return <Logout {...props} />;
};
export default Page;
export default WithLayout({ getLayout: null, ServerPage: Page })<"P">;
@@ -10,4 +10,4 @@ export const generateMetadata = async () =>
() => "admin_description"
);
export default WithLayout({ getLayout, Page: LegacyPage })<"P">;
export default WithLayout({ getServerLayout: getLayout, Page: LegacyPage })<"P">;
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir";
export default WithLayout({ getLayout })<"L">;
export default WithLayout({ getServerLayout: getLayout })<"L">;
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout: getLayout })<"L">;
export default WithLayout({ getServerLayout: getLayout })<"L">;
@@ -3,9 +3,7 @@ import LegacyPage, {
} from "@pages/settings/organizations/[id]/onboard-members";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { headers } from "next/headers";
import PageWrapper from "@components/PageWrapperAppDir";
import { WithLayout } from "app/layoutHOC";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,18 +12,7 @@ export const generateMetadata = async () =>
);
const Page = ({ params }: PageProps) => {
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
return (
<PageWrapper
getLayout={(page: React.ReactElement) => buildWrappedOnboardTeamMembersPage(params.id, page)}
requiresLicense={false}
nonce={nonce}
themeBasis={null}>
<LegacyPage />
</PageWrapper>
);
return buildWrappedOnboardTeamMembersPage(params.id, <LegacyPage />);
};
export default Page;
export default WithLayout({ Page });
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
export default WithLayout({ getServerLayout: getLayout });
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
export default WithLayout({ getServerLayout: getLayout });
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
export default WithLayout({ getServerLayout: getLayout });
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
export default WithLayout({ getServerLayout: getLayout });
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
export default WithLayout({ getServerLayout: getLayout });
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
export default WithLayout({ getServerLayout: getLayout });
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
export default WithLayout({ getServerLayout: getLayout });
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
export default WithLayout({ getServerLayout: getLayout });
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
export default WithLayout({ getServerLayout: getLayout });
@@ -2,4 +2,4 @@ import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
export default WithLayout({ getLayout })<"L">;
export default WithLayout({ getServerLayout: getLayout })<"L">;
+16 -3
View File
@@ -7,8 +7,10 @@ import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import PageWrapper from "@components/PageWrapperAppDir";
type WithLayoutParams<T extends Record<string, any>> = {
getLayout: ((page: React.ReactElement) => React.ReactNode) | null;
getLayout?: ((page: React.ReactElement) => React.ReactNode) | null;
getServerLayout?: (page: React.ReactElement) => Promise<React.ReactNode | null>;
Page?: (props: T) => React.ReactElement | null;
ServerPage?: (props: T) => Promise<React.ReactElement> | null;
getData?: (arg: GetServerSidePropsContext) => Promise<T | undefined>;
isBookingPage?: boolean;
requiresLicense?: boolean;
@@ -16,7 +18,9 @@ type WithLayoutParams<T extends Record<string, any>> = {
export function WithLayout<T extends Record<string, any>>({
getLayout,
getServerLayout,
getData,
ServerPage,
Page,
isBookingPage,
requiresLicense,
@@ -30,7 +34,16 @@ export function WithLayout<T extends Record<string, any>>({
props = (await getData(buildLegacyCtx(h, cookies(), p.params, p.searchParams))) ?? ({} as T);
}
const children = "children" in p ? p.children : null;
// `p.children` exists only for layout.tsx files
const childrenFromLayoutFile = "children" in p ? p.children : null;
const page = ServerPage ? (
await ServerPage({ ...props, ...p })
) : Page ? (
<Page {...props} />
) : (
childrenFromLayoutFile
);
const pageWithServerLayout = page ? (getServerLayout ? await getServerLayout(page) : page) : null;
return (
<PageWrapper
@@ -40,7 +53,7 @@ export function WithLayout<T extends Record<string, any>>({
themeBasis={null}
isBookingPage={isBookingPage || !!(Page && "isBookingPage" in Page && Page.isBookingPage)}
{...props}>
{Page ? <Page {...props} /> : children}
{pageWithServerLayout}
</PageWrapper>
);
};
+2 -9
View File
@@ -5,21 +5,14 @@ import type { SSRConfig } from "next-i18next";
// import I18nLanguageHandler from "@components/I18nLanguageHandler";
import { usePathname } from "next/navigation";
import Script from "next/script";
import type { ReactNode } from "react";
import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import type { AppProps } from "@lib/app-providers-app-dir";
import AppProviders from "@lib/app-providers-app-dir";
export interface CalPageWrapper {
(props?: AppProps): JSX.Element;
PageWrapper?: AppProps["Component"]["PageWrapper"];
}
export type PageWrapperProps = Readonly<{
getLayout: ((page: React.ReactElement) => ReactNode) | null;
getLayout?: ((page: React.ReactElement) => React.ReactNode) | null;
children: React.ReactNode;
requiresLicense: boolean;
nonce: string | undefined;
@@ -50,7 +43,7 @@ function PageWrapper(props: PageWrapperProps) {
nonce,
};
const getLayout: (page: React.ReactElement) => ReactNode = props.getLayout ?? ((page) => page);
const getLayout: (page: React.ReactElement) => React.ReactNode = props.getLayout ?? ((page) => page);
return (
<AppProviders {...providerProps}>
@@ -1,40 +1,23 @@
"use client";
import { getServerSession } from "next-auth";
import dynamic from "next/dynamic";
import React from "react";
import { useSession } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import React, { useEffect } from "react";
import { AUTH_OPTIONS } from "@calcom/features/auth/lib/next-auth-options";
import SettingsLayoutAppDir from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
import SettingsLayout from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
import type Shell from "@calcom/features/shell/Shell";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { ErrorBoundary } from "@calcom/ui";
import type { AdminLayoutProps } from "./AdminLayoutAppDirClient";
export default function AdminLayout({
children,
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
const pathname = usePathname();
const session = useSession();
const router = useRouter();
const AdminLayoutAppDirClient = dynamic(() => import("./AdminLayoutAppDirClient"), {
ssr: false,
});
// Force redirect on component level
useEffect(() => {
if (session.data && session.data.user.role !== UserPermissionRole.ADMIN) {
router.replace("/settings/my-account/profile");
}
}, [session, router]);
type AdminLayoutAppDirProps = Omit<AdminLayoutProps, "userRole">;
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
return (
<SettingsLayout {...rest}>
<div className="divide-subtle mx-auto flex max-w-4xl flex-row divide-y">
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</div>
</SettingsLayout>
);
export default async function AdminLayoutAppDir(props: AdminLayoutAppDirProps) {
const session = await getServerSession(AUTH_OPTIONS);
const userRole = session?.user?.role;
return await SettingsLayoutAppDir({ children: <AdminLayoutAppDirClient {...props} userRole={userRole} /> });
}
export const getLayout = (page: React.ReactElement) => <AdminLayout>{page}</AdminLayout>;
export const getLayout = async (page: React.ReactElement) => await AdminLayoutAppDir({ children: page });
@@ -0,0 +1,34 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import React, { useEffect } from "react";
import type Shell from "@calcom/features/shell/Shell";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { ErrorBoundary } from "@calcom/ui";
export type AdminLayoutProps = {
children: React.ReactNode;
userRole: UserPermissionRole | "INACTIVE_ADMIN" | undefined;
} & ComponentProps<typeof Shell>;
export default function AdminLayoutAppDirClient({ userRole, children }: AdminLayoutProps) {
const pathname = usePathname();
const router = useRouter();
// Force redirect on component level
useEffect(() => {
if (userRole !== UserPermissionRole.ADMIN) {
router.replace("/settings/my-account/profile");
}
}, [userRole, router]);
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
return (
<div className="divide-subtle bg-default mx-auto flex max-w-4xl flex-row divide-y">
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</div>
);
}
+3 -5
View File
@@ -5,10 +5,8 @@ import React from "react";
import Shell from "@calcom/features/shell/Shell";
export default function MainLayout({
children,
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
export type MainLayoutAppDirProps = { children: React.ReactNode } & ComponentProps<typeof Shell>;
export default function MainLayoutAppDir({ children, ...rest }: MainLayoutAppDirProps) {
return (
<Shell withoutMain={true} {...rest}>
{children}
@@ -16,4 +14,4 @@ export default function MainLayout({
);
}
export const getLayout = (page: React.ReactElement) => <MainLayout>{page}</MainLayout>;
export const getLayout = (page: React.ReactElement) => <MainLayoutAppDir>{page}</MainLayoutAppDir>;
@@ -1,737 +1,37 @@
"use client";
import { getServerSession } from "next-auth";
import dynamic from "next/dynamic";
import React from "react";
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, { useEffect, useState, useMemo } from "react";
import { AUTH_OPTIONS } from "@calcom/features/auth/lib/next-auth-options";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
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, VerticalTabItem } from "@calcom/ui";
import type { SettingsLayoutProps } from "./SettingsLayoutAppDirClient";
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" });
}
const SettingsLayoutAppDirClient = dynamic(() => import("./SettingsLayoutAppDirClient"), {
ssr: false,
});
// The following keys are assigned to admin only
const adminRequiredKeys = ["admin"];
const organizationRequiredKeys = ["organization"];
const organizationAdminKeys = ["privacy", "billing", "OAuth Clients", "SSO", "directory_sync"];
type SettingsLayoutAppDir = Omit<SettingsLayoutProps, "currentOrg" | "otherTeams">;
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;
export default async function SettingsLayoutAppDir(props: SettingsLayoutAppDir) {
const session = await getServerSession(AUTH_OPTIONS);
const userId = session?.user?.id ?? -1;
const orgId = session?.user?.org?.id ?? -1;
let currentOrg = null;
let otherTeams = null;
const processTabsMemod = useMemo(() => {
const processedTabs = tabs.map((tab) => {
if (tab.href === "/settings/my-account") {
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)
);
try {
currentOrg = await OrganizationRepository.findCurrentOrg({ userId, orgId });
} catch (err) {}
// TODO: figure out feature flag as it doesnt cause a re-render of the component when loaded.
// You have to refresh the page to see the changes.
if (true) {
newArray.splice(4, 0, {
name: "attributes",
href: "/settings/organizations/attributes",
});
}
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") {
const filtered = tab?.children?.filter(
(childTab) => isOrgAdminOrOwner || childTab.name !== "admin_api"
);
return { ...tab, children: filtered };
}
return tab;
try {
otherTeams = await OrganizationRepository.findTeamsInOrgIamNotPartOf({
userId,
parentId: orgId,
});
} catch (err) {}
// 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;
return <SettingsLayoutAppDirClient {...props} currentOrg={currentOrg} otherTeams={otherTeams} />;
}
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 transition"
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 transition">
{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 transition">
{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 transition"
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 [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">
<ErrorBoundary>{children}</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 const getLayout = async (page: React.ReactElement) => await SettingsLayoutAppDir({ children: page });
@@ -0,0 +1,745 @@
"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, { 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 type { OrganizationRepository } from "@calcom/lib/server/repository/organization";
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, 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") {
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)
);
// TODO: figure out feature flag as it doesnt cause a re-render of the component when loaded.
// You have to refresh the page to see the changes.
if (true) {
newArray.splice(4, 0, {
name: "attributes",
href: "/settings/organizations/attributes",
});
}
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") {
const filtered = tab?.children?.filter(
(childTab) => isOrgAdminOrOwner || 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;
currentOrg: SettingsLayoutProps["currentOrg"];
otherTeams: SettingsLayoutProps["otherTeams"];
}
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 transition"
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,
currentOrg,
otherTeams,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const tabsWithPermissions = useTabs();
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
{
teamId: number | undefined;
teamMenuOpen: boolean;
}[]
>();
// 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 transition">
{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 transition">
{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 transition"
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 type SettingsLayoutProps = {
children: React.ReactNode;
currentOrg: Awaited<ReturnType<typeof OrganizationRepository.findCurrentOrg>> | null;
otherTeams: Awaited<ReturnType<typeof OrganizationRepository.findTeamsInOrgIamNotPartOf>> | null;
} & ComponentProps<typeof Shell>;
export default function SettingsLayoutAppDirClient({
children,
currentOrg,
otherTeams,
...rest
}: SettingsLayoutProps) {
const pathname = usePathname();
const state = useState(false);
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
currentOrg={currentOrg}
otherTeams={otherTeams}
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">
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</div>
</Shell>
);
}
const SidebarContainerElement = ({
sideContainerOpen,
bannersHeight,
setSideContainerOpen,
currentOrg,
otherTeams,
}: 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}
currentOrg={currentOrg}
otherTeams={otherTeams}
/>
</>
);
};
type SidebarContainerElementProps = {
sideContainerOpen: boolean;
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
currentOrg: SettingsLayoutProps["currentOrg"];
otherTeams: SettingsLayoutProps["otherTeams"];
};
+60 -1
View File
@@ -194,6 +194,66 @@ export class OrganizationRepository {
return getParsedTeam(org);
}
static async findCurrentOrg({ userId, orgId }: { userId: number; orgId: number }) {
const membership = await prisma.membership.findFirst({
where: {
userId,
team: {
id: orgId,
},
},
include: {
team: true,
},
});
const organizationSettings = await prisma.organizationSettings.findUnique({
where: {
organizationId: orgId,
},
select: {
lockEventTypeCreationForUsers: true,
adminGetsNoSlotsNotification: true,
isAdminReviewed: true,
},
});
if (!membership) {
throw new Error("You do not have a membership to your organization");
}
const metadata = teamMetadataSchema.parse(membership?.team.metadata);
return {
canAdminImpersonate: !!organizationSettings?.isAdminReviewed,
organizationSettings: {
lockEventTypeCreationForUsers: organizationSettings?.lockEventTypeCreationForUsers,
adminGetsNoSlotsNotification: organizationSettings?.adminGetsNoSlotsNotification,
},
user: {
role: membership?.role,
accepted: membership?.accepted,
},
...membership?.team,
metadata,
};
}
static async findTeamsInOrgIamNotPartOf({ userId, parentId }: { userId: number; parentId: number | null }) {
const teamsInOrgIamNotPartOf = await prisma.team.findMany({
where: {
parentId,
members: {
none: {
userId,
},
},
},
});
return teamsInOrgIamNotPartOf;
}
static async adminFindById({ id }: { id: number }) {
const org = await prisma.team.findUnique({
where: {
@@ -228,7 +288,6 @@ export class OrganizationRepository {
},
},
});
if (!org) {
throw new Error("Organization not found");
}
@@ -1,5 +1,4 @@
import type { PrismaClient } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
@@ -7,7 +6,6 @@ import { TRPCError } from "@trpc/server";
type ListHandlerInput = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
};
@@ -16,52 +14,10 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => {
if (!ctx.user.organization?.id) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You do not belong to an organization" });
}
const membership = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
team: {
id: ctx.user.organization.id,
},
},
include: {
team: true,
},
return await OrganizationRepository.findCurrentOrg({
userId: ctx.user.id,
orgId: ctx.user.organization.id,
});
const organizationSettings = await ctx.prisma.organizationSettings.findUnique({
where: {
organizationId: ctx.user.organization.id,
},
select: {
lockEventTypeCreationForUsers: true,
adminGetsNoSlotsNotification: true,
isAdminReviewed: true,
},
});
if (!membership) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You do not have a membership to your organization",
});
}
const metadata = teamMetadataSchema.parse(membership?.team.metadata);
return {
canAdminImpersonate: !!organizationSettings?.isAdminReviewed,
organizationSettings: {
lockEventTypeCreationForUsers: organizationSettings?.lockEventTypeCreationForUsers,
adminGetsNoSlotsNotification: organizationSettings?.adminGetsNoSlotsNotification,
},
user: {
role: membership?.role,
accepted: membership?.accepted,
},
...membership?.team,
metadata,
};
};
export default listHandler;
@@ -1,4 +1,4 @@
import { prisma } from "@calcom/prisma";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import type { TrpcSessionUser } from "../../../trpc";
@@ -12,18 +12,11 @@ export const listOtherTeamHandler = async ({ ctx: { user } }: ListOptions) => {
if (!user?.organization?.isOrgAdmin) {
return [];
}
const teamsInOrgIamNotPartOf = await prisma.team.findMany({
where: {
parentId: user?.organization?.id ?? null,
members: {
none: {
userId: user.id,
},
},
},
});
return teamsInOrgIamNotPartOf;
return await OrganizationRepository.findTeamsInOrgIamNotPartOf({
userId: user.id,
parentId: user?.organization?.id ?? null,
});
};
export default listOtherTeamHandler;