@nocobase/client-v2 2.1.0-beta.35 → 2.1.0-beta.37

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 (94) hide show
  1. package/es/BaseApplication.d.ts +2 -1
  2. package/es/components/PoweredBy.d.ts +18 -0
  3. package/es/components/SwitchLanguage.d.ts +11 -0
  4. package/es/components/form/DialogFormLayout.d.ts +51 -0
  5. package/es/components/form/DrawerFormLayout.d.ts +11 -11
  6. package/es/components/form/PasswordInput.d.ts +40 -0
  7. package/es/components/form/RemoteSelect.d.ts +79 -0
  8. package/es/components/form/filter/CollectionFilter.d.ts +41 -0
  9. package/es/components/form/filter/CollectionFilterItem.d.ts +41 -0
  10. package/es/components/form/filter/DateFilterDynamicComponent.d.ts +57 -0
  11. package/es/components/form/filter/FilterValueInput.d.ts +29 -0
  12. package/es/components/form/filter/index.d.ts +11 -0
  13. package/es/components/form/filter/useFilterActionProps.d.ts +96 -0
  14. package/es/components/form/index.d.ts +4 -0
  15. package/es/components/form/table/styles.d.ts +10 -0
  16. package/es/components/index.d.ts +2 -0
  17. package/es/data-source/ExtendCollectionsProvider.d.ts +24 -0
  18. package/es/data-source/index.d.ts +9 -0
  19. package/es/flow/components/filter/index.d.ts +2 -0
  20. package/es/flow/components/filter/useFilterOptions.d.ts +54 -0
  21. package/es/flow/models/base/ActionModelCore.d.ts +6 -0
  22. package/es/flow/models/base/GridModel.d.ts +2 -0
  23. package/es/flow/utils/dataScopeFormValueClear.d.ts +14 -0
  24. package/es/flow-compat/passwordUtils.d.ts +1 -1
  25. package/es/hooks/index.d.ts +2 -0
  26. package/es/hooks/useCurrentAppInfo.d.ts +9 -0
  27. package/es/index.d.ts +1 -0
  28. package/es/index.mjs +109 -92
  29. package/es/nocobase-buildin-plugin/index.d.ts +20 -2
  30. package/es/utils/appVersionHTML.d.ts +10 -0
  31. package/es/utils/index.d.ts +1 -0
  32. package/es/utils/remotePlugins.d.ts +4 -1
  33. package/lib/index.js +115 -98
  34. package/package.json +7 -7
  35. package/src/BaseApplication.tsx +16 -3
  36. package/src/PluginSettingsManager.ts +2 -1
  37. package/src/__tests__/PluginSettingsManager.test.ts +19 -0
  38. package/src/__tests__/PoweredBy.test.tsx +130 -0
  39. package/src/__tests__/app.test.tsx +40 -0
  40. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +39 -72
  41. package/src/__tests__/remotePlugins.test.ts +55 -0
  42. package/src/__tests__/useCurrentRoles.test.tsx +100 -0
  43. package/src/components/PoweredBy.tsx +71 -0
  44. package/src/components/README.md +397 -0
  45. package/src/components/README.zh-CN.md +394 -0
  46. package/src/components/SwitchLanguage.tsx +48 -0
  47. package/src/components/form/DialogFormLayout.tsx +87 -0
  48. package/src/components/form/DrawerFormLayout.tsx +13 -32
  49. package/src/components/form/PasswordInput.tsx +211 -0
  50. package/src/components/form/RemoteSelect.tsx +137 -0
  51. package/src/components/form/filter/CollectionFilter.tsx +101 -0
  52. package/src/components/form/filter/CollectionFilterItem.tsx +176 -0
  53. package/src/components/form/filter/DateFilterDynamicComponent.tsx +283 -0
  54. package/src/components/form/filter/FilterValueInput.tsx +198 -0
  55. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +205 -0
  56. package/src/components/form/filter/__tests__/DateFilterDynamicComponent.test.tsx +148 -0
  57. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +243 -0
  58. package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +146 -0
  59. package/src/components/form/filter/index.ts +13 -0
  60. package/src/components/form/filter/useFilterActionProps.ts +200 -0
  61. package/src/components/form/index.tsx +4 -0
  62. package/src/components/form/table/Table.tsx +2 -1
  63. package/src/components/form/table/styles.ts +19 -0
  64. package/src/components/index.ts +2 -0
  65. package/src/css-variable/CSSVariableProvider.tsx +10 -1
  66. package/src/data-source/ExtendCollectionsProvider.tsx +70 -0
  67. package/src/data-source/index.ts +10 -0
  68. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +96 -0
  69. package/src/flow/actions/dataScope.tsx +3 -0
  70. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -4
  71. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +7 -33
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/filter/index.ts +3 -0
  74. package/src/flow/components/filter/useFilterOptions.ts +80 -0
  75. package/src/flow/models/base/ActionModel.tsx +8 -7
  76. package/src/flow/models/base/ActionModelCore.tsx +15 -7
  77. package/src/flow/models/base/GridModel.tsx +93 -36
  78. package/src/flow/models/base/__tests__/GridModel.visibleLayout.test.ts +83 -11
  79. package/src/flow/models/blocks/details/DetailsItemModel.tsx +2 -0
  80. package/src/flow/models/blocks/form/FormItemModel.tsx +5 -3
  81. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +108 -0
  82. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +5 -0
  83. package/src/flow/models/blocks/table/TableColumnModel.tsx +2 -0
  84. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +2 -0
  85. package/src/flow/utils/dataScopeFormValueClear.ts +278 -0
  86. package/src/hooks/index.ts +2 -0
  87. package/src/hooks/useCurrentAppInfo.ts +36 -0
  88. package/src/index.ts +1 -0
  89. package/src/nocobase-buildin-plugin/index.tsx +66 -18
  90. package/src/nocobase-buildin-plugin/plugins/LocalePlugin.ts +1 -0
  91. package/src/utils/appVersionHTML.ts +28 -0
  92. package/src/utils/globalDeps.ts +2 -2
  93. package/src/utils/index.tsx +2 -0
  94. package/src/utils/remotePlugins.ts +12 -7
