@nocobase/app-template-default 1.0.0-beta.72 → 1.0.0-beta.74

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 (33) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.MD +2 -0
  3. package/client/components/default-access-editor.tsx +183 -0
  4. package/client/components/permission-editor.tsx +216 -150
  5. package/client/components/role-list.tsx +61 -0
  6. package/client/lib/navigation-transform.ts +28 -1
  7. package/client/locales/system/settings-en-US.ts +73 -2
  8. package/client/locales/system/settings-zh-CN.ts +65 -2
  9. package/client/pages/settings/default-access-form.tsx +499 -0
  10. package/client/pages/settings/default-access-model.ts +154 -0
  11. package/client/pages/settings/default-access.tsx +407 -0
  12. package/client/pages/settings/permission-filters.tsx +43 -37
  13. package/client/pages/settings/permission-management-page.tsx +105 -0
  14. package/client/pages/settings/permissions-model.ts +37 -1
  15. package/client/pages/settings/role-form.tsx +46 -18
  16. package/client/pages/settings/roles.tsx +23 -43
  17. package/client/pages/settings/routes.ts +78 -19
  18. package/client/pages/settings/shared.tsx +3 -0
  19. package/client/pages/settings/users.tsx +1 -1
  20. package/client/routing/route-navigation.ts +21 -1
  21. package/package.json +22 -21
  22. package/tests/components/default-access.test.tsx +554 -0
  23. package/tests/components/page-harness.test.tsx +132 -112
  24. package/tests/components/permission-editor.test.tsx +233 -73
  25. package/tests/components/permission-management-tabs.test.tsx +128 -0
  26. package/tests/components/role-list.test.tsx +71 -0
  27. package/tests/components/roles.test.tsx +78 -0
  28. package/tests/components/user-permissions.test.tsx +12 -0
  29. package/tests/logic/client-routes.test.ts +8 -0
  30. package/tests/logic/default-access-model.test.ts +167 -0
  31. package/tests/logic/permission-management-routes.test.ts +64 -0
  32. package/tests/logic/settings-permissions.test.ts +89 -0
  33. package/vitest.config.ts +7 -6
