@nocobase/app-template-default 1.0.0-beta.83 → 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 +7 -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
@@ -126,68 +126,64 @@ export function permissionSections(
126
126
  actionKey: action.name,
127
127
  enabled: Boolean(granted),
128
128
  };
129
- // Scope selectors belong to the action and do not inflate permission counts.
129
+ // Keep scope metadata stable for layout and search; only enabled actions render controls.
130
130
  return {
131
131
  ...row,
132
- scopes: granted
133
- ? (resource.dataScopes?.[action.name] ?? [])
134
- .filter((scope) =>
135
- showScope(resource, action.name, scope.key),
136
- )
137
- .map((scope) => {
138
- const value = scopeSelectionKey(
139
- granted.policy?.scopes &&
140
- typeof granted.policy.scopes === 'object'
141
- ? Reflect.get(granted.policy.scopes, scope.key)
142
- : undefined,
143
- );
144
- const preserved =
145
- value !== '' && !scope.recordAccess.includes(value);
146
- return {
147
- id: JSON.stringify([
148
- resource.type,
149
- resource.id,
150
- action.name,
151
- scope.key,
152
- ]),
132
+ scopes: (resource.dataScopes?.[action.name] ?? [])
133
+ .filter((scope) => showScope(resource, action.name, scope.key))
134
+ .map((scope) => {
135
+ const value = scopeSelectionKey(
136
+ granted?.policy?.scopes &&
137
+ typeof granted.policy.scopes === 'object'
138
+ ? Reflect.get(granted.policy.scopes, scope.key)
139
+ : undefined,
140
+ );
141
+ const preserved =
142
+ value !== '' && !scope.recordAccess.includes(value);
143
+ return {
144
+ id: JSON.stringify([
145
+ resource.type,
146
+ resource.id,
147
+ action.name,
148
+ scope.key,
149
+ ]),
150
+ label: text(
151
+ typeof scope.title === 'string' &&
152
+ scope.title === scope.collection
153
+ ? { key: 'settings.recordScope' }
154
+ : scope.title,
155
+ ),
156
+ levels: [
157
+ ...(preserved
158
+ ? [
159
+ {
160
+ value,
161
+ label: text({
162
+ key: 'settings.preservedRecordScope',
163
+ }),
164
+ },
165
+ ]
166
+ : []),
167
+ {
168
+ value: '',
169
+ label: text({ key: 'settings.noRecordScope' }),
170
+ },
171
+ ...scope.recordAccess.map((key) => ({
172
+ value: key,
153
173
  label: text(
154
- typeof scope.title === 'string' &&
155
- scope.title === scope.collection
156
- ? { key: 'settings.recordScope' }
157
- : scope.title,
174
+ catalog.recordAccess.find(
175
+ (entry) => entry.key === key,
176
+ )?.title ?? key,
158
177
  ),
159
- levels: [
160
- ...(preserved
161
- ? [
162
- {
163
- value,
164
- label: text({
165
- key: 'settings.preservedRecordScope',
166
- }),
167
- },
168
- ]
169
- : []),
170
- {
171
- value: '',
172
- label: text({ key: 'settings.noRecordScope' }),
173
- },
174
- ...scope.recordAccess.map((key) => ({
175
- value: key,
176
- label: text(
177
- catalog.recordAccess.find(
178
- (entry) => entry.key === key,
179
- )?.title ?? key,
180
- ),
181
- })),
182
- ],
183
- level: value,
184
- hint:
185
- value === ''
186
- ? text({ key: 'settings.noAdditionalScopeHint' })
187
- : undefined,
188
- };
189
- })
190
- : [],
178
+ })),
179
+ ],
180
+ level: value,
181
+ hint:
182
+ value === ''
183
+ ? text({ key: 'settings.noAdditionalScopeHint' })
184
+ : undefined,
185
+ };
186
+ }),
191
187
  };
192
188
  }),
193
189
  })),
