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

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 (67) hide show
  1. package/es/BaseApplication.d.ts +1 -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 +75 -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/index.d.ts +3 -0
  9. package/es/components/form/table/styles.d.ts +10 -0
  10. package/es/components/index.d.ts +2 -0
  11. package/es/flow/models/base/ActionModelCore.d.ts +6 -0
  12. package/es/flow/models/base/GridModel.d.ts +2 -0
  13. package/es/flow/utils/dataScopeFormValueClear.d.ts +14 -0
  14. package/es/flow-compat/passwordUtils.d.ts +1 -1
  15. package/es/hooks/index.d.ts +2 -0
  16. package/es/hooks/useCurrentAppInfo.d.ts +9 -0
  17. package/es/index.mjs +102 -90
  18. package/es/nocobase-buildin-plugin/index.d.ts +25 -0
  19. package/es/utils/appVersionHTML.d.ts +10 -0
  20. package/es/utils/index.d.ts +1 -0
  21. package/es/utils/remotePlugins.d.ts +4 -1
  22. package/lib/index.js +108 -96
  23. package/package.json +7 -7
  24. package/src/BaseApplication.tsx +3 -3
  25. package/src/PluginSettingsManager.ts +2 -1
  26. package/src/__tests__/PluginSettingsManager.test.ts +19 -0
  27. package/src/__tests__/PoweredBy.test.tsx +130 -0
  28. package/src/__tests__/app.test.tsx +31 -0
  29. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +39 -72
  30. package/src/__tests__/remotePlugins.test.ts +55 -0
  31. package/src/__tests__/useCurrentRoles.test.tsx +100 -0
  32. package/src/components/PoweredBy.tsx +71 -0
  33. package/src/components/README.md +314 -0
  34. package/src/components/README.zh-CN.md +312 -0
  35. package/src/components/SwitchLanguage.tsx +48 -0
  36. package/src/components/form/DialogFormLayout.tsx +111 -0
  37. package/src/components/form/DrawerFormLayout.tsx +13 -32
  38. package/src/components/form/PasswordInput.tsx +211 -0
  39. package/src/components/form/RemoteSelect.tsx +137 -0
  40. package/src/components/form/index.tsx +3 -0
  41. package/src/components/form/table/Table.tsx +2 -1
  42. package/src/components/form/table/styles.ts +19 -0
  43. package/src/components/index.ts +2 -0
  44. package/src/css-variable/CSSVariableProvider.tsx +10 -1
  45. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +96 -0
  46. package/src/flow/actions/dataScope.tsx +3 -0
  47. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -4
  48. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +7 -33
  49. package/src/flow/components/BlockItemCard.tsx +2 -2
  50. package/src/flow/models/base/ActionModel.tsx +8 -7
  51. package/src/flow/models/base/ActionModelCore.tsx +15 -7
  52. package/src/flow/models/base/GridModel.tsx +93 -36
  53. package/src/flow/models/base/__tests__/GridModel.visibleLayout.test.ts +83 -11
  54. package/src/flow/models/blocks/details/DetailsItemModel.tsx +2 -0
  55. package/src/flow/models/blocks/form/FormItemModel.tsx +5 -3
  56. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +108 -0
  57. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +5 -0
  58. package/src/flow/models/blocks/table/TableColumnModel.tsx +2 -0
  59. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +2 -0
  60. package/src/flow/utils/dataScopeFormValueClear.ts +278 -0
  61. package/src/hooks/index.ts +2 -0
  62. package/src/hooks/useCurrentAppInfo.ts +36 -0
  63. package/src/nocobase-buildin-plugin/index.tsx +70 -16
  64. package/src/utils/appVersionHTML.ts +28 -0
  65. package/src/utils/globalDeps.ts +2 -2
  66. package/src/utils/index.tsx +2 -0
  67. 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
