@vendure/dashboard 3.6.1-master-202604080307 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.6.1-master-202604080307",
4
+ "version": "3.6.1",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -137,8 +137,8 @@
137
137
  "@storybook/addon-vitest": "^10.3.1",
138
138
  "@storybook/react-vite": "^10.3.1",
139
139
  "@types/node": "^22.19.0",
140
- "@vendure/common": "^3.6.1-master-202604080307",
141
- "@vendure/core": "^3.6.1-master-202604080307",
140
+ "@vendure/common": "3.6.1",
141
+ "@vendure/core": "3.6.1",
142
142
  "@vitest/browser": "^3.2.4",
143
143
  "@vitest/coverage-v8": "^3.2.4",
144
144
  "eslint": "^9.39.0",
package/src/app/main.tsx CHANGED
@@ -9,6 +9,7 @@ import { useServerConfig } from '@/vdb/hooks/use-server-config.js';
9
9
  import { defaultLocale, dynamicActivate } from '@/vdb/providers/i18n-provider.js';
10
10
  import { AnyRoute, createRouter, RouterOptions, RouterProvider } from '@tanstack/react-router';
11
11
  import React, { useEffect } from 'react';
12
+ import { createPortal } from 'react-dom';
12
13
  import ReactDOM from 'react-dom/client';
13
14
 
14
15
  import { useDisplayLocale } from '@/vdb/hooks/use-display-locale.js';
@@ -116,7 +117,7 @@ function App() {
116
117
  extensionsLoaded && (
117
118
  <AppProviders>
118
119
  <InnerApp />
119
- <Toaster />
120
+ {createPortal(<Toaster />, document.body)}
120
121
  </AppProviders>
121
122
  )
122
123
  );
