@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.
- package/es/BaseApplication.d.ts +1 -1
- package/es/components/PoweredBy.d.ts +18 -0
- package/es/components/SwitchLanguage.d.ts +11 -0
- package/es/components/form/DialogFormLayout.d.ts +75 -0
- package/es/components/form/DrawerFormLayout.d.ts +11 -11
- package/es/components/form/PasswordInput.d.ts +40 -0
- package/es/components/form/RemoteSelect.d.ts +79 -0
- package/es/components/form/index.d.ts +3 -0
- package/es/components/form/table/styles.d.ts +10 -0
- package/es/components/index.d.ts +2 -0
- package/es/flow/models/base/ActionModelCore.d.ts +6 -0
- package/es/flow/models/base/GridModel.d.ts +2 -0
- package/es/flow/utils/dataScopeFormValueClear.d.ts +14 -0
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/hooks/index.d.ts +2 -0
- package/es/hooks/useCurrentAppInfo.d.ts +9 -0
- package/es/index.mjs +102 -90
- package/es/nocobase-buildin-plugin/index.d.ts +25 -0
- package/es/utils/appVersionHTML.d.ts +10 -0
- package/es/utils/index.d.ts +1 -0
- package/es/utils/remotePlugins.d.ts +4 -1
- package/lib/index.js +108 -96
- package/package.json +7 -7
- package/src/BaseApplication.tsx +3 -3
- package/src/PluginSettingsManager.ts +2 -1
- package/src/__tests__/PluginSettingsManager.test.ts +19 -0
- package/src/__tests__/PoweredBy.test.tsx +130 -0
- package/src/__tests__/app.test.tsx +31 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +39 -72
- package/src/__tests__/remotePlugins.test.ts +55 -0
- package/src/__tests__/useCurrentRoles.test.tsx +100 -0
- package/src/components/PoweredBy.tsx +71 -0
- package/src/components/README.md +314 -0
- package/src/components/README.zh-CN.md +312 -0
- package/src/components/SwitchLanguage.tsx +48 -0
- package/src/components/form/DialogFormLayout.tsx +111 -0
- package/src/components/form/DrawerFormLayout.tsx +13 -32
- package/src/components/form/PasswordInput.tsx +211 -0
- package/src/components/form/RemoteSelect.tsx +137 -0
- package/src/components/form/index.tsx +3 -0
- package/src/components/form/table/Table.tsx +2 -1
- package/src/components/form/table/styles.ts +19 -0
- package/src/components/index.ts +2 -0
- package/src/css-variable/CSSVariableProvider.tsx +10 -1
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +96 -0
- package/src/flow/actions/dataScope.tsx +3 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -4
- package/src/flow/admin-shell/admin-layout/HelpLite.tsx +7 -33
- package/src/flow/components/BlockItemCard.tsx +2 -2
- package/src/flow/models/base/ActionModel.tsx +8 -7
- package/src/flow/models/base/ActionModelCore.tsx +15 -7
- package/src/flow/models/base/GridModel.tsx +93 -36
- package/src/flow/models/base/__tests__/GridModel.visibleLayout.test.ts +83 -11
- package/src/flow/models/blocks/details/DetailsItemModel.tsx +2 -0
- package/src/flow/models/blocks/form/FormItemModel.tsx +5 -3
- package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +108 -0
- package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +5 -0
- package/src/flow/models/blocks/table/TableColumnModel.tsx +2 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +2 -0
- package/src/flow/utils/dataScopeFormValueClear.ts +278 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useCurrentAppInfo.ts +36 -0
- package/src/nocobase-buildin-plugin/index.tsx +70 -16
- package/src/utils/appVersionHTML.ts +28 -0
- package/src/utils/globalDeps.ts +2 -2
- package/src/utils/index.tsx +2 -0
- 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
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
'&': '&',
|
|
12
|
+
'<': '<',
|
|
13
|
+
'>': '>',
|
|
14
|
+
'"': '"',
|
|
15
|
+
"'": ''',
|
|
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
|
+
}
|
package/src/utils/globalDeps.ts
CHANGED
|
@@ -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
|
|
96
|
-
requirejs
|
|
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);
|
package/src/utils/index.tsx
CHANGED
|
@@ -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,
|
|
35
|
-
Object.entries(plugins).forEach(([packageName,
|
|
36
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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(
|
|
204
|
+
defineDevPlugins(resolveDevPluginModules);
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
const remotePlugins = pluginData.filter((item) => !resolveDevPlugins[item.packageName]);
|