@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.77

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 (61) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/README.MD +2 -2
  4. package/client/components/data-table/data-table.tsx +2 -0
  5. package/client/components/data-table/pagination.tsx +6 -6
  6. package/client/components/default-access-editor.tsx +79 -52
  7. package/client/components/flow-diagram.tsx +645 -83
  8. package/client/components/flow-layout.ts +117 -7
  9. package/client/components/lifecycle-diagram.tsx +175 -37
  10. package/client/components/long-text.tsx +200 -0
  11. package/client/components/permission-editor.tsx +534 -341
  12. package/client/components/record-link.tsx +20 -0
  13. package/client/components/required-role-setup.tsx +93 -0
  14. package/client/components/role-list.tsx +21 -5
  15. package/client/components/setup-readiness-state.ts +135 -0
  16. package/client/components/setup-readiness.tsx +91 -0
  17. package/client/components/ui/combobox.tsx +300 -0
  18. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  19. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  20. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  21. package/client/hooks/use-table-url-state.ts +93 -0
  22. package/client/lib/required-role-setup.ts +98 -0
  23. package/client/lib/return-to.ts +19 -0
  24. package/client/locales/system/en-US.ts +10 -0
  25. package/client/locales/system/settings-en-US.ts +51 -4
  26. package/client/locales/system/settings-zh-CN.ts +46 -5
  27. package/client/locales/system/zh-CN.ts +10 -0
  28. package/client/pages/settings/default-access-form.tsx +14 -2
  29. package/client/pages/settings/default-access-model.ts +6 -2
  30. package/client/pages/settings/default-access.tsx +40 -8
  31. package/client/pages/settings/page-permissions.ts +166 -1
  32. package/client/pages/settings/permission-filters.tsx +9 -2
  33. package/client/pages/settings/permission-workspace.ts +330 -0
  34. package/client/pages/settings/permissions-model.ts +62 -53
  35. package/client/pages/settings/role-form.tsx +31 -95
  36. package/client/pages/settings/roles.tsx +44 -6
  37. package/client/pages/settings/shared.tsx +15 -13
  38. package/client/pages/settings/user-detail.tsx +15 -259
  39. package/client/pages/settings/user-form.tsx +299 -147
  40. package/client/pages/settings/users.tsx +211 -123
  41. package/config.example.yml +6 -5
  42. package/package.json +17 -17
  43. package/server/config/app.ts +1 -1
  44. package/server/config/session.ts +7 -5
  45. package/tests/components/default-access-editor.test.tsx +130 -0
  46. package/tests/components/default-access.test.tsx +141 -2
  47. package/tests/components/permission-editor.test.tsx +613 -572
  48. package/tests/components/permission-filter-binding.test.tsx +20 -0
  49. package/tests/components/settings-continuity.test.tsx +426 -0
  50. package/tests/components/settings-feedback.test.tsx +304 -0
  51. package/tests/components/settings.test.tsx +34 -9
  52. package/tests/components/setup-readiness.test.tsx +370 -0
  53. package/tests/components/table-url-state.test.tsx +95 -0
  54. package/tests/components/user-drawer.test.tsx +526 -0
  55. package/tests/components/user-permissions.test.tsx +47 -13
  56. package/tests/logic/config.test.ts +3 -1
  57. package/tests/logic/page-permission-navigation.test.ts +394 -0
  58. package/tests/logic/permission-workspace.test.ts +570 -0
  59. package/tests/logic/return-to.test.ts +19 -0
  60. package/tests/logic/session-isolation.test.ts +250 -0
  61. package/tsconfig.json +1 -0
