@nocobase/app-template-default 1.0.0-beta.74 → 1.0.0-beta.76

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 (52) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/AGENTS.md +7 -1
  4. package/README.MD +3 -3
  5. package/cli/plugins.ts +2 -1
  6. package/client/components/data-table/data-table.tsx +2 -0
  7. package/client/components/data-table/pagination.tsx +6 -6
  8. package/client/components/flow-diagram.tsx +645 -83
  9. package/client/components/flow-layout.ts +117 -7
  10. package/client/components/lifecycle-diagram.tsx +175 -37
  11. package/client/components/record-link.tsx +20 -0
  12. package/client/components/required-role-setup.tsx +93 -0
  13. package/client/components/role-list.tsx +71 -31
  14. package/client/components/setup-readiness-state.ts +135 -0
  15. package/client/components/setup-readiness.tsx +91 -0
  16. package/client/components/ui/combobox.tsx +300 -0
  17. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  18. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  19. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  20. package/client/hooks/use-table-url-state.ts +93 -0
  21. package/client/lib/navigation-transform.ts +2 -1
  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 +38 -1
  26. package/client/locales/system/settings-zh-CN.ts +34 -2
  27. package/client/locales/system/zh-CN.ts +10 -0
  28. package/client/pages/settings/default-access-form.tsx +4 -1
  29. package/client/pages/settings/default-access.tsx +40 -8
  30. package/client/pages/settings/role-form.tsx +1 -1
  31. package/client/pages/settings/roles.tsx +47 -6
  32. package/client/pages/settings/shared.tsx +15 -13
  33. package/client/pages/settings/user-detail.tsx +15 -259
  34. package/client/pages/settings/user-form.tsx +299 -147
  35. package/client/pages/settings/users.tsx +211 -123
  36. package/client/plugins.ts +2 -0
  37. package/config.example.yml +6 -5
  38. package/package.json +16 -14
  39. package/server/plugins.ts +2 -0
  40. package/tests/components/default-access.test.tsx +141 -2
  41. package/tests/components/role-list.test.tsx +73 -8
  42. package/tests/components/roles.test.tsx +14 -7
  43. package/tests/components/settings-continuity.test.tsx +426 -0
  44. package/tests/components/settings-feedback.test.tsx +304 -0
  45. package/tests/components/settings.test.tsx +1 -1
  46. package/tests/components/setup-readiness.test.tsx +370 -0
  47. package/tests/components/table-url-state.test.tsx +95 -0
  48. package/tests/components/user-drawer.test.tsx +526 -0
  49. package/tests/logic/authorization-assignment-safety.test.ts +82 -0
  50. package/tests/logic/foundation-integration.test.ts +62 -0
  51. package/tests/logic/return-to.test.ts +19 -0
  52. package/tsconfig.json +1 -0
