@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 +3 -3
- package/src/app/main.tsx +2 -1
- package/src/app/routes/_authenticated/_customers/components/customer-address-card.tsx +1 -1
- package/src/app/routes/_authenticated/_customers/customers_.$id.tsx +2 -2
- package/src/app/routes/_authenticated/index.tsx +12 -3
- package/src/lib/framework/defaults.ts +3 -0
- package/src/lib/framework/extension-api/define-dashboard-extension.spec.ts +77 -0
- package/src/lib/framework/extension-api/types/widgets.ts +8 -0
- package/src/lib/hooks/use-permissions.ts +20 -13
- package/src/lib/providers/channel-provider.tsx +3 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vendure/dashboard",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "3.6.1
|
|
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": "
|
|
141
|
-
"@vendure/core": "
|
|
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
|
|
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())
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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'
|
|
229
|
+
queryKey: ['channels'],
|
|
230
230
|
});
|
|
231
231
|
queryClient.invalidateQueries({
|
|
232
|
-
queryKey: ['activeChannel'
|
|
232
|
+
queryKey: ['activeChannel'],
|
|
233
233
|
});
|
|
234
|
-
}, [refreshCurrentUser, queryClient
|
|
234
|
+
}, [refreshCurrentUser, queryClient]);
|
|
235
235
|
|
|
236
236
|
const contextValue: ChannelContext = React.useMemo(
|
|
237
237
|
() => ({
|