@nocobase/app-template-default 1.0.0-beta.84 → 1.0.0-beta.85

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 (42) hide show
  1. package/AGENTS.md +1 -1
  2. package/client/components/data-table/data-table.tsx +86 -32
  3. package/client/components/list-feedback.tsx +174 -0
  4. package/client/components/permission-editor.tsx +40 -33
  5. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  6. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  7. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  8. package/client/hooks/use-list-data.ts +1 -2
  9. package/client/locales/system/en-US.ts +14 -1
  10. package/client/locales/system/settings-en-US.ts +0 -10
  11. package/client/locales/system/settings-zh-CN.ts +0 -8
  12. package/client/locales/system/zh-CN.ts +14 -1
  13. package/client/pages/settings/default-access.tsx +50 -72
  14. package/client/pages/settings/permissions-model.ts +0 -1
  15. package/client/pages/settings/roles.tsx +63 -97
  16. package/client/pages/settings/users.tsx +72 -70
  17. package/client/runtime.ts +0 -4
  18. package/config.example.yml +24 -0
  19. package/package.json +19 -17
  20. package/server/app.ts +0 -2
  21. package/server/config/auth.ts +2 -0
  22. package/server/config/concurrency.ts +3 -0
  23. package/server/config/index.ts +3 -0
  24. package/server/config/jobs.ts +1 -13
  25. package/server/config/notification.ts +1 -1
  26. package/server/config/queue.ts +1 -7
  27. package/server/plugins.ts +2 -0
  28. package/tests/client/data-table.test.tsx +9 -4
  29. package/tests/client/default-access.test.tsx +43 -5
  30. package/tests/client/list-feedback.test.tsx +301 -0
  31. package/tests/client/manual-list-retry.test.tsx +190 -0
  32. package/tests/client/permission-workspace.test.ts +13 -0
  33. package/tests/client/settings-continuity.test.tsx +80 -1
  34. package/tests/client/settings-feedback.test.tsx +7 -15
  35. package/tests/fixtures/permission-alignment.html +16 -0
  36. package/tests/fixtures/permission-alignment.tsx +124 -0
  37. package/tests/playwright/permission-alignment.test.ts +109 -0
  38. package/tests/server/auth-email-config.test.ts +79 -0
  39. package/tests/server/concurrency.test.ts +61 -0
  40. package/tests/server/config.test.ts +34 -14
  41. package/client/route-overrides.ts +0 -9
  42. package/client/source-extensions.ts +0 -19
@@ -1,3 +1,4 @@
1
+ import type { ListState } from '@nocobase/app-client';
1
2
  import type { ReactElement, ReactNode } from 'react';
