@vendure/dashboard 3.8.1-master-202610230212 → 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-CKKK4E4c.js → 2SSKDMRQ-BHHzW1a_.js} +2 -2
- package/dist/bundle/chunks/{AP7HFJJL-nqtKmI7K.js → AP7HFJJL-B5qPACUM.js} +1 -1
- package/dist/bundle/chunks/{WDYDFRGG-CN9zGdUM.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-DNYmbej0.js → common-operations-DaHPL4es.js} +81 -23
- 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-DQffIWuP.js → locale-cLfB2IZc.js} +1 -1
- package/dist/bundle/chunks/{main-CrFObvKu.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 +2 -2
- package/dist/bundle/main.js +2 -2
- package/dist/vite/vite-plugin-config.js +28 -1
- package/dist/vite/vite-plugin-dashboard-metadata.js +47 -15
- 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/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
|
@@ -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
|
@@ -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
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { LS_KEY_USER_SETTINGS } from '@/vdb/constants.js';
|
|
1
|
+
import { LS_KEY_USER_SETTINGS, SETTINGS_STORE_KEY_USER_SETTINGS } from '@/vdb/constants.js';
|
|
2
|
+
import { isForbiddenError } from '@/vdb/utils/is-forbidden-error.js';
|
|
2
3
|
import { QueryClient, useMutation, useQuery } from '@tanstack/react-query';
|
|
3
4
|
import { ColumnFiltersState } from '@tanstack/react-table';
|
|
4
5
|
import React, { createContext, useEffect, useRef, useState } from 'react';
|
|
@@ -136,8 +137,6 @@ export interface UserSettingsContextType {
|
|
|
136
137
|
|
|
137
138
|
export const UserSettingsContext = createContext<UserSettingsContextType | undefined>(undefined);
|
|
138
139
|
|
|
139
|
-
const SETTINGS_STORE_KEY = 'vendure.dashboard.userSettings';
|
|
140
|
-
|
|
141
140
|
interface UserSettingsProviderProps {
|
|
142
141
|
queryClient?: QueryClient;
|
|
143
142
|
children: React.ReactNode;
|
|
@@ -171,8 +170,8 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
|
|
|
171
170
|
isError: serverSettingsErrored,
|
|
172
171
|
error,
|
|
173
172
|
} = useQuery({
|
|
174
|
-
queryKey: ['user-settings',
|
|
175
|
-
queryFn: () => api.query(getSettingsStoreValueDocument, { key:
|
|
173
|
+
queryKey: ['user-settings', SETTINGS_STORE_KEY_USER_SETTINGS],
|
|
174
|
+
queryFn: () => api.query(getSettingsStoreValueDocument, { key: SETTINGS_STORE_KEY_USER_SETTINGS }),
|
|
176
175
|
retry: false,
|
|
177
176
|
staleTime: 0,
|
|
178
177
|
enabled: settingsStoreIsAvailable,
|
|
@@ -181,7 +180,9 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
|
|
|
181
180
|
useEffect(() => {
|
|
182
181
|
if (
|
|
183
182
|
settingsStoreIsAvailable &&
|
|
184
|
-
error?.message.includes(
|
|
183
|
+
error?.message.includes(
|
|
184
|
+
`Settings store field not registered: ${SETTINGS_STORE_KEY_USER_SETTINGS}`,
|
|
185
|
+
)
|
|
185
186
|
) {
|
|
186
187
|
logSettingsStoreWarning();
|
|
187
188
|
setSettingsStoreIsAvailable(false);
|
|
@@ -192,7 +193,7 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
|
|
|
192
193
|
const saveToServerMutation = useMutation({
|
|
193
194
|
mutationFn: (settingsToSave: UserSettings) =>
|
|
194
195
|
api.mutate(setSettingsStoreValueDocument, {
|
|
195
|
-
input: { key:
|
|
196
|
+
input: { key: SETTINGS_STORE_KEY_USER_SETTINGS, value: settingsToSave },
|
|
196
197
|
}),
|
|
197
198
|
onSuccess: (_, settingsSaved) => {
|
|
198
199
|
// Only update serverSettings after successful save
|
|
@@ -205,9 +206,11 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
|
|
|
205
206
|
},
|
|
206
207
|
});
|
|
207
208
|
|
|
208
|
-
// Initialize settings from server
|
|
209
|
+
// Initialize settings from the first successful read of the server value. Until then
|
|
210
|
+
// `serverSettings` stays null, which stops the save effect below from writing this
|
|
211
|
+
// browser's local copy over the stored value.
|
|
209
212
|
useEffect(() => {
|
|
210
|
-
if (
|
|
213
|
+
if (serverSettings) {
|
|
211
214
|
return;
|
|
212
215
|
}
|
|
213
216
|
if (serverSettingsLoaded) {
|
|
@@ -230,13 +233,26 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
|
|
|
230
233
|
setServerSettings(settings);
|
|
231
234
|
setIsReady(true);
|
|
232
235
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// A FORBIDDEN read (no session yet) initializes nothing: login() re-reads the settings.
|
|
239
|
+
// A signed-in user whose channel token grants no permissions gets FORBIDDEN too, and the
|
|
240
|
+
// settings stay not ready until a later read succeeds, e.g. after a channel switch.
|
|
241
|
+
if (serverSettingsErrored && !isForbiddenError(error)) {
|
|
242
|
+
// The read won't retry (retry: false). Mark ready so consumers gating on
|
|
243
|
+
// `settingsReady` don't hang forever, but leave `serverSettings` unset: the local
|
|
244
|
+
// copy was never read from the server, so it must not be saved over the stored
|
|
245
|
+
// value. A later successful read still initializes the settings.
|
|
237
246
|
setIsReady(true);
|
|
238
247
|
}
|
|
239
|
-
}, [
|
|
248
|
+
}, [
|
|
249
|
+
serverSettings,
|
|
250
|
+
serverSettingsLoaded,
|
|
251
|
+
serverSettingsErrored,
|
|
252
|
+
error,
|
|
253
|
+
serverSettingsResponse,
|
|
254
|
+
settings,
|
|
255
|
+
]);
|
|
240
256
|
|
|
241
257
|
// Save settings to localStorage whenever they change
|
|
242
258
|
useEffect(() => {
|
package/src/lib/storage-keys.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The local storage keys, kept in their own module so the Playwright suite can
|
|
2
|
+
* The local storage and settings store keys, kept in their own module so the Playwright suite can
|
|
3
3
|
* import them. They cannot live in `constants.ts`, because that imports
|
|
4
4
|
* `schema-enums.js`, which imports `virtual:admin-api-schema` — a module only
|
|
5
5
|
* the Vite build provides. Importing `constants.ts` from Playwright's Node
|
|
@@ -14,3 +14,5 @@ export const LS_KEY_USER_SETTINGS = 'vendure-user-settings';
|
|
|
14
14
|
export const LS_KEY_SELECTED_CHANNEL_TOKEN = 'vendure-selected-channel-token';
|
|
15
15
|
export const LS_KEY_SHIPPING_TEST_ORDER = 'vendure-shipping-test-order';
|
|
16
16
|
export const LS_KEY_SHIPPING_TEST_ADDRESS = 'vendure-shipping-test-address';
|
|
17
|
+
/** The settings store key under which the server stores the user settings. */
|
|
18
|
+
export const SETTINGS_STORE_KEY_USER_SETTINGS = 'vendure.dashboard.userSettings';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a GraphQL request failed because the request has no session or lacks permission.
|
|
3
|
+
* `api.query`/`api.mutate` throw a `GraphQLRequestError`, which carries the Vendure error
|
|
4
|
+
* code in `extensions.code`.
|
|
5
|
+
*/
|
|
6
|
+
export function isForbiddenError(error: unknown): boolean {
|
|
7
|
+
const code = (error as { extensions?: { code?: unknown } } | null)?.extensions?.code;
|
|
8
|
+
return code === 'FORBIDDEN' || code === 'UNAUTHORIZED';
|
|
9
|
+
}
|