@nocobase/app-template-default 1.0.0-beta.72 → 1.0.0-beta.74

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 (33) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.MD +2 -0
  3. package/client/components/default-access-editor.tsx +183 -0
  4. package/client/components/permission-editor.tsx +216 -150
  5. package/client/components/role-list.tsx +61 -0
  6. package/client/lib/navigation-transform.ts +28 -1
  7. package/client/locales/system/settings-en-US.ts +73 -2
  8. package/client/locales/system/settings-zh-CN.ts +65 -2
  9. package/client/pages/settings/default-access-form.tsx +499 -0
  10. package/client/pages/settings/default-access-model.ts +154 -0
  11. package/client/pages/settings/default-access.tsx +407 -0
  12. package/client/pages/settings/permission-filters.tsx +43 -37
  13. package/client/pages/settings/permission-management-page.tsx +105 -0
  14. package/client/pages/settings/permissions-model.ts +37 -1
  15. package/client/pages/settings/role-form.tsx +46 -18
  16. package/client/pages/settings/roles.tsx +23 -43
  17. package/client/pages/settings/routes.ts +78 -19
  18. package/client/pages/settings/shared.tsx +3 -0
  19. package/client/pages/settings/users.tsx +1 -1
  20. package/client/routing/route-navigation.ts +21 -1
  21. package/package.json +22 -21
  22. package/tests/components/default-access.test.tsx +554 -0
  23. package/tests/components/page-harness.test.tsx +132 -112
  24. package/tests/components/permission-editor.test.tsx +233 -73
  25. package/tests/components/permission-management-tabs.test.tsx +128 -0
  26. package/tests/components/role-list.test.tsx +71 -0
  27. package/tests/components/roles.test.tsx +78 -0
  28. package/tests/components/user-permissions.test.tsx +12 -0
  29. package/tests/logic/client-routes.test.ts +8 -0
  30. package/tests/logic/default-access-model.test.ts +167 -0
  31. package/tests/logic/permission-management-routes.test.ts +64 -0
  32. package/tests/logic/settings-permissions.test.ts +89 -0
  33. package/vitest.config.ts +7 -6
