@nocobase/app-template-default 1.0.0-beta.76 → 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.
@@ -18,6 +18,25 @@ const catalog = {
18
18
  {
19
19
  name: 'business',
20
20
  title: 'Business',
21
+ subsections: [
22
+ {
23
+ name: 'other',
24
+ title: 'Other',
25
+ resources: [
26
+ {
27
+ type: 'composite',
28
+ id: 'expenses.records',
29
+ title: 'Expenses',
30
+ actions: [{ name: 'other', title: 'Other' }],
31
+ dataScopes: {},
32
+ },
33
+ ],
34
+ },
35
+ ],
36
+ },
37
+ {
38
+ name: 'configuration',
39
+ title: 'Configuration',
21
40
  subsections: [
22
41
  {
23
42
  name: 'expenses',
@@ -108,6 +127,7 @@ it('collects native custom filter parameters before changing the draft, then sav
108
127
  );
109
128
  }
110
129
  render(<Role />);
130
+ await user.click(screen.getByRole('button', { name: 'Configuration' }));
111
131
  const scope = screen.getByRole('combobox', {
112
132
  name: 'How far View · Records reaches',
113
133
  });
@@ -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';
@@ -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
  ],
@@ -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 () => {
@@ -0,0 +1,394 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ defineAppRoutes,
4
+ resolveAppClientContributions,
5
+ } from '@nocobase/app-client/plugins';
6
+ import type { LocalizedText } from '@nocobase/app-plugin-authorization/client';
7
+ import type {
8
+ PermissionEditorNavigationNode,
9
+ PermissionEditorSection,
10
+ } from '../../client/components/permission-editor.js';
11
+ import {
12
+ pagePermissionNavigation,
13
+ pagePermissionResources,
14
+ permissionNavigationSections,
15
+ } from '../../client/pages/settings/page-permissions.js';
16
+ import enUS from '../../client/locales/en-US.js';
17
+ import zhCN from '../../client/locales/zh-CN.js';
18
+
19
+ const componentLoader = async () => ({ default: () => null });
20
+ const text = (value: LocalizedText): string =>
21
+ typeof value === 'string' ? value : value.key;
22
+ const resource = (id: string) => ({
23
+ resource: { type: 'page' as const, id },
24
+ action: 'access',
25
+ });
26
+ const flatten = (
27
+ nodes: readonly PermissionEditorNavigationNode[],
28
+ ): PermissionEditorNavigationNode[] =>
29
+ nodes.flatMap((node) => [node, ...flatten(node.children ?? [])]);
30
+ const fixture = () =>
31
+ resolveAppClientContributions([
32
+ {
33
+ packageName: 'private-app',
34
+ source: 'application',
35
+ routes: defineAppRoutes([
36
+ {
37
+ name: 'basic',
38
+ navigation: { title: 'Basic data' },
39
+ children: [
40
+ {
41
+ name: 'materials',
42
+ path: '/materials',
43
+ navigation: { title: 'Materials' },
44
+ authz: resource('legacy-materials'),
45
+ componentLoader,
46
+ },
47
+ {
48
+ name: 'locations',
49
+ navigation: { title: 'Locations' },
50
+ children: [
51
+ {
52
+ name: 'warehouses',
53
+ path: '/warehouses',
54
+ navigation: { title: 'Warehouses' },
55
+ authz: resource('legacy-warehouses'),
56
+ componentLoader,
57
+ },
58
+ ],
59
+ },
60
+ ],
61
+ },
62
+ {
63
+ name: 'loans',
64
+ path: '/loans',
65
+ navigation: { title: 'Loans' },
66
+ authz: resource('legacy-loans'),
67
+ componentLoader,
68
+ children: [
69
+ { name: 'loanDetail', path: ':id', componentLoader },
70
+ {
71
+ name: 'reviews',
72
+ path: 'reviews',
73
+ navigation: { title: 'Reviews' },
74
+ authz: resource('legacy-reviews'),
75
+ componentLoader,
76
+ },
77
+ {
78
+ name: 'audit',
79
+ path: 'audit',
80
+ authz: resource('legacy-audit'),
81
+ componentLoader,
82
+ },
83
+ ],
84
+ },
85
+ {
86
+ name: 'alias',
87
+ path: '/loan-alias',
88
+ navigation: { title: 'Loan alias' },
89
+ authz: resource('legacy-loans'),
90
+ componentLoader,
91
+ },
92
+ {
93
+ name: 'settings',
94
+ path: '/settings',
95
+ navigation: { title: 'Settings' },
96
+ authz: {
97
+ resource: { type: 'settings', id: 'rules' },
98
+ action: 'read',
99
+ },
100
+ componentLoader,
101
+ },
102
+ {
103
+ name: 'landing',
104
+ path: '/',
105
+ navigation: { title: 'Landing' },
106
+ authz: 'skip',
107
+ componentLoader,
108
+ },
109
+ ]),
110
+ },
111
+ ]);
112
+
113
+ describe('page permission navigation projection', () => {
114
+ it('supplies named disclosure and non-menu labels in both supported languages', () => {
115
+ for (const locale of [enUS, zhCN]) {
116
+ const settings = Reflect.get(locale, 'settings') as Record<
117
+ string,
118
+ unknown
119
+ >;
120
+ for (const key of [
121
+ 'expandPermissionGroup',
122
+ 'collapsePermissionGroup',
123
+ 'pagesOutsideNavigation',
124
+ ]) {
125
+ const value = settings[key];
126
+ expect(typeof value).toBe('string');
127
+ expect(String(value).trim()).not.toBe('');
128
+ expect(value).not.toBe(key);
129
+ if (key !== 'pagesOutsideNavigation')
130
+ expect(value).toContain('{{name}}');
131
+ }
132
+ }
133
+ });
134
+ it('preserves real groups, deeper menus and independent parent/child identities', () => {
135
+ const { routes } = fixture();
136
+ const result = pagePermissionNavigation(
137
+ routes,
138
+ { audit: 'Audit details' },
139
+ (path) => `Unnamed (${path})`,
140
+ text,
141
+ );
142
+ expect(result.map((node) => node.label)).toEqual([
143
+ 'Basic data',
144
+ 'Loans',
145
+ 'settings.pagesOutsideNavigation',
146
+ ]);
147
+ expect(result[0]?.rowId).toBeUndefined();
148
+ expect(result[0]?.children?.map((node) => node.label)).toEqual([
149
+ 'Materials',
150
+ 'Locations',
151
+ ]);
152
+ expect(result[0]?.children?.[1]?.children?.[0]?.label).toBe('Warehouses');
153
+ expect(result[1]?.rowId).toBe('["page","legacy-loans","access"]');
154
+ expect(result[1]?.children?.[0]?.rowId).toBe(
155
+ '["page","legacy-reviews","access"]',
156
+ );
157
+ expect(
158
+ flatten(result)
159
+ .filter((node) => node.rowId)
160
+ .map((node) => node.rowId),
161
+ ).toEqual([
162
+ '["page","legacy-materials","access"]',
163
+ '["page","legacy-warehouses","access"]',
164
+ '["page","legacy-loans","access"]',
165
+ '["page","legacy-reviews","access"]',
166
+ '["page","legacy-audit","access"]',
167
+ ]);
168
+ });
169
+
170
+ it('keeps actual non-menu independent pages and deduplicates inherited and aliased grants', () => {
171
+ const { routes } = fixture();
172
+ const before = JSON.stringify(routes);
173
+ const pages = pagePermissionResources(
174
+ routes,
175
+ { audit: 'Audit details' },
176
+ (path) => `Unnamed (${path})`,
177
+ );
178
+ const result = pagePermissionNavigation(
179
+ routes,
180
+ { audit: 'Audit details' },
181
+ (path) => `Unnamed (${path})`,
182
+ text,
183
+ );
184
+ expect(result.at(-1)?.children).toEqual([
185
+ {
186
+ id: 'page:legacy-audit',
187
+ label: 'Audit details',
188
+ rowId: '["page","legacy-audit","access"]',
189
+ },
190
+ ]);
191
+ expect(flatten(result).filter((node) => node.rowId)).toHaveLength(
192
+ pages.length,
193
+ );
194
+ expect(flatten(result).map((node) => node.label)).not.toContain(
195
+ 'Loan alias',
196
+ );
197
+ expect(flatten(result).map((node) => node.label)).not.toContain('Landing');
198
+ expect(flatten(result).map((node) => node.label)).not.toContain('Settings');
199
+ expect(JSON.stringify(routes)).toBe(before);
200
+ expect(
201
+ pagePermissionNavigation(
202
+ routes,
203
+ { audit: 'Audit details' },
204
+ (path) => `Unnamed (${path})`,
205
+ text,
206
+ ),
207
+ ).toEqual(result);
208
+ });
209
+
210
+ it('renders single-level menus at a single level without inventing groups', () => {
211
+ const { routes } = resolveAppClientContributions([
212
+ {
213
+ packageName: 'flat-app',
214
+ source: 'application',
215
+ routes: defineAppRoutes([
216
+ {
217
+ name: 'one',
218
+ path: '/one',
219
+ navigation: { title: 'One' },
220
+ authz: resource('one'),
221
+ componentLoader,
222
+ },
223
+ {
224
+ name: 'two',
225
+ path: '/two',
226
+ navigation: { title: 'Two' },
227
+ authz: resource('two'),
228
+ componentLoader,
229
+ },
230
+ ]),
231
+ },
232
+ ]);
233
+ const result = pagePermissionNavigation(routes, {}, () => 'Unnamed', text);
234
+ expect(result.map((node) => node.label)).toEqual(['One', 'Two']);
235
+ expect(result.every((node) => !node.children?.length)).toBe(true);
236
+ expect(result.every((node) => node.rowId)).toBe(true);
237
+ });
238
+ });
239
+
240
+ it('omits menus governed by settings or account permissions instead of mirroring them as page controls or links', () => {
241
+ const { routes } = resolveAppClientContributions([
242
+ {
243
+ packageName: 'app',
244
+ source: 'application',
245
+ routes: defineAppRoutes([
246
+ {
247
+ name: 'settings',
248
+ path: '/settings',
249
+ navigation: { title: 'System settings' },
250
+ children: [
251
+ {
252
+ name: 'users',
253
+ path: 'users',
254
+ navigation: { title: 'User management' },
255
+ authz: { resource: { type: 'user', id: '*' }, action: 'read' },
256
+ componentLoader,
257
+ },
258
+ {
259
+ name: 'roles',
260
+ path: 'roles',
261
+ navigation: { title: 'Permission management' },
262
+ authz: {
263
+ resource: {
264
+ type: 'settings',
265
+ id: 'authorization.permission-sets',
266
+ },
267
+ action: 'read',
268
+ },
269
+ componentLoader,
270
+ },
271
+ {
272
+ name: 'logs',
273
+ path: 'logs',
274
+ navigation: { title: 'Notification logs' },
275
+ authz: resource('logs'),
276
+ componentLoader,
277
+ },
278
+ ],
279
+ },
280
+ ]),
281
+ },
282
+ ]);
283
+ const result = pagePermissionNavigation(routes, {}, (path) => path, text);
284
+ expect(result).toEqual([]);
285
+ const withoutPage = routes.map((route) => ({
286
+ ...route,
287
+ children: route.children?.filter(({ name }) => name !== 'logs'),
288
+ }));
289
+ expect(
290
+ pagePermissionNavigation(withoutPage, {}, (path) => path, text),
291
+ ).toEqual([]);
292
+ });
293
+
294
+ it.each(['settings', 'systemManagement'])(
295
+ 'moves all %s controls into configuration without changing or duplicating grants',
296
+ (rootName) => {
297
+ const { routes } = resolveAppClientContributions([
298
+ {
299
+ packageName: 'app',
300
+ source: 'application',
301
+ routes: defineAppRoutes([
302
+ {
303
+ name: 'orders',
304
+ path: '/orders',
305
+ navigation: { title: 'Orders' },
306
+ authz: resource('orders'),
307
+ componentLoader,
308
+ },
309
+ {
310
+ name: rootName,
311
+ path: '/settings',
312
+ navigation: { title: 'Settings' },
313
+ children: [
314
+ {
315
+ name: 'users',
316
+ path: 'users',
317
+ navigation: { title: 'Users' },
318
+ authz: { resource: { type: 'user', id: '*' }, action: 'read' },
319
+ componentLoader,
320
+ },
321
+ {
322
+ name: 'logs',
323
+ path: 'logs',
324
+ navigation: { title: 'Logs' },
325
+ authz: resource('logs'),
326
+ componentLoader,
327
+ },
328
+ ],
329
+ },
330
+ ]),
331
+ },
332
+ ]);
333
+ const user = {
334
+ id: '["user","*","read"]',
335
+ label: 'View users',
336
+ enabled: true,
337
+ };
338
+ const accountAction = {
339
+ id: '["user","*","disable"]',
340
+ label: 'Disable account',
341
+ enabled: false,
342
+ };
343
+ const order = {
344
+ id: '["page","orders","access"]',
345
+ label: 'Orders',
346
+ enabled: true,
347
+ };
348
+ const logs = {
349
+ id: '["page","logs","access"]',
350
+ label: 'Logs',
351
+ enabled: false,
352
+ };
353
+ const sections: PermissionEditorSection[] = [
354
+ {
355
+ id: 'users',
356
+ title: 'Users and accounts',
357
+ groups: [{ id: 'users', rows: [user, accountAction] }],
358
+ },
359
+ {
360
+ id: 'pages',
361
+ title: 'Page access',
362
+ groups: [
363
+ { id: 'orders', accessOnly: true, rows: [order] },
364
+ { id: 'logs', accessOnly: true, rows: [logs] },
365
+ ],
366
+ },
367
+ ];
368
+ const before = JSON.stringify(sections);
369
+ const result = permissionNavigationSections(
370
+ sections,
371
+ routes,
372
+ {},
373
+ (path) => path,
374
+ text,
375
+ );
376
+ expect(result.map(({ id }) => id)).toEqual(['pages', 'configuration']);
377
+ expect(result[0]?.groups.flatMap(({ rows }) => rows)).toEqual([order]);
378
+ expect(
379
+ flatten(result[0]?.navigation ?? []).flatMap(({ rowId }) =>
380
+ rowId ? [rowId] : [],
381
+ ),
382
+ ).toEqual([order.id]);
383
+ expect(result[1]?.groups.flatMap(({ rows }) => rows)).toEqual([
384
+ user,
385
+ accountAction,
386
+ logs,
387
+ ]);
388
+ expect(result[1]?.groups[0]?.rows[0]).toBe(user);
389
+ expect(JSON.stringify(sections)).toBe(before);
390
+ expect(
391
+ permissionNavigationSections(result, routes, {}, (path) => path, text),
392
+ ).toEqual(result);
393
+ },
394
+ );