@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.
Files changed (61) 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/default-access-editor.tsx +79 -52
  7. package/client/components/flow-diagram.tsx +645 -83
  8. package/client/components/flow-layout.ts +117 -7
  9. package/client/components/lifecycle-diagram.tsx +175 -37
  10. package/client/components/long-text.tsx +200 -0
  11. package/client/components/permission-editor.tsx +534 -341
  12. package/client/components/record-link.tsx +20 -0
  13. package/client/components/required-role-setup.tsx +93 -0
  14. package/client/components/role-list.tsx +21 -5
  15. package/client/components/setup-readiness-state.ts +135 -0
  16. package/client/components/setup-readiness.tsx +91 -0
  17. package/client/components/ui/combobox.tsx +300 -0
  18. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  19. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  20. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  21. package/client/hooks/use-table-url-state.ts +93 -0
  22. package/client/lib/required-role-setup.ts +98 -0
  23. package/client/lib/return-to.ts +19 -0
  24. package/client/locales/system/en-US.ts +10 -0
  25. package/client/locales/system/settings-en-US.ts +51 -4
  26. package/client/locales/system/settings-zh-CN.ts +46 -5
  27. package/client/locales/system/zh-CN.ts +10 -0
  28. package/client/pages/settings/default-access-form.tsx +14 -2
  29. package/client/pages/settings/default-access-model.ts +6 -2
  30. package/client/pages/settings/default-access.tsx +40 -8
  31. package/client/pages/settings/page-permissions.ts +166 -1
  32. package/client/pages/settings/permission-filters.tsx +9 -2
  33. package/client/pages/settings/permission-workspace.ts +330 -0
  34. package/client/pages/settings/permissions-model.ts +62 -53
  35. package/client/pages/settings/role-form.tsx +31 -95
  36. package/client/pages/settings/roles.tsx +44 -6
  37. package/client/pages/settings/shared.tsx +15 -13
  38. package/client/pages/settings/user-detail.tsx +15 -259
  39. package/client/pages/settings/user-form.tsx +299 -147
  40. package/client/pages/settings/users.tsx +211 -123
  41. package/config.example.yml +6 -5
  42. package/package.json +17 -17
  43. package/server/config/app.ts +1 -1
  44. package/server/config/session.ts +7 -5
  45. package/tests/components/default-access-editor.test.tsx +130 -0
  46. package/tests/components/default-access.test.tsx +141 -2
  47. package/tests/components/permission-editor.test.tsx +613 -572
  48. package/tests/components/permission-filter-binding.test.tsx +20 -0
  49. package/tests/components/settings-continuity.test.tsx +426 -0
  50. package/tests/components/settings-feedback.test.tsx +304 -0
  51. package/tests/components/settings.test.tsx +34 -9
  52. package/tests/components/setup-readiness.test.tsx +370 -0
  53. package/tests/components/table-url-state.test.tsx +95 -0
  54. package/tests/components/user-drawer.test.tsx +526 -0
  55. package/tests/components/user-permissions.test.tsx +47 -13
  56. package/tests/logic/config.test.ts +3 -1
  57. package/tests/logic/page-permission-navigation.test.ts +394 -0
  58. package/tests/logic/permission-workspace.test.ts +570 -0
  59. package/tests/logic/return-to.test.ts +19 -0
  60. package/tests/logic/session-isolation.test.ts +250 -0
  61. 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: [{ action: 'read' }, { action: 'disable' }],
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.inviteUsers,
97
- words.deleteUsers,
98
- words.resetUserPassword,
120
+ words.createUsers,
121
+ words.updateUsers,
122
+ words.revokeSessions,
99
123
  ];
100
124
  for (const label of labels) {
101
- const checkbox = await screen.findByRole('checkbox', { name: label });
125
+ const toggle = await screen.findByRole('checkbox', { name: label });
102
126
  await waitFor(() =>
103
- expect(checkbox).not.toHaveAttribute('aria-disabled', 'true'),
127
+ expect(toggle).not.toHaveAttribute('aria-disabled', 'true'),
104
128
  );
105
- expect(checkbox).not.toBeChecked();
106
- await user.click(checkbox);
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(10);
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(10);
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 = ['read', 'disable', 'invite', 'delete', 'reset-password'];
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.deleteUsers }),
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 !== 'delete'),
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('nocobase_session');
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 () => {