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

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 (38) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.MD +3 -1
  3. package/cli/plugins.ts +2 -1
  4. package/client/components/default-access-editor.tsx +183 -0
  5. package/client/components/permission-editor.tsx +216 -150
  6. package/client/components/role-list.tsx +85 -0
  7. package/client/lib/navigation-transform.ts +29 -1
  8. package/client/locales/system/settings-en-US.ts +74 -2
  9. package/client/locales/system/settings-zh-CN.ts +66 -2
  10. package/client/pages/settings/default-access-form.tsx +499 -0
  11. package/client/pages/settings/default-access-model.ts +154 -0
  12. package/client/pages/settings/default-access.tsx +407 -0
  13. package/client/pages/settings/permission-filters.tsx +43 -37
  14. package/client/pages/settings/permission-management-page.tsx +105 -0
  15. package/client/pages/settings/permissions-model.ts +37 -1
  16. package/client/pages/settings/role-form.tsx +46 -18
  17. package/client/pages/settings/roles.tsx +26 -43
  18. package/client/pages/settings/routes.ts +78 -19
  19. package/client/pages/settings/shared.tsx +3 -0
  20. package/client/pages/settings/users.tsx +1 -1
  21. package/client/plugins.ts +2 -0
  22. package/client/routing/route-navigation.ts +21 -1
  23. package/package.json +25 -23
  24. package/server/plugins.ts +2 -0
  25. package/tests/components/default-access.test.tsx +554 -0
  26. package/tests/components/page-harness.test.tsx +132 -112
  27. package/tests/components/permission-editor.test.tsx +233 -73
  28. package/tests/components/permission-management-tabs.test.tsx +128 -0
  29. package/tests/components/role-list.test.tsx +136 -0
  30. package/tests/components/roles.test.tsx +85 -0
  31. package/tests/components/user-permissions.test.tsx +12 -0
  32. package/tests/logic/authorization-assignment-safety.test.ts +82 -0
  33. package/tests/logic/client-routes.test.ts +8 -0
  34. package/tests/logic/default-access-model.test.ts +167 -0
  35. package/tests/logic/foundation-integration.test.ts +62 -0
  36. package/tests/logic/permission-management-routes.test.ts +64 -0
  37. package/tests/logic/settings-permissions.test.ts +89 -0
  38. package/vitest.config.ts +2 -2
