@vendure/dashboard 3.8.1-master-202610220213 → 3.8.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/dist/bundle/chunks/{2SSKDMRQ-BVYTtZ4W.js → 2SSKDMRQ-BHHzW1a_.js} +2 -2
- package/dist/bundle/chunks/{AP7HFJJL-BDXyVf1-.js → AP7HFJJL-B5qPACUM.js} +1 -1
- package/dist/bundle/chunks/{WDYDFRGG-DP5Ikh83.js → WDYDFRGG-BfBnFCDT.js} +2 -2
- package/dist/bundle/chunks/{ar-DMK_eFVD.js → ar-ZYyYt3LW.js} +1 -1
- package/dist/bundle/chunks/{bg-_l7rTFuK.js → bg-BgDGBTKB.js} +1 -1
- package/dist/bundle/chunks/{common-operations-CVADEI6Z.js → common-operations-DaHPL4es.js} +732 -674
- package/dist/bundle/chunks/{cs-DMrGo78x.js → cs-BYGURbP6.js} +1 -1
- package/dist/bundle/chunks/{de-DEE86yVt.js → de-HdkR3fYi.js} +1 -1
- package/dist/bundle/chunks/{en-Cl1y9IIy.js → en-BAhByZZJ.js} +1 -1
- package/dist/bundle/chunks/{es-BxdaHguU.js → es-Dt0C52Mq.js} +1 -1
- package/dist/bundle/chunks/{fa-DmehFzkF.js → fa-CREXA3HL.js} +1 -1
- package/dist/bundle/chunks/{fr-OT8cvTDq.js → fr-Cz8kKMli.js} +1 -1
- package/dist/bundle/chunks/{he-BKP3NS5j.js → he-9NoIAj8m.js} +1 -1
- package/dist/bundle/chunks/{hr-BYAgAid3.js → hr-BwXcr71B.js} +1 -1
- package/dist/bundle/chunks/{hu-D5RzrlfJ.js → hu-DUz4OEIA.js} +1 -1
- package/dist/bundle/chunks/{it-DjrjGb8J.js → it-CAlaDmH7.js} +1 -1
- package/dist/bundle/chunks/{ja-CHHuYFJd.js → ja-2JxS6pFi.js} +1 -1
- package/dist/bundle/chunks/{ko-CIrLMXet.js → ko-BsFLDX_m.js} +1 -1
- package/dist/bundle/chunks/{locale-DlaghAWQ.js → locale-cLfB2IZc.js} +1 -1
- package/dist/bundle/chunks/{main-CcSc9LYG.js → main-MGWdQzqd.js} +3 -3
- package/dist/bundle/chunks/{nb-CBS2CxWA.js → nb-Dff7oHih.js} +1 -1
- package/dist/bundle/chunks/{ne-BKFoZX0n.js → ne-ByGdZmfQ.js} +1 -1
- package/dist/bundle/chunks/{nl-CioNp_N5.js → nl-DniFiVio.js} +1 -1
- package/dist/bundle/chunks/{pl-ChqS0pNw.js → pl-B5DHommF.js} +1 -1
- package/dist/bundle/chunks/{pt_BR-8wz_k8Ib.js → pt_BR-BAq7WTWG.js} +1 -1
- package/dist/bundle/chunks/{pt_PT-BsddGCVI.js → pt_PT-DL7eSU8R.js} +1 -1
- package/dist/bundle/chunks/{ro-DLZ4JjP9.js → ro-CGYxDEv8.js} +1 -1
- package/dist/bundle/chunks/{ru-CDB-oX6Y.js → ru-BduayFrD.js} +1 -1
- package/dist/bundle/chunks/{sv-C05330I8.js → sv-E3RLr9cy.js} +1 -1
- package/dist/bundle/chunks/{tr-BmdyAxJM.js → tr-pMuzyLjn.js} +1 -1
- package/dist/bundle/chunks/{uk-UHmyTWD9.js → uk-BFfpHemj.js} +1 -1
- package/dist/bundle/chunks/{uz-BPv1mncF.js → uz-DBzLLOoE.js} +1 -1
- package/dist/bundle/chunks/{zh_Hans-DYtmTSvd.js → zh_Hans-DDD5yH27.js} +1 -1
- package/dist/bundle/chunks/{zh_Hant-CdyREtHJ.js → zh_Hant-B9iOWieA.js} +1 -1
- package/dist/bundle/lib.js +653 -707
- package/dist/bundle/main.js +2 -2
- package/dist/vite/utils/get-active-plugin-info.d.ts +0 -29
- package/dist/vite/utils/get-active-plugin-info.js +1 -3
- package/dist/vite/vite-plugin-config.js +28 -2
- package/dist/vite/vite-plugin-dashboard-metadata.js +47 -15
- package/dist/vite/vite-plugin-vendure-dashboard.d.ts +0 -1
- package/dist/vite/vite-plugin-vendure-dashboard.js +0 -5
- package/package.json +3 -3
- package/src/i18n/locales/ar.po +4 -0
- package/src/i18n/locales/bg.po +4 -0
- package/src/i18n/locales/cs.po +4 -0
- package/src/i18n/locales/de.po +4 -0
- package/src/i18n/locales/en.po +4 -0
- package/src/i18n/locales/es.po +4 -0
- package/src/i18n/locales/fa.po +4 -0
- package/src/i18n/locales/fr.po +4 -0
- package/src/i18n/locales/he.po +4 -0
- package/src/i18n/locales/hr.po +4 -0
- package/src/i18n/locales/hu.po +4 -0
- package/src/i18n/locales/it.po +4 -0
- package/src/i18n/locales/ja.po +4 -0
- package/src/i18n/locales/ko.po +4 -0
- package/src/i18n/locales/nb.po +4 -0
- package/src/i18n/locales/ne.po +4 -0
- package/src/i18n/locales/nl.po +4 -0
- package/src/i18n/locales/pl.po +4 -0
- package/src/i18n/locales/pt_BR.po +4 -0
- package/src/i18n/locales/pt_PT.po +4 -0
- package/src/i18n/locales/ro.po +4 -0
- package/src/i18n/locales/ru.po +4 -0
- package/src/i18n/locales/sv.po +4 -0
- package/src/i18n/locales/tr.po +4 -0
- package/src/i18n/locales/uk.po +4 -0
- package/src/i18n/locales/uz.po +4 -0
- package/src/i18n/locales/zh_Hans.po +4 -0
- package/src/i18n/locales/zh_Hant.po +4 -0
- package/src/lib/components/shared/error-page.tsx +2 -2
- package/src/lib/components/shared/paginated-list-data-table.spec.tsx +151 -0
- package/src/lib/components/shared/paginated-list-data-table.tsx +11 -2
- package/src/lib/constants.ts +1 -0
- package/src/lib/framework/registry/registry-types.ts +0 -2
- package/src/lib/index.ts +0 -3
- package/src/lib/providers/auth.tsx +75 -7
- package/src/lib/providers/server-config.tsx +3 -2
- package/src/lib/providers/user-settings.spec.tsx +191 -0
- package/src/lib/providers/user-settings.tsx +30 -14
- package/src/lib/storage-keys.ts +3 -1
- package/src/lib/utils/is-forbidden-error.ts +9 -0
- package/src/lib/virtual.d.ts +0 -6
- package/dist/vite/vite-plugin-platform-promotion.d.ts +0 -25
- package/dist/vite/vite-plugin-platform-promotion.js +0 -83
- package/src/lib/framework/platform-promotion/platform-granted-capabilities.ts +0 -71
- package/src/lib/framework/platform-promotion/platform-promotion-types.ts +0 -5
- package/src/lib/hooks/use-platform-promotion.spec.tsx +0 -117
- package/src/lib/hooks/use-platform-promotion.ts +0 -117
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { VisibilityState } from '@tanstack/table-core';
|
|
3
|
+
import { DocumentNode, parse, print } from 'graphql';
|
|
4
|
+
import { act, useEffect } from 'react';
|
|
5
|
+
import { createRoot } from 'react-dom/client';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { PaginatedListDataTable } from './paginated-list-data-table.js';
|
|
9
|
+
|
|
10
|
+
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
vi.mock('virtual:admin-api-schema', () => {
|
|
13
|
+
return import('../../framework/document-introspection/testing-utils.js').then(m => m.getMockSchemaInfo());
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const queryDocuments: DocumentNode[] = [];
|
|
17
|
+
// The visibility changes the DataTable stub reports after mounting
|
|
18
|
+
let reportedVisibilityChange: VisibilityState = {};
|
|
19
|
+
|
|
20
|
+
vi.mock('@/vdb/graphql/api.js', () => ({
|
|
21
|
+
api: {
|
|
22
|
+
query: vi.fn((document: DocumentNode) => {
|
|
23
|
+
queryDocuments.push(document);
|
|
24
|
+
return Promise.resolve({ products: { items: [], totalItems: 0 } });
|
|
25
|
+
}),
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Stands in for DataTable, which owns the live column visibility state. After mounting it
|
|
30
|
+
// reports `reportedVisibilityChange` the same way DataTable reports a column settings
|
|
31
|
+
// change through onColumnVisibilityChange.
|
|
32
|
+
vi.mock('@/vdb/components/data-table/data-table.js', () => ({
|
|
33
|
+
DataTable: (props: {
|
|
34
|
+
defaultColumnVisibility?: VisibilityState;
|
|
35
|
+
onColumnVisibilityChange?: (table: any, visibility: VisibilityState) => void;
|
|
36
|
+
}) => {
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
props.onColumnVisibilityChange?.(
|
|
39
|
+
{},
|
|
40
|
+
{
|
|
41
|
+
...props.defaultColumnVisibility,
|
|
42
|
+
...reportedVisibilityChange,
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
}, []);
|
|
46
|
+
return null;
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('@/vdb/hooks/use-extended-list-query.js', () => ({
|
|
51
|
+
useExtendedListQuery: (listQuery: DocumentNode) => listQuery,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('@/vdb/framework/document-introspection/add-custom-fields.js', () => ({
|
|
55
|
+
addCustomFields: (listQuery: DocumentNode) => listQuery,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
vi.mock('@/vdb/framework/document-introspection/hooks.js', () => ({
|
|
59
|
+
useListQueryFields: () => [],
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
vi.mock('@/vdb/hooks/use-view-option-defaults.js', () => ({
|
|
63
|
+
useViewOptionDefaults: (defaultColumnVisibility: VisibilityState) => ({
|
|
64
|
+
defaultColumnVisibility: defaultColumnVisibility ?? {},
|
|
65
|
+
defaultColumnOrder: [],
|
|
66
|
+
}),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
vi.mock('../data-table/use-generated-columns.js', () => ({
|
|
70
|
+
useGeneratedColumns: () => ({
|
|
71
|
+
columns: [{ id: 'id' }, { id: 'name' }, { id: 'sku' }],
|
|
72
|
+
customFieldColumnNames: [],
|
|
73
|
+
}),
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
const listQuery = parse(`
|
|
77
|
+
query ProductList($options: ProductListOptions) {
|
|
78
|
+
products(options: $options) {
|
|
79
|
+
items {
|
|
80
|
+
id
|
|
81
|
+
name
|
|
82
|
+
sku
|
|
83
|
+
}
|
|
84
|
+
totalItems
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
describe('PaginatedListDataTable', () => {
|
|
90
|
+
let container: HTMLDivElement;
|
|
91
|
+
let root: ReturnType<typeof createRoot>;
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
queryDocuments.length = 0;
|
|
95
|
+
container = document.createElement('div');
|
|
96
|
+
document.body.appendChild(container);
|
|
97
|
+
root = createRoot(container);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
act(() => root.unmount());
|
|
102
|
+
container.remove();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
async function renderTable() {
|
|
106
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
107
|
+
await act(async () => {
|
|
108
|
+
root.render(
|
|
109
|
+
<QueryClientProvider client={queryClient}>
|
|
110
|
+
<PaginatedListDataTable
|
|
111
|
+
listQuery={listQuery}
|
|
112
|
+
defaultVisibility={{ name: true, sku: false }}
|
|
113
|
+
page={1}
|
|
114
|
+
itemsPerPage={10}
|
|
115
|
+
sorting={[]}
|
|
116
|
+
columnFilters={[]}
|
|
117
|
+
onPageChange={() => undefined}
|
|
118
|
+
onSortChange={() => undefined}
|
|
119
|
+
/>
|
|
120
|
+
</QueryClientProvider>,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function lastQuery() {
|
|
126
|
+
return print(queryDocuments[queryDocuments.length - 1]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// #5619 — a column shown via the column settings rendered empty cells when the caller
|
|
130
|
+
// did not persist column visibility, because its field was never selected
|
|
131
|
+
it('selects the field of a column shown in the DataTable without onColumnVisibilityChange', async () => {
|
|
132
|
+
reportedVisibilityChange = { sku: true };
|
|
133
|
+
await renderTable();
|
|
134
|
+
|
|
135
|
+
await vi.waitFor(() => {
|
|
136
|
+
expect(queryDocuments.length).toBeGreaterThan(0);
|
|
137
|
+
expect(lastQuery()).toMatch(/\bsku\b/);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('does not select the field of a column that stays hidden', async () => {
|
|
142
|
+
reportedVisibilityChange = {};
|
|
143
|
+
await renderTable();
|
|
144
|
+
|
|
145
|
+
await vi.waitFor(() => {
|
|
146
|
+
expect(queryDocuments.length).toBeGreaterThan(0);
|
|
147
|
+
expect(lastQuery()).toMatch(/\bname\b/);
|
|
148
|
+
expect(lastQuery()).not.toMatch(/\bsku\b/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -529,11 +529,20 @@ export function PaginatedListDataTable<
|
|
|
529
529
|
includeSelectionColumn,
|
|
530
530
|
});
|
|
531
531
|
const columnVisibility = getColumnVisibility(columns, defaultColumnVisibility, customFieldColumnNames);
|
|
532
|
+
// DataTable owns column visibility and reports it via onColumnVisibilityChange. Mirror it
|
|
533
|
+
// here so the list query selects every visible column, including when the caller does
|
|
534
|
+
// not persist visibility.
|
|
535
|
+
const [liveColumnVisibility, setLiveColumnVisibility] = React.useState<VisibilityState>();
|
|
536
|
+
const handleColumnVisibilityChange = (table: Table<any>, visibility: VisibilityState) => {
|
|
537
|
+
setLiveColumnVisibility(visibility);
|
|
538
|
+
onColumnVisibilityChange?.(table, visibility);
|
|
539
|
+
};
|
|
540
|
+
const effectiveColumnVisibility = { ...columnVisibility, ...liveColumnVisibility };
|
|
532
541
|
// Get the actual visible columns and only fetch those
|
|
533
542
|
const visibleColumns = columns
|
|
534
543
|
// Filter out invisible columns, but _always_ select "id"
|
|
535
544
|
// because it is usually needed.
|
|
536
|
-
.filter(c =>
|
|
545
|
+
.filter(c => effectiveColumnVisibility[c.id as string] !== false || c.id === 'id')
|
|
537
546
|
.map(c => ({
|
|
538
547
|
name: c.id as string,
|
|
539
548
|
isCustomField: (c.meta as any)?.isCustomField ?? false,
|
|
@@ -615,7 +624,7 @@ export function PaginatedListDataTable<
|
|
|
615
624
|
onPageChange={onPageChange}
|
|
616
625
|
onSortChange={onSortChange}
|
|
617
626
|
onFilterChange={onFilterChange}
|
|
618
|
-
onColumnVisibilityChange={
|
|
627
|
+
onColumnVisibilityChange={handleColumnVisibilityChange}
|
|
619
628
|
onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
|
|
620
629
|
searchPlaceholder={searchPlaceholder}
|
|
621
630
|
defaultColumnVisibility={columnVisibility}
|
package/src/lib/constants.ts
CHANGED
|
@@ -18,7 +18,6 @@ import { DataDisplayComponent } from '../component-registry/component-registry.j
|
|
|
18
18
|
import { DashboardAlertDefinition } from '../extension-api/types/alerts.js';
|
|
19
19
|
import { DataTableDisplayComponent } from '../extension-api/types/data-table.js';
|
|
20
20
|
import { NavMenuConfig } from '../nav-menu/nav-menu-extensions.js';
|
|
21
|
-
import { PlatformGrantedCapabilitiesStore } from '../platform-promotion/platform-granted-capabilities.js';
|
|
22
21
|
|
|
23
22
|
export interface GlobalRegistryContents {
|
|
24
23
|
extensionSourceChangeCallbacks: Set<() => void>;
|
|
@@ -42,5 +41,4 @@ export interface GlobalRegistryContents {
|
|
|
42
41
|
navMenuModifiers: Array<(config: NavMenuConfig) => NavMenuConfig>;
|
|
43
42
|
dashboardToolbarItemRegistry: Map<string, DashboardToolbarItemDefinition>;
|
|
44
43
|
dashboardCustomProvidersRegistry: Map<string, DashboardCustomProviderDefinition>;
|
|
45
|
-
platformGrantedCapabilities: PlatformGrantedCapabilitiesStore;
|
|
46
44
|
}
|
package/src/lib/index.ts
CHANGED
|
@@ -344,8 +344,6 @@ export * from './framework/page/page-api.js';
|
|
|
344
344
|
export * from './framework/page/page-types.js';
|
|
345
345
|
export * from './framework/page/use-detail-page.js';
|
|
346
346
|
export * from './framework/page/use-extended-router.js';
|
|
347
|
-
export * from './framework/platform-promotion/platform-granted-capabilities.js';
|
|
348
|
-
export * from './framework/platform-promotion/platform-promotion-types.js';
|
|
349
347
|
export * from './framework/refund-destination/refund-destination-extensions.js';
|
|
350
348
|
export * from './framework/registry/global-registry.js';
|
|
351
349
|
export * from './framework/registry/registry-types.js';
|
|
@@ -376,7 +374,6 @@ export * from './hooks/use-page-block.js';
|
|
|
376
374
|
export * from './hooks/use-page.js';
|
|
377
375
|
export * from './hooks/use-paginated-list.js';
|
|
378
376
|
export * from './hooks/use-permissions.js';
|
|
379
|
-
export * from './hooks/use-platform-promotion.js';
|
|
380
377
|
export * from './hooks/use-redirect-to-list-on-not-found.js';
|
|
381
378
|
export * from './hooks/use-saved-views.js';
|
|
382
379
|
export * from './hooks/use-server-config.js';
|
|
@@ -2,6 +2,7 @@ import { LS_KEY_SELECTED_CHANNEL_TOKEN } from '@/vdb/constants.js';
|
|
|
2
2
|
import { api } from '@/vdb/graphql/api.js';
|
|
3
3
|
import { graphql, ResultOf } from '@/vdb/graphql/graphql.js';
|
|
4
4
|
import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
|
|
5
|
+
import { useLingui } from '@lingui/react/macro';
|
|
5
6
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
6
7
|
import * as React from 'react';
|
|
7
8
|
|
|
@@ -27,7 +28,9 @@ export interface AuthContext {
|
|
|
27
28
|
authenticationError?: string;
|
|
28
29
|
/**
|
|
29
30
|
* @description
|
|
30
|
-
* Whether the user
|
|
31
|
+
* Whether the user can use the Dashboard: they have an active session with access to
|
|
32
|
+
* at least one Channel. An Administrator without access to any Channel is not
|
|
33
|
+
* authenticated, as every Channel-scoped query is forbidden for them.
|
|
31
34
|
*/
|
|
32
35
|
isAuthenticated: boolean;
|
|
33
36
|
/**
|
|
@@ -127,6 +130,15 @@ const CurrentUserQuery = graphql(`
|
|
|
127
130
|
}
|
|
128
131
|
`);
|
|
129
132
|
|
|
133
|
+
/**
|
|
134
|
+
* An Administrator needs access to at least one Channel to use the Dashboard. Without it, every
|
|
135
|
+
* Channel-scoped query is forbidden, including the server config which the Dashboard waits for
|
|
136
|
+
* before it renders. See #5617
|
|
137
|
+
*/
|
|
138
|
+
function hasDashboardAccess(currentUser: ResultOf<typeof CurrentUserQuery> | undefined): boolean {
|
|
139
|
+
return !!currentUser?.me?.channels.length;
|
|
140
|
+
}
|
|
141
|
+
|
|
130
142
|
const CurrentAdministratorAvatarQuery = `
|
|
131
143
|
query CurrentAdministratorAvatar {
|
|
132
144
|
activeAdministrator {
|
|
@@ -148,6 +160,8 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
148
160
|
const [isLoginLogoutInProgress, setIsLoginLogoutInProgress] = React.useState(false);
|
|
149
161
|
const { settings, setActiveChannelId } = useUserSettings();
|
|
150
162
|
const queryClient = useQueryClient();
|
|
163
|
+
const { t } = useLingui();
|
|
164
|
+
const noChannelAccessError = t`You do not have access to any channel`;
|
|
151
165
|
|
|
152
166
|
// Query for current user
|
|
153
167
|
const {
|
|
@@ -174,7 +188,7 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
174
188
|
}, [settings.activeChannelId, currentUserData?.me?.channels]);
|
|
175
189
|
|
|
176
190
|
// Determine isAuthenticated from currentUserData
|
|
177
|
-
const isAuthenticated =
|
|
191
|
+
const isAuthenticated = hasDashboardAccess(currentUserData);
|
|
178
192
|
const { data: avatarData } = useQuery<{
|
|
179
193
|
activeAdministrator: { avatar: AdministratorAvatar | null } | null;
|
|
180
194
|
}>({
|
|
@@ -185,6 +199,21 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
185
199
|
staleTime: Infinity,
|
|
186
200
|
});
|
|
187
201
|
|
|
202
|
+
// A session without access to any channel cannot be used, so end it. This also
|
|
203
|
+
// removes the cached user, so that `user` is not set while `isAuthenticated` is false.
|
|
204
|
+
const endSessionWithoutChannelAccess = React.useCallback(async () => {
|
|
205
|
+
setIsLoginLogoutInProgress(true);
|
|
206
|
+
try {
|
|
207
|
+
await api.mutate(LogOutMutation)({});
|
|
208
|
+
} catch {
|
|
209
|
+
// The session has no access anyway, so the local state is cleared regardless
|
|
210
|
+
}
|
|
211
|
+
queryClient.removeQueries({ queryKey: ['currentUser'] });
|
|
212
|
+
setAuthenticationError(noChannelAccessError);
|
|
213
|
+
setStatus('unauthenticated');
|
|
214
|
+
setIsLoginLogoutInProgress(false);
|
|
215
|
+
}, [queryClient, noChannelAccessError]);
|
|
216
|
+
|
|
188
217
|
// Auth actions
|
|
189
218
|
const login = React.useCallback(
|
|
190
219
|
(username: string, password: string, onLoginSuccess?: () => void) => {
|
|
@@ -194,7 +223,11 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
194
223
|
.then(async data => {
|
|
195
224
|
if (data.login.__typename === 'CurrentUser') {
|
|
196
225
|
setAuthenticationError(undefined);
|
|
197
|
-
const { data: refetchedData } = await refetchCurrentUser();
|
|
226
|
+
const { data: refetchedData, error: refetchError } = await refetchCurrentUser();
|
|
227
|
+
if (!refetchError && !hasDashboardAccess(refetchedData)) {
|
|
228
|
+
await endSessionWithoutChannelAccess();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
198
231
|
// Synchronously set the channel token in localStorage before
|
|
199
232
|
// invalidating queries. This is critical for non-superadmin users
|
|
200
233
|
// whose roles are scoped only to non-default channels. Without this,
|
|
@@ -221,6 +254,24 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
221
254
|
await queryClient.cancelQueries();
|
|
222
255
|
// Invalidate all queries to ensure fresh data after login
|
|
223
256
|
await queryClient.invalidateQueries();
|
|
257
|
+
// The refetch above can fail, so check the access again with the reloaded
|
|
258
|
+
// user. Without access, the Dashboard would wait for the server config forever.
|
|
259
|
+
const currentUserState = queryClient.getQueryState<ResultOf<typeof CurrentUserQuery>>(
|
|
260
|
+
['currentUser'],
|
|
261
|
+
);
|
|
262
|
+
if (
|
|
263
|
+
currentUserState?.status === 'success' &&
|
|
264
|
+
!hasDashboardAccess(currentUserState.data)
|
|
265
|
+
) {
|
|
266
|
+
await endSessionWithoutChannelAccess();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!hasDashboardAccess(currentUserState?.data)) {
|
|
270
|
+
setAuthenticationError(currentUserState?.error?.message);
|
|
271
|
+
setStatus('unauthenticated');
|
|
272
|
+
setIsLoginLogoutInProgress(false);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
224
275
|
setStatus('authenticated');
|
|
225
276
|
setIsLoginLogoutInProgress(false);
|
|
226
277
|
onLoginSuccess?.();
|
|
@@ -236,7 +287,13 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
236
287
|
setIsLoginLogoutInProgress(false);
|
|
237
288
|
});
|
|
238
289
|
},
|
|
239
|
-
[
|
|
290
|
+
[
|
|
291
|
+
refetchCurrentUser,
|
|
292
|
+
queryClient,
|
|
293
|
+
settings.activeChannelId,
|
|
294
|
+
setActiveChannelId,
|
|
295
|
+
endSessionWithoutChannelAccess,
|
|
296
|
+
],
|
|
240
297
|
);
|
|
241
298
|
|
|
242
299
|
const logout = React.useCallback(
|
|
@@ -263,7 +320,7 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
263
320
|
// means react-query won't auto-refetch this query).
|
|
264
321
|
setAuthenticationError(error instanceof Error ? error.message : String(error));
|
|
265
322
|
const { data: refreshedData, error: refreshedError } = await refetchCurrentUser();
|
|
266
|
-
if (refreshedError || !refreshedData
|
|
323
|
+
if (refreshedError || !hasDashboardAccess(refreshedData)) {
|
|
267
324
|
setStatus('unauthenticated');
|
|
268
325
|
} else {
|
|
269
326
|
setStatus('authenticated');
|
|
@@ -312,13 +369,24 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
312
369
|
|
|
313
370
|
// If query has completed (not loading) and we're in verifying state, determine final status
|
|
314
371
|
if (!isLoading && status === 'verifying') {
|
|
315
|
-
if (currentUserError
|
|
372
|
+
if (!currentUserError && currentUserData?.me?.id && !isAuthenticated) {
|
|
373
|
+
// Signed in, but without access to any channel, e.g. after it was removed
|
|
374
|
+
void endSessionWithoutChannelAccess();
|
|
375
|
+
} else if (currentUserError || !isAuthenticated) {
|
|
316
376
|
setStatus('unauthenticated');
|
|
317
377
|
} else {
|
|
318
378
|
setStatus('authenticated');
|
|
319
379
|
}
|
|
320
380
|
}
|
|
321
|
-
}, [
|
|
381
|
+
}, [
|
|
382
|
+
isLoading,
|
|
383
|
+
currentUserData,
|
|
384
|
+
currentUserError,
|
|
385
|
+
isAuthenticated,
|
|
386
|
+
status,
|
|
387
|
+
isLoginLogoutInProgress,
|
|
388
|
+
endSessionWithoutChannelAccess,
|
|
389
|
+
]);
|
|
322
390
|
|
|
323
391
|
const refreshCurrentUser = () => {
|
|
324
392
|
queryClient.invalidateQueries({
|
|
@@ -264,13 +264,14 @@ export interface ServerConfig {
|
|
|
264
264
|
|
|
265
265
|
// create a provider for the global settings
|
|
266
266
|
export const ServerConfigProvider = ({ children }: { children: React.ReactNode }) => {
|
|
267
|
-
const { user } = useAuth();
|
|
267
|
+
const { user, isAuthenticated } = useAuth();
|
|
268
268
|
const queryKey = ['getServerConfig', user?.id];
|
|
269
269
|
const { data } = useQuery({
|
|
270
270
|
queryKey,
|
|
271
271
|
queryFn: () => api.query(getServerConfigDocument),
|
|
272
272
|
retry: false,
|
|
273
|
-
|
|
273
|
+
// Like the Channel queries, the server config needs access to a Channel. See #5617
|
|
274
|
+
enabled: isAuthenticated,
|
|
274
275
|
// The server config rarely changes during a session (it's driven by
|
|
275
276
|
// VendureConfig at boot time). Treat it as session-stable so we don't
|
|
276
277
|
// re-fetch on every reconnect, which would cause flashes of empty
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { act, useContext } from 'react';
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { SETTINGS_STORE_KEY_USER_SETTINGS } from '../storage-keys.js';
|
|
7
|
+
import { UserSettingsContext, type UserSettingsContextType, UserSettingsProvider } from './user-settings.js';
|
|
8
|
+
|
|
9
|
+
const apiMock = vi.hoisted(() => ({ query: vi.fn(), mutate: vi.fn() }));
|
|
10
|
+
|
|
11
|
+
vi.mock('../graphql/api.js', () => ({ api: apiMock }));
|
|
12
|
+
|
|
13
|
+
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
function graphQLError(message: string, code?: string) {
|
|
16
|
+
return Object.assign(new Error(message), code ? { extensions: { code } } : {});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const forbiddenError = () =>
|
|
20
|
+
graphQLError('You are not currently authorized to perform this action', 'FORBIDDEN');
|
|
21
|
+
|
|
22
|
+
describe('UserSettingsProvider', () => {
|
|
23
|
+
let container: HTMLDivElement;
|
|
24
|
+
let root: Root;
|
|
25
|
+
let queryClient: QueryClient;
|
|
26
|
+
let context: UserSettingsContextType;
|
|
27
|
+
|
|
28
|
+
function Probe() {
|
|
29
|
+
context = useContext(UserSettingsContext)!;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function flush() {
|
|
34
|
+
for (let i = 0; i < 5; i++) {
|
|
35
|
+
await act(async () => {
|
|
36
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function render() {
|
|
42
|
+
await act(async () => {
|
|
43
|
+
root.render(
|
|
44
|
+
<QueryClientProvider client={queryClient}>
|
|
45
|
+
<UserSettingsProvider queryClient={queryClient}>
|
|
46
|
+
<Probe />
|
|
47
|
+
</UserSettingsProvider>
|
|
48
|
+
</QueryClientProvider>,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
await flush();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function savedValues() {
|
|
55
|
+
return apiMock.mutate.mock.calls.map(([, variables]) => variables.input.value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
localStorage.clear();
|
|
60
|
+
apiMock.query.mockReset();
|
|
61
|
+
apiMock.mutate.mockReset();
|
|
62
|
+
apiMock.mutate.mockResolvedValue({ setSettingsStoreValue: { result: true } });
|
|
63
|
+
queryClient = new QueryClient();
|
|
64
|
+
container = document.createElement('div');
|
|
65
|
+
root = createRoot(container);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
act(() => root.unmount());
|
|
70
|
+
queryClient.clear();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('initializes from the stored value and saves changes on top of it', async () => {
|
|
74
|
+
apiMock.query.mockResolvedValue({ getSettingsStoreValue: { displayLanguage: 'de' } });
|
|
75
|
+
await render();
|
|
76
|
+
|
|
77
|
+
expect(context.settingsReady).toBe(true);
|
|
78
|
+
expect(context.settings.displayLanguage).toBe('de');
|
|
79
|
+
|
|
80
|
+
await act(async () => context.setActiveChannelId('1'));
|
|
81
|
+
await flush();
|
|
82
|
+
expect(savedValues()).toEqual([
|
|
83
|
+
expect.objectContaining({ displayLanguage: 'de', activeChannelId: '1' }),
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// #5595 — a FORBIDDEN read (no session yet) must not initialize the settings, and the
|
|
88
|
+
// authenticated re-read after the login must initialize them from the stored value
|
|
89
|
+
it('does not initialize on a FORBIDDEN read and initializes from the read after login', async () => {
|
|
90
|
+
apiMock.query.mockRejectedValue(forbiddenError());
|
|
91
|
+
await render();
|
|
92
|
+
|
|
93
|
+
expect(context.settingsReady).toBe(false);
|
|
94
|
+
// The login sets the active channel before it invalidates the queries
|
|
95
|
+
await act(async () => context.setActiveChannelId('1'));
|
|
96
|
+
await flush();
|
|
97
|
+
expect(apiMock.mutate).not.toHaveBeenCalled();
|
|
98
|
+
|
|
99
|
+
apiMock.query.mockResolvedValue({
|
|
100
|
+
getSettingsStoreValue: { displayLanguage: 'de', tableSettings: { products: { pageSize: 37 } } },
|
|
101
|
+
});
|
|
102
|
+
await act(async () => queryClient.invalidateQueries());
|
|
103
|
+
await flush();
|
|
104
|
+
|
|
105
|
+
expect(context.settingsReady).toBe(true);
|
|
106
|
+
expect(context.settings.displayLanguage).toBe('de');
|
|
107
|
+
expect(context.settings.tableSettings).toEqual({ products: { pageSize: 37 } });
|
|
108
|
+
// The stored value has no active channel, so it replaces the one which the login set
|
|
109
|
+
expect(context.settings.activeChannelId).toBe('');
|
|
110
|
+
expect(apiMock.mutate).not.toHaveBeenCalled();
|
|
111
|
+
|
|
112
|
+
// Stands in for the "Set active channel if needed" effect in AuthProvider
|
|
113
|
+
await act(async () => context.setActiveChannelId('1'));
|
|
114
|
+
await flush();
|
|
115
|
+
expect(savedValues()).toEqual([
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
displayLanguage: 'de',
|
|
118
|
+
activeChannelId: '1',
|
|
119
|
+
tableSettings: { products: { pageSize: 37 } },
|
|
120
|
+
}),
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('uses the local settings when the read after login finds no stored value', async () => {
|
|
125
|
+
apiMock.query.mockRejectedValue(forbiddenError());
|
|
126
|
+
await render();
|
|
127
|
+
expect(context.settingsReady).toBe(false);
|
|
128
|
+
|
|
129
|
+
apiMock.query.mockResolvedValue({ getSettingsStoreValue: null });
|
|
130
|
+
await act(async () => queryClient.invalidateQueries());
|
|
131
|
+
await flush();
|
|
132
|
+
|
|
133
|
+
expect(context.settingsReady).toBe(true);
|
|
134
|
+
expect(apiMock.mutate).not.toHaveBeenCalled();
|
|
135
|
+
|
|
136
|
+
const localSettings = context.settings;
|
|
137
|
+
await act(async () => context.setTheme('dark'));
|
|
138
|
+
await flush();
|
|
139
|
+
expect(savedValues()).toEqual([{ ...localSettings, theme: 'dark' }]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('becomes ready on a failed read without saving the local copy', async () => {
|
|
143
|
+
apiMock.query.mockRejectedValue(graphQLError('Network request failed'));
|
|
144
|
+
await render();
|
|
145
|
+
|
|
146
|
+
expect(context.settingsReady).toBe(true);
|
|
147
|
+
await act(async () => context.setTheme('dark'));
|
|
148
|
+
await flush();
|
|
149
|
+
expect(apiMock.mutate).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('initializes from a successful read that follows a failed read', async () => {
|
|
153
|
+
apiMock.query.mockRejectedValue(graphQLError('Network request failed'));
|
|
154
|
+
await render();
|
|
155
|
+
expect(context.settingsReady).toBe(true);
|
|
156
|
+
|
|
157
|
+
apiMock.query.mockResolvedValue({ getSettingsStoreValue: { displayLanguage: 'de' } });
|
|
158
|
+
await act(async () => queryClient.invalidateQueries());
|
|
159
|
+
await flush();
|
|
160
|
+
expect(context.settings.displayLanguage).toBe('de');
|
|
161
|
+
|
|
162
|
+
await act(async () => context.setTheme('dark'));
|
|
163
|
+
await flush();
|
|
164
|
+
expect(savedValues()).toEqual([expect.objectContaining({ displayLanguage: 'de', theme: 'dark' })]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('does not initialize on an UNAUTHORIZED read', async () => {
|
|
168
|
+
apiMock.query.mockRejectedValue(graphQLError('Unauthorized', 'UNAUTHORIZED'));
|
|
169
|
+
await render();
|
|
170
|
+
|
|
171
|
+
expect(context.settingsReady).toBe(false);
|
|
172
|
+
await act(async () => context.setTheme('dark'));
|
|
173
|
+
await flush();
|
|
174
|
+
expect(apiMock.mutate).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('becomes ready when the settings store field is not registered', async () => {
|
|
178
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
179
|
+
apiMock.query.mockRejectedValue(
|
|
180
|
+
graphQLError(`Settings store field not registered: ${SETTINGS_STORE_KEY_USER_SETTINGS}`),
|
|
181
|
+
);
|
|
182
|
+
await render();
|
|
183
|
+
|
|
184
|
+
expect(context.settingsReady).toBe(true);
|
|
185
|
+
expect(context.settingsStoreIsAvailable).toBe(false);
|
|
186
|
+
await act(async () => context.setTheme('dark'));
|
|
187
|
+
await flush();
|
|
188
|
+
expect(apiMock.mutate).not.toHaveBeenCalled();
|
|
189
|
+
warn.mockRestore();
|
|
190
|
+
});
|
|
191
|
+
});
|