@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,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.findByRole('link', { name: 'Alice' })).toBeVisible();
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: 'settings',
201
- title: 'Settings',
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: 'files',
211
- title: 'Files',
212
- actions: [{ name: 'read', title: 'Read files' }],
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
- await user.click(await screen.findByRole('button', { name: /^Settings/ }));
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: { type: 'settings', id: 'files' },
308
+ resource: {
309
+ type: 'settings',
310
+ id: 'authorization.permission-sets',
311
+ },
287
312
  actions: [{ action: 'read' }],
288
313
  },
289
314
  ],