fix: filter navigation items based on user's permissions and role

This commit is contained in:
Rahadi Jalu
2025-01-22 11:22:30 +07:00
parent 51f6e08e16
commit 026e1bece6

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
Activity, Activity,
AudioWaveform,
BarChartHorizontalBigIcon, BarChartHorizontalBigIcon,
Bell, Bell,
BlocksIcon, BlocksIcon,
@@ -9,7 +8,6 @@ import {
Boxes, Boxes,
ChevronRight, ChevronRight,
CircleHelp, CircleHelp,
Command,
CreditCard, CreditCard,
Database, Database,
Folder, Folder,
@@ -27,8 +25,8 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import type * as React from "react"; import type * as React from "react";
import { useEffect, useState } from "react";
import { import {
Breadcrumb, Breadcrumb,
@@ -65,243 +63,290 @@ import {
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import type { inferRouterOutputs } from "@trpc/server";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Logo } from "../shared/logo"; import { Logo } from "../shared/logo";
import { UpdateServerButton } from "./update-server"; import { UpdateServerButton } from "./update-server";
import { UserNav } from "./user-nav"; import { UserNav } from "./user-nav";
// This is sample data.
interface NavItem { // The types of the queries we are going to use
type AuthQueryOutput = inferRouterOutputs<AppRouter>["auth"]["get"];
type UserQueryOutput = inferRouterOutputs<AppRouter>["user"]["byAuthId"];
type SingleNavItem = {
isSingle?: true;
title: string; title: string;
url: string; url: string;
icon: LucideIcon; icon?: LucideIcon;
isSingle: boolean; isEnabled?: (opts: {
isActive: boolean; auth?: AuthQueryOutput;
items?: { user?: UserQueryOutput;
title: string; isCloud: boolean;
url: string; }) => boolean;
icon?: LucideIcon; };
}[];
}
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; name: string;
url: string; url: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
} isEnabled?: (opts: {
auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean;
}) => boolean;
};
const data = { // Menu type
user: { // Consists of home, settings, and help items
name: "shadcn", type Menu = {
email: "m@example.com", home: NavItem[];
avatar: "/avatars/shadcn.jpg", settings: NavItem[];
}, help: ExternalLink[];
teams: [ };
{
name: "Dokploy", // Menu items
logo: Logo, // Consists of unfiltered home, settings, and help items
plan: "Enterprise", // 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 = {
name: "Acme Corp.",
logo: AudioWaveform,
plan: "Startup",
},
{
name: "Evil Corp.",
logo: Command,
plan: "Free",
},
],
home: [ home: [
{ {
isSingle: true,
title: "Projects", title: "Projects",
url: "/dashboard/projects", url: "/dashboard/projects",
icon: Folder, icon: Folder,
isSingle: true,
isActive: false,
}, },
{ {
isSingle: true,
title: "Monitoring", title: "Monitoring",
url: "/dashboard/monitoring", url: "/dashboard/monitoring",
icon: BarChartHorizontalBigIcon, icon: BarChartHorizontalBigIcon,
isSingle: true, // Only enabled in non-cloud environments
isActive: false, isEnabled: ({ auth, user, isCloud }) => !isCloud,
}, },
{ {
isSingle: true,
title: "Traefik File System", title: "Traefik File System",
url: "/dashboard/traefik", url: "/dashboard/traefik",
icon: GalleryVerticalEnd, icon: GalleryVerticalEnd,
isSingle: true, // Only enabled for admins and users with access to Traefik files in non-cloud environments
isActive: false, isEnabled: ({ auth, user, isCloud }) =>
!!(
(auth?.rol === "admin" || user?.canAccessToTraefikFiles) &&
!isCloud
),
}, },
{ {
isSingle: true,
title: "Docker", title: "Docker",
url: "/dashboard/docker", url: "/dashboard/docker",
icon: BlocksIcon, icon: BlocksIcon,
isSingle: true, // Only enabled for admins and users with access to Docker in non-cloud environments
isActive: false, isEnabled: ({ auth, user, isCloud }) =>
!!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud),
}, },
{ {
isSingle: true,
title: "Swarm", title: "Swarm",
url: "/dashboard/swarm", url: "/dashboard/swarm",
icon: PieChart, icon: PieChart,
isSingle: true, // Only enabled for admins and users with access to Docker in non-cloud environments
isActive: false, isEnabled: ({ auth, user, isCloud }) =>
!!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud),
}, },
{ {
isSingle: true,
title: "Requests", title: "Requests",
url: "/dashboard/requests", url: "/dashboard/requests",
icon: Forward, icon: Forward,
isSingle: true, // Only enabled for admins and users with access to Docker in non-cloud environments
isActive: false, isEnabled: ({ auth, user, isCloud }) =>
!!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud),
}, },
// Legacy unused menu, adjusted to the new structure
// { // {
// isSingle: true,
// title: "Projects", // title: "Projects",
// url: "/dashboard/projects", // url: "/dashboard/projects",
// icon: Folder, // icon: Folder,
// isSingle: true,
// }, // },
// { // {
// isSingle: true,
// title: "Monitoring", // title: "Monitoring",
// icon: BarChartHorizontalBigIcon, // icon: BarChartHorizontalBigIcon,
// url: "/dashboard/settings/monitoring", // url: "/dashboard/settings/monitoring",
// isSingle: true,
// }, // },
// { // {
// title: "Settings", // isSingle: false,
// url: "#", // title: "Settings",
// icon: Settings2, // icon: Settings2,
// isActive: true, // items: [
// items: [ // {
// { // title: "Profile",
// title: "Profile", // url: "/dashboard/settings/profile",
// url: "/dashboard/settings/profile", // },
// }, // {
// { // title: "Users",
// title: "Users", // url: "/dashboard/settings/users",
// url: "/dashboard/settings/users", // },
// }, // {
// { // title: "SSH Key",
// title: "SSH Key", // url: "/dashboard/settings/ssh-keys",
// url: "/dashboard/settings/ssh-keys", // },
// }, // {
// { // title: "Git",
// title: "Git", // url: "/dashboard/settings/git-providers",
// url: "/dashboard/settings/git-providers", // },
// }, // ],
// ],
// }, // },
// { // {
// title: "Integrations", // isSingle: false,
// icon: BlocksIcon, // title: "Integrations",
// items: [ // icon: BlocksIcon,
// { // items: [
// title: "S3 Destinations", // {
// url: "/dashboard/settings/destinations", // title: "S3 Destinations",
// }, // url: "/dashboard/settings/destinations",
// { // },
// title: "Registry", // {
// url: "/dashboard/settings/registry", // title: "Registry",
// }, // url: "/dashboard/settings/registry",
// { // },
// title: "Notifications", // {
// url: "/dashboard/settings/notifications", // title: "Notifications",
// }, // url: "/dashboard/settings/notifications",
// ], // },
] as NavItem[], // ],
// },
],
settings: [ settings: [
{ {
isSingle: true,
title: "Server", title: "Server",
url: "/dashboard/settings/server", url: "/dashboard/settings/server",
icon: Activity, icon: Activity,
isSingle: true, // Only enabled for admins in non-cloud environments
isActive: false, isEnabled: ({ auth, user, isCloud }) =>
!!(auth?.rol === "admin" && !isCloud),
}, },
{ {
isSingle: true,
title: "Profile", title: "Profile",
url: "/dashboard/settings/profile", url: "/dashboard/settings/profile",
icon: User, icon: User,
isSingle: true,
isActive: false,
}, },
{ {
isSingle: true,
title: "Servers", title: "Servers",
url: "/dashboard/settings/servers", url: "/dashboard/settings/servers",
icon: Server, icon: Server,
isSingle: true, // Only enabled for admins
isActive: false, isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
}, },
{ {
isSingle: true,
title: "Users", title: "Users",
icon: Users, icon: Users,
url: "/dashboard/settings/users", url: "/dashboard/settings/users",
isSingle: true, // Only enabled for admins
isActive: false, isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
}, },
{ {
isSingle: true,
title: "SSH Keys", title: "SSH Keys",
icon: KeyRound, icon: KeyRound,
url: "/dashboard/settings/ssh-keys", url: "/dashboard/settings/ssh-keys",
isSingle: true, // Only enabled for admins and users with access to SSH keys
isActive: false, isEnabled: ({ auth, user }) =>
!!(auth?.rol === "admin" || user?.canAccessToSSHKeys),
}, },
{ {
isSingle: true,
title: "Git", title: "Git",
url: "/dashboard/settings/git-providers", url: "/dashboard/settings/git-providers",
icon: GitBranch, icon: GitBranch,
isSingle: true, // Only enabled for admins and users with access to Git providers
isActive: false, isEnabled: ({ auth, user }) =>
!!(auth?.rol === "admin" || user?.canAccessToGitProviders),
}, },
{ {
isSingle: true,
title: "Registry", title: "Registry",
url: "/dashboard/settings/registry", url: "/dashboard/settings/registry",
icon: Package, icon: Package,
isSingle: true, // Only enabled for admins
isActive: false, isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
}, },
{ {
isSingle: true,
title: "S3 Destinations", title: "S3 Destinations",
url: "/dashboard/settings/destinations", url: "/dashboard/settings/destinations",
icon: Database, icon: Database,
isSingle: true, // Only enabled for admins
isActive: false, isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
}, },
{ {
isSingle: true,
title: "Certificates", title: "Certificates",
url: "/dashboard/settings/certificates", url: "/dashboard/settings/certificates",
icon: ShieldCheck, icon: ShieldCheck,
isSingle: true, // Only enabled for admins
isActive: false, isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
}, },
{ {
isSingle: true,
title: "Cluster", title: "Cluster",
url: "/dashboard/settings/cluster", url: "/dashboard/settings/cluster",
icon: Boxes, icon: Boxes,
isSingle: true, // Only enabled for admins in non-cloud environments
isActive: false, isEnabled: ({ auth, user, isCloud }) =>
!!(auth?.rol === "admin" && !isCloud),
}, },
{ {
isSingle: true,
title: "Notifications", title: "Notifications",
url: "/dashboard/settings/notifications", url: "/dashboard/settings/notifications",
icon: Bell, icon: Bell,
isSingle: true, // Only enabled for admins
isActive: false, isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
}, },
{ {
isSingle: true,
title: "Billing", title: "Billing",
url: "/dashboard/settings/billing", url: "/dashboard/settings/billing",
icon: CreditCard, icon: CreditCard,
isSingle: true, // Only enabled for admins in cloud environments
isActive: false, isEnabled: ({ auth, user, isCloud }) =>
!!(auth?.rol === "admin" && isCloud),
}, },
] as NavItem[], ],
help: [ help: [
{ {
name: "Documentation", 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 { interface Props {
children: React.ReactNode; children: React.ReactNode;
@@ -398,64 +543,21 @@ export default function Page({ children }: Props) {
const includesProjects = pathname?.includes("/dashboard/project"); const includesProjects = pathname?.includes("/dashboard/project");
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery(); 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 showProjectsButton =
const nextChar = normalizedPathname.charAt(normalizedItemUrl.length); // currentPath === "/dashboard/projects" &&
return nextChar === "/"; // (auth?.rol === "admin" || user?.canCreateProjects);
}
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);
return ( return (
<SidebarProvider <SidebarProvider
@@ -486,173 +588,185 @@ export default function Page({ children }: Props) {
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Home</SidebarGroupLabel> <SidebarGroupLabel>Home</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{filteredHome.map((item) => ( {filteredHome.map((item) => {
<Collapsible const isSingle = item.isSingle !== false;
key={item.title} const isActive = isSingle
asChild ? isActiveRoute({ itemUrl: item.url, pathname })
defaultOpen={item.isActive} : item.items.some((item) =>
className="group/collapsible" isActiveRoute({ itemUrl: item.url, pathname }),
> );
<SidebarMenuItem>
{item.isSingle ? (
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(isActiveRoute(item.url) && "bg-border")}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
>
<item.icon
className={cn(
isActiveRoute(item.url) && "text-primary",
)}
/>
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
) : (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={item.isActive}
>
{item.icon && <item.icon />}
<span>{item.title}</span> return (
{item.items?.length && ( <Collapsible
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> key={item.title}
asChild
defaultOpen={isActive}
className="group/collapsible"
>
<SidebarMenuItem>
{isSingle ? (
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(isActive && "bg-border")}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
>
{item.icon && (
<item.icon
className={cn(isActive && "text-primary")}
/>
)} )}
</SidebarMenuButton> <span>{item.title}</span>
</CollapsibleTrigger> </Link>
<CollapsibleContent> </SidebarMenuButton>
<SidebarMenuSub> ) : (
{item.items?.map((subItem) => ( <>
<SidebarMenuSubItem key={subItem.title}> <CollapsibleTrigger asChild>
<SidebarMenuSubButton <SidebarMenuButton
asChild tooltip={item.title}
className={cn( isActive={isActive}
isActiveRoute(subItem.url) && "bg-border", >
)} {item.icon && <item.icon />}
>
<Link <span>{item.title}</span>
href={subItem.url} {item.items?.length && (
className="flex w-full items-center" <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
)}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
className={cn(isActive && "bg-border")}
> >
{subItem.icon && ( <Link
<span className="mr-2"> href={subItem.url}
<subItem.icon className="flex w-full items-center"
className={cn( >
"h-4 w-4 text-muted-foreground", {subItem.icon && (
isActiveRoute(subItem.url) && <span className="mr-2">
"text-primary", <subItem.icon
)} className={cn(
/> "h-4 w-4 text-muted-foreground",
</span> isActive && "text-primary",
)} )}
<span>{subItem.title}</span> />
</Link> </span>
</SidebarMenuSubButton> )}
</SidebarMenuSubItem> <span>{subItem.title}</span>
))} </Link>
</SidebarMenuSub> </SidebarMenuSubButton>
</CollapsibleContent> </SidebarMenuSubItem>
</> ))}
)} </SidebarMenuSub>
</SidebarMenuItem> </CollapsibleContent>
</Collapsible> </>
))} )}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Settings</SidebarGroupLabel> <SidebarGroupLabel>Settings</SidebarGroupLabel>
<SidebarMenu className="gap-2"> <SidebarMenu className="gap-2">
{filteredSettings.map((item) => ( {filteredSettings.map((item) => {
<Collapsible const isSingle = item.isSingle !== false;
key={item.title} const isActive = isSingle
asChild ? isActiveRoute({ itemUrl: item.url, pathname })
defaultOpen={item.isActive} : item.items.some((item) =>
className="group/collapsible" isActiveRoute({ itemUrl: item.url, pathname }),
> );
<SidebarMenuItem>
{item.isSingle ? (
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(isActiveRoute(item.url) && "bg-border")}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
>
<item.icon
className={cn(
isActiveRoute(item.url) && "text-primary",
)}
/>
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
) : (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={item.isActive}
>
{item.icon && <item.icon />}
<span>{item.title}</span> return (
{item.items?.length && ( <Collapsible
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> key={item.title}
asChild
defaultOpen={isActive}
className="group/collapsible"
>
<SidebarMenuItem>
{isSingle ? (
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(isActive && "bg-border")}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
>
{item.icon && (
<item.icon
className={cn(isActive && "text-primary")}
/>
)} )}
</SidebarMenuButton> <span>{item.title}</span>
</CollapsibleTrigger> </Link>
<CollapsibleContent> </SidebarMenuButton>
<SidebarMenuSub> ) : (
{item.items?.map((subItem) => ( <>
<SidebarMenuSubItem key={subItem.title}> <CollapsibleTrigger asChild>
<SidebarMenuSubButton <SidebarMenuButton
asChild tooltip={item.title}
className={cn( isActive={isActive}
isActiveRoute(subItem.url) && "bg-border", >
)} {item.icon && <item.icon />}
>
<Link <span>{item.title}</span>
href={subItem.url} {item.items?.length && (
className="flex w-full items-center" <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
)}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
className={cn(isActive && "bg-border")}
> >
{subItem.icon && ( <Link
<span className="mr-2"> href={subItem.url}
<subItem.icon className="flex w-full items-center"
className={cn( >
"h-4 w-4 text-muted-foreground", {subItem.icon && (
isActiveRoute(subItem.url) && <span className="mr-2">
"text-primary", <subItem.icon
)} className={cn(
/> "h-4 w-4 text-muted-foreground",
</span> isActive && "text-primary",
)} )}
<span>{subItem.title}</span> />
</Link> </span>
</SidebarMenuSubButton> )}
</SidebarMenuSubItem> <span>{subItem.title}</span>
))} </Link>
</SidebarMenuSub> </SidebarMenuSubButton>
</CollapsibleContent> </SidebarMenuSubItem>
</> ))}
)} </SidebarMenuSub>
</SidebarMenuItem> </CollapsibleContent>
</Collapsible> </>
))} )}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
<SidebarGroup className="group-data-[collapsible=icon]:hidden"> <SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Extra</SidebarGroupLabel> <SidebarGroupLabel>Extra</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{data.help.map((item: ExternalLink) => ( {help.map((item: ExternalLink) => (
<SidebarMenuItem key={item.name}> <SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<a <a