@nocobase/client-v2 2.3.0-beta.9 → 2.4.0-alpha.10

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 (117) hide show
  1. package/es/RouteRepository.d.ts +6 -0
  2. package/es/acl/ACLProvider.d.ts +10 -0
  3. package/es/acl/aclCheckReadiness.d.ts +11 -0
  4. package/es/authRedirect.d.ts +11 -2
  5. package/es/components/AttachmentUpload.d.ts +51 -0
  6. package/es/components/index.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  8. package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
  9. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  10. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  11. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  12. package/es/index.d.ts +4 -1
  13. package/es/index.mjs +129 -118
  14. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  15. package/es/nocobase-buildin-plugin/index.d.ts +10 -0
  16. package/es/settings-app/SettingsApplication.d.ts +18 -0
  17. package/es/settings-app/SettingsBrand.d.ts +16 -0
  18. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  19. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  20. package/es/settings-app/SettingsGroupNav.d.ts +18 -0
  21. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  22. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  23. package/es/settings-app/SettingsSearch.d.ts +17 -0
  24. package/es/settings-app/SettingsShell.d.ts +10 -0
  25. package/es/settings-app/isSettingsApp.d.ts +18 -0
  26. package/es/settings-app/runtimePaths.d.ts +18 -0
  27. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  28. package/es/settings-app/settingsTheme.d.ts +87 -0
  29. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  30. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  31. package/es/settings-center/groups.d.ts +66 -0
  32. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  33. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  34. package/es/settings-center/utils.d.ts +24 -0
  35. package/es/theme/index.d.ts +1 -0
  36. package/es/theme/itemActive.d.ts +24 -0
  37. package/es/theme/type.d.ts +9 -0
  38. package/lib/index.js +133 -122
  39. package/package.json +7 -7
  40. package/src/BaseApplication.tsx +2 -0
  41. package/src/RouteRepository.ts +14 -0
  42. package/src/__tests__/RouteRepository.test.ts +51 -0
  43. package/src/__tests__/app.test.tsx +135 -3
  44. package/src/__tests__/authRedirect.test.ts +154 -0
  45. package/src/__tests__/browserChecker.test.ts +172 -6
  46. package/src/__tests__/mockSettingsApplication.ts +29 -0
  47. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
  48. package/src/__tests__/plugin-manager.test.tsx +7 -6
  49. package/src/__tests__/settings-application.test.ts +164 -0
  50. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  51. package/src/__tests__/settings-center.test.tsx +168 -130
  52. package/src/__tests__/settings-layout-root.test.tsx +600 -0
  53. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  54. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  55. package/src/__tests__/settings-shell.test.tsx +261 -0
  56. package/src/acl/ACLProvider.tsx +53 -3
  57. package/src/acl/aclCheckReadiness.ts +44 -0
  58. package/src/authRedirect.ts +85 -8
  59. package/src/components/AppComponents.tsx +16 -7
  60. package/src/components/AttachmentUpload.tsx +275 -0
  61. package/src/components/index.ts +1 -0
  62. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  63. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  64. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  65. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  66. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +104 -20
  67. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  68. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  69. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  70. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  71. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  72. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  73. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  74. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  75. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  76. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  77. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  78. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  79. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  80. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  81. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +7 -1
  82. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  83. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
  84. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +17 -10
  85. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  86. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  87. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  88. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  89. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
  90. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  91. package/src/index.ts +11 -1
  92. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  93. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  94. package/src/settings-app/SettingsApplication.ts +31 -0
  95. package/src/settings-app/SettingsBrand.tsx +108 -0
  96. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  97. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  98. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  99. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  100. package/src/settings-app/SettingsRouterManager.ts +92 -0
  101. package/src/settings-app/SettingsSearch.tsx +343 -0
  102. package/src/settings-app/SettingsShell.tsx +184 -0
  103. package/src/settings-app/isSettingsApp.ts +21 -0
  104. package/src/settings-app/runtimePaths.ts +103 -0
  105. package/src/settings-app/settingsDocumentPath.ts +66 -0
  106. package/src/settings-app/settingsTheme.ts +369 -0
  107. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  108. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  109. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  110. package/src/settings-center/__tests__/groups.test.ts +25 -0
  111. package/src/settings-center/groups.ts +108 -0
  112. package/src/settings-center/useSettingsGroups.ts +105 -0
  113. package/src/settings-center/useSettingsSearch.ts +173 -0
  114. package/src/settings-center/utils.tsx +50 -1
  115. package/src/theme/index.tsx +1 -0
  116. package/src/theme/itemActive.ts +31 -0
  117. package/src/theme/type.ts +10 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.3.0-beta.9",