@@ -0,0 +1,278 @@
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 type { FlowContext, FlowModel } from '@nocobase/flow-engine';
11
+ import _ from 'lodash';
12
+ import { namePathToPathKey, pathKeyToNamePath } from '../models/blocks/form/value-runtime/path';
13
+ import { collectStaticDepsFromTemplateValue, type DepCollector } from '../models/blocks/form/value-runtime/deps';
14
+
15
+ type NamePath = Array<string | number>;
16
+
17
+ type DataScopeClearDeps = {
18
+ wildcard: boolean;
19
+ valuePaths: NamePath[];
20
+ };
21
+
22
+ type DataScopeClearBinding = {
23
+ signature: string;
24
+ dispose: () => void;
25
+ };
26
+
27
+ const FORM_VALUES_CHANGE_EVENT = 'formValuesChange';
28
+ const DATA_SCOPE_CLEAR_BINDINGS_KEY = '__formValueDrivenDataScopeClearBindings';
29
+
30
+ function dedupeNamePaths(paths: NamePath[]) {
31
+ const byKey = new Map<string, NamePath>();
32
+ for (const path of paths) {
33
+ if (!path?.length) continue;
34
+ byKey.set(namePathToPathKey(path), path);
35
+ }
36
+ return Array.from(byKey.values());
37
+ }
38
+
39
+ function isNamePathPrefix(prefix: NamePath, path: NamePath) {
40
+ if (prefix.length > path.length) return false;
41
+ return prefix.every((seg, index) => seg === path[index]);
42
+ }
43
+
44
+ function minimizeValueNamePaths(paths: NamePath[]) {
45
+ const deduped = dedupeNamePaths(paths);
46
+ return deduped.filter((path, index) => {
47
+ return !deduped.some((other, otherIndex) => otherIndex !== index && isNamePathPrefix(path, other));
48
+ });
49
+ }
50
+
51
+ function collectDataScopeClearDeps(params: any): DataScopeClearDeps {
52
+ const collector: DepCollector = { deps: new Set(), wildcard: false };
53
+ collectStaticDepsFromTemplateValue(params, collector);
54
+
55
+ const valuePaths: NamePath[] = [];
56
+ let wildcard = collector.wildcard;
57
+
58
+ for (const depKey of collector.deps) {
59
+ if (depKey === 'fv:*') {
60
+ wildcard = true;
61
+ continue;
62
+ }
63
+ if (!depKey.startsWith('fv:')) {
64
+ continue;
65
+ }
66
+ const inner = depKey.slice('fv:'.length);
67
+ if (!inner) {
68
+ wildcard = true;
69
+ continue;
70
+ }
71
+ valuePaths.push(pathKeyToNamePath(inner));
72
+ }
73
+
74
+ return {
75
+ wildcard,
76
+ valuePaths: minimizeValueNamePaths(valuePaths),
77
+ };
78
+ }
79
+
80
+ function hasDeps(deps: DataScopeClearDeps) {
81
+ return deps.wildcard || deps.valuePaths.length > 0;
82
+ }
83
+
84
+ function hasModelValue(model: any) {
85
+ const current = model?.props?.value;
86
+ if (current == null) return false;
87
+ if (Array.isArray(current)) return current.length > 0;
88
+ return true;
89
+ }
90
+
91
+ function getChangedPathsFromPayload(payload: any): NamePath[] {
92
+ const rawChangedPaths = Array.isArray(payload?.changedPaths) ? payload.changedPaths : [];
93
+ const out: NamePath[] = [];
94
+
95
+ for (const path of rawChangedPaths) {
96
+ if (Array.isArray(path)) {
97
+ const segs = path.filter((seg) => typeof seg === 'string' || typeof seg === 'number') as NamePath;
98
+ if (segs.length) out.push(segs);
99
+ continue;
100
+ }
101
+ if (typeof path === 'string' && path) {
102
+ out.push(pathKeyToNamePath(path));
103
+ }
104
+ }
105
+
106
+ if (out.length) {
107
+ return out;
108
+ }
109
+
110
+ const changedValues = payload?.changedValues;
111
+ if (changedValues && typeof changedValues === 'object' && !Array.isArray(changedValues)) {
112
+ for (const key of Object.keys(changedValues)) {
113
+ const namePath = pathKeyToNamePath(key);
114
+ if (namePath.length) out.push(namePath);
115
+ }
116
+ }
117
+
118
+ return out;
119
+ }
120
+
121
+ function depsMatchPayload(deps: DataScopeClearDeps, payload: any) {
122
+ if (!hasDeps(deps)) return false;
123
+ if (deps.wildcard) return true;
124
+
125
+ const changedPaths = getChangedPathsFromPayload(payload);
126
+ if (!changedPaths.length) return true;
127
+
128
+ for (const changedPath of changedPaths) {
129
+ for (const depPath of deps.valuePaths) {
130
+ if (isNamePathPrefix(depPath, changedPath) || isNamePathPrefix(changedPath, depPath)) {
131
+ return true;
132
+ }
133
+ }
134
+ }
135
+ return false;
136
+ }
137
+
138
+ function getDepsSignature(deps: DataScopeClearDeps, formBlock: any) {
139
+ const toKeys = (paths: NamePath[]) => paths.map((path) => namePathToPathKey(path)).sort();
140
+ return JSON.stringify({
141
+ formBlockUid: formBlock?.uid,
142
+ wildcard: deps.wildcard,
143
+ valuePaths: toKeys(deps.valuePaths),
144
+ });
145
+ }
146
+
147
+ function getBindings(model: any): Map<string, DataScopeClearBinding> {
148
+ return (model[DATA_SCOPE_CLEAR_BINDINGS_KEY] ||= new Map<string, DataScopeClearBinding>());
149
+ }
150
+
151
+ function isFormBlock(model: any) {
152
+ if (!model || typeof model !== 'object') return false;
153
+ if (!model.emitter || typeof model.emitter.on !== 'function' || typeof model.emitter.off !== 'function') return false;
154
+ return !!model.formValueRuntime || !!model.context?.form || typeof model.context?.setFormValues === 'function';
155
+ }
156
+
157
+ function findFormBlock(ctx: FlowContext): any | null {
158
+ const candidates: any[] = [];
159
+ const push = (model: any) => {
160
+ if (model && !candidates.includes(model)) candidates.push(model);
161
+ };
162
+
163
+ push((ctx.model as any)?.context?.blockModel);
164
+ push(ctx.model);
165
+
166
+ let cursor: any = (ctx.model as any)?.parent;
167
+ while (cursor) {
168
+ push(cursor);
169
+ cursor = cursor?.parent;
170
+ }
171
+
172
+ return candidates.find(isFormBlock) || null;
173
+ }
174
+
175
+ function clearModelValue(model: any) {
176
+ if (!hasModelValue(model)) return;
177
+ const next = Array.isArray(model?.props?.value) ? [] : null;
178
+ if (typeof model.change === 'function') {
179
+ model.change(next);
180
+ return;
181
+ }
182
+ if (typeof model?.props?.onChange === 'function') {
183
+ model.props.onChange(next);
184
+ }
185
+ }
186
+
187
+ function shouldBind(model: any) {
188
+ return !!model && typeof model === 'object' && typeof model?.props?.onChange === 'function';
189
+ }
190
+
191
+ function disposeBinding(model: any, key: string) {
192
+ const bindings = getBindings(model);
193
+ const existing = bindings.get(key);
194
+ if (existing) {
195
+ existing.dispose();
196
+ }
197
+ }
198
+
199
+ /**
200
+ * When a field's dataScope filter references other form values (e.g. `{{ ctx.formValues.school.id }}`),
201
+ * clear current field value after the dependency changes, so users don't keep an invalid stale selection.
202
+ */
203
+ export function ensureFormValueDrivenDataScopeClear(ctx: FlowContext, params: any) {
204
+ const model: any = ctx.model;
205
+ const flowKey = (ctx as any)?.flowKey;
206
+ if (!shouldBind(model) || !flowKey) return;
207
+
208
+ const stepKey = 'dataScope';
209
+ const bindingKey = `${flowKey}:${stepKey}`;
210
+ const deps = collectDataScopeClearDeps(params);
211
+ if (!hasDeps(deps)) {
212
+ disposeBinding(model, bindingKey);
213
+ return;
214
+ }
215
+
216
+ const formBlock = findFormBlock(ctx);
217
+ if (!formBlock) {
218
+ disposeBinding(model, bindingKey);
219
+ return;
220
+ }
221
+
222
+ const signature = getDepsSignature(deps, formBlock);
223
+ const bindings = getBindings(model);
224
+ const existing = bindings.get(bindingKey);
225
+ if (existing?.signature === signature) {
226
+ return;
227
+ }
228
+ if (existing) {
229
+ existing.dispose();
230
+ }
231
+
232
+ const engineEmitter = model?.flowEngine?.emitter || (ctx as any)?.engine?.emitter || model?.context?.engine?.emitter;
233
+
234
+ const binding: DataScopeClearBinding = {
235
+ signature,
236
+ dispose: () => {},
237
+ };
238
+
239
+ const dispose = () => {
240
+ formBlock.emitter?.off?.(FORM_VALUES_CHANGE_EVENT, listener);
241
+ engineEmitter?.off?.('model:unmounted', cleanupOnUnmount);
242
+ engineEmitter?.off?.('model:destroyed', cleanupOnDestroyed);
243
+ if (bindings.get(bindingKey) === binding) {
244
+ bindings.delete(bindingKey);
245
+ }
246
+ };
247
+
248
+ const listener = (payload: any) => {
249
+ if (model.disposed || formBlock.disposed) {
250
+ dispose();
251
+ return;
252
+ }
253
+
254
+ if (!hasModelValue(model) || !depsMatchPayload(deps, payload)) {
255
+ return;
256
+ }
257
+
258
+ clearModelValue(model);
259
+ };
260
+
261
+ const cleanupOnUnmount = ({ model: unmountedModel }: { model: FlowModel }) => {
262
+ if (unmountedModel === formBlock || (unmountedModel === model && model.disposed)) {
263
+ dispose();
264
+ }
265
+ };
266
+
267
+ const cleanupOnDestroyed = ({ model: destroyedModel }: { model: FlowModel }) => {
268
+ if (destroyedModel === model || destroyedModel === formBlock) {
269
+ dispose();
270
+ }
271
+ };
272
+
273
+ binding.dispose = dispose;
274
+ bindings.set(bindingKey, binding);
275
+ formBlock.emitter.on(FORM_VALUES_CHANGE_EVENT, listener);
276
+ engineEmitter?.on?.('model:unmounted', cleanupOnUnmount);
277
+ engineEmitter?.on?.('model:destroyed', cleanupOnDestroyed);
278
+ }
@@ -8,5 +8,7 @@
8
8
  */
