@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.84
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.
- package/.agents/rules/build-and-deploy.md +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +7 -1
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/permission-editor.tsx +396 -210
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/hooks/use-list-data.ts +77 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +8 -0
- package/client/locales/system/settings-zh-CN.ts +8 -0
- package/client/locales/system/zh-CN.ts +8 -0
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +56 -27
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -58
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +11 -3
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +39 -27
- package/client/routing/session-preparation.tsx +10 -3
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +13 -12
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/default-access.test.tsx +60 -4
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +5 -4
- package/tests/client/settings-feedback.test.tsx +25 -0
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
|
@@ -1,50 +1,128 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import { useLocation, useSearchParams } from 'react-router';
|
|
3
4
|
import type {
|
|
4
5
|
OnChangeFn,
|
|
5
6
|
PaginationState,
|
|
6
7
|
SortingState,
|
|
7
8
|
} from '@tanstack/react-table';
|
|
8
9
|
|
|
10
|
+
/** A bounded recovery failed because concurrent writes kept moving the final page. */
|
|
11
|
+
export class ListRecoveryError extends Error {}
|
|
12
|
+
|
|
9
13
|
const sizes = [10, 20, 50, 100] as const;
|
|
10
14
|
|
|
15
|
+
interface TableUrlOptions {
|
|
16
|
+
defaultPageSize?: number;
|
|
17
|
+
pageSizeOptions?: readonly number[];
|
|
18
|
+
sortColumns: readonly string[];
|
|
19
|
+
defaultSorting?: SortingState;
|
|
20
|
+
/** Match the existing endpoint contract; do not translate URLs on upgrade. */
|
|
21
|
+
sortFormat?: 'sort-order' | 'orderBy';
|
|
22
|
+
rowCount?: number;
|
|
23
|
+
active?: boolean;
|
|
24
|
+
revision?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One parser for both the visible controls and the request. */
|
|
28
|
+
export function readTableUrlState(
|
|
29
|
+
params: URLSearchParams,
|
|
30
|
+
{
|
|
31
|
+
defaultPageSize = 10,
|
|
32
|
+
pageSizeOptions = sizes,
|
|
33
|
+
sortColumns,
|
|
34
|
+
defaultSorting = [],
|
|
35
|
+
sortFormat = 'sort-order',
|
|
36
|
+
}: TableUrlOptions,
|
|
37
|
+
) {
|
|
38
|
+
const page = Number(params.get('page'));
|
|
39
|
+
const size = Number(params.get('pageSize'));
|
|
40
|
+
const fallback = defaultSorting.filter((sort) =>
|
|
41
|
+
sortColumns.includes(sort.id),
|
|
42
|
+
);
|
|
43
|
+
const fields =
|
|
44
|
+
sortFormat === 'orderBy'
|
|
45
|
+
? (params.get('orderBy') ?? '').split(',').map((part) => {
|
|
46
|
+
const [id, direction = 'asc', ...rest] = part.trim().split(/\s+/u);
|
|
47
|
+
return { id, direction: rest.length ? '' : direction };
|
|
48
|
+
})
|
|
49
|
+
: [
|
|
50
|
+
{
|
|
51
|
+
id: params.get('sort') ?? sortColumns[0],
|
|
52
|
+
direction: params.get('order'),
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
const valid = fields.every(
|
|
56
|
+
({ id, direction }) =>
|
|
57
|
+
id &&
|
|
58
|
+
sortColumns.includes(id) &&
|
|
59
|
+
(direction === 'asc' || direction === 'desc'),
|
|
60
|
+
);
|
|
61
|
+
const sortingState: SortingState = valid
|
|
62
|
+
? fields
|
|
63
|
+
.filter(
|
|
64
|
+
(field, index) =>
|
|
65
|
+
fields.findIndex((other) => other.id === field.id) === index,
|
|
66
|
+
)
|
|
67
|
+
.map(({ id, direction }) => ({ id, desc: direction === 'desc' }))
|
|
68
|
+
: fallback;
|
|
69
|
+
return {
|
|
70
|
+
paginationState: {
|
|
71
|
+
pageIndex: Number.isSafeInteger(page) && page > 0 ? page - 1 : 0,
|
|
72
|
+
pageSize: pageSizeOptions.includes(size) ? size : defaultPageSize,
|
|
73
|
+
},
|
|
74
|
+
sortingState,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
11
78
|
/** Keep list position shareable across overlays, refresh and browser history. */
|
|
12
79
|
export function useTableUrlState({
|
|
13
80
|
defaultPageSize = 10,
|
|
14
81
|
pageSizeOptions = sizes,
|
|
15
82
|
sortColumns,
|
|
16
83
|
rowCount,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}) {
|
|
84
|
+
active = true,
|
|
85
|
+
revision = 0,
|
|
86
|
+
defaultSorting = [],
|
|
87
|
+
sortFormat = 'sort-order',
|
|
88
|
+
}: TableUrlOptions) {
|
|
23
89
|
const [params, setParams] = useSearchParams();
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
90
|
+
const { paginationState, sortingState } = readTableUrlState(params, {
|
|
91
|
+
defaultPageSize,
|
|
92
|
+
pageSizeOptions,
|
|
93
|
+
sortColumns,
|
|
94
|
+
defaultSorting,
|
|
95
|
+
sortFormat,
|
|
96
|
+
});
|
|
97
|
+
const { pageIndex, pageSize } = paginationState;
|
|
98
|
+
const { t } = useTranslation();
|
|
99
|
+
const { pathname } = useLocation();
|
|
100
|
+
const contextParams = new URLSearchParams(params);
|
|
101
|
+
contextParams.delete('page');
|
|
102
|
+
const context = JSON.stringify([
|
|
103
|
+
pathname,
|
|
104
|
+
contextParams.toString(),
|
|
105
|
+
revision,
|
|
106
|
+
]);
|
|
107
|
+
const correctionsRef = useRef({ context, attempts: 0, expectedPage: -1 });
|
|
108
|
+
const [blocked, setBlocked] = useState<string>();
|
|
40
109
|
const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
|
41
110
|
const next =
|
|
42
111
|
typeof updater === 'function' ? updater(paginationState) : updater;
|
|
43
112
|
setParams(
|
|
44
113
|
(old) => {
|
|
45
114
|
const result = new URLSearchParams(old);
|
|
46
|
-
|
|
47
|
-
|
|
115
|
+
const size = pageSizeOptions.includes(next.pageSize)
|
|
116
|
+
? next.pageSize
|
|
117
|
+
: defaultPageSize;
|
|
118
|
+
const index =
|
|
119
|
+
size !== pageSize ||
|
|
120
|
+
!Number.isSafeInteger(next.pageIndex) ||
|
|
121
|
+
next.pageIndex < 0
|
|
122
|
+
? 0
|
|
123
|
+
: next.pageIndex;
|
|
124
|
+
result.set('page', String(index + 1));
|
|
125
|
+
result.set('pageSize', String(size));
|
|
48
126
|
return result;
|
|
49
127
|
},
|
|
50
128
|
{ replace: false },
|
|
@@ -57,7 +135,17 @@ export function useTableUrlState({
|
|
|
57
135
|
(old) => {
|
|
58
136
|
const result = new URLSearchParams(old);
|
|
59
137
|
result.delete('page');
|
|
60
|
-
if (
|
|
138
|
+
if (sortFormat === 'orderBy') {
|
|
139
|
+
const valid = next.filter((sort) => sortColumns.includes(sort.id));
|
|
140
|
+
if (valid.length)
|
|
141
|
+
result.set(
|
|
142
|
+
'orderBy',
|
|
143
|
+
valid
|
|
144
|
+
.map((sort) => `${sort.id}${sort.desc ? ' desc' : ''}`)
|
|
145
|
+
.join(','),
|
|
146
|
+
);
|
|
147
|
+
else result.delete('orderBy');
|
|
148
|
+
} else if (next[0] && sortColumns.includes(next[0].id)) {
|
|
61
149
|
result.set('sort', next[0].id);
|
|
62
150
|
result.set('order', next[0].desc ? 'desc' : 'asc');
|
|
63
151
|
} else {
|
|
@@ -71,9 +159,30 @@ export function useTableUrlState({
|
|
|
71
159
|
};
|
|
72
160
|
// Only pass a count for the current, successfully loaded query.
|
|
73
161
|
useEffect(() => {
|
|
74
|
-
if (
|
|
162
|
+
if (
|
|
163
|
+
!active ||
|
|
164
|
+
rowCount === undefined ||
|
|
165
|
+
!Number.isSafeInteger(rowCount) ||
|
|
166
|
+
rowCount < 0
|
|
167
|
+
)
|
|
168
|
+
return;
|
|
169
|
+
if (
|
|
170
|
+
correctionsRef.current.context !== context ||
|
|
171
|
+
(correctionsRef.current.expectedPage !== -1 &&
|
|
172
|
+
correctionsRef.current.expectedPage !== pageIndex)
|
|
173
|
+
)
|
|
174
|
+
correctionsRef.current = { context, attempts: 0, expectedPage: -1 };
|
|
75
175
|
const lastPage = Math.max(0, Math.ceil(rowCount / pageSize) - 1);
|
|
76
|
-
if (pageIndex <= lastPage)
|
|
176
|
+
if (pageIndex <= lastPage) {
|
|
177
|
+
correctionsRef.current.attempts = 0;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (correctionsRef.current.attempts >= 2) {
|
|
181
|
+
setBlocked(`${context}:${pageIndex}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
correctionsRef.current.attempts += 1;
|
|
185
|
+
correctionsRef.current.expectedPage = lastPage;
|
|
77
186
|
setParams(
|
|
78
187
|
(old) => {
|
|
79
188
|
const result = new URLSearchParams(old);
|
|
@@ -82,8 +191,12 @@ export function useTableUrlState({
|
|
|
82
191
|
},
|
|
83
192
|
{ replace: true },
|
|
84
193
|
);
|
|
85
|
-
}, [rowCount, pageSize, pageIndex, setParams]);
|
|
194
|
+
}, [rowCount, pageSize, pageIndex, setParams, active, context]);
|
|
86
195
|
return {
|
|
196
|
+
error:
|
|
197
|
+
blocked === `${context}:${pageIndex}`
|
|
198
|
+
? new ListRecoveryError(t('settings.listChanged'))
|
|
199
|
+
: undefined,
|
|
87
200
|
paginationState,
|
|
88
201
|
onPaginationChange,
|
|
89
202
|
sortingState,
|
|
@@ -54,6 +54,16 @@ const systemEnUS = {
|
|
|
54
54
|
...deviceApprovalEnUS,
|
|
55
55
|
// The UI Library block of the `/inbox` page; the keys below may reword it.
|
|
56
56
|
...inboxEnUS,
|
|
57
|
+
// Shared form wording overrides the defaults included by composed blocks.
|
|
58
|
+
formFeedback: {
|
|
59
|
+
field: 'This value was rejected. Check it and try again.',
|
|
60
|
+
invalid: 'Check the highlighted fields and try again.',
|
|
61
|
+
failed: 'Unable to save. Your input has been kept. Try again.',
|
|
62
|
+
network:
|
|
63
|
+
'Unable to connect. Check your connection and try again. Your input has been kept.',
|
|
64
|
+
denied:
|
|
65
|
+
'You cannot submit this form with your current access. Your input has been kept.',
|
|
66
|
+
},
|
|
57
67
|
// The UI Library component of the header's inbox button.
|
|
58
68
|
'inboxButton.title': 'Inbox',
|
|
59
69
|
'inboxButton.pending': 'Inbox, {{count}} waiting',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const settings = {
|
|
2
|
+
listChanged: 'The list has changed. Please retry.',
|
|
2
3
|
setup: {
|
|
3
4
|
title: 'Feature prerequisites',
|
|
4
5
|
checking: 'Checking current configuration…',
|
|
@@ -207,6 +208,13 @@ const settings = {
|
|
|
207
208
|
configurationPermissionsHint:
|
|
208
209
|
'Control access and actions for system settings, user management and business configuration. Only actual application features are shown.',
|
|
209
210
|
permissionSearch: 'Search categories, groups or permissions',
|
|
211
|
+
permissionAll: 'All',
|
|
212
|
+
permissionGranted: 'Granted',
|
|
213
|
+
permissionUngranted: 'Ungranted',
|
|
214
|
+
permissionStatus: 'Authorization status',
|
|
215
|
+
permissionClearSearch: 'Clear search',
|
|
216
|
+
permissionCollapseAll: 'Collapse all',
|
|
217
|
+
permissionExpandAll: 'Expand all',
|
|
210
218
|
enabledOnly: 'Enabled only',
|
|
211
219
|
noPermissions: 'No permissions to configure',
|
|
212
220
|
permissionEmptyHint:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const settings = {
|
|
2
|
+
listChanged: '列表已变化,请重试。',
|
|
2
3
|
setup: {
|
|
3
4
|
title: '功能前置配置',
|
|
4
5
|
checking: '正在检查当前配置…',
|
|
@@ -190,6 +191,13 @@ const settings = {
|
|
|
190
191
|
configurationPermissionsHint:
|
|
191
192
|
'统一设置系统设置、用户管理和业务配置项的访问与操作权限,仅显示应用实际开放的功能。',
|
|
192
193
|
permissionSearch: '搜索分类、分组或权限',
|
|
194
|
+
permissionAll: '全部',
|
|
195
|
+
permissionGranted: '已授权',
|
|
196
|
+
permissionUngranted: '未授权',
|
|
197
|
+
permissionStatus: '授权状态',
|
|
198
|
+
permissionClearSearch: '清空搜索',
|
|
199
|
+
permissionCollapseAll: '全部收起',
|
|
200
|
+
permissionExpandAll: '全部展开',
|
|
193
201
|
enabledOnly: '仅显示已授权',
|
|
194
202
|
noPermissions: '暂无可配置权限',
|
|
195
203
|
permissionEmptyHint: '请联系应用开发者,开放此角色需要配置的权限。',
|
|
@@ -50,6 +50,14 @@ const systemZhCN: SystemResource = {
|
|
|
50
50
|
...deviceApprovalZhCN,
|
|
51
51
|
// The UI Library block of the `/inbox` page; the keys below may reword it.
|
|
52
52
|
...inboxZhCN,
|
|
53
|
+
// Shared form wording overrides the defaults included by composed blocks.
|
|
54
|
+
formFeedback: {
|
|
55
|
+
field: '此字段的值未通过校验,请检查并修改后重试。',
|
|
56
|
+
invalid: '请检查标记的字段后重试。',
|
|
57
|
+
failed: '保存失败,已保留您的输入,请重试。',
|
|
58
|
+
network: '无法连接,请检查网络后重试。已保留您的输入。',
|
|
59
|
+
denied: '当前权限无法提交此表单。已保留您的输入。',
|
|
60
|
+
},
|
|
53
61
|
// The UI Library component of the header's inbox button.
|
|
54
62
|
'inboxButton.title': '收件箱',
|
|
55
63
|
'inboxButton.pending': '收件箱,{{count}} 项待处理',
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { FeedbackForm } from '#components/feedback-form';
|
|
2
|
+
import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
|
|
3
|
+
import { useSubmission } from '@nocobase/app-client';
|
|
1
4
|
import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
|
|
2
5
|
import {
|
|
3
6
|
useCan,
|
|
@@ -60,7 +63,12 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
60
63
|
action: creating ? 'create' : 'update',
|
|
61
64
|
}).can;
|
|
62
65
|
const [draft, setDraft] = useState<DefaultAccessRule>();
|
|
63
|
-
const
|
|
66
|
+
const { session } = useAuthentication();
|
|
67
|
+
const submission = useSubmission({
|
|
68
|
+
scope: `${session?.user.id}:settings:default-access:${key}`,
|
|
69
|
+
});
|
|
70
|
+
const [requestBusy, setRequestBusy] = useState(false);
|
|
71
|
+
const busy = requestBusy || submission.submitting;
|
|
64
72
|
const [error, setError] = useState<unknown>();
|
|
65
73
|
const [message, setMessage] = useState<string>();
|
|
66
74
|
const [submitted, setSubmitted] = useState(false);
|
|
@@ -245,28 +253,36 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
245
253
|
);
|
|
246
254
|
return;
|
|
247
255
|
}
|
|
248
|
-
|
|
256
|
+
setRequestBusy(true);
|
|
249
257
|
try {
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
258
|
+
const saved = await submission.run(
|
|
259
|
+
{ creating, key, value },
|
|
260
|
+
async (idempotencyKey, _signal, _snapshot, recovering) => {
|
|
261
|
+
if (!creating && !recovering) {
|
|
262
|
+
const latest = await api.request<{
|
|
263
|
+
data: readonly DefaultAccessRule[];
|
|
264
|
+
}>({ path: 'authorization/defaultAccess' });
|
|
265
|
+
if (
|
|
266
|
+
JSON.stringify(latest.data.find((rule) => rule.key === key)) !==
|
|
267
|
+
JSON.stringify(initial)
|
|
268
|
+
) {
|
|
269
|
+
setMessage(t('settings.defaultAccess.changed'));
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return api.request<{ data: DefaultAccessRule }>({
|
|
274
|
+
path: creating
|
|
275
|
+
? 'authorization/defaultAccess'
|
|
276
|
+
: `authorization/defaultAccess/${encodeURIComponent(key ?? '')}`,
|
|
277
|
+
headers: { 'Idempotency-Key': idempotencyKey },
|
|
278
|
+
method: creating ? 'POST' : 'PATCH',
|
|
279
|
+
json: creating
|
|
280
|
+
? { ...value, key: value.key.trim() }
|
|
281
|
+
: { actions: value.actions },
|
|
282
|
+
});
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
if (!saved) return;
|
|
270
286
|
toaster.show({
|
|
271
287
|
type: 'success',
|
|
272
288
|
title: t('settings.defaultAccess.saved', { name: value.key }),
|
|
@@ -331,7 +347,7 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
331
347
|
setMessage(t('settings.defaultAccess.invalid'));
|
|
332
348
|
else setError(next);
|
|
333
349
|
} finally {
|
|
334
|
-
|
|
350
|
+
setRequestBusy(false);
|
|
335
351
|
}
|
|
336
352
|
};
|
|
337
353
|
return (
|
|
@@ -360,7 +376,10 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
360
376
|
{!creating && !initial ? (
|
|
361
377
|
<p role='status'>{t('settings.missing')}</p>
|
|
362
378
|
) : (
|
|
363
|
-
<
|
|
379
|
+
<FeedbackForm
|
|
380
|
+
submitting={busy}
|
|
381
|
+
error={error}
|
|
382
|
+
errorContent={<SettingsError error={error} inline />}
|
|
364
383
|
ref={formRef}
|
|
365
384
|
noValidate
|
|
366
385
|
className='flex flex-col gap-6'
|
|
@@ -369,7 +388,6 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
369
388
|
void save();
|
|
370
389
|
}}
|
|
371
390
|
>
|
|
372
|
-
<SettingsError error={error} />
|
|
373
391
|
{message && (
|
|
374
392
|
<Alert variant='destructive'>
|
|
375
393
|
<AlertDescription>
|
|
@@ -487,7 +505,7 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
487
505
|
</Button>
|
|
488
506
|
)}
|
|
489
507
|
</div>
|
|
490
|
-
</
|
|
508
|
+
</FeedbackForm>
|
|
491
509
|
)}
|
|
492
510
|
{!creating && !initial && (
|
|
493
511
|
<Button variant='outline' onClick={close}>
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { useListLocation } from '#hooks/use-list-location';
|
|
1
2
|
import { RecordLink } from '#components/record-link';
|
|
2
3
|
import { useTableUrlState } from '#hooks/use-table-url-state';
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ApiClientError,
|
|
6
|
+
useApiClient,
|
|
7
|
+
useSubmission,
|
|
8
|
+
useToaster,
|
|
9
|
+
} from '@nocobase/app-client';
|
|
4
10
|
import {
|
|
5
11
|
useCan,
|
|
6
12
|
type AuthorizationOptionsResponse,
|
|
@@ -8,7 +14,7 @@ import {
|
|
|
8
14
|
} from '@nocobase/app-plugin-authorization/client';
|
|
9
15
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
10
16
|
import { useRef, useState, type ReactElement } from 'react';
|
|
11
|
-
import { Link, Outlet, useLocation
|
|
17
|
+
import { Link, Outlet, useLocation } from 'react-router';
|
|
12
18
|
import { ListFilterIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react';
|
|
13
19
|
import type { ColumnDef } from '@tanstack/react-table';
|
|
14
20
|
import { Alert, AlertDescription } from '#components/ui/alert';
|
|
@@ -45,11 +51,12 @@ export default function DefaultAccessPage(): ReactElement {
|
|
|
45
51
|
const api = useApiClient();
|
|
46
52
|
const toaster = useToaster();
|
|
47
53
|
const location = useLocation();
|
|
48
|
-
const
|
|
54
|
+
const { params, setParams, active } = useListLocation();
|
|
49
55
|
const search = params.get('q') ?? '';
|
|
50
56
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
51
57
|
const rules = useSettingsData<{ data: readonly DefaultAccessRule[] }>(
|
|
52
58
|
'authorization/defaultAccess',
|
|
59
|
+
{ active },
|
|
53
60
|
);
|
|
54
61
|
const catalog = useSettingsData<{ data: AuthorizationOptionsResponse }>(
|
|
55
62
|
'authorization/defaultAccess/options',
|
|
@@ -212,6 +219,8 @@ export default function DefaultAccessPage(): ReactElement {
|
|
|
212
219
|
)
|
|
213
220
|
.sort((a, b) => name(a).localeCompare(name(b), i18n.language));
|
|
214
221
|
const tableState = useTableUrlState({
|
|
222
|
+
active,
|
|
223
|
+
revision: rules.revision,
|
|
215
224
|
defaultPageSize: 20,
|
|
216
225
|
sortColumns: ['resource'],
|
|
217
226
|
rowCount:
|
|
@@ -262,43 +271,58 @@ export default function DefaultAccessPage(): ReactElement {
|
|
|
262
271
|
</div>
|
|
263
272
|
)
|
|
264
273
|
) : null;
|
|
274
|
+
const submission = useSubmission();
|
|
265
275
|
const remove = async () => {
|
|
266
276
|
if (!removing || busy || !canDelete) return;
|
|
267
277
|
setBusy(true);
|
|
268
278
|
setError(undefined);
|
|
279
|
+
let submitted = false;
|
|
269
280
|
try {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
281
|
+
const outcome = await submission.run(
|
|
282
|
+
{ key: removing.key },
|
|
283
|
+
async (idempotencyKey, _signal, _snapshot, recovering) => {
|
|
284
|
+
if (!recovering) {
|
|
285
|
+
const latest = await api.request<{
|
|
286
|
+
data: readonly DefaultAccessRule[];
|
|
287
|
+
}>({ path: 'authorization/defaultAccess' });
|
|
288
|
+
if (
|
|
289
|
+
JSON.stringify(
|
|
290
|
+
latest.data.find((rule) => rule.key === removing.key),
|
|
291
|
+
) !== JSON.stringify(removing)
|
|
292
|
+
) {
|
|
293
|
+
rules.reload();
|
|
294
|
+
setRemoving(undefined);
|
|
295
|
+
setConflict(true);
|
|
296
|
+
requestAnimationFrame(() => searchRef.current?.focus());
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
submitted = true;
|
|
301
|
+
await api.request({
|
|
302
|
+
path: `authorization/defaultAccess/${encodeURIComponent(removing.key)}`,
|
|
303
|
+
method: 'DELETE',
|
|
304
|
+
headers: { 'Idempotency-Key': idempotencyKey },
|
|
305
|
+
});
|
|
306
|
+
return true;
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
if (!outcome) return;
|
|
288
310
|
toaster.show({
|
|
289
311
|
type: 'success',
|
|
290
312
|
title: t('settings.defaultAccess.deleted', { name: name(removing) }),
|
|
291
313
|
});
|
|
292
|
-
rules.replaceData({
|
|
293
|
-
data: (rules.data?.data ?? []).filter(
|
|
294
|
-
(rule) => rule.key !== removing.key,
|
|
295
|
-
),
|
|
296
|
-
});
|
|
297
314
|
setRemoving(undefined);
|
|
298
315
|
rules.reload();
|
|
299
316
|
requestAnimationFrame(() => searchRef.current?.focus());
|
|
300
317
|
} catch (next) {
|
|
301
318
|
setError(next);
|
|
319
|
+
if (
|
|
320
|
+
submitted &&
|
|
321
|
+
(!(next instanceof ApiClientError) || next.status >= 500)
|
|
322
|
+
) {
|
|
323
|
+
// The write may have committed before the response was lost. Verify without replaying it.
|
|
324
|
+
rules.reload();
|
|
325
|
+
}
|
|
302
326
|
} finally {
|
|
303
327
|
setBusy(false);
|
|
304
328
|
}
|
|
@@ -345,7 +369,11 @@ export default function DefaultAccessPage(): ReactElement {
|
|
|
345
369
|
</Alert>
|
|
346
370
|
)}
|
|
347
371
|
<SettingsError
|
|
348
|
-
error={
|
|
372
|
+
error={
|
|
373
|
+
(tableState.error ?? rules.error) ||
|
|
374
|
+
catalog.error ||
|
|
375
|
+
(!removing && error)
|
|
376
|
+
}
|
|
349
377
|
retry={() => {
|
|
350
378
|
rules.reload();
|
|
351
379
|
catalog.reload();
|
|
@@ -356,6 +384,7 @@ export default function DefaultAccessPage(): ReactElement {
|
|
|
356
384
|
(catalog.loading && !catalog.data) ? (
|
|
357
385
|
<SettingsLoading />
|
|
358
386
|
) : (
|
|
387
|
+
!tableState.error &&
|
|
359
388
|
!rules.error &&
|
|
360
389
|
!catalog.error &&
|
|
361
390
|
(rows.length ? (
|
|
@@ -1,67 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ApiClientError,
|
|
3
|
-
useApiClient,
|
|
4
|
-
useNavigationGuard,
|
|
5
|
-
} from '@nocobase/app-client';
|
|
1
|
+
import { useNavigationGuard } from '@nocobase/app-client';
|
|
6
2
|
import { useCan } from '@nocobase/app-plugin-authorization/client';
|
|
7
3
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
8
|
-
import {
|
|
4
|
+
import { useEffect, useRef, useState } from 'react';
|
|
9
5
|
import { useLocation } from 'react-router';
|
|
10
6
|
import { ConfirmAction } from './shared.js';
|
|
11
7
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const [revision, setRevision] = useState(0);
|
|
15
|
-
const [result, setResult] = useState<{
|
|
16
|
-
path: string;
|
|
17
|
-
revision: number;
|
|
18
|
-
data?: T;
|
|
19
|
-
error?: unknown;
|
|
20
|
-
loading: boolean;
|
|
21
|
-
}>({ path, revision: -1, loading: true });
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
let active = true;
|
|
24
|
-
void api.request<T>({ path }).then(
|
|
25
|
-
(data) => {
|
|
26
|
-
if (active) setResult({ path, revision, data, loading: false });
|
|
27
|
-
},
|
|
28
|
-
(error: unknown) => {
|
|
29
|
-
if (active)
|
|
30
|
-
setResult((old) => ({
|
|
31
|
-
...(old.path === path &&
|
|
32
|
-
!(
|
|
33
|
-
error instanceof ApiClientError &&
|
|
34
|
-
[401, 403, 404].includes(error.status)
|
|
35
|
-
)
|
|
36
|
-
? old
|
|
37
|
-
: {}),
|
|
38
|
-
path,
|
|
39
|
-
revision,
|
|
40
|
-
error,
|
|
41
|
-
loading: false,
|
|
42
|
-
}));
|
|
43
|
-
},
|
|
44
|
-
);
|
|
45
|
-
return () => {
|
|
46
|
-
active = false;
|
|
47
|
-
};
|
|
48
|
-
}, [api, path, revision]);
|
|
49
|
-
return {
|
|
50
|
-
...(result.path === path || keepPreviousData
|
|
51
|
-
? result
|
|
52
|
-
: { path, loading: true, data: undefined, error: undefined }),
|
|
53
|
-
loading: result.path !== path || result.revision !== revision,
|
|
54
|
-
error:
|
|
55
|
-
result.path === path && result.revision === revision
|
|
56
|
-
? result.error
|
|
57
|
-
: undefined,
|
|
58
|
-
reload: useCallback(() => setRevision((value) => value + 1), []),
|
|
59
|
-
replaceData: useCallback(
|
|
60
|
-
(data: T) => setResult({ path, revision, data, loading: false }),
|
|
61
|
-
[path, revision],
|
|
62
|
-
),
|
|
63
|
-
};
|
|
64
|
-
}
|
|
8
|
+
/** Compatibility entry for prepared settings pages and application-owned consumers. */
|
|
9
|
+
export { useListData as useSettingsData } from '#hooks/use-list-data';
|
|
65
10
|
|
|
66
11
|
export function useUserPermission(action: string, id = '*') {
|
|
67
12
|
return useCan({ resource: { type: 'user', id }, action }).can;
|
|
@@ -101,6 +101,21 @@ export function pagePermissionNavigation(
|
|
|
101
101
|
({ type, id }) => !configuration.has(JSON.stringify([type, id])),
|
|
102
102
|
);
|
|
103
103
|
const resources = new Map(pages.map((page) => [page.id, page]));
|
|
104
|
+
const paths = new Map<string, string>();
|
|
105
|
+
const collectPaths = (items: readonly AppClientRegisteredRoute[]): void => {
|
|
106
|
+
for (const route of items) {
|
|
107
|
+
if (
|
|
108
|
+
route.componentLoader &&
|
|
109
|
+
typeof route.authz === 'object' &&
|
|
110
|
+
route.authz.resource.type === 'page' &&
|
|
111
|
+
!paths.has(route.authz.resource.id)
|
|
112
|
+
) {
|
|
113
|
+
paths.set(route.authz.resource.id, route.path);
|
|
114
|
+
}
|
|
115
|
+
collectPaths(route.children ?? []);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
collectPaths(routes);
|
|
104
119
|
const seen = new Set<string>();
|
|
105
120
|
const visit = (
|
|
106
121
|
items: readonly RouteNavigationItem[],
|
|
@@ -130,6 +145,7 @@ export function pagePermissionNavigation(
|
|
|
130
145
|
: unnamed(route.path),
|
|
131
146
|
),
|
|
132
147
|
rowId,
|
|
148
|
+
path: route.path,
|
|
133
149
|
children: descendants,
|
|
134
150
|
},
|
|
135
151
|
];
|
|
@@ -142,6 +158,7 @@ export function pagePermissionNavigation(
|
|
|
142
158
|
id: `page:${page.id}`,
|
|
143
159
|
label: text(page.title),
|
|
144
160
|
rowId: permissionRowId(page, 'access'),
|
|
161
|
+
path: paths.get(page.id),
|
|
145
162
|
}));
|
|
146
163
|
return [
|
|
147
164
|
...navigation,
|