@@ -0,0 +1,407 @@
1
+ import { 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 { Link, Outlet, useLocation, useSearchParams } from 'react-router';
10
+ import { ListFilterIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react';
11
+ import type { ColumnDef } from '@tanstack/react-table';
12
+ import { Alert, AlertDescription } from '#components/ui/alert';
13
+ import { PermissionManagementPage } from './permission-management-page.js';
14
+ import { DataTable, DataTableColumnHeader } from '#components/data-table';
15
+ import { Button } from '#components/ui/button';
16
+ import { Input } from '#components/ui/input';
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuItem,
21
+ DropdownMenuSeparator,
22
+ DropdownMenuTrigger,
23
+ } from '#components/ui/dropdown-menu';
24
+ import {
25
+ Empty,
26
+ EmptyMedia,
27
+ EmptyTitle,
28
+ EmptyDescription,
29
+ EmptyContent,
30
+ } from '#components/ui/empty';
31
+ import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
32
+ import { useSettingsData } from './hooks.js';
33
+ import {
34
+ actionKey,
35
+ resourceKey,
36
+ ruleResources,
37
+ ruleScopes,
38
+ type DefaultAccessRule,
39
+ } from './default-access-model.js';
40
+
41
+ export default function DefaultAccessPage(): ReactElement {
42
+ const { t, i18n } = useTranslation();
43
+ const api = useApiClient();
44
+ const toaster = useToaster();
45
+ const location = useLocation();
46
+ const [params, setParams] = useSearchParams();
47
+ const search = params.get('q') ?? '';
48
+ const searchRef = useRef<HTMLInputElement>(null);
49
+ const rules = useSettingsData<{ data: readonly DefaultAccessRule[] }>(
50
+ 'authorization/defaultAccess',
51
+ );
52
+ const catalog = useSettingsData<{ data: AuthorizationOptionsResponse }>(
53
+ 'authorization/defaultAccess/options',
54
+ );
55
+ const canCreate = useCan({
56
+ resource: { type: 'settings', id: 'authorization.default-access' },
57
+ action: 'create',
58
+ }).can;
59
+ const canDelete = useCan({
60
+ resource: { type: 'settings', id: 'authorization.default-access' },
61
+ action: 'delete',
62
+ }).can;
63
+ const [removing, setRemoving] = useState<DefaultAccessRule>();
64
+ const [busy, setBusy] = useState(false);
65
+ const [error, setError] = useState<unknown>();
66
+ const [conflict, setConflict] = useState(false);
67
+ const text = (value: LocalizedText): string =>
68
+ typeof value === 'string'
69
+ ? value
70
+ : t(value.key, {
71
+ ns: value.ns,
72
+ defaultValue: value.defaultValue ?? value.key,
73
+ });
74
+ const resources = catalog.data ? ruleResources(catalog.data.data) : [];
75
+ const name = (rule: DefaultAccessRule): string =>
76
+ text(
77
+ resources.find(
78
+ (resource) => resourceKey(resource) === resourceKey(rule.resource),
79
+ )?.title ?? rule.resource.id,
80
+ );
81
+ const summary = (rule: DefaultAccessRule): string => {
82
+ const resource = resources.find(
83
+ (resource) => resourceKey(resource) === resourceKey(rule.resource),
84
+ );
85
+ const scopes = catalog.data ? ruleScopes(resource, catalog.data.data) : [];
86
+ return (
87
+ rule.actions
88
+ .map((entry) => {
89
+ const scope = scopes.find((scope) => scope.id === actionKey(entry));
90
+ const label =
91
+ entry.selection.type === 'all'
92
+ ? t('settings.defaultAccess.all')
93
+ : entry.selection.type === 'records'
94
+ ? t('settings.defaultAccess.records', {
95
+ count: entry.selection.ids.length,
96
+ })
97
+ : text(
98
+ catalog.data?.data.recordAccess.find(
99
+ (item) =>
100
+ entry.selection.type === 'recordAccess' &&
101
+ item.key === entry.selection.key,
102
+ )?.title ?? entry.selection.key,
103
+ );
104
+ return `${text(scope?.actionTitle ?? entry.action)}${scope ? ` · ${text(scope.title)}` : entry.scopeKey ? ` · ${entry.scopeKey}` : ''}: ${label}`;
105
+ })
106
+ .join('; ') || t('settings.defaultAccess.none')
107
+ );
108
+ };
109
+ const columns: ColumnDef<DefaultAccessRule>[] = [
110
+ {
111
+ id: 'resource',
112
+ accessorFn: name,
113
+ sortingFn: (a, b) =>
114
+ name(a.original).localeCompare(name(b.original), i18n.language),
115
+ header: ({ column }) => (
116
+ <DataTableColumnHeader
117
+ column={column}
118
+ title={t('settings.defaultAccess.resource')}
119
+ />
120
+ ),
121
+ cell: ({ row }) => (
122
+ <Link
123
+ className='text-foreground underline-offset-4 hover:underline'
124
+ to={{
125
+ pathname: `${encodeURIComponent(row.original.key)}/edit`,
126
+ search: location.search,
127
+ }}
128
+ >
129
+ {name(row.original)}
130
+ </Link>
131
+ ),
132
+ },
133
+ {
134
+ accessorKey: 'key',
135
+ header: t('settings.key'),
136
+ enableSorting: false,
137
+ cell: ({ row }) => (
138
+ <span
139
+ className='block max-w-48 truncate text-muted-foreground'
140
+ title={row.original.key}
141
+ >
142
+ {row.original.key}
143
+ </span>
144
+ ),
145
+ },
146
+ {
147
+ id: 'scopes',
148
+ header: t('settings.defaultAccess.scopes'),
149
+ enableSorting: false,
150
+ cell: ({ row }) => (
151
+ <span className='block max-w-96 truncate' title={summary(row.original)}>
152
+ {summary(row.original)}
153
+ </span>
154
+ ),
155
+ },
156
+ {
157
+ id: 'actions',
158
+ enableSorting: false,
159
+ cell: ({ row }) => (
160
+ <DropdownMenu>
161
+ <DropdownMenuTrigger
162
+ render={
163
+ <Button
164
+ variant='ghost'
165
+ size='icon-sm'
166
+ aria-label={t('settings.actionsFor', {
167
+ name: name(row.original),
168
+ })}
169
+ >
170
+ <MoreHorizontalIcon />
171
+ </Button>
172
+ }
173
+ />
174
+ <DropdownMenuContent align='end' className='w-auto min-w-40'>
175
+ <DropdownMenuItem
176
+ render={
177
+ <Link
178
+ to={{
179
+ pathname: `${encodeURIComponent(row.original.key)}/edit`,
180
+ search: location.search,
181
+ }}
182
+ />
183
+ }
184
+ >
185
+ {t('settings.defaultAccess.open')}
186
+ </DropdownMenuItem>
187
+ {canDelete && (
188
+ <>
189
+ <DropdownMenuSeparator />
190
+ <DropdownMenuItem
191
+ variant='destructive'
192
+ onClick={() => {
193
+ setError(undefined);
194
+ setRemoving(row.original);
195
+ }}
196
+ >
197
+ {t('settings.defaultAccess.delete')}
198
+ </DropdownMenuItem>
199
+ </>
200
+ )}
201
+ </DropdownMenuContent>
202
+ </DropdownMenu>
203
+ ),
204
+ },
205
+ ];
206
+ const rows = (rules.data?.data ?? [])
207
+ .filter((rule) =>
208
+ `${name(rule)} ${rule.key}`
209
+ .toLocaleLowerCase()
210
+ .includes(search.toLocaleLowerCase()),
211
+ )
212
+ .sort((a, b) => name(a).localeCompare(name(b), i18n.language));
213
+ const eligible = resources.some(
214
+ (resource) =>
215
+ !rules.data?.data.some(
216
+ (rule) => resourceKey(rule.resource) === resourceKey(resource),
217
+ ),
218
+ );
219
+ const create =
220
+ canCreate && rules.data && catalog.data ? (
221
+ eligible ? (
222
+ <Button
223
+ nativeButton={false}
224
+ render={<Link to={{ pathname: 'create', search: location.search }} />}
225
+ >
226
+ <PlusIcon />
227
+ {t('settings.defaultAccess.new')}
228
+ </Button>
229
+ ) : (
230
+ <div className='flex flex-wrap items-center gap-2'>
231
+ <span className='text-sm text-muted-foreground'>
232
+ {t('settings.defaultAccess.noResourcesShort')}
233
+ </span>
234
+ <Button disabled>
235
+ <PlusIcon />
236
+ {t('settings.defaultAccess.new')}
237
+ </Button>
238
+ </div>
239
+ )
240
+ ) : null;
241
+ const remove = async () => {
242
+ if (!removing || busy || !canDelete) return;
243
+ setBusy(true);
244
+ setError(undefined);
245
+ try {
246
+ const latest = await api.request<{ data: readonly DefaultAccessRule[] }>({
247
+ path: 'authorization/defaultAccess',
248
+ });
249
+ if (
250
+ JSON.stringify(
251
+ latest.data.find((rule) => rule.key === removing.key),
252
+ ) !== JSON.stringify(removing)
253
+ ) {
254
+ rules.reload();
255
+ setRemoving(undefined);
256
+ setConflict(true);
257
+ requestAnimationFrame(() => searchRef.current?.focus());
258
+ return;
259
+ }
260
+ await api.request({
261
+ path: `authorization/defaultAccess/${encodeURIComponent(removing.key)}`,
262
+ method: 'DELETE',
263
+ });
264
+ toaster.show({
265
+ type: 'success',
266
+ title: t('settings.defaultAccess.deleted', { name: name(removing) }),
267
+ });
268
+ rules.replaceData({
269
+ data: (rules.data?.data ?? []).filter(
270
+ (rule) => rule.key !== removing.key,
271
+ ),
272
+ });
273
+ setRemoving(undefined);
274
+ rules.reload();
275
+ requestAnimationFrame(() => searchRef.current?.focus());
276
+ } catch (next) {
277
+ setError(next);
278
+ } finally {
279
+ setBusy(false);
280
+ }
281
+ };
282
+ return (
283
+ <>
284
+ <PermissionManagementPage
285
+ tab='default-access'
286
+ actions={
287
+ rules.data && (rules.data.data.length || search) ? create : undefined
288
+ }
289
+ >
290
+ <div className='flex flex-wrap items-center gap-2'>
291
+ <Input
292
+ ref={searchRef}
293
+ className='sm:max-w-xs'
294
+ value={search}
295
+ placeholder={t('settings.defaultAccess.search')}
296
+ aria-label={t('settings.defaultAccess.search')}
297
+ onChange={(event) =>
298
+ setParams(event.target.value ? { q: event.target.value } : {}, {
299
+ replace: true,
300
+ })
301
+ }
302
+ />
303
+ {search && (
304
+ <Button variant='ghost' onClick={() => setParams({})}>
305
+ {t('settings.clear')}
306
+ </Button>
307
+ )}
308
+ </div>
309
+ {conflict && (
310
+ <Alert variant='destructive'>
311
+ <AlertDescription>
312
+ {t('settings.defaultAccess.deleteChanged')}
313
+ </AlertDescription>
314
+ </Alert>
315
+ )}
316
+ <SettingsError
317
+ error={rules.error || catalog.error || (!removing && error)}
318
+ retry={() => {
319
+ rules.reload();
320
+ catalog.reload();
321
+ setError(undefined);
322
+ }}
323
+ />
324
+ {(rules.loading && !rules.data) ||
325
+ (catalog.loading && !catalog.data) ? (
326
+ <SettingsLoading />
327
+ ) : (
328
+ !rules.error &&
329
+ !catalog.error &&
330
+ (rows.length ? (
331
+ <DataTable
332
+ columns={columns}
333
+ data={rows}
334
+ getRowId={(rule) => rule.key}
335
+ showSelectedCount={false}
336
+ pageSize={20}
337
+ />
338
+ ) : (
339
+ <Empty className='min-h-48 border border-dashed'>
340
+ <EmptyMedia variant='icon'>
341
+ <ListFilterIcon />
342
+ </EmptyMedia>
343
+ <EmptyTitle>
344
+ {t(
345
+ search
346
+ ? 'settings.noResults'
347
+ : 'settings.defaultAccess.empty',
348
+ )}
349
+ </EmptyTitle>
350
+ <EmptyDescription>
351
+ {t(
352
+ search
353
+ ? 'settings.clearHint'
354
+ : canCreate
355
+ ? 'settings.defaultAccess.emptyHint'
356
+ : 'settings.defaultAccess.readOnlyEmpty',
357
+ )}
358
+ </EmptyDescription>
359
+ <EmptyContent>
360
+ {search ? (
361
+ <Button variant='outline' onClick={() => setParams({})}>
362
+ {t('settings.clear')}
363
+ </Button>
364
+ ) : (
365
+ create
366
+ )}
367
+ </EmptyContent>
368
+ </Empty>
369
+ ))
370
+ )}
371
+ <ConfirmAction
372
+ error={<SettingsError error={error} />}
373
+ open={Boolean(removing)}
374
+ title={t('settings.defaultAccess.deleteTitle', {
375
+ name: removing ? name(removing) : '',
376
+ })}
377
+ description={t('settings.defaultAccess.deleteHint')}
378
+ confirmLabel={t('settings.defaultAccess.delete')}
379
+ busy={busy}
380
+ onCancel={() => {
381
+ setRemoving(undefined);
382
+ setError(undefined);
383
+ }}
384
+ onConfirm={() => {
385
+ void remove();
386
+ }}
387
+ />
388
+ </PermissionManagementPage>
389
+ <Outlet
390
+ context={{
391
+ reload: rules.reload,
392
+ saved: (updated: DefaultAccessRule) => {
393
+ rules.replaceData({
394
+ data: [
395
+ ...(rules.data?.data ?? []).filter(
396
+ (rule) => rule.key !== updated.key,
397
+ ),
398
+ updated,
399
+ ],
400
+ });
401
+ rules.reload();
402
+ },
403
+ }}
404
+ />
405
+ </>
406
+ );
407
+ }
@@ -19,43 +19,7 @@ export function configurePermissionScopes(
19
19
  onApply: (id: string, value: unknown) => void,
20
20
  disabled: boolean,
21
21
  ): readonly PermissionEditorSection[] {
22
- const labels: PermissionFilterLabels = {
23
- limit: text('settings.permissionFilter.limit'),
24
- configure: text('settings.permissionFilter.configure'),
25
- title: text('settings.permissionFilter.title'),
26
- description: text('settings.permissionFilter.description'),
27
- all: text('settings.permissionFilter.all'),
28
- any: text('settings.permissionFilter.any'),
29
- field: text('settings.permissionFilter.field'),
30
- operator: text('settings.permissionFilter.operator'),
31
- valueType: text('settings.permissionFilter.valueType'),
32
- value: text('settings.permissionFilter.value'),
33
- text: text('settings.permissionFilter.text'),
34
- number: text('settings.permissionFilter.number'),
35
- boolean: text('settings.permissionFilter.boolean'),
36
- true: text('settings.permissionFilter.true'),
37
- false: text('settings.permissionFilter.false'),
38
- add: text('settings.permissionFilter.add'),
39
- remove: text('settings.permissionFilter.remove'),
40
- cancel: text('settings.permissionFilter.cancel'),
41
- apply: text('settings.permissionFilter.apply'),
42
- invalid: text('settings.permissionFilter.invalid'),
43
- advanced: text('settings.permissionFilter.advanced'),
44
- reconfigure: text('settings.permissionFilter.reconfigure'),
45
- operators: Object.fromEntries(
46
- [
47
- '$eq',
48
- '$ne',
49
- '$includes',
50
- '$gt',
51
- '$gte',
52
- '$lt',
53
- '$lte',
54
- '$empty',
55
- '$notEmpty',
56
- ].map((key) => [key, text(`settings.permissionFilter.operators.${key}`)]),
57
- ),
58
- };
22
+ const labels = permissionFilterLabels(text);
59
23
  const resources = catalog.sections.flatMap((section) =>
60
24
  section.subsections.flatMap((sub) => sub.resources),
61
25
  );
@@ -117,3 +81,45 @@ export function configurePermissionScopes(
117
81
  })),