2
3
  import type {
3
4
  CellContext,
@@ -17,12 +18,14 @@ export interface WorkflowExecutionRow {
17
18
  createdAt: ReactNode;
18
19
  }
19
20
  export interface WorkflowExecutionListProps {
21
+ listState?: ListState;
22
+ onClearFilters?: () => void;
20
23
  rows: readonly WorkflowExecutionRow[];
21
24
  loaded: boolean;
22
25
  loading: boolean;
23
26
  filters: ReactNode;
24
27
  status?: ReactNode;
25
- empty: ReactNode;
28
+ empty?: ReactNode;
26
29
  labels: {
27
30
  refresh: string;
28
31
  record: string;
@@ -47,6 +50,8 @@ type ExecutionDateMeta = ColumnMeta<WorkflowExecutionRow, unknown> &
47
50
 
48
51
  export function WorkflowExecutionList({
49
52
  rows,
53
+ listState,
54
+ onClearFilters,
50
55
  loaded,
51
56
  loading,
52
57
  filters,
@@ -72,53 +77,61 @@ export function WorkflowExecutionList({
72
77
  </Button>
73
78
  </div>
74
79
  {status}
75
- <div className='bg-card'>
76
- <DataTable
77
- data={rows}
78
- getRowId={(row) => row.id}
79
- pagination={false}
80
- manualSorting
81
- sortingState={[{ id: 'createdAt', desc: order === 'newest' }]}
82
- loading={loading}
83
- emptyMessage={
84
- loaded ? (
85
- <Empty>
86
- <EmptyDescription>{empty}</EmptyDescription>
87
- </Empty>
88
- ) : (
89
- ''
90
- )
91
- }
92
- columns={[
93
- {
94
- id: 'record',
95
- header: labels.record,
96
- enableSorting: false,
97
- cell: ExecutionRecordCell,
98
- },
99
- {
100
- id: 'workflow',
101
- header: labels.workflow,
102
- enableSorting: false,
103
- cell: ExecutionWorkflowCell,
104
- },
105
- {
106
- id: 'state',
107
- header: labels.state,
108
- enableSorting: false,
109
- cell: ExecutionStateCell,
110
- },
111
- {
112
- id: 'createdAt',
113
- accessorFn: (row) => row.createdAt,
114
- enableSorting: true,
115
- meta: activityMeta,
116
- header: ExecutionDateHeader,
117
- cell: ExecutionCreatedAtCell,
118
- },
119
- ]}
120
- />
121
- </div>
80
+ {(listState !== undefined || loaded || loading) && (
81
+ <div className='bg-card'>
82
+ <DataTable
83
+ data={rows}
84
+ listState={
85
+ listState ??
86
+ (!loaded ? 'loading' : rows.length ? 'ready' : 'empty')
87
+ }
88
+ onRetry={onRefresh}
89
+ onClearFilters={onClearFilters}
90
+ getRowId={(row) => row.id}
91
+ pagination={false}
92
+ manualSorting
93
+ sortingState={[{ id: 'createdAt', desc: order === 'newest' }]}
94
+ loading={loading}
95
+ emptyMessage={
96
+ loaded ? (
97
+ <Empty>
98
+ <EmptyDescription>{empty}</EmptyDescription>
99
+ </Empty>
100
+ ) : (
101
+ ''
102
+ )
103
+ }
104
+ columns={[
105
+ {
106
+ id: 'record',
107
+ header: labels.record,
108
+ enableSorting: false,
109
+ cell: ExecutionRecordCell,
110
+ },
111
+ {
112
+ id: 'workflow',
113
+ header: labels.workflow,
114
+ enableSorting: false,
115
+ cell: ExecutionWorkflowCell,
116
+ },
117
+ {
118
+ id: 'state',
119
+ header: labels.state,
120
+ enableSorting: false,
121
+ cell: ExecutionStateCell,
122
+ },
123
+ {
124
+ id: 'createdAt',
125
+ accessorFn: (row) => row.createdAt,
126
+ enableSorting: true,
127
+ meta: activityMeta,
128
+ header: ExecutionDateHeader,
129
+ cell: ExecutionCreatedAtCell,
130
+ },
131
+ ]}
132
+ />
133
+ </div>
134
+ )}
122
135
  <div className='flex flex-wrap items-center justify-between gap-3 text-sm'>
123
136
  <p>{pagination}</p>
124
137
  <div className='flex gap-2'>
@@ -59,8 +59,7 @@ export function useListData<T>(
59
59
  path: result.path,
60
60
  data: result.path === path || keepPreviousData ? result.data : undefined,
61
61
  loading: !active || result.path !== path || result.key !== key,
62
- error:
63
- result.path === path && result.key === key ? result.error : undefined,
62
+ error: result.path === path ? result.error : undefined,
64
63
  revision,
65
64
  reload: useCallback(() => {
66
65
  invalidate();
@@ -8,6 +8,19 @@ import inboxEnUS from '#extensions/nocobase-inbox/locales/en-US';
8
8
  * upgrade replaces this file, so put the application's own copy in `../en-US.ts` rather than here.
9
9
  */
10
10
  const systemEnUS = {
11
+ listFeedback: {
12
+ loading: 'Loading',
13
+ empty: 'No data yet',
14
+ noResults: 'No results',
15
+ forbidden: 'Access denied',
16
+ unauthenticated: 'Session expired',
17
+ failed: 'Unable to load list',
18
+ refreshFailed: 'Refresh failed; showing previous data',
19
+ retry: 'Retry',
20
+ retrying: 'Retrying…',
21
+ clear: 'Clear filters',
22
+ signIn: 'Sign in',
23
+ },
11
24
  flowDiagram: {
12
25
  controls: 'Diagram controls',
13
26
  fitView: 'Fit to view',
@@ -399,7 +412,7 @@ const systemEnUS = {
399
412
  back: 'Back',
400
413
  },
401
414
  dataTable: {
402
- noResults: 'No results.',
415
+ noResults: 'No results',
403
416
  sortAscending: 'Asc',
404
417
  sortDescending: 'Desc',
405
418
  hideColumn: 'Hide',
@@ -50,10 +50,6 @@ const settings = {
50
50
  records: 'Selected records ({{count}})',
51
51
  retained: 'Retain existing configuration',
52
52
  search: 'Search business objects or keys',
53
- empty: 'No default data scopes',
54
- emptyHint:
55
- 'Create a rule to configure baseline access for a business object.',
56
- readOnlyEmpty: 'Ask an administrator to configure baseline data access.',
57
53
  noResources:
58
54
  'Every available business object already has a default rule, or no configurable objects are registered.',
59
55
  unsupported:
@@ -86,8 +82,6 @@ const settings = {
86
82
  'Manage user information, account security and role assignments. Review each permission separately.',
87
83
  businessOperationsHint:
88
84
  'Choose the operations this role can perform and the data each operation can use.',
89
- pageAccessHint:
90
- '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.',
91
85
  roleAssignment: 'Role assignment',
92
86
  accountSecurity: 'Account security',
93
87
  userInformation: 'User information',
@@ -145,10 +139,6 @@ const settings = {
145
139
  noResults: 'No matching results',
146
140
  clearHint: 'Try a different search or clear the filters.',
147
141
  noUsers: 'No users yet',
148
- usersReadOnlyEmpty:
149
- 'Contact an administrator to add users or grant the permissions needed to create users and assign roles.',
150
- rolesReadOnlyEmpty:
151
- 'Contact an administrator to create a role or grant you permission to create roles.',
152
142
  noRoleAccess:
153
143
  'This user can still sign in and use account basics. No business access is granted by roles.',
154
144
  noAssignedRoles: 'No assigned roles',
@@ -44,9 +44,6 @@ const settings = {
44
44
  records: '指定记录({{count}} 条)',
45
45
  retained: '保留现有配置',
46
46
  search: '搜索业务对象或标识',
47
- empty: '暂无默认数据范围',
48
- emptyHint: '新建规则,为业务对象配置基础数据访问范围。',
49
- readOnlyEmpty: '请联系管理员配置基础数据访问范围。',
50
47
  noResources: '可用业务对象均已配置默认规则,或当前没有可配置的业务对象。',
51
48
  unsupported:
52
49
  '此规则包含当前插件目录中不存在的配置,已保留原值并以只读方式展示。',
@@ -75,8 +72,6 @@ const settings = {
75
72
  '管理用户资料、账号安全和角色分配,请逐项确认需要授予的权限。',
76
73
  businessOperationsHint:
77
74
  '设置此角色可以执行的业务操作,以及每项操作可使用的数据范围。',
78
- pageAccessHint:
79
- '设置此角色可访问的业务页面。系统设置(包括用户管理、业务配置项)统一在“配置项权限”中控制。页面内的数据和业务操作仍需单独授权。',
80
75
  roleAssignment: '角色分配',
81
76
  accountSecurity: '账号安全',
82
77
  userInformation: '用户资料',
@@ -134,9 +129,6 @@ const settings = {
134
129
  noResults: '没有匹配的结果',
135
130
  clearHint: '请更换搜索条件或清除筛选。',
136
131
  noUsers: '暂无用户',
137
- usersReadOnlyEmpty:
138
- '请联系管理员添加用户,或授予创建用户和分配角色所需的权限。',
139
- rolesReadOnlyEmpty: '请联系管理员创建角色,或授予你创建角色的权限。',
140
132
  noRoleAccess: '此用户仍可登录并使用账号基础功能,角色未授予业务访问。',
141
133
  noAssignedRoles: '未分配角色',
142
134
  noRoles: '暂无角色',
@@ -5,6 +5,19 @@ import inboxZhCN from '#extensions/nocobase-inbox/locales/zh-CN';
5
5
 
6
6
  /** The Chinese copy of what the template ships; see `./en-US.ts`. */
7
7
  const systemZhCN: SystemResource = {
8
+ listFeedback: {
9
+ loading: '加载中',
10
+ empty: '暂无数据',
11
+ noResults: '无结果',
12
+ forbidden: '无权访问',
13
+ unauthenticated: '会话已过期',
14
+ failed: '列表加载失败',
15
+ refreshFailed: '刷新失败,仍显示旧数据',
16
+ retry: '重试',
17
+ retrying: '正在重试…',
18
+ clear: '清空筛选',
19
+ signIn: '登录',
20
+ },
8
21
  flowDiagram: {
9
22
  controls: '流程图控制',
10
23
  fitView: '适应画布',
@@ -374,7 +387,7 @@ const systemZhCN: SystemResource = {
374
387
  back: '返回',
375
388
  },
376
389
  dataTable: {
377
- noResults: '暂无数据。',
390
+ noResults: '无结果',
378
391
  sortAscending: '升序',
379
392
  sortDescending: '降序',
380
393
  hideColumn: '隐藏',
@@ -3,6 +3,7 @@ import { RecordLink } from '#components/record-link';
3
3
  import { useTableUrlState } from '#hooks/use-table-url-state';
4
4
  import {
5
5
  ApiClientError,
6
+ resolveListState,
6
7
  useApiClient,
7
8
  useSubmission,
8
9
  useToaster,
@@ -15,12 +16,13 @@ import {
15
16
  import { useTranslation } from '@nocobase/i18n/client';
16
17
  import { useRef, useState, type ReactElement } from 'react';
17
18
  import { Link, Outlet, useLocation } from 'react-router';
18
- import { ListFilterIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react';
19
+ import { MoreHorizontalIcon, PlusIcon } from 'lucide-react';
19
20
  import type { ColumnDef } from '@tanstack/react-table';
20
21
  import { Alert, AlertDescription } from '#components/ui/alert';
21
22
  import { PermissionManagementPage } from './permission-management-page.js';
22
23
  import { DataTable, DataTableColumnHeader } from '#components/data-table';
23
24
  import { Button } from '#components/ui/button';
25
+ import { Spinner } from '#components/ui/spinner';
24
26
  import { Input } from '#components/ui/input';
25
27
  import {
26
28
  DropdownMenu,
@@ -29,14 +31,7 @@ import {
29
31
  DropdownMenuSeparator,
30
32
  DropdownMenuTrigger,
31
33
  } from '#components/ui/dropdown-menu';
32
- import {
33
- Empty,
34
- EmptyMedia,
35
- EmptyTitle,
36
- EmptyDescription,
37
- EmptyContent,
38
- } from '#components/ui/empty';
39
- import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
34
+ import { ConfirmAction, SettingsError } from './shared.js';
40
35
  import { useSettingsData } from './hooks.js';
41
36
  import {
42
37
  actionKey,
@@ -61,10 +56,11 @@ export default function DefaultAccessPage(): ReactElement {
61
56
  const catalog = useSettingsData<{ data: AuthorizationOptionsResponse }>(
62
57
  'authorization/defaultAccess/options',
63
58
  );
64
- const canCreate = useCan({
59
+ const createPermission = useCan({
65
60
  resource: { type: 'settings', id: 'authorization.default-access' },
66
61
  action: 'create',
67
- }).can;
62
+ });
63
+ const canCreate = createPermission.can && !createPermission.isPending;
68
64
  const canDelete = useCan({
69
65
  resource: { type: 'settings', id: 'authorization.default-access' },
70
66
  action: 'delete',
@@ -233,6 +229,30 @@ export default function DefaultAccessPage(): ReactElement {
233
229
  ? rows.length
234
230
  : undefined,
235
231
  });
232
+ const listError =
233
+ [rules.error, catalog.error].find(
234
+ (error) =>
235
+ error instanceof ApiClientError && [401, 403].includes(error.status),
236
+ ) ??
237
+ tableState.error ??
238
+ rules.error ??
239
+ catalog.error;
240
+ const listData = rules.data && catalog.data ? rows : undefined;
241
+ const listState = resolveListState(
242
+ {
243
+ data: listData,
244
+ error: listError,
245
+ isPending: !listData && (rules.loading || catalog.loading),
246
+ },
247
+ rows.length,
248
+ Boolean(search.trim()),
249
+ );
250
+ const canShowCreate =
251
+ canCreate &&
252
+ (listState === 'empty' ||
253
+ listState === 'ready' ||
254
+ listState === 'no-results' ||
255
+ listState === 'refresh-failed');
236
256
  const clearSearch = () =>
237
257
  setParams(
238
258
  (old) => {
@@ -331,9 +351,7 @@ export default function DefaultAccessPage(): ReactElement {
331
351
  <>
332
352
  <PermissionManagementPage
333
353
  tab='default-access'
334
- actions={
335
- rules.data && (rules.data.data.length || search) ? create : undefined
336
- }
354
+ actions={canShowCreate ? create : undefined}
337
355
  >
338
356
  <div className='flex flex-wrap items-center gap-2'>
339
357
  <Input
@@ -355,11 +373,16 @@ export default function DefaultAccessPage(): ReactElement {
355
373
  )
356
374
  }
357
375
  />
358
- {search && (
376
+ {search && listState !== 'no-results' && (
359
377
  <Button variant='ghost' onClick={clearSearch}>
360
378
  {t('settings.clear')}
361
379
  </Button>
362
380
  )}
381
+ {active &&
382
+ (rules.loading || catalog.loading) &&
383
+ (rules.data || catalog.data) ? (
384
+ <Spinner className='text-muted-foreground' />
385
+ ) : null}
363
386
  </div>
364
387
  {conflict && (
365
388
  <Alert variant='destructive'>
@@ -368,67 +391,22 @@ export default function DefaultAccessPage(): ReactElement {
368
391
  </AlertDescription>
369
392
  </Alert>
370
393
  )}
371
- <SettingsError
372
- error={
373
- (tableState.error ?? rules.error) ||
374
- catalog.error ||
375
- (!removing && error)
376
- }
377
- retry={() => {
394
+ <SettingsError error={!removing && error} />
395
+ <DataTable
396
+ {...tableState}
397
+ columns={columns}
398
+ data={rows}
399
+ getRowId={(rule) => rule.key}
400
+ showSelectedCount={false}
401
+ pageSize={20}
402
+ loading={rules.loading || catalog.loading}
403
+ listState={listState}
404
+ onRetry={() => {
378
405
  rules.reload();
379
406
  catalog.reload();
380
- setError(undefined);
381
407
  }}
408
+ onClearFilters={clearSearch}
382
409
  />
383
- {(rules.loading && !rules.data) ||
384
- (catalog.loading && !catalog.data) ? (
385
- <SettingsLoading />
386
- ) : (
387
- !tableState.error &&
388
- !rules.error &&
389
- !catalog.error &&
390
- (rows.length ? (
391
- <DataTable
392
- {...tableState}
393
- columns={columns}
394
- data={rows}
395
- getRowId={(rule) => rule.key}
396
- showSelectedCount={false}
397
- pageSize={20}
398
- />
399
- ) : (
400
- <Empty className='min-h-48 border border-dashed'>
401
- <EmptyMedia variant='icon'>
402
- <ListFilterIcon />
403
- </EmptyMedia>
404
- <EmptyTitle>
405
- {t(
406
- search
407
- ? 'settings.noResults'
408
- : 'settings.defaultAccess.empty',
409
- )}
410
- </EmptyTitle>
411
- <EmptyDescription>
412
- {t(
413
- search
414
- ? 'settings.clearHint'
415
- : canCreate
416
- ? 'settings.defaultAccess.emptyHint'
417
- : 'settings.defaultAccess.readOnlyEmpty',
418
- )}
419
- </EmptyDescription>
420
- <EmptyContent>
421
- {search ? (
422
- <Button variant='outline' onClick={clearSearch}>
423
- {t('settings.clear')}
424
- </Button>
425
- ) : (
426
- create
427
- )}
428
- </EmptyContent>
429
- </Empty>
430
- ))
431
- )}
432
410
  <ConfirmAction
433
411
  error={<SettingsError error={error} />}
434
412
  open={Boolean(removing)}
@@ -61,7 +61,6 @@ export function setPermission(
61
61
  }
62
62
 
63
63
  const sectionDescriptions: Readonly<Record<string, string>> = {
64
- pages: 'pageAccessHint',
65
64
  business: 'businessOperationsHint',
66
65
  users: 'userAccountsHint',
67
66
  configuration: 'configurationPermissionsHint',
@@ -5,22 +5,17 @@ import type {
5
5
  PermissionSet,
6
6
  LocalizedText,
7
7
  } from '@nocobase/app-plugin-authorization/client';
8
+ import { resolveListState } from '@nocobase/app-client';
8
9
  import { type ReactElement } from 'react';
9
10
  import { Link, Outlet, useLocation } from 'react-router';
10
- import { ShieldCheckIcon, PlusIcon } from 'lucide-react';
11
+ import { PlusIcon } from 'lucide-react';
11
12
  import { PermissionManagementPage } from './permission-management-page.js';
12
13
  import { Input } from '#components/ui/input';
13
14
  import { Button } from '#components/ui/button';
15
+ import { Spinner } from '#components/ui/spinner';
14
16
  import { useTableUrlState } from '#hooks/use-table-url-state';
15
17
  import { RoleList } from '#components/role-list';
16
- import {
17
- Empty,
18
- EmptyTitle,
19
- EmptyDescription,
20
- EmptyContent,
21
- EmptyMedia,
22
- } from '#components/ui/empty';
23
- import { SettingsError, SettingsLoading } from './shared.js';
18
+ import { ListFeedback, ListFeedbackActions } from '#components/list-feedback';
24
19
 
25
20
  export default function RolesPage(): ReactElement {
26
21
  const { t, i18n } = useTranslation();
@@ -60,6 +55,31 @@ export default function RolesPage(): ReactElement {
60
55
  ? rows.length
61
56
  : undefined,
62
57
  });
58
+ const listState = resolveListState(
59
+ {
60
+ data: request.data,
61
+ error: tableState.error ?? request.error,
62
+ isPending: request.loading && !request.data,
63
+ },
64
+ rows.length,
65
+ Boolean(search.trim()),
66
+ );
67
+ const canShowCreate =
68
+ canCreate &&
69
+ (listState === 'empty' ||
70
+ listState === 'ready' ||
71
+ listState === 'no-results' ||
72
+ listState === 'refresh-failed');
73
+ const clearSearch = () =>
74
+ setParams(
75
+ (old) => {
76
+ const next = new URLSearchParams(old);
77
+ next.delete('q');
78
+ next.delete('page');
79
+ return next;
80
+ },
81
+ { replace: true },
82
+ );
63
83
  const create = canCreate ? (
64
84
  <Button
65
85
  nativeButton={false}
@@ -73,7 +93,7 @@ export default function RolesPage(): ReactElement {
73
93
  <>
74
94
  <PermissionManagementPage
75
95
  tab='roles'
76
- actions={rows.length || search ? create : undefined}
96
+ actions={canShowCreate ? create : undefined}
77
97
  >
78
98
  <div className='flex items-center gap-3'>
79
99
  <Input
@@ -94,97 +114,43 @@ export default function RolesPage(): ReactElement {
94
114
  );
95
115
  }}
96
116
  />
97
- {search && (
98
- <Button
99
- variant='ghost'
100
- onClick={() => {
101
- setParams(
102
- (old) => {
103
- const next = new URLSearchParams(old);
104
- next.delete('q');
105
- next.delete('page');
106
- return next;
107
- },
108
- { replace: true },
109
- );
110
- }}
111
- >
117
+ {search && listState !== 'no-results' && (
118
+ <Button variant='ghost' onClick={clearSearch}>
112
119
  {t('settings.clear')}
113
120
  </Button>
114
121
  )}
122
+ <ListFeedbackActions
123
+ state={listState}
124
+ onClear={clearSearch}
125
+ onRetry={request.reload}
126
+ busy={request.loading}
127
+ />
128
+ {active && request.loading && request.data ? (
129
+ <Spinner className='text-muted-foreground' />
130
+ ) : null}
115
131
  </div>
116
- <SettingsError
117
- error={tableState.error ?? request.error}
118
- retry={request.reload}
119
- />
120
- {request.loading && !request.data ? (
121
- <SettingsLoading />
122
- ) : (
123
- !tableState.error &&
124
- !request.error &&
125
- (rows.length ? (
126
- <RoleList
127
- tableState={tableState}
128
- ariaLabel={t('settings.roles')}
129
- roles={rows.map((role) => ({
130
- key: role.key,
131
- title: text(role.title ?? role.key),
132
- to: {
133
- pathname: `${encodeURIComponent(role.key)}/edit`,
134
- search: location.search,
135
- },
136
- protected: Boolean(role.protection),
137
- }))}
138
- labels={{
139
- name: t('settings.roleName'),
140
- key: t('settings.key'),
141
- type: t('settings.roleType'),
142
- protected: t('settings.protected'),
143
- custom: t('settings.custom'),
144
- }}
145
- />
146
- ) : (
147
- <Empty className='min-h-48 border border-dashed'>
148
- <EmptyMedia variant='icon'>
149
- <ShieldCheckIcon />
150
- </EmptyMedia>
151
- <EmptyTitle>
152
- {t(search ? 'settings.noResults' : 'settings.noRoles')}
153
- </EmptyTitle>
154
- <EmptyDescription>
155
- {t(
156
- search
157
- ? 'settings.clearHint'
158
- : canCreate
159
- ? 'settings.rolesDescription'
160
- : 'settings.rolesReadOnlyEmpty',
161
- )}
162
- </EmptyDescription>
163
- <EmptyContent>
164
- {search ? (
165
- <Button
166
- variant='outline'
167
- onClick={() => {
168
- setParams(
169
- (old) => {
170
- const next = new URLSearchParams(old);
171
- next.delete('q');
172
- next.delete('page');
173
- return next;
174
- },
175
- { replace: true },
176
- );
177
- }}
178
- >
179
- {t('settings.clear')}
180
- </Button>
181
- ) : (
182
- create
183
- )}
184
- </EmptyContent>
185
- </Empty>
186
- ))
187
- )}
132
+ <ListFeedback state={listState}>
133
+ <RoleList
134
+ tableState={tableState}
135
+ ariaLabel={t('settings.roles')}
136
+ roles={rows.map((role) => ({
137
+ key: role.key,
138
+ title: text(role.title ?? role.key),
139
+ to: {
140
+ pathname: `${encodeURIComponent(role.key)}/edit`,
141
+ search: location.search,
142
+ },
143
+ protected: Boolean(role.protection),
144
+ }))}
145
+ labels={{
146
+ name: t('settings.roleName'),
147
+ key: t('settings.key'),
148
+ type: t('settings.roleType'),
149
+ protected: t('settings.protected'),
150
+ custom: t('settings.custom'),
151
+ }}
152
+ />
153
+ </ListFeedback>
188
154
  </PermissionManagementPage>
189
155
  <Outlet context={{ reload: request.reload }} />
190
156
  </>