@nocobase/app-template-default 1.0.0-beta.75 → 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 (43) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/README.MD +2 -2
  4. package/client/components/data-table/data-table.tsx +2 -0
  5. package/client/components/data-table/pagination.tsx +6 -6
  6. package/client/components/flow-diagram.tsx +645 -83
  7. package/client/components/flow-layout.ts +117 -7
  8. package/client/components/lifecycle-diagram.tsx +175 -37
  9. package/client/components/record-link.tsx +20 -0
  10. package/client/components/required-role-setup.tsx +93 -0
  11. package/client/components/role-list.tsx +21 -5
  12. package/client/components/setup-readiness-state.ts +135 -0
  13. package/client/components/setup-readiness.tsx +91 -0
  14. package/client/components/ui/combobox.tsx +300 -0
  15. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  16. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  17. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  18. package/client/hooks/use-table-url-state.ts +93 -0
  19. package/client/lib/required-role-setup.ts +98 -0
  20. package/client/lib/return-to.ts +19 -0
  21. package/client/locales/system/en-US.ts +10 -0
  22. package/client/locales/system/settings-en-US.ts +37 -1
  23. package/client/locales/system/settings-zh-CN.ts +33 -2
  24. package/client/locales/system/zh-CN.ts +10 -0
  25. package/client/pages/settings/default-access-form.tsx +4 -1
  26. package/client/pages/settings/default-access.tsx +40 -8
  27. package/client/pages/settings/role-form.tsx +1 -1
  28. package/client/pages/settings/roles.tsx +44 -6
  29. package/client/pages/settings/shared.tsx +15 -13
  30. package/client/pages/settings/user-detail.tsx +15 -259
  31. package/client/pages/settings/user-form.tsx +299 -147
  32. package/client/pages/settings/users.tsx +211 -123
  33. package/config.example.yml +6 -5
  34. package/package.json +11 -11
  35. package/tests/components/default-access.test.tsx +141 -2
  36. package/tests/components/settings-continuity.test.tsx +426 -0
  37. package/tests/components/settings-feedback.test.tsx +304 -0
  38. package/tests/components/settings.test.tsx +1 -1
  39. package/tests/components/setup-readiness.test.tsx +370 -0
  40. package/tests/components/table-url-state.test.tsx +95 -0
  41. package/tests/components/user-drawer.test.tsx +526 -0
  42. package/tests/logic/return-to.test.ts +19 -0
  43. 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
