@nocobase/app-template-default 1.0.0-beta.82 → 1.0.0-beta.84

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 (77) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +9 -1
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/default-access-editor.tsx +13 -2
  7. package/client/components/feedback-form.tsx +474 -0
  8. package/client/components/lifecycle-diagram.tsx +10 -10
  9. package/client/components/permission-editor.tsx +396 -210
  10. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  11. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  12. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  13. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  14. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  15. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  16. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  17. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  18. package/client/hooks/use-list-data.ts +77 -0
  19. package/client/hooks/use-list-location.ts +23 -0
  20. package/client/hooks/use-list-tabs.ts +42 -0
  21. package/client/hooks/use-table-url-state.ts +143 -30
  22. package/client/locales/system/en-US.ts +10 -0
  23. package/client/locales/system/settings-en-US.ts +8 -0
  24. package/client/locales/system/settings-zh-CN.ts +8 -0
  25. package/client/locales/system/zh-CN.ts +8 -0
  26. package/client/pages/settings/default-access-form.tsx +44 -26
  27. package/client/pages/settings/default-access.tsx +56 -27
  28. package/client/pages/settings/hooks.tsx +4 -59
  29. package/client/pages/settings/page-permissions.ts +17 -0
  30. package/client/pages/settings/permissions-model.ts +54 -58
  31. package/client/pages/settings/role-form.tsx +98 -56
  32. package/client/pages/settings/roles.tsx +11 -3
  33. package/client/pages/settings/shared.tsx +43 -33
  34. package/client/pages/settings/user-form.tsx +51 -30
  35. package/client/pages/settings/users.tsx +39 -27
  36. package/client/routing/session-preparation.tsx +10 -3
  37. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  38. package/package.json +13 -12
  39. package/server/providers/index.ts +4 -1
  40. package/server/providers/submission.ts +61 -0
  41. package/tests/client/auth-i18n.test.tsx +9 -0
  42. package/tests/client/back-button.test.tsx +3 -3
  43. package/tests/client/default-access.test.tsx +60 -4
  44. package/tests/client/form-feedback.test.tsx +589 -0
  45. package/tests/client/list-recovery.test.tsx +373 -0
  46. package/tests/client/list-tabs.test.tsx +121 -0
  47. package/tests/client/page-permission-navigation.test.ts +8 -0
  48. package/tests/client/permission-editor.test.tsx +191 -21
  49. package/tests/client/permission-filter-binding.test.tsx +1 -1
  50. package/tests/client/role-return.test.tsx +2 -1
  51. package/tests/client/session-preparation.test.tsx +24 -1
  52. package/tests/client/settings-continuity.test.tsx +5 -4
  53. package/tests/client/settings-feedback.test.tsx +25 -0
  54. package/tests/client/settings-permissions.test.ts +35 -0
  55. package/tests/client/settings.test.tsx +2 -1
  56. package/tests/client/table-url-state.test.tsx +117 -3
  57. package/tests/client/user-drawer.test.tsx +2 -1
  58. package/tests/client/user-permissions.test.tsx +30 -5
  59. package/tests/database/mutation-receipts.test.ts +45 -0
  60. package/tests/fixtures/form-feedback/README.md +33 -0
  61. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  62. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  63. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  64. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  65. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  66. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  67. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  68. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  69. package/tests/fixtures/submission/README.md +35 -0
  70. package/tests/fixtures/submission/browser.test.ts +478 -0
  71. package/tests/fixtures/submission/migration.ts +61 -0
  72. package/tests/fixtures/submission/page.tsx +138 -0
  73. package/tests/fixtures/submission/playwright.config.ts +11 -0
  74. package/tests/fixtures/submission/routes.ts +208 -0
  75. package/tests/helpers/settings-client.tsx +31 -0
  76. package/tests/server/member-role.test.ts +7 -1
  77. package/tests/server/submission-settings.test.ts +241 -0
