Jelajahi Sumber

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

Michael Bromley 3 bulan lalu
induk
melakukan
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.
 
 ```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
 
-<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.
 
+Individual items can then be added to the section by defining routes in the
+`routes` property of your Dashboard extension.
+
 ```ts title="Signature"
 interface DashboardNavSectionDefinition {
     id: string;
@@ -55,4 +58,86 @@ import { PlusIcon } from 'lucide-react';
 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>

+ 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'.
 ### 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
 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
 
 <MemberInfo kind="property" type={`RouteOptions['loader']`}   />

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

@@ -29,7 +29,7 @@ function useChannel(): void
 
 ## 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
 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,
     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}
         </>
     );
 }

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

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

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

@@ -27,6 +27,10 @@ export interface DashboardRouteDefinition {
      * @description
      * Optional navigation menu item configuration to add this route to the nav menu
      * 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 };
     /**
@@ -42,8 +46,12 @@ export interface DashboardRouteDefinition {
  * @description
  * 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
  * @docsPage Navigation
+ * @docsWeight 0
  * @since 3.4.0
  */
 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
 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 {
     id: string;
     title: string;
     icon?: LucideIcon;
     order?: number;
     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 {
+    /**
+     * @description
+     * The url of the route which this nav item links to.
+     */
     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';
 
-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 { hasPermissions } = usePermissions();
     if (!serverConfig) {
         return [];
     }
     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
-const ChannelsQuery = graphql(
+const activeChannelDocument = graphql(
     `
         query ChannelInformation {
             activeChannel {
@@ -28,6 +28,14 @@ const ChannelsQuery = graphql(
                     id
                 }
             }
+        }
+    `,
+    [channelFragment],
+);
+
+const channelsDocument = graphql(
+    `
+        query ChannelInformation {
             channels {
                 items {
                     ...ChannelInfo
@@ -40,7 +48,7 @@ const ChannelsQuery = graphql(
 );
 
 // Define the type for a channel
-type ActiveChannel = ResultOf<typeof ChannelsQuery>['activeChannel'];
+type ActiveChannel = ResultOf<typeof activeChannelDocument>['activeChannel'];
 type Channel = ResultOf<typeof channelFragment>;
 
 /**
@@ -106,10 +114,18 @@ export function ChannelProvider({ children }: Readonly<{ children: React.ReactNo
         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
-    const { data: channelsData, isLoading: isChannelsLoading } = useQuery({
+    const { data: channelsData } = useQuery({
         queryKey: ['channels', isAuthenticated],
-        queryFn: () => api.query(ChannelsQuery),
+        queryFn: () => api.query(channelsDocument),
         retry: false,
         enabled: isAuthenticated,
     });
@@ -168,10 +184,10 @@ export function ChannelProvider({ children }: Readonly<{ children: React.ReactNo
         }
     }, [selectedChannelId, channels]);
 
-    const isLoading = isChannelsLoading;
+    const isLoading = isActiveChannelLoading;
 
     // Find the selected channel from the list of channels
-    const selectedChannel = channelsData?.activeChannel;
+    const selectedChannel = activeChannelData?.activeChannel;
 
     const refreshChannels = () => {
         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'];
+export type CustomFieldConfig = QueryResult['entityCustomFields'][number]['customFields'][number];
 
 export interface ServerConfig {
     availableLanguages: string[];