@@ -0,0 +1,167 @@
1
+ import { expect, it } from 'vitest';
2
+ import type { AuthorizationOptionsResponse } from '@nocobase/app-plugin-authorization/client';
3
+ import {
4
+ actionKey,
5
+ ruleResources,
6
+ ruleScopes,
7
+ replaceRuleScope,
8
+ editableRule,
9
+ type DefaultAccessRule,
10
+ } from '../../client/pages/settings/default-access-model.js';
11
+ const catalog: AuthorizationOptionsResponse = {
12
+ sections: [
13
+ {
14
+ name: 'business',
15
+ title: 'Business',
16
+ order: 0,
17
+ subsections: [
18
+ {
19
+ name: 'projects',
20
+ title: 'Projects',
21
+ resources: [
22
+ {
23
+ type: 'composite',
24
+ id: 'projects',
25
+ title: 'Projects',
26
+ actions: [
27
+ { name: 'view', title: 'View' },
28
+ { name: 'submit', title: 'Submit' },
29
+ ],
30
+ dataScopes: {
31
+ view: [
32
+ {
33
+ key: 'projects',
34
+ title: 'Projects',
35
+ collection: 'projects',
36
+ fields: ['name'],
37
+ recordAccess: ['own', 'customFilter'],
38
+ },
39
+ ],
40
+ submit: [
41
+ {
42
+ key: 'projects',
43
+ title: 'Projects',
44
+ collection: 'projects',
45
+ fields: ['name'],
46
+ recordAccess: ['own'],
47
+ },
48
+ {
49
+ key: 'quotes',
50
+ title: 'Quotes',
51
+ collection: 'quotes',
52
+ fields: ['name'],
53
+ recordAccess: ['own'],
54
+ },
55
+ ],
56
+ },
57
+ },
58
+ ],
59
+ },
60
+ ],
61
+ },
62
+ ],
63
+ subjectTypes: [],
64
+ recordAccess: [
65
+ { key: 'own', title: 'Own', collections: ['*'] },
66
+ { key: 'customFilter', title: 'Custom', collections: ['*'] },
67
+ ],
68
+ collections: [{ name: 'projects', fields: ['name'] }],
69
+ };
70
+ const resource = ruleResources(catalog)[0]!;
71
+ const scopes = ruleScopes(resource, catalog);
72
+ const rule: DefaultAccessRule = {
73
+ key: 'baseline',
74
+ resource: { type: 'composite', id: 'projects' },
75
+ actions: [
76
+ {
77
+ action: 'view',
78
+ scopeKey: 'projects',
79
+ selection: {
80
+ type: 'recordAccess',
81
+ key: 'customFilter',
82
+ params: { filter: { $and: [{ name: { $eq: 'Secret' } }] } },
83
+ },
84
+ },
85
+ {
86
+ action: 'submit',
87
+ scopeKey: 'quotes',
88
+ selection: { type: 'records', ids: ['q1', 'q2'] },
89
+ },
90
+ {
91
+ action: 'submit',
92
+ scopeKey: 'projects',
93
+ selection: {
94
+ type: 'recordAccess',
95
+ key: 'own',
96
+ params: { preserved: { value: true } },
97
+ },
98
+ },
99
+ ],
100
+ };
101
+ it('updates exactly one independently keyed scope without mutating advanced filters, record IDs or unrelated parameters', () => {
102
+ const before = structuredClone(rule);
103
+ const changed = replaceRuleScope(
104
+ rule,
105
+ scopes.find(
106
+ (scope) =>
107
+ scope.id === actionKey({ action: 'submit', scopeKey: 'projects' }),
108
+ )!,
109
+ { type: 'all' },
110
+ );
111
+ expect(changed.actions[0]).toEqual(rule.actions[0]);
112
+ expect(changed.actions[1]).toEqual(rule.actions[1]);
113
+ expect(changed.actions[2]?.selection).toEqual({ type: 'all' });
114
+ expect(rule).toEqual(before);
115
+ expect(
116
+ replaceRuleScope(changed, scopes[0]!, changed.actions[0]!.selection),
117
+ ).toBe(changed);
118
+ });
119
+ it('removes only an explicit scope and adds no default for unconfigured operations', () => {
120
+ const removed = replaceRuleScope(rule, scopes[0]!, undefined);
121
+ expect(removed.actions).toEqual(rule.actions.slice(1));
122
+ const empty = { ...rule, actions: [] };
123
+ expect(replaceRuleScope(empty, scopes[0]!, undefined)).toBe(empty);
124
+ });
125
+ it('preserves unknown entries and makes them read-only instead of dropping them from an update', () => {
126
+ expect(editableRule(rule, scopes, catalog)).toBe(true);
127
+ for (const changed of [
128
+ {
129
+ ...rule,
130
+ actions: [
131
+ ...rule.actions,
132
+ { action: 'legacy', selection: { type: 'all' as const } },
133
+ ],
134
+ },
135
+ {
136
+ ...rule,
137
+ actions: [
138
+ {
139
+ action: 'view',
140
+ scopeKey: 'projects',
141
+ selection: { type: 'recordAccess' as const, key: 'removed' },
142
+ },
143
+ ],
144
+ },
145
+ ]) {
146
+ const snapshot = structuredClone(changed);
147
+ expect(editableRule(changed, scopes, catalog)).toBe(false);
148
+ expect(changed).toEqual(snapshot);
149
+ }
150
+ expect(editableRule(rule, [], catalog)).toBe(false);
151
+ });
152
+ it('offers database rules without unsupported create actions and never confuses collection and composite scopes', () => {
153
+ const collection = ruleResources(catalog).find(
154
+ (resource) => resource.type === 'database.collection',
155
+ )!;
156
+ expect(
157
+ ruleScopes(collection, catalog).map((scope) => [
158
+ scope.action,
159
+ scope.scopeKey,
160
+ ]),
161
+ ).toEqual([
162
+ ['read', undefined],
163
+ ['update', undefined],
164
+ ['delete', undefined],
165
+ ]);
166
+ expect(new Set(scopes.map((scope) => scope.id)).size).toBe(scopes.length);
167
+ });
@@ -0,0 +1,64 @@
1
+ import { resolveAppClientContributions } from '@nocobase/app-client/plugins';
2
+ import { expect, it } from 'vitest';
3
+ import settings from '../../client/pages/settings/routes.js';
4
+ import {
5
+ buildRouteNavigation,
6
+ selectedNavigationId,
7
+ routeKey,
8
+ } from '../../client/routing/route-navigation.js';
9
+ it('groups existing permission pages without changing their URLs or authorization and hides inaccessible children', () => {
10
+ const { routes } = resolveAppClientContributions([
11
+ { packageName: 'test', source: 'application', routes: [settings] },
12
+ ]);
13
+ const group = routes[0].children!.find(
14
+ (route) => route.name === 'permissionManagement',
15
+ )!;
16
+ expect(group.componentLoader).toBeUndefined();
17
+ expect(
18
+ group.children!.map((route) => [route.name, route.path, route.authz]),
19
+ ).toEqual([
20
+ [
21
+ 'settingsRoles',
22
+ '/settings/roles',
23
+ {
24
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
25
+ action: 'read',
26
+ },
27
+ ],
28
+ [
29
+ 'settingsDefaultAccess',
30
+ '/settings/default-access',
31
+ {
32
+ resource: { type: 'settings', id: 'authorization.default-access' },
33
+ action: 'read',
34
+ },
35
+ ],
36
+ ]);
37
+ const visible = buildRouteNavigation(routes, new Set())[0].children.find(
38
+ ({ route }) => route.name === 'permissionManagement',
39
+ )!;
40
+ expect(visible.children).toEqual([]);
41
+ expect(visible.route.path).toBe('/settings/roles');
42
+ expect(visible.route.navigation?.title).toBe('settings.permissionManagement');
43
+ expect(selectedNavigationId(routes, '/settings/roles/member/edit')).toBe(
44
+ routeKey(visible.route),
45
+ );
46
+ expect(selectedNavigationId(routes, '/settings/default-access')).toBe(
47
+ routeKey(visible.route),
48
+ );
49
+ const denied = new Set([routeKey(group.children![0])]);
50
+ const onlyDefault = buildRouteNavigation(routes, denied)[0].children.find(
51
+ ({ route }) => route.name === 'permissionManagement',
52
+ )!;
53
+ expect(onlyDefault.children).toEqual([]);
54
+ expect(onlyDefault.route.path).toBe('/settings/default-access');
55
+ expect(selectedNavigationId(routes, '/settings/default-access', denied)).toBe(
56
+ routeKey(onlyDefault.route),
57
+ );
58
+ denied.add(routeKey(group.children![1]));
59
+ expect(
60
+ buildRouteNavigation(routes, denied)[0].children.map(
61
+ ({ route }) => route.name,
62
+ ),
63
+ ).toEqual(['settingsUsers']);
64
+ });
@@ -243,3 +243,92 @@ it.each(['retiredScope', { type: 'legacyPolicy', filter: { owner: 'me' } }])(
243
243
  ).toEqual({ records: 'all' });
