@nocobase/client-v2 2.3.0-beta.6 → 2.4.0-alpha.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/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
@@ -14,12 +14,11 @@ import { Button, Dropdown, theme, Tooltip, type ButtonProps, type MenuProps } fr
14
14
  import React, { useEffect, useMemo, useState } from 'react';
15
15
  import { useHotkeys } from 'react-hotkeys-hook';
16
16
  import { useTranslation } from 'react-i18next';
17
- import { Link, useLocation } from 'react-router-dom';
17
+ import { useLocation } from 'react-router-dom';
18
18
  import { useACLRoleContext } from '../../../acl';
19
- import type { BaseApplication } from '../../../BaseApplication';
20
19
  import type { PluginSettingsPageType } from '../../../PluginSettingsManager';
21
- import { shouldOpenAdminRouteInNewWindow } from '../../../RouterManager';
22
20
  import { useApp } from '../../../hooks/useApp';
21
+ import { resolveStandaloneSettingsPath } from '../../../settings-app/runtimePaths';
23
22
  import {
24
23
  filterRenderableSettings,
25
24
  filterVisibleSettings,
@@ -66,109 +65,6 @@ const topbarActionTriggerClassName = css`
66
65
  height: 100%;
67
66
  `;
68
67
 
69
- type TopbarSettingsAppLike = Pick<
70
- BaseApplication<any>,
71
- 'name' | 'router' | 'getPublicPath' | 'getHref' | 'layoutManager'
72
- >;
73
-
74
- const normalizeTopbarPath = (pathname?: string) => {
75
- const trimmed = pathname?.trim();
76
- if (!trimmed || trimmed === '/') {
77
- return '/';
78
- }
79
- return `/${trimmed.replace(/^\/+/, '')}`;
80
- };
81
-
82
- const normalizeTopbarBasePath = (pathname?: string) => {
83
- const normalized = normalizeTopbarPath(pathname).replace(/\/+$/, '');
84
- return normalized === '' || normalized === '/' ? '' : normalized;
85
- };
86
-
87
- const getTopbarRouterBasePath = (app: TopbarSettingsAppLike | undefined) => {
88
- return app?.router?.getBasename?.() || app?.router?.basename || app?.getPublicPath?.() || '';
89
- };
90
-
91
- const stripTopbarRouterBasePath = (pathname: string, basename?: string) => {
92
- const normalizedPath = normalizeTopbarPath(pathname);
93
- const normalizedBase = normalizeTopbarBasePath(basename);
94
-
95
- if (!normalizedBase) {
96
- return normalizedPath;
97
- }
98
-
99
- if (normalizedPath === normalizedBase) {
100
- return '/';
101
- }
102
-
103
- if (normalizedPath.startsWith(`${normalizedBase}/`)) {
104
- return normalizeTopbarPath(normalizedPath.slice(normalizedBase.length));
105
- }
106
-
107
- return normalizedPath;
108
- };
109
-
110
- const getTopbarAppPath = (pathname: string) => {
111
- const normalizedPath = normalizeTopbarPath(pathname);
112
- const match = /^(.*?\/(?:apps|_app)\/[^/]+)(?=\/|$)/.exec(normalizedPath);
113
- const appPath = match?.[1] || '';
114
-
115
- return {
116
- appPath,
117
- routePath: appPath ? normalizeTopbarPath(normalizedPath.slice(appPath.length)) : normalizedPath,
118
- };
119
- };
120
-
121
- const getTopbarContextAppPath = (app: TopbarSettingsAppLike | undefined, basename?: string) => {
122
- const basenameAppPath = getTopbarAppPath(basename || '').appPath;
123
- if (basenameAppPath) {
124
- return basenameAppPath;
125
- }
126
-
127
- const publicPathAppPath = getTopbarAppPath(app?.getPublicPath?.() || '').appPath;
128
- if (publicPathAppPath) {
129
- return publicPathAppPath;
130
- }
131
-
132
- if (app?.name && app.name !== 'main' && app.getHref) {
133
- return normalizeTopbarBasePath(app.getHref('/'));
134
- }
135
-
136
- return '';
137
- };
138
-
139
- const prependTopbarAppPath = (pathname: string, appPath: string) => {
140
- const normalizedPath = normalizeTopbarPath(pathname);
141
-
142
- if (!appPath || normalizedPath === appPath || normalizedPath.startsWith(`${appPath}/`)) {
143
- return normalizedPath;
144
- }
145
-
146
- return `${appPath}${normalizedPath}`;
147
- };
148
-
149
- const isAdminRuntimePath = (pathname: string) => {
150
- return pathname === '/admin' || pathname.startsWith('/admin/');
151
- };
152
-
153
- const getTopbarAdminRoutePath = (app: TopbarSettingsAppLike | undefined) => {
154
- try {
155
- return app?.layoutManager?.getLayout?.('admin')?.routePath;
156
- } catch {
157
- return undefined;
158
- }
159
- };
160
-
161
- const buildTopbarDocumentHref = (targetPath: string, basename?: string) => {
162
- const normalizedTarget = normalizeTopbarPath(targetPath);
163
- const normalizedBase = normalizeTopbarBasePath(basename);
164
-
165
- if (!normalizedBase || normalizedTarget === normalizedBase || normalizedTarget.startsWith(`${normalizedBase}/`)) {
166
- return normalizedTarget;
167
- }
168
-
169
- return `${normalizedBase}${normalizedTarget}`;
170
- };
171
-
172
68
  function TopbarExternalSettingsLabel(props: { title: React.ReactNode; link: string }) {
173
69
  return (
174
70
  <div
@@ -185,39 +81,13 @@ function TopbarInternalSettingsLabel(props: { title: React.ReactNode; path?: str
185
81
  const app = useApp();
186
82
  const location = useLocation();
187
83
  const targetPath = props.path || '/admin/settings';
188
- const basename = getTopbarRouterBasePath(app);
189
- const currentPath = stripTopbarRouterBasePath(location.pathname, basename);
190
- const currentLocationAppPath = getTopbarAppPath(currentPath);
191
- const currentAppPath = currentLocationAppPath.appPath || getTopbarContextAppPath(app, basename);
192
- const currentRoutePath = currentLocationAppPath.appPath ? currentLocationAppPath.routePath : currentPath;
193
- const targetPathInCurrentApp = prependTopbarAppPath(stripTopbarRouterBasePath(targetPath, basename), currentAppPath);
194
-
195
- if (currentAppPath) {
196
- const href = buildTopbarDocumentHref(targetPathInCurrentApp, basename);
197
- const shouldOpenInNewWindow = shouldOpenAdminRouteInNewWindow({
198
- currentPathname: currentPath,
199
- targetPathname: targetPathInCurrentApp,
200
- basePath: basename,
201
- adminRoutePath: getTopbarAdminRoutePath(app),
202
- });
203
-
204
- return (
205
- <a
206
- href={href}
207
- target={shouldOpenInNewWindow ? '_blank' : undefined}
208
- rel={shouldOpenInNewWindow ? 'noopener noreferrer' : undefined}
209
- >
210
- {props.title}
211
- </a>
212
- );
213
- }
214
-
215
- if (isAdminRuntimePath(currentRoutePath)) {
216
- return <Link to={targetPathInCurrentApp}>{props.title}</Link>;
217
- }
218
84
 
219
85
  return (
220
- <a href={buildTopbarDocumentHref(targetPath, basename)} target="_blank" rel="noopener noreferrer">
86
+ <a
87
+ href={resolveStandaloneSettingsPath(app, targetPath, location.pathname)}
88
+ target="_blank"
89
+ rel="noopener noreferrer"
90
+ >
221
91
  {props.title}
222
92
  </a>
223
93
  );
@@ -238,66 +108,68 @@ export function getTopbarPluginSettingsItems(options: {
238
108
  }): NonNullable<MenuProps['items']> {
239
109
  const { settings, canManagePlugins, t } = options;
240
110
  const topLevelSettings = filterVisibleSettings(filterRenderableSettings(settings));
241
- const pluginManagerSetting = topLevelSettings.find((item) => item.name === PLUGIN_MANAGER_SETTING_NAME);
111
+ const primarySettings = sortTopLevelSettings(
112
+ topLevelSettings
113
+ .filter(
114
+ (item) =>
115
+ ((item.sort || 0) < 0 || item.name === PLUGIN_MANAGER_SETTING_NAME) &&
116
+ (item.name !== PLUGIN_MANAGER_SETTING_NAME || canManagePlugins),
117
+ )
118
+ .map((item) => ({
119
+ ...item,
120
+ children: undefined,
121
+ })),
122
+ );
242
123
  const settingsByKey = new Map<string, PluginSettingsPageType>();
243
124
  const normalSettings = sortTopLevelSettings(
244
125
  topLevelSettings
245
- .filter((item) => item.name !== PLUGIN_MANAGER_SETTING_NAME)
126
+ .filter((item) => (item.sort || 0) >= 0 && item.name !== PLUGIN_MANAGER_SETTING_NAME)
246
127
  .map((item) => ({
247
128
  ...item,
248
129
  children: undefined,
249
130
  })),
250
131
  );
251
132
 
252
- normalSettings.forEach((item) => {
133
+ [...primarySettings, ...normalSettings].forEach((item) => {
253
134
  settingsByKey.set(item.key, item);
254
135
  });
255
136
 
256
- const orderedSettings = (getMenuItems(normalSettings) || []).map((item: any) => {
257
- if (item?.type === 'divider') {
258
- return item;
259
- }
260
-
261
- const matchedSetting = settingsByKey.get(String(item.key));
262
- const targetPath = matchedSetting?.path;
263
- const targetLink = matchedSetting?.link;
264
- const targetTitle = matchedSetting?.title || item.title;
265
-
266
- return {
267
- key: item.key,
268
- name: matchedSetting?.name,
269
- path: matchedSetting?.path,
270
- link: targetLink,
271
- title: targetTitle,
272
- icon: item.icon,
273
- label: targetLink ? (
274
- <TopbarExternalSettingsLabel title={targetTitle} link={targetLink} />
275
- ) : (
276
- <TopbarInternalSettingsLabel title={targetTitle} path={targetPath} />
277
- ),
278
- };
279
- });
280
-
281
- const items: NonNullable<MenuProps['items']> = [];
282
-
283
- if (canManagePlugins && pluginManagerSetting) {
284
- items.push({
285
- key: pluginManagerSetting.key,
286
- icon: pluginManagerSetting.icon || <ApiOutlined />,
287
- label: (
288
- <TopbarInternalSettingsLabel
289
- title={pluginManagerSetting.title || t('Plugin manager')}
290
- path={pluginManagerSetting.path}
291
- />
292
- ),
137
+ const buildMenuItems = (targetSettings: PluginSettingsPageType[]) =>
138
+ (getMenuItems(targetSettings) || []).map((item: any) => {
139
+ if (item?.type === 'divider') {
140
+ return item;
141
+ }
142
+
143
+ const matchedSetting = settingsByKey.get(String(item.key));
144
+ const targetPath = matchedSetting?.path;
145
+ const targetLink = matchedSetting?.link;
146
+ const isPluginManager = matchedSetting?.name === PLUGIN_MANAGER_SETTING_NAME;
147
+ const targetTitle = matchedSetting?.title || (isPluginManager ? t('Plugin manager') : item.title);
148
+
149
+ return {
150
+ key: item.key,
151
+ name: matchedSetting?.name,
152
+ path: matchedSetting?.path,
153
+ link: targetLink,
154
+ title: targetTitle,
155
+ icon: isPluginManager ? matchedSetting?.icon || <ApiOutlined /> : item.icon,
156
+ label: targetLink ? (
157
+ <TopbarExternalSettingsLabel title={targetTitle} link={targetLink} />
158
+ ) : (
159
+ <TopbarInternalSettingsLabel title={targetTitle} path={targetPath} />
160
+ ),
161
+ };
293
162
  });
294
- }
295
163
 
296
- if (canManagePlugins && orderedSettings.length) {
164
+ const primaryItems = buildMenuItems(primarySettings);
165
+ const normalItems = buildMenuItems(normalSettings);
166
+ const items: NonNullable<MenuProps['items']> = [...primaryItems];
167
+
168
+ if (primaryItems.length && normalItems.length) {
297
169
  items.push({ type: 'divider' });
298
170
  }
299
171
 
300
- items.push(...orderedSettings);
172
+ items.push(...normalItems);
301
173
 
302
174
  return items;
303
175
  }
package/src/index.ts CHANGED
@@ -29,7 +29,15 @@ export * from './PluginSettingsManager';
29
29
  export * from './layout-manager';
30
30
  export * from './hooks';
31
31
  export { default as languageCodes } from './locale/languageCodes';
32
- export * from './nocobase-buildin-plugin';
32
+ export {
33
+ CurrentUserContext,
34
+ NocoBaseBuildInPlugin,
35
+ NocoBaseBuildInPluginV2,
36
+ resolveUnauthenticatedSignInRoute,
37
+ useCurrentRoles,
38
+ useCurrentUserContext,
39
+ } from './nocobase-buildin-plugin';
40
+ export type { CurrentRoleOption, CurrentUserState } from './nocobase-buildin-plugin';
33
41
  export { getRouteRuntimeVersion } from './utils/getRouteRuntimeVersion';
34
42
  export type { RouteRuntimeVersion } from './utils/getRouteRuntimeVersion';
35
43
  export * from './collection-field-interface/CollectionFieldInterface';
@@ -55,3 +63,5 @@ export type { NocoBaseDesktopRoute } from './flow-compat';
55
63
  export * from './utils/markdownSanitize';
56
64
  export * from './ui-operation';
57
65
  export { default as AntdAppProvider } from './theme/AntdAppProvider';
66
+ export { isSettingsApp } from './settings-app/isSettingsApp';
67
+ export { MINIMAL_THEME_UID, useSettingsThemeConfig } from './settings-app/useSettingsThemeConfig';
@@ -0,0 +1,58 @@
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 { useSyncExternalStore } from 'react';
11
+
12
+ export type CurrentUserAuthStatus = 'unknown' | 'authenticated' | 'unauthenticated' | 'redirecting';
13
+
14
+ type AuthStatusStore = {
15
+ getSnapshot: () => CurrentUserAuthStatus;
16
+ set: (status: CurrentUserAuthStatus) => void;
17
+ subscribe: (listener: () => void) => () => void;
18
+ };
19
+
20
+ const stores = new WeakMap<object, AuthStatusStore>();
21
+
22
+ function createAuthStatusStore(): AuthStatusStore {
23
+ let status: CurrentUserAuthStatus = 'unknown';
24
+ const listeners = new Set<() => void>();
25
+
26
+ return {
27
+ getSnapshot: () => status,
28
+ set: (nextStatus) => {
29
+ if (status === nextStatus) {
30
+ return;
31
+ }
32
+ status = nextStatus;
33
+ listeners.forEach((listener) => listener());
34
+ },
35
+ subscribe: (listener) => {
36
+ listeners.add(listener);
37
+ return () => listeners.delete(listener);
38
+ },
39
+ };
40
+ }
41
+
42
+ function getAuthStatusStore(app: object) {
43
+ let store = stores.get(app);
44
+ if (!store) {
45
+ store = createAuthStatusStore();
46
+ stores.set(app, store);
47
+ }
48
+ return store;
49
+ }
50
+
51
+ export function setCurrentUserAuthStatus(app: object, status: CurrentUserAuthStatus) {
52
+ getAuthStatusStore(app).set(status);
53
+ }
54
+
55
+ export function useCurrentUserAuthStatus(app: object) {
56
+ const store = getAuthStatusStore(app);
57
+ return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
58
+ }
@@ -9,21 +9,23 @@
9
9
 
10
10
  import { createCollectionContextMeta, useFlowEngine } from '@nocobase/flow-engine';
11
11
  import React, { createContext, type FC, useContext, useEffect, useMemo, useRef, useState } from 'react';
12
- import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
12
+ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
13
13
  import { useACLRoleContext } from '../acl';
14
14
  import type { Application } from '../Application';
15
- import { getCurrentV2RedirectPath, getDefaultV2AdminRedirectPath } from '../authRedirect';
15
+ import { getCurrentV2RedirectPath, redirectToV2Signin } from '../authRedirect';
16
16
  import { AppNotFound } from '../components';
17
17
  import { PluginFlowEngine } from '../flow';
18
18
  import {
19
- ADMIN_LAYOUT_MODEL_UID,
20
19
  AdminLayoutMenuItemModel,
21
20
  AdminLayoutModel,
22
21
  AppSwitcherActionPanelModel,
23
22
  } from '../flow/admin-shell/admin-layout';
24
23
  import { useApp } from '../hooks/useApp';
25
24
  import { Plugin } from '../Plugin';
25
+ import type { PluginClass } from '../PluginManager';
26
26
  import { AdminSettingsLayoutModel } from '../settings-center';
27
+ import { SettingsDocumentRedirect } from '../settings-app/SettingsDocumentRedirect';
28
+ import { type CurrentUserAuthStatus, setCurrentUserAuthStatus } from './currentUserAuthStatus';
27
29
  import { LocalePlugin } from './plugins/LocalePlugin';
28
30
 
29
31
  export type CurrentUserState = {
@@ -33,8 +35,6 @@ export type CurrentUserState = {
33
35
  loading: boolean;
34
36
  };
35
37
 
36
- type CurrentUserAuthStatus = 'unknown' | 'authenticated' | 'unauthenticated' | 'redirecting';
37
-
38
38
  type CurrentUserInternalState = CurrentUserState & {
39
39
  authStatus: CurrentUserAuthStatus;
40
40
  error?: Error | null;
@@ -181,6 +181,32 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
181
181
  return <>{children}</>;
182
182
  };
183
183
 
184
+ export function resolveUnauthenticatedSignInRoute(app: Application, pathname: string) {
185
+ const matchedRoutes = app.router.matchRoutes(pathname) || [];
186
+ for (let index = matchedRoutes.length - 1; index >= 0; index -= 1) {
187
+ const pathnameBase = matchedRoutes[index].pathnameBase.replace(/\/+$/g, '');
188
+ const candidate = `${pathnameBase}/signin`;
189
+ if (app.router.isSkippedAuthCheckRoute(candidate)) {
190
+ return candidate;
191
+ }
192
+ }
193
+ return '/signin';
194
+ }
195
+
196
+ function redirectUnauthenticatedRoute(
197
+ app: Application,
198
+ location: { pathname: string; search?: string; hash?: string },
199
+ navigate: ReturnType<typeof useNavigate>,
200
+ ) {
201
+ const redirectPath = getCurrentV2RedirectPath(app, location);
202
+ if (app.pluginSettingsManager.getRouteName('') === 'settings.') {
203
+ redirectToV2Signin(app, redirectPath);
204
+ return;
205
+ }
206
+ const signInPath = resolveUnauthenticatedSignInRoute(app, location.pathname);
207
+ navigate(`${signInPath}?redirect=${encodeURIComponent(redirectPath)}`, { replace: true });
208
+ }
209
+
184
210
  const CurrentUserProvider: FC = ({ children }) => {
185
211
  const app = useApp<Application>();
186
212
  const location = useLocation();
@@ -234,9 +260,7 @@ const CurrentUserProvider: FC = ({ children }) => {
234
260
  if (user?.id == null) {
235
261
  // 用 react-router navigate (虚拟跳转)而不是 location.replace, 这样如果有其他响应拦截器已经发起了 window.location.href 整页跳转(例如 2FA 插件接收到服务端 302 重定向), 真实跳转可以胜出 navigate, 不会被这里的 signin 重定向覆盖。
236
262
  setState({ loading: true, authStatus: 'unauthenticated', error: null });
237
- navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
238
- replace: true,
239
- });
263
+ redirectUnauthenticatedRoute(app, locationRef.current, navigate);
240
264
  return;
241
265
  }
242
266
 
@@ -277,9 +301,7 @@ const CurrentUserProvider: FC = ({ children }) => {
277
301
  const isAuthError = errorLike?.response?.status === 401 || errorLike?.status === 401;
278
302
  if (isAuthError) {
279
303
  setState({ loading: true, authStatus: 'unauthenticated', error: null });
280
- navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
281
- replace: true,
282
- });
304
+ redirectUnauthenticatedRoute(app, locationRef.current, navigate);
283
305
  return;
284
306
  }
285
307
  setState({
@@ -297,6 +319,10 @@ const CurrentUserProvider: FC = ({ children }) => {
297
319
  };
298
320
  }, [app, authCheckRouteState, navigate]);
299
321
 
322
+ useEffect(() => {
323
+ setCurrentUserAuthStatus(app, state.authStatus);
324
+ }, [app, state.authStatus]);
325
+
300
326
  if (state.error) {
301
327
  throw state.error;
302
328
  }
@@ -310,19 +336,6 @@ const CurrentUserProvider: FC = ({ children }) => {
310
336
 
311
337
  CurrentUserProvider.displayName = 'CurrentUserProvider';
312
338
 
313
- const RootRedirect: FC = () => {
314
- const app = useApp<Application>();
315
- const hasToken = !!app?.apiClient?.auth?.token;
316
- const targetPath = getDefaultV2AdminRedirectPath(app);
317
-
318
- if (!hasToken) {
319
- // 用 react-router <Navigate /> 而非 location.replace, 避免覆盖同时段其它响应拦截器触发的 window.location.href (例如 2FA 接收到服务端 302 时设置的整页跳转)。
320
- return <Navigate replace to={`/signin?redirect=${encodeURIComponent(targetPath)}`} />;
321
- }
322
-
323
- return <Navigate replace to="/admin" />;
324
- };
325
-
326
339
  /**
327
340
  * client-v2 使用的内建插件集合。
328
341
  *
@@ -346,56 +359,11 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
346
359
  AppSwitcherActionPanelModel,
347
360
  AdminSettingsLayoutModel,
348
361
  });
349
- this.app.layoutManager.registerLayout({
350
- routeName: 'admin',
351
- routePath: '/admin',
352
- uid: ADMIN_LAYOUT_MODEL_UID,
353
- layoutModelClass: 'AdminLayoutModel',
354
- });
355
362
 
356
- this.app.pluginSettingsManager.addMenuItem({
357
- key: 'plugin-manager',
358
- title: this.app.i18n.t('Plugin manager'),
359
- icon: 'ApiOutlined',
360
- aclSnippet: 'pm',
361
- sort: -200,
362
- });
363
- this.app.pluginSettingsManager.addPageTabItem({
364
- menuKey: 'plugin-manager',
365
- key: 'index',
366
- title: this.app.i18n.t('Plugin manager'),
367
- componentLoader: () => import('../settings-center/plugin-manager'),
368
- aclSnippet: 'pm',
369
- sort: -200,
370
- });
371
- this.app.pluginSettingsManager.addMenuItem({
372
- key: 'system-settings',
373
- title: this.app.i18n.t('System settings'),
374
- icon: 'SettingOutlined',
375
- aclSnippet: 'pm.system-settings.system-settings',
376
- });
377
- this.app.pluginSettingsManager.addPageTabItem({
378
- menuKey: 'system-settings',
379
- key: 'index',
380
- title: this.app.i18n.t('System settings'),
381
- componentLoader: () => import('../settings-center/SystemSettingsPage'),
382
- aclSnippet: 'pm.system-settings.system-settings',
383
- });
384
- // Parent menu for security-related plugin settings (password policy, locked users, etc.). Registered here in the buildin plugin so any pro plugin can attach page tabs to `menuKey: 'security'` without each one re-registering the same parent.
385
- this.app.pluginSettingsManager.addMenuItem({
386
- key: 'security',
387
- title: this.app.i18n.t('Security'),
388
- icon: 'SafetyOutlined',
389
- aclSnippet: 'pm.security',
390
- });
363
+ registerDefaultSettings(this.app);
391
364
  }
392
365
 
393
366
  addRoutes() {
394
- this.router.add('root', {
395
- path: '/',
396
- element: <RootRedirect />,
397
- });
398
-
399
367
  this.router.add('not-found', {
400
368
  path: '*',
401
369
  Component: AppNotFound,
@@ -403,7 +371,7 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
403
371
 
404
372
  this.router.add('admin.settings', {
405
373
  path: '/admin/settings',
406
- componentLoader: () => import('../settings-center/AdminSettingsLayout'),
374
+ Component: SettingsDocumentRedirect,
407
375
  });
408
376
  this.router.add('admin.settings.route-empty', {
409
377
  path: '*',
@@ -419,4 +387,77 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
419
387
  }
420
388
  }
421
389
 
390
+ function registerDefaultSettings(app: Application) {
391
+ app.pluginSettingsManager.addMenuItem({
392
+ key: 'plugin-manager',
393
+ title: app.i18n.t('Plugin manager'),
394
+ icon: 'ApiOutlined',
395
+ aclSnippet: 'pm',
396
+ sort: -200,
397
+ });
398
+ app.pluginSettingsManager.addPageTabItem({
399
+ menuKey: 'plugin-manager',
400
+ key: 'index',
401
+ title: app.i18n.t('Plugin manager'),
402
+ componentLoader: () => import('../settings-center/plugin-manager'),
403
+ aclSnippet: 'pm',
404
+ sort: -200,
405
+ });
406
+ app.pluginSettingsManager.addMenuItem({
407
+ key: 'system-settings',
408
+ title: app.i18n.t('System settings'),
409
+ icon: 'SettingOutlined',
410
+ aclSnippet: 'pm.system-settings.system-settings',
411
+ });
412
+ app.pluginSettingsManager.addPageTabItem({
413
+ menuKey: 'system-settings',
414
+ key: 'index',
415
+ title: app.i18n.t('System settings'),
416
+ componentLoader: () => import('../settings-center/SystemSettingsPage'),
417
+ aclSnippet: 'pm.system-settings.system-settings',
418
+ });
419
+ // Parent menu for security-related plugin settings (password policy, locked users, etc.). Registered here in the buildin plugin so any pro plugin can attach page tabs to `menuKey: 'security'` without each one re-registering the same parent.
420
+ app.pluginSettingsManager.addMenuItem({
421
+ key: 'security',
422
+ title: app.i18n.t('Security'),
423
+ icon: 'SafetyOutlined',
424
+ aclSnippet: 'pm.security',
425
+ });
426
+ }
427
+
428
+ /**
429
+ * Internal built-in runtime for the standalone Client V2 settings entry.
430
+ * It intentionally shares the existing plugin lane and runtime providers but
431
+ * does not register the Admin Layout.
432
+ */
433
+ export class SettingsBuildInPlugin extends Plugin<any, Application> {
434
+ async afterAdd() {
435
+ await this.app.pm.add(PluginFlowEngine);
436
+ await this.app.pm.add(LocalePlugin as unknown as PluginClass, { name: 'builtin-locale' });
437
+ }
438
+
439
+ async load() {
440
+ this.app.use(CurrentUserProvider);
441
+ this.app.use(DataSourceBootstrapProvider);
442
+ this.app.flowEngine.registerModels({ AdminSettingsLayoutModel });
443
+
444
+ this.router.add('settings', {
445
+ path: '/settings',
446
+ authCheck: true,
447
+ componentLoader: () => import('../settings-center/AdminSettingsLayout'),
448
+ });
449
+ this.router.add('settingsDetails', {
450
+ path: '/settings',
451
+ authCheck: true,
452
+ Component: Outlet,
453
+ });
454
+ this.router.add('settings.route-empty', {
455
+ path: '*',
456
+ Component: Outlet,
457
+ });
458
+
459
+ registerDefaultSettings(this.app);
460
+ }
461
+ }
462
+
422
463
  export { NocoBaseBuildInPlugin as NocoBaseBuildInPluginV2 };
@@ -0,0 +1,31 @@
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 { Application, type ApplicationOptions } from '../index';
11
+ import { SettingsPluginSettingsManager } from './SettingsPluginSettingsManager';
12
+ import { SettingsRouterManager } from './SettingsRouterManager';
13
+ import { SettingsShell } from './SettingsShell';
14
+
15
+ export class SettingsApplication extends Application {
16
+ /** 设置中心的标记,插件通过 `isSettingsApp(app)` 读它,避免反向依赖这个类。 */
17
+ readonly isSettingsApp = true;
18
+
19
+ protected addCustomProviders() {
20
+ super.addCustomProviders();
21
+ this.use(SettingsShell);
22
+ }
23
+
24
+ protected createRouterManager(options: ApplicationOptions): SettingsRouterManager<this> {
25
+ return new SettingsRouterManager<this>(options.router, this);
26
+ }
27
+
28
+ protected createPluginSettingsManager(_options: ApplicationOptions): SettingsPluginSettingsManager<this> {
29
+ return new SettingsPluginSettingsManager<this>(this);
30
+ }
31
+ }