|
|
@@ -14,6 +14,7 @@ import {
|
|
|
NavMenuSection,
|
|
|
NavMenuSectionPlacement,
|
|
|
} from '@/vdb/framework/nav-menu/nav-menu-extensions.js';
|
|
|
+import { usePermissions } from '@/vdb/hooks/use-permissions.js';
|
|
|
import { Link, useRouter, useRouterState } from '@tanstack/react-router';
|
|
|
import { ChevronRight } from 'lucide-react';
|
|
|
import * as React from 'react';
|
|
|
@@ -39,6 +40,7 @@ function escapeRegexChars(str: string): string {
|
|
|
export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavMenuItem> }>) {
|
|
|
const router = useRouter();
|
|
|
const routerState = useRouterState();
|
|
|
+ const { hasPermissions } = usePermissions();
|
|
|
const currentPath = routerState.location.pathname;
|
|
|
const basePath = router.basepath || '';
|
|
|
|
|
|
@@ -46,16 +48,20 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
|
|
|
const isPathActive = React.useCallback(
|
|
|
(itemUrl: string) => {
|
|
|
// Remove basepath prefix from current path for comparison
|
|
|
- const normalizedCurrentPath = basePath ? currentPath.replace(new RegExp(`^${escapeRegexChars(basePath)}`), '') : currentPath;
|
|
|
-
|
|
|
+ const normalizedCurrentPath = basePath
|
|
|
+ ? currentPath.replace(new RegExp(`^${escapeRegexChars(basePath)}`), '')
|
|
|
+ : currentPath;
|
|
|
+
|
|
|
// Ensure normalized path starts with /
|
|
|
- const cleanPath = normalizedCurrentPath.startsWith('/') ? normalizedCurrentPath : `/${normalizedCurrentPath}`;
|
|
|
-
|
|
|
+ const cleanPath = normalizedCurrentPath.startsWith('/')
|
|
|
+ ? normalizedCurrentPath
|
|
|
+ : `/${normalizedCurrentPath}`;
|
|
|
+
|
|
|
// Special handling for root path
|
|
|
if (itemUrl === '/') {
|
|
|
return cleanPath === '/' || cleanPath === '';
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
// For other paths, check exact match or prefix match
|
|
|
return cleanPath === itemUrl || cleanPath.startsWith(`${itemUrl}/`);
|
|
|
},
|
|
|
@@ -97,6 +103,20 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
|
|
|
return activeTopSections;
|
|
|
});
|
|
|
|
|
|
+ // Helper to check if an item is allowed based on permissions
|
|
|
+ const isItemAllowed = React.useCallback(
|
|
|
+ (item: NavMenuItem) => {
|
|
|
+ if (!item.requiresPermission) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ const permissions = Array.isArray(item.requiresPermission)
|
|
|
+ ? item.requiresPermission
|
|
|
+ : [item.requiresPermission];
|
|
|
+ return hasPermissions(permissions);
|
|
|
+ },
|
|
|
+ [hasPermissions],
|
|
|
+ );
|
|
|
+
|
|
|
// Helper to build a sorted list of sections for a given placement, memoized for stability
|
|
|
const getSortedSections = React.useCallback(
|
|
|
(placement: NavMenuSectionPlacement) => {
|
|
|
@@ -104,13 +124,24 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
|
|
|
.filter(item => item.placement === placement)
|
|
|
.slice()
|
|
|
.sort(sortByOrder)
|
|
|
- .map(section =>
|
|
|
- 'items' in section
|
|
|
- ? { ...section, items: section.items?.slice().sort(sortByOrder) }
|
|
|
- : section,
|
|
|
- );
|
|
|
+ .map(section => {
|
|
|
+ if ('items' in section) {
|
|
|
+ // Filter items based on permissions
|
|
|
+ const allowedItems = (section.items ?? []).filter(isItemAllowed).sort(sortByOrder);
|
|
|
+ return { ...section, items: allowedItems };
|
|
|
+ }
|
|
|
+ return section;
|
|
|
+ })
|
|
|
+ .filter(section => {
|
|
|
+ // Drop sections that have no items after permission filtering
|
|
|
+ if ('items' in section) {
|
|
|
+ return section.items && section.items.length > 0;
|
|
|
+ }
|
|
|
+ // For single items, check if they're allowed
|
|
|
+ return isItemAllowed(section as NavMenuItem);
|
|
|
+ });
|
|
|
},
|
|
|
- [items],
|
|
|
+ [items, isItemAllowed],
|
|
|
);
|
|
|
|
|
|
const topSections = React.useMemo(() => getSortedSections('top'), [getSortedSections]);
|
|
|
@@ -154,11 +185,7 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
|
|
|
return (
|
|
|
<NavItemWrapper key={item.title} locationId={item.id} order={item.order} offset={true}>
|
|
|
<SidebarMenuItem>
|
|
|
- <SidebarMenuButton
|
|
|
- tooltip={item.title}
|
|
|
- asChild
|
|
|
- isActive={isPathActive(item.url)}
|
|
|
- >
|
|
|
+ <SidebarMenuButton tooltip={item.title} asChild isActive={isPathActive(item.url)}>
|
|
|
<Link to={item.url}>
|
|
|
{item.icon && <item.icon />}
|
|
|
<span>{item.title}</span>
|
|
|
@@ -220,11 +247,7 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
|
|
|
return (
|
|
|
<NavItemWrapper key={item.title} locationId={item.id} order={item.order} offset={true}>
|
|
|
<SidebarMenuItem>
|
|
|
- <SidebarMenuButton
|
|
|
- tooltip={item.title}
|
|
|
- asChild
|
|
|
- isActive={isPathActive(item.url)}
|
|
|
- >
|
|
|
+ <SidebarMenuButton tooltip={item.title} asChild isActive={isPathActive(item.url)}>
|
|
|
<Link to={item.url}>
|
|
|
{item.icon && <item.icon />}
|
|
|
<span>{item.title}</span>
|
|
|
@@ -287,10 +310,12 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
|
|
|
</SidebarGroup>
|
|
|
|
|
|
{/* Bottom sections - will be pushed to the bottom by CSS */}
|
|
|
- <SidebarGroup className="mt-auto">
|
|
|
- <SidebarGroupLabel>Administration</SidebarGroupLabel>
|
|
|
- <SidebarMenu>{bottomSections.map(renderBottomSection)}</SidebarMenu>
|
|
|
- </SidebarGroup>
|
|
|
+ {bottomSections.length ? (
|
|
|
+ <SidebarGroup className="mt-auto">
|
|
|
+ <SidebarGroupLabel>Administration</SidebarGroupLabel>
|
|
|
+ <SidebarMenu>{bottomSections.map(renderBottomSection)}</SidebarMenu>
|
|
|
+ </SidebarGroup>
|
|
|
+ ) : null}
|
|
|
</>
|
|
|
);
|
|
|
}
|