@nocobase/app-template-default 1.0.0-beta.76 → 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.
@@ -0,0 +1,330 @@
1
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
+ import type {
3
+ AuthorizationOptionsResponse,
4
+ LocalizedText,
5
+ } from '@nocobase/app-plugin-authorization/client';
6
+ import type { CatalogResource } from './permissions-model.js';
7
+
8
+ export interface PermissionWorkspaceSelection {
9
+ readonly business: readonly {
10
+ readonly type: string;
11
+ readonly id: string;
12
+ readonly actions: readonly string[];
13
+ readonly actionDescriptions?: Readonly<Record<string, LocalizedText>>;
14
+ readonly scopeTitles?: Readonly<Record<string, LocalizedText>>;
15
+ readonly actionScopeTitles?: Readonly<
16
+ Record<string, Readonly<Record<string, LocalizedText>>>
17
+ >;
18
+ /** UI-only omission; registered and stored scope values remain unchanged. */
19
+ readonly hiddenActionScopes?: Readonly<Record<string, readonly string[]>>;
20
+ readonly configurationActions?: readonly {
21
+ readonly name: string;
22
+ readonly routes: readonly string[];
23
+ }[];
24
+ readonly configurationTitle?: LocalizedText;
25
+ }[];
26
+ readonly settings: readonly {
27
+ readonly id: string;
28
+ readonly actions: readonly string[];
29
+ }[];
30
+ readonly filterFieldTitles?: Readonly<
31
+ Record<string, Readonly<Record<string, LocalizedText>>>
32
+ >;
33
+ }
34
+
35
+ /** Add only business operations and configuration controls this application exposes. */
36
+ export const permissionWorkspaceSelection: PermissionWorkspaceSelection = {
37
+ business: [],
38
+ settings: [
39
+ {
40
+ id: 'authorization.permission-sets',
41
+ actions: ['read', 'create', 'update'],
42
+ },
43
+ {
44
+ id: 'authorization.default-access',
45
+ actions: ['read', 'create', 'update', 'delete'],
46
+ },
47
+ ],
48
+ };
49
+
50
+ /** Share application-owned data captions across role and default-scope editors. */
51
+ export function permissionScopeTitle(
52
+ resource: { readonly type: string; readonly id: string },
53
+ scope: { readonly collection: string; readonly title: LocalizedText },
54
+ selection: PermissionWorkspaceSelection = permissionWorkspaceSelection,
55
+ action?: string,
56
+ ): LocalizedText {
57
+ const selected = selection.business.find((entry) =>
58
+ resource.type === 'database.collection'
59
+ ? entry.scopeTitles?.[scope.collection] !== undefined
60
+ : entry.type === resource.type && entry.id === resource.id,
61
+ );
62
+ return (
63
+ (action
64
+ ? selected?.actionScopeTitles?.[action]?.[scope.collection]
65
+ : undefined) ??
66
+ selected?.scopeTitles?.[scope.collection] ??
67
+ scope.title
68
+ );
69
+ }
70
+
71
+ export function permissionScopeVisible(
72
+ resource: { readonly type: string; readonly id: string },
73
+ action: string,
74
+ scopeKey: string,
75
+ selection: PermissionWorkspaceSelection = permissionWorkspaceSelection,
76
+ ): boolean {
77
+ return !selection.business
78
+ .find((entry) => entry.type === resource.type && entry.id === resource.id)
79
+ ?.hiddenActionScopes?.[action]?.includes(scopeKey);
80
+ }
81
+
82
+ export function permissionFilterFieldLabel(
83
+ collection: string,
84
+ field: string,
85
+ text: (key: string, fallback?: string) => string,
86
+ selection: PermissionWorkspaceSelection = permissionWorkspaceSelection,
87
+ ): string {
88
+ const title = selection.filterFieldTitles?.[collection]?.[field];
89
+ return title
90
+ ? typeof title === 'string'
91
+ ? title
92
+ : text(title.key, title.defaultValue ?? field)
93
+ : field;
94
+ }
95
+
96
+ function hasConfigurationPage(
97
+ routes: readonly AppClientRegisteredRoute[],
98
+ resourceId: string,
99
+ ): boolean {
100
+ return routes.some(
101
+ (route) =>
102
+ (route.componentLoader &&
103
+ typeof route.authz === 'object' &&
104
+ route.authz.resource.type === 'settings' &&
105
+ route.authz.resource.id === resourceId) ||
106
+ hasConfigurationPage(route.children ?? [], resourceId),
107
+ );
108
+ }
109
+
110
+ function hasUserManagement(
111
+ routes: readonly AppClientRegisteredRoute[],
112
+ ): boolean {
113
+ return routes.some(
114
+ (route) =>
115
+ (route.componentLoader &&
116
+ typeof route.authz === 'object' &&
117
+ route.authz.resource.type === 'user') ||
118
+ hasUserManagement(route.children ?? []),
119
+ );
120
+ }
121
+
122
+ /** A configuration action needs an actual application-owned page, not just an installed capability. */
123
+ function hasApplicationPage(
124
+ routes: readonly AppClientRegisteredRoute[],
125
+ names: readonly string[],
126
+ ): boolean {
127
+ return routes.some(
128
+ (route) =>
129
+ (route.source === 'application' &&
130
+ route.componentLoader &&
131
+ names.includes(route.name)) ||
132
+ hasApplicationPage(route.children ?? [], names),
133
+ );
134
+ }
135
+
136
+ /**
137
+ * Application-owned presentation, not a copy of the complete capability catalog.
138
+ * Select actual business actions in permissionWorkspaceSelection as features are built.
139
+ * Settings additionally require a real configuration page and registered action.
140
+ * Installing a plugin never opts all its management capabilities into this form.
141
+ */
142
+ export function permissionWorkspaceCatalog(
143
+ source: AuthorizationOptionsResponse,
144
+ routes: readonly AppClientRegisteredRoute[],
145
+ pages: readonly CatalogResource[],
146
+ selection: PermissionWorkspaceSelection = permissionWorkspaceSelection,
147
+ ): AuthorizationOptionsResponse {
148
+ const configurationBusiness: CatalogResource[] = [];
149
+ const visibleSections: readonly string[] = ['pages', 'business'];
150
+ const settings = source.sections
151
+ .flatMap((section) => section.subsections.flatMap((sub) => sub.resources))
152
+ .flatMap((resource) => {
153
+ const selected = selection.settings.find(
154
+ (entry) => entry.id === resource.id,
155
+ );
156
+ if (
157
+ resource.type !== 'settings' ||
158
+ !selected ||
159
+ !hasConfigurationPage(routes, resource.id)
160
+ )
161
+ return [];
162
+ const actions = resource.actions.filter((action) =>
163
+ selected.actions.includes(action.name),
164
+ );
165
+ return actions.length ? [{ ...resource, actions }] : [];
166
+ });
167
+ return {
168
+ ...source,
169
+ sections: [
170
+ ...(hasUserManagement(routes)
171
+ ? [
172
+ {
173
+ name: 'users',
174
+ title: { key: 'settings.userPermissions' },
175
+ order: 0,
176
+ subsections: [
177
+ ...[
178
+ {
179
+ name: 'users.information',
180
+ title: 'userInformation',
181
+ actions: ['read', 'create', 'update'],
182
+ },
183
+ {
184
+ name: 'users.security',
185
+ title: 'accountSecurity',
186
+ actions: ['disable', 'enable', 'revoke-sessions'],
187
+ },
188
+ {
189
+ name: 'users.roles',
190
+ title: 'roleAssignment',
191
+ actions: ['assign-role'],
192
+ },
193
+ ].map((group) => ({
194
+ name: group.name,
195
+ title: { key: `settings.${group.title}` },
196
+ resources: [
197
+ {
198
+ type: 'user',
199
+ id: '*',
200
+ title: { key: `settings.${group.title}` },
201
+ actions: [
202
+ { name: 'read', title: { key: 'settings.readUsers' } },
203
+ {
204
+ name: 'create',
205
+ title: { key: 'settings.createUsers' },
206
+ },
207
+ {
208
+ name: 'update',
209
+ title: { key: 'settings.updateUsers' },
210
+ },
211
+ { name: 'disable', title: { key: 'settings.disable' } },
212
+ { name: 'enable', title: { key: 'settings.enable' } },
213
+ {
214
+ name: 'assign-role',
215
+ title: { key: 'settings.assignRole' },
216
+ },
217
+ {
218
+ name: 'revoke-sessions',
219
+ title: { key: 'settings.revokeSessions' },
220
+ },
221
+ ].filter((action) => group.actions.includes(action.name)),
222
+ },
223
+ ],
224
+ })),
225
+ ],
226
+ },
227
+ ]
228
+ : []),
229
+ ...source.sections
230
+ .filter((section) => visibleSections.includes(section.name))
231
+ .map((section) => ({
232
+ ...section,
233
+ subsections: section.subsections.map((sub) => ({
234
+ ...sub,
235
+ resources:
236
+ sub.recordType?.type === 'page'
237
+ ? [...sub.resources, ...pages]
238
+ : sub.resources.flatMap((resource) => {
239
+ const selected = selection.business.find(
240
+ (entry) =>
241
+ entry.type === resource.type &&
242
+ entry.id === resource.id,
243
+ );
244
+ if (!selected) return [];
245
+ const actions = resource.actions.filter((action) =>
246
+ selected.actions.includes(action.name),
247
+ );
248
+ if (!actions.length) return [];
249
+ const projected = {
250
+ ...resource,
251
+ actions: actions.map((action) => ({
252
+ ...action,
253
+ ...(selected.actionDescriptions?.[action.name]
254
+ ? {
255
+ description:
256
+ selected.actionDescriptions[action.name],
257
+ }
258
+ : {}),
259
+ })),
260
+ ...(resource.dataScopes
261
+ ? {
262
+ dataScopes: Object.fromEntries(
263
+ Object.entries(resource.dataScopes).map(
264
+ ([action, scopes]) => [
265
+ action,
266
+ scopes.map((scope) => ({
267
+ ...scope,
268
+ title: permissionScopeTitle(
269
+ resource,
270
+ scope,
271
+ selection,
272
+ action,
273
+ ),
274
+ })),
275
+ ],
276
+ ),
277
+ ),
278
+ }
279
+ : {}),
280
+ };
281
+ const isConfiguration = (name: string) =>
282
+ selected.configurationActions?.some(
283
+ (entry) => entry.name === name,
284
+ );
285
+ const configurationActions = projected.actions.filter(
286
+ (action) => {
287
+ const entry = selected.configurationActions?.find(
288
+ (entry) => entry.name === action.name,
289
+ );
290
+ return (
291
+ entry && hasApplicationPage(routes, entry.routes)
292
+ );
293
+ },
294
+ );
295
+ if (configurationActions.length)
296
+ configurationBusiness.push({
297
+ ...projected,
298
+ title: selected.configurationTitle ?? projected.title,
299
+ actions: configurationActions,
300
+ });
301
+ const businessActions = projected.actions.filter(
302
+ (action) => !isConfiguration(action.name),
303
+ );
304
+ return businessActions.length
305
+ ? [{ ...projected, actions: businessActions }]
306
+ : [];
307
+ }),
308
+ })),
309
+ })),
310
+ ...(settings.length || configurationBusiness.length
311
+ ? [
312
+ {
313
+ name: 'configuration',
314
+ title: { key: 'settings.configurationPermissions' },
315
+ order: 200,
316
+ subsections: [
317
+ {
318
+ name: 'configuration',
319
+ title: { key: 'settings.configurationPermissions' },
320
+ resources: [...settings, ...configurationBusiness],
321
+ },
322
+ ],
323
+ },
324
+ ]
325
+ : []),
326
+ ].filter((section) =>
327
+ section.subsections.some((sub) => sub.resources.length > 0),
328
+ ),
329
+ };
330
+ }
@@ -64,13 +64,18 @@ const sectionDescriptions: Readonly<Record<string, string>> = {
64
64
  pages: 'pageAccessHint',
65
65
  business: 'businessOperationsHint',
66
66
  users: 'userAccountsHint',
67
- administration: 'systemToolsHint',
67
+ configuration: 'configurationPermissionsHint',
68
68
  };