@@ -0,0 +1,93 @@
1
+ import { useEffect } from 'react';
2
+ import { useSearchParams } from 'react-router';
3
+ import type {
4
+ OnChangeFn,
5
+ PaginationState,
6
+ SortingState,
7
+ } from '@tanstack/react-table';
8
+
9
+ const sizes = [10, 20, 50, 100] as const;
10
+
11
+ /** Keep list position shareable across overlays, refresh and browser history. */
12
+ export function useTableUrlState({
13
+ defaultPageSize = 10,
14
+ pageSizeOptions = sizes,
15
+ sortColumns,
16
+ rowCount,
17
+ }: {
18
+ defaultPageSize?: number;
19
+ pageSizeOptions?: readonly number[];
20
+ sortColumns: readonly string[];
21
+ rowCount?: number;
22
+ }) {
23
+ const [params, setParams] = useSearchParams();
24
+ const requestedPage = Number(params.get('page'));
25
+ const pageIndex =
26
+ Number.isSafeInteger(requestedPage) && requestedPage > 0
27
+ ? requestedPage - 1
28
+ : 0;
29
+ const requestedSize = Number(params.get('pageSize'));
30
+ const pageSize = pageSizeOptions.includes(requestedSize)
31
+ ? requestedSize
32
+ : defaultPageSize;
33
+ const paginationState = { pageIndex, pageSize };
34
+ const sort = params.get('sort') ?? sortColumns[0];
35
+ const order = params.get('order');
36
+ const sortingState: SortingState =
37
+ sort && sortColumns.includes(sort) && (order === 'asc' || order === 'desc')
38
+ ? [{ id: sort, desc: order === 'desc' }]
39
+ : [];
40
+ const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
41
+ const next =
42
+ typeof updater === 'function' ? updater(paginationState) : updater;
43
+ setParams(
44
+ (old) => {
45
+ const result = new URLSearchParams(old);
46
+ result.set('page', String(next.pageIndex + 1));
47
+ result.set('pageSize', String(next.pageSize));
48
+ return result;
49
+ },
50
+ { replace: false },
51
+ );
52
+ };
53
+ const onSortingChange: OnChangeFn<SortingState> = (updater) => {
54
+ const next =
55
+ typeof updater === 'function' ? updater(sortingState) : updater;
56
+ setParams(
57
+ (old) => {
58
+ const result = new URLSearchParams(old);
59
+ result.delete('page');
60
+ if (next[0] && sortColumns.includes(next[0].id)) {
61
+ result.set('sort', next[0].id);
62
+ result.set('order', next[0].desc ? 'desc' : 'asc');
63
+ } else {
64
+ result.delete('sort');
65
+ result.delete('order');
66
+ }
67
+ return result;
68
+ },
69
+ { replace: true },
70
+ );
71
+ };
72
+ // Only pass a count for the current, successfully loaded query.
73
+ useEffect(() => {
74
+ if (rowCount === undefined) return;
75
+ const lastPage = Math.max(0, Math.ceil(rowCount / pageSize) - 1);
76
+ if (pageIndex <= lastPage) return;
77
+ setParams(
78
+ (old) => {
79
+ const result = new URLSearchParams(old);
80
+ result.set('page', String(lastPage + 1));
81
+ return result;
82
+ },
83
+ { replace: true },
84
+ );
85
+ }, [rowCount, pageSize, pageIndex, setParams]);
86
+ return {
87
+ paginationState,
88
+ onPaginationChange,
89
+ sortingState,
90
+ onSortingChange,
91
+ pageSizeOptions,
92
+ };
93
+ }
@@ -1,10 +1,11 @@
1
+ import { groupFoundationNavigation } from '@nocobase/app-plugin-foundation/client';
1
2
  import type { RouteNavigationItem } from '../routing/route-navigation.js';
2
3
 
3
4
  /** Application-owned navigation customization, applied after permission filtering. */
4
5
  export function transformNavigation(
5
6
  items: RouteNavigationItem[],
6
7
  ): RouteNavigationItem[] {
7
- return collapsePermissionNavigation(items);
8
+ return groupFoundationNavigation(collapsePermissionNavigation(items));
8
9
  }
9
10
 
10
11
  /** One sidebar entry opens the first readable tab; original routes and guards remain unchanged. */
