@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.
- package/.agents/rules/build-and-deploy.md +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +7 -1
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/permission-editor.tsx +396 -210
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/hooks/use-list-data.ts +77 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +8 -0
- package/client/locales/system/settings-zh-CN.ts +8 -0
- package/client/locales/system/zh-CN.ts +8 -0
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +56 -27
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -58
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +11 -3
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +39 -27
- package/client/routing/session-preparation.tsx +10 -3
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +13 -12
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/default-access.test.tsx +60 -4
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +5 -4
- package/tests/client/settings-feedback.test.tsx +25 -0
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/server/member-role.test.ts +7 -1
- 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
|
-
//
|
|
129
|
+
// Keep scope metadata stable for layout and search; only enabled actions render controls.
|
|
130
130
|
return {
|
|
131
131
|
...row,
|
|
132
|
-
scopes:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
: scope.title,
|
|
174
|
+
catalog.recordAccess.find(
|
|
175
|
+
(entry) => entry.key === key,
|
|
176
|
+
)?.title ?? key,
|
|
158
177
|
),
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
|
138
|
+
const originalRevisionRef = useRef<string | undefined>(undefined);
|
|
139
|
+
const save = (event: React.FormEvent<HTMLFormElement>) => {
|
|
131
140
|
event.preventDefault();
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
-
</
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
343
|
+
setRequestBusy(true);
|
|
336
344
|
setError(undefined);
|
|
337
345
|
setServerErrors({});
|
|
338
346
|
try {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
568
|
+
</FeedbackForm>
|
|
548
569
|
)
|
|
549
570
|
)}
|
|
550
571
|
{leave.dialog}
|