69
69
 
70
70
  export function permissionSections(
71
71
  catalog: AuthorizationOptionsResponse,
72
72
  grants: readonly PermissionGrant[],
73
73
  text: (value: LocalizedText) => string,
74
+ showScope: (
75
+ resource: CatalogResource,
76
+ action: string,
77
+ scopeKey: string,
78
+ ) => boolean = () => true,
74
79
  ): readonly PermissionEditorSection[] {
75
80
  const sharedActionHint = (resource: CatalogResource): string | undefined => {
76
81
  const first = resource.actions[0]?.description;
@@ -125,59 +130,63 @@ export function permissionSections(
125
130
  return {
126
131
  ...row,
127
132
  scopes: granted
128
- ? (resource.dataScopes?.[action.name] ?? []).map((scope) => {
129
- const value = scopeSelectionKey(
130
- granted.policy?.scopes &&
131
- typeof granted.policy.scopes === 'object'
132
- ? Reflect.get(granted.policy.scopes, scope.key)
133
- : undefined,
134
- );
135
- const preserved =
136
- value !== '' && !scope.recordAccess.includes(value);
137
- return {
138
- id: JSON.stringify([
139
- resource.type,
140
- resource.id,
141
- action.name,
142
- scope.key,
143
- ]),
144
- label: text(
145
- typeof scope.title === 'string' &&
146
- scope.title === scope.collection
147
- ? { key: 'settings.recordScope' }
148
- : scope.title,
149
- ),
150
- levels: [
151
- ...(preserved
152
- ? [
153
- {
154
- value,
155
- label: text({
156
- key: 'settings.preservedRecordScope',
157
- }),
158
- },
159
- ]
160
- : []),
161
- {
162
- value: '',
163
- label: text({ key: 'settings.noRecordScope' }),
164
- },
165
- ...scope.recordAccess.map((key) => ({
166
- value: key,
167
- label: text(
168
- catalog.recordAccess.find(
169
- (entry) => entry.key === key,
170
- )?.title ?? key,
171
- ),
172
- })),
173
- ],
174
- level: value,
175
- hint:
176
- value === ''
177
- ? text({ key: 'settings.noAdditionalScopeHint' })
133
+ ? (resource.dataScopes?.[action.name] ?? [])
134
+ .filter((scope) =>
135
+ showScope(resource, action.name, scope.key),
136
+ )
137
+ .map((scope) => {
138
+ const value = scopeSelectionKey(
139
+ granted.policy?.scopes &&
140
+ typeof granted.policy.scopes === 'object'
141
+ ? Reflect.get(granted.policy.scopes, scope.key)
178
142
  : undefined,
179
- };
180
- })
143
+ );
144
+ const preserved =
145
+ value !== '' && !scope.recordAccess.includes(value);
146
+ return {
147
+ id: JSON.stringify([
148
+ resource.type,
149
+ resource.id,
150
+ action.name,
151
+ scope.key,
152
+ ]),
153
+ label: text(
154
+ typeof scope.title === 'string' &&
155
+ scope.title === scope.collection
156
+ ? { key: 'settings.recordScope' }
157
+ : scope.title,
158
+ ),
159
+ levels: [
160
+ ...(preserved
161
+ ? [
162
+ {
163
+ value,
164
+ label: text({
165
+ key: 'settings.preservedRecordScope',
166
+ }),
167
+ },
168
+ ]
169
+ : []),
170
+ {
171
+ value: '',
172
+ label: text({ key: 'settings.noRecordScope' }),
173
+ },
174
+ ...scope.recordAccess.map((key) => ({
175
+ value: key,
176
+ label: text(
177
+ catalog.recordAccess.find(
178
+ (entry) => entry.key === key,
179
+ )?.title ?? key,
180
+ ),
181
+ })),
182
+ ],
183
+ level: value,
184
+ hint:
185
+ value === ''
186
+ ? text({ key: 'settings.noAdditionalScopeHint' })
187
+ : undefined,
188
+ };
189
+ })
181
190
  : [],
182
191
  };
183
192
  }),
@@ -11,17 +11,22 @@ import {
11
11
  import { useTranslation } from '@nocobase/i18n/client';
12
12
  import {
13
13
  useAuthorizationClient,
14
- useCan,
15
14
  type AuthorizationOptionsResponse,
16
15
  type LocalizedText,
17
16
  type PermissionGrant,
18
17
  type PermissionSet,
19
18
  } from '@nocobase/app-plugin-authorization/client';
20
- import { pagePermissionResources } from './page-permissions.js';
19
+ import {
20
+ permissionNavigationSections,
21
+ pagePermissionResources,
22
+ } from './page-permissions.js';
21
23
  import { pagePermissionTitles } from './page-permission-titles.js';
24
+ import {
25
+ permissionScopeVisible,
26
+ permissionWorkspaceCatalog,
27
+ } from './permission-workspace.js';
22
28
  import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
23
29
  import {
24
- Link,
25
30
  useLocation,
26
31
  useNavigate,
27
32
  useOutletContext,
@@ -74,10 +79,6 @@ function RoleEditor(): ReactElement {
74
79
  const options = useSettingsData<{ data: AuthorizationOptionsResponse }>(
75
80
  'authorization/permissionSets/options',
76
81
  );
77
- const canReadDefaults = useCan({
78
- resource: { type: 'settings', id: 'authorization.default-access' },
79
- action: 'read',
80
- }).can;
81
82
  const canCreate = useRolePermission('create');
82
83
  const canUpdate = useRolePermission('update');
83
84
  const [nameDraft, setNameDraft] = useState<string>();
@@ -105,87 +106,16 @@ function RoleEditor(): ReactElement {
105
106
  const grants = grantsDraft ?? initial?.grants ?? [];
106
107
  const catalog = useMemo(() => {
107
108
  if (!options.data) return undefined;
108
- const source = options.data.data;
109
109
  const pages = pagePermissionResources(
110
110
  app.runtime.routes,
111
111
  pagePermissionTitles,
112
112
  (path) => t('settings.unnamedPage', { path }),
113
113
  );
114
- return {
115
- ...source,
116
- sections: [
117
- {
118
- name: 'users',
119
- title: { key: 'settings.userPermissions' },
120
- order: 0,
121
- subsections: [
122
- ...[
123
- {
124
- name: 'users.information',
125
- title: 'userInformation',
126
- actions: ['read', 'create', 'update', 'invite', 'delete'],
127
- },
128
- {
129
- name: 'users.security',
130
- title: 'accountSecurity',
131
- actions: [
132
- 'reset-password',
133
- 'disable',
134
- 'enable',
135
- 'revoke-sessions',
136
- ],
137
- },
138
- {
139
- name: 'users.roles',
140
- title: 'roleAssignment',
141
- actions: ['assign-role'],
142
- },
143
- ].map((group) => ({
144
- name: group.name,
145
- title: { key: `settings.${group.title}` },
146
- resources: [
147
- {
148
- type: 'user',
149
- id: '*',
150
- title: { key: `settings.${group.title}` },
151
- actions: [
152
- { name: 'read', title: { key: 'settings.readUsers' } },
153
- { name: 'create', title: { key: 'settings.createUsers' } },
154
- { name: 'update', title: { key: 'settings.updateUsers' } },
155
- { name: 'invite', title: { key: 'settings.inviteUsers' } },
156
- { name: 'delete', title: { key: 'settings.deleteUsers' } },
157
- {
158
- name: 'reset-password',
159
- title: { key: 'settings.resetUserPassword' },
160
- },
161
- { name: 'disable', title: { key: 'settings.disable' } },
162
- { name: 'enable', title: { key: 'settings.enable' } },
163
- {
164
- name: 'assign-role',
165
- title: { key: 'settings.assignRole' },
166
- },
167
- {
168
- name: 'revoke-sessions',
169
- title: { key: 'settings.revokeSessions' },
170
- },
171
- ].filter((action) => group.actions.includes(action.name)),
172
- },
173
- ],
174
- })),
175
- ],
176
- },
177
- ...source.sections.map((section) => ({
178
- ...section,
179
- subsections: section.subsections.map((sub) => ({
180
- ...sub,
181
- resources:
182
- sub.recordType?.type === 'page'
183
- ? [...sub.resources, ...pages]
184
- : sub.resources,
185
- })),
186
- })),
187
- ],
188
- };
114
+ return permissionWorkspaceCatalog(
115
+ options.data.data,
116
+ app.runtime.routes,
117
+ pages,
118
+ );
189
119
  }, [options.data, app, t]);
190
120
  const allowed = creating
191
121
  ? canCreate
@@ -247,7 +177,13 @@ function RoleEditor(): ReactElement {
247
177
  };
248
178
  const sections = catalog
249
179
  ? configurePermissionScopes(
250
- permissionSections(catalog, grants, text),
180
+ permissionNavigationSections(
181
+ permissionSections(catalog, grants, text, permissionScopeVisible),
182
+ app.runtime.routes,
183
+ pagePermissionTitles,
184
+ (path) => t('settings.unnamedPage', { path }),
185
+ text,
186
+ ),
251
187
  catalog,
252
188
  grants,
253
189
  (key, fallback) => t(key, { defaultValue: fallback ?? key }),
@@ -343,16 +279,9 @@ function RoleEditor(): ReactElement {
343
279
  {t('settings.protectedHint')}
344
280
  </p>
345
281
  )}
346
- {canReadDefaults && (
347
- <div>
348
- <Link
349
- to='/default-access'
350
- className='text-sm text-foreground underline-offset-4 hover:underline'
351
- >
352
- {t('settings.defaultAccess.manage')}
353
- </Link>
354
- </div>
355
- )}
282
+ <p className='text-xs text-muted-foreground'>
283
+ {t('settings.permissionSaveHint')}
284
+ </p>
356
285
  <PermissionEditor
357
286
  sections={sections}
358
287
  readOnly={!allowed || busy}
@@ -365,8 +294,15 @@ function RoleEditor(): ReactElement {
365
294
  enabledOnly: t('settings.enabledOnly'),
366
295
  clear: t('settings.clear'),
367
296
  empty: t('settings.noPermissions'),
297
+ emptyDescription: t('settings.permissionEmptyHint'),
298
+ readOnly: t('settings.permissionReadOnlyHint'),
299
+ managedPermission: t('settings.permissionManagedHint'),
368
300
  noResults: t('settings.noResults'),
369
301
  navigation: t('settings.permissionCategories'),
302
+ expandGroup: (name) =>
303
+ t('settings.expandPermissionGroup', { name }),
304
+ collapseGroup: (name) =>
305
+ t('settings.collapsePermissionGroup', { name }),
370
306
  }}
371
307
  onEnabledChange={(id, enabled) => {
372
308
  if (!catalog) return;