@nocobase/client-v2 2.2.0-beta.1 → 2.2.0-beta.3

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.
@@ -0,0 +1,170 @@
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 } from '@nocobase/flow-engine';
11
+ import { namePathToPathKey, parsePathString, pathKeyToNamePath } from '../models/blocks/form/value-runtime/path';
12
+
13
+ export type NamePath = Array<string | number>;
14
+
15
+ export type FieldIndexEntry = {
16
+ name: string;
17
+ index: number;
18
+ };
19
+
20
+ export function isSameNamePath(a: NamePath, b: NamePath) {
21
+ return a.length === b.length && a.every((seg, index) => seg === b[index]);
22
+ }
23
+
24
+ export function isNamePathPrefix(prefix: NamePath, path: NamePath) {
25
+ if (prefix.length > path.length) return false;
26
+ return prefix.every((seg, index) => seg === path[index]);
27
+ }
28
+
29
+ export function dedupeNamePaths(paths: NamePath[]) {
30
+ const byKey = new Map<string, NamePath>();
31
+ for (const path of paths) {
32
+ if (!path?.length) continue;
33
+ byKey.set(namePathToPathKey(path), path);
34
+ }
35
+ return Array.from(byKey.values());
36
+ }
37
+
38
+ export function minimizeNamePaths(paths: NamePath[]) {
39
+ const deduped = dedupeNamePaths(paths);
40
+ return deduped.filter((path, index) => {
41
+ return !deduped.some((other, otherIndex) => otherIndex !== index && isNamePathPrefix(path, other));
42
+ });
43
+ }
44
+
45
+ export function parseFieldIndexEntries(fieldIndex: unknown): FieldIndexEntry[] {
46
+ const arr = Array.isArray(fieldIndex) ? fieldIndex : [];
47
+ const entries: FieldIndexEntry[] = [];
48
+ for (const item of arr) {
49
+ if (typeof item !== 'string') continue;
50
+ const [name, indexStr] = item.split(':');
51
+ const index = Number(indexStr);
52
+ if (!name || Number.isNaN(index)) continue;
53
+ entries.push({ name, index });
54
+ }
55
+ return entries;
56
+ }
57
+
58
+ export function getFieldIndexEntriesFromContext(ctx: FlowContext | { model?: unknown; fieldIndex?: unknown }) {
59
+ const model = ctx?.model as { context?: { fieldIndex?: unknown } } | undefined;
60
+ return parseFieldIndexEntries(model?.context?.fieldIndex ?? (ctx as { fieldIndex?: unknown })?.fieldIndex);
61
+ }
62
+
63
+ export function buildItemRowPath(entries: FieldIndexEntry[], parentDepth: number): NamePath | null {
64
+ const targetIndex = entries.length - 1 - parentDepth;
65
+ if (targetIndex < 0) return null;
66
+
67
+ const out: NamePath = [];
68
+ for (let index = 0; index <= targetIndex; index++) {
69
+ out.push(entries[index].name, entries[index].index);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ export function buildItemListRootPath(entries: FieldIndexEntry[], parentDepth: number): NamePath | null {
75
+ const rowPath = buildItemRowPath(entries, parentDepth);
76
+ if (!rowPath?.length) return null;
77
+ return rowPath.slice(0, -1);
78
+ }
79
+
80
+ export function parseDependencyPath(subPath: string): NamePath {
81
+ return parsePathString(subPath).filter((seg) => typeof seg !== 'object') as NamePath;
82
+ }
83
+
84
+ export function parsePathKey(pathKey: string): NamePath {
85
+ return pathKeyToNamePath(pathKey) as NamePath;
86
+ }
87
+
88
+ export function getChangedPathsFromPayload(
89
+ payload: unknown,
90
+ options: { includeArrayChangedValues?: boolean } = {},
91
+ ): NamePath[] {
92
+ const payloadObj = payload as
93
+ | {
94
+ changedPaths?: unknown;
95
+ changedValues?: unknown;
96
+ }
97
+ | undefined;
98
+ const rawChangedPaths = Array.isArray(payloadObj?.changedPaths) ? payloadObj.changedPaths : [];
99
+ const out: NamePath[] = [];
100
+
101
+ for (const path of rawChangedPaths) {
102
+ if (Array.isArray(path)) {
103
+ if (path.length === 1 && typeof path[0] === 'string') {
104
+ const namePath = pathKeyToNamePath(path[0]);
105
+ if (namePath.length) out.push(namePath as NamePath);
106
+ continue;
107
+ }
108
+ const segs = path.filter((seg) => typeof seg === 'string' || typeof seg === 'number') as NamePath;
109
+ if (segs.length) out.push(segs);
110
+ continue;
111
+ }
112
+ if (typeof path === 'string' && path) {
113
+ out.push(pathKeyToNamePath(path) as NamePath);
114
+ }
115
+ }
116
+
117
+ if (out.length) {
118
+ return out;
119
+ }
120
+
121
+ const changedValues = payloadObj?.changedValues;
122
+ const canReadChangedValues =
123
+ changedValues &&
124
+ typeof changedValues === 'object' &&
125
+ (options.includeArrayChangedValues || !Array.isArray(changedValues));
126
+ if (canReadChangedValues) {
127
+ for (const key of Object.keys(changedValues)) {
128
+ const namePath = pathKeyToNamePath(key);
129
+ if (namePath.length) out.push(namePath as NamePath);
130
+ }
131
+ }
132
+
133
+ return out;
134
+ }
135
+
136
+ export function isFormValueChangeSource(model: unknown) {
137
+ const candidate = model as
138
+ | {
139
+ emitter?: { on?: unknown; off?: unknown };
140
+ formValueRuntime?: unknown;
141
+ context?: { form?: unknown; setFormValues?: unknown };
142
+ }
143
+ | undefined;
144
+ if (!candidate || typeof candidate !== 'object') return false;
145
+ if (!candidate.emitter || typeof candidate.emitter.on !== 'function' || typeof candidate.emitter.off !== 'function') {
146
+ return false;
147
+ }
148
+ return (
149
+ !!candidate.formValueRuntime || !!candidate.context?.form || typeof candidate.context?.setFormValues === 'function'
150
+ );
151
+ }
152
+
153
+ export function findFormValueChangeSource(ctx: FlowContext): unknown | null {
154
+ const candidates: unknown[] = [];
155
+ const push = (model: unknown) => {
156
+ if (model && !candidates.includes(model)) candidates.push(model);
157
+ };
158
+
159
+ const model = ctx.model as { context?: { blockModel?: unknown }; parent?: unknown } | undefined;
160
+ push(model?.context?.blockModel);
161
+ push(ctx.model);
162
+
163
+ let cursor = model?.parent as { parent?: unknown } | undefined;
164
+ while (cursor) {
165
+ push(cursor);
166
+ cursor = cursor?.parent as { parent?: unknown } | undefined;
167
+ }
168
+
169
+ return candidates.find(isFormValueChangeSource) || null;
170
+ }
@@ -28,6 +28,13 @@ export type CurrentUserState = {
28
28
  loading: boolean;
29
29
  };
30
30
 
31
+ type CurrentUserAuthStatus = 'unknown' | 'authenticated' | 'unauthenticated' | 'redirecting';
32
+
33
+ type CurrentUserInternalState = CurrentUserState & {
34
+ authStatus: CurrentUserAuthStatus;
35
+ error?: Error | null;
36
+ };
37
+
31
38
  export type CurrentRoleOption = {
32
39
  name: string;
33
40
  title: string;
@@ -151,7 +158,7 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
151
158
  }
152
159
  };
153
160
 
154
- void run();
161
+ run();
155
162
 
156
163
  return () => {
157
164
  mounted = false;
@@ -173,21 +180,33 @@ const CurrentUserProvider: FC = ({ children }) => {
173
180
  const app = useApp<Application>();
174
181
  const location = useLocation();
175
182
  const navigate = useNavigate();
176
- const [state, setState] = useState<CurrentUserState>({ loading: true });
183
+ const [state, setState] = useState<CurrentUserInternalState>({ loading: true, authStatus: 'unknown' });
177
184
  const locationRef = useRef(location);
178
185
  locationRef.current = location;
179
186
  const authCheckRouteState = getCurrentUserAuthCheckRouteState(app, location.pathname);
187
+ const shouldBlockAuthRequiredRoute = authCheckRouteState === 'required' && state.authStatus !== 'authenticated';
188
+ const contextValue = useMemo<CurrentUserState>(
189
+ () => ({
190
+ data: state.data,
191
+ loading: state.loading,
192
+ }),
193
+ [state.data, state.loading],
194
+ );
180
195
 
181
196
  useEffect(() => {
182
197
  let mounted = true;
183
198
 
184
199
  if (authCheckRouteState !== 'required') {
185
200
  // 认证页等免鉴权路由不应再执行 `/auth:check`,否则未登录时会重复鉴权并触发重定向抖动。
186
- setState({ loading: false });
201
+ setState({ loading: false, authStatus: 'unauthenticated', error: null });
187
202
  return;
188
203
  }
189
204
 
190
- setState((previous) => (previous.loading ? previous : { ...previous, loading: true }));
205
+ setState((previous) =>
206
+ previous.authStatus === 'authenticated'
207
+ ? previous
208
+ : { data: previous.data, loading: true, authStatus: 'unknown', error: null },
209
+ );
191
210
 
192
211
  const run = async () => {
193
212
  try {
@@ -204,11 +223,12 @@ const CurrentUserProvider: FC = ({ children }) => {
204
223
  const user = res?.data?.data;
205
224
  // 服务端通过 `{ code: 302, redirect }` 通知客户端先去某个中间页(例如 2FA 验证页)。这类响应没有 user.id,但也不能视为未登录——否则会和处理 302 的全局响应拦截器 (例如 plugin-two-factor-authentication 注册的那一个)竞态,而 `window.location.replace` 会覆盖更早发出的 `window.location.href`,把用户错误地弹回登录页。让响应拦截器接管跳转。
206
225
  if (user?.code === 302) {
207
- setState({ loading: false });
226
+ setState({ loading: false, authStatus: 'redirecting', error: null });
208
227
  return;
209
228
  }
210
229
  if (user?.id == null) {
211
230
  // 用 react-router navigate (虚拟跳转)而不是 location.replace, 这样如果有其他响应拦截器已经发起了 window.location.href 整页跳转(例如 2FA 插件接收到服务端 302 重定向), 真实跳转可以胜出 navigate, 不会被这里的 signin 重定向覆盖。
231
+ setState({ loading: true, authStatus: 'unauthenticated', error: null });
212
232
  navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
213
233
  replace: true,
214
234
  });
@@ -230,21 +250,28 @@ const CurrentUserProvider: FC = ({ children }) => {
230
250
  setState({
231
251
  data: res?.data,
232
252
  loading: false,
253
+ authStatus: 'authenticated',
254
+ error: null,
233
255
  });
234
- } catch (error: any) {
256
+ } catch (error: unknown) {
235
257
  if (!mounted) {
236
258
  return;
237
259
  }
238
260
 
239
- const isAuthError = error?.response?.status === 401 || error?.status === 401;
261
+ const errorLike = error as { response?: { status?: number }; status?: number };
262
+ const isAuthError = errorLike?.response?.status === 401 || errorLike?.status === 401;
240
263
  if (isAuthError) {
264
+ setState({ loading: true, authStatus: 'unauthenticated', error: null });
241
265
  navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
242
266
  replace: true,
243
267
  });
244
268
  return;
245
269
  }
246
- setState({ loading: false });
247
- throw error;
270
+ setState({
271
+ loading: false,
272
+ authStatus: 'unknown',
273
+ error: error instanceof Error ? error : new Error(String(error)),
274
+ });
248
275
  }
249
276
  };
250
277
 
@@ -255,11 +282,15 @@ const CurrentUserProvider: FC = ({ children }) => {
255
282
  };
256
283
  }, [app, authCheckRouteState, navigate]);
257
284
 
258
- if (state.loading) {
285
+ if (state.error) {
286
+ throw state.error;
287
+ }
288
+
289
+ if (state.loading || shouldBlockAuthRequiredRoute) {
259
290
  return app.renderComponent('AppSpin');
260
291
  }
261
292
 
262
- return <CurrentUserContext.Provider value={state}>{children}</CurrentUserContext.Provider>;
293
+ return <CurrentUserContext.Provider value={contextValue}>{children}</CurrentUserContext.Provider>;
263
294
  };
264
295
 
265
296
  const RootRedirect: FC = () => {
@@ -289,8 +320,9 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
289
320
  this.addComponents();
290
321
  this.addRoutes();
291
322
 
292
- this.app.use(DataSourceBootstrapProvider);
323
+ // Auth-required routes must finish auth check or redirect before data source metadata requests can start.
293
324
  this.app.use(CurrentUserProvider);
325
+ this.app.use(DataSourceBootstrapProvider);
294
326
  this.app.flowEngine.registerModels({
295
327
  AdminLayoutModel,
296
328
  AdminLayoutMenuItemModel,
@@ -239,11 +239,12 @@ export const InternalAdminSettingsLayout = () => {
239
239
  <Layout.Content
240
240
  style={{
241
241
  background: token.colorBgLayout,
242
+ flex: 1,
242
243
  display: 'flex',
243
244
  flexDirection: 'column',
244
245
  minWidth: 0,
245
- overflowY: 'auto',
246
- overflowX: 'hidden',
246
+ minHeight: 0,
247
+ overflow: 'hidden',
247
248
  }}
248
249
  >
249
250
  <PageHeader
@@ -273,6 +274,10 @@ export const InternalAdminSettingsLayout = () => {
273
274
  />
274
275
  <div
275
276
  style={{
277
+ flex: 1,
278
+ minHeight: 0,
279
+ boxSizing: 'border-box',
280
+ overflow: 'auto',
276
281
  padding: token.paddingLG,
277
282
  }}
278
283
  >