@nocobase/app-template-default 1.0.0-beta.73 → 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.
- package/AGENTS.md +1 -1
- package/README.MD +2 -0
- package/client/components/default-access-editor.tsx +183 -0
- package/client/components/permission-editor.tsx +216 -150
- package/client/components/role-list.tsx +61 -0
- package/client/lib/navigation-transform.ts +28 -1
- package/client/locales/system/settings-en-US.ts +73 -2
- package/client/locales/system/settings-zh-CN.ts +65 -2
- package/client/pages/settings/default-access-form.tsx +499 -0
- package/client/pages/settings/default-access-model.ts +154 -0
- package/client/pages/settings/default-access.tsx +407 -0
- package/client/pages/settings/permission-filters.tsx +43 -37
- package/client/pages/settings/permission-management-page.tsx +105 -0
- package/client/pages/settings/permissions-model.ts +37 -1
- package/client/pages/settings/role-form.tsx +46 -18
- package/client/pages/settings/roles.tsx +23 -43
- package/client/pages/settings/routes.ts +78 -19
- package/client/pages/settings/shared.tsx +3 -0
- package/client/pages/settings/users.tsx +1 -1
- package/client/routing/route-navigation.ts +21 -1
- package/package.json +19 -19
- package/tests/components/default-access.test.tsx +554 -0
- package/tests/components/page-harness.test.tsx +132 -112
- package/tests/components/permission-editor.test.tsx +233 -73
- package/tests/components/permission-management-tabs.test.tsx +128 -0
- package/tests/components/role-list.test.tsx +71 -0
- package/tests/components/roles.test.tsx +78 -0
- package/tests/components/user-permissions.test.tsx +12 -0
- package/tests/logic/client-routes.test.ts +8 -0
- package/tests/logic/default-access-model.test.ts +167 -0
- package/tests/logic/permission-management-routes.test.ts +64 -0
- package/tests/logic/settings-permissions.test.ts +89 -0
- package/vitest.config.ts +2 -2
|
@@ -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: '
|
|
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
|
|
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
|
+
}
|