@nocobase/app-template-default 1.0.0-beta.68 → 1.0.0-beta.70

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 (76) hide show
  1. package/.agents/rules/components.md +5 -1
  2. package/.agents/rules/shell.md +3 -1
  3. package/AGENTS.md +7 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/flow-diagram-css.d.ts +2 -0
  13. package/client/components/flow-diagram.tsx +674 -0
  14. package/client/components/flow-layout.ts +250 -0
  15. package/client/components/lifecycle-diagram-model.ts +217 -0
  16. package/client/components/lifecycle-diagram.tsx +310 -0
  17. package/client/components/permission-editor.tsx +589 -0
  18. package/client/components/permission-filter.tsx +455 -0
  19. package/client/components/route-overlay.tsx +26 -13
  20. package/client/components/ui/checkbox.tsx +25 -0
  21. package/client/components/ui/sidebar.tsx +1 -4
  22. package/client/components/ui/switch.tsx +30 -0
  23. package/client/components/ui/table.tsx +113 -0
  24. package/client/extensions/nocobase-workflow-management/README.md +70 -0
  25. package/client/extensions/nocobase-workflow-management/definition-list.tsx +148 -0
  26. package/client/extensions/nocobase-workflow-management/execution-detail.tsx +230 -0
  27. package/client/extensions/nocobase-workflow-management/execution-list.tsx +175 -0
  28. package/client/extensions/nocobase-workflow-management/layout.tsx +95 -0
  29. package/client/layouts/components/app-sidebar.tsx +1 -1
  30. package/client/layouts/use-sidebar-preference.ts +62 -42
  31. package/client/locales/system/en-US.ts +29 -0
  32. package/client/locales/system/settings-en-US.ts +142 -0
  33. package/client/locales/system/settings-zh-CN.ts +136 -0
  34. package/client/locales/system/zh-CN.ts +29 -0
  35. package/client/pages/auth/auth-page.css +15 -0
  36. package/client/pages/auth/login.tsx +1 -1
  37. package/client/pages/auth/shared.tsx +3 -1
  38. package/client/pages/settings/hooks.tsx +137 -0
  39. package/client/pages/settings/page-permission-titles.ts +7 -0
  40. package/client/pages/settings/page-permissions.ts +42 -0
  41. package/client/pages/settings/permission-filters.tsx +119 -0
  42. package/client/pages/settings/permissions-model.ts +260 -0
  43. package/client/pages/settings/role-form.tsx +405 -0
  44. package/client/pages/settings/roles.tsx +163 -0
  45. package/client/pages/settings/routes.ts +79 -0
  46. package/client/pages/settings/shared.tsx +119 -0
  47. package/client/pages/settings/user-detail.tsx +268 -0
  48. package/client/pages/settings/user-form.tsx +427 -0
  49. package/client/pages/settings/users.tsx +451 -0
  50. package/client/routing/app-router.tsx +4 -1
  51. package/client/routing/session-preparation.tsx +65 -0
  52. package/client/styles.css +4 -7
  53. package/package.json +27 -24
  54. package/tests/components/app-layout-routes.test.tsx +14 -7
  55. package/tests/components/data-table.test.tsx +127 -0
  56. package/tests/components/layout-i18n.test.tsx +8 -1
  57. package/tests/components/navigation-menu.test.tsx +24 -14
  58. package/tests/components/permission-editor.test.tsx +477 -0
  59. package/tests/components/permission-filter-binding.test.tsx +150 -0
  60. package/tests/components/permission-filter.test.tsx +202 -0
  61. package/tests/components/settings.test.tsx +294 -0
  62. package/tests/components/sidebar-permissions.test.tsx +12 -4
  63. package/tests/components/sidebar-preference.test.tsx +209 -14
  64. package/tests/components/user-permissions.test.tsx +142 -0
  65. package/tests/fixtures/theme-tokens.tsx +9 -1
  66. package/tests/logic/asset-url-build.test.ts +0 -1
  67. package/tests/logic/client-routes.test.ts +40 -0
  68. package/tests/logic/client-shell.test.tsx +10 -2
  69. package/tests/logic/config-example.test.ts +30 -0
  70. package/tests/logic/dev-client-watch.test.ts +8 -1
  71. package/tests/logic/page-permission-titles.test.ts +223 -0
  72. package/tests/logic/route-overlay.test.tsx +42 -0
  73. package/tests/logic/session-preparation.test.tsx +223 -0
  74. package/tests/logic/settings-permissions.test.ts +168 -0
  75. package/vite.config.ts +0 -22
  76. package/client/agent-annotations-host.ts +0 -25
