@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,526 @@
|
|
|
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 { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import { Routes, Route, useLocation } from 'react-router';
|
|
10
|
+
import { expect, it, vi } from 'vitest';
|
|
11
|
+
import UsersPage from '../../client/pages/settings/users.js';
|
|
12
|
+
import UserDetail from '../../client/pages/settings/user-detail.js';
|
|
13
|
+
import UserForm from '../../client/pages/settings/user-form.js';
|
|
14
|
+
import enUS from '../../client/locales/en-US.js';
|
|
15
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
16
|
+
|
|
17
|
+
const namespace = '@nocobase/user-drawer-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 original = {
|
|
30
|
+
id: 'alice',
|
|
31
|
+
name: 'Alice',
|
|
32
|
+
username: 'alice',
|
|
33
|
+
email: 'alice@example.test',
|
|
34
|
+
disabledAt: null,
|
|
35
|
+
roleScopes: {},
|
|
36
|
+
};
|
|
37
|
+
const search = '?q=Ali&status=enabled&page=2&pageSize=20&order=desc';
|
|
38
|
+
function Location() {
|
|
39
|
+
const location = useLocation();
|
|
40
|
+
return (
|
|
41
|
+
<output data-testid='location'>
|
|
42
|
+
{location.pathname}
|
|
43
|
+
{location.search}
|
|
44
|
+
</output>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
function Pages({ base }: { readonly base: string }) {
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
<Routes>
|
|
51
|
+
<Route path={base} element={<UsersPage />}>
|
|
52
|
+
<Route path='create' element={<UserForm />} />
|
|
53
|
+
<Route path=':userId' element={<UserDetail />}>
|
|
54
|
+
<Route path='edit' element={<UserForm />} />
|
|
55
|
+
</Route>
|
|
56
|
+
</Route>
|
|
57
|
+
<Route path='*' element={<h1>Fallback home</h1>} />
|
|
58
|
+
</Routes>
|
|
59
|
+
<Location />
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
async function renderUsers(
|
|
64
|
+
base: string,
|
|
65
|
+
child = '',
|
|
66
|
+
unrestricted = true,
|
|
67
|
+
withRole = false,
|
|
68
|
+
actions: string[] = [],
|
|
69
|
+
initialRoles: Record<string, string[]> = {},
|
|
70
|
+
requiredRole = false,
|
|
71
|
+
) {
|
|
72
|
+
let current = { ...original, roleScopes: initialRoles };
|
|
73
|
+
let roleFailure = false;
|
|
74
|
+
const calls = vi.fn(
|
|
75
|
+
({
|
|
76
|
+
path,
|
|
77
|
+
method,
|
|
78
|
+
json,
|
|
79
|
+
}: {
|
|
80
|
+
path: string;
|
|
81
|
+
method: string;
|
|
82
|
+
json?: {
|
|
83
|
+
name?: string;
|
|
84
|
+
username?: string;
|
|
85
|
+
email?: string;
|
|
86
|
+
roleScopes?: Record<string, string[]>;
|
|
87
|
+
};
|
|
88
|
+
}) => {
|
|
89
|
+
if (path === 'authorization/permissions')
|
|
90
|
+
return {
|
|
91
|
+
data: {
|
|
92
|
+
unrestricted,
|
|
93
|
+
permissions: actions.length
|
|
94
|
+
? [{ resource: { type: 'user', id: 'alice' }, actions }]
|
|
95
|
+
: [],
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
if (path === 'users/options')
|
|
99
|
+
return {
|
|
100
|
+
data: {
|
|
101
|
+
roleScopes: withRole
|
|
102
|
+
? [
|
|
103
|
+
{
|
|
104
|
+
key: 'app',
|
|
105
|
+
label: 'Application',
|
|
106
|
+
selection: 'multiple',
|
|
107
|
+
requiredOnCreate: requiredRole,
|
|
108
|
+
options: [
|
|
109
|
+
{ value: 'sales', label: 'Sales team' },
|
|
110
|
+
{ value: 'finance', label: 'Finance team' },
|
|
111
|
+
{
|
|
112
|
+
value: 'root',
|
|
113
|
+
label: 'System administrator',
|
|
114
|
+
assignable: false,
|
|
115
|
+
removable: false,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
]
|
|
120
|
+
: [],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
if (path === 'users/alice') {
|
|
124
|
+
if (method === 'PATCH') {
|
|
125
|
+
if (roleFailure)
|
|
126
|
+
return Response.json(
|
|
127
|
+
{
|
|
128
|
+
error: {
|
|
129
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
130
|
+
message: 'Unavailable',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{ status: 500 },
|
|
134
|
+
);
|
|
135
|
+
current = {
|
|
136
|
+
...current,
|
|
137
|
+
...json,
|
|
138
|
+
roleScopes: { ...current.roleScopes, ...json?.roleScopes },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { data: current };
|
|
142
|
+
}
|
|
143
|
+
return { data: [current], meta: { total: 41 } };
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
const view = await renderWithApp(<Pages base={base} />, {
|
|
147
|
+
route: `${base}${child}${search}`,
|
|
148
|
+
namespace,
|
|
149
|
+
namespaces,
|
|
150
|
+
services,
|
|
151
|
+
fetch: answerApi(calls),
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
...view,
|
|
155
|
+
calls,
|
|
156
|
+
failRole: () => {
|
|
157
|
+
roleFailure = true;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function selectSales(user: ReturnType<typeof userEvent.setup>) {
|
|
163
|
+
await user.click(
|
|
164
|
+
await screen.findByRole('combobox', { name: 'Application', exact: true }),
|
|
165
|
+
);
|
|
166
|
+
await user.click(
|
|
167
|
+
await screen.findByRole('option', { name: 'Sales team', exact: true }),
|
|
168
|
+
);
|
|
169
|
+
await user.keyboard('{Escape}');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it.each(['/users', '/settings/users'])(
|
|
173
|
+
'opens name editing in a drawer with inline account actions and cancels to the same list at %s',
|
|
174
|
+
async (base) => {
|
|
175
|
+
const user = userEvent.setup();
|
|
176
|
+
await renderUsers(base);
|
|
177
|
+
const edit = await screen.findByRole('link', {
|
|
178
|
+
name: 'Alice',
|
|
179
|
+
exact: true,
|
|
180
|
+
});
|
|
181
|
+
expect(edit).toHaveAttribute('href', `${base}/alice/edit${search}`);
|
|
182
|
+
expect(
|
|
183
|
+
screen.getByRole('button', { name: 'Disable', exact: true }),
|
|
184
|
+
).toBeVisible();
|
|
185
|
+
expect(
|
|
186
|
+
screen.getByRole('button', { name: 'Revoke sessions', exact: true }),
|
|
187
|
+
).toBeVisible();
|
|
188
|
+
expect(
|
|
189
|
+
screen.queryByRole('button', { name: 'Edit Alice' }),
|
|
190
|
+
).not.toBeInTheDocument();
|
|
191
|
+
expect(
|
|
192
|
+
screen.queryByRole('button', { name: 'Actions for Alice' }),
|
|
193
|
+
).not.toBeInTheDocument();
|
|
194
|
+
if (base === '/users') {
|
|
195
|
+
edit.focus();
|
|
196
|
+
await user.keyboard('{Enter}');
|
|
197
|
+
} else await user.click(edit);
|
|
198
|
+
const panel = await screen.findByRole('dialog', { name: 'Edit user' });
|
|
199
|
+
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
|
200
|
+
expect(panel).toHaveAttribute('data-presentation', 'drawer');
|
|
201
|
+
const name = await within(panel).findByRole('textbox', { name: 'Name *' });
|
|
202
|
+
await waitFor(() => expect(name).toHaveFocus());
|
|
203
|
+
await user.click(
|
|
204
|
+
within(panel).getByRole('button', { name: 'Cancel', exact: true }),
|
|
205
|
+
);
|
|
206
|
+
await waitFor(() =>
|
|
207
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
208
|
+
);
|
|
209
|
+
expect(screen.getByTestId('location')).toHaveTextContent(
|
|
210
|
+
`${base}${search}`,
|
|
211
|
+
);
|
|
212
|
+
await waitFor(() =>
|
|
213
|
+
expect(
|
|
214
|
+
screen.getByRole('textbox', { name: enUS.settings.userSearch }),
|
|
215
|
+
).toHaveFocus(),
|
|
216
|
+
);
|
|
217
|
+
expect(screen.queryByText('Fallback home')).not.toBeInTheDocument();
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
it.each(['/users', '/settings/users'])(
|
|
222
|
+
'validates and saves profile fields from the sole fixed-footer Save at %s',
|
|
223
|
+
async (base) => {
|
|
224
|
+
const user = userEvent.setup();
|
|
225
|
+
const view = await renderUsers(base, '/alice/edit');
|
|
226
|
+
const name = await screen.findByRole('textbox', { name: 'Name *' });
|
|
227
|
+
await waitFor(() => expect(name).toHaveValue('Alice'));
|
|
228
|
+
const save = screen.getByRole('button', { name: 'Save', exact: true });
|
|
229
|
+
expect(
|
|
230
|
+
save.closest('[data-slot="route-overlay-footer"]'),
|
|
231
|
+
).toBeInTheDocument();
|
|
232
|
+
await user.clear(name);
|
|
233
|
+
await user.click(save);
|
|
234
|
+
expect(await screen.findByText(enUS.settings.required)).toBeVisible();
|
|
235
|
+
expect(
|
|
236
|
+
view.calls.mock.calls.filter(([call]) => call.method === 'PATCH'),
|
|
237
|
+
).toHaveLength(0);
|
|
238
|
+
await user.type(name, 'Alice updated');
|
|
239
|
+
const form = name.closest('form')!;
|
|
240
|
+
expect(save).toHaveProperty('form', form);
|
|
241
|
+
fireEvent.submit(form);
|
|
242
|
+
await waitFor(() =>
|
|
243
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
244
|
+
);
|
|
245
|
+
expect(view.toasts()).toHaveLength(1);
|
|
246
|
+
expect(view.calls).toHaveBeenCalledWith(
|
|
247
|
+
expect.objectContaining({
|
|
248
|
+
path: 'users/alice',
|
|
249
|
+
method: 'PATCH',
|
|
250
|
+
json: {
|
|
251
|
+
name: 'Alice updated',
|
|
252
|
+
username: 'alice',
|
|
253
|
+
email: 'alice@example.test',
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
expect(
|
|
258
|
+
await screen.findByText('Alice updated', { exact: true }),
|
|
259
|
+
).toBeVisible();
|
|
260
|
+
expect(screen.getByTestId('location')).toHaveTextContent(
|
|
261
|
+
`${base}${search}`,
|
|
262
|
+
);
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
it('redirects legacy URLs and uses a searchable multi-select with one Save for both drafts', async () => {
|
|
267
|
+
const user = userEvent.setup();
|
|
268
|
+
const view = await renderUsers('/users', '/alice', true, true);
|
|
269
|
+
const name = await screen.findByRole('textbox', { name: 'Name *' });
|
|
270
|
+
await waitFor(() => expect(name).toHaveValue('Alice'));
|
|
271
|
+
expect(screen.getByTestId('location')).toHaveTextContent(
|
|
272
|
+
`/users/alice/edit${search}`,
|
|
273
|
+
);
|
|
274
|
+
expect(
|
|
275
|
+
screen.getAllByRole('button', { name: 'Save', exact: true }),
|
|
276
|
+
).toHaveLength(1);
|
|
277
|
+
await user.type(name, ' updated');
|
|
278
|
+
await user.click(
|
|
279
|
+
screen.getByRole('combobox', { name: 'Application', exact: true }),
|
|
280
|
+
);
|
|
281
|
+
const searchRoles = await screen.findByRole('combobox', {
|
|
282
|
+
name: enUS.settings.searchRoles,
|
|
283
|
+
});
|
|
284
|
+
await user.type(searchRoles, 'Sales');
|
|
285
|
+
expect(
|
|
286
|
+
await screen.findByRole('option', { name: 'Sales team' }),
|
|
287
|
+
).toBeVisible();
|
|
288
|
+
expect(
|
|
289
|
+
screen.queryByRole('option', { name: 'Finance team' }),
|
|
290
|
+
).not.toBeInTheDocument();
|
|
291
|
+
await user.click(screen.getByRole('option', { name: 'Sales team' }));
|
|
292
|
+
await user.keyboard('{Escape}');
|
|
293
|
+
expect(
|
|
294
|
+
screen.getByRole('combobox', { name: 'Application', exact: true }),
|
|
295
|
+
).toHaveTextContent('Sales team');
|
|
296
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
297
|
+
await waitFor(() =>
|
|
298
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
299
|
+
);
|
|
300
|
+
expect(view.calls).toHaveBeenCalledWith(
|
|
301
|
+
expect.objectContaining({
|
|
302
|
+
path: 'users/alice',
|
|
303
|
+
method: 'PATCH',
|
|
304
|
+
json: {
|
|
305
|
+
name: 'Alice updated',
|
|
306
|
+
username: 'alice',
|
|
307
|
+
email: 'alice@example.test',
|
|
308
|
+
roleScopes: { app: ['sales'] },
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
expect(
|
|
313
|
+
view.calls.mock.calls.filter(([call]) =>
|
|
314
|
+
['PATCH', 'PUT'].includes(call.method),
|
|
315
|
+
),
|
|
316
|
+
).toHaveLength(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('retains both drafts on failed unified save and keeps one discard guard', async () => {
|
|
320
|
+
const user = userEvent.setup();
|
|
321
|
+
const view = await renderUsers('/users', '/alice/edit', true, true);
|
|
322
|
+
view.failRole();
|
|
323
|
+
const name = await screen.findByRole('textbox', { name: 'Name *' });
|
|
324
|
+
await waitFor(() => expect(name).toHaveValue('Alice'));
|
|
325
|
+
await user.type(name, ' draft');
|
|
326
|
+
await selectSales(user);
|
|
327
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
328
|
+
expect(await screen.findByText(enUS.settings.failed)).toBeVisible();
|
|
329
|
+
expect(name).toHaveValue('Alice draft');
|
|
330
|
+
expect(
|
|
331
|
+
screen.getByRole('combobox', { name: 'Application', exact: true }),
|
|
332
|
+
).toHaveTextContent('Sales team');
|
|
333
|
+
expect(view.toasts()).toHaveLength(0);
|
|
334
|
+
await user.click(screen.getByRole('button', { name: 'Close', exact: true }));
|
|
335
|
+
const confirm = await screen.findByRole('alertdialog');
|
|
336
|
+
await user.click(
|
|
337
|
+
within(confirm).getByRole('button', { name: 'Cancel', exact: true }),
|
|
338
|
+
);
|
|
339
|
+
expect(name).toHaveValue('Alice draft');
|
|
340
|
+
await user.click(screen.getByRole('button', { name: 'Close', exact: true }));
|
|
341
|
+
await user.click(
|
|
342
|
+
within(await screen.findByRole('alertdialog')).getByRole('button', {
|
|
343
|
+
name: 'Confirm',
|
|
344
|
+
exact: true,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
await waitFor(() =>
|
|
348
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
349
|
+
);
|
|
350
|
+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('keeps names as plain text and hides account actions for read-only users', async () => {
|
|
354
|
+
await renderUsers('/users', '', false, true, ['read']);
|
|
355
|
+
expect(await screen.findByText('Alice', { exact: true })).toBeVisible();
|
|
356
|
+
expect(
|
|
357
|
+
screen.queryByRole('link', { name: 'Alice', exact: true }),
|
|
358
|
+
).not.toBeInTheDocument();
|
|
359
|
+
for (const name of ['Disable', 'Enable', 'Revoke sessions'])
|
|
360
|
+
expect(
|
|
361
|
+
screen.queryByRole('button', { name, exact: true }),
|
|
362
|
+
).not.toBeInTheDocument();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('keeps the editor read-only without management permission', async () => {
|
|
366
|
+
await renderUsers('/users', '/alice/edit', false, true);
|
|
367
|
+
expect(await screen.findByRole('textbox', { name: 'Name *' })).toBeDisabled();
|
|
368
|
+
expect(
|
|
369
|
+
await screen.findByRole('combobox', { name: 'Application', exact: true }),
|
|
370
|
+
).toBeDisabled();
|
|
371
|
+
expect(
|
|
372
|
+
screen.queryByRole('button', { name: 'Save', exact: true }),
|
|
373
|
+
).not.toBeInTheDocument();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('lets role-only managers save only roleScopes and prevents protected role selection', async () => {
|
|
377
|
+
const user = userEvent.setup();
|
|
378
|
+
const view = await renderUsers('/users', '', false, true, [
|
|
379
|
+
'read',
|
|
380
|
+
'assign-role',
|
|
381
|
+
]);
|
|
382
|
+
const edit = await screen.findByRole('link', {
|
|
383
|
+
name: 'Alice',
|
|
384
|
+
exact: true,
|
|
385
|
+
});
|
|
386
|
+
expect(
|
|
387
|
+
screen.queryByRole('button', { name: 'Actions for Alice' }),
|
|
388
|
+
).not.toBeInTheDocument();
|
|
389
|
+
await user.click(edit);
|
|
390
|
+
expect(await screen.findByRole('textbox', { name: 'Name *' })).toBeDisabled();
|
|
391
|
+
await user.click(
|
|
392
|
+
await screen.findByRole('combobox', { name: 'Application', exact: true }),
|
|
393
|
+
);
|
|
394
|
+
expect(
|
|
395
|
+
await screen.findByRole('option', { name: 'System administrator' }),
|
|
396
|
+
).toHaveAttribute('aria-disabled', 'true');
|
|
397
|
+
await user.click(screen.getByRole('option', { name: 'Sales team' }));
|
|
398
|
+
await user.keyboard('{Escape}');
|
|
399
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
400
|
+
await waitFor(() =>
|
|
401
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
402
|
+
);
|
|
403
|
+
expect(view.calls).toHaveBeenCalledWith(
|
|
404
|
+
expect.objectContaining({
|
|
405
|
+
method: 'PATCH',
|
|
406
|
+
json: { roleScopes: { app: ['sales'] } },
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('blocks closing and duplicate submission until the unified save completes', async () => {
|
|
412
|
+
const user = userEvent.setup();
|
|
413
|
+
let current = { ...original };
|
|
414
|
+
let complete!: (value: unknown) => void;
|
|
415
|
+
const pending = new Promise((resolve) => {
|
|
416
|
+
complete = resolve;
|
|
417
|
+
});
|
|
418
|
+
const calls = vi.fn(({ path, method }: { path: string; method: string }) => {
|
|
419
|
+
if (path === 'authorization/permissions')
|
|
420
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
421
|
+
if (path === 'users/options') return { data: { roleScopes: [] } };
|
|
422
|
+
if (path === 'users/alice' && method === 'PATCH') return pending;
|
|
423
|
+
if (path === 'users/alice') return { data: current };
|
|
424
|
+
return { data: [current], meta: { total: 41 } };
|
|
425
|
+
});
|
|
426
|
+
const view = await renderWithApp(<Pages base='/users' />, {
|
|
427
|
+
route: `/users/alice/edit${search}`,
|
|
428
|
+
namespace,
|
|
429
|
+
namespaces,
|
|
430
|
+
services,
|
|
431
|
+
fetch: answerApi(calls),
|
|
432
|
+
});
|
|
433
|
+
const name = await screen.findByRole('textbox', { name: 'Name *' });
|
|
434
|
+
await waitFor(() => expect(name).toHaveValue('Alice'));
|
|
435
|
+
await user.type(name, ' changed');
|
|
436
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
437
|
+
await waitFor(() => expect(name).toBeDisabled());
|
|
438
|
+
expect(
|
|
439
|
+
screen.getByRole('button', { name: enUS.settings.saving }),
|
|
440
|
+
).toBeDisabled();
|
|
441
|
+
expect(
|
|
442
|
+
screen.getByRole('button', { name: 'Cancel', exact: true }),
|
|
443
|
+
).toBeDisabled();
|
|
444
|
+
await user.click(screen.getByRole('button', { name: 'Close', exact: true }));
|
|
445
|
+
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
|
446
|
+
expect(view.toasts()).toHaveLength(0);
|
|
447
|
+
current = { ...original, name: 'Alice changed' };
|
|
448
|
+
complete({ data: current });
|
|
449
|
+
await waitFor(() =>
|
|
450
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
451
|
+
);
|
|
452
|
+
expect(
|
|
453
|
+
calls.mock.calls.filter(([call]) => call.method === 'PATCH'),
|
|
454
|
+
).toHaveLength(1);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('preserves protected and unknown assignments and confirms actual removals before the one Save', async () => {
|
|
458
|
+
const user = userEvent.setup();
|
|
459
|
+
const view = await renderUsers('/users', '/alice/edit', true, true, [], {
|
|
460
|
+
app: ['root', 'sales', 'legacy-role'],
|
|
461
|
+
external: ['retained'],
|
|
462
|
+
});
|
|
463
|
+
const picker = await screen.findByRole('combobox', {
|
|
464
|
+
name: 'Application',
|
|
465
|
+
exact: true,
|
|
466
|
+
});
|
|
467
|
+
expect(picker).toHaveTextContent('legacy-role');
|
|
468
|
+
await user.click(picker);
|
|
469
|
+
expect(
|
|
470
|
+
await screen.findByRole('option', { name: 'System administrator' }),
|
|
471
|
+
).toHaveAttribute('aria-disabled', 'true');
|
|
472
|
+
expect(screen.getByRole('option', { name: 'legacy-role' })).toHaveAttribute(
|
|
473
|
+
'aria-selected',
|
|
474
|
+
'true',
|
|
475
|
+
);
|
|
476
|
+
await user.click(screen.getByRole('option', { name: 'Sales team' }));
|
|
477
|
+
await user.keyboard('{Escape}');
|
|
478
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
479
|
+
const confirm = await screen.findByRole('alertdialog');
|
|
480
|
+
expect(confirm).toHaveTextContent('Sales team');
|
|
481
|
+
expect(
|
|
482
|
+
view.calls.mock.calls.filter(([call]) => call.method === 'PATCH'),
|
|
483
|
+
).toHaveLength(0);
|
|
484
|
+
await user.click(
|
|
485
|
+
within(confirm).getByRole('button', { name: 'Save', exact: true }),
|
|
486
|
+
);
|
|
487
|
+
await waitFor(() =>
|
|
488
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
489
|
+
);
|
|
490
|
+
expect(view.calls).toHaveBeenCalledWith(
|
|
491
|
+
expect.objectContaining({
|
|
492
|
+
method: 'PATCH',
|
|
493
|
+
json: expect.objectContaining({
|
|
494
|
+
roleScopes: { app: ['root', 'legacy-role'] },
|
|
495
|
+
}),
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('focuses and describes the required role dropdown when creating without a role', async () => {
|
|
501
|
+
const user = userEvent.setup();
|
|
502
|
+
const view = await renderUsers('/users', '/create', true, true, [], {}, true);
|
|
503
|
+
await user.type(
|
|
504
|
+
await screen.findByRole('textbox', { name: 'Name *' }),
|
|
505
|
+
'New user',
|
|
506
|
+
);
|
|
507
|
+
await user.type(
|
|
508
|
+
screen.getByRole('textbox', { name: 'Email *' }),
|
|
509
|
+
'new@example.test',
|
|
510
|
+
);
|
|
511
|
+
await user.type(
|
|
512
|
+
screen.getByLabelText(`${enUS.settings.password} *`),
|
|
513
|
+
'long-enough-password',
|
|
514
|
+
);
|
|
515
|
+
await user.click(screen.getByRole('button', { name: 'Create', exact: true }));
|
|
516
|
+
const roles = screen.getByRole('combobox', {
|
|
517
|
+
name: 'Application',
|
|
518
|
+
exact: true,
|
|
519
|
+
});
|
|
520
|
+
await waitFor(() => expect(roles).toHaveFocus());
|
|
521
|
+
expect(roles).toHaveAttribute('aria-invalid', 'true');
|
|
522
|
+
expect(roles).toHaveAccessibleDescription(enUS.settings.required);
|
|
523
|
+
expect(
|
|
524
|
+
view.calls.mock.calls.filter(([call]) => call.method === 'POST'),
|
|
525
|
+
).toHaveLength(0);
|
|
526
|
+
});
|
|
@@ -5,6 +5,10 @@ import {
|
|
|
5
5
|
authorizationClientToken,
|
|
6
6
|
type PermissionGrant,
|
|
7
7
|
} from '@nocobase/app-plugin-authorization/client';
|
|
8
|
+
import {
|
|
9
|
+
defineAppRoutes,
|
|
10
|
+
defineClientPlugin,
|
|
11
|
+
} from '@nocobase/app-client/plugins';
|
|
8
12
|
import { screen, waitFor } from '@testing-library/react';
|
|
9
13
|
import userEvent from '@testing-library/user-event';
|
|
10
14
|
import { Routes, Route } from 'react-router';
|
|
@@ -35,7 +39,11 @@ describe('user permission configuration', () => {
|
|
|
35
39
|
grants: [
|
|
36
40
|
{
|
|
37
41
|
resource: { type: 'user', id: '*' },
|
|
38
|
-
actions: [
|
|
42
|
+
actions: [
|
|
43
|
+
{ action: 'read' },
|
|
44
|
+
{ action: 'disable' },
|
|
45
|
+
{ action: 'invite' },
|
|
46
|
+
],
|
|
39
47
|
},
|
|
40
48
|
] as readonly PermissionGrant[],
|
|
41
49
|
};
|
|
@@ -83,6 +91,22 @@ describe('user permission configuration', () => {
|
|
|
83
91
|
namespace,
|
|
84
92
|
namespaces,
|
|
85
93
|
locale,
|
|
94
|
+
plugins: [
|
|
95
|
+
defineClientPlugin({
|
|
96
|
+
packageName: namespace,
|
|
97
|
+
routes: defineAppRoutes([
|
|
98
|
+
{
|
|
99
|
+
name: 'users',
|
|
100
|
+
path: '/users',
|
|
101
|
+
authz: {
|
|
102
|
+
resource: { type: 'user', id: '*' },
|
|
103
|
+
action: 'read',
|
|
104
|
+
},
|
|
105
|
+
componentLoader: async () => ({ default: () => null }),
|
|
106
|
+
},
|
|
107
|
+
]),
|
|
108
|
+
})(),
|
|
109
|
+
],
|
|
86
110
|
services: (app: ClientApplication) =>
|
|
87
111
|
app.container.instance(
|
|
88
112
|
authorizationClientToken,
|
|
@@ -93,17 +117,17 @@ describe('user permission configuration', () => {
|
|
|
93
117
|
);
|
|
94
118
|
const first = await mount();
|
|
95
119
|
const labels = [
|
|
96
|
-
words.
|
|
97
|
-
words.
|
|
98
|
-
words.
|
|
120
|
+
words.createUsers,
|
|
121
|
+
words.updateUsers,
|
|
122
|
+
words.revokeSessions,
|
|
99
123
|
];
|
|
100
124
|
for (const label of labels) {
|
|
101
|
-
const
|
|
125
|
+
const toggle = await screen.findByRole('checkbox', { name: label });
|
|
102
126
|
await waitFor(() =>
|
|
103
|
-
expect(
|
|
127
|
+
expect(toggle).not.toHaveAttribute('aria-disabled', 'true'),
|
|
104
128
|
);
|
|
105
|
-
expect(
|
|
106
|
-
await user.click(
|
|
129
|
+
expect(toggle).not.toBeChecked();
|
|
130
|
+
await user.click(toggle);
|
|
107
131
|
}
|
|
108
132
|
expect(
|
|
109
133
|
screen.getByRole('heading', { name: words.userInformation }),
|
|
@@ -114,9 +138,12 @@ describe('user permission configuration', () => {
|
|
|
114
138
|
expect(
|
|
115
139
|
screen.getByRole('heading', { name: words.roleAssignment }),
|
|
116
140
|
).toBeVisible();
|
|
117
|
-
expect(screen.getAllByRole('checkbox')).toHaveLength(
|
|
141
|
+
expect(screen.getAllByRole('checkbox')).toHaveLength(8);
|
|
142
|
+
expect(
|
|
143
|
+
screen.queryByRole('checkbox', { name: words.inviteUsers }),
|
|
144
|
+
).not.toBeInTheDocument();
|
|
118
145
|
const rowIds = screen.getAllByRole('checkbox').map((node) => node.id);
|
|
119
|
-
expect(new Set(rowIds).size).toBe(
|
|
146
|
+
expect(new Set(rowIds).size).toBe(8);
|
|
120
147
|
await user.click(
|
|
121
148
|
screen.getByRole('button', { name: words.save, exact: true }),
|
|
122
149
|
);
|
|
@@ -128,7 +155,14 @@ describe('user permission configuration', () => {
|
|
|
128
155
|
}),
|
|
129
156
|
),
|
|
130
157
|
);
|
|
131
|
-
const all = [
|
|
158
|
+
const all = [
|
|
159
|
+
'read',
|
|
160
|
+
'disable',
|
|
161
|
+
'invite',
|
|
162
|
+
'create',
|
|
163
|
+
'update',
|
|
164
|
+
'revoke-sessions',
|
|
165
|
+
];
|
|
132
166
|
expect(role.grants[0]?.actions.map((action) => action.action)).toEqual(
|
|
133
167
|
all,
|
|
134
168
|
);
|
|
@@ -139,14 +173,14 @@ describe('user permission configuration', () => {
|
|
|
139
173
|
await screen.findByRole('checkbox', { name: label }),
|
|
140
174
|
).toBeChecked();
|
|
141
175
|
await user.click(
|
|
142
|
-
screen.getByRole('checkbox', { name: words.
|
|
176
|
+
screen.getByRole('checkbox', { name: words.updateUsers }),
|
|
143
177
|
);
|
|
144
178
|
await user.click(
|
|
145
179
|
screen.getByRole('button', { name: words.save, exact: true }),
|
|
146
180
|
);
|
|
147
181
|
await waitFor(() =>
|
|
148
182
|
expect(role.grants[0]?.actions.map((action) => action.action)).toEqual(
|
|
149
|
-
all.filter((action) => action !== '
|
|
183
|
+
all.filter((action) => action !== 'update'),
|
|
150
184
|
),
|
|
151
185
|
);
|
|
152
186
|
},
|
|
@@ -167,7 +167,9 @@ describe('application config', () => {
|
|
|
167
167
|
env: { APP_BASE_PATH: '/crm/' },
|
|
168
168
|
});
|
|
169
169
|
expect(mounted.config.get('session.cookie.path')).toBe('/crm');
|
|
170
|
-
expect(mounted.config.get('session.cookie.name')).toBe(
|
|
170
|
+
expect(mounted.config.get('session.cookie.name')).toBe(
|
|
171
|
+
'nocobase_session_%2Fcrm',
|
|
172
|
+
);
|
|
171
173
|
});
|
|
172
174
|
|
|
173
175
|
it('loads only explicit env overrides and restores defaults on reload', async () => {
|