@nocobase/client-v2 2.3.0-beta.6 → 2.4.0-alpha.2

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/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +11 -2
  4. package/es/components/AttachmentUpload.d.ts +51 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  7. package/es/index.d.ts +4 -1
  8. package/es/index.mjs +129 -118
  9. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  10. package/es/nocobase-buildin-plugin/index.d.ts +10 -0
  11. package/es/settings-app/SettingsApplication.d.ts +18 -0
  12. package/es/settings-app/SettingsBrand.d.ts +16 -0
  13. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  14. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  15. package/es/settings-app/SettingsGroupNav.d.ts +18 -0
  16. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  17. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  18. package/es/settings-app/SettingsSearch.d.ts +17 -0
  19. package/es/settings-app/SettingsShell.d.ts +10 -0
  20. package/es/settings-app/isSettingsApp.d.ts +18 -0
  21. package/es/settings-app/runtimePaths.d.ts +18 -0
  22. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  23. package/es/settings-app/settingsTheme.d.ts +87 -0
  24. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  25. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  26. package/es/settings-center/groups.d.ts +66 -0
  27. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  28. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  29. package/es/settings-center/utils.d.ts +24 -0
  30. package/es/theme/index.d.ts +1 -0
  31. package/es/theme/itemActive.d.ts +24 -0
  32. package/es/theme/type.d.ts +9 -0
  33. package/lib/index.js +157 -146
  34. package/package.json +7 -7
  35. package/src/__tests__/app.test.tsx +122 -3
  36. package/src/__tests__/authRedirect.test.ts +154 -0
  37. package/src/__tests__/browserChecker.test.ts +172 -6
  38. package/src/__tests__/mockSettingsApplication.ts +29 -0
  39. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
  40. package/src/__tests__/plugin-manager.test.tsx +7 -6
  41. package/src/__tests__/settings-application.test.ts +164 -0
  42. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  43. package/src/__tests__/settings-center.test.tsx +168 -130
  44. package/src/__tests__/settings-layout-root.test.tsx +600 -0
  45. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  46. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  47. package/src/__tests__/settings-shell.test.tsx +261 -0
  48. package/src/acl/ACLProvider.tsx +53 -3
  49. package/src/acl/aclCheckReadiness.ts +44 -0
  50. package/src/authRedirect.ts +85 -8
  51. package/src/components/AppComponents.tsx +16 -7
  52. package/src/components/AttachmentUpload.tsx +275 -0
  53. package/src/components/index.ts +1 -0
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  55. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  56. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  57. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  58. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  59. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  60. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  61. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  62. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  63. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  64. package/src/index.ts +11 -1
  65. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  66. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  67. package/src/settings-app/SettingsApplication.ts +31 -0
  68. package/src/settings-app/SettingsBrand.tsx +108 -0
  69. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  70. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  71. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  72. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  73. package/src/settings-app/SettingsRouterManager.ts +92 -0
  74. package/src/settings-app/SettingsSearch.tsx +343 -0
  75. package/src/settings-app/SettingsShell.tsx +184 -0
  76. package/src/settings-app/isSettingsApp.ts +21 -0
  77. package/src/settings-app/runtimePaths.ts +103 -0
  78. package/src/settings-app/settingsDocumentPath.ts +66 -0
  79. package/src/settings-app/settingsTheme.ts +369 -0
  80. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  81. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  82. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  83. package/src/settings-center/__tests__/groups.test.ts +25 -0
  84. package/src/settings-center/groups.ts +108 -0
  85. package/src/settings-center/useSettingsGroups.ts +105 -0
  86. package/src/settings-center/useSettingsSearch.ts +173 -0
  87. package/src/settings-center/utils.tsx +50 -1
  88. package/src/theme/index.tsx +1 -0
  89. package/src/theme/itemActive.ts +31 -0
  90. package/src/theme/type.ts +10 -0