@@ -0,0 +1,98 @@
1
+ import type { UsersClient } from '@nocobase/app-plugin-users/client/user-client';
2
+ import {
3
+ setupInspectionFailure,
4
+ type SetupInspection,
5
+ type SetupInspections,
6
+ } from '../components/setup-readiness-state.js';
7
+ import { safeReturnTo } from './return-to.js';
8
+
9
+ function isReadonlyArray<T>(
10
+ value: readonly T[] | undefined,
11
+ ): value is readonly T[] {
12
+ return Array.isArray(value);
13
+ }
14
+
15
+ export interface RequiredSetupRole {
16
+ readonly scope: string;
17
+ readonly role: string;
18
+ /** A localized business label; never render the role key as a title. */
19
+ readonly title: string;
20
+ readonly description: string;
21
+ }
22
+ export function requiredRoleSetupId(
23
+ role: Pick<RequiredSetupRole, 'scope' | 'role'>,
24
+ ): string {
25
+ return JSON.stringify([role.scope, role.role]);
26
+ }
27
+ export function requiredRoleSetupLink(
28
+ role: Pick<RequiredSetupRole, 'scope' | 'role'>,
29
+ returnTo: string,
30
+ ): string {
31
+ const query = new URLSearchParams({
32
+ roleScope: role.scope,
33
+ role: role.role,
34
+ status: 'enabled',
35
+ });
36
+ const source = safeReturnTo(returnTo);
37
+ if (source) query.set('returnTo', source);
38
+ return `/settings/users?${query}`;
39
+ }
40
+
41
+ /** One metadata read and one bounded read per distinct required role. No writes. */
42
+ export async function inspectRequiredRoles(
43
+ client: Pick<UsersClient, 'options' | 'list'>,
44
+ requirements: readonly RequiredSetupRole[],
45
+ ): Promise<SetupInspections> {
46
+ const unique = [
47
+ ...new Map(
48
+ requirements.map((role) => [requiredRoleSetupId(role), role]),
49
+ ).values(),
50
+ ];
51
+ if (unique.length === 0) return {};
52
+ const options = await client.options();
53
+ const roleScopes = options?.roleScopes;
54
+ if (!isReadonlyArray(roleScopes))
55
+ return Object.fromEntries(
56
+ unique.map((role) => [
57
+ requiredRoleSetupId(role),
58
+ { status: 'unknown', reason: 'invalid' },
59
+ ]),
60
+ );
61
+ const entries = await Promise.all(
62
+ unique.map(
63
+ async (requirement): Promise<readonly [string, SetupInspection]> => {
64
+ const id = requiredRoleSetupId(requirement);
65
+ const scope = roleScopes.find(
66
+ (scope) => scope?.key === requirement.scope,
67
+ );
68
+ const role = isReadonlyArray(scope?.options)
69
+ ? scope.options.find((role) => role?.value === requirement.role)
70
+ : undefined;
71
+ // Metadata offers direct selectable roles; authenticated defaults apply separately.
72
+ if (!scope || !role)
73
+ return [id, { status: 'unknown', reason: 'invalid' }];
74
+ try {
75
+ const page = await client.list({
76
+ roleScope: requirement.scope,
77
+ role: requirement.role,
78
+ status: 'enabled',
79
+ page: 1,
80
+ pageSize: 1,
81
+ });
82
+ if (!Number.isSafeInteger(page?.total) || page.total < 0)
83
+ return [id, { status: 'unknown', reason: 'invalid' }];
84
+ return [
85
+ id,
86
+ {
87
+ status: page.total > 0 ? 'ready' : 'missing',
88
+ canConfigure: role.assignable !== false,
89
+ },
90
+ ];
91
+ } catch (error: unknown) {
92
+ return [id, setupInspectionFailure(error)];
93
+ }
94
+ },
95
+ ),
96
+ );
97
+ return Object.fromEntries(entries);
98
+ }
@@ -0,0 +1,19 @@
1
+ /** Accept only an application-relative destination, resolved by the app router's basename. */
2
+ export function safeReturnTo(value: string | null): string | undefined {
3
+ if (
4
+ !value ||
5
+ !value.startsWith('/') ||
6
+ value.startsWith('//') ||
7
+ [...value].some(
8
+ (character) =>
9
+ character === '\\' ||
10
+ character.charCodeAt(0) <= 32 ||
11
+ character.charCodeAt(0) === 127,
12
+ )
13
+ )
14
+ return;
15
+ const base = 'https://app.invalid';
16
+ const url = new URL(value, base);
17
+ if (url.origin !== base || url.pathname.startsWith('//')) return;
18
+ return `${url.pathname}${url.search}${url.hash}`;
19
+ }
@@ -9,6 +9,16 @@ import inboxEnUS from '#extensions/nocobase-inbox/locales/en-US';
9
9
  */
