Browse Source

feat(dashboard): Dashboard permissions on nav menu & custom fields (#3824)

Michael Bromley 4 months ago
parent
commit
b95ad17ebb

+ 1 - 1
docs/docs/reference/dashboard/components/vendure-image.md

@@ -177,7 +177,7 @@ interface AssetLike {
 The presets that can be used for the <a href='/reference/dashboard/components/vendure-image#vendureimage'>VendureImage</a> component.
 The presets that can be used for the <a href='/reference/dashboard/components/vendure-image#vendureimage'>VendureImage</a> component.
 
 
 ```ts title="Signature"
 ```ts title="Signature"
-type ImagePreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | null
+type ImagePreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | 'full' | null
 ```
 ```
 
 
 
 

+ 86 - 1
docs/docs/reference/dashboard/extensions-api/navigation.md

@@ -11,10 +11,13 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 
 ## DashboardNavSectionDefinition
 ## DashboardNavSectionDefinition
 
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/navigation.ts" sourceLine="49" packageName="@vendure/dashboard" since="3.4.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/navigation.ts" sourceLine="57" packageName="@vendure/dashboard" since="3.4.0" />
 
 
 Defines a custom navigation section in the dashboard sidebar.
 Defines a custom navigation section in the dashboard sidebar.
 
 
+Individual items can then be added to the section by defining routes in the
+`routes` property of your Dashboard extension.
+
 ```ts title="Signature"
 ```ts title="Signature"
 interface DashboardNavSectionDefinition {
 interface DashboardNavSectionDefinition {
     id: string;
     id: string;
@@ -55,4 +58,86 @@ import { PlusIcon } from 'lucide-react';
 Optional order number to control the position of this section in the sidebar.
 Optional order number to control the position of this section in the sidebar.
 
 
 
 
+</div>
+
+
+## NavMenuBaseItem
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/nav-menu/nav-menu-extensions.ts" sourceLine="16" packageName="@vendure/dashboard" since="3.4.0" />
+
+The base configuration for navigation items and sections of the main app nav bar.
+
+```ts title="Signature"
+interface NavMenuBaseItem {
+    id: string;
+    title: string;
+    icon?: LucideIcon;
+    order?: number;
+    placement?: NavMenuSectionPlacement;
+    requiresPermission?: string | string[];
+}
+```
+
+<div className="members-wrapper">
+
+### id
+
+<MemberInfo kind="property" type={`string`}   />
+
+
+### title
+
+<MemberInfo kind="property" type={`string`}   />
+
+
+### icon
+
+<MemberInfo kind="property" type={`LucideIcon`}   />
+
+
+### order
+
+<MemberInfo kind="property" type={`number`}   />
+
+
+### placement
+
+<MemberInfo kind="property" type={`NavMenuSectionPlacement`}   />
+
+
+### requiresPermission
+
+<MemberInfo kind="property" type={`string | string[]`}   />
+
+This can be used to restrict the menu item to the given
+permission or permissions.
+
+
+</div>
+
+
+## NavMenuItem
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/nav-menu/nav-menu-extensions.ts" sourceLine="38" packageName="@vendure/dashboard" since="3.4.0" />
+
+Defines an items in the navigation menu.
+
+```ts title="Signature"
+interface NavMenuItem extends NavMenuBaseItem {
+    url: string;
+}
+```
+* Extends: <code><a href='/reference/dashboard/extensions-api/navigation#navmenubaseitem'>NavMenuBaseItem</a></code>
+
+
+
+<div className="members-wrapper">
+
+### url
+
+<MemberInfo kind="property" type={`string`}   />
+
+The url of the route which this nav item links to.
+
+
 </div>
 </div>

+ 5 - 1
docs/docs/reference/dashboard/extensions-api/routes.md

@@ -38,10 +38,14 @@ The React component that will be rendered for this route.
 The URL path for this route, e.g. '/my-custom-page'.
 The URL path for this route, e.g. '/my-custom-page'.
 ### navMenuItem
 ### navMenuItem
 
 
-<MemberInfo kind="property" type={`Partial&#60;<a href='/reference/admin-ui-api/nav-menu/nav-menu-item#navmenuitem'>NavMenuItem</a>&#62; &#38; { sectionId: string }`}   />
+<MemberInfo kind="property" type={`Partial&#60;<a href='/reference/dashboard/extensions-api/navigation#navmenuitem'>NavMenuItem</a>&#62; &#38; { sectionId: string }`}   />
 
 
 Optional navigation menu item configuration to add this route to the nav menu
 Optional navigation menu item configuration to add this route to the nav menu
 on the left side of the dashboard.
 on the left side of the dashboard.
+
+The `sectionId` specifies which nav menu section (e.g. "catalog", "customers")
+this item should appear in. It can also point to custom nav menu sections that
+have been defined using the `navSections` extension property.
 ### loader
 ### loader
 
 
 <MemberInfo kind="property" type={`RouteOptions['loader']`}   />
 <MemberInfo kind="property" type={`RouteOptions['loader']`}   />

+ 1 - 1
docs/docs/reference/dashboard/hooks/use-channel.md

@@ -29,7 +29,7 @@ function useChannel(): void
 
 
 ## ChannelContext
 ## ChannelContext
 
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/providers/channel-provider.tsx" sourceLine="55" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/providers/channel-provider.tsx" sourceLine="63" packageName="@vendure/dashboard" since="3.3.0" />
 
 
 Provides information about the active channel, and the means to set a new
 Provides information about the active channel, and the means to set a new
 active channel.
 active channel.

+ 28 - 0
docs/docs/reference/dashboard/hooks/use-custom-field-config.md

@@ -0,0 +1,28 @@
+---
+title: "UseCustomFieldConfig"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## useCustomFieldConfig
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/hooks/use-custom-field-config.ts" sourceLine="15" packageName="@vendure/dashboard" since="3.4.0" />
+
+Returns the custom field config for the given entity type (e.g. 'Product').
+Also filters out any custom fields that the current active user does not
+have permissions to access.
+
+```ts title="Signature"
+function useCustomFieldConfig(entityType: string): CustomFieldConfig[]
+```
+Parameters
+
+### entityType
+
+<MemberInfo kind="parameter" type={`string`} />
+

+ 50 - 25
packages/dashboard/src/lib/components/layout/nav-main.tsx

@@ -14,6 +14,7 @@ import {
     NavMenuSection,
     NavMenuSection,
     NavMenuSectionPlacement,
     NavMenuSectionPlacement,
 } from '@/vdb/framework/nav-menu/nav-menu-extensions.js';
 } 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 { Link, useRouter, useRouterState } from '@tanstack/react-router';
 import { ChevronRight } from 'lucide-react';
 import { ChevronRight } from 'lucide-react';
 import * as React from 'react';
 import * as React from 'react';
@@ -39,6 +40,7 @@ function escapeRegexChars(str: string): string {
 export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavMenuItem> }>) {
 export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavMenuItem> }>) {
     const router = useRouter();
     const router = useRouter();
     const routerState = useRouterState();
     const routerState = useRouterState();
+    const { hasPermissions } = usePermissions();
     const currentPath = routerState.location.pathname;
     const currentPath = routerState.location.pathname;
     const basePath = router.basepath || '';
     const basePath = router.basepath || '';
 
 
@@ -46,16 +48,20 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
     const isPathActive = React.useCallback(
     const isPathActive = React.useCallback(
         (itemUrl: string) => {
         (itemUrl: string) => {
             // Remove basepath prefix from current path for comparison
             // 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 /
             // Ensure normalized path starts with /
-            const cleanPath = normalizedCurrentPath.startsWith('/') ? normalizedCurrentPath : `/${normalizedCurrentPath}`;
-            
+            const cleanPath = normalizedCurrentPath.startsWith('/')
+                ? normalizedCurrentPath
+                : `/${normalizedCurrentPath}`;
+
             // Special handling for root path
             // Special handling for root path
             if (itemUrl === '/') {
             if (itemUrl === '/') {
                 return cleanPath === '/' || cleanPath === '';
                 return cleanPath === '/' || cleanPath === '';
             }
             }
-            
+
             // For other paths, check exact match or prefix match
             // For other paths, check exact match or prefix match
             return cleanPath === itemUrl || cleanPath.startsWith(`${itemUrl}/`);
             return cleanPath === itemUrl || cleanPath.startsWith(`${itemUrl}/`);
         },
         },
@@ -97,6 +103,20 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
         return activeTopSections;
         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
     // Helper to build a sorted list of sections for a given placement, memoized for stability
     const getSortedSections = React.useCallback(
     const getSortedSections = React.useCallback(
         (placement: NavMenuSectionPlacement) => {
         (placement: NavMenuSectionPlacement) => {
@@ -104,13 +124,24 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
                 .filter(item => item.placement === placement)
                 .filter(item => item.placement === placement)
                 .slice()
                 .slice()
                 .sort(sortByOrder)
                 .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]);
     const topSections = React.useMemo(() => getSortedSections('top'), [getSortedSections]);
@@ -154,11 +185,7 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
             return (
             return (
                 <NavItemWrapper key={item.title} locationId={item.id} order={item.order} offset={true}>
                 <NavItemWrapper key={item.title} locationId={item.id} order={item.order} offset={true}>
                     <SidebarMenuItem>
                     <SidebarMenuItem>
-                        <SidebarMenuButton
-                            tooltip={item.title}
-                            asChild
-                            isActive={isPathActive(item.url)}
-                        >
+                        <SidebarMenuButton tooltip={item.title} asChild isActive={isPathActive(item.url)}>
                             <Link to={item.url}>
                             <Link to={item.url}>
                                 {item.icon && <item.icon />}
                                 {item.icon && <item.icon />}
                                 <span>{item.title}</span>
                                 <span>{item.title}</span>
@@ -220,11 +247,7 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
             return (
             return (
                 <NavItemWrapper key={item.title} locationId={item.id} order={item.order} offset={true}>
                 <NavItemWrapper key={item.title} locationId={item.id} order={item.order} offset={true}>
                     <SidebarMenuItem>
                     <SidebarMenuItem>
-                        <SidebarMenuButton
-                            tooltip={item.title}
-                            asChild
-                            isActive={isPathActive(item.url)}
-                        >
+                        <SidebarMenuButton tooltip={item.title} asChild isActive={isPathActive(item.url)}>
                             <Link to={item.url}>
                             <Link to={item.url}>
                                 {item.icon && <item.icon />}
                                 {item.icon && <item.icon />}
                                 <span>{item.title}</span>
                                 <span>{item.title}</span>
@@ -287,10 +310,12 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
             </SidebarGroup>
             </SidebarGroup>
 
 
             {/* Bottom sections - will be pushed to the bottom by CSS */}
             {/* 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}
         </>
         </>
     );
     );
 }
 }

+ 24 - 0
packages/dashboard/src/lib/framework/defaults.ts

@@ -37,30 +37,35 @@ export function registerDefaults() {
                         title: 'Products',
                         title: 'Products',
                         url: '/products',
                         url: '/products',
                         order: 100,
                         order: 100,
+                        requiresPermission: ['ReadProduct', 'ReadCatalog'],
                     },
                     },
                     {
                     {
                         id: 'product-variants',
                         id: 'product-variants',
                         title: 'Product Variants',
                         title: 'Product Variants',
                         url: '/product-variants',
                         url: '/product-variants',
                         order: 200,
                         order: 200,
+                        requiresPermission: ['ReadProduct', 'ReadCatalog'],
                     },
                     },
                     {
                     {
                         id: 'facets',
                         id: 'facets',
                         title: 'Facets',
                         title: 'Facets',
                         url: '/facets',
                         url: '/facets',
                         order: 300,
                         order: 300,
+                        requiresPermission: ['ReadProduct', 'ReadCatalog'],
                     },
                     },
                     {
                     {
                         id: 'collections',
                         id: 'collections',
                         title: 'Collections',
                         title: 'Collections',
                         url: '/collections',
                         url: '/collections',
                         order: 400,
                         order: 400,
+                        requiresPermission: ['ReadCollection', 'ReadCatalog'],
                     },
                     },
                     {
                     {
                         id: 'assets',
                         id: 'assets',
                         title: 'Assets',
                         title: 'Assets',
                         url: '/assets',
                         url: '/assets',
                         order: 500,
                         order: 500,
+                        requiresPermission: ['ReadAsset', 'ReadCatalog'],
                     },
                     },
                 ],
                 ],
             },
             },
@@ -76,6 +81,7 @@ export function registerDefaults() {
                         title: 'Orders',
                         title: 'Orders',
                         url: '/orders',
                         url: '/orders',
                         order: 100,
                         order: 100,
+                        requiresPermission: ['ReadOrder'],
                     },
                     },
                 ],
                 ],
             },
             },
@@ -91,12 +97,14 @@ export function registerDefaults() {
                         title: 'Customers',
                         title: 'Customers',
                         url: '/customers',
                         url: '/customers',
                         order: 100,
                         order: 100,
+                        requiresPermission: ['ReadCustomer'],
                     },
                     },
                     {
                     {
                         id: 'customer-groups',
                         id: 'customer-groups',
                         title: 'Customer Groups',
                         title: 'Customer Groups',
                         url: '/customer-groups',
                         url: '/customer-groups',
                         order: 200,
                         order: 200,
+                        requiresPermission: ['ReadCustomerGroup'],
                     },
                     },
                 ],
                 ],
             },
             },
@@ -112,6 +120,7 @@ export function registerDefaults() {
                         title: 'Promotions',
                         title: 'Promotions',
                         url: '/promotions',
                         url: '/promotions',
                         order: 100,
                         order: 100,
+                        requiresPermission: ['ReadPromotion'],
                     },
                     },
                 ],
                 ],
             },
             },
@@ -127,18 +136,21 @@ export function registerDefaults() {
                         title: 'Job Queue',
                         title: 'Job Queue',
                         url: '/job-queue',
                         url: '/job-queue',
                         order: 100,
                         order: 100,
+                        requiresPermission: ['ReadSystem'],
                     },
                     },
                     {
                     {
                         id: 'healthchecks',
                         id: 'healthchecks',
                         title: 'Healthchecks',
                         title: 'Healthchecks',
                         url: '/healthchecks',
                         url: '/healthchecks',
                         order: 200,
                         order: 200,
+                        requiresPermission: ['ReadSystem'],
                     },
                     },
                     {
                     {
                         id: 'scheduled-tasks',
                         id: 'scheduled-tasks',
                         title: 'Scheduled Tasks',
                         title: 'Scheduled Tasks',
                         url: '/scheduled-tasks',
                         url: '/scheduled-tasks',
                         order: 300,
                         order: 300,
+                        requiresPermission: ['ReadSystem'],
                     },
                     },
                 ],
                 ],
             },
             },
@@ -154,72 +166,84 @@ export function registerDefaults() {
                         title: 'Sellers',
                         title: 'Sellers',
                         url: '/sellers',
                         url: '/sellers',
                         order: 100,
                         order: 100,
+                        requiresPermission: ['ReadSeller'],
                     },
                     },
                     {
                     {
                         id: 'channels',
                         id: 'channels',
                         title: 'Channels',
                         title: 'Channels',
                         url: '/channels',
                         url: '/channels',
                         order: 200,
                         order: 200,
+                        requiresPermission: ['ReadChannel'],
                     },
                     },
                     {
                     {
                         id: 'stock-locations',
                         id: 'stock-locations',
                         title: 'Stock Locations',
                         title: 'Stock Locations',
                         url: '/stock-locations',
                         url: '/stock-locations',
                         order: 300,
                         order: 300,
+                        requiresPermission: ['ReadStockLocation'],
                     },
                     },
                     {
                     {
                         id: 'administrators',
                         id: 'administrators',
                         title: 'Administrators',
                         title: 'Administrators',
                         url: '/administrators',
                         url: '/administrators',
                         order: 400,
                         order: 400,
+                        requiresPermission: ['ReadAdministrator'],
                     },
                     },
                     {
                     {
                         id: 'roles',
                         id: 'roles',
                         title: 'Roles',
                         title: 'Roles',
                         url: '/roles',
                         url: '/roles',
                         order: 500,
                         order: 500,
+                        requiresPermission: ['ReadAdministrator'],
                     },
                     },
                     {
                     {
                         id: 'shipping-methods',
                         id: 'shipping-methods',
                         title: 'Shipping Methods',
                         title: 'Shipping Methods',
                         url: '/shipping-methods',
                         url: '/shipping-methods',
                         order: 600,
                         order: 600,
+                        requiresPermission: ['ReadShippingMethod'],
                     },
                     },
                     {
                     {
                         id: 'payment-methods',
                         id: 'payment-methods',
                         title: 'Payment Methods',
                         title: 'Payment Methods',
                         url: '/payment-methods',
                         url: '/payment-methods',
                         order: 700,
                         order: 700,
+                        requiresPermission: ['ReadPaymentMethod'],
                     },
                     },
                     {
                     {
                         id: 'tax-categories',
                         id: 'tax-categories',
                         title: 'Tax Categories',
                         title: 'Tax Categories',
                         url: '/tax-categories',
                         url: '/tax-categories',
                         order: 800,
                         order: 800,
+                        requiresPermission: ['ReadTaxCategory'],
                     },
                     },
                     {
                     {
                         id: 'tax-rates',
                         id: 'tax-rates',
                         title: 'Tax Rates',
                         title: 'Tax Rates',
                         url: '/tax-rates',
                         url: '/tax-rates',
                         order: 900,
                         order: 900,
+                        requiresPermission: ['ReadTaxRate'],
                     },
                     },
                     {
                     {
                         id: 'countries',
                         id: 'countries',
                         title: 'Countries',
                         title: 'Countries',
                         url: '/countries',
                         url: '/countries',
                         order: 1000,
                         order: 1000,
+                        requiresPermission: ['ReadCountry'],
                     },
                     },
                     {
                     {
                         id: 'zones',
                         id: 'zones',
                         title: 'Zones',
                         title: 'Zones',
                         url: '/zones',
                         url: '/zones',
                         order: 1100,
                         order: 1100,
+                        requiresPermission: ['ReadZone'],
                     },
                     },
                     {
                     {
                         id: 'global-settings',
                         id: 'global-settings',
                         title: 'Global Settings',
                         title: 'Global Settings',
                         url: '/global-settings',
                         url: '/global-settings',
                         order: 1200,
                         order: 1200,
+                        requiresPermission: ['UpdateGlobalSettings'],
                     },
                     },
                 ],
                 ],
             },
             },

+ 8 - 0
packages/dashboard/src/lib/framework/extension-api/types/navigation.ts

@@ -27,6 +27,10 @@ export interface DashboardRouteDefinition {
      * @description
      * @description
      * Optional navigation menu item configuration to add this route to the nav menu
      * Optional navigation menu item configuration to add this route to the nav menu
      * on the left side of the dashboard.
      * on the left side of the dashboard.
+     *
+     * The `sectionId` specifies which nav menu section (e.g. "catalog", "customers")
+     * this item should appear in. It can also point to custom nav menu sections that
+     * have been defined using the `navSections` extension property.
      */
      */
     navMenuItem?: Partial<NavMenuItem> & { sectionId: string };
     navMenuItem?: Partial<NavMenuItem> & { sectionId: string };
     /**
     /**
@@ -42,8 +46,12 @@ export interface DashboardRouteDefinition {
  * @description
  * @description
  * Defines a custom navigation section in the dashboard sidebar.
  * Defines a custom navigation section in the dashboard sidebar.
  *
  *
+ * Individual items can then be added to the section by defining routes in the
+ * `routes` property of your Dashboard extension.
+ *
  * @docsCategory extensions-api
  * @docsCategory extensions-api
  * @docsPage Navigation
  * @docsPage Navigation
+ * @docsWeight 0
  * @since 3.4.0
  * @since 3.4.0
  */
  */
 export interface DashboardNavSectionDefinition {
 export interface DashboardNavSectionDefinition {

+ 26 - 0
packages/dashboard/src/lib/framework/nav-menu/nav-menu-extensions.ts

@@ -5,15 +5,41 @@ import { globalRegistry } from '../registry/global-registry.js';
 // Define the placement options for navigation sections
 // Define the placement options for navigation sections
 export type NavMenuSectionPlacement = 'top' | 'bottom';
 export type NavMenuSectionPlacement = 'top' | 'bottom';
 
 
+/**
+ * @description
+ * The base configuration for navigation items and sections of the main app nav bar.
+ *
+ * @docsCategory extensions-api
+ * @docsPage Navigation
+ * @since 3.4.0
+ */
 interface NavMenuBaseItem {
 interface NavMenuBaseItem {
     id: string;
     id: string;
     title: string;
     title: string;
     icon?: LucideIcon;
     icon?: LucideIcon;
     order?: number;
     order?: number;
     placement?: NavMenuSectionPlacement;
     placement?: NavMenuSectionPlacement;
+    /**
+     * @description
+     * This can be used to restrict the menu item to the given
+     * permission or permissions.
+     */
+    requiresPermission?: string | string[];
 }
 }
 
 
+/**
+ * @description
+ * Defines an items in the navigation menu.
+ *
+ * @docsCategory extensions-api
+ * @docsPage Navigation
+ * @since 3.4.0
+ */
 export interface NavMenuItem extends NavMenuBaseItem {
 export interface NavMenuItem extends NavMenuBaseItem {
+    /**
+     * @description
+     * The url of the route which this nav item links to.
+     */
     url: string;
     url: string;
 }
 }
 
 

+ 19 - 2
packages/dashboard/src/lib/hooks/use-custom-field-config.ts

@@ -1,10 +1,27 @@
+import { usePermissions } from '@/vdb/hooks/use-permissions.js';
+import { CustomFieldConfig } from '@/vdb/providers/server-config.js';
+
 import { useServerConfig } from './use-server-config.js';
 import { useServerConfig } from './use-server-config.js';
 
 
-export function useCustomFieldConfig(entityType: string) {
+/**
+ * @description
+ * Returns the custom field config for the given entity type (e.g. 'Product').
+ * Also filters out any custom fields that the current active user does not
+ * have permissions to access.
+ *
+ * @docsCategory hooks
+ * @since 3.4.0
+ */
+export function useCustomFieldConfig(entityType: string): CustomFieldConfig[] {
     const serverConfig = useServerConfig();
     const serverConfig = useServerConfig();
+    const { hasPermissions } = usePermissions();
     if (!serverConfig) {
     if (!serverConfig) {
         return [];
         return [];
     }
     }
     const customFieldConfig = serverConfig.entityCustomFields.find(field => field.entityName === entityType);
     const customFieldConfig = serverConfig.entityCustomFields.find(field => field.entityName === entityType);
-    return customFieldConfig?.customFields;
+    return (
+        customFieldConfig?.customFields?.filter(config => {
+            return config.requiresPermission ? hasPermissions(config.requiresPermission) : true;
+        }) ?? []
+    );
 }
 }

+ 22 - 6
packages/dashboard/src/lib/providers/channel-provider.tsx

@@ -19,7 +19,7 @@ const channelFragment = graphql(`
 `);
 `);
 
 
 // Query to get all available channels and the active channel
 // Query to get all available channels and the active channel
-const ChannelsQuery = graphql(
+const activeChannelDocument = graphql(
     `
     `
         query ChannelInformation {
         query ChannelInformation {
             activeChannel {
             activeChannel {
@@ -28,6 +28,14 @@ const ChannelsQuery = graphql(
                     id
                     id
                 }
                 }
             }
             }
+        }
+    `,
+    [channelFragment],
+);
+
+const channelsDocument = graphql(
+    `
+        query ChannelInformation {
             channels {
             channels {
                 items {
                 items {
                     ...ChannelInfo
                     ...ChannelInfo
@@ -40,7 +48,7 @@ const ChannelsQuery = graphql(
 );
 );
 
 
 // Define the type for a channel
 // Define the type for a channel
-type ActiveChannel = ResultOf<typeof ChannelsQuery>['activeChannel'];
+type ActiveChannel = ResultOf<typeof activeChannelDocument>['activeChannel'];
 type Channel = ResultOf<typeof channelFragment>;
 type Channel = ResultOf<typeof channelFragment>;
 
 
 /**
 /**
@@ -106,10 +114,18 @@ export function ChannelProvider({ children }: Readonly<{ children: React.ReactNo
         return activeChannelId;
         return activeChannelId;
     });
     });
 
 
+    // Fetch active channel
+    const { data: activeChannelData, isLoading: isActiveChannelLoading } = useQuery({
+        queryKey: ['activeChannel', isAuthenticated],
+        queryFn: () => api.query(activeChannelDocument),
+        retry: false,
+        enabled: isAuthenticated,
+    });
+
     // Fetch all available channels
     // Fetch all available channels
-    const { data: channelsData, isLoading: isChannelsLoading } = useQuery({
+    const { data: channelsData } = useQuery({
         queryKey: ['channels', isAuthenticated],
         queryKey: ['channels', isAuthenticated],
-        queryFn: () => api.query(ChannelsQuery),
+        queryFn: () => api.query(channelsDocument),
         retry: false,
         retry: false,
         enabled: isAuthenticated,
         enabled: isAuthenticated,
     });
     });
@@ -168,10 +184,10 @@ export function ChannelProvider({ children }: Readonly<{ children: React.ReactNo
         }
         }
     }, [selectedChannelId, channels]);
     }, [selectedChannelId, channels]);
 
 
-    const isLoading = isChannelsLoading;
+    const isLoading = isActiveChannelLoading;
 
 
     // Find the selected channel from the list of channels
     // Find the selected channel from the list of channels
-    const selectedChannel = channelsData?.activeChannel;
+    const selectedChannel = activeChannelData?.activeChannel;
 
 
     const refreshChannels = () => {
     const refreshChannels = () => {
         refreshCurrentUser();
         refreshCurrentUser();

+ 1 - 0
packages/dashboard/src/lib/providers/server-config.tsx

@@ -250,6 +250,7 @@ export const getServerConfigDocument = graphql(
 );
 );
 
 
 type QueryResult = ResultOf<typeof getServerConfigDocument>['globalSettings']['serverConfig'];
 type QueryResult = ResultOf<typeof getServerConfigDocument>['globalSettings']['serverConfig'];
+export type CustomFieldConfig = QueryResult['entityCustomFields'][number]['customFields'][number];
 
 
 export interface ServerConfig {
 export interface ServerConfig {
     availableLanguages: string[];
     availableLanguages: string[];