@@ -0,0 +1,260 @@
1
+ import type {
2
+ AuthorizationOptionsResponse,
3
+ PermissionGrant,
4
+ LocalizedText,
5
+ } from '@nocobase/app-plugin-authorization/client';
6
+ import type { PermissionEditorSection } from '#components/permission-editor';
7
+
8
+ export type CatalogResource =
9
+ AuthorizationOptionsResponse['sections'][number]['subsections'][number]['resources'][number];
10
+ export const permissionRowId = (
11
+ resource: { type: string; id: string },
12
+ action: string,
13
+ ): string => JSON.stringify([resource.type, resource.id, action]);
14
+ export function setPermission(
15
+ grants: readonly PermissionGrant[],
16
+ resource: CatalogResource,
17
+ action: string,
18
+ enabled: boolean,
19
+ ): readonly PermissionGrant[] {
20
+ const next = grants.map((grant) => ({
21
+ ...grant,
22
+ actions: [...grant.actions],
23
+ }));
24
+ let grant = next.find(
25
+ (item) =>
26
+ item.resource.type === resource.type && item.resource.id === resource.id,
27
+ );
28
+ if (!grant && enabled) {
29
+ grant = { resource: { type: resource.type, id: resource.id }, actions: [] };
30
+ next.push(grant);
31
+ }
32
+ if (grant) {
33
+ grant.actions = grant.actions.filter((item) => item.action !== action);
34
+ if (enabled) {
35
+ const scopes = resource.dataScopes?.[action];
36
+ grant.actions.push({
37
+ action,
38
+ ...(scopes?.length
39
+ ? {
40
+ policy: {
41
+ type: 'composite',
42
+ scopes: Object.fromEntries(
43
+ scopes.map((scope) => [scope.key, scope.defaultValue ?? '']),
44
+ ),
45
+ },
46
+ }
47
+ : {}),
48
+ });
49
+ }
50
+ }
51
+ return next.filter((item) => item.actions.length);
52
+ }
53
+
54
+ export function permissionSections(
55
+ catalog: AuthorizationOptionsResponse,
56
+ grants: readonly PermissionGrant[],
57
+ text: (value: LocalizedText) => string,
58
+ ): readonly PermissionEditorSection[] {
59
+ return catalog.sections
60
+ .map((section) => ({
61
+ id: section.name,
62
+ title: text(section.title),
63
+ groups: section.subsections.flatMap((subsection) =>
64
+ subsection.resources.map((resource) => ({
65
+ id: JSON.stringify([subsection.name, resource.type, resource.id]),
66
+ title: text(resource.title),
67
+ accessOnly:
68
+ resource.type === 'page' &&
69
+ resource.actions.length === 1 &&
70
+ resource.actions[0]?.name === 'access',
71
+ hint: resource.description ? text(resource.description) : undefined,
72
+ rows: resource.actions.map((action) => {
73
+ const granted = grants
74
+ .find(
75
+ (grant) =>
76
+ grant.resource.type === resource.type &&
77
+ grant.resource.id === resource.id,
78
+ )
79
+ ?.actions.find((entry) => entry.action === action.name);
80
+ const row = {
81
+ id: permissionRowId(resource, action.name),
82
+ label: text(action.title),
83
+ actionKey: action.name,
84
+ enabled: Boolean(granted),
85
+ };
86
+ // Scope selectors belong to the action and do not inflate permission counts.
87
+ return {
88
+ ...row,
89
+ scopes: granted
90
+ ? (resource.dataScopes?.[action.name] ?? []).map((scope) => ({
91
+ id: JSON.stringify([
92
+ resource.type,
93
+ resource.id,
94
+ action.name,
95
+ scope.key,
96
+ ]),
97
+ label: text(
98
+ typeof scope.title === 'string' &&
99
+ scope.title === scope.collection
100
+ ? { key: 'settings.recordScope' }
101
+ : scope.title,
102
+ ),
103
+ levels: [
104
+ {
105
+ value: '',
106
+ label: text({ key: 'settings.noRecordScope' }),
107
+ },
108
+ ...scope.recordAccess.map((key) => ({
109
+ value: key,
110
+ label: text(
111
+ catalog.recordAccess.find(
112
+ (entry) => entry.key === key,
113
+ )?.title ?? key,
114
+ ),
115
+ })),
116
+ ],
117
+ level: scopeSelectionKey(
118
+ granted.policy?.scopes &&
119
+ typeof granted.policy.scopes === 'object'
120
+ ? Reflect.get(granted.policy.scopes, scope.key)
121
+ : undefined,
122
+ ),
123
+ }))
124
+ : [],
125
+ };
126
+ }),
127
+ })),
128
+ ),
129
+ }))
130
+ .filter((section) => section.groups.length);
131
+ }
132
+
133
+ export function setScope(
134
+ grants: readonly PermissionGrant[],
135
+ rowId: string,
136
+ value: unknown,
137
+ ): readonly PermissionGrant[] {
138
+ const [type, id, action, scope] = JSON.parse(rowId) as string[];
139
+ return grants.map((grant) =>
140
+ grant.resource.type !== type || grant.resource.id !== id
141
+ ? grant
142
+ : {
143
+ ...grant,
144
+ actions: grant.actions.map((entry) =>
145
+ entry.action !== action
146
+ ? entry
147
+ : {
148
+ ...entry,
149
+ policy: {
150
+ ...entry.policy,
151
+ type: 'composite',
152
+ scopes: {
153
+ ...(entry.policy?.scopes as
154
+ Record<string, unknown> | undefined),
155
+ [scope]: value,
156
+ },
157
+ },
158
+ },
159
+ ),
160
+ },
161
+ );
162
+ }
163
+
164
+ /** Fold an explicit edit batch into one draft without replacing unrelated policies. */
165
+ export function setPermissions(
166
+ grants: readonly PermissionGrant[],
167
+ catalog: AuthorizationOptionsResponse,
168
+ changes: readonly { id: string; enabled: boolean }[],
169
+ ): readonly PermissionGrant[] {
170
+ const resources = catalog.sections.flatMap((section) =>
171
+ section.subsections.flatMap((sub) => sub.resources),
172
+ );
173
+ return changes.reduce((current, { id, enabled }) => {
174
+ const [type, resourceId, action, scope] = JSON.parse(id) as string[];
175
+ if (scope) return current;
176
+ const resource = resources.find(
177
+ (item) => item.type === type && item.id === resourceId,
178
+ );
179
+ if (
180
+ !resource ||
181
+ !action ||
182
+ !resource.actions.some((item) => item.name === action)
183
+ )
184
+ return current;
185
+ const existing =
186
+ current
187
+ .find(
188
+ (grant) =>
189
+ grant.resource.type === type && grant.resource.id === resourceId,
190
+ )
191
+ ?.actions.some((item) => item.action === action) ?? false;
192
+ return existing === enabled
193
+ ? current
194
+ : setPermission(current, resource, action, enabled);
195
+ }, grants);
196
+ }
197
+
198
+ export function getScopeSelection(
199
+ grants: readonly PermissionGrant[],
200
+ rowId: string,
201
+ ): unknown {
202
+ const [type, id, action, scope] = JSON.parse(rowId) as string[];
203
+ const policy = grants
204
+ .find((grant) => grant.resource.type === type && grant.resource.id === id)
205
+ ?.actions.find((entry) => entry.action === action)?.policy;
206
+ return policy?.scopes && typeof policy.scopes === 'object' && scope
207
+ ? Reflect.get(policy.scopes, scope)
208
+ : undefined;
209
+ }
210
+ function scopeSelectionKey(value: unknown): string {
211
+ if (typeof value === 'string') return value;
212
+ if (
213
+ value &&
214
+ typeof value === 'object' &&
215
+ Reflect.get(value, 'type') === 'recordAccess' &&
216
+ typeof Reflect.get(value, 'key') === 'string'
217
+ )
218
+ return String(Reflect.get(value, 'key'));
219
+ return '';
220
+ }
221
+
222
+ /** Legacy bare custom-filter keys require configuration before saving. */
223
+ export function unconfiguredFilter(
224
+ grants: readonly PermissionGrant[],
225
+ ): string | undefined {
226
+ for (const grant of grants)
227
+ for (const action of grant.actions) {
228
+ const scopes = action.policy?.scopes;
229
+ if (!scopes || typeof scopes !== 'object') continue;
230
+ for (const [key, value] of Object.entries(scopes)) {
231
+ if (value === 'customFilter')
232
+ return JSON.stringify([
233
+ grant.resource.type,
234
+ grant.resource.id,
235
+ action.action,
236
+ key,
237
+ ]);
238
+ if (
239
+ !value ||
240
+ typeof value !== 'object' ||
241
+ Reflect.get(value, 'type') !== 'recordAccess' ||
242
+ Reflect.get(value, 'key') !== 'customFilter'
243
+ )
244
+ continue;
245
+ const params: unknown = Reflect.get(value, 'params');
246
+ const filter: unknown =
247
+ params && typeof params === 'object'
248
+ ? Reflect.get(params, 'filter')
249
+ : undefined;
250
+ if (filter === null || !['object', 'boolean'].includes(typeof filter))
251
+ return JSON.stringify([
252
+ grant.resource.type,
253
+ grant.resource.id,
254
+ action.action,
255
+ key,
256
+ ]);
257
+ }
258
+ }
259
+ return undefined;
260
+ }
@@ -0,0 +1,405 @@
1
+ import {
2
+ useSettingsLeave,
3
+ useSettingsData,
4
+ useRolePermission,
5
+ } from './hooks.js';
6
+ import {
7
+ ApiClientError,
8
+ useClientApplication,
9
+ useToaster,
10
+ } from '@nocobase/app-client';
11
+ import { useTranslation } from '@nocobase/i18n/client';
12
+ import {
13
+ useAuthorizationClient,
14
+ type AuthorizationOptionsResponse,
15
+ type LocalizedText,
16
+ type PermissionGrant,
17
+ type PermissionSet,
18
+ } from '@nocobase/app-plugin-authorization/client';
19
+ import { pagePermissionResources } from './page-permissions.js';
20
+ import { pagePermissionTitles } from './page-permission-titles.js';
21
+ import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
22
+ import {
23
+ useLocation,
24
+ useNavigate,
25
+ useOutletContext,
26
+ useParams,
27
+ } from 'react-router';
28
+ import { RouteChildPage } from '#components/route-child-page';
29
+ import { BackButton } from '#components/back-button';
30
+ import { PageContainer } from '#components/page-container';
31
+ import { PageHeader } from '#components/page-header';
32
+ import { PermissionEditor } from '#components/permission-editor';
33
+ import { Button } from '#components/ui/button';
34
+ import { Input } from '#components/ui/input';
35
+ import { Label } from '#components/ui/label';
36
+ import { Badge } from '#components/ui/badge';
37
+ import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
38
+ import { configurePermissionScopes } from './permission-filters.js';
39
+ import {
40
+ permissionSections,
41
+ unconfiguredFilter,
42
+ setPermissions,
43
+ setScope,
44
+ } from './permissions-model.js';
45
+
46
+ function RoleEditor(): ReactElement {
47
+ const { key } = useParams();
48
+ const creating = !key;
49
+ const { t } = useTranslation();
50
+ const app = useClientApplication();
51
+ const authz = useAuthorizationClient();
52
+ const toaster = useToaster();
53
+ const navigate = useNavigate();
54
+ const location = useLocation();
55
+ const parent = useOutletContext<{ reload: () => void }>();
56
+ const request = useSettingsData<{ data: PermissionSet }>(
57
+ key
58
+ ? `authorization/permissionSets/${encodeURIComponent(key)}`
59
+ : 'authorization/permissionSets/options',
60
+ );
61
+ const missingRef = useRef<unknown>(undefined);
62
+ useEffect(() => {
63
+ if (
64
+ request.error instanceof ApiClientError &&
65
+ request.error.status === 404 &&
66
+ missingRef.current !== request.error
67
+ ) {
68
+ missingRef.current = request.error;
69
+ parent.reload();
70
+ }
71
+ }, [request.error, parent]);
72
+ const options = useSettingsData<{ data: AuthorizationOptionsResponse }>(
73
+ 'authorization/permissionSets/options',
74
+ );
75
+ const canCreate = useRolePermission('create');
76
+ const canUpdate = useRolePermission('update');
77
+ const [nameDraft, setNameDraft] = useState<string>();
78
+ const [keyDraft, setKeyDraft] = useState<string>();
79
+ const [grantsDraft, setGrantsDraft] = useState<readonly PermissionGrant[]>();
80
+ const [dirty, setDirty] = useState(false);
81
+ const [editingScope, setEditingScope] = useState<string>();
82
+ const [busy, setBusy] = useState(false);
83
+ const leave = useSettingsLeave(dirty, busy);
84
+ const [error, setError] = useState<unknown>();
85
+
86
+ const [reloadConfirm, setReloadConfirm] = useState(false);
87
+ const [submitted, setSubmitted] = useState(false);
88
+ const [keyConflict, setKeyConflict] = useState(false);
89
+ const text = (value: LocalizedText): string =>
90
+ typeof value === 'string'
91
+ ? value
92
+ : t(value.key, {
93
+ ns: value.ns,
94
+ defaultValue: value.defaultValue ?? value.key,
95
+ });
96
+ const initial = !creating ? request.data?.data : undefined;
97
+ const name = nameDraft ?? (initial ? text(initial.title ?? initial.key) : '');
98
+ const roleKey = keyDraft ?? initial?.key ?? '';
99
+ const grants = grantsDraft ?? initial?.grants ?? [];
100
+ const catalog = useMemo(() => {
101
+ if (!options.data) return undefined;
102
+ const source = options.data.data;
103
+ const pages = pagePermissionResources(
104
+ app.runtime.routes,
105
+ pagePermissionTitles,
106
+ (path) => t('settings.unnamedPage', { path }),
107
+ );
108
+ return {
109
+ ...source,
110
+ sections: [
111
+ {
112
+ name: 'users',
113
+ title: { key: 'settings.userPermissions' },
114
+ order: 0,
115
+ subsections: [
116
+ {
117
+ name: 'users.accounts',
118
+ title: { key: 'settings.users' },
119
+ resources: [
120
+ {
121
+ type: 'user',
122
+ id: '*',
123
+ title: { key: 'settings.users' },
124
+ actions: [
125
+ { name: 'read', title: { key: 'settings.readUsers' } },
126
+ { name: 'create', title: { key: 'settings.createUsers' } },
127
+ { name: 'update', title: { key: 'settings.updateUsers' } },
128
+ { name: 'invite', title: { key: 'settings.inviteUsers' } },
129
+ { name: 'delete', title: { key: 'settings.deleteUsers' } },
130
+ {
131
+ name: 'reset-password',
132
+ title: { key: 'settings.resetUserPassword' },
133
+ },
134
+ { name: 'disable', title: { key: 'settings.disable' } },
135
+ { name: 'enable', title: { key: 'settings.enable' } },
136
+ {
137
+ name: 'assign-role',
138
+ title: { key: 'settings.assignRole' },
139
+ },
140
+ {
141
+ name: 'revoke-sessions',
142
+ title: { key: 'settings.revokeSessions' },
143
+ },
144
+ ],
145
+ },
146
+ ],
147
+ },
148
+ ],
149
+ },
150
+ ...source.sections.map((section) => ({
151
+ ...section,
152
+ subsections: section.subsections.map((sub) => ({
153
+ ...sub,
154
+ resources:
155
+ sub.recordType?.type === 'page'
156
+ ? [...sub.resources, ...pages]
157
+ : sub.resources,
158
+ })),
159
+ })),
160
+ ],
161
+ };
162
+ }, [options.data, app, t]);
163
+ const allowed = creating
164
+ ? canCreate
165
+ : canUpdate &&
166
+ (!request.data?.data.protection ||
167
+ request.data.data.protection.allow.includes('update'));
168
+ const closeTo = { pathname: '/settings/roles', search: location.search };
169
+ const save = async (event: React.FormEvent<HTMLFormElement>) => {
170
+ event.preventDefault();
171
+ if (options.error || !catalog || (!creating && request.error)) return;
172
+ setSubmitted(true);
173
+ if (!name.trim() || !roleKey.trim()) {
174
+ event.currentTarget
175
+ .querySelector<HTMLInputElement>('input:invalid')
176
+ ?.focus();
177
+ return;
178
+ }
179
+ const incomplete = unconfiguredFilter(grants);
180
+ if (incomplete) {
181
+ setEditingScope(incomplete);
182
+ return;
183
+ }
184
+ setEditingScope(undefined);
185
+ setBusy(true);
186
+ setError(undefined);
187
+ setKeyConflict(false);
188
+ try {
189
+ const input = {
190
+ key: roleKey,
191
+ title:
192
+ !creating && initial && nameDraft === undefined
193
+ ? initial.title
194
+ : name.trim(),
195
+ grants,
196
+ ...(!creating && request.data?.data.revision
197
+ ? { expectedRevision: request.data.data.revision }
198
+ : {}),
199
+ };
200
+ if (creating) await authz.createPermissionSet(input);
201
+ else await authz.updatePermissionSet(key, input);
202
+ setDirty(false);
203
+ parent.reload();
204
+ toaster.show({ type: 'success', title: t('settings.saved', { name }) });
205
+ leave.allow();
206
+ void navigate(closeTo);
207
+ } catch (next) {
208
+ if (
209
+ creating &&
210
+ next instanceof Error &&
211
+ 'status' in next &&
212
+ next.status === 409
213
+ ) {
214
+ setKeyConflict(true);
215
+ document.getElementById('role-key')?.focus();
216
+ } else setError(next);
217
+ } finally {
218
+ setBusy(false);
219
+ }
220
+ };
221
+ const sections = catalog
222
+ ? configurePermissionScopes(
223
+ permissionSections(catalog, grants, text),
224
+ catalog,
225
+ grants,
226
+ (key, fallback) => t(key, { defaultValue: fallback ?? key }),
227
+ editingScope,
228
+ setEditingScope,
229
+ (id, value) => {
230
+ setGrantsDraft((current) => setScope(current ?? grants, id, value));
231
+ setDirty(true);
232
+ },
233
+ !allowed || busy,
234
+ )
235
+ : [];
236
+ return (
237
+ <RouteChildPage>
238
+ <PageContainer>
239
+ <BackButton to={closeTo}>{t('settings.roles')}</BackButton>
240
+ <PageHeader
241
+ title={t(creating ? 'settings.newRole' : 'settings.editRole')}
242
+ description={t('settings.rolesDescription')}
243
+ actions={
244
+ dirty ? (
245
+ <Badge variant='secondary'>{t('settings.unsaved')}</Badge>
246
+ ) : undefined
247
+ }
248
+ />
249
+ <SettingsError error={request.error} retry={request.reload} />
250
+ <SettingsError error={options.error} retry={options.reload} />
251
+ <SettingsError error={error} />
252
+ {error instanceof Error &&
253
+ 'status' in error &&
254
+ error.status === 409 && (
255
+ <Button variant='outline' onClick={() => setReloadConfirm(true)}>
256
+ {t('settings.reload')}
257
+ </Button>
258
+ )}
259
+ {(request.loading && !creating) || options.loading ? (
260
+ <SettingsLoading />
261
+ ) : (
262
+ !request.error &&
263
+ !options.error && (
264
+ <form
265
+ onSubmit={(event) => void save(event)}
266
+ noValidate
267
+ className='flex flex-col gap-6'
268
+ >
269
+ <div className='grid gap-4 sm:grid-cols-2'>
270
+ <div className='flex flex-col gap-2'>
271
+ <Label htmlFor='role-key'>{t('settings.key')} *</Label>
272
+ <Input
273
+ id='role-key'
274
+ required
275
+ value={roleKey}
276
+ disabled={!creating || !allowed || busy}
277
+ aria-invalid={keyConflict || (submitted && !roleKey.trim())}
278
+ onChange={(event) => {
279
+ setKeyConflict(false);
280
+ setKeyDraft(event.target.value);
281
+ setDirty(true);
282
+ }}
283
+ />
284
+ {(keyConflict || (submitted && !roleKey.trim())) && (
285
+ <p className='text-xs text-destructive'>
286
+ {t(
287
+ keyConflict
288
+ ? 'settings.keyConflict'
289
+ : 'settings.required',
290
+ )}
291
+ </p>
292
+ )}
293
+ </div>
294
+ <div className='flex flex-col gap-2'>
295
+ <Label htmlFor='role-name'>{t('settings.roleName')} *</Label>
296
+ <Input
297
+ id='role-name'
298
+ required
299
+ value={name}
300
+ disabled={!allowed || busy}
301
+ aria-invalid={submitted && !name.trim()}
302
+ onChange={(event) => {
303
+ setNameDraft(event.target.value);
304
+ setDirty(true);
305
+ }}
306
+ />
307
+ {submitted && !name.trim() && (
308
+ <p className='text-xs text-destructive'>
309
+ {t('settings.required')}
310
+ </p>
311
+ )}
312
+ </div>
313
+ </div>
314
+ {request.data?.data.protection && (
315
+ <p className='text-sm text-muted-foreground'>
316
+ {t('settings.protectedHint')}
317
+ </p>
318
+ )}
319
+ <PermissionEditor
320
+ sections={sections}
321
+ readOnly={!allowed || busy}
322
+ labels={{
323
+ selectAll: t('settings.selectAllPermissions'),
324
+ selectVisible: (name) =>
325
+ t('settings.selectVisiblePermissions', { name }),
326
+ levelFor: (name) => t('settings.levelFor', { name }),
327
+ search: t('settings.permissionSearch'),
328
+ enabledOnly: t('settings.enabledOnly'),
329
+ clear: t('settings.clear'),
330
+ empty: t('settings.noPermissions'),
331
+ noResults: t('settings.noResults'),
332
+ navigation: t('settings.permissionCategories'),
333
+ summary: (enabled, total) =>
334
+ t('settings.permissionSummary', { enabled, total }),
335
+ }}
336
+ onEnabledChange={(id, enabled) => {
337
+ if (!catalog) return;
338
+ setGrantsDraft((current) =>
339
+ setPermissions(current ?? grants, catalog, [
340
+ { id, enabled },
341
+ ]),
342
+ );
343
+ setDirty(true);
344
+ }}
345
+ onEnabledBatchChange={(changes) => {
346
+ if (!catalog) return;
347
+ setGrantsDraft((current) =>
348
+ setPermissions(current ?? grants, catalog, changes),
349
+ );
350
+ setDirty(true);
351
+ }}
352
+ onLevelChange={(id, value) => {
353
+ if (value === 'customFilter') {
354
+ setEditingScope(id);
355
+ return;
356
+ }
357
+ setGrantsDraft((current) =>
358
+ setScope(current ?? grants, id, value),
359
+ );
360
+ setDirty(true);
361
+ }}
362
+ />
363
+ <div className='sticky bottom-0 flex justify-end gap-2 border-t bg-background py-4'>
364
+ <Button
365
+ variant='outline'
366
+ disabled={busy}
367
+ onClick={() => void navigate(closeTo)}
368
+ >
369
+ {t('settings.cancel')}
370
+ </Button>
371
+ {allowed && (
372
+ <Button type='submit' disabled={busy || !dirty}>
373
+ {t(busy ? 'settings.saving' : 'settings.save')}
374
+ </Button>
375
+ )}
376
+ </div>
377
+ </form>
378
+ )
379
+ )}
380
+ {leave.dialog}
381
+ <ConfirmAction
382
+ open={reloadConfirm}
383
+ title={t('settings.discardTitle')}
384
+ description={t('settings.discardHint')}
385
+ busy={false}
386
+ onCancel={() => setReloadConfirm(false)}
387
+ onConfirm={() => {
388
+ setNameDraft(undefined);
389
+ setKeyDraft(undefined);
390
+ setGrantsDraft(undefined);
391
+ setDirty(false);
392
+ setError(undefined);
393
+ setReloadConfirm(false);
394
+ request.reload();
395
+ }}
396
+ />
397
+ </PageContainer>
398
+ </RouteChildPage>
399
+ );
400
+ }
401
+
402
+ export default function RoleForm(): ReactElement {
403
+ const { key } = useParams();
404
+ return <RoleEditor key={key ?? '$new'} />;
405
+ }