10
10
  const systemEnUS = {
11
11
  flowDiagram: {
12
+ controls: 'Diagram controls',
13
+ fitView: 'Fit to view',
14
+ fullscreen: 'Fullscreen',
15
+ exitFullscreen: 'Exit fullscreen',
16
+ escapeHint: 'Press Escape to exit',
17
+ leavesTo: 'Leaves to',
18
+ arrivesFrom: 'Arrives from',
19
+ otherRoutes: 'Other routes',
20
+ minimap: 'Diagram overview',
21
+
12
22
  configure: 'Configure',
13
23
  overview: 'Diagram overview',
14
24
  actualSize: 'Actual size',
@@ -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',
@@ -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',
@@ -104,6 +131,7 @@ const settings = {
104
131
  username: 'Username',
105
132
  email: 'Email',
106
133
  password: 'Initial password',
134
+ roleType: 'Role type',
107
135
  roleName: 'Role name',
108
136
  key: 'Key',
109
137
  status: 'Status',
@@ -116,9 +144,16 @@ const settings = {
116
144
  noResults: 'No matching results',
117
145
  clearHint: 'Try a different search or clear the filters.',
118
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.',
119
151
  noRoles: 'No roles yet',
120
152
  create: 'Create',
121
153
  edit: 'Edit',
154
+ selectRoles: 'Select roles',
155
+ searchRoles: 'Search roles',
156
+ noRolesFound: 'No roles found.',
122
157
  save: 'Save',
123
158
  cancel: 'Cancel',
124
159
  saving: 'Saving…',
@@ -148,12 +183,14 @@ const settings = {
148
183
  discardTitle: 'Discard changes?',
149
184
  discardHint: 'Your unsaved changes will be lost.',
150
185
  failed: 'Unable to complete the request. Try again.',
186
+ invalidRequest:
187
+ 'Some values are invalid. Check your input or filters and try again.',
151
188
  retry: 'Retry',
152
189
  loading: 'Loading',
153
190
  denied: 'You do not have access. Contact an administrator.',
154
191
  missing: 'This record does not exist or has been deleted.',
155
192
  conflict:
156
- '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.',
157
194
  sessionEnded: 'Your session has ended. Sign in again.',
158
195
  previous: 'Previous',
159
196
  next: 'Next',
@@ -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: '暂无可配置的业务对象',
@@ -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: '编辑角色',
@@ -96,6 +120,7 @@ const settings = {
96
120
  username: '用户名',
97
121
  email: '邮箱',
98
122
  password: '初始密码',
123
+ roleType: '角色类型',
99
124
  roleName: '角色名称',
100
125
  key: '标识',
101
126
  status: '状态',
@@ -108,9 +133,15 @@ const settings = {
108
133
  noResults: '没有匹配的结果',
109
134
  clearHint: '请更换搜索条件或清除筛选。',
110
135
  noUsers: '暂无用户',
136
+ usersReadOnlyEmpty:
137
+ '请联系管理员添加用户,或授予创建用户和分配角色所需的权限。',
138
+ rolesReadOnlyEmpty: '请联系管理员创建角色,或授予你创建角色的权限。',
111
139
  noRoles: '暂无角色',
112
140
  create: '创建',
113
141
  edit: '编辑',
142
+ selectRoles: '选择角色',
143
+ searchRoles: '搜索角色',
144
+ noRolesFound: '没有匹配的角色。',
114
145
  save: '保存',
115
146
  cancel: '取消',
116
147
  saving: '正在保存…',
@@ -138,11 +169,12 @@ const settings = {
138
169
  discardTitle: '放弃未保存的更改?',
139
170
  discardHint: '未保存的更改将丢失。',
140
171
  failed: '请求失败,请重试。',
172
+ invalidRequest: '部分内容无效,请检查填写内容或筛选条件后重试。',
141
173
  retry: '重试',
142
174
  loading: '加载中',
143
- denied: '无权查看,请联系管理员。',
175
+ denied: '没有执行此操作的权限,请联系管理员。',
144
176
  missing: '记录不存在或已被删除。',
145
- conflict: '角色已被其他人修改,请重新加载后合并。',
177
+ conflict: '此操作与当前数据冲突,请查看最新记录后再试。',
146
178
  sessionEnded: '会话已结束,请重新登录。',
147
179
  previous: '上一页',
148
180
  next: '下一页',
@@ -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: '实际大小',
@@ -79,7 +79,10 @@ function DefaultAccessFormBody(): ReactElement {
79
79
  draft !== undefined && JSON.stringify(draft) !== JSON.stringify(initial);
80
80
  const leave = useSettingsLeave(dirty, busy);
81
81
  const close = () => {
82
- void navigate({ pathname: '/default-access', search: location.search });
82
+ void navigate(
83
+ { pathname: '/default-access', search: location.search },
84
+ { replace: true },
85
+ );
83
86
  };
84
87
  const text = (value: LocalizedText): string =>
85
88
  typeof value === 'string'
@@ -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
  ) : (
@@ -230,7 +230,7 @@ function RoleEditor(): ReactElement {
230
230
  parent.reload();
231
231
  toaster.show({ type: 'success', title: t('settings.saved', { name }) });
232
232
  leave.allow();
233
- void navigate(closeTo);
233
+ void navigate(closeTo, { replace: true });
234
234
  } catch (next) {
235
235
  if (
236
236
  creating &&
@@ -10,6 +10,7 @@ import { ShieldCheckIcon, PlusIcon } from 'lucide-react';
10
10
  import { PermissionManagementPage } from './permission-management-page.js';
11
11
  import { Input } from '#components/ui/input';
12
12
  import { Button } from '#components/ui/button';
13
+ import { useTableUrlState } from '#hooks/use-table-url-state';
13
14
  import { RoleList } from '#components/role-list';
14
15
  import {
15
16
  Empty,
@@ -48,6 +49,13 @@ export default function RolesPage(): ReactElement {
48
49
  i18n.language,
49
50
  ),
50
51
  );
52
+ const tableState = useTableUrlState({
53
+ sortColumns: ['title', 'key'],
54
+ rowCount:
55
+ request.data && !request.loading && !request.error
56
+ ? rows.length
57
+ : undefined,
58
+ });
51
59
  const create = canCreate ? (
52
60
  <Button
53
61
  nativeButton={false}
@@ -70,16 +78,31 @@ export default function RolesPage(): ReactElement {
70
78
  placeholder={t('settings.roleSearch')}
71
79
  aria-label={t('settings.roleSearch')}
72
80
  onChange={(event) => {
73
- setParams(event.target.value ? { q: event.target.value } : {}, {
74
- replace: true,
75
- });
81
+ setParams(
82
+ (old) => {
83
+ const next = new URLSearchParams(old);
84
+ next.delete('page');
85
+ if (event.target.value) next.set('q', event.target.value);
86
+ else next.delete('q');
87
+ return next;
88
+ },
89
+ { replace: true },
90
+ );
76
91
  }}
77
92
  />
78
93
  {search && (
79
94
  <Button
80
95
  variant='ghost'
81
96
  onClick={() => {
82
- setParams({});
97
+ setParams(
98
+ (old) => {
99
+ const next = new URLSearchParams(old);
100
+ next.delete('q');
101
+ next.delete('page');
102
+ return next;
103
+ },
104
+ { replace: true },
105
+ );
83
106
  }}
84
107
  >
85
108
  {t('settings.clear')}
@@ -93,6 +116,7 @@ export default function RolesPage(): ReactElement {
93
116
  !request.error &&
94
117
  (rows.length ? (
95
118
  <RoleList
119
+ tableState={tableState}
96
120
  ariaLabel={t('settings.roles')}
97
121
  roles={rows.map((role) => ({
98
122
  key: role.key,
@@ -104,6 +128,9 @@ export default function RolesPage(): ReactElement {
104
128
  protected: Boolean(role.protection),
105
129
  }))}
106
130
  labels={{
131
+ name: t('settings.roleName'),
132
+ key: t('settings.key'),
133
+ type: t('settings.roleType'),
107
134
  protected: t('settings.protected'),
108
135
  custom: t('settings.custom'),
109
136
  }}
@@ -117,14 +144,28 @@ export default function RolesPage(): ReactElement {
117
144
  {t(search ? 'settings.noResults' : 'settings.noRoles')}
118
145
  </EmptyTitle>
119
146
  <EmptyDescription>
120
- {t(search ? 'settings.clearHint' : 'settings.rolesDescription')}
147
+ {t(
148
+ search
149
+ ? 'settings.clearHint'
150
+ : canCreate
151
+ ? 'settings.rolesDescription'
152
+ : 'settings.rolesReadOnlyEmpty',
153
+ )}
121
154
  </EmptyDescription>
122
155
  <EmptyContent>
123
156
  {search ? (
124
157
  <Button
125
158
  variant='outline'
126
159
  onClick={() => {
127
- setParams({});
160
+ setParams(
161
+ (old) => {
162
+ const next = new URLSearchParams(old);
163
+ next.delete('q');
164
+ next.delete('page');
165
+ return next;
166
+ },
167
+ { replace: true },
168
+ );
128
169
  }}
129
170
  >
130
171
  {t('settings.clear')}