@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.
@@ -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 action grants no additional record access here. Default access, sharing and restriction rules still determine actual access.',
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
- 'Controls which pages this role can open. Data and actions on each page require their own permissions.',
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: 'No additional record access',
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 navigator.clipboard.writeText(text);
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
- // The Clipboard API is missing outside a secure context and rejects without permission.
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: fieldLabels[field] ?? field,
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: collection.name,
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.title,
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 { CatalogResource } from './permissions-model.js';
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 && resource.id === resourceId,
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: text(`settings.permissionFields.${field}`, field),
68
+ label: permissionFilterFieldLabel(
69
+ definition?.collection ?? '',
70
+ field,
71
+ text,
72
+ ),
66
73
  }))}
67
74
  labels={labels}
68
75
  disabled={disabled}