@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.
Files changed (90) hide show
  1. package/dist/bundle/chunks/{2SSKDMRQ-BVYTtZ4W.js → 2SSKDMRQ-BHHzW1a_.js} +2 -2
  2. package/dist/bundle/chunks/{AP7HFJJL-BDXyVf1-.js → AP7HFJJL-B5qPACUM.js} +1 -1
  3. package/dist/bundle/chunks/{WDYDFRGG-DP5Ikh83.js → WDYDFRGG-BfBnFCDT.js} +2 -2
  4. package/dist/bundle/chunks/{ar-DMK_eFVD.js → ar-ZYyYt3LW.js} +1 -1
  5. package/dist/bundle/chunks/{bg-_l7rTFuK.js → bg-BgDGBTKB.js} +1 -1
  6. package/dist/bundle/chunks/{common-operations-CVADEI6Z.js → common-operations-DaHPL4es.js} +732 -674
  7. package/dist/bundle/chunks/{cs-DMrGo78x.js → cs-BYGURbP6.js} +1 -1
  8. package/dist/bundle/chunks/{de-DEE86yVt.js → de-HdkR3fYi.js} +1 -1
  9. package/dist/bundle/chunks/{en-Cl1y9IIy.js → en-BAhByZZJ.js} +1 -1
  10. package/dist/bundle/chunks/{es-BxdaHguU.js → es-Dt0C52Mq.js} +1 -1
  11. package/dist/bundle/chunks/{fa-DmehFzkF.js → fa-CREXA3HL.js} +1 -1
  12. package/dist/bundle/chunks/{fr-OT8cvTDq.js → fr-Cz8kKMli.js} +1 -1
  13. package/dist/bundle/chunks/{he-BKP3NS5j.js → he-9NoIAj8m.js} +1 -1
  14. package/dist/bundle/chunks/{hr-BYAgAid3.js → hr-BwXcr71B.js} +1 -1
  15. package/dist/bundle/chunks/{hu-D5RzrlfJ.js → hu-DUz4OEIA.js} +1 -1
  16. package/dist/bundle/chunks/{it-DjrjGb8J.js → it-CAlaDmH7.js} +1 -1
  17. package/dist/bundle/chunks/{ja-CHHuYFJd.js → ja-2JxS6pFi.js} +1 -1
  18. package/dist/bundle/chunks/{ko-CIrLMXet.js → ko-BsFLDX_m.js} +1 -1
  19. package/dist/bundle/chunks/{locale-DlaghAWQ.js → locale-cLfB2IZc.js} +1 -1
  20. package/dist/bundle/chunks/{main-CcSc9LYG.js → main-MGWdQzqd.js} +3 -3
  21. package/dist/bundle/chunks/{nb-CBS2CxWA.js → nb-Dff7oHih.js} +1 -1
  22. package/dist/bundle/chunks/{ne-BKFoZX0n.js → ne-ByGdZmfQ.js} +1 -1
  23. package/dist/bundle/chunks/{nl-CioNp_N5.js → nl-DniFiVio.js} +1 -1
  24. package/dist/bundle/chunks/{pl-ChqS0pNw.js → pl-B5DHommF.js} +1 -1
  25. package/dist/bundle/chunks/{pt_BR-8wz_k8Ib.js → pt_BR-BAq7WTWG.js} +1 -1
  26. package/dist/bundle/chunks/{pt_PT-BsddGCVI.js → pt_PT-DL7eSU8R.js} +1 -1
  27. package/dist/bundle/chunks/{ro-DLZ4JjP9.js → ro-CGYxDEv8.js} +1 -1
  28. package/dist/bundle/chunks/{ru-CDB-oX6Y.js → ru-BduayFrD.js} +1 -1
  29. package/dist/bundle/chunks/{sv-C05330I8.js → sv-E3RLr9cy.js} +1 -1
  30. package/dist/bundle/chunks/{tr-BmdyAxJM.js → tr-pMuzyLjn.js} +1 -1
  31. package/dist/bundle/chunks/{uk-UHmyTWD9.js → uk-BFfpHemj.js} +1 -1
  32. package/dist/bundle/chunks/{uz-BPv1mncF.js → uz-DBzLLOoE.js} +1 -1
  33. package/dist/bundle/chunks/{zh_Hans-DYtmTSvd.js → zh_Hans-DDD5yH27.js} +1 -1
  34. package/dist/bundle/chunks/{zh_Hant-CdyREtHJ.js → zh_Hant-B9iOWieA.js} +1 -1
  35. package/dist/bundle/lib.js +653 -707
  36. package/dist/bundle/main.js +2 -2
  37. package/dist/vite/utils/get-active-plugin-info.d.ts +0 -29
  38. package/dist/vite/utils/get-active-plugin-info.js +1 -3
  39. package/dist/vite/vite-plugin-config.js +28 -2
  40. package/dist/vite/vite-plugin-dashboard-metadata.js +47 -15
  41. package/dist/vite/vite-plugin-vendure-dashboard.d.ts +0 -1
  42. package/dist/vite/vite-plugin-vendure-dashboard.js +0 -5
  43. package/package.json +3 -3
  44. package/src/i18n/locales/ar.po +4 -0
  45. package/src/i18n/locales/bg.po +4 -0
  46. package/src/i18n/locales/cs.po +4 -0
  47. package/src/i18n/locales/de.po +4 -0
  48. package/src/i18n/locales/en.po +4 -0
  49. package/src/i18n/locales/es.po +4 -0
  50. package/src/i18n/locales/fa.po +4 -0
  51. package/src/i18n/locales/fr.po +4 -0
  52. package/src/i18n/locales/he.po +4 -0
  53. package/src/i18n/locales/hr.po +4 -0
  54. package/src/i18n/locales/hu.po +4 -0
  55. package/src/i18n/locales/it.po +4 -0
  56. package/src/i18n/locales/ja.po +4 -0
  57. package/src/i18n/locales/ko.po +4 -0
  58. package/src/i18n/locales/nb.po +4 -0
  59. package/src/i18n/locales/ne.po +4 -0
  60. package/src/i18n/locales/nl.po +4 -0
  61. package/src/i18n/locales/pl.po +4 -0
  62. package/src/i18n/locales/pt_BR.po +4 -0
  63. package/src/i18n/locales/pt_PT.po +4 -0
  64. package/src/i18n/locales/ro.po +4 -0
  65. package/src/i18n/locales/ru.po +4 -0
  66. package/src/i18n/locales/sv.po +4 -0
  67. package/src/i18n/locales/tr.po +4 -0
  68. package/src/i18n/locales/uk.po +4 -0
  69. package/src/i18n/locales/uz.po +4 -0
  70. package/src/i18n/locales/zh_Hans.po +4 -0
  71. package/src/i18n/locales/zh_Hant.po +4 -0
  72. package/src/lib/components/shared/error-page.tsx +2 -2
  73. package/src/lib/components/shared/paginated-list-data-table.spec.tsx +151 -0
  74. package/src/lib/components/shared/paginated-list-data-table.tsx +11 -2
  75. package/src/lib/constants.ts +1 -0
  76. package/src/lib/framework/registry/registry-types.ts +0 -2
  77. package/src/lib/index.ts +0 -3
  78. package/src/lib/providers/auth.tsx +75 -7
  79. package/src/lib/providers/server-config.tsx +3 -2
  80. package/src/lib/providers/user-settings.spec.tsx +191 -0
  81. package/src/lib/providers/user-settings.tsx +30 -14
  82. package/src/lib/storage-keys.ts +3 -1
  83. package/src/lib/utils/is-forbidden-error.ts +9 -0
  84. package/src/lib/virtual.d.ts +0 -6
  85. package/dist/vite/vite-plugin-platform-promotion.d.ts +0 -25
  86. package/dist/vite/vite-plugin-platform-promotion.js +0 -83
  87. package/src/lib/framework/platform-promotion/platform-granted-capabilities.ts +0 -71
  88. package/src/lib/framework/platform-promotion/platform-promotion-types.ts +0 -5
  89. package/src/lib/hooks/use-platform-promotion.spec.tsx +0 -117
  90. 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 => columnVisibility[c.id as string] !== false || c.id === 'id')
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={onColumnVisibilityChange}
627
+ onColumnVisibilityChange={handleColumnVisibilityChange}
619
628
  onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
620
629
  searchPlaceholder={searchPlaceholder}
621
630
  defaultColumnVisibility={columnVisibility}
@@ -6,6 +6,7 @@ export {
6
6
  LS_KEY_SHIPPING_TEST_ADDRESS,
7
7
  LS_KEY_SHIPPING_TEST_ORDER,
8
8
  LS_KEY_USER_SETTINGS,
9
+ SETTINGS_STORE_KEY_USER_SETTINGS,
9
10
  } from './storage-keys.js';
10
11
 
11
12
  export const NEW_ENTITY_PATH = 'new';
@@ -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 is authenticated.
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 = !!currentUserData?.me?.id;
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
- [refetchCurrentUser, queryClient, settings.activeChannelId, setActiveChannelId],
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?.me?.id) {
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 || !currentUserData?.me?.id) {
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
- }, [isLoading, currentUserData, currentUserError, status, isLoginLogoutInProgress]);
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
- enabled: !!user?.id,
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
+ });