9
9
 
10
10
  export * from './useApp';
11
+ export * from './useCurrentAppInfo';
11
12
  export * from './usePlugin';
12
13
  export * from './useRouter';
14
+ export { escapeHTML, getAppVersionHTML } from '../utils/appVersionHTML';
@@ -0,0 +1,36 @@
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 { useFlowEngineContext } from '@nocobase/flow-engine';
11
+ import { useEffect, useState } from 'react';
12
+
13
+ export function useCurrentAppInfo<TAppInfo extends Record<string, any> = Record<string, any>>() {
14
+ const ctx = useFlowEngineContext();
15
+ const [data, setData] = useState<TAppInfo>();
16
+
17
+ useEffect(() => {
18
+ let active = true;
19
+
20
+ Promise.resolve(ctx.appInfo)
21
+ .then((info) => {
22
+ if (active) {
23
+ setData((info || {}) as TAppInfo);
24
+ }
25
+ })
26
+ .catch((error) => {
27
+ console.error(error);
28
+ });
29
+
30
+ return () => {
31
+ active = false;
32
+ };
33
+ }, [ctx]);
34
+
35
+ return data;
36
+ }
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export * from './nocobase-buildin-plugin';
32
32
  export * from './collection-field-interface/CollectionFieldInterface';
33
33
  export * from './collection-field-interface/CollectionFieldInterfaceManager';
