@nocobase/app-template-default 1.0.0-beta.74 → 1.0.0-beta.76
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 +4 -0
- package/.agents/rules/components.md +3 -1
- package/AGENTS.md +7 -1
- package/README.MD +3 -3
- package/cli/plugins.ts +2 -1
- package/client/components/data-table/data-table.tsx +2 -0
- package/client/components/data-table/pagination.tsx +6 -6
- package/client/components/flow-diagram.tsx +645 -83
- package/client/components/flow-layout.ts +117 -7
- package/client/components/lifecycle-diagram.tsx +175 -37
- package/client/components/record-link.tsx +20 -0
- package/client/components/required-role-setup.tsx +93 -0
- package/client/components/role-list.tsx +71 -31
- package/client/components/setup-readiness-state.ts +135 -0
- package/client/components/setup-readiness.tsx +91 -0
- package/client/components/ui/combobox.tsx +300 -0
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
- package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
- package/client/hooks/use-table-url-state.ts +93 -0
- package/client/lib/navigation-transform.ts +2 -1
- package/client/lib/required-role-setup.ts +98 -0
- package/client/lib/return-to.ts +19 -0
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +38 -1
- package/client/locales/system/settings-zh-CN.ts +34 -2
- package/client/locales/system/zh-CN.ts +10 -0
- package/client/pages/settings/default-access-form.tsx +4 -1
- package/client/pages/settings/default-access.tsx +40 -8
- package/client/pages/settings/role-form.tsx +1 -1
- package/client/pages/settings/roles.tsx +47 -6
- package/client/pages/settings/shared.tsx +15 -13
- package/client/pages/settings/user-detail.tsx +15 -259
- package/client/pages/settings/user-form.tsx +299 -147
- package/client/pages/settings/users.tsx +211 -123
- package/client/plugins.ts +2 -0
- package/config.example.yml +6 -5
- package/package.json +16 -14
- package/server/plugins.ts +2 -0
- package/tests/components/default-access.test.tsx +141 -2
- package/tests/components/role-list.test.tsx +73 -8
- package/tests/components/roles.test.tsx +14 -7
- package/tests/components/settings-continuity.test.tsx +426 -0
- package/tests/components/settings-feedback.test.tsx +304 -0
- package/tests/components/settings.test.tsx +1 -1
- package/tests/components/setup-readiness.test.tsx +370 -0
- package/tests/components/table-url-state.test.tsx +95 -0
- package/tests/components/user-drawer.test.tsx +526 -0
- package/tests/logic/authorization-assignment-safety.test.ts +82 -0
- package/tests/logic/foundation-integration.test.ts +62 -0
- package/tests/logic/return-to.test.ts +19 -0
- package/tsconfig.json +1 -0
|
@@ -12,39 +12,55 @@ import {
|
|
|
12
12
|
type UserRoleScopeOption,
|
|
13
13
|
} from '@nocobase/app-plugin-users/client/user-client';
|
|
14
14
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
useEffect,
|
|
17
|
+
useId,
|
|
18
|
+
useMemo,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
type ReactElement,
|
|
22
|
+
} from 'react';
|
|
16
23
|
import {
|
|
17
24
|
useLocation,
|
|
18
25
|
useNavigate,
|
|
19
26
|
useOutletContext,
|
|
20
27
|
useParams,
|
|
28
|
+
useResolvedPath,
|
|
21
29
|
} from 'react-router';
|
|
22
30
|
import { RouteDialog } from '#components/route-dialog';
|
|
31
|
+
import { RouteDrawer } from '#components/route-drawer';
|
|
23
32
|
import { Button } from '#components/ui/button';
|
|
24
33
|
import { Input } from '#components/ui/input';
|
|
25
34
|
import { Label } from '#components/ui/label';
|
|
26
|
-
import { Switch } from '#components/ui/switch';
|
|
27
35
|
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
Combobox,
|
|
37
|
+
ComboboxTrigger,
|
|
38
|
+
ComboboxInput,
|
|
39
|
+
ComboboxContent,
|
|
40
|
+
ComboboxList,
|
|
41
|
+
ComboboxItem,
|
|
42
|
+
ComboboxEmpty,
|
|
43
|
+
} from '#components/ui/combobox';
|
|
44
|
+
import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
|
|
35
45
|
|
|
36
46
|
export function RoleSelection({
|
|
37
47
|
scope,
|
|
38
48
|
value,
|
|
39
49
|
onChange,
|
|
40
50
|
disabled = false,
|
|
51
|
+
id,
|
|
52
|
+
error,
|
|
41
53
|
}: {
|
|
42
54
|
readonly scope: UserRoleScopeOption;
|
|
43
55
|
readonly value: UserRoleValue;
|
|
44
56
|
readonly onChange: (value: UserRoleValue) => void;
|
|
45
57
|
readonly disabled?: boolean;
|
|
58
|
+
readonly id?: string;
|
|
59
|
+
readonly error?: string;
|
|
46
60
|
}): ReactElement {
|
|
47
61
|
const { t } = useTranslation();
|
|
62
|
+
const generatedId = useId();
|
|
63
|
+
const triggerId = id ?? generatedId;
|
|
48
64
|
const label = scope.labelI18nKey
|
|
49
65
|
? t(scope.labelI18nKey, {
|
|
50
66
|
ns: scope.labelI18nNs,
|
|
@@ -58,76 +74,106 @@ export function RoleSelection({
|
|
|
58
74
|
defaultValue: option.label,
|
|
59
75
|
})
|
|
60
76
|
: option.label;
|
|
77
|
+
const selected =
|
|
78
|
+
typeof value === 'string' ? (value ? [value] : []) : [...value];
|
|
79
|
+
const items = [
|
|
80
|
+
...scope.options.map((option) => option.value),
|
|
81
|
+
...selected.filter(
|
|
82
|
+
(key) => !scope.options.some((option) => option.value === key),
|
|
83
|
+
),
|
|
84
|
+
];
|
|
85
|
+
const itemLabel = (key: string) => {
|
|
86
|
+
const option = scope.options.find((item) => item.value === key);
|
|
87
|
+
return option ? text(option) : key;
|
|
88
|
+
};
|
|
89
|
+
const locked =
|
|
90
|
+
disabled ||
|
|
91
|
+
(scope.selection === 'single' &&
|
|
92
|
+
scope.options.some(
|
|
93
|
+
(option) =>
|
|
94
|
+
selected.includes(option.value) && option.removable === false,
|
|
95
|
+
));
|
|
96
|
+
const summary = selected.length
|
|
97
|
+
? selected.map(itemLabel).join(', ')
|
|
98
|
+
: t('settings.selectRoles');
|
|
61
99
|
return (
|
|
62
100
|
<div className='flex flex-col gap-2'>
|
|
63
|
-
<Label>
|
|
101
|
+
<Label htmlFor={triggerId}>
|
|
64
102
|
{label}
|
|
65
103
|
{scope.requiredOnCreate ? ' *' : ''}
|
|
66
104
|
</Label>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
105
|
+
<Combobox
|
|
106
|
+
items={items}
|
|
107
|
+
itemToStringLabel={itemLabel}
|
|
108
|
+
multiple={scope.selection === 'multiple'}
|
|
109
|
+
value={scope.selection === 'single' ? (selected[0] ?? null) : selected}
|
|
110
|
+
disabled={locked}
|
|
111
|
+
onValueChange={(next) => {
|
|
112
|
+
const proposed =
|
|
113
|
+
next === null ? [] : typeof next === 'string' ? [next] : next;
|
|
114
|
+
if (
|
|
76
115
|
scope.options.some(
|
|
77
|
-
(option) =>
|
|
116
|
+
(option) =>
|
|
117
|
+
(selected.includes(option.value) &&
|
|
118
|
+
!proposed.includes(option.value) &&
|
|
119
|
+
option.removable === false) ||
|
|
120
|
+
(!selected.includes(option.value) &&
|
|
121
|
+
proposed.includes(option.value) &&
|
|
122
|
+
option.assignable === false),
|
|
78
123
|
)
|
|
124
|
+
)
|
|
125
|
+
return;
|
|
126
|
+
onChange(
|
|
127
|
+
scope.selection === 'single' ? (proposed[0] ?? '') : proposed,
|
|
128
|
+
);
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<ComboboxTrigger
|
|
132
|
+
id={triggerId}
|
|
133
|
+
aria-label={label}
|
|
134
|
+
aria-invalid={Boolean(error)}
|
|
135
|
+
aria-describedby={error ? `${triggerId}-error` : undefined}
|
|
136
|
+
disabled={locked}
|
|
137
|
+
render={
|
|
138
|
+
<Button
|
|
139
|
+
variant='outline'
|
|
140
|
+
className='w-full justify-between font-normal'
|
|
141
|
+
/>
|
|
79
142
|
}
|
|
80
|
-
onValueChange={(next) => {
|
|
81
|
-
if (next !== null) onChange(next);
|
|
82
|
-
}}
|
|
83
143
|
>
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
</
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const current = typeof value === 'string' ? [] : value;
|
|
103
|
-
const checked = current.includes(option.value);
|
|
104
|
-
return (
|
|
105
|
-
<label
|
|
106
|
-
key={option.value}
|
|
107
|
-
className='flex items-center gap-3 text-sm'
|
|
108
|
-
>
|
|
109
|
-
<Switch
|
|
110
|
-
checked={checked}
|
|
144
|
+
<span className='min-w-0 truncate' title={summary}>
|
|
145
|
+
{summary}
|
|
146
|
+
</span>
|
|
147
|
+
</ComboboxTrigger>
|
|
148
|
+
<ComboboxContent>
|
|
149
|
+
<ComboboxInput
|
|
150
|
+
aria-label={t('settings.searchRoles')}
|
|
151
|
+
placeholder={t('settings.searchRoles')}
|
|
152
|
+
showTrigger={false}
|
|
153
|
+
/>
|
|
154
|
+
<ComboboxEmpty>{t('settings.noRolesFound')}</ComboboxEmpty>
|
|
155
|
+
<ComboboxList>
|
|
156
|
+
{(key: string) => {
|
|
157
|
+
const option = scope.options.find((item) => item.value === key);
|
|
158
|
+
return (
|
|
159
|
+
<ComboboxItem
|
|
160
|
+
key={key}
|
|
161
|
+
value={key}
|
|
111
162
|
disabled={
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
: option.assignable === false)
|
|
116
|
-
}
|
|
117
|
-
onCheckedChange={(next) =>
|
|
118
|
-
onChange(
|
|
119
|
-
next
|
|
120
|
-
? [...current, option.value]
|
|
121
|
-
: current.filter((item) => item !== option.value),
|
|
122
|
-
)
|
|
163
|
+
selected.includes(key)
|
|
164
|
+
? option?.removable === false
|
|
165
|
+
: option?.assignable === false
|
|
123
166
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
167
|
+
>
|
|
168
|
+
<span className='min-w-0 whitespace-normal break-words'>
|
|
169
|
+
{itemLabel(key)}
|
|
170
|
+
</span>
|
|
171
|
+
</ComboboxItem>
|
|
172
|
+
);
|
|
173
|
+
}}
|
|
174
|
+
</ComboboxList>
|
|
175
|
+
</ComboboxContent>
|
|
176
|
+
</Combobox>
|
|
131
177
|
</div>
|
|
132
178
|
);
|
|
133
179
|
}
|
|
@@ -143,6 +189,7 @@ function UserEditor(): ReactElement {
|
|
|
143
189
|
const location = useLocation();
|
|
144
190
|
const parent = useOutletContext<{
|
|
145
191
|
reload: (updated?: ManagedUser) => void;
|
|
192
|
+
listPath?: string;
|
|
146
193
|
}>();
|
|
147
194
|
const data = useSettingsData<{ data: ManagedUser }>(
|
|
148
195
|
userId ? `users/${encodeURIComponent(userId)}` : 'users/options',
|
|
@@ -175,7 +222,38 @@ function UserEditor(): ReactElement {
|
|
|
175
222
|
email: initial?.email ?? '',
|
|
176
223
|
password: '',
|
|
177
224
|
};
|
|
178
|
-
const [
|
|
225
|
+
const [roleDraft, setRoleDraft] = useState<Record<string, UserRoleValue>>({});
|
|
226
|
+
const roles = { ...(initial?.roleScopes ?? {}), ...roleDraft };
|
|
227
|
+
const changedRoles = Object.fromEntries(
|
|
228
|
+
Object.entries(roleDraft).filter(
|
|
229
|
+
([key, value]) =>
|
|
230
|
+
JSON.stringify(value) !==
|
|
231
|
+
JSON.stringify(
|
|
232
|
+
initial?.roleScopes[key] ?? (typeof value === 'string' ? '' : []),
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
const removedRoles = (options.data?.data.roleScopes ?? []).flatMap(
|
|
237
|
+
(scope) => {
|
|
238
|
+
const before = initial?.roleScopes[scope.key] ?? [];
|
|
239
|
+
const after = roles[scope.key] ?? [];
|
|
240
|
+
return (typeof before === 'string' ? [before] : before)
|
|
241
|
+
.filter(
|
|
242
|
+
(key) => !(typeof after === 'string' ? [after] : after).includes(key),
|
|
243
|
+
)
|
|
244
|
+
.map((key) => {
|
|
245
|
+
const option = scope.options.find((item) => item.value === key);
|
|
246
|
+
return option?.labelI18nKey
|
|
247
|
+
? t(option.labelI18nKey, {
|
|
248
|
+
ns: option.labelI18nNs,
|
|
249
|
+
defaultValue: option.label,
|
|
250
|
+
})
|
|
251
|
+
: (option?.label ?? key);
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
const [confirmRoles, setConfirmRoles] = useState(false);
|
|
256
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
179
257
|
const [dirty, setDirty] = useState(false);
|
|
180
258
|
const [busy, setBusy] = useState(false);
|
|
181
259
|
const leave = useSettingsLeave(dirty, busy);
|
|
@@ -183,10 +261,9 @@ function UserEditor(): ReactElement {
|
|
|
183
261
|
const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
|
|
184
262
|
const [submitted, setSubmitted] = useState(false);
|
|
185
263
|
|
|
264
|
+
const parentPath = useResolvedPath('..').pathname;
|
|
186
265
|
const closeTo = {
|
|
187
|
-
pathname: creating
|
|
188
|
-
? '/settings/users'
|
|
189
|
-
: `/settings/users/${encodeURIComponent(userId)}`,
|
|
266
|
+
pathname: creating ? parentPath : (parent.listPath ?? parentPath),
|
|
190
267
|
search: location.search,
|
|
191
268
|
};
|
|
192
269
|
const fieldError = (name: keyof typeof fields): string | undefined => {
|
|
@@ -201,10 +278,10 @@ function UserEditor(): ReactElement {
|
|
|
201
278
|
return t('settings.invalidEmail');
|
|
202
279
|
return;
|
|
203
280
|
};
|
|
204
|
-
const save = async (
|
|
205
|
-
|
|
281
|
+
const save = async (form: HTMLFormElement, confirmed = false) => {
|
|
282
|
+
if (busy || !(creating ? canCreate && canAssign : canUpdate || canAssign))
|
|
283
|
+
return;
|
|
206
284
|
setSubmitted(true);
|
|
207
|
-
const form = event.currentTarget;
|
|
208
285
|
if (
|
|
209
286
|
!fields.name.trim() ||
|
|
210
287
|
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fields.email) ||
|
|
@@ -213,7 +290,12 @@ function UserEditor(): ReactElement {
|
|
|
213
290
|
form.querySelector<HTMLInputElement>('input:invalid')?.focus();
|
|
214
291
|
return;
|
|
215
292
|
}
|
|
216
|
-
if (
|
|
293
|
+
if (
|
|
294
|
+
data.error ||
|
|
295
|
+
data.loading ||
|
|
296
|
+
(canAssign && (options.loading || options.error || !options.data))
|
|
297
|
+
)
|
|
298
|
+
return;
|
|
217
299
|
if (creating) {
|
|
218
300
|
const missing = (options.data?.data.roleScopes ?? []).filter(
|
|
219
301
|
(scope) => scope.requiredOnCreate && !roles[scope.key]?.length,
|
|
@@ -224,9 +306,20 @@ function UserEditor(): ReactElement {
|
|
|
224
306
|
missing.map((scope) => [scope.key, t('settings.required')]),
|
|
225
307
|
),
|
|
226
308
|
);
|
|
309
|
+
document
|
|
310
|
+
.getElementById(
|
|
311
|
+
`${formId}-roles-${encodeURIComponent(missing[0].key)}`,
|
|
312
|
+
)
|
|
313
|
+
?.focus();
|
|
227
314
|
return;
|
|
228
315
|
}
|
|
229
316
|
}
|
|
317
|
+
if (!creating && !canUpdate && Object.keys(changedRoles).length === 0)
|
|
318
|
+
return;
|
|
319
|
+
if (!creating && removedRoles.length > 0 && !confirmed) {
|
|
320
|
+
setConfirmRoles(true);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
230
323
|
setBusy(true);
|
|
231
324
|
setError(undefined);
|
|
232
325
|
setServerErrors({});
|
|
@@ -244,9 +337,16 @@ function UserEditor(): ReactElement {
|
|
|
244
337
|
});
|
|
245
338
|
else
|
|
246
339
|
updated = await client.update(userId, {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
340
|
+
...(canUpdate
|
|
341
|
+
? {
|
|
342
|
+
name: fields.name.trim(),
|
|
343
|
+
email: fields.email.trim(),
|
|
344
|
+
username: fields.username.trim() || null,
|
|
345
|
+
}
|
|
346
|
+
: {}),
|
|
347
|
+
...(canAssign && Object.keys(changedRoles).length
|
|
348
|
+
? { roleScopes: changedRoles }
|
|
349
|
+
: {}),
|
|
250
350
|
});
|
|
251
351
|
setDirty(false);
|
|
252
352
|
parent.reload(updated);
|
|
@@ -256,9 +356,11 @@ function UserEditor(): ReactElement {
|
|
|
256
356
|
name: fields.name,
|
|
257
357
|
}),
|
|
258
358
|
});
|
|
359
|
+
setConfirmRoles(false);
|
|
259
360
|
leave.allow();
|
|
260
|
-
void navigate(closeTo);
|
|
361
|
+
void navigate(closeTo, { replace: true });
|
|
261
362
|
} catch (next) {
|
|
363
|
+
setConfirmRoles(false);
|
|
262
364
|
if (
|
|
263
365
|
next instanceof ApiClientError &&
|
|
264
366
|
[
|
|
@@ -284,51 +386,61 @@ function UserEditor(): ReactElement {
|
|
|
284
386
|
setBusy(false);
|
|
285
387
|
}
|
|
286
388
|
};
|
|
287
|
-
const allowed = creating ? canCreate && canAssign : canUpdate;
|
|
389
|
+
const allowed = creating ? canCreate && canAssign : canUpdate || canAssign;
|
|
288
390
|
const formId = creating ? 'new-user' : 'edit-user';
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
391
|
+
const focusedRef = useRef(false);
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
if (creating || data.loading || data.error || focusedRef.current) return;
|
|
394
|
+
focusedRef.current = true;
|
|
395
|
+
const frame = requestAnimationFrame(() =>
|
|
396
|
+
document.getElementById(`${formId}-name`)?.focus(),
|
|
397
|
+
);
|
|
398
|
+
return () => cancelAnimationFrame(frame);
|
|
399
|
+
}, [creating, data.error, data.loading, formId]);
|
|
400
|
+
const actions = (
|
|
401
|
+
<>
|
|
402
|
+
<Button
|
|
403
|
+
variant='outline'
|
|
404
|
+
disabled={busy}
|
|
405
|
+
onClick={() => void navigate(closeTo, { replace: true })}
|
|
406
|
+
>
|
|
407
|
+
{t('settings.cancel')}
|
|
408
|
+
</Button>
|
|
409
|
+
{allowed && (
|
|
410
|
+
<Button
|
|
411
|
+
form={formId}
|
|
412
|
+
type='submit'
|
|
413
|
+
disabled={
|
|
414
|
+
busy ||
|
|
415
|
+
(!creating &&
|
|
416
|
+
(!dirty ||
|
|
417
|
+
(!canUpdate && Object.keys(changedRoles).length === 0))) ||
|
|
418
|
+
Boolean(data.error) ||
|
|
419
|
+
(!creating &&
|
|
420
|
+
(data.loading ||
|
|
421
|
+
(canAssign &&
|
|
422
|
+
(options.loading ||
|
|
423
|
+
Boolean(options.error) ||
|
|
424
|
+
!options.data)))) ||
|
|
425
|
+
(creating &&
|
|
426
|
+
(options.loading || Boolean(options.error) || !options.data))
|
|
427
|
+
}
|
|
428
|
+
>
|
|
429
|
+
{t(
|
|
430
|
+
busy
|
|
431
|
+
? 'settings.saving'
|
|
432
|
+
: creating
|
|
433
|
+
? 'settings.create'
|
|
434
|
+
: 'settings.save',
|
|
324
435
|
)}
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
>
|
|
328
|
-
<SettingsError error={data.error} retry={data.reload} />
|
|
329
|
-
{creating && (
|
|
330
|
-
<SettingsError error={options.error} retry={options.reload} />
|
|
436
|
+
</Button>
|
|
331
437
|
)}
|
|
438
|
+
</>
|
|
439
|
+
);
|
|
440
|
+
const body = (
|
|
441
|
+
<>
|
|
442
|
+
<SettingsError error={data.error} retry={data.reload} />
|
|
443
|
+
<SettingsError error={options.error} retry={options.reload} />
|
|
332
444
|
<SettingsError error={error} />
|
|
333
445
|
{!creating && data.loading ? (
|
|
334
446
|
<SettingsLoading />
|
|
@@ -336,7 +448,11 @@ function UserEditor(): ReactElement {
|
|
|
336
448
|
!data.error && (
|
|
337
449
|
<form
|
|
338
450
|
id={formId}
|
|
339
|
-
|
|
451
|
+
ref={formRef}
|
|
452
|
+
onSubmit={(event) => {
|
|
453
|
+
event.preventDefault();
|
|
454
|
+
void save(event.currentTarget);
|
|
455
|
+
}}
|
|
340
456
|
noValidate
|
|
341
457
|
className='flex flex-col gap-4'
|
|
342
458
|
>
|
|
@@ -365,7 +481,7 @@ function UserEditor(): ReactElement {
|
|
|
365
481
|
: 'text'
|
|
366
482
|
}
|
|
367
483
|
autoComplete={name === 'password' ? 'new-password' : 'off'}
|
|
368
|
-
disabled={!allowed || busy}
|
|
484
|
+
disabled={!(creating ? allowed : canUpdate) || busy}
|
|
369
485
|
value={fields[name]}
|
|
370
486
|
aria-invalid={Boolean(fieldError(name))}
|
|
371
487
|
aria-describedby={
|
|
@@ -390,33 +506,69 @@ function UserEditor(): ReactElement {
|
|
|
390
506
|
)}
|
|
391
507
|
</div>
|
|
392
508
|
))}
|
|
393
|
-
{
|
|
394
|
-
|
|
395
|
-
<
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
509
|
+
{options.data?.data.roleScopes.map((scope) => (
|
|
510
|
+
<div key={scope.key}>
|
|
511
|
+
<RoleSelection
|
|
512
|
+
scope={scope}
|
|
513
|
+
id={`${formId}-roles-${encodeURIComponent(scope.key)}`}
|
|
514
|
+
error={serverErrors[scope.key]}
|
|
515
|
+
disabled={!canAssign || busy}
|
|
516
|
+
value={
|
|
517
|
+
roles[scope.key] ?? (scope.selection === 'single' ? '' : [])
|
|
518
|
+
}
|
|
519
|
+
onChange={(value) => {
|
|
520
|
+
setRoleDraft((old) => ({ ...old, [scope.key]: value }));
|
|
521
|
+
setServerErrors((old) => ({ ...old, [scope.key]: '' }));
|
|
522
|
+
setDirty(true);
|
|
523
|
+
}}
|
|
524
|
+
/>
|
|
525
|
+
{serverErrors[scope.key] && (
|
|
526
|
+
<p
|
|
527
|
+
id={`${formId}-roles-${encodeURIComponent(scope.key)}-error`}
|
|
528
|
+
className='text-xs text-destructive'
|
|
529
|
+
>
|
|
530
|
+
{serverErrors[scope.key]}
|
|
531
|
+
</p>
|
|
532
|
+
)}
|
|
533
|
+
</div>
|
|
534
|
+
))}
|
|
416
535
|
</form>
|
|
417
536
|
)
|
|
418
537
|
)}
|
|
419
538
|
{leave.dialog}
|
|
539
|
+
</>
|
|
540
|
+
);
|
|
541
|
+
if (!creating)
|
|
542
|
+
return (
|
|
543
|
+
<RouteDrawer
|
|
544
|
+
title={t('settings.editUser')}
|
|
545
|
+
description={initial?.name}
|
|
546
|
+
closeTo={closeTo}
|
|
547
|
+
beforeClose={leave.guard}
|
|
548
|
+
footer={actions}
|
|
549
|
+
>
|
|
550
|
+
{body}
|
|
551
|
+
<ConfirmAction
|
|
552
|
+
open={confirmRoles}
|
|
553
|
+
title={t('settings.revokeRoleTitle', { name: fields.name })}
|
|
554
|
+
description={`${t('settings.revokeRoleHint')} ${removedRoles.join(', ')}`}
|
|
555
|
+
confirmLabel={t('settings.save')}
|
|
556
|
+
busy={busy}
|
|
557
|
+
onCancel={() => setConfirmRoles(false)}
|
|
558
|
+
onConfirm={() => {
|
|
559
|
+
if (formRef.current) void save(formRef.current, true);
|
|
560
|
+
}}
|
|
561
|
+
/>
|
|
562
|
+
</RouteDrawer>
|
|
563
|
+
);
|
|
564
|
+
return (
|
|
565
|
+
<RouteDialog
|
|
566
|
+
title={t('settings.newUser')}
|
|
567
|
+
closeTo={closeTo}
|
|
568
|
+
beforeClose={leave.guard}
|
|
569
|
+
footer={actions}
|
|
570
|
+
>
|
|
571
|
+
{body}
|
|
420
572
|
</RouteDialog>
|
|
421
573
|
);
|
|
422
574
|
}
|