@@ -1,4 +1,25 @@
1
1
  const settings = {
2
+ setup: {
3
+ title: 'Feature prerequisites',
4
+ checking: 'Checking current configuration…',
5
+ checkAgain: 'Check again',
6
+ ready: 'The checked prerequisites are ready.',
7
+ missing: 'Review the configuration items below.',
8
+ manageMembers: 'Manage members',
9
+ manageMembersFor: 'Manage {{role}} members',
10
+ unavailable: 'Unable to check right now. Try again.',
11
+ signIn: 'Your session expired. Sign in again to check configuration.',
12
+ forbidden:
13
+ 'You do not have access to check this configuration. Contact an administrator.',
14
+ invalid:
15
+ 'This prerequisite could not be verified. Ask the application maintainer to check its configuration.',
16
+ },
17
+ roleFilterIncomplete:
18
+ 'Choose both a role scope and a role, or clear the filters.',
19
+ backToSource: 'Return to previous page',
20
+ roleFilter: 'Filter by role',
21
+ allRoles: 'All roles',
22
+
2
23
  permissionManagement: 'Permission management',
3
24
  defaultAccess: {
4
25
  noResourcesShort: 'No configurable objects available',
@@ -57,7 +78,7 @@ const settings = {
57
78
  scopeExplanation:
58
79
  'Each range applies to its corresponding data. The operation must also pass its authorization checks.',
59
80
  noAdditionalScopeHint:
60
- '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.',
61
82
  systemToolsHint:
62
83
  'Manage system configuration and tools. Review the capabilities each action includes before granting access.',
63
84
  userAccountsHint:
@@ -65,7 +86,7 @@ const settings = {
65
86
  businessOperationsHint:
66
87
  'Choose the operations this role can perform and the data each operation can use.',
67
88
  pageAccessHint:
68
- '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.',
69
90
  roleAssignment: 'Role assignment',
70
91
  accountSecurity: 'Account security',
71
92
  userInformation: 'User information',
@@ -83,6 +104,11 @@ const settings = {
83
104
 
84
105
  assignedRoles: 'Assigned roles',
85
106
  actionSaved: 'Updated access for "{{name}}"',
107
+ userActionSaved: {
108
+ disable: 'Disabled user "{{name}}"',
109
+ enable: 'Enabled user "{{name}}"',
110
+ revokeSessions: 'Revoked active sessions for "{{name}}"',
111
+ },
86
112
 
87
113
  identityConflict: 'This email or username is already in use.',
88
114
  signInAgain: 'Sign in again',
@@ -97,6 +123,7 @@ const settings = {
97
123
  userSearch: 'Search names, usernames or emails',
98
124
  roleSearch: 'Search role names',
99
125
  newUser: 'New user',
126
+ editUserFor: 'Edit {{name}}',
100
127
  editUser: 'Edit user',
101
128
  newRole: 'New role',
102
129
  editRole: 'Edit role',
@@ -117,9 +144,16 @@ const settings = {
117
144
  noResults: 'No matching results',
118
145
  clearHint: 'Try a different search or clear the filters.',
119
146
  noUsers: 'No users yet',
147
+ usersReadOnlyEmpty:
148
+ 'Contact an administrator to add users or grant the permissions needed to create users and assign roles.',
149
+ rolesReadOnlyEmpty:
150
+ 'Contact an administrator to create a role or grant you permission to create roles.',
120
151
  noRoles: 'No roles yet',
121
152
  create: 'Create',
122
153
  edit: 'Edit',
154
+ selectRoles: 'Select roles',
155
+ searchRoles: 'Search roles',
156
+ noRolesFound: 'No roles found.',
123
157
  save: 'Save',
124
158
  cancel: 'Cancel',
125
159
  saving: 'Saving…',
@@ -149,28 +183,41 @@ const settings = {
149
183
  discardTitle: 'Discard changes?',
150
184
  discardHint: 'Your unsaved changes will be lost.',
151
185
  failed: 'Unable to complete the request. Try again.',
186
+ invalidRequest:
187
+ 'Some values are invalid. Check your input or filters and try again.',
152
188
  retry: 'Retry',
153
189
  loading: 'Loading',
154
190
  denied: 'You do not have access. Contact an administrator.',
155
191
  missing: 'This record does not exist or has been deleted.',
156
192
  conflict:
157
- 'This role has changed. Reload it and merge your changes before saving.',
193
+ 'The request conflicts with current data. Review the latest record before trying again.',
158
194
  sessionEnded: 'Your session has ended. Sign in again.',
159
195
  previous: 'Previous',
160
196
  next: 'Next',
161
197
  total_one: '{{count}} user',
162
198
  total_other: '{{count}} users',
163
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.',
164
206
  permissionSearch: 'Search categories, groups or permissions',
165
207
  enabledOnly: 'Enabled only',
166
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.',
167
214
  permissionCategories: 'Permission categories',
168
215
  selectAllPermissions: 'All',
169
216
  dataRanges: 'Data ranges ({{count}})',
170
217
  selectVisiblePermissions: 'Select visible permissions in {{name}}',
171
218
  permissionSummary: '{{enabled}} of {{total}} enabled',
172
219
  preservedRecordScope: 'Keep existing configuration',
173
- noRecordScope: 'No additional record access',
220
+ noRecordScope: 'Use default rules',
174
221
  permissionFilter: {
175
222
  limit: 'Up to 20 conditions',
176
223
  configure: 'Conditions',
@@ -1,4 +1,22 @@
1
1
  const settings = {
2
+ setup: {
3
+ title: '功能前置配置',
4
+ checking: '正在检查当前配置…',
5
+ checkAgain: '重新检查',
6
+ ready: '已检查的前置配置已就绪。',
7
+ missing: '请检查以下配置项。',
8
+ manageMembers: '管理成员',
9
+ manageMembersFor: '管理{{role}}成员',
10
+ unavailable: '暂时无法检查,请稍后重试。',
11
+ signIn: '登录已失效,请重新登录后检查配置。',
12
+ forbidden: '没有权限检查此配置,请联系管理员。',
13
+ invalid: '无法确认此前置配置,请应用维护者检查其配置声明。',
14
+ },
15
+ roleFilterIncomplete: '请同时指定角色范围与角色,或清除筛选条件。',
16
+ backToSource: '返回原页面',
17
+ roleFilter: '按角色筛选',
18
+ allRoles: '全部角色',
19
+
2
20
  permissionManagement: '权限管理',
3
21
  defaultAccess: {
4
22
  noResourcesShort: '暂无可配置的业务对象',
@@ -50,14 +68,14 @@ const settings = {
50
68
  scopeExplanation:
51
69
  '每项范围分别约束对应数据,实际操作还需通过相应的权限检查。',
52
70
  noAdditionalScopeHint:
53
- '此操作在这里不额外授予数据访问权限,实际可访问范围仍取决于默认数据范围、共享与限制规则。',
71
+ '此角色不增加记录范围;实际访问还取决于其他角色及默认、共享和限制规则。',
54
72
  systemToolsHint: '管理系统配置与工具,授权前请确认每项操作包含的能力。',
55
73
  userAccountsHint:
56
74
  '管理用户资料、账号安全和角色分配,请逐项确认需要授予的权限。',
57
75
  businessOperationsHint:
58
76
  '设置此角色可以执行的业务操作,以及每项操作可使用的数据范围。',
59
77
  pageAccessHint:
60
- '控制此角色可以进入哪些页面。页面中的数据和操作仍需单独授权。',
78
+ '设置此角色可访问的业务页面。系统设置(包括用户管理、业务配置项)统一在“配置项权限”中控制。页面内的数据和业务操作仍需单独授权。',
61
79
  roleAssignment: '角色分配',
62
80
  accountSecurity: '账号安全',
63
81
  userInformation: '用户资料',
@@ -75,6 +93,11 @@ const settings = {
75
93
 
76
94
  assignedRoles: '已分配角色',
77
95
  actionSaved: '已更新“{{name}}”的访问状态',
96
+ userActionSaved: {
97
+ disable: '已禁用用户“{{name}}”',
98
+ enable: '已启用用户“{{name}}”',
99
+ revokeSessions: '已撤销“{{name}}”的登录会话',
100
+ },
78
101
 
79
102
  identityConflict: '此邮箱或用户名已被使用。',
80
103
  signInAgain: '重新登录',
@@ -89,6 +112,7 @@ const settings = {
89
112
  userSearch: '搜索姓名、用户名或邮箱',
90
113
  roleSearch: '搜索角色名称',
91
114
  newUser: '新建用户',
115
+ editUserFor: '编辑 {{name}}',
92
116
  editUser: '编辑用户',
93
117
  newRole: '新建角色',
94
118
  editRole: '编辑角色',
@@ -109,9 +133,15 @@ const settings = {
109
133
  noResults: '没有匹配的结果',
110
134
  clearHint: '请更换搜索条件或清除筛选。',
111
135
  noUsers: '暂无用户',
136
+ usersReadOnlyEmpty:
137
+ '请联系管理员添加用户,或授予创建用户和分配角色所需的权限。',
138
+ rolesReadOnlyEmpty: '请联系管理员创建角色,或授予你创建角色的权限。',
112
139
  noRoles: '暂无角色',
113
140
  create: '创建',
114
141
  edit: '编辑',
142
+ selectRoles: '选择角色',
143
+ searchRoles: '搜索角色',
144
+ noRolesFound: '没有匹配的角色。',
115
145
  save: '保存',
116
146
  cancel: '取消',
117
147
  saving: '正在保存…',
@@ -139,26 +169,37 @@ const settings = {
139
169
  discardTitle: '放弃未保存的更改?',
140
170
  discardHint: '未保存的更改将丢失。',
141
171
  failed: '请求失败,请重试。',
172
+ invalidRequest: '部分内容无效,请检查填写内容或筛选条件后重试。',
142
173
  retry: '重试',
143
174
  loading: '加载中',
144
- denied: '无权查看,请联系管理员。',
175
+ denied: '没有执行此操作的权限,请联系管理员。',
145
176
  missing: '记录不存在或已被删除。',
146
- conflict: '角色已被其他人修改,请重新加载后合并。',
177
+ conflict: '此操作与当前数据冲突,请查看最新记录后再试。',
147
178
  sessionEnded: '会话已结束,请重新登录。',
148
179
  previous: '上一页',
149
180
  next: '下一页',
150
181
  total_one: '共 {{count}} 位用户',
151
182
  total_other: '共 {{count}} 位用户',
152
183
  access: '访问',
184
+ expandPermissionGroup: '展开“{{name}}”',
185
+ collapsePermissionGroup: '收起“{{name}}”',
186
+ pagesOutsideNavigation: '未显示在菜单中的页面',
187
+ configurationPermissions: '配置项权限',
188
+ configurationPermissionsHint:
189
+ '统一设置系统设置、用户管理和业务配置项的访问与操作权限,仅显示应用实际开放的功能。',
153
190
  permissionSearch: '搜索分类、分组或权限',
154
191
  enabledOnly: '仅显示已授权',
155
192
  noPermissions: '暂无可配置权限',
193
+ permissionEmptyHint: '请联系应用开发者,开放此角色需要配置的权限。',
194
+ permissionReadOnlyHint: '当前权限配置为只读。',
195
+ permissionManagedHint: '此权限由应用管理。',
196
+ permissionSaveHint: '权限修改保存后生效。',
156
197
  permissionCategories: '权限分类',
157
198
  selectAllPermissions: '全选',
158
199
  dataRanges: '数据范围({{count}})',
159
200
  selectVisiblePermissions: '选择“{{name}}”中显示的权限',
160
201
  permissionSummary: '已授权 {{enabled}} / {{total}}',
161
- noRecordScope: '不额外授权数据范围',
202
+ noRecordScope: '按默认规则',
162
203
  preservedRecordScope: '保留现有配置',
163
204
  permissionFilter: {
164
205
  limit: '最多支持 20 个条件',
@@ -6,6 +6,16 @@ import inboxZhCN from '#extensions/nocobase-inbox/locales/zh-CN';
6
6
  /** The Chinese copy of what the template ships; see `./en-US.ts`. */
7
7
  const systemZhCN: SystemResource = {
8
8
  flowDiagram: {
9
+ controls: '流程图控制',
10
+ fitView: '适应画布',
11
+ fullscreen: '全屏',
12
+ exitFullscreen: '退出全屏',
13
+ escapeHint: '按 Escape 退出',
14
+ leavesTo: '可转到',
15
+ arrivesFrom: '来自',
16
+ otherRoutes: '其他流转',
17
+ minimap: '流程缩略导航',
18
+
9
19
  configure: '配置',
10
20
  overview: '流程缩略导航',
11
21
  actualSize: '实际大小',
@@ -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,
@@ -79,7 +80,10 @@ function DefaultAccessFormBody(): ReactElement {
79
80
  draft !== undefined && JSON.stringify(draft) !== JSON.stringify(initial);
80
81
  const leave = useSettingsLeave(dirty, busy);
81
82
  const close = () => {
82
- void navigate({ pathname: '/default-access', search: location.search });
83
+ void navigate(
84
+ { pathname: '/default-access', search: location.search },
85
+ { replace: true },
86
+ );
83
87
  };
84
88
  const text = (value: LocalizedText): string =>
85
89
  typeof value === 'string'
@@ -174,6 +178,7 @@ function DefaultAccessFormBody(): ReactElement {
174
178
  id: scope.id,
175
179
  error: scopeErrors[scope.id],
176
180
  action: text(scope.actionTitle),
181
+ actionId: scope.action,
177
182
  scope: text(scope.title),
178
183
  value: selectedValue,
179
184
  options: choices,
@@ -185,7 +190,13 @@ function DefaultAccessFormBody(): ReactElement {
185
190
  value={params}
186
191
  fields={scope.fields.map((field) => ({
187
192
  value: field,
188
- 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
+ ),
189
200
  }))}
190
201
  labels={labels}
191
202
  disabled={readOnly}
@@ -210,6 +221,7 @@ function DefaultAccessFormBody(): ReactElement {
210
221
  controls.push({
211
222
  id: actionKey(action),
212
223
  action: action.action,
224
+ actionId: action.action,
213
225
  scope: action.scopeKey ?? value.resource.id,
214
226
  value: JSON.stringify(action.selection),
215
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,3 +1,5 @@
1
+ import { RecordLink } from '#components/record-link';
2
+ import { useTableUrlState } from '#hooks/use-table-url-state';
1
3
  import { useApiClient, useToaster } from '@nocobase/app-client';
2
4
  import {
3
5
  useCan,
@@ -119,15 +121,14 @@ export default function DefaultAccessPage(): ReactElement {
119
121
  />
120
122
  ),
121
123
  cell: ({ row }) => (
122
- <Link
123
- className='text-foreground underline-offset-4 hover:underline'
124
+ <RecordLink
124
125
  to={{
125
126
  pathname: `${encodeURIComponent(row.original.key)}/edit`,
126
127
  search: location.search,
127
128
  }}
128
129
  >
129
130
  {name(row.original)}
130
- </Link>
131
+ </RecordLink>
131
132
  ),
132
133
  },
133
134
  {
@@ -210,6 +211,29 @@ export default function DefaultAccessPage(): ReactElement {
210
211
  .includes(search.toLocaleLowerCase()),
211
212
  )
212
213
  .sort((a, b) => name(a).localeCompare(name(b), i18n.language));
214
+ const tableState = useTableUrlState({
215
+ defaultPageSize: 20,
216
+ sortColumns: ['resource'],
217
+ rowCount:
218
+ rules.data &&
219
+ !rules.loading &&
220
+ !rules.error &&
221
+ catalog.data &&
222
+ !catalog.loading &&
223
+ !catalog.error
224
+ ? rows.length
225
+ : undefined,
226
+ });
227
+ const clearSearch = () =>
228
+ setParams(
229
+ (old) => {
230
+ const next = new URLSearchParams(old);
231
+ next.delete('q');
232
+ next.delete('page');
233
+ return next;
234
+ },
235
+ { replace: true },
236
+ );
213
237
  const eligible = resources.some(
214
238
  (resource) =>
215
239
  !rules.data?.data.some(
@@ -295,13 +319,20 @@ export default function DefaultAccessPage(): ReactElement {
295
319
  placeholder={t('settings.defaultAccess.search')}
296
320
  aria-label={t('settings.defaultAccess.search')}
297
321
  onChange={(event) =>
298
- setParams(event.target.value ? { q: event.target.value } : {}, {
299
- replace: true,
300
- })
322
+ setParams(
323
+ (old) => {
324
+ const next = new URLSearchParams(old);
325
+ next.delete('page');
326
+ if (event.target.value) next.set('q', event.target.value);
327
+ else next.delete('q');
328
+ return next;
329
+ },
330
+ { replace: true },
331
+ )
301
332
  }
302
333
  />
303
334
  {search && (
304
- <Button variant='ghost' onClick={() => setParams({})}>
335
+ <Button variant='ghost' onClick={clearSearch}>
305
336
  {t('settings.clear')}
306
337
  </Button>
307
338
  )}
@@ -329,6 +360,7 @@ export default function DefaultAccessPage(): ReactElement {
329
360
  !catalog.error &&
330
361
  (rows.length ? (
331
362
  <DataTable
363
+ {...tableState}
332
364
  columns={columns}
333
365
  data={rows}
334
366
  getRowId={(rule) => rule.key}
@@ -358,7 +390,7 @@ export default function DefaultAccessPage(): ReactElement {
358
390
  </EmptyDescription>
359
391
  <EmptyContent>
360
392
  {search ? (
361
- <Button variant='outline' onClick={() => setParams({})}>
393
+ <Button variant='outline' onClick={clearSearch}>
362
394
  {t('settings.clear')}
363
395
  </Button>
364
396
  ) : (
@@ -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}