@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.77
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 +4 -0
- package/.agents/rules/components.md +3 -1
- package/README.MD +2 -2
- package/client/components/data-table/data-table.tsx +2 -0
- package/client/components/data-table/pagination.tsx +6 -6
- package/client/components/default-access-editor.tsx +79 -52
- package/client/components/flow-diagram.tsx +645 -83
- package/client/components/flow-layout.ts +117 -7
- package/client/components/lifecycle-diagram.tsx +175 -37
- package/client/components/long-text.tsx +200 -0
- package/client/components/permission-editor.tsx +534 -341
- package/client/components/record-link.tsx +20 -0
- package/client/components/required-role-setup.tsx +93 -0
- package/client/components/role-list.tsx +21 -5
- package/client/components/setup-readiness-state.ts +135 -0
- package/client/components/setup-readiness.tsx +91 -0
- package/client/components/ui/combobox.tsx +300 -0
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
- package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
- package/client/hooks/use-table-url-state.ts +93 -0
- package/client/lib/required-role-setup.ts +98 -0
- package/client/lib/return-to.ts +19 -0
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +51 -4
- package/client/locales/system/settings-zh-CN.ts +46 -5
- package/client/locales/system/zh-CN.ts +10 -0
- package/client/pages/settings/default-access-form.tsx +14 -2
- package/client/pages/settings/default-access-model.ts +6 -2
- package/client/pages/settings/default-access.tsx +40 -8
- package/client/pages/settings/page-permissions.ts +166 -1
- package/client/pages/settings/permission-filters.tsx +9 -2
- package/client/pages/settings/permission-workspace.ts +330 -0
- package/client/pages/settings/permissions-model.ts +62 -53
- package/client/pages/settings/role-form.tsx +31 -95
- package/client/pages/settings/roles.tsx +44 -6
- package/client/pages/settings/shared.tsx +15 -13
- package/client/pages/settings/user-detail.tsx +15 -259
- package/client/pages/settings/user-form.tsx +299 -147
- package/client/pages/settings/users.tsx +211 -123
- package/config.example.yml +6 -5
- package/package.json +17 -17
- package/server/config/app.ts +1 -1
- package/server/config/session.ts +7 -5
- package/tests/components/default-access-editor.test.tsx +130 -0
- package/tests/components/default-access.test.tsx +141 -2
- package/tests/components/permission-editor.test.tsx +613 -572
- package/tests/components/permission-filter-binding.test.tsx +20 -0
- package/tests/components/settings-continuity.test.tsx +426 -0
- package/tests/components/settings-feedback.test.tsx +304 -0
- package/tests/components/settings.test.tsx +34 -9
- package/tests/components/setup-readiness.test.tsx +370 -0
- package/tests/components/table-url-state.test.tsx +95 -0
- package/tests/components/user-drawer.test.tsx +526 -0
- package/tests/components/user-permissions.test.tsx +47 -13
- package/tests/logic/config.test.ts +3 -1
- package/tests/logic/page-permission-navigation.test.ts +394 -0
- package/tests/logic/permission-workspace.test.ts +570 -0
- package/tests/logic/return-to.test.ts +19 -0
- package/tests/logic/session-isolation.test.ts +250 -0
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
|
|
2
|
+
import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
|
|
3
|
+
import {
|
|
4
|
+
AuthorizationClient,
|
|
5
|
+
authorizationClientToken,
|
|
6
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
7
|
+
import { screen, waitFor, within } from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { Routes, Route } from 'react-router';
|
|
11
|
+
import UsersPage from '../../client/pages/settings/users.js';
|
|
12
|
+
import RolesPage from '../../client/pages/settings/roles.js';
|
|
13
|
+
import { SettingsError } from '../../client/pages/settings/shared.js';
|
|
14
|
+
import enUS from '../../client/locales/en-US.js';
|
|
15
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
16
|
+
|
|
17
|
+
const namespace = '@nocobase/settings-feedback-test';
|
|
18
|
+
const namespaces = {
|
|
19
|
+
[namespace]: {
|
|
20
|
+
'en-US': async () => ({ default: enUS }),
|
|
21
|
+
'zh-CN': async () => ({ default: zhCN }),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const services = (app: ClientApplication) =>
|
|
25
|
+
app.container.instance(
|
|
26
|
+
authorizationClientToken,
|
|
27
|
+
new AuthorizationClient(app.container.resolve(apiClientToken)),
|
|
28
|
+
);
|
|
29
|
+
const userRow = {
|
|
30
|
+
id: 'alice',
|
|
31
|
+
name: 'Alice',
|
|
32
|
+
email: 'alice@example.test',
|
|
33
|
+
disabledAt: null as string | null,
|
|
34
|
+
roleScopes: {},
|
|
35
|
+
};
|
|
36
|
+
const options = { data: { roleScopes: [] } };
|
|
37
|
+
|
|
38
|
+
describe.each([
|
|
39
|
+
['en-US', enUS],
|
|
40
|
+
['zh-CN', zhCN],
|
|
41
|
+
] as const)('%s', (language, copy) => {
|
|
42
|
+
it.each(['users', 'roles'] as const)(
|
|
43
|
+
'explains the next step for a read-only empty %s list',
|
|
44
|
+
async (kind) => {
|
|
45
|
+
await renderWithApp(
|
|
46
|
+
<Routes>
|
|
47
|
+
<Route
|
|
48
|
+
path={`/settings/${kind}`}
|
|
49
|
+
element={kind === 'users' ? <UsersPage /> : <RolesPage />}
|
|
50
|
+
/>
|
|
51
|
+
</Routes>,
|
|
52
|
+
{
|
|
53
|
+
route: `/settings/${kind}`,
|
|
54
|
+
locale: language,
|
|
55
|
+
namespace,
|
|
56
|
+
namespaces,
|
|
57
|
+
services,
|
|
58
|
+
fetch: answerApi(({ path }) => {
|
|
59
|
+
if (path === 'authorization/permissions')
|
|
60
|
+
return { data: { unrestricted: false, permissions: [] } };
|
|
61
|
+
if (path === 'users/options') return options;
|
|
62
|
+
return { data: [], meta: { total: 0 } };
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
expect(
|
|
67
|
+
await screen.findByText(
|
|
68
|
+
kind === 'users'
|
|
69
|
+
? copy.settings.usersReadOnlyEmpty
|
|
70
|
+
: copy.settings.rolesReadOnlyEmpty,
|
|
71
|
+
),
|
|
72
|
+
).toBeVisible();
|
|
73
|
+
expect(
|
|
74
|
+
screen.queryByRole('button', {
|
|
75
|
+
name:
|
|
76
|
+
kind === 'users' ? copy.settings.newUser : copy.settings.newRole,
|
|
77
|
+
}),
|
|
78
|
+
).not.toBeInTheDocument();
|
|
79
|
+
expect(
|
|
80
|
+
screen.queryByText(copy.settings.noResults),
|
|
81
|
+
).not.toBeInTheDocument();
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
it.each(['users', 'roles'] as const)(
|
|
86
|
+
'keeps a single creation entry for an authorized empty %s list',
|
|
87
|
+
async (kind) => {
|
|
88
|
+
await renderWithApp(
|
|
89
|
+
<Routes>
|
|
90
|
+
<Route
|
|
91
|
+
path={`/settings/${kind}`}
|
|
92
|
+
element={kind === 'users' ? <UsersPage /> : <RolesPage />}
|
|
93
|
+
/>
|
|
94
|
+
</Routes>,
|
|
95
|
+
{
|
|
96
|
+
route: `/settings/${kind}`,
|
|
97
|
+
locale: language,
|
|
98
|
+
namespace,
|
|
99
|
+
namespaces,
|
|
100
|
+
services,
|
|
101
|
+
fetch: answerApi(({ path }) => {
|
|
102
|
+
if (path === 'authorization/permissions')
|
|
103
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
104
|
+
if (path === 'users/options') return options;
|
|
105
|
+
return { data: [], meta: { total: 0 } };
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
expect(
|
|
110
|
+
await screen.findByRole('button', {
|
|
111
|
+
name:
|
|
112
|
+
kind === 'users' ? copy.settings.newUser : copy.settings.newRole,
|
|
113
|
+
}),
|
|
114
|
+
).toHaveAttribute('href', `/settings/${kind}/create`);
|
|
115
|
+
expect(
|
|
116
|
+
screen.getAllByRole('button', {
|
|
117
|
+
name:
|
|
118
|
+
kind === 'users' ? copy.settings.newUser : copy.settings.newRole,
|
|
119
|
+
}),
|
|
120
|
+
).toHaveLength(1);
|
|
121
|
+
expect(
|
|
122
|
+
screen.queryByText(
|
|
123
|
+
kind === 'users'
|
|
124
|
+
? copy.settings.usersReadOnlyEmpty
|
|
125
|
+
: copy.settings.rolesReadOnlyEmpty,
|
|
126
|
+
),
|
|
127
|
+
).not.toBeInTheDocument();
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
it('guides correction of invalid input without replaying it or exposing raw errors', async () => {
|
|
132
|
+
const retry = vi.fn();
|
|
133
|
+
await renderWithApp(
|
|
134
|
+
<SettingsError
|
|
135
|
+
error={{ status: 400, message: 'secret database details' }}
|
|
136
|
+
retry={retry}
|
|
137
|
+
/>,
|
|
138
|
+
{
|
|
139
|
+
locale: language,
|
|
140
|
+
namespace,
|
|
141
|
+
namespaces,
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
expect(screen.getByText(copy.settings.invalidRequest)).toBeVisible();
|
|
145
|
+
expect(
|
|
146
|
+
screen.queryByRole('button', { name: copy.settings.retry }),
|
|
147
|
+
).not.toBeInTheDocument();
|
|
148
|
+
expect(
|
|
149
|
+
screen.queryByText('secret database details'),
|
|
150
|
+
).not.toBeInTheDocument();
|
|
151
|
+
expect(retry).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('keeps a retry for temporary failures and performs it only on demand', async () => {
|
|
155
|
+
const retry = vi.fn();
|
|
156
|
+
await renderWithApp(
|
|
157
|
+
<SettingsError error={{ status: 503 }} retry={retry} />,
|
|
158
|
+
{
|
|
159
|
+
locale: language,
|
|
160
|
+
namespace,
|
|
161
|
+
namespaces,
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
expect(screen.getByText(copy.settings.failed)).toBeVisible();
|
|
165
|
+
expect(retry).not.toHaveBeenCalled();
|
|
166
|
+
await userEvent.click(
|
|
167
|
+
screen.getByRole('button', { name: copy.settings.retry }),
|
|
168
|
+
);
|
|
169
|
+
expect(retry).toHaveBeenCalledTimes(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('does not describe every conflict as a role edit or offer a blind retry', async () => {
|
|
173
|
+
await renderWithApp(
|
|
174
|
+
<SettingsError error={{ status: 409 }} retry={vi.fn()} />,
|
|
175
|
+
{
|
|
176
|
+
locale: language,
|
|
177
|
+
namespace,
|
|
178
|
+
namespaces,
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
expect(screen.getByText(copy.settings.conflict)).toBeVisible();
|
|
182
|
+
expect(
|
|
183
|
+
screen.queryByRole('button', { name: copy.settings.retry }),
|
|
184
|
+
).not.toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it.each(['disable', 'enable', 'revokeSessions'] as const)(
|
|
189
|
+
'reports a completed %s action only after the server succeeds',
|
|
190
|
+
async (action) => {
|
|
191
|
+
let complete!: (value: unknown) => void;
|
|
192
|
+
const response = new Promise((resolve) => {
|
|
193
|
+
complete = resolve;
|
|
194
|
+
});
|
|
195
|
+
const endpoint = action;
|
|
196
|
+
const calls = vi.fn(({ path }: { path: string }) => {
|
|
197
|
+
if (path === 'authorization/permissions')
|
|
198
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
199
|
+
if (path === 'users/options') return options;
|
|
200
|
+
if (path === `users/alice/${endpoint}`) return response;
|
|
201
|
+
return {
|
|
202
|
+
data: [
|
|
203
|
+
{
|
|
204
|
+
...userRow,
|
|
205
|
+
disabledAt: action === 'enable' ? '2026-10-21T00:00:00Z' : null,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
meta: { total: 1 },
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
const view = await renderWithApp(<UsersPage />, {
|
|
212
|
+
route: '/settings/users',
|
|
213
|
+
namespace,
|
|
214
|
+
namespaces,
|
|
215
|
+
services,
|
|
216
|
+
fetch: answerApi(calls),
|
|
217
|
+
});
|
|
218
|
+
await userEvent.click(
|
|
219
|
+
await screen.findByRole('button', {
|
|
220
|
+
name: enUS.settings[action],
|
|
221
|
+
exact: true,
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
const dialog = await screen.findByRole('alertdialog');
|
|
225
|
+
await userEvent.click(
|
|
226
|
+
within(dialog).getByRole('button', {
|
|
227
|
+
name: enUS.settings[action],
|
|
228
|
+
exact: true,
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
await waitFor(() =>
|
|
232
|
+
expect(calls).toHaveBeenCalledWith(
|
|
233
|
+
expect.objectContaining({ path: `users/alice/${endpoint}` }),
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
expect(view.toasts()).toHaveLength(0);
|
|
237
|
+
expect(
|
|
238
|
+
within(dialog).getByRole('button', { name: enUS.settings.saving }),
|
|
239
|
+
).toBeDisabled();
|
|
240
|
+
expect(
|
|
241
|
+
within(dialog).getByRole('button', { name: enUS.settings.cancel }),
|
|
242
|
+
).toBeDisabled();
|
|
243
|
+
complete(
|
|
244
|
+
action === 'revokeSessions'
|
|
245
|
+
? { data: null }
|
|
246
|
+
: {
|
|
247
|
+
data: {
|
|
248
|
+
...userRow,
|
|
249
|
+
disabledAt: action === 'disable' ? '2026-10-21T00:00:00Z' : null,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
await waitFor(() =>
|
|
254
|
+
expect(view.toasts()).toMatchObject([
|
|
255
|
+
{
|
|
256
|
+
type: 'success',
|
|
257
|
+
title: enUS.settings.userActionSaved[action].replace(
|
|
258
|
+
'{{name}}',
|
|
259
|
+
'Alice',
|
|
260
|
+
),
|
|
261
|
+
},
|
|
262
|
+
]),
|
|
263
|
+
);
|
|
264
|
+
await waitFor(() =>
|
|
265
|
+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(),
|
|
266
|
+
);
|
|
267
|
+
expect(
|
|
268
|
+
calls.mock.calls.filter(
|
|
269
|
+
([call]) => call.path === `users/alice/${endpoint}`,
|
|
270
|
+
),
|
|
271
|
+
).toHaveLength(1);
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
it('keeps a failed account action in its dialog with no success toast', async () => {
|
|
276
|
+
const view = await renderWithApp(<UsersPage />, {
|
|
277
|
+
route: '/settings/users',
|
|
278
|
+
namespace,
|
|
279
|
+
namespaces,
|
|
280
|
+
services,
|
|
281
|
+
fetch: answerApi(({ path }) => {
|
|
282
|
+
if (path === 'authorization/permissions')
|
|
283
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
284
|
+
if (path === 'users/options') return options;
|
|
285
|
+
if (path === 'users/alice/disable')
|
|
286
|
+
return Response.json(
|
|
287
|
+
{ error: { code: 'FORBIDDEN', message: 'internal details' } },
|
|
288
|
+
{ status: 403 },
|
|
289
|
+
);
|
|
290
|
+
return { data: [userRow], meta: { total: 1 } };
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
await userEvent.click(
|
|
294
|
+
await screen.findByRole('button', { name: 'Disable', exact: true }),
|
|
295
|
+
);
|
|
296
|
+
const dialog = await screen.findByRole('alertdialog');
|
|
297
|
+
await userEvent.click(
|
|
298
|
+
within(dialog).getByRole('button', { name: 'Disable', exact: true }),
|
|
299
|
+
);
|
|
300
|
+
expect(await within(dialog).findByText(enUS.settings.denied)).toBeVisible();
|
|
301
|
+
expect(view.toasts()).toHaveLength(0);
|
|
302
|
+
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeEnabled();
|
|
303
|
+
expect(screen.queryByText('internal details')).not.toBeInTheDocument();
|
|
304
|
+
});
|
|
@@ -4,6 +4,10 @@ import {
|
|
|
4
4
|
AuthorizationClient,
|
|
5
5
|
authorizationClientToken,
|
|
6
6
|
} from '@nocobase/app-plugin-authorization/client';
|
|
7
|
+
import {
|
|
8
|
+
defineAppRoutes,
|
|
9
|
+
defineClientPlugin,
|
|
10
|
+
} from '@nocobase/app-client/plugins';
|
|
7
11
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
|
8
12
|
import userEvent from '@testing-library/user-event';
|
|
9
13
|
import { Routes, Route, useNavigate } from 'react-router';
|
|
@@ -48,7 +52,7 @@ describe('template system settings', () => {
|
|
|
48
52
|
services,
|
|
49
53
|
fetch: answerApi(calls),
|
|
50
54
|
});
|
|
51
|
-
expect(await screen.
|
|
55
|
+
expect(await screen.findByText('Alice', { exact: true })).toBeVisible();
|
|
52
56
|
expect(
|
|
53
57
|
screen.queryByRole('link', { name: 'New user' }),
|
|
54
58
|
).not.toBeInTheDocument();
|
|
@@ -197,8 +201,8 @@ describe('template system settings', () => {
|
|
|
197
201
|
data: {
|
|
198
202
|
sections: [
|
|
199
203
|
{
|
|
200
|
-
name: '
|
|
201
|
-
title: '
|
|
204
|
+
name: 'administration',
|
|
205
|
+
title: 'Business operations',
|
|
202
206
|
order: 0,
|
|
203
207
|
subsections: [
|
|
204
208
|
{
|
|
@@ -207,9 +211,9 @@ describe('template system settings', () => {
|
|
|
207
211
|
resources: [
|
|
208
212
|
{
|
|
209
213
|
type: 'settings',
|
|
210
|
-
id: '
|
|
211
|
-
title: '
|
|
212
|
-
actions: [{ name: 'read', title: 'Read
|
|
214
|
+
id: 'authorization.permission-sets',
|
|
215
|
+
title: 'Roles',
|
|
216
|
+
actions: [{ name: 'read', title: 'Read roles' }],
|
|
213
217
|
},
|
|
214
218
|
],
|
|
215
219
|
},
|
|
@@ -261,14 +265,32 @@ describe('template system settings', () => {
|
|
|
261
265
|
</Routes>,
|
|
262
266
|
{
|
|
263
267
|
route: '/settings/roles/sales/edit',
|
|
268
|
+
plugins: [
|
|
269
|
+
defineClientPlugin({
|
|
270
|
+
packageName: settingsNamespace,
|
|
271
|
+
routes: defineAppRoutes([
|
|
272
|
+
{
|
|
273
|
+
name: 'roles',
|
|
274
|
+
path: '/role-config',
|
|
275
|
+
componentLoader: async () => ({ default: () => null }),
|
|
276
|
+
authz: {
|
|
277
|
+
resource: {
|
|
278
|
+
type: 'settings',
|
|
279
|
+
id: 'authorization.permission-sets',
|
|
280
|
+
},
|
|
281
|
+
action: 'read',
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
]),
|
|
285
|
+
})(),
|
|
286
|
+
],
|
|
264
287
|
namespace: settingsNamespace,
|
|
265
288
|
namespaces,
|
|
266
289
|
services,
|
|
267
290
|
fetch: answerApi(calls),
|
|
268
291
|
},
|
|
269
292
|
);
|
|
270
|
-
|
|
271
|
-
const toggle = await screen.findByRole('checkbox', { name: 'Read files' });
|
|
293
|
+
const toggle = await screen.findByRole('checkbox', { name: 'Read roles' });
|
|
272
294
|
await waitFor(() =>
|
|
273
295
|
expect(toggle).not.toHaveAttribute('aria-disabled', 'true'),
|
|
274
296
|
);
|
|
@@ -283,7 +305,10 @@ describe('template system settings', () => {
|
|
|
283
305
|
expectedRevision: 'version-1',
|
|
284
306
|
grants: [
|
|
285
307
|
{
|
|
286
|
-
resource: {
|
|
308
|
+
resource: {
|
|
309
|
+
type: 'settings',
|
|
310
|
+
id: 'authorization.permission-sets',
|
|
311
|
+
},
|
|
287
312
|
actions: [{ action: 'read' }],
|
|
288
313
|
},
|
|
289
314
|
],
|