@nocobase/app-template-default 1.0.0-beta.73 → 1.0.0-beta.75
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.
- package/AGENTS.md +8 -2
- package/README.MD +3 -1
- package/cli/plugins.ts +2 -1
- package/client/components/default-access-editor.tsx +183 -0
- package/client/components/permission-editor.tsx +216 -150
- package/client/components/role-list.tsx +85 -0
- package/client/lib/navigation-transform.ts +29 -1
- package/client/locales/system/settings-en-US.ts +74 -2
- package/client/locales/system/settings-zh-CN.ts +66 -2
- package/client/pages/settings/default-access-form.tsx +499 -0
- package/client/pages/settings/default-access-model.ts +154 -0
- package/client/pages/settings/default-access.tsx +407 -0
- package/client/pages/settings/permission-filters.tsx +43 -37
- package/client/pages/settings/permission-management-page.tsx +105 -0
- package/client/pages/settings/permissions-model.ts +37 -1
- package/client/pages/settings/role-form.tsx +46 -18
- package/client/pages/settings/roles.tsx +26 -43
- package/client/pages/settings/routes.ts +78 -19
- package/client/pages/settings/shared.tsx +3 -0
- package/client/pages/settings/users.tsx +1 -1
- package/client/plugins.ts +2 -0
- package/client/routing/route-navigation.ts +21 -1
- package/package.json +25 -23
- package/server/plugins.ts +2 -0
- package/tests/components/default-access.test.tsx +554 -0
- package/tests/components/page-harness.test.tsx +132 -112
- package/tests/components/permission-editor.test.tsx +233 -73
- package/tests/components/permission-management-tabs.test.tsx +128 -0
- package/tests/components/role-list.test.tsx +136 -0
- package/tests/components/roles.test.tsx +85 -0
- package/tests/components/user-permissions.test.tsx +12 -0
- package/tests/logic/authorization-assignment-safety.test.ts +82 -0
- package/tests/logic/client-routes.test.ts +8 -0
- package/tests/logic/default-access-model.test.ts +167 -0
- package/tests/logic/foundation-integration.test.ts +62 -0
- package/tests/logic/permission-management-routes.test.ts +64 -0
- package/tests/logic/settings-permissions.test.ts +89 -0
- package/vitest.config.ts +2 -2
|
@@ -0,0 +1,85 @@
|
|
|
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 { Routes, Route } from 'react-router';
|
|
10
|
+
import { expect, it } from 'vitest';
|
|
11
|
+
import RolesPage from '../../client/pages/settings/roles.js';
|
|
12
|
+
import enUS from '../../client/locales/en-US.js';
|
|
13
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
14
|
+
const settingsNamespace = '@nocobase/compact-role-test';
|
|
15
|
+
const services = (app: ClientApplication) =>
|
|
16
|
+
app.container.instance(
|
|
17
|
+
authorizationClientToken,
|
|
18
|
+
new AuthorizationClient(app.container.resolve(apiClientToken)),
|
|
19
|
+
);
|
|
20
|
+
const namespaces = {
|
|
21
|
+
[settingsNamespace]: {
|
|
22
|
+
'en-US': async () => ({ default: enUS }),
|
|
23
|
+
'zh-CN': async () => ({ default: zhCN }),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
it('keeps the standard role table sorted, searchable and linked to the current query', async () => {
|
|
28
|
+
const user = userEvent.setup();
|
|
29
|
+
await renderWithApp(
|
|
30
|
+
<Routes>
|
|
31
|
+
<Route path='/settings/roles' element={<RolesPage />} />
|
|
32
|
+
</Routes>,
|
|
33
|
+
{
|
|
34
|
+
route: '/settings/roles',
|
|
35
|
+
namespace: settingsNamespace,
|
|
36
|
+
namespaces,
|
|
37
|
+
services,
|
|
38
|
+
fetch: answerApi(({ path }) =>
|
|
39
|
+
path === 'authorization/permissions'
|
|
40
|
+
? { data: { unrestricted: false, permissions: [] } }
|
|
41
|
+
: {
|
|
42
|
+
data: [
|
|
43
|
+
{ key: 'team', title: 'NocoBase team', grants: [] },
|
|
44
|
+
{ key: 'cp-customer', title: 'Customer', grants: [] },
|
|
45
|
+
{
|
|
46
|
+
key: 'member',
|
|
47
|
+
title: 'Member',
|
|
48
|
+
grants: [],
|
|
49
|
+
protection: { allow: ['update'] },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
await screen.findByTitle('Customer');
|
|
57
|
+
const region = screen.getByRole('region', {
|
|
58
|
+
name: 'Roles and permissions',
|
|
59
|
+
});
|
|
60
|
+
const table = within(region).getByRole('table');
|
|
61
|
+
expect(within(table).getAllByRole('columnheader')).toHaveLength(3);
|
|
62
|
+
expect(
|
|
63
|
+
within(table)
|
|
64
|
+
.getAllByRole('link')
|
|
65
|
+
.map((link) => link.textContent),
|
|
66
|
+
).toEqual(['Customer', 'Member', 'NocoBase team']);
|
|
67
|
+
expect(
|
|
68
|
+
screen.queryByRole('heading', { name: 'Customer' }),
|
|
69
|
+
).not.toBeInTheDocument();
|
|
70
|
+
expect(
|
|
71
|
+
screen.queryByRole('button', { name: 'New role' }),
|
|
72
|
+
).not.toBeInTheDocument();
|
|
73
|
+
await user.type(
|
|
74
|
+
screen.getByRole('textbox', { name: 'Search role names' }),
|
|
75
|
+
'Customer',
|
|
76
|
+
);
|
|
77
|
+
await waitFor(() => {
|
|
78
|
+
expect(within(table).getAllByRole('row')).toHaveLength(2);
|
|
79
|
+
expect(within(table).getAllByRole('link')).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
expect(screen.getByTitle('Customer').closest('a')).toHaveAttribute(
|
|
82
|
+
'href',
|
|
83
|
+
'/settings/roles/cp-customer/edit?q=Customer',
|
|
84
|
+
);
|
|
85
|
+
});
|
|
@@ -105,6 +105,18 @@ describe('user permission configuration', () => {
|
|
|
105
105
|
expect(checkbox).not.toBeChecked();
|
|
106
106
|
await user.click(checkbox);
|
|
107
107
|
}
|
|
108
|
+
expect(
|
|
109
|
+
screen.getByRole('heading', { name: words.userInformation }),
|
|
110
|
+
).toBeVisible();
|
|
111
|
+
expect(
|
|
112
|
+
screen.getByRole('heading', { name: words.accountSecurity }),
|
|
113
|
+
).toBeVisible();
|
|
114
|
+
expect(
|
|
115
|
+
screen.getByRole('heading', { name: words.roleAssignment }),
|
|
116
|
+
).toBeVisible();
|
|
117
|
+
expect(screen.getAllByRole('checkbox')).toHaveLength(10);
|
|
118
|
+
const rowIds = screen.getAllByRole('checkbox').map((node) => node.id);
|
|
119
|
+
expect(new Set(rowIds).size).toBe(10);
|
|
108
120
|
await user.click(
|
|
109
121
|
screen.getByRole('button', { name: words.save, exact: true }),
|
|
110
122
|
);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import { expect, it } from 'vitest';
|
|
4
|
+
import { createAppTest } from '@nocobase/app-testing/server';
|
|
5
|
+
import { createStandaloneServer } from '../../server/standalone.js';
|
|
6
|
+
|
|
7
|
+
type Assignment = {
|
|
8
|
+
subjectType: unknown;
|
|
9
|
+
subjectId: unknown;
|
|
10
|
+
permissionSetKey: unknown;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function unexpectedGlobalAssignments(
|
|
14
|
+
assignments: readonly Assignment[],
|
|
15
|
+
): Assignment[] {
|
|
16
|
+
return assignments.filter(
|
|
17
|
+
(assignment) =>
|
|
18
|
+
assignment.subjectId === '*' &&
|
|
19
|
+
!(
|
|
20
|
+
assignment.subjectType === 'authenticated' &&
|
|
21
|
+
assignment.permissionSetKey === 'member'
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const test = createAppTest({
|
|
27
|
+
createServer: createStandaloneServer,
|
|
28
|
+
connections: ['main'],
|
|
29
|
+
config: {
|
|
30
|
+
app: { publicOrigin: 'http://localhost' },
|
|
31
|
+
auth: { secret: 'test-auth-secret-at-least-32-characters' },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('initialization does not silently grant business roles to every signed-in user', async ({
|
|
36
|
+
connection,
|
|
37
|
+
}) => {
|
|
38
|
+
const assignments = await connection.query
|
|
39
|
+
.selectFrom('authorizationPermissionSetAssignments')
|
|
40
|
+
.select(['subjectType', 'subjectId', 'permissionSetKey'])
|
|
41
|
+
.execute();
|
|
42
|
+
expect(
|
|
43
|
+
unexpectedGlobalAssignments(assignments),
|
|
44
|
+
'Global role assignments must be explicitly requested and editable in administration. Use per-user role assignments by default; do not remove this guard to make a feature accessible.',
|
|
45
|
+
).toEqual([]);
|
|
46
|
+
const member = await connection.query
|
|
47
|
+
.selectFrom('authorizationPermissionSets')
|
|
48
|
+
.select(['grants'])
|
|
49
|
+
.where('key', '=', 'member')
|
|
50
|
+
.executeTakeFirstOrThrow();
|
|
51
|
+
let grants: unknown = member.grants;
|
|
52
|
+
// Database JSON codecs may preserve the serialized seed value as a string.
|
|
53
|
+
while (typeof grants === 'string') grants = JSON.parse(grants) as unknown;
|
|
54
|
+
expect(
|
|
55
|
+
grants,
|
|
56
|
+
'The inherited platform member role must not conceal application business grants. Assign a separate business role to explicit users.',
|
|
57
|
+
).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('detects wildcard recipients regardless of the role name, while preserving explicit user assignments', () => {
|
|
61
|
+
const global = {
|
|
62
|
+
subjectType: 'authenticated',
|
|
63
|
+
subjectId: '*',
|
|
64
|
+
permissionSetKey: 'task-member',
|
|
65
|
+
};
|
|
66
|
+
expect(
|
|
67
|
+
unexpectedGlobalAssignments([
|
|
68
|
+
{
|
|
69
|
+
subjectType: 'authenticated',
|
|
70
|
+
subjectId: '*',
|
|
71
|
+
permissionSetKey: 'member',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
subjectType: 'user',
|
|
75
|
+
subjectId: 'user-1',
|
|
76
|
+
permissionSetKey: 'task-member',
|
|
77
|
+
},
|
|
78
|
+
global,
|
|
79
|
+
{ ...global, subjectType: 'everyone' },
|
|
80
|
+
]),
|
|
81
|
+
).toEqual([global, { ...global, subjectType: 'everyone' }]);
|
|
82
|
+
});
|
|
@@ -90,6 +90,14 @@ describe('app client routes', () => {
|
|
|
90
90
|
name: 'settingsRoleEdit',
|
|
91
91
|
authorizedAs: 'settings:authorization.permission-sets',
|
|
92
92
|
},
|
|
93
|
+
...[
|
|
94
|
+
'settingsDefaultAccess',
|
|
95
|
+
'settingsDefaultAccessCreate',
|
|
96
|
+
'settingsDefaultAccessEdit',
|
|
97
|
+
].map((name) => ({
|
|
98
|
+
name,
|
|
99
|
+
authorizedAs: 'settings:authorization.default-access',
|
|
100
|
+
})),
|
|
93
101
|
]);
|
|
94
102
|
for (const loader of componentLoadersIn(built.routes))
|
|
95
103
|
await expect(loader()).resolves.toMatchObject({
|
|
@@ -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,62 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveAppRuntime } from '@nocobase/app-client/runtime';
|
|
3
|
+
import { defineAppRuntime } from '@nocobase/app-client/runtime';
|
|
4
|
+
import appRuntime from '../../client/runtime.js';
|
|
5
|
+
import cliPlugins from '../../cli/plugins.js';
|
|
6
|
+
import serverPlugins from '../../server/plugins.js';
|
|
7
|
+
import settingsRoutes from '../../client/pages/settings/routes.js';
|
|
8
|
+
import { buildRouteNavigation } from '../../client/routing/route-navigation.js';
|
|
9
|
+
|
|
10
|
+
describe('default Foundation integration', () => {
|
|
11
|
+
it('registers the Foundation deployment evidence build hook', () => {
|
|
12
|
+
expect(
|
|
13
|
+
cliPlugins.plugins.filter(
|
|
14
|
+
(plugin) => plugin.packageName === '@nocobase/app-plugin-foundation',
|
|
15
|
+
),
|
|
16
|
+
).toHaveLength(1);
|
|
17
|
+
});
|
|
18
|
+
it('exposes Foundation in settings for an unrestricted administrator and removes the empty group when denied', async () => {
|
|
19
|
+
const runtime = await resolveAppRuntime(appRuntime);
|
|
20
|
+
const menu = buildRouteNavigation(runtime.routes, new Set());
|
|
21
|
+
const group = menu.find(({ route }) => route.name === 'foundationSettings');
|
|
22
|
+
const page = group?.children.find(
|
|
23
|
+
({ route }) => route.name === 'foundation',
|
|
24
|
+
)?.route;
|
|
25
|
+
expect(page).toMatchObject({
|
|
26
|
+
path: '/foundation',
|
|
27
|
+
auth: 'required',
|
|
28
|
+
authz: 'unrestricted',
|
|
29
|
+
});
|
|
30
|
+
if (!page) throw new Error('Foundation route missing');
|
|
31
|
+
expect(
|
|
32
|
+
buildRouteNavigation(
|
|
33
|
+
runtime.routes,
|
|
34
|
+
new Set([`${page.packageName}:${page.path}:${page.id}`]),
|
|
35
|
+
).some(({ route }) => route.name === 'foundationSettings'),
|
|
36
|
+
).toBe(false);
|
|
37
|
+
expect(
|
|
38
|
+
serverPlugins.plugins.filter(
|
|
39
|
+
(plugin) => plugin.packageName === '@nocobase/app-plugin-foundation',
|
|
40
|
+
),
|
|
41
|
+
).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('merges Foundation into business settings without changing its protected URL', async () => {
|
|
45
|
+
const runtime = await resolveAppRuntime(
|
|
46
|
+
defineAppRuntime({
|
|
47
|
+
...appRuntime,
|
|
48
|
+
routes: [...appRuntime.routes, settingsRoutes],
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
const menu = buildRouteNavigation(runtime.routes, new Set());
|
|
52
|
+
expect(menu.some(({ route }) => route.name === 'foundationSettings')).toBe(
|
|
53
|
+
false,
|
|
54
|
+
);
|
|
55
|
+
const settings = menu.find(({ route }) => route.name === 'settings');
|
|
56
|
+
expect(settings?.children.at(-1)?.route).toMatchObject({
|
|
57
|
+
name: 'foundation',
|
|
58
|
+
path: '/foundation',
|
|
59
|
+
authz: 'unrestricted',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -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
|
@@ -15,9 +15,9 @@ export default createReactVitestConfig({
|
|
|
15
15
|
/@nocobase\/app-testing\/(?:dist\/)?src\/client\//u,
|
|
16
16
|
// AppClientProviders includes the navigation guard and must use that same router context.
|
|
17
17
|
/@nocobase\/app-client\//u,
|
|
18
|
-
//
|
|
18
|
+
// Client plugins in every scope, including @acme/app-plugin-*, must share the transformed runtime.
|
|
19
19
|
// Keep server/shared modules external so their service identities remain unchanged.
|
|
20
|
-
/@
|
|
20
|
+
/@[^/]+\/app-plugin-[^/]+\/(?:dist\/)?client\//u,
|
|
21
21
|
],
|
|
22
22
|
},
|
|
23
23
|
},
|