@@ -123,7 +123,7 @@ export function CustomerAddressCard({
123
123
  <DialogTrigger>
124
124
  <EditIcon className="w-4 h-4" />
125
125
  </DialogTrigger>
126
- <DialogContent>
126
+ <DialogContent className="max-h-[90vh] overflow-y-auto">
127
127
  <DialogHeader>
128
128
  <DialogTitle>
129
129
  <Trans>Edit Address</Trans>
@@ -14,6 +14,7 @@ import {
14
14
  import { Input } from '@/vdb/components/ui/input.js';
15
15
  import { NEW_ENTITY_PATH } from '@/vdb/constants.js';
16
16
  import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
17
+ import { ActionBarItem } from '@/vdb/framework/layout-engine/action-bar-item-wrapper.js';
17
18
  import {
18
19
  CustomFieldsPageBlock,
19
20
  DetailFormGrid,
@@ -23,7 +24,6 @@ import {
23
24
  PageLayout,
24
25
  PageTitle,
25
26
  } from '@/vdb/framework/layout-engine/page-layout.js';
26
- import { ActionBarItem } from '@/vdb/framework/layout-engine/action-bar-item-wrapper.js';
27
27
  import { detailPageRouteLoader } from '@/vdb/framework/page/detail-page-route-loader.js';
28
28
  import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
29
29
  import { api } from '@/vdb/graphql/api.js';
@@ -222,7 +222,7 @@ function CustomerDetailPage() {
222
222
  <DialogTrigger render={<Button variant="outline" />}>
223
223
  <Plus className="w-4 h-4" /> <Trans>Add new address</Trans>
224
224
  </DialogTrigger>
225
- <DialogContent>
225
+ <DialogContent className="max-h-[90vh] overflow-y-auto">
226
226
  <DialogHeader>
227
227
  <DialogTitle>
228
228
  <Trans>Add new address</Trans>
@@ -2,7 +2,8 @@ import { DateRangePicker } from '@/vdb/components/date-range-picker.js';
2
2
  import { Button } from '@/vdb/components/ui/button.js';
3
3
  import type { GridLayout as GridLayoutType } from '@/vdb/components/ui/grid-layout.js';
4
4
  import { GridLayout } from '@/vdb/components/ui/grid-layout.js';
5
- import { getDashboardWidget, getDashboardWidgetRegistry, } from '@/vdb/framework/dashboard-widget/widget-extensions.js';
5
+ import { getDashboardWidget, getDashboardWidgetRegistry } from '@/vdb/framework/dashboard-widget/widget-extensions.js';
6
+ import { usePermissions } from '@/vdb/hooks/use-permissions.js';
6
7
  import { DefinedDateRange, WidgetFiltersProvider, } from '@/vdb/framework/dashboard-widget/widget-filters-context.js';
7
8
  import { DashboardWidgetInstance } from '@/vdb/framework/extension-api/types/widgets.js';
8
9
  import {
@@ -75,11 +76,19 @@ function DashboardPage() {
75
76
  });
76
77
 
77
78
  const { settings, setWidgetLayout } = useUserSettings();
79
+ const { hasPermissions } = usePermissions();
78
80
 
79
81
  useEffect(() => {
80
82
  const savedLayouts = settings.widgetLayout || {};
81
83
 
82
- const initialWidgets = Array.from(getDashboardWidgetRegistry().entries()).reduce(
84
+ const initialWidgets = Array.from(getDashboardWidgetRegistry().entries())
85
+ .filter(([, widget]) => {
86
+ if (!widget.requiresPermissions || widget.requiresPermissions.length === 0) {
87
+ return true;
88
+ }
89
+ return hasPermissions(widget.requiresPermissions);
90
+ })
91
+ .reduce(
83
92
  (acc: DashboardWidgetInstance[], [id, widget]) => {
84
93
  const defaultSize = {
85
94
  w: widget.defaultSize.w ?? 4, // Default 4 columns
@@ -130,7 +139,7 @@ function DashboardPage() {
130
139
 
131
140
  setWidgets(initialWidgets);
132
141
  setIsInitialized(true);
133
- }, [settings.widgetLayout]);
142
+ }, [settings.widgetLayout, hasPermissions]);
134
143
 
135
144
  // Save layout when edit mode is turned off
136
145
  useEffect(() => {
@@ -264,6 +264,7 @@ export function registerDefaults() {
264
264
  component: MetricsWidget,
265
265
  defaultSize: { w: 12, h: 6, x: 0, y: 0 },
266
266
  minSize: { w: 6, h: 4 },
267
+ requiresPermissions: ['ReadOrder'],
267
268
  });
268
269
 
269
270
  registerDashboardWidget({
@@ -271,6 +272,7 @@ export function registerDefaults() {
271
272
  name: /* i18n*/ 'Latest Orders Widget',
272
273
  component: LatestOrdersWidget,
273
274
  defaultSize: { w: 6, h: 7, x: 0, y: 0 },
275
+ requiresPermissions: ['ReadOrder'],
274
276
  });
275
277
 
276
278
  registerDashboardWidget({
@@ -278,6 +280,7 @@ export function registerDefaults() {
278
280
  name: /* i18n*/ 'Orders Summary Widget',
279
281
  component: OrdersSummaryWidget,
280
282
  defaultSize: { w: 6, h: 3, x: 6, y: 0 },
283
+ requiresPermissions: ['ReadOrder'],
281
284
  });
282
285
 
283
286
  registerAlert(searchIndexBufferAlert);
@@ -1,5 +1,9 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
+ import {
4
+ getDashboardWidgetRegistry,
5
+ registerDashboardWidget,
6
+ } from '../dashboard-widget/widget-extensions.js';
3
7
  import {
4
8
  addNavMenuSection,
5
9
  getNavMenuConfig,
@@ -12,6 +16,7 @@ import {
12
16
  defineDashboardExtension,
13
17
  executeDashboardExtensionCallbacks,
14
18
  } from './define-dashboard-extension.js';
19
+ import { DashboardWidgetDefinition } from './types/index.js';
15
20
 
16
21
  function resetNavState() {
17
22
  setNavMenuConfig({ sections: [] });
@@ -20,6 +25,10 @@ function resetNavState() {
20
25
  (globalRegistry as any).registry.set('navMenuModifiers', []);
21
26
  }
22
27
 
28
+ function resetWidgetRegistry() {
29
+ globalRegistry.set('dashboardWidgetRegistry', () => new Map<string, DashboardWidgetDefinition>());
30
+ }
31
+
23
32
  describe('defineDashboardExtension - navSections', () => {
24
33
  beforeEach(() => {
25
34
  resetNavState();
@@ -220,3 +229,71 @@ describe('defineDashboardExtension - navSections', () => {
220
229
  ]);
221
230
  });
222
231
  });
232
+
233
+ describe('DashboardWidgetDefinition - requiresPermissions', () => {
234
+ beforeEach(() => {
235
+ resetWidgetRegistry();
236
+ });
237
+
238
+ it('registers a widget without requiresPermissions', () => {
239
+ const DummyComponent = () => null;
240
+ registerDashboardWidget({
241
+ id: 'test-widget',
242
+ name: 'Test Widget',
243
+ component: DummyComponent,
244
+ defaultSize: { w: 6, h: 3 },
245
+ });
246
+
247
+ const registry = getDashboardWidgetRegistry();
248
+ const widget = registry.get('test-widget');
249
+ expect(widget).toBeDefined();
250
+ expect(widget?.requiresPermissions).toBeUndefined();
251
+ });
252
+
253
+ it('registers a widget with requiresPermissions and preserves the value', () => {
254
+ const DummyComponent = () => null;
255
+ registerDashboardWidget({
256
+ id: 'restricted-widget',
257
+ name: 'Restricted Widget',
258
+ component: DummyComponent,
259
+ defaultSize: { w: 6, h: 3 },
260
+ requiresPermissions: ['ReadOrder'],
261
+ });
262
+
263
+ const registry = getDashboardWidgetRegistry();
264
+ const widget = registry.get('restricted-widget');
265
+ expect(widget).toBeDefined();
266
+ expect(widget?.requiresPermissions).toEqual(['ReadOrder']);
267
+ });
268
+
269
+ it('supports multiple permissions', () => {
270
+ const DummyComponent = () => null;
271
+ registerDashboardWidget({
272
+ id: 'multi-perm-widget',
273
+ name: 'Multi Permission Widget',
274
+ component: DummyComponent,
275
+ defaultSize: { w: 4, h: 2 },
276
+ requiresPermissions: ['ReadOrder', 'ReadCatalog'],
277
+ });
278
+
279
+ const registry = getDashboardWidgetRegistry();
280
+ const widget = registry.get('multi-perm-widget');
281
+ expect(widget?.requiresPermissions).toEqual(['ReadOrder', 'ReadCatalog']);
282
+ });
283
+
284
+ it('preserves an empty requiresPermissions array (public widget)', () => {
285
+ const DummyComponent = () => null;
286
+ registerDashboardWidget({
287
+ id: 'empty-perm-widget',
288
+ name: 'Empty Perm Widget',
289
+ component: DummyComponent,
290
+ defaultSize: { w: 6, h: 3 },
291
+ requiresPermissions: [],
292
+ });
293
+
294
+ const registry = getDashboardWidgetRegistry();
295
+ const widget = registry.get('empty-perm-widget');
296
+ expect(widget).toBeDefined();
297
+ expect(widget?.requiresPermissions).toEqual([]);
298
+ });
299
+ });
@@ -98,4 +98,12 @@ export type DashboardWidgetDefinition = {
98
98
  * The maximum size constraints for the widget.
99
99
  */
100
100
  maxSize?: { w: number; h: number };
101
+ /**
102
+ * @description
103
+ * If set, the widget will only be displayed if the current user has
104
+ * at least one of the specified permissions in the active channel.
105
+ *
106
+ * If not set, the widget will be visible to all users.
107
+ */
108
+ requiresPermissions?: string[];
101
109
  };
@@ -1,3 +1,5 @@
1
+ import { useCallback } from 'react';
2
+
1
3
  import { useAuth } from './use-auth.js';
2
4
  import { useChannel } from './use-channel.js';
3
5
 
@@ -22,19 +24,24 @@ export function usePermissions() {
22
24
  const { channels } = useAuth();
23
25
  const { activeChannel } = useChannel();
24
26
 
25
- function hasPermissions(permissions: string[]) {
26
- if (permissions.length === 0) {
27
- return true;
28
- }
29
- // Use the selected channel instead of settings.activeChannelId
30
- const selectedChannel = (channels ?? []).find(channel => channel.id === activeChannel?.id);
31
- if (!selectedChannel) {
32
- return false;
33
- }
34
- return permissions.some(permission =>
35
- selectedChannel.permissions.includes(permission as (typeof selectedChannel.permissions)[number]),
36
- );
37
- }
27
+ const hasPermissions = useCallback(
28
+ (permissions: string[]) => {
29
+ if (permissions.length === 0) {
30
+ return true;
31
+ }
32
+ // Use the selected channel instead of settings.activeChannelId
33
+ const selectedChannel = (channels ?? []).find(channel => channel.id === activeChannel?.id);
34
+ if (!selectedChannel) {
35
+ return false;
36
+ }
37
+ return permissions.some(permission =>
38
+ selectedChannel.permissions.includes(
39
+ permission as (typeof selectedChannel.permissions)[number],
40
+ ),
41
+ );
42
+ },
43
+ [channels, activeChannel?.id],
44
+ );
38
45
 
39
46
  return { hasPermissions };
40
47
  }
@@ -226,12 +226,12 @@ export function ChannelProvider({ children }: Readonly<{ children: React.ReactNo
226
226
  const refreshChannels = React.useCallback(() => {
227
227
  refreshCurrentUser();
228
228
  queryClient.invalidateQueries({
229
- queryKey: ['channels', isAuthenticated],
229
+ queryKey: ['channels'],
230
230
  });
231
231
  queryClient.invalidateQueries({
232
- queryKey: ['activeChannel', isAuthenticated],
232
+ queryKey: ['activeChannel'],
233
233
  });
234
- }, [refreshCurrentUser, queryClient, isAuthenticated]);
234
+ }, [refreshCurrentUser, queryClient]);
235
235
 
236
236
  const contextValue: ChannelContext = React.useMemo(
237
237
  () => ({