+ }
@@ -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',
@@ -117,9 +144,16 @@ const settings = {
117
144
  noResults: 'No matching results',
118
145
  clearHint: 'Try a different search or clear the filters.',
119
146
  noUsers: 'No users yet',
147
+ usersReadOnlyEmpty:
148
+ 'Contact an administrator to add users or grant the permissions needed to create users and assign roles.',
149
+ rolesReadOnlyEmpty:
150
+ 'Contact an administrator to create a role or grant you permission to create roles.',
120
151
  noRoles: 'No roles yet',
121
152
  create: 'Create',
122
153
  edit: 'Edit',
154
+ selectRoles: 'Select roles',
155
+ searchRoles: 'Search roles',
156
+ noRolesFound: 'No roles found.',
123
157
  save: 'Save',
124
158
  cancel: 'Cancel',
125
159
  saving: 'Saving…',
@@ -149,12 +183,14 @@ const settings = {
149
183
  discardTitle: 'Discard changes?',
150
184
  discardHint: 'Your unsaved changes will be lost.',
151
185
  failed: 'Unable to complete the request. Try again.',
186
+ invalidRequest:
187
+ 'Some values are invalid. Check your input or filters and try again.',
152
188
  retry: 'Retry',
153
189
  loading: 'Loading',
154
190
  denied: 'You do not have access. Contact an administrator.',
155
191
  missing: 'This record does not exist or has been deleted.',
156
192
  conflict:
157
- 'This role has changed. Reload it and merge your changes before saving.',
193
+ 'The request conflicts with current data. Review the latest record before trying again.',
158
194
  sessionEnded: 'Your session has ended. Sign in again.',
159
195
  previous: 'Previous',
160
196
  next: 'Next',
@@ -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: '编辑角色',
@@ -109,9 +133,15 @@ const settings = {
109
133
  noResults: '没有匹配的结果',
110
134
  clearHint: '请更换搜索条件或清除筛选。',
111
135
  noUsers: '暂无用户',
136
+ usersReadOnlyEmpty:
137
+ '请联系管理员添加用户,或授予创建用户和分配角色所需的权限。',
138
+ rolesReadOnlyEmpty: '请联系管理员创建角色,或授予你创建角色的权限。',
112
139
  noRoles: '暂无角色',
113
140
  create: '创建',
114
141
  edit: '编辑',
142
+ selectRoles: '选择角色',
143
+ searchRoles: '搜索角色',
144
+ noRolesFound: '没有匹配的角色。',
115
145
  save: '保存',
116
146
  cancel: '取消',
117
147
  saving: '正在保存…',
@@ -139,11 +169,12 @@ const settings = {
139
169
  discardTitle: '放弃未保存的更改?',
140
170
  discardHint: '未保存的更改将丢失。',
141
171
  failed: '请求失败,请重试。',
172
+ invalidRequest: '部分内容无效,请检查填写内容或筛选条件后重试。',
142
173
  retry: '重试',
143
174
  loading: '加载中',
144
- denied: '无权查看,请联系管理员。',
175
+ denied: '没有执行此操作的权限,请联系管理员。',
145
176
  missing: '记录不存在或已被删除。',
146
- conflict: '角色已被其他人修改,请重新加载后合并。',
177
+ conflict: '此操作与当前数据冲突,请查看最新记录后再试。',
147
178
  sessionEnded: '会话已结束,请重新登录。',
148
179
  previous: '上一页',
149
180
  next: '下一页',
@@ -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,
@@ -120,14 +144,28 @@ export default function RolesPage(): ReactElement {
120
144
  {t(search ? 'settings.noResults' : 'settings.noRoles')}
121
145
  </EmptyTitle>
122
146
  <EmptyDescription>
123
- {t(search ? 'settings.clearHint' : 'settings.rolesDescription')}
147
+ {t(
148
+ search
149
+ ? 'settings.clearHint'
150
+ : canCreate
151
+ ? 'settings.rolesDescription'
152
+ : 'settings.rolesReadOnlyEmpty',
153
+ )}
124
154
  </EmptyDescription>
125
155
  <EmptyContent>
126
156
  {search ? (
127
157
  <Button
128
158
  variant='outline'
129
159
  onClick={() => {
130
- 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
+ );
131
169
  }}
132
170
  >
133
171
  {t('settings.clear')}
@@ -32,18 +32,20 @@ export function SettingsError({
32
32
  <Alert variant='destructive'>
33
33
  <AlertDescription>
34
34
  {t(
35
- status === 401
36
- ? 'settings.sessionEnded'
37
- : status === 403
38
- ? 'settings.denied'
39
- : status === 404
40
- ? 'settings.missing'
41
- : status === 409
42
- ? error instanceof ApiClientError &&
43
- error.reason === 'USER_IDENTITY_CONFLICT'
44
- ? 'settings.identityConflict'
45
- : 'settings.conflict'
46
- : 'settings.failed',
35
+ status === 400
36
+ ? 'settings.invalidRequest'
37
+ : status === 401
38
+ ? 'settings.sessionEnded'
39
+ : status === 403
40
+ ? 'settings.denied'
41
+ : status === 404
42
+ ? 'settings.missing'
43
+ : status === 409
44
+ ? error instanceof ApiClientError &&
45
+ error.reason === 'USER_IDENTITY_CONFLICT'
46
+ ? 'settings.identityConflict'
47
+ : 'settings.conflict'
48
+ : 'settings.failed',
47
49
  )}
48
50
  {status === 401 && (
49
51
  <Button
@@ -54,7 +56,7 @@ export function SettingsError({
54
56
  {t('settings.signInAgain')}
55
57
  </Button>
56
58
  )}
57
- {retry && ![401, 403, 404, 409].includes(Number(status)) && (
59
+ {retry && ![400, 401, 403, 404, 409].includes(Number(status)) && (
58
60
  <Button variant='outline' size='sm' onClick={retry}>
59
61
  {t('settings.retry')}
60
62
  </Button>