@@ -1,8 +1,78 @@
1
1
  const settings = {
2
+ permissionManagement: 'Permission management',
3
+ defaultAccess: {
4
+ noResourcesShort: 'No configurable objects available',
5
+ scopeInvalid:
6
+ 'Choose a valid data scope or complete its filter conditions.',
7
+
8
+ deleteChanged:
9
+ 'This rule changed or was removed. The list was refreshed; review it before deleting.',
10
+ filterDescription:
11
+ 'Apply conditions to this rule draft, then save the rule to make them effective.',
12
+ fieldLabels: { name: 'Name', id: 'ID' },
13
+ delete: 'Delete',
14
+ title: 'Default data scopes',
15
+ description:
16
+ 'Adds data access for users with operation permission, combined with role-granted scopes and subject to restriction rules.',
17
+ manage: 'Manage default data scopes',
18
+ new: 'New default scope',
19
+ edit: 'Default scope details',
20
+ resource: 'Business object',
21
+ chooseResource: 'Choose a business object',
22
+ action: 'Operation',
23
+ scope: 'Data scope',
24
+ scopes: 'Operations and data scopes',
25
+ none: 'No default scope',
26
+ all: 'All records',
27
+ custom: 'Custom filter',
28
+ records: 'Selected records ({{count}})',
29
+ retained: 'Retain existing configuration',
30
+ search: 'Search business objects or keys',
31
+ empty: 'No default data scopes',
32
+ emptyHint:
33
+ 'Create a rule to configure baseline access for a business object.',
34
+ readOnlyEmpty: 'Ask an administrator to configure baseline data access.',
35
+ noResources:
36
+ 'Every available business object already has a default rule, or no configurable objects are registered.',
37
+ unsupported:
38
+ 'This rule includes configuration absent from the current plugin catalog. It is preserved and shown read-only.',
39
+ changed:
40
+ 'This rule changed or was removed while you were editing. Reload it before saving. Your draft has not been saved.',
41
+ duplicate:
42
+ 'This key or business object already has a default rule. Use a different key or edit the existing rule.',
43
+ invalid:
44
+ 'The configuration is not accepted. Check the business object, operation and data scope.',
45
+ keyRequired: 'Enter a key other than options, subjects or records.',
46
+ resourceRequired: 'Choose a business object.',
47
+ reload: 'Reload rule',
48
+ saved: 'Saved default scope “{{name}}”',
49
+ deleted: 'Deleted default scope “{{name}}”',
50
+ deleteTitle: 'Delete default scope “{{name}}”?',
51
+ deleteHint:
52
+ 'This removes the baseline access supplied by this rule. Existing role grants and other rules remain. This cannot be undone.',
53
+ back: 'Back to default scopes',
54
+ open: 'View / edit',
55
+ },
56
+ scopeHintFor: 'About the data range for {{name}}',
57
+ scopeExplanation:
58
+ 'Each range applies to its corresponding data. The operation must also pass its authorization checks.',
59
+ noAdditionalScopeHint:
60
+ 'This action grants no additional record access here. Default access, sharing and restriction rules still determine actual access.',
61
+ systemToolsHint:
62
+ 'Manage system configuration and tools. Review the capabilities each action includes before granting access.',
63
+ userAccountsHint:
64
+ 'Manage user information, account security and role assignments. Review each permission separately.',
65
+ businessOperationsHint:
66
+ 'Choose the operations this role can perform and the data each operation can use.',
67
+ pageAccessHint:
68
+ 'Controls which pages this role can open. Data and actions on each page require their own permissions.',
69
+ roleAssignment: 'Role assignment',
70
+ accountSecurity: 'Account security',
71
+ userInformation: 'User information',
2
72
  unnamedPage: 'Unnamed page ({{path}})',
3
73
  revokeRoles: 'Revoke roles',
4
74
  keyConflict: 'This role key is already in use.',
5
- userPermissions: 'User management',
75
+ userPermissions: 'Users and accounts',
6
76
  readUsers: 'View users',
7
77
  createUsers: 'Create users',
8
78
  updateUsers: 'Edit users',
@@ -95,10 +165,11 @@ const settings = {
95
165
  noPermissions: 'No permissions to configure',
96
166
  permissionCategories: 'Permission categories',
97
167
  selectAllPermissions: 'All',
168
+ dataRanges: 'Data ranges ({{count}})',
98
169
  selectVisiblePermissions: 'Select visible permissions in {{name}}',
99
170
  permissionSummary: '{{enabled}} of {{total}} enabled',
100
171
  preservedRecordScope: 'Keep existing configuration',
101
- noRecordScope: 'No records (not authorized)',
172
+ noRecordScope: 'No additional record access',
102
173
  permissionFilter: {
103
174
  limit: 'Up to 20 conditions',
104
175
  configure: 'Conditions',
@@ -1,8 +1,70 @@
1
1
  const settings = {
2
+ permissionManagement: '权限管理',
3
+ defaultAccess: {
4
+ noResourcesShort: '暂无可配置的业务对象',
5
+ scopeInvalid: '请重新选择有效的数据范围或完善筛选条件。',
6
+
7
+ deleteChanged: '此规则已被修改或删除,列表已刷新,请重新确认后再删除。',
8
+ filterDescription: '将条件应用到当前规则草稿,保存规则后生效。',
9
+ fieldLabels: { name: '名称', id: '标识' },
10
+ delete: '删除',
11
+ title: '默认数据范围',
12
+ description:
13
+ '为已有操作权限的用户补充数据范围,与角色授权范围合并,最终仍受限制规则约束。',
14
+ manage: '管理默认数据范围',
15
+ new: '新建默认范围',
16
+ edit: '默认范围详情',
17
+ resource: '业务对象',
18
+ chooseResource: '请选择业务对象',
19
+ action: '操作',
20
+ scope: '数据范围',
21
+ scopes: '操作与数据范围',
22
+ none: '不设置默认范围',
23
+ all: '所有记录',
24
+ custom: '自定义筛选',
25
+ records: '指定记录({{count}} 条)',
26
+ retained: '保留现有配置',
27
+ search: '搜索业务对象或标识',
28
+ empty: '暂无默认数据范围',
29
+ emptyHint: '新建规则,为业务对象配置基础数据访问范围。',
30
+ readOnlyEmpty: '请联系管理员配置基础数据访问范围。',
31
+ noResources: '可用业务对象均已配置默认规则,或当前没有可配置的业务对象。',
32
+ unsupported:
33
+ '此规则包含当前插件目录中不存在的配置,已保留原值并以只读方式展示。',
34
+ changed:
35
+ '此规则在编辑期间已被修改或删除,请重新加载后再保存。当前草稿未保存。',
36
+ duplicate: '此标识或业务对象已有默认规则,请更换标识或编辑已有规则。',
37
+ invalid: '配置未通过校验,请检查业务对象、操作和数据范围。',
38
+ keyRequired: '请填写标识,不能使用 options、subjects 或 records。',
39
+ resourceRequired: '请选择业务对象。',
40
+ reload: '重新加载规则',
41
+ saved: '已保存默认范围“{{name}}”',
42
+ deleted: '已删除默认范围“{{name}}”',
43
+ deleteTitle: '删除默认范围“{{name}}”?',
44
+ deleteHint:
45
+ '删除后,此规则提供的基础访问范围将失效;已有角色授权和其他规则保留。此操作无法撤销。',
46
+ back: '返回默认数据范围',
47
+ open: '查看或编辑',
48
+ },
49
+ scopeHintFor: '关于“{{name}}”的数据范围',
50
+ scopeExplanation:
51
+ '每项范围分别约束对应数据,实际操作还需通过相应的权限检查。',
52
+ noAdditionalScopeHint:
53
+ '此操作在这里不额外授予数据访问权限,实际可访问范围仍取决于默认数据范围、共享与限制规则。',
54
+ systemToolsHint: '管理系统配置与工具,授权前请确认每项操作包含的能力。',
55
+ userAccountsHint:
56
+ '管理用户资料、账号安全和角色分配,请逐项确认需要授予的权限。',
57
+ businessOperationsHint:
58
+ '设置此角色可以执行的业务操作,以及每项操作可使用的数据范围。',
59
+ pageAccessHint:
60
+ '控制此角色可以进入哪些页面。页面中的数据和操作仍需单独授权。',
61
+ roleAssignment: '角色分配',
62
+ accountSecurity: '账号安全',
63
+ userInformation: '用户资料',
2
64
  unnamedPage: '未命名页面({{path}})',
3
65
  revokeRoles: '撤销角色',
4
66
  keyConflict: '此角色标识已被使用。',
5
- userPermissions: '用户管理',
67
+ userPermissions: '用户与账号',
6
68
  readUsers: '查看用户',
7
69
  createUsers: '创建用户',
8
70
  updateUsers: '编辑用户',
@@ -92,9 +154,10 @@ const settings = {
92
154
  noPermissions: '暂无可配置权限',
93
155
  permissionCategories: '权限分类',
94
156
  selectAllPermissions: '全选',
157
+ dataRanges: '数据范围({{count}})',
95
158
  selectVisiblePermissions: '选择“{{name}}”中显示的权限',
96
159
  permissionSummary: '已授权 {{enabled}} / {{total}}',
97
- noRecordScope: '不允许访问数据',
160
+ noRecordScope: '不额外授权数据范围',
98
161
  preservedRecordScope: '保留现有配置',
99
162
  permissionFilter: {
100
163
  limit: '最多支持 20 个条件',
@@ -0,0 +1,499 @@
1
+ import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
2
+ import {
3
+ useCan,
4
+ type AuthorizationOptionsResponse,
5
+ type LocalizedText,
6
+ } from '@nocobase/app-plugin-authorization/client';
7
+ import { useTranslation } from '@nocobase/i18n/client';
8
+ import { useRef, useState, type ReactElement } from 'react';
9
+ import {
10
+ useLocation,
11
+ useNavigate,
12
+ useOutletContext,
13
+ useParams,
14
+ } from 'react-router';
15
+ import { PageContainer } from '#components/page-container';
16
+ import { PageHeader } from '#components/page-header';
17
+ import { RouteChildPage } from '#components/route-child-page';
18
+ import {
19
+ DefaultAccessEditor,
20
+ type DefaultAccessScopeControl,
21
+ } from '#components/default-access-editor';
22
+ import { PermissionFilter } from '#components/permission-filter';
23
+ import { Button } from '#components/ui/button';
24
+ import { Alert, AlertDescription } from '#components/ui/alert';
25
+ import { SettingsError, SettingsLoading } from './shared.js';
26
+ import { useSettingsData, useSettingsLeave } from './hooks.js';
27
+ import { permissionFilterLabels } from './permission-filters.js';
28
+ import {
29
+ actionKey,
30
+ resourceKey,
31
+ ruleResources,
32
+ ruleScopes,
33
+ replaceRuleScope,
34
+ editableRule,
35
+ type DefaultAccessRule,
36
+ type ScopeSelection,
37
+ } from './default-access-model.js';
38
+
39
+ function DefaultAccessFormBody(): ReactElement {
40
+ const { key } = useParams();
41
+ const creating = key === undefined;
42
+ const { t } = useTranslation();
43
+ const api = useApiClient();
44
+ const toaster = useToaster();
45
+ const navigate = useNavigate();
46
+ const location = useLocation();
47
+ const parent = useOutletContext<{
48
+ reload: () => void;
49
+ saved: (rule: DefaultAccessRule) => void;
50
+ }>();
51
+ const rules = useSettingsData<{ data: readonly DefaultAccessRule[] }>(
52
+ 'authorization/defaultAccess',
53
+ );
54
+ const catalog = useSettingsData<{ data: AuthorizationOptionsResponse }>(
55
+ 'authorization/defaultAccess/options',
56
+ );
57
+ const canWrite = useCan({
58
+ resource: { type: 'settings', id: 'authorization.default-access' },
59
+ action: creating ? 'create' : 'update',
60
+ }).can;
61
+ const [draft, setDraft] = useState<DefaultAccessRule>();
62
+ const [busy, setBusy] = useState(false);
63
+ const [error, setError] = useState<unknown>();
64
+ const [message, setMessage] = useState<string>();
65
+ const [submitted, setSubmitted] = useState(false);
66
+ const [fieldErrors, setFieldErrors] = useState<{
67
+ key?: string;
68
+ resource?: string;
69
+ }>({});
70
+ const [scopeErrors, setScopeErrors] = useState<Record<string, string>>({});
71
+ const [editing, setEditing] = useState<string>();
72
+ const formRef = useRef<HTMLFormElement>(null);
73
+ const initial = creating
74
+ ? undefined
75
+ : rules.data?.data.find((rule) => rule.key === key);
76
+ const value = draft ??
77
+ initial ?? { key: '', resource: { type: '', id: '' }, actions: [] };
78
+ const dirty =
79
+ draft !== undefined && JSON.stringify(draft) !== JSON.stringify(initial);
80
+ const leave = useSettingsLeave(dirty, busy);
81
+ const close = () => {
82
+ void navigate({ pathname: '/default-access', search: location.search });
83
+ };
84
+ const text = (value: LocalizedText): string =>
85
+ typeof value === 'string'
86
+ ? value
87
+ : t(value.key, {
88
+ ns: value.ns,
89
+ defaultValue: value.defaultValue ?? value.key,
90
+ });
91
+ const resources = catalog.data ? ruleResources(catalog.data.data) : [];
92
+ const resource = resources.find(
93
+ (entry) => resourceKey(entry) === resourceKey(value.resource),
94
+ );
95
+ const scopes = catalog.data ? ruleScopes(resource, catalog.data.data) : [];
96
+ const supported = creating
97
+ ? scopes.length > 0
98
+ : Boolean(catalog.data && editableRule(value, scopes, catalog.data.data));
99
+ const readOnly = !canWrite || busy || (!creating && !supported);
100
+ const options = resources
101
+ .filter(
102
+ (entry) =>
103
+ !creating ||
104
+ !rules.data?.data.some(
105
+ (rule) => resourceKey(rule.resource) === resourceKey(entry),
106
+ ),
107
+ )
108
+ .map((entry) => ({ value: resourceKey(entry), label: text(entry.title) }));
109
+ if (
110
+ !creating &&
111
+ !options.some((option) => option.value === resourceKey(value.resource))
112
+ )
113
+ options.push({
114
+ value: resourceKey(value.resource),
115
+ label: value.resource.id,
116
+ });
117
+ const invalidKey =
118
+ !value.key.trim() ||
119
+ ['options', 'subjects', 'records'].includes(value.key.trim());
120
+ const fieldLabels: Record<string, string> = {
121
+ name: t('settings.defaultAccess.fieldLabels.name'),
122
+ id: t('settings.defaultAccess.fieldLabels.id'),
123
+ };
124
+ const labels = {
125
+ ...permissionFilterLabels((label, fallback) =>
126
+ t(label, { defaultValue: fallback ?? label }),
127
+ ),
128
+ description: t('settings.defaultAccess.filterDescription'),
129
+ };
130
+ const controls: DefaultAccessScopeControl[] = scopes.map((scope) => {
131
+ const selected = value.actions.find(
132
+ (entry) => actionKey(entry) === scope.id,
133
+ )?.selection;
134
+ const selectedValue = selected ? JSON.stringify(selected) : '';
135
+ const choices = [
136
+ { value: '', label: t('settings.defaultAccess.none') },
137
+ ...(!scope.recordAccess.includes('allRecords')
138
+ ? [
139
+ {
140
+ value: JSON.stringify({ type: 'all' }),
141
+ label: t('settings.defaultAccess.all'),
142
+ },
143
+ ]
144
+ : []),
145
+ ...scope.recordAccess.map((preset) => ({
146
+ value: JSON.stringify({ type: 'recordAccess', key: preset }),
147
+ label: text(
148
+ catalog.data?.data.recordAccess.find((entry) => entry.key === preset)
149
+ ?.title ?? preset,
150
+ ),
151
+ })),
152
+ ];
153
+ if (selected && !choices.some((choice) => choice.value === selectedValue))
154
+ choices.push({
155
+ value: selectedValue,
156
+ label:
157
+ selected.type === 'all'
158
+ ? t('settings.defaultAccess.all')
159
+ : selected.type === 'records'
160
+ ? t('settings.defaultAccess.records', {
161
+ count: selected.ids.length,
162
+ })
163
+ : selected.key === 'customFilter'
164
+ ? t('settings.defaultAccess.custom')
165
+ : t('settings.defaultAccess.retained'),
166
+ });
167
+ const custom =
168
+ selected?.type === 'recordAccess' && selected.key === 'customFilter';
169
+ const params =
170
+ custom && selected.params && typeof selected.params === 'object'
171
+ ? (Reflect.get(selected.params, 'filter') as unknown)
172
+ : undefined;
173
+ return {
174
+ id: scope.id,
175
+ error: scopeErrors[scope.id],
176
+ action: text(scope.actionTitle),
177
+ scope: text(scope.title),
178
+ value: selectedValue,
179
+ options: choices,
180
+ configuration:
181
+ custom || editing === scope.id ? (
182
+ <PermissionFilter
183
+ open={editing === scope.id}
184
+ onOpenChange={(open) => setEditing(open ? scope.id : undefined)}
185
+ value={params}
186
+ fields={scope.fields.map((field) => ({
187
+ value: field,
188
+ label: fieldLabels[field] ?? field,
189
+ }))}
190
+ labels={labels}
191
+ disabled={readOnly}
192
+ onApply={(filter) => {
193
+ setDraft(
194
+ replaceRuleScope(value, scope, {
195
+ type: 'recordAccess',
196
+ key: 'customFilter',
197
+ params: { filter },
198
+ }),
199
+ );
200
+ setScopeErrors((old) => ({ ...old, [scope.id]: '' }));
201
+ setEditing(undefined);
202
+ }}
203
+ />
204
+ ) : undefined,
205
+ };
206
+ });
207
+ // Never discard entries no longer present in the live plugin catalog.
208
+ for (const action of value.actions)
209
+ if (!scopes.some((scope) => scope.id === actionKey(action)))
210
+ controls.push({
211
+ id: actionKey(action),
212
+ action: action.action,
213
+ scope: action.scopeKey ?? value.resource.id,
214
+ value: JSON.stringify(action.selection),
215
+ options: [
216
+ {
217
+ value: JSON.stringify(action.selection),
218
+ label: t('settings.defaultAccess.retained'),
219
+ },
220
+ ],
221
+ });
222
+ const save = async () => {
223
+ setSubmitted(true);
224
+ setFieldErrors({});
225
+ setScopeErrors({});
226
+ setMessage(undefined);
227
+ setError(undefined);
228
+ if (invalidKey || !resource || !supported || !canWrite || busy) {
229
+ requestAnimationFrame(() =>
230
+ formRef.current
231
+ ?.querySelector<HTMLElement>('[aria-invalid="true"]')
232
+ ?.focus(),
233
+ );
234
+ return;
235
+ }
236
+ setBusy(true);
237
+ try {
238
+ const latest = await api.request<{ data: readonly DefaultAccessRule[] }>({
239
+ path: 'authorization/defaultAccess',
240
+ });
241
+ if (
242
+ !creating &&
243
+ JSON.stringify(latest.data.find((rule) => rule.key === key)) !==
244
+ JSON.stringify(initial)
245
+ ) {
246
+ setMessage(t('settings.defaultAccess.changed'));
247
+ return;
248
+ }
249
+ const saved = await api.request<{ data: DefaultAccessRule }>({
250
+ path: creating
251
+ ? 'authorization/defaultAccess'
252
+ : `authorization/defaultAccess/${encodeURIComponent(key ?? '')}`,
253
+ method: creating ? 'POST' : 'PATCH',
254
+ json: creating
255
+ ? { ...value, key: value.key.trim() }
256
+ : { actions: value.actions },
257
+ });
258
+ toaster.show({
259
+ type: 'success',
260
+ title: t('settings.defaultAccess.saved', { name: value.key }),
261
+ });
262
+ leave.allow();
263
+ parent.saved(saved.data);
264
+ close();
265
+ } catch (next) {
266
+ if (
267
+ next instanceof ApiClientError &&
268
+ next.payload &&
269
+ typeof next.payload === 'object'
270
+ ) {
271
+ const detail: unknown = Reflect.get(next.payload, 'error');
272
+ const violations: unknown =
273
+ detail && typeof detail === 'object'
274
+ ? Reflect.get(detail, 'fieldViolations')
275
+ : undefined;
276
+ if (Array.isArray(violations)) {
277
+ const errors: { key?: string; resource?: string } = {};
278
+ const scopeErrors: Record<string, string> = {};
279
+ for (const violation of violations) {
280
+ const field: unknown =
281
+ violation && typeof violation === 'object'
282
+ ? Reflect.get(violation, 'field')
283
+ : undefined;
284
+ if (field === 'key')
285
+ errors.key = t('settings.defaultAccess.keyRequired');
286
+ else if (typeof field === 'string' && field.startsWith('resource'))
287
+ errors.resource = t('settings.defaultAccess.resourceRequired');
288
+ else if (typeof field === 'string') {
289
+ const index = /^actions\.(\d+)(?:\.|$)/.exec(field);
290
+ const entry = index ? value.actions[Number(index[1])] : undefined;
291
+ if (entry)
292
+ scopeErrors[actionKey(entry)] = t(
293
+ 'settings.defaultAccess.scopeInvalid',
294
+ );
295
+ }
296
+ }
297
+ setFieldErrors(errors);
298
+ setScopeErrors(scopeErrors);
299
+ requestAnimationFrame(() =>
300
+ formRef.current
301
+ ?.querySelector<HTMLElement>('[aria-invalid="true"]')
302
+ ?.focus(),
303
+ );
304
+ }
305
+ }
306
+ const reason: unknown =
307
+ next && typeof next === 'object'
308
+ ? Reflect.get(next, 'reason')
309
+ : undefined;
310
+ if (
311
+ reason === 'DEFAULT_ACCESS_CONFLICT' ||
312
+ reason === 'RULE_ALREADY_EXISTS'
313
+ )
314
+ setMessage(t('settings.defaultAccess.duplicate'));
315
+ else if (
316
+ reason === 'INVALID_AUTHORIZATION_INPUT' ||
317
+ reason === 'INVALID_INPUT'
318
+ )
319
+ setMessage(t('settings.defaultAccess.invalid'));
320
+ else setError(next);
321
+ } finally {
322
+ setBusy(false);
323
+ }
324
+ };
325
+ return (
326
+ <PageContainer className='max-w-4xl'>
327
+ <PageHeader
328
+ title={t(
329
+ creating
330
+ ? 'settings.defaultAccess.new'
331
+ : 'settings.defaultAccess.edit',
332
+ )}
333
+ description={t('settings.defaultAccess.description')}
334
+ />
335
+ <SettingsError
336
+ error={rules.error || catalog.error}
337
+ retry={() => {
338
+ rules.reload();
339
+ catalog.reload();
340
+ }}
341
+ />
342
+ {(rules.loading && !rules.data) || (catalog.loading && !catalog.data) ? (
343
+ <SettingsLoading />
344
+ ) : (
345
+ !rules.error &&
346
+ !catalog.error && (
347
+ <>
348
+ {!creating && !initial ? (
349
+ <p role='status'>{t('settings.missing')}</p>
350
+ ) : (
351
+ <form
352
+ ref={formRef}
353
+ noValidate
354
+ className='flex flex-col gap-6'
355
+ onSubmit={(event) => {
356
+ event.preventDefault();
357
+ void save();
358
+ }}
359
+ >
360
+ <SettingsError error={error} />
361
+ {message && (
362
+ <Alert variant='destructive'>
363
+ <AlertDescription>
364
+ {message}
365
+ {!creating && (
366
+ <Button
367
+ type='button'
368
+ variant='outline'
369
+ onClick={() => {
370
+ setDraft(undefined);
371
+ setMessage(undefined);
372
+ rules.reload();
373
+ }}
374
+ >
375
+ {t('settings.defaultAccess.reload')}
376
+ </Button>
377
+ )}
378
+ </AlertDescription>
379
+ </Alert>
380
+ )}
381
+ {!creating && !supported && (
382
+ <p className='text-sm text-muted-foreground'>
383
+ {t('settings.defaultAccess.unsupported')}
384
+ </p>
385
+ )}
386
+ {creating && !options.length && (
387
+ <p className='text-sm text-muted-foreground'>
388
+ {t('settings.defaultAccess.noResources')}
389
+ </p>
390
+ )}
391
+ <DefaultAccessEditor
392
+ ruleKey={value.key}
393
+ resource={
394
+ value.resource.type ? resourceKey(value.resource) : ''
395
+ }
396
+ resources={options}
397
+ scopes={controls}
398
+ existing={!creating}
399
+ readOnly={readOnly}
400
+ keyError={
401
+ submitted && invalidKey
402
+ ? t('settings.defaultAccess.keyRequired')
403
+ : fieldErrors.key
404
+ }
405
+ resourceError={
406
+ submitted && !resource
407
+ ? t('settings.defaultAccess.resourceRequired')
408
+ : fieldErrors.resource
409
+ }
410
+ labels={{
411
+ key: t('settings.key'),
412
+ resource: t('settings.defaultAccess.resource'),
413
+ chooseResource: t('settings.defaultAccess.chooseResource'),
414
+ action: t('settings.defaultAccess.action'),
415
+ scope: t('settings.defaultAccess.scope'),
416
+ noScopes: t('settings.defaultAccess.chooseResource'),
417
+ }}
418
+ onKeyChange={(key) => {
419
+ setDraft({ ...value, key });
420
+ setFieldErrors((old) => {
421
+ const { key: _key, ...next } = old;
422
+ return next;
423
+ });
424
+ }}
425
+ onResourceChange={(selected) => {
426
+ const resource = resources.find(
427
+ (entry) => resourceKey(entry) === selected,
428
+ );
429
+ if (resource) {
430
+ setDraft({
431
+ ...value,
432
+ resource: { type: resource.type, id: resource.id },
433
+ actions: [],
434
+ });
435
+ setEditing(undefined);
436
+ setFieldErrors({});
437
+ setScopeErrors({});
438
+ }
439
+ }}
440
+ onScopeChange={(id, next) => {
441
+ const scope = scopes.find((scope) => scope.id === id);
442
+ if (!scope) return;
443
+ setScopeErrors((old) => ({ ...old, [id]: '' }));
444
+ const selection = next
445
+ ? (JSON.parse(next) as ScopeSelection)
446
+ : undefined;
447
+ if (
448
+ selection?.type === 'recordAccess' &&
449
+ selection.key === 'customFilter' &&
450
+ !('params' in selection)
451
+ )
452
+ setEditing(id);
453
+ else setDraft(replaceRuleScope(value, scope, selection));
454
+ }}
455
+ />
456
+ <div className='flex justify-end gap-2 border-t pt-4'>
457
+ <Button
458
+ type='button'
459
+ variant='outline'
460
+ disabled={busy}
461
+ onClick={() => {
462
+ void Promise.resolve(leave.guard()).then((allowed) => {
463
+ if (allowed) close();
464
+ });
465
+ }}
466
+ >
467
+ {t('settings.cancel')}
468
+ </Button>
469
+ {canWrite && (
470
+ <Button
471
+ type='submit'
472
+ disabled={busy || !dirty || !supported}
473
+ >
474
+ {t(busy ? 'settings.saving' : 'settings.save')}
475
+ </Button>
476
+ )}
477
+ </div>
478
+ </form>
479
+ )}
480
+ {!creating && !initial && (
481
+ <Button variant='outline' onClick={close}>
482
+ {t('settings.defaultAccess.back')}
483
+ </Button>
484
+ )}
485
+ </>
486
+ )
487
+ )}
488
+ {leave.dialog}
489
+ </PageContainer>
490
+ );
491
+ }
492
+ export default function DefaultAccessForm(): ReactElement {
493
+ const { key } = useParams();
494
+ return (
495
+ <RouteChildPage>
496
+ <DefaultAccessFormBody key={key ?? 'create'} />
497
+ </RouteChildPage>
498
+ );
499
+ }