118
82
  }));
119
83
  }
84
+
85
+ export function permissionFilterLabels(
86
+ text: (key: string, fallback?: string) => string,
87
+ ): PermissionFilterLabels {
88
+ return {
89
+ limit: text('settings.permissionFilter.limit'),
90
+ configure: text('settings.permissionFilter.configure'),
91
+ title: text('settings.permissionFilter.title'),
92
+ description: text('settings.permissionFilter.description'),
93
+ all: text('settings.permissionFilter.all'),
94
+ any: text('settings.permissionFilter.any'),
95
+ field: text('settings.permissionFilter.field'),
96
+ operator: text('settings.permissionFilter.operator'),
97
+ valueType: text('settings.permissionFilter.valueType'),
98
+ value: text('settings.permissionFilter.value'),
99
+ text: text('settings.permissionFilter.text'),
100
+ number: text('settings.permissionFilter.number'),
101
+ boolean: text('settings.permissionFilter.boolean'),
102
+ true: text('settings.permissionFilter.true'),
103
+ false: text('settings.permissionFilter.false'),
104
+ add: text('settings.permissionFilter.add'),
105
+ remove: text('settings.permissionFilter.remove'),
106
+ cancel: text('settings.permissionFilter.cancel'),
107
+ apply: text('settings.permissionFilter.apply'),
108
+ invalid: text('settings.permissionFilter.invalid'),
109
+ advanced: text('settings.permissionFilter.advanced'),
110
+ reconfigure: text('settings.permissionFilter.reconfigure'),
111
+ operators: Object.fromEntries(
112
+ [
113
+ '$eq',
114
+ '$ne',
115
+ '$includes',
116
+ '$gt',
117
+ '$gte',
118
+ '$lt',
119
+ '$lte',
120
+ '$empty',
121
+ '$notEmpty',
122
+ ].map((key) => [key, text(`settings.permissionFilter.operators.${key}`)]),
123
+ ),
124
+ };
125
+ }
@@ -0,0 +1,105 @@
1
+ import { useEffect, useRef, type ReactElement, type ReactNode } from 'react';
2
+ import { useNavigate, useResolvedPath, useLocation } from 'react-router';
3
+ import { useCan } from '@nocobase/app-plugin-authorization/client';
4
+ import { useTranslation } from '@nocobase/i18n/client';
5
+ import { PageContainer } from '#components/page-container';
6
+ import { PageHeader } from '#components/page-header';
7
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '#components/ui/tabs';
8
+
9
+ export function PermissionManagementPage({
10
+ tab,
11
+ actions,
12
+ children,
13
+ }: {
14
+ tab: 'roles' | 'default-access';
15
+ actions?: ReactNode;
16
+ children: ReactNode;
17
+ }): ReactElement {
18
+ const { t } = useTranslation();
19
+ const navigate = useNavigate();
20
+ const location = useLocation();
21
+ const activeRef = useRef<HTMLButtonElement>(null);
22
+ const focusedRef = useRef(false);
23
+ const current = useResolvedPath('.').pathname;
24
+ const prefix = current.slice(0, current.lastIndexOf('/'));
25
+ const roles = useCan({
26
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
27
+ action: 'read',
28
+ }).can;
29
+ const defaults = useCan({
30
+ resource: { type: 'settings', id: 'authorization.default-access' },
31
+ action: 'read',
32
+ }).can;
33
+ // Route changes remount each existing page; keep keyboard focus on the selected tab.
34
+ useEffect(() => {
35
+ if (
36
+ !focusedRef.current &&
37
+ location.state &&
38
+ typeof location.state === 'object' &&
39
+ Reflect.get(location.state, 'permissionTabNavigation') === true &&
40
+ activeRef.current
41
+ ) {
42
+ const frame = requestAnimationFrame(() => {
43
+ if (activeRef.current) {
44
+ const selected = activeRef.current;
45
+ if (
46
+ !selected
47
+ .closest('[role="tablist"]')
48
+ ?.contains(selected.ownerDocument.activeElement)
49
+ )
50
+ selected.focus();
51
+ focusedRef.current = true;
52
+ }
53
+ });
54
+ return () => cancelAnimationFrame(frame);
55
+ }
56
+ }, [location.state, roles, defaults]);
57
+ return (
58
+ <PageContainer>
59
+ <PageHeader
60
+ title={t('settings.permissionManagement')}
61
+ description={t(
62
+ tab === 'roles'
63
+ ? 'settings.rolesDescription'
64
+ : 'settings.defaultAccess.description',
65
+ )}
66
+ actions={actions}
67
+ />
68
+ <Tabs
69
+ className='gap-6'
70
+ value={tab}
71
+ onValueChange={(value) => {
72
+ if (value === 'roles' || value === 'default-access')
73
+ void navigate(`${prefix}/${value}`, {
74
+ state: { permissionTabNavigation: true },
75
+ });
76
+ }}
77
+ >
78
+ <TabsList
79
+ variant='line'
80
+ aria-label={t('settings.permissionManagement')}
81
+ >
82
+ {roles && (
83
+ <TabsTrigger
84
+ ref={tab === 'roles' ? activeRef : undefined}
85
+ value='roles'
86
+ >
87
+ {t('settings.roles')}
88
+ </TabsTrigger>
89
+ )}
90
+ {defaults && (
91
+ <TabsTrigger
92
+ ref={tab === 'default-access' ? activeRef : undefined}
93
+ value='default-access'
94
+ >
95
+ {t('settings.defaultAccess.title')}
96
+ </TabsTrigger>
97
+ )}
98
+ </TabsList>
99
+ <TabsContent value={tab} className='space-y-6'>
100
+ {children}
101
+ </TabsContent>
102
+ </Tabs>
103
+ </PageContainer>
104
+ );
105
+ }
@@ -60,19 +60,43 @@ export function setPermission(
60
60
  return next.filter((item) => item.actions.length);
61
61
  }