3
+ "version": "2.4.0-alpha.10",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.3.0-beta.9",
31
- "@nocobase/flow-engine": "2.3.0-beta.9",
32
- "@nocobase/sdk": "2.3.0-beta.9",
33
- "@nocobase/shared": "2.3.0-beta.9",
34
- "@nocobase/utils": "2.3.0-beta.9",
30
+ "@nocobase/evaluators": "2.4.0-alpha.10",
31
+ "@nocobase/flow-engine": "2.4.0-alpha.10",
32
+ "@nocobase/sdk": "2.4.0-alpha.10",
33
+ "@nocobase/shared": "2.4.0-alpha.10",
34
+ "@nocobase/utils": "2.4.0-alpha.10",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -49,5 +49,5 @@
49
49
  "react-router-dom": "^6.30.1",
50
50
  "zxing-wasm": "^3.1.3"
51
51
  },
52
- "gitHead": "c215e60ed704afe6c086bd96304f4b0d3b46d02e"
52
+ "gitHead": "c869865c0413fbe4ef85b26be1f2b8d7078c38df"
53
53
  }
@@ -274,6 +274,8 @@ export abstract class BaseApplication<
274
274
  this.eventBus.addEventListener('auth:tokenChanged', (event: Event) => {
275
275
  const detail = (event as CustomEvent<AuthTokenPayload>).detail;
276
276
  if (!detail?.token) {
277
+ // 登录态已结束(退出登录或 token 失效),清空路由缓存,避免下一个用户沿用上一个用户的可访问路由。
278
+ this.context.routeRepository?.clear();
277
279
  return;
278
280
  }
279
281
  this.setTokenInWebSocket(detail);
@@ -153,6 +153,20 @@ export class RouteRepository {
153
153
  return loadingPromise;
154
154
  }
155
155
 
156
+ /**
157
+ * 清空所有布局的路由缓存,并丢弃清空前发起的请求结果。
158
+ *
159
+ * 用于登录态失效(如退出登录)时,避免下一个用户沿用上一个用户的可访问路由。
160
+ */
161
+ clear() {
162
+ this.routeCaches.clear();
163
+ this.accessibleLoadingPromises.clear();
164
+ this.refreshRequestIds.forEach((requestId, layoutUid) => {
165
+ this.refreshRequestIds.set(layoutUid, requestId + 1);
166
+ });
167
+ this.syncRoutesProperty();
168
+ }
169
+
156
170
  /**
157
171
  * 订阅路由缓存变化,用于驱动 React 上下文刷新。
158
172
  *
@@ -193,6 +193,57 @@ describe('RouteRepository', () => {
193
193
  unsubscribeCustom();
194
194
  });
195
195
 
196
+ it('should drop cached routes of every layout when cleared', () => {
197
+ const { repository } = createRouteRepository();
198
+
199
+ repository.setRoutes([route('admin-page')], 'admin-layout-model');
200
+ repository.setRoutes([route('custom-page')], 'custom-desktop-layout-model');
201
+
202
+ repository.clear();
203
+
204
+ expect(repository.isAccessibleLoaded()).toBe(false);
205
+ expect(repository.listAccessible()).toEqual([]);
206
+ expect(repository.routes).toEqual([]);
207
+ const deactivateLayout = repository.activateLayout({
208
+ uid: 'custom-desktop-layout-model',
209
+ });
210
+ expect(repository.isAccessibleLoaded()).toBe(false);
211
+ expect(repository.listAccessible()).toEqual([]);
212
+ deactivateLayout();
213
+ });
214
+
215
+ it('should reload accessible routes after being cleared', async () => {
216
+ const { repository, request } = createRouteRepository();
217
+ request.mockResolvedValueOnce({ data: { data: [route('previous-user-page')] } });
218
+ await repository.ensureAccessibleLoaded();
219
+
220
+ repository.clear();
221
+ request.mockResolvedValueOnce({ data: { data: [route('next-user-page')] } });
222
+ const routes = await repository.ensureAccessibleLoaded();
223
+
224
+ expect(request).toHaveBeenCalledTimes(2);
225
+ expect(routes.map((item) => item.schemaUid)).toEqual(['next-user-page']);
226
+ });
227
+
228
+ it('should ignore refresh results requested before being cleared', async () => {
229
+ const { repository, request } = createRouteRepository();
230
+ let resolveStaleRequest: (value: unknown) => void = () => {};
231
+ request.mockImplementationOnce(
232
+ () =>
233
+ new Promise((resolve) => {
234
+ resolveStaleRequest = resolve;
235
+ }),
236
+ );
237
+
238
+ const staleRefresh = repository.refreshAccessible();
239
+ repository.clear();
240
+ resolveStaleRequest({ data: { data: [route('previous-user-page')] } });
241
+ await staleRefresh;
242
+
243
+ expect(repository.isAccessibleLoaded()).toBe(false);
244
+ expect(repository.listAccessible()).toEqual([]);
245
+ });
246
+
196
247
  it('should pass the default admin layout when creating a route', async () => {
197
248
  const { repository, create } = createRouteRepository();
198
249
 
@@ -171,6 +171,19 @@ describe('app', () => {
171
171
  expect(document.documentElement.lang).toBe('ja-JP');
172
172
  });
173
173
 
174
+ it('should clear cached accessible routes when the auth token is cleared', () => {
175
+ const app = new Application({ router });
176
+ const routeRepository = app.flowEngine.context.routeRepository;
177
+ routeRepository.setRoutes([{ schemaUid: 'previous-user-page' }]);
178
+
179
+ app.apiClient.auth.setToken('renewed-token');
180
+ expect(routeRepository.listAccessible().map((route) => route.schemaUid)).toEqual(['previous-user-page']);
181
+
182
+ app.apiClient.auth.setToken(null);
183
+ expect(routeRepository.isAccessibleLoaded()).toBe(false);
184
+ expect(routeRepository.listAccessible()).toEqual([]);
185
+ });
186
+
174
187
  it('should escape app version html placeholder content', () => {
175
188
  expect(getAppVersionHTML('<script>alert(1)</script>&"')).toBe(
176
189
  '<span class="nb-app-version">v&lt;script&gt;alert(1)&lt;/script&gt;&amp;&quot;</span>',
@@ -379,7 +392,11 @@ describe('app', () => {
379
392
  expect(screen.getByText('maintaining dialog message')).toBeInTheDocument();
380
393
  });
381
394
 
382
- it('should keep current content behind maintained dialog state', async () => {
395
+ it.each([
396
+ ['pm.enable', 'Enabling plugin'],
397
+ ['pm.disable', 'Disabling plugin'],
398
+ ['future.command', 'future.command'],
399
+ ])('should keep current content behind the default dialog while app commanding: %s', async (commandName, title) => {
383
400
  const CurrentPage = () => {
384
401
  const [count, setCount] = React.useState(0);
385
402
  return <button onClick={() => setCount((value) => value + 1)}>Current page count: {count}</button>;
@@ -398,12 +415,127 @@ describe('app', () => {
398
415
  act(() => {
399
416
  app.maintained = true;
400
417
  app.maintaining = true;
401
- app.error = { code: 'APP_COMMANDING', command: { name: 'pm.enable' }, message: 'starting sub applications...' };
418
+ app.error = { code: 'APP_COMMANDING', command: { name: commandName }, message: 'starting sub applications...' };
402
419
  });
403
420
 
404
421
  expect(screen.getByRole('button', { name: 'Current page count: 1' })).toBeInTheDocument();
405
422
  expect(screen.getByRole('dialog')).toBeInTheDocument();
406
- expect(screen.getByText('Enabling plugin')).toBeInTheDocument();
423
+ expect(screen.getByText(title)).toBeInTheDocument();
424
+ });
425
+
426
+ it('should keep current content without a dialog while app upgrading', async () => {
427
+ const CurrentPage = () => <div>Current page</div>;
428
+
429
+ class PluginHelloClient extends Plugin {
430
+ async load() {
431
+ this.router.add('root', { path: '/', Component: CurrentPage });
432
+ }
433
+ }
434
+
435
+ const app = createMockClient({ plugins: [PluginHelloClient] });
436
+ await renderApp(app);
437
+
438
+ act(() => {
439
+ app.maintained = true;
440
+ app.maintaining = true;
441
+ app.error = Object.assign(new Error('Loading data sources...'), {
442
+ code: 'APP_COMMANDING',
443
+ command: { name: 'upgrade' },
444
+ });
445
+ });
446
+
447
+ expect(screen.getByText('Current page')).toBeInTheDocument();
448
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
449
+ });
450
+
451
+ it.each([
452
+ ['upgrade', 'App upgrading'],
453
+ ['pm.enable', 'Enabling plugin'],
454
+ ['pm.disable', 'Disabling plugin'],
455
+ ['future.command', 'future.command'],
456
+ ])('should not show Try again for an app commanding error state: %s', async (commandName, title) => {
457
+ class PluginHelloClient extends Plugin {}
458
+ const app = createMockClient({ plugins: [PluginHelloClient] });
459
+ app.error = Object.assign(new Error('Loading data sources...'), {
460
+ code: 'APP_COMMANDING',
461
+ command: { name: commandName },
462
+ });
463
+
464
+ await renderApp(app);
465
+
466
+ expect(screen.getByText(title)).toBeInTheDocument();
467
+ expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
468
+ });
469
+
470
+ it('should clear upgrade feedback after an existing page error', async () => {
471
+ const BrokenPage = () => {
472
+ throw new Error('page error');
473
+ };
474
+
475
+ class PluginHelloClient extends Plugin {
476
+ async load() {
477
+ this.router.add('root', { path: '/', Component: BrokenPage });
478
+ }
479
+ }
480
+
481
+ const app = createMockClient({
482
+ plugins: [PluginHelloClient],
483
+ ws: { url: 'ws://localhost:3000/ws' },
484
+ });
485
+ app.maintained = true;
486
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
487
+ try {
488
+ await renderApp(app);
489
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
490
+
491
+ act(() => {
492
+ app.ws.emit('message', {
493
+ type: 'maintaining',
494
+ payload: {
495
+ code: 'APP_COMMANDING',
496
+ command: { name: 'upgrade' },
497
+ message: 'Loading data sources...',
498
+ },
499
+ });
500
+ });
501
+
502
+ expect(screen.getByText('App upgrading')).toBeInTheDocument();
503
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
504
+ expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
505
+ } finally {
506
+ errorSpy.mockRestore();
507
+ }
508
+ });
509
+
510
+ it('should keep a custom maintaining dialog while app upgrading', async () => {
511
+ class PluginHelloClient extends Plugin {}
512
+ const app = createMockClient({ plugins: [PluginHelloClient] });
513
+ app.addComponents({
514
+ CustomUpgradeStatus: () => <div>Custom upgrade status</div>,
515
+ });
516
+ app.maintained = true;
517
+ app.maintaining = true;
518
+ app.error = Object.assign(new Error('Loading data sources...'), {
519
+ code: 'APP_COMMANDING',
520
+ command: {
521
+ name: 'upgrade',
522
+ components: { maintainingDialog: 'CustomUpgradeStatus' },
523
+ },
524
+ });
525
+
526
+ await renderApp(app);
527
+
528
+ expect(screen.getByText('Custom upgrade status')).toBeInTheDocument();
529
+ });
530
+
531
+ it('should keep Try again for recoverable app errors', async () => {
532
+ class PluginHelloClient extends Plugin {}
533
+ const app = createMockClient({ plugins: [PluginHelloClient] });
534
+ app.error = Object.assign(new Error('load error'), { code: 'LOAD_ERROR' });
535
+
536
+ await renderApp(app);
537
+
538
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
407
539
  });
408
540
 
409
541
  it('should handle long loading state gracefully', async () => {
@@ -17,12 +17,18 @@ import {
17
17
 
18
18
  describe('auth redirect helpers', () => {
19
19
  const originalLocation = globalThis.window.location;
20
+ const originalModernClientPrefix = window.__nocobase_modern_client_prefix__;
20
21
 
21
22
  afterEach(() => {
22
23
  Object.defineProperty(globalThis.window, 'location', {
23
24
  configurable: true,
24
25
  value: originalLocation,
25
26
  });
27
+ if (originalModernClientPrefix === undefined) {
28
+ delete window.__nocobase_modern_client_prefix__;
29
+ } else {
30
+ window.__nocobase_modern_client_prefix__ = originalModernClientPrefix;
31
+ }
26
32
  vi.restoreAllMocks();
27
33
  });
28
34
 
@@ -84,6 +90,107 @@ describe('auth redirect helpers', () => {
84
90
  expect(buildV2SigninHref(app, '/v2/admin/7vu4c2sdk6h')).toBe('/v2/signin?redirect=%2Fv2%2Fadmin%2F7vu4c2sdk6h');
85
91
  });
86
92
 
93
+ it('should keep validated standalone settings redirects outside the v2 basename', () => {
94
+ const app = {
95
+ getPublicPath: () => '/nocobase/v2/',
96
+ router: {
97
+ getBasename: () => '/nocobase/v2',
98
+ },
99
+ } as any;
100
+
101
+ expect(normalizeV2RedirectPath(app, '/nocobase/settings/workflow?tab=list#recent')).toBe(
102
+ '/nocobase/settings/workflow?tab=list#recent',
103
+ );
104
+ });
105
+
106
+ it('should build the standalone settings signin URL for a standalone settings app', () => {
107
+ const app = {
108
+ name: 'main',
109
+ getPublicPath: () => '/nocobase/',
110
+ router: {
111
+ getBasename: () => '/nocobase',
112
+ },
113
+ pluginSettingsManager: {
114
+ getRouteName: () => 'settings.',
115
+ getRoutePath: () => '/settings/',
116
+ },
117
+ } satisfies Parameters<typeof buildV2SigninHref>[0];
118
+ expect(buildV2SigninHref(app, '/nocobase/settings/workflow?tab=list#recent')).toBe(
119
+ '/nocobase/settings/signin?redirect=%2Fnocobase%2Fsettings%2Fworkflow%3Ftab%3Dlist%23recent',
120
+ );
121
+ });
122
+
123
+ it('should keep an apps segment inside the main public path out of the Settings application scope', () => {
124
+ const app = {
125
+ name: 'main',
126
+ getPublicPath: () => '/tenant/apps/root/',
127
+ router: {
128
+ getBasename: () => '/tenant/apps/root/',
129
+ },
130
+ pluginSettingsManager: {
131
+ getRouteName: () => 'settings.',
132
+ getRoutePath: () => '/settings/',
133
+ },
134
+ } satisfies Parameters<typeof buildV2SigninHref>[0];
135
+
136
+ expect(buildV2SigninHref(app, '/tenant/apps/root/settings/workflow')).toBe(
137
+ '/tenant/apps/root/settings/signin?redirect=%2Ftenant%2Fapps%2Froot%2Fsettings%2Fworkflow',
138
+ );
139
+ });
140
+
141
+ it.each([
142
+ ['/nocobase/settings/apps/test-app/', '/nocobase/settings/apps/test-app/signin'],
143
+ ['/nocobase/settings/_app/test-app/', '/nocobase/settings/_app/test-app/signin'],
144
+ ])('should keep the standalone settings signin in the current sub-app scope: %s', (basename, signinPath) => {
145
+ const app = {
146
+ name: 'test-app',
147
+ getPublicPath: () => '/nocobase/',
148
+ router: {
149
+ getBasename: () => basename,
150
+ },
151
+ pluginSettingsManager: {
152
+ getRouteName: () => 'settings.',
153
+ getRoutePath: () => '/',
154
+ },
155
+ } as any;
156
+
157
+ expect(buildV2SigninHref(app, `${basename}workflow`)).toBe(
158
+ `${signinPath}?redirect=${encodeURIComponent(`${basename}workflow`)}`,
159
+ );
160
+ });
161
+
162
+ it('should accept the current standalone settings signin URL only', () => {
163
+ Object.defineProperty(globalThis.window, 'location', {
164
+ configurable: true,
165
+ value: {
166
+ ...originalLocation,
167
+ origin: 'http://localhost:20000',
168
+ },
169
+ });
170
+ const app = {
171
+ name: 'test-app',
172
+ getPublicPath: () => '/nocobase/',
173
+ router: {
174
+ getBasename: () => '/nocobase/settings/apps/test-app/',
175
+ },
176
+ pluginSettingsManager: {
177
+ getRouteName: () => 'settings.',
178
+ getRoutePath: () => '/',
179
+ },
180
+ } as any;
181
+
182
+ expect(
183
+ resolveV2SigninRedirect(
184
+ '/nocobase/settings/apps/test-app/signin?redirect=%2Fnocobase%2Fsettings%2Fapps%2Ftest-app',
185
+ app,
186
+ ),
187
+ ).toBe(
188
+ 'http://localhost:20000/nocobase/settings/apps/test-app/signin?redirect=%2Fnocobase%2Fsettings%2Fapps%2Ftest-app',
189
+ );
190
+ expect(resolveV2SigninRedirect('/nocobase/v/apps/test-app/signin', app)).toBeNull();
191
+ expect(resolveV2SigninRedirect('/nocobase/settings/apps/other-app/signin', app)).toBeNull();
192
+ });
193
+
87
194
  it('should redirect with window.location.replace by default', () => {
88
195
  const replace = vi.fn();
89
196
  Object.defineProperty(globalThis.window, 'location', {
@@ -134,6 +241,36 @@ describe('auth redirect helpers', () => {
134
241
  });
135
242
 
136
243
  describe('v2 sub-app context (router basename contains /apps/<id>/)', () => {
244
+ it('should keep only the current sub-app standalone settings redirect', () => {
245
+ const app = {
246
+ getPublicPath: () => '/nocobase/v2/',
247
+ router: {
248
+ getBasename: () => '/nocobase/v2/apps/test-app/',
249
+ },
250
+ } as any;
251
+
252
+ expect(normalizeV2RedirectPath(app, '/nocobase/settings/apps/test-app/workflow')).toBe(
253
+ '/nocobase/settings/apps/test-app/workflow',
254
+ );
255
+ expect(normalizeV2RedirectPath(app, '/nocobase/settings/apps/other-app/workflow')).toBe(
256
+ '/nocobase/v2/apps/test-app/admin/',
257
+ );
258
+ });
259
+
260
+ it.each([
261
+ '/nocobase/settings/apps/test-app/../../other-app/settings',
262
+ '/nocobase/settings/apps/test-app/%2e%2e/%2E%2e/other-app/settings',
263
+ ])('should reject a standalone settings redirect that resolves outside the current sub-app: %s', (target) => {
264
+ const app = {
265
+ getPublicPath: () => '/nocobase/v2/',
266
+ router: {
267
+ getBasename: () => '/nocobase/v2/apps/test-app/',
268
+ },
269
+ } as any;
270
+
271
+ expect(normalizeV2RedirectPath(app, target)).toBe('/nocobase/v2/apps/test-app/admin/');
272
+ });
273
+
137
274
  it('should normalize signin redirect fallback under the current sub-app basename', () => {
138
275
  const app = {
139
276
  getPublicPath: () => '/v/',
@@ -210,6 +347,23 @@ describe('auth redirect helpers', () => {
210
347
  );
211
348
  });
212
349
 
350
+ it('should derive a scoped Portal signin href under the current sub-app basename', () => {
351
+ const app = {
352
+ getPublicPath: () => '/nocobase/v2/',
353
+ router: {
354
+ getBasename: () => '/nocobase/v2/apps/test-app/',
355
+ },
356
+ } satisfies Parameters<typeof buildV2SigninHref>[0];
357
+
358
+ expect(
359
+ buildV2SigninHref(app, '/nocobase/v2/apps/test-app/customer/orders', {
360
+ signInRoutePath: '/customer/signin',
361
+ }),
362
+ ).toBe(
363
+ '/nocobase/v2/apps/test-app/customer/signin?redirect=%2Fnocobase%2Fv2%2Fapps%2Ftest-app%2Fcustomer%2Forders',
364
+ );
365
+ });
366
+
213
367
  it('should redirect to sub-app signin with window.location.replace', () => {
214
368
  const replace = vi.fn();
215
369
  Object.defineProperty(globalThis.window, 'location', {