+ }
@@ -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,42 @@ 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`:
68
+ * 从 FlowEngine 全局上下文 `engine.context.user.roles` 派生(CurrentUserProvider 在
69
+ * `/auth:check` 成功后通过 `defineProperty('user', { value })` 写入),按需追加匿名角色,
70
+ * 并去掉合并角色 `__union__`。v2 中角色 title 可能含有 `{{t('...')}}` 模板,因此用
71
+ * flowEngine.context.t 解析。
72
+ *
73
+ * 不读 React `CurrentUserContext`:FlowEngine 的 dialog/drawer/popover 内容通过 `ctx.viewer`
74
+ * 渲染到独立的 ElementsHolder,部分场景会脱离原 Provider 树;FlowEngine 全局上下文是同一份
75
+ * 数据但不受 React 树位置影响。
76
+ */
77
+ export function useCurrentRoles(): CurrentRoleOption[] {
78
+ const { allowAnonymous } = useACLRoleContext();
79
+ const engine = useFlowEngine();
80
+ const rolesRaw = engine?.context?.user?.roles as Array<{ name: string; title?: string }> | undefined;
81
+
82
+ return useMemo(() => {
83
+ const compile = (value: string | undefined): string =>
84
+ value == null ? '' : engine?.context?.t ? engine.context.t(value) : value;
85
+ const roles: CurrentRoleOption[] = (rolesRaw || [])
86
+ .filter((role) => role?.name !== '__union__')
87
+ .map((role) => ({ name: role.name, title: compile(role.title) }));
88
+ if (allowAnonymous) {
89
+ roles.push({ name: 'anonymous', title: 'Anonymous' });
90
+ }
91
+ return roles;
92
+ }, [allowAnonymous, engine, rolesRaw]);
93
+ }
94
+
56
95
  const DataSourceBootstrapProvider: FC = ({ children }) => {
57
96
  const app = useApp();
58
97
  const location = useLocation();
@@ -115,6 +154,7 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
115
154
  const CurrentUserProvider: FC = ({ children }) => {
116
155
  const app = useApp();
117
156
  const location = useLocation();
157
+ const navigate = useNavigate();
118
158
  const [state, setState] = useState<CurrentUserState>({ loading: true });
119
159
  const pathnameRef = useRef(location.pathname);
120
160
  pathnameRef.current = location.pathname;
@@ -143,8 +183,23 @@ const CurrentUserProvider: FC = ({ children }) => {
143
183
  });
144
184
 
145
185
  const user = res?.data?.data;
186
+ // 服务端通过 `{ code: 302, redirect }` 通知客户端先去某个中间页(例如 2FA 验证页)。
187
+ // 这类响应没有 user.id,但也不能视为未登录——否则会和处理 302 的全局响应拦截器
188
+ // (例如 plugin-two-factor-authentication 注册的那一个)竞态,而 `window.location.replace`
189
+ // 会覆盖更早发出的 `window.location.href`,把用户错误地弹回登录页。让响应拦截器接管跳转。
190
+ if (user?.code === 302) {
191
+ if (mounted) {
192
+ setState({ loading: false });
193
+ }
194
+ return;
195
+ }
146
196
  if (user?.id == null) {
147
- redirectToV2Signin(app, getCurrentV2RedirectPath(app, locationRef.current), { replace: true });
197
+ // 用 react-router navigate (虚拟跳转)而不是 location.replace, 这样如果有其他响应拦截器
198
+ // 已经发起了 window.location.href 整页跳转(例如 2FA 插件接收到服务端 302 重定向),
199
+ // 真实跳转可以胜出 navigate, 不会被这里的 signin 重定向覆盖。
200
+ navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
201
+ replace: true,
202
+ });
148
203
  return;
149
204
  }
150
205
 
@@ -169,7 +224,9 @@ const CurrentUserProvider: FC = ({ children }) => {
169
224
  } catch (error: any) {
170
225
  const isAuthError = error?.response?.status === 401 || error?.status === 401;
171
226
  if (isAuthError) {
172
- redirectToV2Signin(app, getCurrentV2RedirectPath(app, locationRef.current), { replace: true });
227
+ navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
228
+ replace: true,
229
+ });
173
230
  return;
174
231
  }
175
232
  if (mounted) {
@@ -184,7 +241,7 @@ const CurrentUserProvider: FC = ({ children }) => {
184
241
  return () => {
185
242
  mounted = false;
186
243
  };
187
- }, [app]);
244
+ }, [app, navigate]);
188
245
 
189
246
  if (state.loading) {
190
247
  return app.renderComponent('AppSpin');
@@ -196,15 +253,12 @@ const CurrentUserProvider: FC = ({ children }) => {
196
253
  const RootRedirect: FC = () => {
197
254
  const app = useApp();
198
255
  const hasToken = !!app?.apiClient?.auth?.token;
199
-
200
- useEffect(() => {
201
- if (!hasToken) {
202
- redirectToV2Signin(app, getDefaultV2AdminRedirectPath(app), { replace: true });
203
- }
204
- }, [app, hasToken]);
256
+ const targetPath = getDefaultV2AdminRedirectPath(app);
205
257
 
206
258
  if (!hasToken) {
207
- return app.renderComponent('AppSpin');
259
+ // 用 react-router <Navigate /> 而非 location.replace, 避免覆盖同时段其它响应拦截器
260
+ // 触发的 window.location.href (例如 2FA 接收到服务端 302 时设置的整页跳转)。
261
+ return <Navigate replace to={`/signin?redirect=${encodeURIComponent(targetPath)}`} />;
208
262
  }
209
263
 
210
264
  return <Navigate replace to="/admin" />;
@@ -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]);