34
34
  export * from './collection-manager/interfaces';
35
+ export * from './data-source';
35
36
  export * from './flow';
36
37
  export { DEFAULT_DATA_SOURCE_KEY, isTitleField, isTitleFieldInterface } from './flow-compat';
37
38
  export { default as AntdAppProvider } from './theme/AntdAppProvider';
@@ -7,11 +7,12 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { createCollectionContextMeta } from '@nocobase/flow-engine';
11
- import React, { createContext, type FC, useEffect, useRef, useState } from 'react';
12
- import { Navigate, Outlet, useLocation } from 'react-router-dom';
10
+ import { createCollectionContextMeta, useFlowEngine } from '@nocobase/flow-engine';
11
+ import React, { createContext, type FC, useContext, useEffect, useMemo, useRef, useState } from 'react';
12
+ import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
13
+ import { useACLRoleContext } from '../acl';
13
14
  import type { Application } from '../Application';
14
- import { getCurrentV2RedirectPath, getDefaultV2AdminRedirectPath, redirectToV2Signin } from '../authRedirect';
15
+ import { getCurrentV2RedirectPath, getDefaultV2AdminRedirectPath } from '../authRedirect';
15
16
  import { AppNotFound } from '../components';
16
17
  import { PluginFlowEngine } from '../flow';