@@ -1,50 +1,128 @@
1
- import { useEffect } from 'react';
2
- import { useSearchParams } from 'react-router';
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useTranslation } from '@nocobase/i18n/client';
3
+ import { useLocation, useSearchParams } from 'react-router';
3
4
  import type {
4
5
  OnChangeFn,
5
6
  PaginationState,
6
7
  SortingState,
7
8
  } from '@tanstack/react-table';
8
9
 
10
+ /** A bounded recovery failed because concurrent writes kept moving the final page. */
11
+ export class ListRecoveryError extends Error {}
12
+
9
13
  const sizes = [10, 20, 50, 100] as const;
10
14
 
15
+ interface TableUrlOptions {
16
+ defaultPageSize?: number;
17
+ pageSizeOptions?: readonly number[];
18
+ sortColumns: readonly string[];
19
+ defaultSorting?: SortingState;
20
+ /** Match the existing endpoint contract; do not translate URLs on upgrade. */
21
+ sortFormat?: 'sort-order' | 'orderBy';
22
+ rowCount?: number;
23
+ active?: boolean;
24
+ revision?: number;
25
+ }
26
+
27
+ /** One parser for both the visible controls and the request. */
28
+ export function readTableUrlState(
29
+ params: URLSearchParams,
30
+ {
31
+ defaultPageSize = 10,
32
+ pageSizeOptions = sizes,
33
+ sortColumns,
34
+ defaultSorting = [],
35
+ sortFormat = 'sort-order',
36
+ }: TableUrlOptions,
37
+ ) {
38
+ const page = Number(params.get('page'));
39
+ const size = Number(params.get('pageSize'));
40
+ const fallback = defaultSorting.filter((sort) =>
41
+ sortColumns.includes(sort.id),
42
+ );
43
+ const fields =
44
+ sortFormat === 'orderBy'
45
+ ? (params.get('orderBy') ?? '').split(',').map((part) => {
46
+ const [id, direction = 'asc', ...rest] = part.trim().split(/\s+/u);
47
+ return { id, direction: rest.length ? '' : direction };
48
+ })
49
+ : [
50
+ {
51
+ id: params.get('sort') ?? sortColumns[0],
52
+ direction: params.get('order'),
53
+ },
54
+ ];
55
+ const valid = fields.every(
56
+ ({ id, direction }) =>
57
+ id &&
58
+ sortColumns.includes(id) &&
59
+ (direction === 'asc' || direction === 'desc'),
60
+ );
61
+ const sortingState: SortingState = valid
62
+ ? fields
63
+ .filter(
64
+ (field, index) =>
65
+ fields.findIndex((other) => other.id === field.id) === index,
66
+ )
67
+ .map(({ id, direction }) => ({ id, desc: direction === 'desc' }))
68
+ : fallback;
69
+ return {
70
+ paginationState: {
71
+ pageIndex: Number.isSafeInteger(page) && page > 0 ? page - 1 : 0,
72
+ pageSize: pageSizeOptions.includes(size) ? size : defaultPageSize,
73
+ },
74
+ sortingState,
75
+ };
76
+ }
77
+
11
78
  /** Keep list position shareable across overlays, refresh and browser history. */