@@ -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 {
2
5
  useSettingsLeave,
3
6
  useSettingsData,
@@ -87,7 +90,12 @@ function RoleEditor(): ReactElement {
87
90
  const [grantsDraft, setGrantsDraft] = useState<readonly PermissionGrant[]>();
88
91
  const [dirty, setDirty] = useState(false);
89
92
  const [editingScope, setEditingScope] = useState<string>();
90
- const [busy, setBusy] = useState(false);
93
+ const { session } = useAuthentication();
94
+ const submission = useSubmission({
95
+ scope: `${session?.user.id}:settings:role:${key}`,
96
+ });
97
+ const [requestBusy, setRequestBusy] = useState(false);
98
+ const busy = requestBusy || submission.submitting;
91
99
  const leave = useSettingsLeave(dirty, busy);
92
100
  const [error, setError] = useState<unknown>();
93
101
 
@@ -127,57 +135,67 @@ function RoleEditor(): ReactElement {
127
135
  pathname: useResolvedPath('..').pathname,
128
136
  search: location.search,
129
137
  };
130
- const save = async (event: React.FormEvent<HTMLFormElement>) => {
138
+ const originalRevisionRef = useRef<string | undefined>(undefined);
139
+ const save = (event: React.FormEvent<HTMLFormElement>) => {
131
140
  event.preventDefault();
132
- if (options.error || !catalog || (!creating && request.error)) return;
133
- setSubmitted(true);
134
- if (!name.trim() || !roleKey.trim()) {
135
- event.currentTarget
136
- .querySelector<HTMLInputElement>('input:invalid')
137
- ?.focus();
138
- return;
139
- }
140
- const incomplete = unconfiguredFilter(grants);
141
- if (incomplete) {
142
- setEditingScope(incomplete);
143
- return;
144
- }
145
- setEditingScope(undefined);
146
- setBusy(true);
147
- setError(undefined);
148
- setKeyConflict(false);
149
- try {
150
- const input = {
151
- key: roleKey,
152
- title:
153
- !creating && initial && nameDraft === undefined
154
- ? initial.title
155
- : name.trim(),
156
- grants,
157
- ...(!creating && request.data?.data.revision
158
- ? { expectedRevision: request.data.data.revision }
159
- : {}),
160
- };
161
- if (creating) await authz.createPermissionSet(input);
162
- else await authz.updatePermissionSet(key, input);
163
- setDirty(false);
164
- parent.reload();
165
- toaster.show({ type: 'success', title: t('settings.saved', { name }) });
166
- leave.allow();
167
- void navigate(closeTo, { replace: true });
168
- } catch (next) {
169
- if (
170
- creating &&
171
- next instanceof Error &&
172
- 'status' in next &&
173
- next.status === 409
174
- ) {
175
- setKeyConflict(true);
176
- document.getElementById('role-key')?.focus();
177
- } else setError(next);
178
- } finally {
179
- setBusy(false);
180
- }
141
+ const form = event.currentTarget;
142
+ return (async () => {
143
+ event.preventDefault();
144
+ if (options.error || !catalog || (!creating && request.error)) return;
145
+ setSubmitted(true);
146
+ if (!name.trim() || !roleKey.trim()) {
147
+ form.querySelector<HTMLInputElement>('input:invalid')?.focus();
148
+ return;
149
+ }
150
+ const incomplete = unconfiguredFilter(grants);
151
+ if (incomplete) {
152
+ setEditingScope(incomplete);
153
+ return;
154
+ }
155
+ setEditingScope(undefined);
156
+ setRequestBusy(true);
157
+ setError(undefined);
158
+ setKeyConflict(false);
159
+ try {
160
+ originalRevisionRef.current ??= request.data?.data.revision;
161
+ const input = {
162
+ key: roleKey,
163
+ title:
164
+ !creating && initial && nameDraft === undefined
165
+ ? initial.title
166
+ : name.trim(),
167
+ grants,
168
+ ...(!creating && request.data?.data.revision
169
+ ? { expectedRevision: originalRevisionRef.current }
170
+ : {}),
171
+ };
172
+ const saved = await submission.run(
173
+ { key, creating, input },
174
+ (idempotencyKey) =>
175
+ creating
176
+ ? authz.createPermissionSet(input, idempotencyKey)
177
+ : authz.updatePermissionSet(key, input, idempotencyKey),
178
+ );
179
+ if (!saved) return;
180
+ setDirty(false);
181
+ parent.reload();
182
+ toaster.show({ type: 'success', title: t('settings.saved', { name }) });
183
+ leave.allow();
184
+ void navigate(closeTo, { replace: true });
185
+ } catch (next) {
186
+ if (
187
+ creating &&
188
+ next instanceof Error &&
189
+ 'status' in next &&
190
+ next.status === 409
191
+ ) {
192
+ setKeyConflict(true);
193
+ document.getElementById('role-key')?.focus();
194
+ } else setError(next);
195
+ } finally {
196
+ setRequestBusy(false);
197
+ }
198
+ })();
181
199
  };
182
200
  const sections = catalog
183
201
  ? configurePermissionScopes(
@@ -215,7 +233,6 @@ function RoleEditor(): ReactElement {
215
233
  />
216
234
  <SettingsError error={request.error} retry={request.reload} />
217
235
  <SettingsError error={options.error} retry={options.reload} />
218
- <SettingsError error={error} />
219
236
  {error instanceof Error &&
220
237
  'status' in error &&
221
238
  error.status === 409 && (
@@ -228,7 +245,10 @@ function RoleEditor(): ReactElement {
228
245
  ) : (
229
246
  !request.error &&
230
247
  !options.error && (
231
- <form
248
+ <FeedbackForm
249
+ submitting={busy}
250
+ error={error}
251
+ errorContent={<SettingsError error={error} inline />}
232
252
  onSubmit={(event) => void save(event)}
233
253
  noValidate
234
254
  className='flex flex-col gap-6'
@@ -238,10 +258,16 @@ function RoleEditor(): ReactElement {
238
258
  <Label htmlFor='role-key'>{t('settings.key')} *</Label>
239
259
  <Input
240
260
  id='role-key'
261
+ name='key'
241
262
  required
242
263
  value={roleKey}
243
264
  disabled={!creating || !allowed || busy}
244
265
  aria-invalid={keyConflict || (submitted && !roleKey.trim())}
266
+ aria-errormessage={
267
+ keyConflict || (submitted && !roleKey.trim())
268
+ ? 'role-key-error'
269
+ : undefined
270
+ }
245
271
  onChange={(event) => {
246
272
  setKeyConflict(false);
247
273
  setKeyDraft(event.target.value);
@@ -249,7 +275,7 @@ function RoleEditor(): ReactElement {
249
275
  }}
250
276
  />
251
277
  {(keyConflict || (submitted && !roleKey.trim())) && (
252
- <p className='text-xs text-destructive'>
278
+ <p id='role-key-error' className='text-xs text-destructive'>
253
279
  {t(
254
280
  keyConflict
255
281
  ? 'settings.keyConflict'
@@ -262,17 +288,24 @@ function RoleEditor(): ReactElement {
262
288
  <Label htmlFor='role-name'>{t('settings.roleName')} *</Label>
263
289
  <Input
264
290
  id='role-name'
291
+ name='title'
265
292
  required
266
293
  value={name}
267
294
  disabled={!allowed || busy}
268
295
  aria-invalid={submitted && !name.trim()}
296
+ aria-errormessage={
297
+ submitted && !name.trim() ? 'role-name-error' : undefined
298
+ }
269
299
  onChange={(event) => {
270
300
  setNameDraft(event.target.value);
271
301
  setDirty(true);
272
302
  }}
273
303
  />
274
304
  {submitted && !name.trim() && (
275
- <p className='text-xs text-destructive'>
305
+ <p
306
+ id='role-name-error'
307
+ className='text-xs text-destructive'
308
+ >
276
309
  {t('settings.required')}
277
310
  </p>
278
311
  )}
@@ -296,6 +329,15 @@ function RoleEditor(): ReactElement {
296
329
  levelFor: (name) => t('settings.levelFor', { name }),
297
330
  search: t('settings.permissionSearch'),
298
331
  enabledOnly: t('settings.enabledOnly'),
332
+ granted: t('settings.permissionGranted'),
333
+ ungranted: t('settings.permissionUngranted'),
334
+ selectAll: t('settings.permissionAll'),
335
+ authorizationStatus: t('settings.permissionStatus'),
336
+ clearSearch: t('settings.permissionClearSearch'),
337
+ collapseAll: t('settings.permissionCollapseAll'),
338
+ expandAll: t('settings.permissionExpandAll'),
339
+ summary: (enabled, total) =>
340
+ t('settings.permissionSummary', { enabled, total }),
299
341
  clear: t('settings.clear'),
300
342
  empty: t('settings.noPermissions'),
301
343
  emptyDescription: t('settings.permissionEmptyHint'),
@@ -342,7 +384,7 @@ function RoleEditor(): ReactElement {
342
384
  </Button>
343
385
  )}
344
386
  </div>
345
- </form>
387
+ </FeedbackForm>
346
388
  )
347
389
  )}
348
390
  {leave.dialog}
@@ -1,3 +1,4 @@
1
+ import { useListLocation } from '#hooks/use-list-location';
1
2
  import { useSettingsData, useRolePermission } from './hooks.js';
2
3
  import { useTranslation } from '@nocobase/i18n/client';
3
4
  import type {
@@ -5,7 +6,7 @@ import type {
5
6
  LocalizedText,
6
7
  } from '@nocobase/app-plugin-authorization/client';
7
8
  import { type ReactElement } from 'react';
8
- import { Link, Outlet, useLocation, useSearchParams } from 'react-router';
9
+ import { Link, Outlet, useLocation } from 'react-router';
9
10
  import { ShieldCheckIcon, PlusIcon } from 'lucide-react';
10
11
  import { PermissionManagementPage } from './permission-management-page.js';
11
12
  import { Input } from '#components/ui/input';
@@ -23,11 +24,12 @@ import { SettingsError, SettingsLoading } from './shared.js';
23
24
 
24
25
  export default function RolesPage(): ReactElement {
25
26
  const { t, i18n } = useTranslation();
26
- const [params, setParams] = useSearchParams();
27
+ const { params, setParams, active } = useListLocation();
27
28
  const location = useLocation();
28
29
  const search = params.get('q') ?? '';
29
30
  const request = useSettingsData<{ data: readonly PermissionSet[] }>(
30
31
  'authorization/permissionSets',
32
+ { active },
31
33
  );
32
34
  const canCreate = useRolePermission('create');
33
35
  const text = (value: LocalizedText): string =>
@@ -50,6 +52,8 @@ export default function RolesPage(): ReactElement {
50
52
  ),
51
53
  );
52
54
  const tableState = useTableUrlState({
55
+ active,
56
+ revision: request.revision,
53
57
  sortColumns: ['title', 'key'],
54
58
  rowCount:
55
59
  request.data && !request.loading && !request.error
@@ -109,10 +113,14 @@ export default function RolesPage(): ReactElement {
109
113
  </Button>
110
114
  )}
111
115
  </div>
112
- <SettingsError error={request.error} retry={request.reload} />
116
+ <SettingsError
117
+ error={tableState.error ?? request.error}
118
+ retry={request.reload}
119
+ />
113
120
  {request.loading && !request.data ? (
114
121
  <SettingsLoading />
115
122
  ) : (
123
+ !tableState.error &&
116
124
  !request.error &&
117
125
  (rows.length ? (
118
126
  <RoleList
@@ -1,3 +1,4 @@
1
+ import { ListRecoveryError } from '#hooks/use-table-url-state';
1
2
  import { ApiClientError } from '@nocobase/app-client';
2
3
  import { Link } from 'react-router';
3
4
  import { useTranslation } from '@nocobase/i18n/client';
@@ -18,9 +19,11 @@ import {
18
19
  export function SettingsError({
19
20
  error,
20
21
  retry,
22
+ inline = false,
21
23
  }: {
22
24
  readonly error: unknown;
23
25
  readonly retry?: () => void;
26
+ readonly inline?: boolean;
24
27
  }): ReactElement | null {
25
28
  const { t } = useTranslation();
26
29
  if (!error) return null;
@@ -28,40 +31,47 @@ export function SettingsError({
28
31
  typeof error === 'object' && error !== null
29
32
  ? Reflect.get(error, 'status')
30
33
  : undefined;
31
- return (
34
+ const content = (
35
+ <>
36
+ {error instanceof ListRecoveryError
37
+ ? error.message
38
+ : t(
39
+ status === 400
40
+ ? 'settings.invalidRequest'
41
+ : status === 401
42
+ ? 'settings.sessionEnded'
43
+ : status === 403
44
+ ? 'settings.denied'
45
+ : status === 404
46
+ ? 'settings.missing'
47
+ : status === 409
48
+ ? error instanceof ApiClientError &&
49
+ error.reason === 'USER_IDENTITY_CONFLICT'
50
+ ? 'settings.identityConflict'
51
+ : 'settings.conflict'
52
+ : 'settings.failed',
53
+ )}
54
+ {status === 401 && (
55
+ <Button
56
+ variant='outline'
57
+ nativeButton={false}
58
+ render={<Link to='/login' />}
59
+ >
60
+ {t('settings.signInAgain')}
61
+ </Button>
62
+ )}
63
+ {retry && ![400, 401, 403, 404, 409].includes(Number(status)) && (
64
+ <Button variant='outline' size='sm' onClick={retry}>
65
+ {t('settings.retry')}
66
+ </Button>
67
+ )}
68
+ </>
69
+ );
70
+ return inline ? (
71
+ content
72
+ ) : (
32
73
  <Alert variant='destructive'>
33
- <AlertDescription>
34
- {t(
35
- status === 400
36
- ? 'settings.invalidRequest'
37
- : status === 401
38
- ? 'settings.sessionEnded'
39
- : status === 403
40
- ? 'settings.denied'
41
- : status === 404
42
- ? 'settings.missing'
43
- : status === 409
44
- ? error instanceof ApiClientError &&
45
- error.reason === 'USER_IDENTITY_CONFLICT'
46
- ? 'settings.identityConflict'
47
- : 'settings.conflict'
48
- : 'settings.failed',
49
- )}
50
- {status === 401 && (
51
- <Button
52
- variant='outline'
53
- nativeButton={false}
54
- render={<Link to='/login' />}
55
- >
56
- {t('settings.signInAgain')}
57
- </Button>
58
- )}
59
- {retry && ![400, 401, 403, 404, 409].includes(Number(status)) && (
60
- <Button variant='outline' size='sm' onClick={retry}>
61
- {t('settings.retry')}
62
- </Button>
63
- )}
64
- </AlertDescription>
74
+ <AlertDescription>{content}</AlertDescription>
65
75
  </Alert>
66
76
  );
67
77
  }
@@ -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 {
2
5
  useSettingsLeave,
3
6
  useSettingsData,
@@ -267,7 +270,12 @@ function UserEditor(): ReactElement {
267
270
  const [confirmRoles, setConfirmRoles] = useState(false);
268
271
  const formRef = useRef<HTMLFormElement>(null);
269
272
  const [dirty, setDirty] = useState(false);
270
- const [busy, setBusy] = useState(false);
273
+ const { session } = useAuthentication();
274
+ const submission = useSubmission({
275
+ scope: `${session?.user.id}:settings:user:${userId}`,
276
+ });
277
+ const [requestBusy, setRequestBusy] = useState(false);
278
+ const busy = requestBusy || submission.submitting;
271
279
  const leave = useSettingsLeave(dirty, busy);
272
280
  const [error, setError] = useState<unknown>();
273
281
  const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
@@ -332,34 +340,43 @@ function UserEditor(): ReactElement {
332
340
  setConfirmRoles(true);
333
341
  return;
334
342
  }
335
- setBusy(true);
343
+ setRequestBusy(true);
336
344
  setError(undefined);
337
345
  setServerErrors({});
338
346
  try {
339
- let updated: ManagedUser;
340
- if (creating)
341
- updated = await client.create({
342
- ...fields,
343
- name: fields.name.trim(),
344
- email: fields.email.trim(),
345
- ...(fields.username.trim()
346
- ? { username: fields.username.trim() }
347
- : { username: undefined }),
348
- roleScopes: roles,
349
- });
350
- else
351
- updated = await client.update(userId, {
352
- ...(canUpdate
353
- ? {
354
- name: fields.name.trim(),
355
- email: fields.email.trim(),
356
- username: fields.username.trim() || null,
357
- }
358
- : {}),
359
- ...(canAssign && Object.keys(changedRoles).length
360
- ? { roleScopes: changedRoles }
361
- : {}),
362
- });
347
+ const input = creating
348
+ ? {
349
+ ...fields,
350
+ name: fields.name.trim(),
351
+ email: fields.email.trim(),
352
+ ...(fields.username.trim()
353
+ ? { username: fields.username.trim() }
354
+ : { username: undefined }),
355
+ roleScopes: roles,
356
+ }
357
+ : {
358
+ ...(canUpdate
359
+ ? {
360
+ name: fields.name.trim(),
361
+ email: fields.email.trim(),
362
+ username: fields.username.trim() || null,
363
+ }
364
+ : {}),
365
+ ...(canAssign && Object.keys(changedRoles).length
366
+ ? { roleScopes: changedRoles }
367
+ : {}),
368
+ };
369
+ const updated = await submission.run(
370
+ { userId, creating, input },
371
+ (idempotencyKey) =>
372
+ creating
373
+ ? client.create(
374
+ input as Parameters<typeof client.create>[0],
375
+ idempotencyKey,
376
+ )
377
+ : client.update(userId, input, idempotencyKey),
378
+ );
379
+ if (!updated) return;
363
380
  setDirty(false);
364
381
  parent.reload(updated);
365
382
  toaster.show({
@@ -395,7 +412,7 @@ function UserEditor(): ReactElement {
395
412
  setError(next);
396
413
  }
397
414
  } finally {
398
- setBusy(false);
415
+ setRequestBusy(false);
399
416
  }
400
417
  };
401
418
  const allowed = creating ? canCreate && canAssign : canUpdate || canAssign;
@@ -453,12 +470,14 @@ function UserEditor(): ReactElement {
453
470
  <>
454
471
  <SettingsError error={data.error} retry={data.reload} />
455
472
  <SettingsError error={options.error} retry={options.reload} />
456
- <SettingsError error={error} />
457
473
  {!creating && data.loading ? (
458
474
  <SettingsLoading />
459
475
  ) : (
460
476
  !data.error && (
461
- <form
477
+ <FeedbackForm
478
+ submitting={busy}
479
+ error={error}
480
+ errorContent={<SettingsError error={error} inline />}
462
481
  id={formId}
463
482
  ref={formRef}
464
483
  onSubmit={(event) => {
@@ -510,6 +529,7 @@ function UserEditor(): ReactElement {
510
529
  />
511
530
  {fieldError(name) && (
512
531
  <p
532
+ data-feedback-field-error
513
533
  id={`${formId}-${name}-error`}
514
534
  className='text-xs text-destructive'
515
535
  >
@@ -536,6 +556,7 @@ function UserEditor(): ReactElement {
536
556
  />
537
557
  {serverErrors[scope.key] && (
538
558
  <p
559
+ data-feedback-field-error
539
560
  id={`${formId}-roles-${encodeURIComponent(scope.key)}-error`}
540
561
  className='text-xs text-destructive'
541
562
  >
@@ -544,7 +565,7 @@ function UserEditor(): ReactElement {
544
565
  )}
545
566
  </div>
546
567
  ))}
547
- </form>
568
+ </FeedbackForm>
548
569
  )
549
570
  )}
550
571
  {leave.dialog}