17
18
  import { AdminLayoutMenuItemModel, AdminLayoutModel } from '../flow/admin-shell/admin-layout';
@@ -20,13 +21,18 @@ import { Plugin } from '../Plugin';
20
21
  import { AdminSettingsLayoutModel } from '../settings-center';
21
22
  import { LocalePlugin } from './plugins/LocalePlugin';
22
23
 
23
- type CurrentUserState = {
24
+ export type CurrentUserState = {
24
25
  data?: {
25
26
  data?: any;
26
27
  };
27
28
  loading: boolean;
28
29
  };
29
30
 
31
+ export type CurrentRoleOption = {
32
+ name: string;
33
+ title: string;
34
+ };
35
+
30
36
  const AUTH_ROUTE_PREFIXES = ['/signin', '/signup', '/forgot-password', '/reset-password'];
31
37
 
32
38
  function removeBasename(pathname: string, basename?: string) {
@@ -50,9 +56,36 @@ function isAdminRuntimeRoute(pathname: string, basename?: string) {
50
56
  return normalizedPathname === '/admin' || normalizedPathname.startsWith('/admin/');
51
57
  }
52
58
 
53
- const CurrentUserContext = createContext<CurrentUserState | null>(null);
59
+ export const CurrentUserContext = createContext<CurrentUserState | null>(null);
54
60
  CurrentUserContext.displayName = 'CurrentUserContext';
55
61
 
62
+ export function useCurrentUserContext() {
63
+ return useContext(CurrentUserContext);
64
+ }
65
+
66
+ /**
67
+ * 返回当前用户在 v2 应用上下文中可选的角色列表,等价于 v1 `useCurrentRoles`:从 FlowEngine 全局上下文 `engine.context.user.roles` 派生(CurrentUserProvider 在 `/auth:check` 成功后通过 `defineProperty('user', { value })` 写入),按需追加匿名角色,并去掉合并角色 `__union__`。v2 中角色 title 可能含有 `{{t('...')}}` 模板,因此用 flowEngine.context.t 解析。
68
+ *
69
+ * 不读 React `CurrentUserContext`:FlowEngine 的 dialog/drawer/popover 内容通过 `ctx.viewer` 渲染到独立的 ElementsHolder,部分场景会脱离原 Provider 树;FlowEngine 全局上下文是同一份数据但不受 React 树位置影响。
70
+ */
71
+ export function useCurrentRoles(): CurrentRoleOption[] {
72
+ const { allowAnonymous } = useACLRoleContext();
73
+ const engine = useFlowEngine();
74
+ const rolesRaw = engine?.context?.user?.roles as Array<{ name: string; title?: string }> | undefined;
75
+
76
+ return useMemo(() => {
77
+ const compile = (value: string | undefined): string =>
78
+ value == null ? '' : engine?.context?.t ? engine.context.t(value) : value;
79
+ const roles: CurrentRoleOption[] = (rolesRaw || [])
80
+ .filter((role) => role?.name !== '__union__')
81
+ .map((role) => ({ name: role.name, title: compile(role.title) }));
82
+ if (allowAnonymous) {
83
+ roles.push({ name: 'anonymous', title: 'Anonymous' });
84
+ }
85
+ return roles;
86
+ }, [allowAnonymous, engine, rolesRaw]);
87
+ }
88
+
56
89
  const DataSourceBootstrapProvider: FC = ({ children }) => {
57
90
  const app = useApp();
58
91
  const location = useLocation();
@@ -115,6 +148,7 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
115
148
  const CurrentUserProvider: FC = ({ children }) => {
116
149
  const app = useApp();
117
150
  const location = useLocation();
151
+ const navigate = useNavigate();
118
152
  const [state, setState] = useState<CurrentUserState>({ loading: true });
119
153
  const pathnameRef = useRef(location.pathname);
120
154
  pathnameRef.current = location.pathname;
@@ -143,8 +177,18 @@ const CurrentUserProvider: FC = ({ children }) => {
143
177
  });
144
178
 
145
179
  const user = res?.data?.data;
180
+ // 服务端通过 `{ code: 302, redirect }` 通知客户端先去某个中间页(例如 2FA 验证页)。这类响应没有 user.id,但也不能视为未登录——否则会和处理 302 的全局响应拦截器 (例如 plugin-two-factor-authentication 注册的那一个)竞态,而 `window.location.replace` 会覆盖更早发出的 `window.location.href`,把用户错误地弹回登录页。让响应拦截器接管跳转。
181
+ if (user?.code === 302) {
182
+ if (mounted) {
183
+ setState({ loading: false });
184
+ }
185
+ return;
186
+ }
146
187
  if (user?.id == null) {
147
- redirectToV2Signin(app, getCurrentV2RedirectPath(app, locationRef.current), { replace: true });
188
+ // 用 react-router navigate (虚拟跳转)而不是 location.replace, 这样如果有其他响应拦截器已经发起了 window.location.href 整页跳转(例如 2FA 插件接收到服务端 302 重定向), 真实跳转可以胜出 navigate, 不会被这里的 signin 重定向覆盖。
189
+ navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
190
+ replace: true,
191
+ });
148
192
  return;
149
193
  }
150
194
 
@@ -169,7 +213,9 @@ const CurrentUserProvider: FC = ({ children }) => {
169
213
  } catch (error: any) {
170
214
  const isAuthError = error?.response?.status === 401 || error?.status === 401;
171
215
  if (isAuthError) {
172
- redirectToV2Signin(app, getCurrentV2RedirectPath(app, locationRef.current), { replace: true });
216
+ navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
217
+ replace: true,
218
+ });
173
219
  return;
174
220
  }
