@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,370 @@
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 } from '@testing-library/react';
8
+ import userEvent from '@testing-library/user-event';
9
+ import { it, expect, vi } from 'vitest';
10
+ import { Link, Route, Routes } from 'react-router';
11
+ import { useState } from 'react';
12
+ import { RequiredRoleSetup } from '../../client/components/required-role-setup.js';
13
+ import {
14
+ inspectRequiredRoles,
15
+ requiredRoleSetupId,
16
+ requiredRoleSetupLink,
17
+ } from '../../client/lib/required-role-setup.js';
18
+ import enUS from '../../client/locales/en-US.js';
19
+ import zhCN from '../../client/locales/zh-CN.js';
20
+
21
+ const namespace = '@nocobase/setup-test';
22
+ const namespaces = {
23
+ [namespace]: {
24
+ 'en-US': async () => ({ default: enUS }),
25
+ 'zh-CN': async () => ({ default: zhCN }),
26
+ },
27
+ };
28
+ const services = (app: ClientApplication) =>
29
+ app.container.instance(
30
+ authorizationClientToken,
31
+ new AuthorizationClient(app.container.resolve(apiClientToken)),
32
+ );
33
+ const role = {
34
+ scope: 'app',
35
+ role: 'finance',
36
+ title: 'Finance reviewer',
37
+ description: 'Assign an enabled reviewer before finance review can run.',
38
+ };
39
+ const roles = [role];
40
+ const registered = {
41
+ roleScopes: [
42
+ {
43
+ key: 'app',
44
+ label: 'App roles',
45
+ selection: 'multiple' as const,
46
+ hasAuthenticatedDefaultAccess: true,
47
+ requiredOnCreate: false,
48
+ options: [{ value: 'finance', label: 'Finance' }],
49
+ },
50
+ ],
51
+ };
52
+ const id = requiredRoleSetupId(role);
53
+
54
+ it('reads only enabled members in the exact registered scope and deduplicates required roles', async () => {
55
+ const client = {
56
+ options: vi.fn().mockResolvedValue(registered),
57
+ list: vi.fn().mockResolvedValue({ total: 0 }),
58
+ };
59
+ expect(await inspectRequiredRoles(client, [role, role])).toEqual({
60
+ [id]: { status: 'missing', canConfigure: true },
61
+ });
62
+ expect(client.options).toHaveBeenCalledTimes(1);
63
+ expect(client.list).toHaveBeenCalledTimes(1);
64
+ expect(client.list).toHaveBeenCalledWith({
65
+ roleScope: 'app',
66
+ role: 'finance',
67
+ status: 'enabled',
68
+ page: 1,
69
+ pageSize: 1,
70
+ });
71
+ client.list.mockResolvedValue({ total: 1 });
72
+ expect((await inspectRequiredRoles(client, roles))[id]?.status).toBe('ready');
73
+ });
74
+
75
+ it.each(['unknown-role', 'unknown-scope', 'default-only-role'])(
76
+ 'does not misreport %s as missing membership',
77
+ async (kind) => {
78
+ const requirement =
79
+ kind === 'unknown-role'
80
+ ? { ...role, role: 'deleted' }
81
+ : kind === 'unknown-scope'
82
+ ? { ...role, scope: 'deleted' }
83
+ : { ...role, role: 'authenticated-member' };
84
+ const client = {
85
+ options: vi.fn().mockResolvedValue({
86
+ roleScopes: registered.roleScopes.map((scope) => ({
87
+ ...scope,
88
+ hasAuthenticatedDefaultAccess: true,
89
+ })),
90
+ }),
91
+ list: vi.fn(),
92
+ };
93
+ expect(
94
+ (await inspectRequiredRoles(client, [requirement]))[
95
+ requiredRoleSetupId(requirement)
96
+ ],
97
+ ).toEqual({ status: 'unknown', reason: 'invalid' });
98
+ expect(client.list).not.toHaveBeenCalled();
99
+ },
100
+ );
101
+
102
+ it.each([undefined, -1, Number.NaN])(
103
+ 'treats malformed totals %s as unknown',
104
+ async (total) => {
105
+ const client = {
106
+ options: vi.fn().mockResolvedValue(registered),
107
+ list: vi.fn().mockResolvedValue({ total }),
108
+ };
109
+ expect((await inspectRequiredRoles(client, roles))[id]?.status).toBe(
110
+ 'unknown',
111
+ );
112
+ },
113
+ );
114
+
115
+ it('keeps partial inspection success and cannot configure a non-assignable missing role', async () => {
116
+ const other = { ...role, role: 'manager' };
117
+ const client = {
118
+ options: vi.fn().mockResolvedValue({
119
+ roleScopes: [
120
+ {
121
+ ...registered.roleScopes[0],
122
+ options: [
123
+ { value: 'finance', label: 'Finance', assignable: false },
124
+ { value: 'manager', label: 'Manager' },
125
+ ],
126
+ },
127
+ ],
128
+ }),
129
+ list: vi
130
+ .fn()
131
+ .mockImplementation(({ role: key }: { role: string }) =>
132
+ key === 'finance'
133
+ ? Promise.resolve({ total: 0 })
134
+ : Promise.reject({ status: 403, message: 'private-server-detail' }),
135
+ ),
136
+ };
137
+ expect(await inspectRequiredRoles(client, [role, other])).toEqual({
138
+ [id]: { status: 'missing', canConfigure: false },
139
+ [requiredRoleSetupId(other)]: { status: 'unknown', reason: 'forbidden' },
140
+ });
141
+ });
142
+
143
+ it('does not read metadata for an empty declaration and validates the return destination', async () => {
144
+ const client = { options: vi.fn(), list: vi.fn() };
145
+ expect(await inspectRequiredRoles(client, [])).toEqual({});
146
+ expect(client.options).not.toHaveBeenCalled();
147
+ const url = new URL(
148
+ requiredRoleSetupLink(role, '/processes/expense?tab=definition#review'),
149
+ 'https://app.test',
150
+ );
151
+ expect(Object.fromEntries(url.searchParams)).toEqual({
152
+ roleScope: 'app',
153
+ role: 'finance',
154
+ status: 'enabled',
155
+ returnTo: '/processes/expense?tab=definition#review',
156
+ });
157
+ expect(requiredRoleSetupLink(role, '//outside.test')).not.toContain(
158
+ 'returnTo',
159
+ );
160
+ });
161
+
162
+ it('checks real API-client results, links to scoped management and rechecks on return and explicit refresh', async () => {
163
+ let total = 0;
164
+ const calls = vi.fn(
165
+ ({ path, query }: { path: string; query?: Record<string, string> }) => {
166
+ if (path === 'authorization/permissions')
167
+ return { data: { unrestricted: true, permissions: [] } };
168
+ if (path === 'users/options') return { data: registered };
169
+ if (path === 'users') {
170
+ expect(query).toMatchObject({
171
+ status: 'enabled',
172
+ roleScope: 'app',
173
+ role: 'finance',
174
+ pageSize: '1',
175
+ });
176
+ return { data: [], meta: { total, page: 1, pageSize: 1 } };
177
+ }
178
+ throw new Error(`Unexpected request: ${path}`);
179
+ },
180
+ );
181
+ await renderWithApp(
182
+ <Routes>
183
+ <Route path='/process' element={<RequiredRoleSetup roles={roles} />} />
184
+ <Route
185
+ path='/settings/users'
186
+ element={
187
+ <Link to='/process?name=travel#review'>Return to workflow</Link>
188
+ }
189
+ />
190
+ </Routes>,
191
+ {
192
+ route: '/process?name=travel#review',
193
+ namespace,
194
+ namespaces,
195
+ services,
196
+ fetch: answerApi(calls),
197
+ },
198
+ );
199
+ const action = await screen.findByRole('link', {
200
+ name: 'Manage Finance reviewer members',
201
+ });
202
+ expect(action).toHaveAttribute(
203
+ 'href',
204
+ requiredRoleSetupLink(role, '/process?name=travel#review'),
205
+ );
206
+ await userEvent.click(action);
207
+ total = 1;
208
+ const beforeReturn = calls.mock.calls.filter(
209
+ ([request]) => request.path === 'users',
210
+ ).length;
211
+ await userEvent.click(
212
+ screen.getByRole('link', { name: 'Return to workflow' }),
213
+ );
214
+ await waitFor(() => {
215
+ expect(
216
+ calls.mock.calls.filter(([request]) => request.path === 'users').length,
217
+ ).toBeGreaterThan(beforeReturn);
218
+ expect(screen.queryByRole('region')).not.toBeInTheDocument();
219
+ });
220
+ total = 0;
221
+ fireEvent(window, new Event('focus'));
222
+ await screen.findByRole('link', { name: 'Manage Finance reviewer members' });
223
+ total = 1;
224
+ const refresh = screen.getByRole('button', { name: 'Check again' });
225
+ await userEvent.click(refresh);
226
+ expect(
227
+ await screen.findByText('The checked prerequisites are ready.'),
228
+ ).toBeVisible();
229
+ expect(screen.getByRole('button', { name: 'Check again' })).toBe(refresh);
230
+ expect(
231
+ calls.mock.calls.every(
232
+ ([request]) => !('method' in request) || request.method === 'GET',
233
+ ),
234
+ ).toBe(true);
235
+ });
236
+
237
+ it.each([false, true])(
238
+ 'never requests member data when access is %s or a declaration is absent',
239
+ async (empty) => {
240
+ const calls = vi.fn(({ path }: { path: string }) =>
241
+ path === 'authorization/permissions'
242
+ ? { data: { unrestricted: false, permissions: [] } }
243
+ : (() => {
244
+ throw new Error('No member reads permitted');
245
+ })(),
246
+ );
247
+ const view = await renderWithApp(
248
+ <RequiredRoleSetup roles={empty ? [] : roles} />,
249
+ { namespace, namespaces, services, fetch: answerApi(calls) },
250
+ );
251
+ await new Promise((resolve) => setTimeout(resolve, 20));
252
+ expect(view.container).toBeEmptyDOMElement();
253
+ expect(
254
+ calls.mock.calls.some(([request]) => request.path.startsWith('users')),
255
+ ).toBe(false);
256
+ },
257
+ );
258
+
259
+ it('allows read-only inspection without offering role assignment', async () => {
260
+ await renderWithApp(<RequiredRoleSetup roles={roles} />, {
261
+ namespace,
262
+ namespaces,
263
+ services,
264
+ fetch: answerApi(({ path }) =>
265
+ path === 'authorization/permissions'
266
+ ? {
267
+ data: {
268
+ unrestricted: false,
269
+ permissions: [
270
+ { resource: { type: 'user', id: '*' }, actions: ['read'] },
271
+ ],
272
+ },
273
+ }
274
+ : path === 'users/options'
275
+ ? { data: registered }
276
+ : { data: [], meta: { total: 0 } },
277
+ ),
278
+ });
279
+ expect(await screen.findByText(role.description)).toBeVisible();
280
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
281
+ });
282
+
283
+ it('uses short action copy for long role labels and rechecks the mounted feature after a successful inline save', async () => {
284
+ let total = 0;
285
+ const longRole = {
286
+ ...role,
287
+ title: 'Department finance reviewer '.repeat(12),
288
+ };
289
+ const requirements = [longRole];
290
+ function Feature() {
291
+ const [revision, setRevision] = useState(0);
292
+ return (
293
+ <>
294
+ <button
295
+ onClick={() => {
296
+ total = 1;
297
+ setRevision((value) => value + 1);
298
+ }}
299
+ >
300
+ Save configuration fixture
301
+ </button>
302
+ <RequiredRoleSetup roles={requirements} refreshKey={String(revision)} />
303
+ </>
304
+ );
305
+ }
306
+ await renderWithApp(<Feature />, {
307
+ namespace,
308
+ namespaces,
309
+ services,
310
+ fetch: answerApi(({ path }) =>
311
+ path === 'authorization/permissions'
312
+ ? { data: { unrestricted: true, permissions: [] } }
313
+ : path === 'users/options'
314
+ ? { data: registered }
315
+ : { data: [], meta: { total } },
316
+ ),
317
+ });
318
+ const link = await screen.findByRole('link');
319
+ expect(link.textContent).toBe('Manage members');
320
+ expect(link).toHaveAttribute(
321
+ 'aria-label',
322
+ `Manage ${longRole.title} members`,
323
+ );
324
+ await userEvent.click(
325
+ screen.getByRole('button', { name: 'Save configuration fixture' }),
326
+ );
327
+ await waitFor(() =>
328
+ expect(screen.queryByRole('region')).not.toBeInTheDocument(),
329
+ );
330
+ expect(
331
+ screen.queryByText('Checking current configuration…'),
332
+ ).not.toBeInTheDocument();
333
+ });
334
+
335
+ it.each([
336
+ null,
337
+ {},
338
+ { roleScopes: null },
339
+ { roleScopes: [{ key: 'app', options: null }] },
340
+ ])(
341
+ 'treats incompatible role metadata as an invalid check without reading users',
342
+ async (metadata) => {
343
+ const client = {
344
+ options: vi.fn().mockResolvedValue(metadata),
345
+ list: vi.fn(),
346
+ };
347
+ expect((await inspectRequiredRoles(client, roles))[id]).toEqual({
348
+ status: 'unknown',
349
+ reason: 'invalid',
350
+ });
351
+ expect(client.list).not.toHaveBeenCalled();
352
+ },
353
+ );
354
+
355
+ it('ignores malformed unrelated metadata while checking a valid declared role', async () => {
356
+ const client = {
357
+ options: vi.fn().mockResolvedValue({
358
+ roleScopes: [
359
+ null,
360
+ {
361
+ ...registered.roleScopes[0],
362
+ options: [null, ...registered.roleScopes[0]!.options],
363
+ },
364
+ ],
365
+ }),
366
+ list: vi.fn().mockResolvedValue({ total: 1 }),
367
+ };
368
+ expect((await inspectRequiredRoles(client, roles))[id]?.status).toBe('ready');
369
+ expect(client.list).toHaveBeenCalledTimes(1);
370
+ });
@@ -0,0 +1,95 @@
1
+ import { renderWithApp } from '@nocobase/app-testing/client';
2
+ import { screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { useState } from 'react';
5
+ import { useLocation, useNavigate } from 'react-router';
6
+ import { expect, it } from 'vitest';
7
+ import { useTableUrlState } from '../../client/hooks/use-table-url-state.js';
8
+
9
+ function Probe() {
10
+ const [count, setCount] = useState<number>();
11
+ const state = useTableUrlState({ sortColumns: ['name'], rowCount: count });
12
+ const location = useLocation();
13
+ const navigate = useNavigate();
14
+ return (
15
+ <>
16
+ <button onClick={() => void navigate(-1)}>Back in history</button>
17
+ <output data-testid='state'>
18
+ {JSON.stringify(state.paginationState)}|
19
+ {JSON.stringify(state.sortingState)}
20
+ </output>
21
+ <output data-testid='url'>{location.search}</output>
22
+ <button onClick={() => setCount(25)}>Loaded</button>
23
+ <button onClick={() => setCount(10)}>Removed</button>
24
+ <button
25
+ onClick={() => state.onSortingChange([{ id: 'name', desc: true }])}
26
+ >
27
+ Sort
28
+ </button>
29
+ <button
30
+ onClick={() =>
31
+ state.onPaginationChange((old) => ({ ...old, pageIndex: 1 }))
32
+ }
33
+ >
34
+ Next
35
+ </button>
36
+ <button
37
+ onClick={() =>
38
+ void navigate('?page=2&pageSize=20&sort=name&order=asc&q=historic')
39
+ }
40
+ >
41
+ History
42
+ </button>
43
+ </>
44
+ );
45
+ }
46
+ it('clamps only a successfully loaded count and retains unrelated query context', async () => {
47
+ await renderWithApp(<Probe />, {
48
+ route: '/?page=5&pageSize=10&q=kept&returnTo=%2Fworkflows',
49
+ });
50
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":4');
51
+ await userEvent.click(screen.getByRole('button', { name: 'Loaded' }));
52
+ await waitFor(() =>
53
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":2'),
54
+ );
55
+ await userEvent.click(screen.getByRole('button', { name: 'Removed' }));
56
+ await waitFor(() =>
57
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":0'),
58
+ );
59
+ expect(screen.getByTestId('url')).toHaveTextContent('q=kept');
60
+ expect(screen.getByTestId('url')).toHaveTextContent('returnTo=%2Fworkflows');
61
+ });
62
+ it('restores external URL changes, resets the page on sort and retains sorting when paging', async () => {
63
+ await renderWithApp(<Probe />, { route: '/?page=3&pageSize=10' });
64
+ await userEvent.click(screen.getByRole('button', { name: 'History' }));
65
+ expect(screen.getByTestId('state')).toHaveTextContent(
66
+ '"pageIndex":1,"pageSize":20',
67
+ );
68
+ expect(screen.getByTestId('state')).toHaveTextContent('"desc":false');
69
+ await userEvent.click(screen.getByRole('button', { name: 'Sort' }));
70
+ expect(screen.getByTestId('url')).not.toHaveTextContent('page=2');
71
+ expect(screen.getByTestId('url')).toHaveTextContent('q=historic');
72
+ expect(screen.getByTestId('state')).toHaveTextContent('"desc":true');
73
+ await userEvent.click(screen.getByRole('button', { name: 'Next' }));
74
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":1');
75
+ expect(screen.getByTestId('url')).toHaveTextContent('order=desc');
76
+ });
77
+ it('normalizes invalid pagination and ignores unsupported sort columns', async () => {
78
+ await renderWithApp(<Probe />, {
79
+ route: '/?page=1.5&pageSize=3&sort=unknown&order=desc',
80
+ });
81
+ expect(screen.getByTestId('state')).toHaveTextContent(
82
+ '{"pageIndex":0,"pageSize":10}|[]',
83
+ );
84
+ });
85
+
86
+ it('allows browser Back to restore the preceding page', async () => {
87
+ await renderWithApp(<Probe />, { route: '/?page=3&pageSize=10&q=kept' });
88
+ await userEvent.click(screen.getByRole('button', { name: 'Next' }));
89
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":1');
90
+ await userEvent.click(
91
+ screen.getByRole('button', { name: 'Back in history' }),
92
+ );
93
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":2');
94
+ expect(screen.getByTestId('url')).toHaveTextContent('q=kept');
95
+ });