@@ -0,0 +1,600 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
11
+ import MockAdapter from 'axios-mock-adapter';
12
+ import React from 'react';
13
+ import { afterEach, describe, expect, it, vi } from 'vitest';
14
+ import { ACLRolesCheckProvider, useRoleRecheck } from '../acl';
15
+ import { Plugin } from '../Plugin';
16
+ import { SettingsApplication } from '../settings-app/SettingsApplication';
17
+ import { SettingsBuildInPlugin } from '../settings-app/SettingsBuildInPlugin';
18
+
19
+ class TestAclPlugin extends Plugin {
20
+ async load() {
21
+ this.app.use(ACLRolesCheckProvider);
22
+ }
23
+ }
24
+
25
+ class StandaloneSettingsPlugin extends Plugin {
26
+ async load() {
27
+ this.pluginSettingsManager.addMenuItem({ key: 'standalone', title: 'Standalone' });
28
+ this.pluginSettingsManager.addPageTabItem({
29
+ menuKey: 'standalone',
30
+ key: 'index',
31
+ title: 'Standalone',
32
+ Component: () => <div>Standalone settings page</div>,
33
+ sort: -1000,
34
+ });
35
+ }
36
+ }
37
+
38
+ class MultiPortalSettingsPlugin extends Plugin {
39
+ async load() {
40
+ this.pluginSettingsManager.addMenuItem({
41
+ key: 'multi-portal',
42
+ title: 'Portal manager',
43
+ aclSnippet: 'pm.multi-portal',
44
+ sort: -300,
45
+ });
46
+ this.pluginSettingsManager.addPageTabItem({
47
+ menuKey: 'multi-portal',
48
+ key: 'index',
49
+ title: 'Portal manager',
50
+ aclSnippet: 'pm.multi-portal',
51
+ Component: () => <div>Portal manager page</div>,
52
+ });
53
+ }
54
+ }
55
+
56
+ class PrimarySettingsPlugin extends Plugin {
57
+ async load() {
58
+ this.pluginSettingsManager.addMenuItem({
59
+ key: 'portal-manager',
60
+ title: 'Portal manager',
61
+ sort: -300,
62
+ });
63
+ this.pluginSettingsManager.addPageTabItem({
64
+ menuKey: 'portal-manager',
65
+ key: 'index',
66
+ title: 'Portal manager',
67
+ Component: () => <div>Portal manager page</div>,
68
+ });
69
+ }
70
+ }
71
+
72
+ let recheckRole: (() => Promise<void>) | null = null;
73
+
74
+ const RoleRecheckPage = () => {
75
+ recheckRole = useRoleRecheck();
76
+ return <div>Role recheck page</div>;
77
+ };
78
+
79
+ class RoleRecheckSettingsPlugin extends Plugin {
80
+ async load() {
81
+ this.pluginSettingsManager.addMenuItem({ key: 'role-recheck', title: 'Role recheck' });
82
+ this.pluginSettingsManager.addPageTabItem({
83
+ menuKey: 'role-recheck',
84
+ key: 'index',
85
+ title: 'Role recheck',
86
+ Component: RoleRecheckPage,
87
+ });
88
+ }
89
+ }
90
+
91
+ describe('standalone settings layout root', () => {
92
+ const originalLocation = window.location;
93
+ const originalModernClientPrefix = window.__nocobase_modern_client_prefix__;
94
+
95
+ afterEach(() => {
96
+ recheckRole = null;
97
+ vi.restoreAllMocks();
98
+ Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
99
+ if (originalModernClientPrefix === undefined) {
100
+ delete window.__nocobase_modern_client_prefix__;
101
+ } else {
102
+ window.__nocobase_modern_client_prefix__ = originalModernClientPrefix;
103
+ }
104
+ });
105
+
106
+ it.each(['/settings', '/settings/', '/settings/index'])(
107
+ 'redirects %s to multi-portal when it is accessible',
108
+ async (initialEntry) => {
109
+ const app = new SettingsApplication({
110
+ plugins: [SettingsBuildInPlugin, MultiPortalSettingsPlugin],
111
+ router: { type: 'memory', initialEntries: [initialEntry] },
112
+ ws: false,
113
+ });
114
+ const apiMock = new MockAdapter(app.apiClient.axios);
115
+ app.dataSourceManager.ensureLoaded = async () => {};
116
+ apiMock.onGet('app:getLang').reply(200, {
117
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
118
+ });
119
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
120
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
121
+ apiMock.onGet('systemSettings:get').reply(200, {
122
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
123
+ });
124
+
125
+ const Root = app.getRootComponent();
126
+ render(<Root />);
127
+
128
+ await waitFor(() => {
129
+ expect(app.router.state.location.pathname).toBe('/settings/multi-portal');
130
+ });
131
+ },
132
+ );
133
+
134
+ it('falls back to system-settings when multi-portal is not registered', async () => {
135
+ const app = new SettingsApplication({
136
+ plugins: [SettingsBuildInPlugin, StandaloneSettingsPlugin],
137
+ router: { type: 'memory', initialEntries: ['/settings'] },
138
+ ws: false,
139
+ });
140
+ const apiMock = new MockAdapter(app.apiClient.axios);
141
+ app.dataSourceManager.ensureLoaded = async () => {};
142
+ apiMock.onGet('app:getLang').reply(200, {
143
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
144
+ });
145
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
146
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
147
+ apiMock.onGet('systemSettings:get').reply(200, {
148
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
149
+ });
150
+
151
+ const Root = app.getRootComponent();
152
+ render(<Root />);
153
+
154
+ await waitFor(() => {
155
+ expect(app.router.state.location.pathname).toBe('/settings/system-settings');
156
+ });
157
+ });
158
+
159
+ it('falls back to system-settings when multi-portal is not accessible', async () => {
160
+ const app = new SettingsApplication({
161
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin],
162
+ router: { type: 'memory', initialEntries: ['/settings'] },
163
+ ws: false,
164
+ });
165
+ const apiMock = new MockAdapter(app.apiClient.axios);
166
+ app.dataSourceManager.ensureLoaded = async () => {};
167
+ apiMock.onGet('app:getLang').reply(200, {
168
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
169
+ });
170
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
171
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
172
+ apiMock.onGet('roles:check').reply(200, {
173
+ data: { role: 'member', snippets: ['!pm.multi-portal'] },
174
+ });
175
+ apiMock.onGet('systemSettings:get').reply(200, {
176
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
177
+ });
178
+
179
+ const Root = app.getRootComponent();
180
+ render(<Root />);
181
+
182
+ await waitFor(() => {
183
+ expect(app.router.state.location.pathname).toBe('/settings/system-settings');
184
+ });
185
+ });
186
+
187
+ it('keeps ordinary negative-sort settings reachable in the active Settings group', async () => {
188
+ const app = new SettingsApplication({
189
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, PrimarySettingsPlugin],
190
+ router: { type: 'memory', initialEntries: ['/settings/portal-manager'] },
191
+ ws: false,
192
+ });
193
+ const apiMock = new MockAdapter(app.apiClient.axios);
194
+ app.dataSourceManager.ensureLoaded = async () => {};
195
+ apiMock.onGet('app:getLang').reply(200, {
196
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
197
+ });
198
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
199
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
200
+ apiMock.onGet('roles:check').reply(200, {
201
+ data: { role: 'root', snippets: ['pm'] },
202
+ });
203
+ apiMock.onGet('systemSettings:get').reply(200, {
204
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
205
+ });
206
+
207
+ const Root = app.getRootComponent();
208
+ render(<Root />);
209
+
210
+ expect(await screen.findByText('Portal manager page')).toBeInTheDocument();
211
+
212
+ // The sidebar is gone: a negative-sort setting no longer gets its own column and
213
+ // follows whichever group it belongs to. This checks it did not drift into a
214
+ // different group.
215
+ expect(document.querySelector('.ant-layout-sider')).toBeNull();
216
+ const groupNav = document.querySelector('.ant-layout-header .ant-menu') as HTMLElement;
217
+ expect(groupNav).toBeInTheDocument();
218
+ // Being an ordinary negative-sort setting, it lands in the catch-all "other
219
+ // settings" group: never promoted to a top-level entry, never folded into the
220
+ // plugin manager group.
221
+ expect(within(groupNav).getByRole('menuitem', { name: 'Other settings' })).toBeInTheDocument();
222
+ expect(within(groupNav).getByRole('menuitem', { name: /Plugin manager$/ })).toBeInTheDocument();
223
+ expect(within(groupNav).queryByRole('menuitem', { name: 'Portal manager' })).not.toBeInTheDocument();
224
+ });
225
+
226
+ it('uses document navigation to the standalone Settings signin page when unauthenticated', async () => {
227
+ const replace = vi.fn();
228
+ Object.defineProperty(window, 'location', {
229
+ configurable: true,
230
+ value: { ...originalLocation, replace },
231
+ });
232
+ window.__nocobase_modern_client_prefix__ = 'v';
233
+ const app = new SettingsApplication({
234
+ plugins: [SettingsBuildInPlugin],
235
+ router: { type: 'memory', initialEntries: ['/settings/workflow?tab=list#recent'] },
236
+ ws: false,
237
+ });
238
+ const apiMock = new MockAdapter(app.apiClient.axios);
239
+ apiMock.onGet('app:getLang').reply(200, {
240
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
241
+ });
242
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
243
+ apiMock.onGet('systemSettings:get').reply(200, {
244
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
245
+ });
246
+ apiMock.onGet('/auth:check').reply(200, { data: {} });
247
+
248
+ const Root = app.getRootComponent();
249
+ render(<Root />);
250
+
251
+ await waitFor(() => {
252
+ expect(replace).toHaveBeenCalledWith('/settings/signin?redirect=%2Fsettings%2Fworkflow%3Ftab%3Dlist%23recent');
253
+ });
254
+ });
255
+
256
+ it('does not render the Settings header before the initial auth check completes', async () => {
257
+ let resolveAuthCheck: (response: [number, { data: { id: number } }]) => void = () => {
258
+ throw new Error('Auth check resolver is not initialized');
259
+ };
260
+ const authCheckResponse = new Promise<[number, { data: { id: number } }]>((resolve) => {
261
+ resolveAuthCheck = resolve;
262
+ });
263
+ const app = new SettingsApplication({
264
+ plugins: [SettingsBuildInPlugin],
265
+ router: { type: 'memory', initialEntries: ['/settings'] },
266
+ ws: false,
267
+ });
268
+ const apiMock = new MockAdapter(app.apiClient.axios);
269
+ app.dataSourceManager.ensureLoaded = async () => {};
270
+ apiMock.onGet('app:getLang').reply(200, {
271
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
272
+ });
273
+ apiMock.onGet('/auth:check').reply(() => authCheckResponse);
274
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
275
+ apiMock.onGet('systemSettings:get').reply(200, {
276
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
277
+ });
278
+
279
+ const Root = app.getRootComponent();
280
+ render(<Root />);
281
+
282
+ await waitFor(() => {
283
+ expect(apiMock.history.get.some((request) => request.url === '/auth:check')).toBe(true);
284
+ });
285
+ expect(screen.queryByRole('banner')).not.toBeInTheDocument();
286
+
287
+ resolveAuthCheck([200, { data: { id: 1 } }]);
288
+ expect(await screen.findByRole('banner')).toBeInTheDocument();
289
+ });
290
+
291
+ it('does not render Settings navigation or search before the initial role check completes', async () => {
292
+ let resolveRoleCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
293
+ throw new Error('Role check resolver is not initialized');
294
+ };
295
+ const roleCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
296
+ resolveRoleCheck = resolve;
297
+ });
298
+ const app = new SettingsApplication({
299
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin],
300
+ router: { type: 'memory', initialEntries: ['/settings/system-settings'] },
301
+ ws: false,
302
+ });
303
+ const apiMock = new MockAdapter(app.apiClient.axios);
304
+ app.dataSourceManager.ensureLoaded = async () => {};
305
+ apiMock.onGet('app:getLang').reply(200, {
306
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
307
+ });
308
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
309
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
310
+ apiMock.onGet('roles:check').reply(() => roleCheckResponse);
311
+ apiMock.onGet('systemSettings:get').reply(200, {
312
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
313
+ });
314
+
315
+ const Root = app.getRootComponent();
316
+ render(<Root />);
317
+
318
+ await waitFor(() => {
319
+ expect(apiMock.history.get.some((request) => request.url === 'roles:check')).toBe(true);
320
+ });
321
+ const header = await screen.findByRole('banner');
322
+ expect(within(header).queryByRole('menu')).not.toBeInTheDocument();
323
+ expect(within(header).queryByTitle('Search settings')).not.toBeInTheDocument();
324
+
325
+ resolveRoleCheck([200, { data: { role: 'root', snippets: ['pm'] } }]);
326
+ await waitFor(() => {
327
+ expect(within(header).getByRole('menu')).toBeInTheDocument();
328
+ expect(within(header).getByTitle('Search settings')).toBeInTheDocument();
329
+ });
330
+ });
331
+
332
+ it('keeps Settings navigation hidden until the latest overlapping role check completes', async () => {
333
+ let resolveOlderCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
334
+ throw new Error('Older role check resolver is not initialized');
335
+ };
336
+ let resolveLatestCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
337
+ throw new Error('Latest role check resolver is not initialized');
338
+ };
339
+ const olderCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
340
+ resolveOlderCheck = resolve;
341
+ });
342
+ const latestCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
343
+ resolveLatestCheck = resolve;
344
+ });
345
+ const app = new SettingsApplication({
346
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin, RoleRecheckSettingsPlugin],
347
+ router: { type: 'memory', initialEntries: ['/settings/role-recheck'] },
348
+ ws: false,
349
+ });
350
+ const apiMock = new MockAdapter(app.apiClient.axios);
351
+ app.dataSourceManager.ensureLoaded = async () => {};
352
+ apiMock.onGet('app:getLang').reply(200, {
353
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
354
+ });
355
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
356
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
357
+ apiMock.onGet('roles:check').replyOnce(200, { data: { role: 'root', snippets: ['pm'] } });
358
+ apiMock.onGet('roles:check').replyOnce(() => olderCheckResponse);
359
+ apiMock.onGet('roles:check').replyOnce(() => latestCheckResponse);
360
+ apiMock.onGet('systemSettings:get').reply(200, {
361
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
362
+ });
363
+
364
+ const Root = app.getRootComponent();
365
+ render(<Root />);
366
+
367
+ expect(await screen.findByText('Role recheck page')).toBeInTheDocument();
368
+ const header = await screen.findByRole('banner');
369
+ expect(within(header).getByRole('menu')).toBeInTheDocument();
370
+ if (!recheckRole) {
371
+ throw new Error('Expected the role recheck callback to be available');
372
+ }
373
+
374
+ let olderCheck: Promise<void> = Promise.resolve();
375
+ let latestCheck: Promise<void> = Promise.resolve();
376
+ act(() => {
377
+ olderCheck = recheckRole?.() || Promise.resolve();
378
+ latestCheck = recheckRole?.() || Promise.resolve();
379
+ });
380
+ await waitFor(() => {
381
+ expect(apiMock.history.get.filter((request) => request.url === 'roles:check')).toHaveLength(3);
382
+ });
383
+ expect(within(header).queryByRole('menu')).not.toBeInTheDocument();
384
+
385
+ await act(async () => {
386
+ resolveOlderCheck([200, { data: { role: 'root', snippets: ['pm'] } }]);
387
+ await olderCheck;
388
+ });
389
+ expect(within(header).queryByRole('menu')).not.toBeInTheDocument();
390
+
391
+ await act(async () => {
392
+ resolveLatestCheck([200, { data: { role: 'member', snippets: ['!pm.*'] } }]);
393
+ await latestCheck;
394
+ });
395
+ expect(within(header).queryByRole('menu')).not.toBeInTheDocument();
396
+ expect(within(header).getByTitle('Search settings')).toBeInTheDocument();
397
+ });
398
+
399
+ it('ignores a pending role check from an unmounted provider', async () => {
400
+ let resolveUnmountedCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
401
+ throw new Error('Unmounted role check resolver is not initialized');
402
+ };
403
+ let resolveRemountedCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
404
+ throw new Error('Remounted role check resolver is not initialized');
405
+ };
406
+ const unmountedCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
407
+ resolveUnmountedCheck = resolve;
408
+ });
409
+ const remountedCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
410
+ resolveRemountedCheck = resolve;
411
+ });
412
+ const app = new SettingsApplication({
413
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin, RoleRecheckSettingsPlugin],
414
+ router: { type: 'memory', initialEntries: ['/settings/role-recheck'] },
415
+ ws: false,
416
+ });
417
+ const apiMock = new MockAdapter(app.apiClient.axios);
418
+ app.dataSourceManager.ensureLoaded = async () => {};
419
+ apiMock.onGet('app:getLang').reply(200, {
420
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
421
+ });
422
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
423
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
424
+ apiMock.onGet('roles:check').replyOnce(200, { data: { role: 'root', snippets: ['pm'] } });
425
+ apiMock.onGet('roles:check').replyOnce(() => unmountedCheckResponse);
426
+ apiMock.onGet('roles:check').replyOnce(() => remountedCheckResponse);
427
+ apiMock.onGet('systemSettings:get').reply(200, {
428
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
429
+ });
430
+
431
+ const Root = app.getRootComponent();
432
+ const firstRender = render(<Root />);
433
+
434
+ expect(await screen.findByText('Role recheck page')).toBeInTheDocument();
435
+ if (!recheckRole) {
436
+ throw new Error('Expected the role recheck callback to be available');
437
+ }
438
+
439
+ let unmountedCheck: Promise<void> = Promise.resolve();
440
+ act(() => {
441
+ unmountedCheck = recheckRole?.() || Promise.resolve();
442
+ });
443
+ await waitFor(() => {
444
+ expect(apiMock.history.get.filter((request) => request.url === 'roles:check')).toHaveLength(2);
445
+ });
446
+
447
+ firstRender.unmount();
448
+ render(<Root />);
449
+
450
+ await waitFor(() => {
451
+ expect(apiMock.history.get.filter((request) => request.url === 'roles:check')).toHaveLength(3);
452
+ });
453
+ const remountedHeader = await screen.findByRole('banner');
454
+ expect(within(remountedHeader).queryByRole('menu')).not.toBeInTheDocument();
455
+
456
+ await act(async () => {
457
+ resolveUnmountedCheck([200, { data: { role: 'root', snippets: ['pm'] } }]);
458
+ await unmountedCheck;
459
+ });
460
+ expect(within(remountedHeader).queryByRole('menu')).not.toBeInTheDocument();
461
+
462
+ await act(async () => {
463
+ resolveRemountedCheck([200, { data: { role: 'member', snippets: ['!pm.*'] } }]);
464
+ });
465
+ await waitFor(() => {
466
+ expect(within(remountedHeader).getByTitle('Search settings')).toBeInTheDocument();
467
+ });
468
+ });
469
+
470
+ it('does not reopen Settings search after a role recheck', async () => {
471
+ let resolveRoleCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
472
+ throw new Error('Role check resolver is not initialized');
473
+ };
474
+ const roleCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
475
+ resolveRoleCheck = resolve;
476
+ });
477
+ const app = new SettingsApplication({
478
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin, RoleRecheckSettingsPlugin],
479
+ router: { type: 'memory', initialEntries: ['/settings/role-recheck'] },
480
+ ws: false,
481
+ });
482
+ const apiMock = new MockAdapter(app.apiClient.axios);
483
+ app.dataSourceManager.ensureLoaded = async () => {};
484
+ apiMock.onGet('app:getLang').reply(200, {
485
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
486
+ });
487
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
488
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
489
+ apiMock.onGet('roles:check').replyOnce(200, { data: { role: 'root', snippets: ['pm'] } });
490
+ apiMock.onGet('roles:check').replyOnce(() => roleCheckResponse);
491
+ apiMock.onGet('systemSettings:get').reply(200, {
492
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
493
+ });
494
+
495
+ const Root = app.getRootComponent();
496
+ render(<Root />);
497
+
498
+ expect(await screen.findByText('Role recheck page')).toBeInTheDocument();
499
+ const header = await screen.findByRole('banner');
500
+ fireEvent.click(within(header).getByTitle('Search settings'));
501
+ expect(await screen.findByRole('dialog')).toBeInTheDocument();
502
+ if (!recheckRole) {
503
+ throw new Error('Expected the role recheck callback to be available');
504
+ }
505
+
506
+ let roleCheck: Promise<void> = Promise.resolve();
507
+ act(() => {
508
+ roleCheck = recheckRole?.() || Promise.resolve();
509
+ });
510
+ await waitFor(() => {
511
+ expect(apiMock.history.get.filter((request) => request.url === 'roles:check')).toHaveLength(2);
512
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
513
+ });
514
+
515
+ await act(async () => {
516
+ resolveRoleCheck([200, { data: { role: 'member', snippets: ['!pm.*'] } }]);
517
+ await roleCheck;
518
+ });
519
+ expect(within(header).getByTitle('Search settings')).toBeInTheDocument();
520
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
521
+ });
522
+
523
+ it('keeps Settings navigation and search unavailable when the initial role check fails', async () => {
524
+ vi.spyOn(console, 'error').mockImplementation(() => {});
525
+ const app = new SettingsApplication({
526
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin],
527
+ router: { type: 'memory', initialEntries: ['/settings/system-settings'] },
528
+ ws: false,
529
+ });
530
+ const apiMock = new MockAdapter(app.apiClient.axios);
531
+ app.dataSourceManager.ensureLoaded = async () => {};
532
+ apiMock.onGet('app:getLang').reply(200, {
533
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
534
+ });
535
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
536
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
537
+ apiMock.onGet('roles:check').reply(500, { errors: [{ message: 'Temporary failure' }] });
538
+ apiMock.onGet('systemSettings:get').reply(200, {
539
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
540
+ });
541
+
542
+ const Root = app.getRootComponent();
543
+ render(<Root />);
544
+
545
+ await waitFor(() => {
546
+ expect(apiMock.history.get.some((request) => request.url === 'roles:check')).toBe(true);
547
+ });
548
+ const header = await screen.findByRole('banner');
549
+ expect(within(header).queryByRole('menu')).not.toBeInTheDocument();
550
+ expect(within(header).queryByTitle('Search settings')).not.toBeInTheDocument();
551
+
552
+ const isMac = /Mac|iPhone|iPad/.test(window.navigator.platform || '');
553
+ const shortcutEvent = new KeyboardEvent('keydown', {
554
+ key: 'f',
555
+ ctrlKey: !isMac,
556
+ metaKey: isMac,
557
+ bubbles: true,
558
+ cancelable: true,
559
+ });
560
+ fireEvent(window, shortcutEvent);
561
+ expect(shortcutEvent.defaultPrevented).toBe(false);
562
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
563
+ });
564
+
565
+ it('restores the last successful Settings readiness after a recheck error', async () => {
566
+ vi.spyOn(console, 'error').mockImplementation(() => {});
567
+ const app = new SettingsApplication({
568
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin, RoleRecheckSettingsPlugin],
569
+ router: { type: 'memory', initialEntries: ['/settings/role-recheck'] },
570
+ ws: false,
571
+ });
572
+ const apiMock = new MockAdapter(app.apiClient.axios);
573
+ app.dataSourceManager.ensureLoaded = async () => {};
574
+ apiMock.onGet('app:getLang').reply(200, {
575
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
576
+ });
577
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
578
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
579
+ apiMock.onGet('roles:check').replyOnce(200, { data: { role: 'root', snippets: ['pm'] } });
580
+ apiMock.onGet('roles:check').replyOnce(500, { errors: [{ message: 'Temporary failure' }] });
581
+ apiMock.onGet('systemSettings:get').reply(200, {
582
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
583
+ });
584
+
585
+ const Root = app.getRootComponent();
586
+ render(<Root />);
587
+
588
+ expect(await screen.findByText('Role recheck page')).toBeInTheDocument();
589
+ const header = await screen.findByRole('banner');
590
+ expect(within(header).getByRole('menu')).toBeInTheDocument();
591
+ if (!recheckRole) {
592
+ throw new Error('Expected the role recheck callback to be available');
593
+ }
594
+
595
+ await act(async () => {
596
+ await recheckRole?.();
597
+ });
598
+ expect(within(header).getByRole('menu')).toBeInTheDocument();
599
+ });
600
+ });