62
62
 
63
+ const sectionDescriptions: Readonly<Record<string, string>> = {
64
+ pages: 'pageAccessHint',
65
+ business: 'businessOperationsHint',
66
+ users: 'userAccountsHint',
67
+ administration: 'systemToolsHint',
68
+ };
69
+
63
70
  export function permissionSections(
64
71
  catalog: AuthorizationOptionsResponse,
65
72
  grants: readonly PermissionGrant[],
66
73
  text: (value: LocalizedText) => string,
67
74
  ): readonly PermissionEditorSection[] {
75
+ const sharedActionHint = (resource: CatalogResource): string | undefined => {
76
+ const first = resource.actions[0]?.description;
77
+ if (!first) return undefined;
78
+ const hint = text(first);
79
+ return resource.actions.every(
80
+ (action) => action.description && text(action.description) === hint,
81
+ )
82
+ ? hint
83
+ : undefined;
84
+ };
68
85
  return catalog.sections
69
86
  .map((section) => ({
70
87
  id: section.name,
71
88
  title: text(section.title),
89
+ description: sectionDescriptions[section.name]
90
+ ? text({ key: `settings.${sectionDescriptions[section.name]}` })
91
+ : undefined,
72
92
  groups: section.subsections.flatMap((subsection) =>
73
93
  subsection.resources.map((resource) => ({
74
94
  id: JSON.stringify([subsection.name, resource.type, resource.id]),
75
95
  title: text(resource.title),
96
+ actionHint:
97
+ resource.actions.length > 1
98
+ ? sharedActionHint(resource)
99
+ : undefined,
76
100
  accessOnly:
77
101
  resource.type === 'page' &&
78
102
  resource.actions.length === 1 &&
@@ -89,6 +113,11 @@ export function permissionSections(
89
113
  const row = {
90
114
  id: permissionRowId(resource, action.name),
91
115
  label: text(action.title),
116
+ hint:
117
+ action.description &&
118
+ !(resource.actions.length > 1 && sharedActionHint(resource))
119
+ ? text(action.description)
120
+ : undefined,
92
121
  actionKey: action.name,
93
122
  enabled: Boolean(granted),
94
123
  };
@@ -143,6 +172,10 @@ export function permissionSections(
143
172
  })),
144
173
  ],
145
174
  level: value,
175
+ hint:
176
+ value === ''
177
+ ? text({ key: 'settings.noAdditionalScopeHint' })
178
+ : undefined,
146
179
  };
147
180
  })
148
181
  : [],
@@ -203,7 +236,10 @@ export function setPermissions(
203
236
  const [type, resourceId, action, scope] = JSON.parse(id) as string[];
204
237
  if (scope) return current;
205
238
  const resource = resources.find(
206
- (item) => item.type === type && item.id === resourceId,
239
+ (item) =>
240
+ item.type === type &&
241
+ item.id === resourceId &&
242
+ item.actions.some((entry) => entry.name === action),
207
243
  );
208
244
  if (
209
245
  !resource ||