From 026e1bece679305f9ea0f06e47cb1df4268ecb29 Mon Sep 17 00:00:00 2001 From: Rahadi Jalu Date: Wed, 22 Jan 2025 11:22:30 +0700 Subject: [PATCH] fix: filter navigation items based on user's permissions and role --- apps/dokploy/components/layouts/side.tsx | 770 +++++++++++++---------- 1 file changed, 442 insertions(+), 328 deletions(-) diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index b2f87e41..c711b862 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -1,7 +1,6 @@ "use client"; import { Activity, - AudioWaveform, BarChartHorizontalBigIcon, Bell, BlocksIcon, @@ -9,7 +8,6 @@ import { Boxes, ChevronRight, CircleHelp, - Command, CreditCard, Database, Folder, @@ -27,8 +25,8 @@ import { Users, } from "lucide-react"; import { usePathname } from "next/navigation"; -import { useEffect, useState } from "react"; import type * as React from "react"; +import { useEffect, useState } from "react"; import { Breadcrumb, @@ -65,243 +63,290 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; +import type { AppRouter } from "@/server/api/root"; import { api } from "@/utils/api"; +import type { inferRouterOutputs } from "@trpc/server"; import Link from "next/link"; import { useRouter } from "next/router"; import { Logo } from "../shared/logo"; import { UpdateServerButton } from "./update-server"; import { UserNav } from "./user-nav"; -// This is sample data. -interface NavItem { + +// The types of the queries we are going to use +type AuthQueryOutput = inferRouterOutputs["auth"]["get"]; +type UserQueryOutput = inferRouterOutputs["user"]["byAuthId"]; + +type SingleNavItem = { + isSingle?: true; title: string; url: string; - icon: LucideIcon; - isSingle: boolean; - isActive: boolean; - items?: { - title: string; - url: string; - icon?: LucideIcon; - }[]; -} + icon?: LucideIcon; + isEnabled?: (opts: { + auth?: AuthQueryOutput; + user?: UserQueryOutput; + isCloud: boolean; + }) => boolean; +}; -interface ExternalLink { +// NavItem type +// Consists of a single item or a group of items +// If `isSingle` is true or undefined, the item is a single item +// If `isSingle` is false, the item is a group of items +type NavItem = + | SingleNavItem + | { + isSingle: false; + title: string; + icon: LucideIcon; + items: SingleNavItem[]; + isEnabled?: (opts: { + auth?: AuthQueryOutput; + user?: UserQueryOutput; + isCloud: boolean; + }) => boolean; + }; + +// ExternalLink type +// Represents an external link item (used for the help section) +type ExternalLink = { name: string; url: string; icon: React.ComponentType<{ className?: string }>; -} + isEnabled?: (opts: { + auth?: AuthQueryOutput; + user?: UserQueryOutput; + isCloud: boolean; + }) => boolean; +}; -const data = { - user: { - name: "shadcn", - email: "m@example.com", - avatar: "/avatars/shadcn.jpg", - }, - teams: [ - { - name: "Dokploy", - logo: Logo, - plan: "Enterprise", - }, - { - name: "Acme Corp.", - logo: AudioWaveform, - plan: "Startup", - }, - { - name: "Evil Corp.", - logo: Command, - plan: "Free", - }, - ], +// Menu type +// Consists of home, settings, and help items +type Menu = { + home: NavItem[]; + settings: NavItem[]; + help: ExternalLink[]; +}; + +// Menu items +// Consists of unfiltered home, settings, and help items +// The items are filtered based on the user's role and permissions +// The `isEnabled` function is called to determine if the item should be displayed +const MENU: Menu = { home: [ { + isSingle: true, title: "Projects", url: "/dashboard/projects", icon: Folder, - isSingle: true, - isActive: false, }, { + isSingle: true, title: "Monitoring", url: "/dashboard/monitoring", icon: BarChartHorizontalBigIcon, - isSingle: true, - isActive: false, + // Only enabled in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => !isCloud, }, { + isSingle: true, title: "Traefik File System", url: "/dashboard/traefik", icon: GalleryVerticalEnd, - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to Traefik files in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!( + (auth?.rol === "admin" || user?.canAccessToTraefikFiles) && + !isCloud + ), }, { + isSingle: true, title: "Docker", url: "/dashboard/docker", icon: BlocksIcon, - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud), }, { + isSingle: true, title: "Swarm", url: "/dashboard/swarm", icon: PieChart, - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud), }, { + isSingle: true, title: "Requests", url: "/dashboard/requests", icon: Forward, - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud), }, + // Legacy unused menu, adjusted to the new structure // { + // isSingle: true, // title: "Projects", // url: "/dashboard/projects", // icon: Folder, - // isSingle: true, // }, // { + // isSingle: true, // title: "Monitoring", // icon: BarChartHorizontalBigIcon, // url: "/dashboard/settings/monitoring", - // isSingle: true, // }, - // { - // title: "Settings", - // url: "#", - // icon: Settings2, - // isActive: true, - // items: [ - // { - // title: "Profile", - // url: "/dashboard/settings/profile", - // }, - // { - // title: "Users", - // url: "/dashboard/settings/users", - // }, - // { - // title: "SSH Key", - // url: "/dashboard/settings/ssh-keys", - // }, - // { - // title: "Git", - // url: "/dashboard/settings/git-providers", - // }, - // ], + // isSingle: false, + // title: "Settings", + // icon: Settings2, + // items: [ + // { + // title: "Profile", + // url: "/dashboard/settings/profile", + // }, + // { + // title: "Users", + // url: "/dashboard/settings/users", + // }, + // { + // title: "SSH Key", + // url: "/dashboard/settings/ssh-keys", + // }, + // { + // title: "Git", + // url: "/dashboard/settings/git-providers", + // }, + // ], // }, - // { - // title: "Integrations", - // icon: BlocksIcon, - // items: [ - // { - // title: "S3 Destinations", - // url: "/dashboard/settings/destinations", - // }, - // { - // title: "Registry", - // url: "/dashboard/settings/registry", - // }, - // { - // title: "Notifications", - // url: "/dashboard/settings/notifications", - // }, - // ], - ] as NavItem[], + // isSingle: false, + // title: "Integrations", + // icon: BlocksIcon, + // items: [ + // { + // title: "S3 Destinations", + // url: "/dashboard/settings/destinations", + // }, + // { + // title: "Registry", + // url: "/dashboard/settings/registry", + // }, + // { + // title: "Notifications", + // url: "/dashboard/settings/notifications", + // }, + // ], + // }, + ], + settings: [ { + isSingle: true, title: "Server", url: "/dashboard/settings/server", icon: Activity, - isSingle: true, - isActive: false, + // Only enabled for admins in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!(auth?.rol === "admin" && !isCloud), }, { + isSingle: true, title: "Profile", url: "/dashboard/settings/profile", icon: User, - isSingle: true, - isActive: false, }, { + isSingle: true, title: "Servers", url: "/dashboard/settings/servers", icon: Server, - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "Users", icon: Users, url: "/dashboard/settings/users", - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "SSH Keys", icon: KeyRound, url: "/dashboard/settings/ssh-keys", - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to SSH keys + isEnabled: ({ auth, user }) => + !!(auth?.rol === "admin" || user?.canAccessToSSHKeys), }, - { + isSingle: true, title: "Git", url: "/dashboard/settings/git-providers", icon: GitBranch, - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to Git providers + isEnabled: ({ auth, user }) => + !!(auth?.rol === "admin" || user?.canAccessToGitProviders), }, { + isSingle: true, title: "Registry", url: "/dashboard/settings/registry", icon: Package, - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "S3 Destinations", url: "/dashboard/settings/destinations", icon: Database, - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "Certificates", url: "/dashboard/settings/certificates", icon: ShieldCheck, - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "Cluster", url: "/dashboard/settings/cluster", icon: Boxes, - isSingle: true, - isActive: false, + // Only enabled for admins in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!(auth?.rol === "admin" && !isCloud), }, { + isSingle: true, title: "Notifications", url: "/dashboard/settings/notifications", icon: Bell, - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "Billing", url: "/dashboard/settings/billing", icon: CreditCard, - isSingle: true, - isActive: false, + // Only enabled for admins in cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!(auth?.rol === "admin" && isCloud), }, - ] as NavItem[], + ], + help: [ { name: "Documentation", @@ -325,8 +370,108 @@ const data = { /> ), }, - ] as ExternalLink[], -}; + ], +} as const; + +/** + * Creates a menu based on the current user's role and permissions + * @returns a menu object with the home, settings, and help items + */ +function createMenuForAuthUser(opts: { + auth?: AuthQueryOutput; + user?: UserQueryOutput; + isCloud: boolean; +}): Menu { + return { + // Filter the home items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + home: MENU.home.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + user: opts.user, + isCloud: opts.isCloud, + }), + ), + // Filter the settings items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + settings: MENU.settings.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + user: opts.user, + isCloud: opts.isCloud, + }), + ), + // Filter the help items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + help: MENU.help.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + user: opts.user, + isCloud: opts.isCloud, + }), + ), + }; +} + +/** + * Determines if an item url is active based on the current pathname + * @returns true if the item url is active, false otherwise + */ +function isActiveRoute(opts: { + /** The url of the item. Usually obtained from `item.url` */ + itemUrl: string; + /** The current pathname. Usually obtained from `usePathname()` */ + pathname: string; +}): boolean { + const normalizedItemUrl = opts.itemUrl?.replace("/projects", "/project"); + const normalizedPathname = opts.pathname?.replace("/projects", "/project"); + + if (!normalizedPathname) return false; + + if (normalizedPathname === normalizedItemUrl) return true; + + if (normalizedPathname.startsWith(normalizedItemUrl)) { + const nextChar = normalizedPathname.charAt(normalizedItemUrl.length); + return nextChar === "/"; + } + + return false; +} + +/** + * Finds the active nav item based on the current pathname + * @returns the active nav item with `SingleNavItem` type or undefined if none is active + */ +function findActiveNavItem( + navItems: NavItem[], + pathname: string, +): SingleNavItem | undefined { + const found = navItems.find((item) => + item.isSingle !== false + ? // The current item is single, so check if the item url is active + isActiveRoute({ itemUrl: item.url, pathname }) + : // The current item is not single, so check if any of the sub items are active + item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ), + ); + + if (found?.isSingle !== false) { + // The found item is single, so return it + return found; + } + + // The found item is not single, so find the active sub item + return found?.items.find((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ); +} interface Props { children: React.ReactNode; @@ -398,64 +543,21 @@ export default function Page({ children }: Props) { const includesProjects = pathname?.includes("/dashboard/project"); const { data: isCloud, isLoading } = api.settings.isCloud.useQuery(); - const isActiveRoute = (itemUrl: string) => { - const normalizedItemUrl = itemUrl?.replace("/projects", "/project"); - const normalizedPathname = pathname?.replace("/projects", "/project"); - if (!normalizedPathname) return false; + const { + home: filteredHome, + settings: filteredSettings, + help, + } = createMenuForAuthUser({ auth, user, isCloud: !!isCloud }); - if (normalizedPathname === normalizedItemUrl) return true; + const activeItem = findActiveNavItem( + [...filteredHome, ...filteredSettings], + pathname, + ); - if (normalizedPathname.startsWith(normalizedItemUrl)) { - const nextChar = normalizedPathname.charAt(normalizedItemUrl.length); - return nextChar === "/"; - } - - return false; - }; - - let filteredHome = isCloud - ? data.home.filter( - (item) => - ![ - "/dashboard/monitoring", - "/dashboard/traefik", - "/dashboard/docker", - "/dashboard/swarm", - "/dashboard/requests", - ].includes(item.url), - ) - : data.home; - - let filteredSettings = isCloud - ? data.settings.filter( - (item) => - ![ - "/dashboard/settings/server", - "/dashboard/settings/cluster", - ].includes(item.url), - ) - : data.settings.filter( - (item) => !["/dashboard/settings/billing"].includes(item.url), - ); - - filteredHome = filteredHome.map((item) => ({ - ...item, - isActive: isActiveRoute(item.url), - })); - - filteredSettings = filteredSettings.map((item) => ({ - ...item, - isActive: isActiveRoute(item.url), - })); - - const activeItem = - filteredHome.find((item) => item.isActive) || - filteredSettings.find((item) => item.isActive); - - const showProjectsButton = - currentPath === "/dashboard/projects" && - (auth?.rol === "admin" || user?.canCreateProjects); + // const showProjectsButton = + // currentPath === "/dashboard/projects" && + // (auth?.rol === "admin" || user?.canCreateProjects); return ( Home - {filteredHome.map((item) => ( - - - {item.isSingle ? ( - - - - {item.title} - - - ) : ( - <> - - - {item.icon && } + {filteredHome.map((item) => { + const isSingle = item.isSingle !== false; + const isActive = isSingle + ? isActiveRoute({ itemUrl: item.url, pathname }) + : item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ); - {item.title} - {item.items?.length && ( - + return ( + + + {isSingle ? ( + + + {item.icon && ( + )} - - - - - {item.items?.map((subItem) => ( - - - {item.title} + + + ) : ( + <> + + + {item.icon && } + + {item.title} + {item.items?.length && ( + + )} + + + + + {item.items?.map((subItem) => ( + + - {subItem.icon && ( - - - - )} - {subItem.title} - - - - ))} - - - - )} - - - ))} + + {subItem.icon && ( + + + + )} + {subItem.title} + + + + ))} + + + + )} + + + ); + })} Settings - {filteredSettings.map((item) => ( - - - {item.isSingle ? ( - - - - {item.title} - - - ) : ( - <> - - - {item.icon && } + {filteredSettings.map((item) => { + const isSingle = item.isSingle !== false; + const isActive = isSingle + ? isActiveRoute({ itemUrl: item.url, pathname }) + : item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ); - {item.title} - {item.items?.length && ( - + return ( + + + {isSingle ? ( + + + {item.icon && ( + )} - - - - - {item.items?.map((subItem) => ( - - - {item.title} + + + ) : ( + <> + + + {item.icon && } + + {item.title} + {item.items?.length && ( + + )} + + + + + {item.items?.map((subItem) => ( + + - {subItem.icon && ( - - - - )} - {subItem.title} - - - - ))} - - - - )} - - - ))} + + {subItem.icon && ( + + + + )} + {subItem.title} + + + + ))} + + + + )} + + + ); + })} Extra - {data.help.map((item: ExternalLink) => ( + {help.map((item: ExternalLink) => (