12
79
  export function useTableUrlState({
13
80
  defaultPageSize = 10,
14
81
  pageSizeOptions = sizes,
15
82
  sortColumns,
16
83
  rowCount,
17
- }: {
18
- defaultPageSize?: number;
19
- pageSizeOptions?: readonly number[];
20
- sortColumns: readonly string[];
21
- rowCount?: number;
22
- }) {
84
+ active = true,
85
+ revision = 0,
86
+ defaultSorting = [],
87
+ sortFormat = 'sort-order',
88
+ }: TableUrlOptions) {
23
89
  const [params, setParams] = useSearchParams();
24
- const requestedPage = Number(params.get('page'));
25
- const pageIndex =
26
- Number.isSafeInteger(requestedPage) && requestedPage > 0
27
- ? requestedPage - 1
28
- : 0;
29
- const requestedSize = Number(params.get('pageSize'));
30
- const pageSize = pageSizeOptions.includes(requestedSize)
31
- ? requestedSize
32
- : defaultPageSize;
33
- const paginationState = { pageIndex, pageSize };
34
- const sort = params.get('sort') ?? sortColumns[0];
35
- const order = params.get('order');
36
- const sortingState: SortingState =
37
- sort && sortColumns.includes(sort) && (order === 'asc' || order === 'desc')
38
- ? [{ id: sort, desc: order === 'desc' }]
39
- : [];
90
+ const { paginationState, sortingState } = readTableUrlState(params, {
91
+ defaultPageSize,
92
+ pageSizeOptions,
93
+ sortColumns,
94
+ defaultSorting,
95
+ sortFormat,
96
+ });
97
+ const { pageIndex, pageSize } = paginationState;
98
+ const { t } = useTranslation();
99
+ const { pathname } = useLocation();
100
+ const contextParams = new URLSearchParams(params);
101
+ contextParams.delete('page');
102
+ const context = JSON.stringify([
103
+ pathname,
104
+ contextParams.toString(),
105
+ revision,
106
+ ]);
107
+ const correctionsRef = useRef({ context, attempts: 0, expectedPage: -1 });
108
+ const [blocked, setBlocked] = useState<string>();
40
109
  const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
41
110
  const next =
42
111
  typeof updater === 'function' ? updater(paginationState) : updater;
43
112
  setParams(
44
113
  (old) => {
45
114
  const result = new URLSearchParams(old);
46
- result.set('page', String(next.pageIndex + 1));
47
- result.set('pageSize', String(next.pageSize));
115
+ const size = pageSizeOptions.includes(next.pageSize)
116
+ ? next.pageSize
117
+ : defaultPageSize;
118
+ const index =
119
+ size !== pageSize ||
120
+ !Number.isSafeInteger(next.pageIndex) ||
121
+ next.pageIndex < 0
122
+ ? 0
123
+ : next.pageIndex;
124
+ result.set('page', String(index + 1));
125
+ result.set('pageSize', String(size));
48
126
  return result;
49
127
  },
50
128
  { replace: false },
@@ -57,7 +135,17 @@ export function useTableUrlState({
57
135
  (old) => {
58
136
  const result = new URLSearchParams(old);
59
137
  result.delete('page');
60
- if (next[0] && sortColumns.includes(next[0].id)) {
138
+ if (sortFormat === 'orderBy') {
139
+ const valid = next.filter((sort) => sortColumns.includes(sort.id));
140
+ if (valid.length)
141
+ result.set(
142
+ 'orderBy',
143
+ valid
144
+ .map((sort) => `${sort.id}${sort.desc ? ' desc' : ''}`)
145
+ .join(','),
146
+ );
147
+ else result.delete('orderBy');
148
+ } else if (next[0] && sortColumns.includes(next[0].id)) {
61
149
  result.set('sort', next[0].id);
62
150
  result.set('order', next[0].desc ? 'desc' : 'asc');
63
151
  } else {
@@ -71,9 +159,30 @@ export function useTableUrlState({
71
159
  };
72
160
  // Only pass a count for the current, successfully loaded query.
73
161
  useEffect(() => {
74
- if (rowCount === undefined) return;
162
+ if (
163
+ !active ||
164
+ rowCount === undefined ||
165
+ !Number.isSafeInteger(rowCount) ||
166
+ rowCount < 0
167
+ )
168
+ return;
169
+ if (
170
+ correctionsRef.current.context !== context ||
171
+ (correctionsRef.current.expectedPage !== -1 &&
172
+ correctionsRef.current.expectedPage !== pageIndex)
173
+ )
174
+ correctionsRef.current = { context, attempts: 0, expectedPage: -1 };
75
175
  const lastPage = Math.max(0, Math.ceil(rowCount / pageSize) - 1);
76
- if (pageIndex <= lastPage) return;
176
+ if (pageIndex <= lastPage) {
177
+ correctionsRef.current.attempts = 0;
178
+ return;
179
+ }
180
+ if (correctionsRef.current.attempts >= 2) {
181
+ setBlocked(`${context}:${pageIndex}`);
182
+ return;
183
+ }
184
+ correctionsRef.current.attempts += 1;
185
+ correctionsRef.current.expectedPage = lastPage;
77
186
  setParams(
78
187
  (old) => {
79
188
  const result = new URLSearchParams(old);
@@ -82,8 +191,12 @@ export function useTableUrlState({
82
191
  },
83
192
  { replace: true },
84
193
  );
85
- }, [rowCount, pageSize, pageIndex, setParams]);
194
+ }, [rowCount, pageSize, pageIndex, setParams, active, context]);
86
195
  return {
196
+ error:
197
+ blocked === `${context}:${pageIndex}`
198
+ ? new ListRecoveryError(t('settings.listChanged'))
199
+ : undefined,
87
200
  paginationState,
88
201
  onPaginationChange,
89
202
  sortingState,
@@ -54,6 +54,16 @@ const systemEnUS = {
54
54
  ...deviceApprovalEnUS,
55
55
  // The UI Library block of the `/inbox` page; the keys below may reword it.
56
56
  ...inboxEnUS,
57
+ // Shared form wording overrides the defaults included by composed blocks.
58
+ formFeedback: {
59
+ field: 'This value was rejected. Check it and try again.',
60
+ invalid: 'Check the highlighted fields and try again.',
61
+ failed: 'Unable to save. Your input has been kept. Try again.',
62
+ network:
63
+ 'Unable to connect. Check your connection and try again. Your input has been kept.',
64
+ denied:
65
+ 'You cannot submit this form with your current access. Your input has been kept.',
66
+ },
57
67
  // The UI Library component of the header's inbox button.
58
68
  'inboxButton.title': 'Inbox',
59
69
  'inboxButton.pending': 'Inbox, {{count}} waiting',
@@ -1,4 +1,5 @@
1
1
  const settings = {
2
+ listChanged: 'The list has changed. Please retry.',
2
3
  setup: {
3
4
  title: 'Feature prerequisites',
4
5
  checking: 'Checking current configuration…',
@@ -207,6 +208,13 @@ const settings = {
207
208
  configurationPermissionsHint:
208
209
  'Control access and actions for system settings, user management and business configuration. Only actual application features are shown.',
209
210
  permissionSearch: 'Search categories, groups or permissions',
211
+ permissionAll: 'All',
212
+ permissionGranted: 'Granted',
213
+ permissionUngranted: 'Ungranted',
214
+ permissionStatus: 'Authorization status',
215
+ permissionClearSearch: 'Clear search',
216
+ permissionCollapseAll: 'Collapse all',
217
+ permissionExpandAll: 'Expand all',
210
218
  enabledOnly: 'Enabled only',
211
219
  noPermissions: 'No permissions to configure',
212
220
  permissionEmptyHint:
@@ -1,4 +1,5 @@
1
1
  const settings = {
2
+ listChanged: '列表已变化,请重试。',
2
3
  setup: {
3
4
  title: '功能前置配置',
4
5
  checking: '正在检查当前配置…',
@@ -190,6 +191,13 @@ const settings = {
190
191
  configurationPermissionsHint:
191
192
  '统一设置系统设置、用户管理和业务配置项的访问与操作权限,仅显示应用实际开放的功能。',
192
193
  permissionSearch: '搜索分类、分组或权限',
194
+ permissionAll: '全部',
195
+ permissionGranted: '已授权',
196
+ permissionUngranted: '未授权',
197
+ permissionStatus: '授权状态',
198
+ permissionClearSearch: '清空搜索',
199
+ permissionCollapseAll: '全部收起',
200
+ permissionExpandAll: '全部展开',
193
201
  enabledOnly: '仅显示已授权',
194
202
  noPermissions: '暂无可配置权限',
195
203
  permissionEmptyHint: '请联系应用开发者,开放此角色需要配置的权限。',
@@ -50,6 +50,14 @@ const systemZhCN: SystemResource = {
50
50
  ...deviceApprovalZhCN,
51
51
  // The UI Library block of the `/inbox` page; the keys below may reword it.
52
52
  ...inboxZhCN,
53
+ // Shared form wording overrides the defaults included by composed blocks.
54
+ formFeedback: {
55
+ field: '此字段的值未通过校验,请检查并修改后重试。',
56
+ invalid: '请检查标记的字段后重试。',
57
+ failed: '保存失败,已保留您的输入,请重试。',
58
+ network: '无法连接,请检查网络后重试。已保留您的输入。',
59
+ denied: '当前权限无法提交此表单。已保留您的输入。',
60
+ },
53
61
  // The UI Library component of the header's inbox button.
54
62
  'inboxButton.title': '收件箱',
55
63
  'inboxButton.pending': '收件箱,{{count}} 项待处理',
@@ -1,3 +1,6 @@
1
+ import { FeedbackForm } from '#components/feedback-form';
2
+ import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
3
+ import { useSubmission } from '@nocobase/app-client';
1
4
  import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
2
5
  import {
3
6
  useCan,
@@ -60,7 +63,12 @@ function DefaultAccessFormBody(): ReactElement {
60
63
  action: creating ? 'create' : 'update',
61
64
  }).can;
62
65
  const [draft, setDraft] = useState<DefaultAccessRule>();
63
- const [busy, setBusy] = useState(false);
66
+ const { session } = useAuthentication();
67
+ const submission = useSubmission({
68
+ scope: `${session?.user.id}:settings:default-access:${key}`,
69
+ });
70
+ const [requestBusy, setRequestBusy] = useState(false);
71
+ const busy = requestBusy || submission.submitting;
64
72
  const [error, setError] = useState<unknown>();
65
73
  const [message, setMessage] = useState<string>();
66
74
  const [submitted, setSubmitted] = useState(false);
@@ -245,28 +253,36 @@ function DefaultAccessFormBody(): ReactElement {
245
253
  );
246
254
  return;
247
255
  }
248
- setBusy(true);
256
+ setRequestBusy(true);
249
257
  try {
250
- const latest = await api.request<{ data: readonly DefaultAccessRule[] }>({
251
- path: 'authorization/defaultAccess',
252
- });
253
- if (
254
- !creating &&
255
- JSON.stringify(latest.data.find((rule) => rule.key === key)) !==
256
- JSON.stringify(initial)
257
- ) {
258
- setMessage(t('settings.defaultAccess.changed'));
259
- return;
260
- }
261
- const saved = await api.request<{ data: DefaultAccessRule }>({
262
- path: creating
263
- ? 'authorization/defaultAccess'
264
- : `authorization/defaultAccess/${encodeURIComponent(key ?? '')}`,
265
- method: creating ? 'POST' : 'PATCH',
266
- json: creating
267
- ? { ...value, key: value.key.trim() }
268
- : { actions: value.actions },
269
- });
258
+ const saved = await submission.run(
259
+ { creating, key, value },
260
+ async (idempotencyKey, _signal, _snapshot, recovering) => {
261
+ if (!creating && !recovering) {
262
+ const latest = await api.request<{
263
+ data: readonly DefaultAccessRule[];
264
+ }>({ path: 'authorization/defaultAccess' });
265
+ if (
266
+ JSON.stringify(latest.data.find((rule) => rule.key === key)) !==
267
+ JSON.stringify(initial)
268
+ ) {
269
+ setMessage(t('settings.defaultAccess.changed'));
270
+ return undefined;
271
+ }
272
+ }
273
+ return api.request<{ data: DefaultAccessRule }>({
274
+ path: creating
275
+ ? 'authorization/defaultAccess'
276
+ : `authorization/defaultAccess/${encodeURIComponent(key ?? '')}`,
277
+ headers: { 'Idempotency-Key': idempotencyKey },
278
+ method: creating ? 'POST' : 'PATCH',
279
+ json: creating
280
+ ? { ...value, key: value.key.trim() }
281
+ : { actions: value.actions },
282
+ });
283
+ },
284
+ );
285
+ if (!saved) return;
270
286
  toaster.show({
271
287
  type: 'success',
272
288
  title: t('settings.defaultAccess.saved', { name: value.key }),
@@ -331,7 +347,7 @@ function DefaultAccessFormBody(): ReactElement {
331
347
  setMessage(t('settings.defaultAccess.invalid'));
332
348
  else setError(next);
333
349
  } finally {
334
- setBusy(false);
350
+ setRequestBusy(false);
335
351
  }
336
352
  };
337
353
  return (
@@ -360,7 +376,10 @@ function DefaultAccessFormBody(): ReactElement {
360
376
  {!creating && !initial ? (
361
377
  <p role='status'>{t('settings.missing')}</p>
362
378
  ) : (
363
- <form
379
+ <FeedbackForm
380
+ submitting={busy}
381
+ error={error}
382
+ errorContent={<SettingsError error={error} inline />}
364
383
  ref={formRef}
365
384
  noValidate
366
385
  className='flex flex-col gap-6'
@@ -369,7 +388,6 @@ function DefaultAccessFormBody(): ReactElement {
369
388
  void save();
370
389
  }}
371
390
  >
372
- <SettingsError error={error} />
373
391
  {message && (
374
392
  <Alert variant='destructive'>
375
393
  <AlertDescription>
@@ -487,7 +505,7 @@ function DefaultAccessFormBody(): ReactElement {
487
505
  </Button>
488
506
  )}
489
507
  </div>
490
- </form>
508
+ </FeedbackForm>
491
509
  )}
492
510
  {!creating && !initial && (
493
511
  <Button variant='outline' onClick={close}>
@@ -1,6 +1,12 @@
1
+ import { useListLocation } from '#hooks/use-list-location';
1
2
  import { RecordLink } from '#components/record-link';
2
3
  import { useTableUrlState } from '#hooks/use-table-url-state';
3
- import { useApiClient, useToaster } from '@nocobase/app-client';
4
+ import {
5
+ ApiClientError,
6
+ useApiClient,
7
+ useSubmission,
8
+ useToaster,
9
+ } from '@nocobase/app-client';
4
10
  import {
5
11
  useCan,
6
12
  type AuthorizationOptionsResponse,
@@ -8,7 +14,7 @@ import {
8
14
  } from '@nocobase/app-plugin-authorization/client';
9
15
  import { useTranslation } from '@nocobase/i18n/client';
10
16
  import { useRef, useState, type ReactElement } from 'react';
11
- import { Link, Outlet, useLocation, useSearchParams } from 'react-router';
17
+ import { Link, Outlet, useLocation } from 'react-router';
12
18
  import { ListFilterIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react';
13
19
  import type { ColumnDef } from '@tanstack/react-table';
14
20
  import { Alert, AlertDescription } from '#components/ui/alert';
@@ -45,11 +51,12 @@ export default function DefaultAccessPage(): ReactElement {
45
51
  const api = useApiClient();
46
52
  const toaster = useToaster();
47
53
  const location = useLocation();
48
- const [params, setParams] = useSearchParams();
54
+ const { params, setParams, active } = useListLocation();
49
55
  const search = params.get('q') ?? '';
50
56
  const searchRef = useRef<HTMLInputElement>(null);
51
57
  const rules = useSettingsData<{ data: readonly DefaultAccessRule[] }>(
52
58
  'authorization/defaultAccess',
59
+ { active },
53
60
  );
54
61
  const catalog = useSettingsData<{ data: AuthorizationOptionsResponse }>(
55
62
  'authorization/defaultAccess/options',
@@ -212,6 +219,8 @@ export default function DefaultAccessPage(): ReactElement {
212
219
  )
213
220
  .sort((a, b) => name(a).localeCompare(name(b), i18n.language));
214
221
  const tableState = useTableUrlState({
222
+ active,
223
+ revision: rules.revision,
215
224
  defaultPageSize: 20,
216
225
  sortColumns: ['resource'],
217
226
  rowCount:
@@ -262,43 +271,58 @@ export default function DefaultAccessPage(): ReactElement {
262
271
  </div>
263
272
  )
264
273
  ) : null;
274
+ const submission = useSubmission();
265
275
  const remove = async () => {
266
276
  if (!removing || busy || !canDelete) return;
267
277
  setBusy(true);
268
278
  setError(undefined);
279
+ let submitted = false;
269
280
  try {
270
- const latest = await api.request<{ data: readonly DefaultAccessRule[] }>({
271
- path: 'authorization/defaultAccess',
272
- });
273
- if (
274
- JSON.stringify(
275
- latest.data.find((rule) => rule.key === removing.key),
276
- ) !== JSON.stringify(removing)
277
- ) {
278
- rules.reload();
279
- setRemoving(undefined);
280
- setConflict(true);
281
- requestAnimationFrame(() => searchRef.current?.focus());
282
- return;
283
- }
284
- await api.request({
285
- path: `authorization/defaultAccess/${encodeURIComponent(removing.key)}`,
286
- method: 'DELETE',
287
- });
281
+ const outcome = await submission.run(
282
+ { key: removing.key },
283
+ async (idempotencyKey, _signal, _snapshot, recovering) => {
284
+ if (!recovering) {
285
+ const latest = await api.request<{
286
+ data: readonly DefaultAccessRule[];
287
+ }>({ path: 'authorization/defaultAccess' });
288
+ if (
289
+ JSON.stringify(
290
+ latest.data.find((rule) => rule.key === removing.key),
291
+ ) !== JSON.stringify(removing)
292
+ ) {
293
+ rules.reload();
294
+ setRemoving(undefined);
295
+ setConflict(true);
296
+ requestAnimationFrame(() => searchRef.current?.focus());
297
+ return undefined;
298
+ }
299
+ }
300
+ submitted = true;
301
+ await api.request({
302
+ path: `authorization/defaultAccess/${encodeURIComponent(removing.key)}`,
303
+ method: 'DELETE',
304
+ headers: { 'Idempotency-Key': idempotencyKey },
305
+ });
306
+ return true;
307
+ },
308
+ );
309
+ if (!outcome) return;
288
310
  toaster.show({
289
311
  type: 'success',
290
312
  title: t('settings.defaultAccess.deleted', { name: name(removing) }),
291
313
  });
292
- rules.replaceData({
293
- data: (rules.data?.data ?? []).filter(
294
- (rule) => rule.key !== removing.key,
295
- ),
296
- });
297
314
  setRemoving(undefined);
298
315
  rules.reload();
299
316
  requestAnimationFrame(() => searchRef.current?.focus());
300
317
  } catch (next) {
301
318
  setError(next);
319
+ if (
320
+ submitted &&
321
+ (!(next instanceof ApiClientError) || next.status >= 500)
322
+ ) {
323
+ // The write may have committed before the response was lost. Verify without replaying it.
324
+ rules.reload();
325
+ }
302
326
  } finally {
303
327
  setBusy(false);
304
328
  }
@@ -345,7 +369,11 @@ export default function DefaultAccessPage(): ReactElement {
345
369
  </Alert>
346
370
  )}
347
371
  <SettingsError
348
- error={rules.error || catalog.error || (!removing && error)}
372
+ error={
373
+ (tableState.error ?? rules.error) ||
374
+ catalog.error ||
375
+ (!removing && error)
376
+ }
349
377
  retry={() => {
350
378
  rules.reload();
351
379
  catalog.reload();
@@ -356,6 +384,7 @@ export default function DefaultAccessPage(): ReactElement {
356
384
  (catalog.loading && !catalog.data) ? (
357
385
  <SettingsLoading />
358
386
  ) : (
387
+ !tableState.error &&
359
388
  !rules.error &&
360
389
  !catalog.error &&
361
390
  (rows.length ? (
@@ -1,67 +1,12 @@
1
- import {
2
- ApiClientError,
3
- useApiClient,
4
- useNavigationGuard,
5
- } from '@nocobase/app-client';
1
+ import { useNavigationGuard } from '@nocobase/app-client';
6
2
  import { useCan } from '@nocobase/app-plugin-authorization/client';
7
3
  import { useTranslation } from '@nocobase/i18n/client';
8
- import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { useEffect, useRef, useState } from 'react';
9
5
  import { useLocation } from 'react-router';
10
6
  import { ConfirmAction } from './shared.js';
11
7
 
12
- export function useSettingsData<T>(path: string, keepPreviousData = false) {
13
- const api = useApiClient();
14
- const [revision, setRevision] = useState(0);
15
- const [result, setResult] = useState<{
16
- path: string;
17
- revision: number;
18
- data?: T;
19
- error?: unknown;
20
- loading: boolean;
21
- }>({ path, revision: -1, loading: true });
22
- useEffect(() => {
23
- let active = true;
24
- void api.request<T>({ path }).then(
25
- (data) => {
26
- if (active) setResult({ path, revision, data, loading: false });
27
- },
28
- (error: unknown) => {
29
- if (active)
30
- setResult((old) => ({
31
- ...(old.path === path &&
32
- !(
33
- error instanceof ApiClientError &&
34
- [401, 403, 404].includes(error.status)
35
- )
36
- ? old
37
- : {}),
38
- path,
39
- revision,
40
- error,
41
- loading: false,
42
- }));
43
- },
44
- );
45
- return () => {
46
- active = false;
47
- };
48
- }, [api, path, revision]);
49
- return {
50
- ...(result.path === path || keepPreviousData
51
- ? result
52
- : { path, loading: true, data: undefined, error: undefined }),
53
- loading: result.path !== path || result.revision !== revision,
54
- error:
55
- result.path === path && result.revision === revision
56
- ? result.error
57
- : undefined,
58
- reload: useCallback(() => setRevision((value) => value + 1), []),
59
- replaceData: useCallback(
60
- (data: T) => setResult({ path, revision, data, loading: false }),
61
- [path, revision],
62
- ),
63
- };
64
- }
8
+ /** Compatibility entry for prepared settings pages and application-owned consumers. */
9
+ export { useListData as useSettingsData } from '#hooks/use-list-data';
65
10
 
66
11
  export function useUserPermission(action: string, id = '*') {
67
12
  return useCan({ resource: { type: 'user', id }, action }).can;
@@ -101,6 +101,21 @@ export function pagePermissionNavigation(
101
101
  ({ type, id }) => !configuration.has(JSON.stringify([type, id])),
102
102
  );
103
103
  const resources = new Map(pages.map((page) => [page.id, page]));
104
+ const paths = new Map<string, string>();
105
+ const collectPaths = (items: readonly AppClientRegisteredRoute[]): void => {
106
+ for (const route of items) {
107
+ if (
108
+ route.componentLoader &&
109
+ typeof route.authz === 'object' &&
110
+ route.authz.resource.type === 'page' &&
111
+ !paths.has(route.authz.resource.id)
112
+ ) {
113
+ paths.set(route.authz.resource.id, route.path);
114
+ }
115
+ collectPaths(route.children ?? []);
116
+ }
117
+ };
118
+ collectPaths(routes);
104
119
  const seen = new Set<string>();
105
120
  const visit = (
106
121
  items: readonly RouteNavigationItem[],
@@ -130,6 +145,7 @@ export function pagePermissionNavigation(
130
145
  : unnamed(route.path),
131
146
  ),
132
147
  rowId,
148
+ path: route.path,
133
149
  children: descendants,
134
150
  },
135
151
  ];
@@ -142,6 +158,7 @@ export function pagePermissionNavigation(
142
158
  id: `page:${page.id}`,
143
159
  label: text(page.title),
144
160
  rowId: permissionRowId(page, 'access'),
161
+ path: paths.get(page.id),
145
162
  }));
146
163
  return [
147
164
  ...navigation,