244
244
  },
245
245
  );
246
+
247
+ it('describes unselected data ranges without claiming that inherited access is denied', () => {
248
+ const described = {
249
+ ...resource,
250
+ actions: [
251
+ { name: 'edit', title: 'Edit', description: 'Change order information.' },
252
+ ],
253
+ };
254
+ const grants = [
255
+ {
256
+ resource: { type: 'composite', id: 'orders' },
257
+ actions: [
258
+ {
259
+ action: 'edit',
260
+ policy: { type: 'composite', scopes: { records: '' } },
261
+ },
262
+ ],
263
+ },
264
+ ];
265
+ const before = structuredClone(grants);
266
+ const catalog = {
267
+ sections: [
268
+ {
269
+ name: 'business',
270
+ title: 'Business operations',
271
+ order: 100,
272
+ subsections: [
273
+ { name: 'orders', title: 'Orders', resources: [described] },
274
+ ],
275
+ },
276
+ ],
277
+ recordAccess: [],
278
+ } as unknown as Parameters<typeof permissionSections>[0];
279
+ const result = permissionSections(catalog, grants, (value) =>
280
+ typeof value === 'string' ? value : value.key,
281
+ );
282
+ expect(result[0]?.description).toBe('settings.businessOperationsHint');
283
+ expect(result[0]?.groups[0]?.rows[0]?.hint).toBe('Change order information.');
284
+ expect(result[0]?.groups[0]?.rows[0]?.scopes?.[0]).toMatchObject({
285
+ level: '',
286
+ hint: 'settings.noAdditionalScopeHint',
287
+ });
288
+ expect(grants).toEqual(before);
289
+ });
290
+
291
+ it('shows a shared plugin action explanation once without duplicating row hints', () => {
292
+ const catalog = {
293
+ sections: [
294
+ {
295
+ name: 'administration',
296
+ title: 'System and tools',
297
+ order: 200,
298
+ subsections: [
299
+ {
300
+ name: 'agents',
301
+ title: 'Agents',
302
+ resources: [
303
+ {
304
+ type: 'agents',
305
+ id: 'agents.agents',
306
+ title: 'Agents and skills',
307
+ actions: [
308
+ {
309
+ name: 'edit.related',
310
+ title: 'Manage own',
311
+ description: 'Includes archiving and deletion.',
312
+ },
313
+ {
314
+ name: 'edit.all',
315
+ title: 'Manage all',
316
+ description: 'Includes archiving and deletion.',
317
+ },
318
+ ],
319
+ },
320
+ ],
321
+ },
322
+ ],
323
+ },
324
+ ],
325
+ recordAccess: [],
326
+ } as unknown as Parameters<typeof permissionSections>[0];
327
+ const result = permissionSections(catalog, [], (value) =>
328
+ typeof value === 'string' ? value : value.key,
329
+ );
330
+ expect(result[0]?.groups[0]?.actionHint).toBe(
331
+ 'Includes archiving and deletion.',
332
+ );
333
+ expect(result[0]?.groups[0]?.rows.every((row) => !row.hint)).toBe(true);
334
+ });
package/vitest.config.ts CHANGED
@@ -11,12 +11,13 @@ export default createReactVitestConfig({
11
11
  deps: {
12
12
  // Transform the public Client surfaces so vi.mock applies, without duplicating the Server packages' identity tokens.
13
13
  inline: [
14
- // The authentication guards read the location, so they share the test's react-router rather than Node's copy,
15
- // together with every client that resolves the authentication tokens and provider.
16
- /@nocobase\/app-plugin-(?:authentication|api-keys|users)\/(?:dist\/)?client\//u,
17
- /@nocobase\/app-plugin-authorization\/(?:dist\/)?client\//u,
18
- /@nocobase\/app-plugin-(?:lifecycle-example|office-flows-example)\/(?:dist\/)?client\//u,
19
- /@nocobase\/app-plugin-notification-in-app\/(?:dist\/)?client\//u,
14
+ // The published fixture mounts MemoryRouter; transform it with the page to share the router context.
15
+ /@nocobase\/app-testing\/(?:dist\/)?src\/client\//u,
16
+ // AppClientProviders includes the navigation guard and must use that same router context.
17
+ /@nocobase\/app-client\//u,
18
+ // Client plugins in every scope, including @acme/app-plugin-*, must share the transformed runtime.
19
+ // Keep server/shared modules external so their service identities remain unchanged.
20
+ /@[^/]+\/app-plugin-[^/]+\/(?:dist\/)?client\//u,
20
21
  ],
21
22
  },
22
23
  },