@nocobase/app-template-default 1.0.0-beta.76 → 1.0.0-beta.78
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/client/components/default-access-editor.tsx +79 -52
- package/client/components/long-text.tsx +200 -0
- package/client/components/permission-editor.tsx +534 -341
- package/client/locales/system/settings-en-US.ts +14 -3
- package/client/locales/system/settings-zh-CN.ts +13 -3
- package/client/pages/home/use-copy.ts +6 -3
- package/client/pages/settings/default-access-form.tsx +10 -1
- package/client/pages/settings/default-access-model.ts +6 -2
- package/client/pages/settings/page-permissions.ts +166 -1
- package/client/pages/settings/permission-filters.tsx +9 -2
- package/client/pages/settings/permission-workspace.ts +330 -0
- package/client/pages/settings/permissions-model.ts +62 -53
- package/client/pages/settings/role-form.tsx +30 -94
- package/package.json +16 -16
- package/server/config/app.ts +1 -1
- package/server/config/session.ts +7 -5
- package/tests/components/default-access-editor.test.tsx +130 -0
- package/tests/components/permission-editor.test.tsx +613 -572
- package/tests/components/permission-filter-binding.test.tsx +20 -0
- package/tests/components/settings.test.tsx +33 -8
- package/tests/components/user-drawer.test.tsx +14 -3
- package/tests/components/user-permissions.test.tsx +47 -13
- package/tests/logic/config.test.ts +3 -1
- package/tests/logic/page-permission-navigation.test.ts +394 -0
- package/tests/logic/permission-workspace.test.ts +570 -0
- package/tests/logic/session-isolation.test.ts +250 -0
|
@@ -78,7 +78,7 @@ const settings = {
|
|
|
78
78
|
scopeExplanation:
|
|
79
79
|
'Each range applies to its corresponding data. The operation must also pass its authorization checks.',
|
|
80
80
|
noAdditionalScopeHint:
|
|
81
|
-
'This
|
|
81
|
+
'This role adds no records. Effective access also depends on other roles and default, sharing and restriction rules.',
|
|
82
82
|
systemToolsHint:
|
|
83
83
|
'Manage system configuration and tools. Review the capabilities each action includes before granting access.',
|
|
84
84
|
userAccountsHint:
|
|
@@ -86,7 +86,7 @@ const settings = {
|
|
|
86
86
|
businessOperationsHint:
|
|
87
87
|
'Choose the operations this role can perform and the data each operation can use.',
|
|
88
88
|
pageAccessHint:
|
|
89
|
-
'
|
|
89
|
+
'Choose the business pages this role can open. All system settings, including user management and business configuration, are controlled under Configuration permissions. Data and business actions require separate permissions.',
|
|
90
90
|
roleAssignment: 'Role assignment',
|
|
91
91
|
accountSecurity: 'Account security',
|
|
92
92
|
userInformation: 'User information',
|
|
@@ -197,16 +197,27 @@ const settings = {
|
|
|
197
197
|
total_one: '{{count}} user',
|
|
198
198
|
total_other: '{{count}} users',
|
|
199
199
|
access: 'Access',
|
|
200
|
+
expandPermissionGroup: 'Expand {{name}}',
|
|
201
|
+
collapsePermissionGroup: 'Collapse {{name}}',
|
|
202
|
+
pagesOutsideNavigation: 'Pages outside navigation',
|
|
203
|
+
configurationPermissions: 'Configuration permissions',
|
|
204
|
+
configurationPermissionsHint:
|
|
205
|
+
'Control access and actions for system settings, user management and business configuration. Only actual application features are shown.',
|
|
200
206
|
permissionSearch: 'Search categories, groups or permissions',
|
|
201
207
|
enabledOnly: 'Enabled only',
|
|
202
208
|
noPermissions: 'No permissions to configure',
|
|
209
|
+
permissionEmptyHint:
|
|
210
|
+
'Ask the application developer to expose the permissions this role can configure.',
|
|
211
|
+
permissionReadOnlyHint: 'These permissions are read-only.',
|
|
212
|
+
permissionManagedHint: 'This permission is managed by the application.',
|
|
213
|
+
permissionSaveHint: 'Permission changes take effect after saving.',
|
|
203
214
|
permissionCategories: 'Permission categories',
|
|
204
215
|
selectAllPermissions: 'All',
|
|
205
216
|
dataRanges: 'Data ranges ({{count}})',
|
|
206
217
|
selectVisiblePermissions: 'Select visible permissions in {{name}}',
|
|
207
218
|
permissionSummary: '{{enabled}} of {{total}} enabled',
|
|
208
219
|
preservedRecordScope: 'Keep existing configuration',
|
|
209
|
-
noRecordScope: '
|
|
220
|
+
noRecordScope: 'Use default rules',
|
|
210
221
|
permissionFilter: {
|
|
211
222
|
limit: 'Up to 20 conditions',
|
|
212
223
|
configure: 'Conditions',
|
|
@@ -68,14 +68,14 @@ const settings = {
|
|
|
68
68
|
scopeExplanation:
|
|
69
69
|
'每项范围分别约束对应数据,实际操作还需通过相应的权限检查。',
|
|
70
70
|
noAdditionalScopeHint:
|
|
71
|
-
'
|
|
71
|
+
'此角色不增加记录范围;实际访问还取决于其他角色及默认、共享和限制规则。',
|
|
72
72
|
systemToolsHint: '管理系统配置与工具,授权前请确认每项操作包含的能力。',
|
|
73
73
|
userAccountsHint:
|
|
74
74
|
'管理用户资料、账号安全和角色分配,请逐项确认需要授予的权限。',
|
|
75
75
|
businessOperationsHint:
|
|
76
76
|
'设置此角色可以执行的业务操作,以及每项操作可使用的数据范围。',
|
|
77
77
|
pageAccessHint:
|
|
78
|
-
'
|
|
78
|
+
'设置此角色可访问的业务页面。系统设置(包括用户管理、业务配置项)统一在“配置项权限”中控制。页面内的数据和业务操作仍需单独授权。',
|
|
79
79
|
roleAssignment: '角色分配',
|
|
80
80
|
accountSecurity: '账号安全',
|
|
81
81
|
userInformation: '用户资料',
|
|
@@ -181,15 +181,25 @@ const settings = {
|
|
|
181
181
|
total_one: '共 {{count}} 位用户',
|
|
182
182
|
total_other: '共 {{count}} 位用户',
|
|
183
183
|
access: '访问',
|
|
184
|
+
expandPermissionGroup: '展开“{{name}}”',
|
|
185
|
+
collapsePermissionGroup: '收起“{{name}}”',
|
|
186
|
+
pagesOutsideNavigation: '未显示在菜单中的页面',
|
|
187
|
+
configurationPermissions: '配置项权限',
|
|
188
|
+
configurationPermissionsHint:
|
|
189
|
+
'统一设置系统设置、用户管理和业务配置项的访问与操作权限,仅显示应用实际开放的功能。',
|
|
184
190
|
permissionSearch: '搜索分类、分组或权限',
|
|
185
191
|
enabledOnly: '仅显示已授权',
|
|
186
192
|
noPermissions: '暂无可配置权限',
|
|
193
|
+
permissionEmptyHint: '请联系应用开发者,开放此角色需要配置的权限。',
|
|
194
|
+
permissionReadOnlyHint: '当前权限配置为只读。',
|
|
195
|
+
permissionManagedHint: '此权限由应用管理。',
|
|
196
|
+
permissionSaveHint: '权限修改保存后生效。',
|
|
187
197
|
permissionCategories: '权限分类',
|
|
188
198
|
selectAllPermissions: '全选',
|
|
189
199
|
dataRanges: '数据范围({{count}})',
|
|
190
200
|
selectVisiblePermissions: '选择“{{name}}”中显示的权限',
|
|
191
201
|
permissionSummary: '已授权 {{enabled}} / {{total}}',
|
|
192
|
-
noRecordScope: '
|
|
202
|
+
noRecordScope: '按默认规则',
|
|
193
203
|
preservedRecordScope: '保留现有配置',
|
|
194
204
|
permissionFilter: {
|
|
195
205
|
limit: '最多支持 20 个条件',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useToaster } from '@nocobase/app-client';
|
|
1
|
+
import { copyText, useToaster } from '@nocobase/app-client';
|
|
2
2
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
3
|
import { useCallback, useEffect, useState } from 'react';
|
|
4
4
|
|
|
@@ -23,11 +23,14 @@ export function useCopy(): CopyState {
|
|
|
23
23
|
const copy = useCallback(
|
|
24
24
|
async (text: string): Promise<void> => {
|
|
25
25
|
try {
|
|
26
|
-
await
|
|
26
|
+
if (!(await copyText(text))) {
|
|
27
|
+
toaster.show({ type: 'error', title: t('home.prompt.copyFailed') });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
27
30
|
setCopied(true);
|
|
28
31
|
toaster.show({ type: 'success', title: t('home.prompt.copied') });
|
|
29
32
|
} catch {
|
|
30
|
-
//
|
|
33
|
+
// Copying can fail when neither browser mechanism is available.
|
|
31
34
|
toaster.show({ type: 'error', title: t('home.prompt.copyFailed') });
|
|
32
35
|
}
|
|
33
36
|
},
|
|
@@ -25,6 +25,7 @@ import { Alert, AlertDescription } from '#components/ui/alert';
|
|
|
25
25
|
import { SettingsError, SettingsLoading } from './shared.js';
|
|
26
26
|
import { useSettingsData, useSettingsLeave } from './hooks.js';
|
|
27
27
|
import { permissionFilterLabels } from './permission-filters.js';
|
|
28
|
+
import { permissionFilterFieldLabel } from './permission-workspace.js';
|
|
28
29
|
import {
|
|
29
30
|
actionKey,
|
|
30
31
|
resourceKey,
|
|
@@ -177,6 +178,7 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
177
178
|
id: scope.id,
|
|
178
179
|
error: scopeErrors[scope.id],
|
|
179
180
|
action: text(scope.actionTitle),
|
|
181
|
+
actionId: scope.action,
|
|
180
182
|
scope: text(scope.title),
|
|
181
183
|
value: selectedValue,
|
|
182
184
|
options: choices,
|
|
@@ -188,7 +190,13 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
188
190
|
value={params}
|
|
189
191
|
fields={scope.fields.map((field) => ({
|
|
190
192
|
value: field,
|
|
191
|
-
label:
|
|
193
|
+
label:
|
|
194
|
+
fieldLabels[field] ??
|
|
195
|
+
permissionFilterFieldLabel(
|
|
196
|
+
scope.collection,
|
|
197
|
+
field,
|
|
198
|
+
(key, fallback) => t(key, { defaultValue: fallback ?? key }),
|
|
199
|
+
),
|
|
192
200
|
}))}
|
|
193
201
|
labels={labels}
|
|
194
202
|
disabled={readOnly}
|
|
@@ -213,6 +221,7 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
213
221
|
controls.push({
|
|
214
222
|
id: actionKey(action),
|
|
215
223
|
action: action.action,
|
|
224
|
+
actionId: action.action,
|
|
216
225
|
scope: action.scopeKey ?? value.resource.id,
|
|
217
226
|
value: JSON.stringify(action.selection),
|
|
218
227
|
options: [
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
LocalizedText,
|
|
4
4
|
} from '@nocobase/app-plugin-authorization/client';
|
|
5
5
|
import type { CatalogResource } from './permissions-model.js';
|
|
6
|
+
import { permissionScopeTitle } from './permission-workspace.js';
|
|
6
7
|
|
|
7
8
|
export type ScopeSelection =
|
|
8
9
|
| { readonly type: 'all' }
|
|
@@ -51,7 +52,10 @@ export function ruleResources(
|
|
|
51
52
|
...catalog.collections.map((collection) => ({
|
|
52
53
|
type: 'database.collection',
|
|
53
54
|
id: collection.name,
|
|
54
|
-
title:
|
|
55
|
+
title: permissionScopeTitle(
|
|
56
|
+
{ type: 'database.collection', id: collection.name },
|
|
57
|
+
{ collection: collection.name, title: collection.name },
|
|
58
|
+
),
|
|
55
59
|
actions: ['read', 'update', 'delete'].map((name) => ({
|
|
56
60
|
name,
|
|
57
61
|
title: {
|
|
@@ -98,7 +102,7 @@ export function ruleScopes(
|
|
|
98
102
|
action: action.name,
|
|
99
103
|
scopeKey: scope.key,
|
|
100
104
|
actionTitle: action.title,
|
|
101
|
-
title: scope.
|
|
105
|
+
title: permissionScopeTitle(resource, scope, undefined, action.name),
|
|
102
106
|
collection: scope.collection,
|
|
103
107
|
fields: scope.fields,
|
|
104
108
|
recordAccess: scope.recordAccess,
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
2
|
import type { LocalizedText } from '@nocobase/app-plugin-authorization/client';
|
|
3
|
-
import type
|
|
3
|
+
import { permissionRowId, type CatalogResource } from './permissions-model.js';
|
|
4
|
+
import type {
|
|
5
|
+
PermissionEditorNavigationNode,
|
|
6
|
+
PermissionEditorSection,
|
|
7
|
+
PermissionEditorGroup,
|
|
8
|
+
} from '#components/permission-editor';
|
|
9
|
+
import {
|
|
10
|
+
buildRouteNavigation,
|
|
11
|
+
type RouteNavigationItem,
|
|
12
|
+
} from '../../routing/route-navigation.js';
|
|
4
13
|
|
|
5
14
|
/** Application route names are stable within their owner; labels never change stored grants. */
|
|
6
15
|
export type PagePermissionTitles = Readonly<Record<string, string>>;
|
|
@@ -49,3 +58,159 @@ export function pagePermissionResources(
|
|
|
49
58
|
visit(routes);
|
|
50
59
|
return [...resources.values()];
|
|
51
60
|
}
|
|
61
|
+
|
|
62
|
+
/** Resource ownership follows the application's real settings branch, not translated labels or URL guesses. */
|
|
63
|
+
function configurationResources(
|
|
64
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
65
|
+
): ReadonlySet<string> {
|
|
66
|
+
const resources = new Set<string>();
|
|
67
|
+
const visit = (
|
|
68
|
+
items: readonly AppClientRegisteredRoute[],
|
|
69
|
+
inSettings = false,
|
|
70
|
+
): void => {
|
|
71
|
+
for (const route of items) {
|
|
72
|
+
const settings =
|
|
73
|
+
inSettings ||
|
|
74
|
+
(route.source === 'application' &&
|
|
75
|
+
['settings', 'systemManagement'].includes(route.name));
|
|
76
|
+
if (
|
|
77
|
+
settings &&
|
|
78
|
+
route.componentLoader &&
|
|
79
|
+
typeof route.authz === 'object'
|
|
80
|
+
) {
|
|
81
|
+
resources.add(
|
|
82
|
+
JSON.stringify([route.authz.resource.type, route.authz.resource.id]),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
visit(route.children ?? [], settings);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
visit(routes);
|
|
89
|
+
return resources;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The business page tree excludes system settings, whose original grants are configured in their own category. */
|
|
93
|
+
export function pagePermissionNavigation(
|
|
94
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
95
|
+
titles: PagePermissionTitles,
|
|
96
|
+
unnamed: (path: string) => string,
|
|
97
|
+
text: (value: LocalizedText) => string,
|
|
98
|
+
): readonly PermissionEditorNavigationNode[] {
|
|
99
|
+
const configuration = configurationResources(routes);
|
|
100
|
+
const pages = pagePermissionResources(routes, titles, unnamed).filter(
|
|
101
|
+
({ type, id }) => !configuration.has(JSON.stringify([type, id])),
|
|
102
|
+
);
|
|
103
|
+
const resources = new Map(pages.map((page) => [page.id, page]));
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
const visit = (
|
|
106
|
+
items: readonly RouteNavigationItem[],
|
|
107
|
+
): PermissionEditorNavigationNode[] =>
|
|
108
|
+
items.flatMap(({ route, children }) => {
|
|
109
|
+
const resource =
|
|
110
|
+
route.componentLoader &&
|
|
111
|
+
typeof route.authz === 'object' &&
|
|
112
|
+
route.authz.resource.type === 'page'
|
|
113
|
+
? resources.get(route.authz.resource.id)
|
|
114
|
+
: undefined;
|
|
115
|
+
const rowId =
|
|
116
|
+
resource && !seen.has(resource.id)
|
|
117
|
+
? permissionRowId(resource, 'access')
|
|
118
|
+
: undefined;
|
|
119
|
+
if (rowId && resource) seen.add(resource.id);
|
|
120
|
+
const descendants = visit(children);
|
|
121
|
+
if (!rowId && !descendants.length) return [];
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
id: `menu:${route.packageName}:${route.id}:${route.path}`,
|
|
125
|
+
label: text(
|
|
126
|
+
rowId && resource
|
|
127
|
+
? resource.title
|
|
128
|
+
: route.navigation?.title
|
|
129
|
+
? { key: route.navigation.title, ns: route.packageName }
|
|
130
|
+
: unnamed(route.path),
|
|
131
|
+
),
|
|
132
|
+
rowId,
|
|
133
|
+
children: descendants,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
});
|
|
137
|
+
// Author the catalog from the complete menu, not the signed-in editor's filtered navigation.
|
|
138
|
+
const navigation = visit(buildRouteNavigation(routes, new Set()));
|
|
139
|
+
const outside = pages
|
|
140
|
+
.filter((page) => !seen.has(page.id))
|
|
141
|
+
.map((page) => ({
|
|
142
|
+
id: `page:${page.id}`,
|
|
143
|
+
label: text(page.title),
|
|
144
|
+
rowId: permissionRowId(page, 'access'),
|
|
145
|
+
}));
|
|
146
|
+
return [
|
|
147
|
+
...navigation,
|
|
148
|
+
...(outside.length
|
|
149
|
+
? [
|
|
150
|
+
{
|
|
151
|
+
id: 'pages:outside-navigation',
|
|
152
|
+
label: text({ key: 'settings.pagesOutsideNavigation' }),
|
|
153
|
+
children: outside,
|
|
154
|
+
},
|
|
155
|
+
]
|
|
156
|
+
: []),
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Reclassify existing controls only: system settings never create a second page-access permission. */
|
|
161
|
+
export function permissionNavigationSections(
|
|
162
|
+
sections: readonly PermissionEditorSection[],
|
|
163
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
164
|
+
titles: PagePermissionTitles,
|
|
165
|
+
unnamed: (path: string) => string,
|
|
166
|
+
text: (value: LocalizedText) => string,
|
|
167
|
+
): readonly PermissionEditorSection[] {
|
|
168
|
+
const configuration = configurationResources(routes);
|
|
169
|
+
const moved: PermissionEditorGroup[] = [];
|
|
170
|
+
const result = sections
|
|
171
|
+
.map((section) => {
|
|
172
|
+
if (section.id === 'configuration') return section;
|
|
173
|
+
const groups = section.groups.flatMap((group) => {
|
|
174
|
+
const belongs = (row: (typeof group.rows)[number]): boolean => {
|
|
175
|
+
const [type, id] = JSON.parse(row.id) as string[];
|
|
176
|
+
return configuration.has(JSON.stringify([type, id]));
|
|
177
|
+
};
|
|
178
|
+
const settingsRows = group.rows.filter(belongs);
|
|
179
|
+
if (settingsRows.length) moved.push({ ...group, rows: settingsRows });
|
|
180
|
+
const rows = group.rows.filter((row) => !belongs(row));
|
|
181
|
+
return rows.length ? [{ ...group, rows }] : [];
|
|
182
|
+
});
|
|
183
|
+
return {
|
|
184
|
+
...section,
|
|
185
|
+
groups,
|
|
186
|
+
...(section.id === 'pages'
|
|
187
|
+
? {
|
|
188
|
+
navigation: pagePermissionNavigation(
|
|
189
|
+
routes,
|
|
190
|
+
titles,
|
|
191
|
+
unnamed,
|
|
192
|
+
text,
|
|
193
|
+
),
|
|
194
|
+
}
|
|
195
|
+
: {}),
|
|
196
|
+
};
|
|
197
|
+
})
|
|
198
|
+
.filter((section) => section.groups.length);
|
|
199
|
+
if (!moved.length) return result;
|
|
200
|
+
const target = result.find((section) => section.id === 'configuration');
|
|
201
|
+
return target
|
|
202
|
+
? result.map((section) =>
|
|
203
|
+
section === target
|
|
204
|
+
? { ...section, groups: [...section.groups, ...moved] }
|
|
205
|
+
: section,
|
|
206
|
+
)
|
|
207
|
+
: [
|
|
208
|
+
...result,
|
|
209
|
+
{
|
|
210
|
+
id: 'configuration',
|
|
211
|
+
title: text({ key: 'settings.configurationPermissions' }),
|
|
212
|
+
description: text({ key: 'settings.configurationPermissionsHint' }),
|
|
213
|
+
groups: moved,
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
}
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
AuthorizationOptionsResponse,
|
|
9
9
|
} from '@nocobase/app-plugin-authorization/client';
|
|
10
10
|
import { getScopeSelection } from './permissions-model.js';
|
|
11
|
+
import { permissionFilterFieldLabel } from './permission-workspace.js';
|
|
11
12
|
|
|
12
13
|
export function configurePermissionScopes(
|
|
13
14
|
sections: readonly PermissionEditorSection[],
|
|
@@ -39,7 +40,9 @@ export function configurePermissionScopes(
|
|
|
39
40
|
const definition = resources
|
|
40
41
|
.find(
|
|
41
42
|
(resource) =>
|
|
42
|
-
resource.type === type &&
|
|
43
|
+
resource.type === type &&
|
|
44
|
+
resource.id === resourceId &&
|
|
45
|
+
resource.actions.some((item) => item.name === action),
|
|
43
46
|
)
|
|
44
47
|
?.dataScopes?.[action ?? '']?.find((item) => item.key === key);
|
|
45
48
|
const value = getScopeSelection(grants, scope.id);
|
|
@@ -62,7 +65,11 @@ export function configurePermissionScopes(
|
|
|
62
65
|
value={filter}
|
|
63
66
|
fields={(definition?.fields ?? []).map((field) => ({
|
|
64
67
|
value: field,
|
|
65
|
-
label:
|
|
68
|
+
label: permissionFilterFieldLabel(
|
|
69
|
+
definition?.collection ?? '',
|
|
70
|
+
field,
|
|
71
|
+
text,
|
|
72
|
+
),
|
|
66
73
|
}))}
|
|
67
74
|
labels={labels}
|
|
68
75
|
disabled={disabled}
|