175
221
  if (mounted) {
@@ -184,7 +230,7 @@ const CurrentUserProvider: FC = ({ children }) => {
184
230
  return () => {
185
231
  mounted = false;
186
232
  };
187
- }, [app]);
233
+ }, [app, navigate]);
188
234
 
189
235
  if (state.loading) {
190
236
  return app.renderComponent('AppSpin');
@@ -196,15 +242,11 @@ const CurrentUserProvider: FC = ({ children }) => {
196
242
  const RootRedirect: FC = () => {
197
243
  const app = useApp();
198
244
  const hasToken = !!app?.apiClient?.auth?.token;
199
-
200
- useEffect(() => {
201
- if (!hasToken) {
202
- redirectToV2Signin(app, getDefaultV2AdminRedirectPath(app), { replace: true });
203
- }
204
- }, [app, hasToken]);
245
+ const targetPath = getDefaultV2AdminRedirectPath(app);
205
246
 
206
247
  if (!hasToken) {
207
- return app.renderComponent('AppSpin');
248
+ // 用 react-router <Navigate /> 而非 location.replace, 避免覆盖同时段其它响应拦截器触发的 window.location.href (例如 2FA 接收到服务端 302 时设置的整页跳转)
249
+ return <Navigate replace to={`/signin?redirect=${encodeURIComponent(targetPath)}`} />;
208
250
  }
209
251
 
210
252
  return <Navigate replace to="/admin" />;
@@ -213,8 +255,7 @@ const RootRedirect: FC = () => {
213
255
  /**
214
256
  * client-v2 使用的内建插件集合。
215
257
  *
216
- * 只迁移当前 v2 运行时仍然需要的部分,显式跳过 schemaInitializerManager
217
- * 以及用户标注暂不迁移的旧插件注册逻辑。
258
+ * 只迁移当前 v2 运行时仍然需要的部分,显式跳过 schemaInitializerManager 以及用户标注暂不迁移的旧插件注册逻辑。
218
259
  */
219
260
  export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
220
261
  async afterAdd() {
@@ -263,6 +304,13 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
263
304
  aclSnippet: 'pm.system-settings.system-settings',
264
305
  sort: -100,
265
306
  });
307
+ // 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.
308
+ this.app.pluginSettingsManager.addMenuItem({
309
+ key: 'security',
310
+ title: this.app.i18n.t('Security'),
311
+ icon: 'SafetyOutlined',
312
+ aclSnippet: 'pm.security',
313
+ });
266
314
  }
267
315
 
268
316
  addRoutes() {
@@ -39,6 +39,7 @@ export class LocalePlugin extends Plugin {
39
39
 
40
40
  if (data.lang) {
41
41
  api.auth.setLocale(data.lang);
42
+ this.app.setDocumentLanguage(data.lang);
42
43
  this.app.i18n.changeLanguage(data.lang);
43
44
  }
44
45
 
@@ -0,0 +1,28 @@
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
+ const htmlEscapeMap: Record<string, string> = {
11
+ '&': '&amp;',
12
+ '<': '&lt;',
13
+ '>': '&gt;',
14
+ '"': '&quot;',
15
+ "'": '&#39;',
16
+ };
17
+
18
+ export function escapeHTML(value: string) {
19
+ return value.replace(/[&<>"']/g, (matched) => htmlEscapeMap[matched]);
20
+ }
21
+
22
+ export function getAppVersionHTML(version: unknown) {
23
+ if (version === null || typeof version === 'undefined' || version === '') {
24
+ return '';
25
+ }
26
+
27
+ return `<span class="nb-app-version">v${escapeHTML(String(version))}</span>`;
28
+ }
@@ -92,8 +92,8 @@ export function defineGlobalDeps(requirejs: RequireJS) {
92
92
  defineGlobalDep(requirejs, '@nocobase/evaluators', nocobaseEvaluators);
93
93
  defineGlobalDep(requirejs, '@nocobase/evaluators/client', nocobaseEvaluators);
94
94
 
95
- requirejs.define('@dnd-kit/core', () => dndKitCore);
96
- requirejs.define('@dnd-kit/sortable', () => dndKitSortable);
95
+ defineGlobalDep(requirejs, '@dnd-kit/core', dndKitCore);
96
+ defineGlobalDep(requirejs, '@dnd-kit/sortable', dndKitSortable);
97
97
 
98
98
  // utils
99
99
  defineGlobalDep(requirejs, 'ahooks', ahooks);
@@ -10,6 +10,8 @@
10
10
  import React, { ComponentType, FC } from 'react';
11
11
  import { BlankComponent } from '../components';
12
12
 
13
+ export * from './appVersionHTML';
14
+
13
15
  export function normalizeContainer(container: Element | ShadowRoot | string): Element | null {
14
16
  if (!container) {
15
17
  console.warn(`Failed to mount app: mount target should not be null or undefined.`);
@@ -31,9 +31,11 @@ function defineAppDevPluginModule(moduleId: string, pluginModule: RemotePluginMo
31
31
  /**
32
32
  * @internal
33
33
  */
34
- export function defineDevPlugins(plugins: Record<string, PluginClass>) {
35
- Object.entries(plugins).forEach(([packageName, plugin]) => {
36
- window.define(getClientV2ModuleId(packageName), () => plugin);
34
+ export function defineDevPlugins(plugins: Record<string, RemotePluginModule>) {
35
+ Object.entries(plugins).forEach(([packageName, pluginModule]) => {
36
+ const moduleId = getClientV2ModuleId(packageName);
37
+ window.define(moduleId, () => pluginModule);
38
+ defineAppDevPluginModule(moduleId, pluginModule);
37
39
  });
38
40
  }
39
41
 
@@ -188,15 +190,18 @@ export async function getPlugins(options: GetPluginsOption): Promise<Array<[stri
188
190
  const res: Array<[string, PluginClass]> = [];
189
191
 
190
192
  const resolveDevPlugins: Record<string, PluginClass> = {};
193
+ const resolveDevPluginModules: Record<string, RemotePluginModule> = {};
191
194
  if (devDynamicImport) {
192
195
  for await (const plugin of pluginData) {
193
- const pluginModule = await devDynamicImport(plugin.packageName);
196
+ const pluginModule: RemotePluginModule | null = await devDynamicImport(plugin.packageName);
194
197
  if (pluginModule) {
195
- res.push([plugin.name, pluginModule.default]);
196
- resolveDevPlugins[plugin.packageName] = pluginModule.default;
198
+ const pluginClass = getPluginClass(pluginModule);
199
+ res.push([plugin.name, pluginClass]);
200
+ resolveDevPlugins[plugin.packageName] = pluginClass;
201
+ resolveDevPluginModules[plugin.packageName] = pluginModule;
197
202
  }
198
203
  }
199
- defineDevPlugins(resolveDevPlugins);
204
+ defineDevPlugins(resolveDevPluginModules);
200
205
  }
201
206
 
202
207
  const remotePlugins = pluginData.filter((item) => !resolveDevPlugins[item.packageName]);