@nocobase/app-template-default 1.0.0-beta.69 → 1.0.0-beta.71
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/rules/components.md +4 -0
- package/AGENTS.md +3 -1
- package/client/components/flow-diagram-css.d.ts +2 -0
- package/client/components/flow-diagram.tsx +674 -0
- package/client/components/flow-layout.ts +250 -0
- package/client/components/lifecycle-diagram-model.ts +217 -0
- package/client/components/lifecycle-diagram.tsx +310 -0
- package/client/components/permission-editor.tsx +111 -73
- package/client/extensions/nocobase-workflow-management/README.md +70 -0
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +148 -0
- package/client/extensions/nocobase-workflow-management/execution-detail.tsx +230 -0
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +175 -0
- package/client/extensions/nocobase-workflow-management/layout.tsx +95 -0
- package/client/locales/system/en-US.ts +22 -0
- package/client/locales/system/settings-en-US.ts +2 -0
- package/client/locales/system/settings-zh-CN.ts +8 -6
- package/client/locales/system/zh-CN.ts +22 -0
- package/client/pages/settings/page-permission-titles.ts +7 -0
- package/client/pages/settings/page-permissions.ts +51 -0
- package/client/pages/settings/permissions-model.ts +60 -31
- package/client/pages/settings/role-form.tsx +8 -29
- package/package.json +11 -9
- package/tests/components/permission-editor.test.tsx +62 -0
- package/tests/logic/page-permission-titles.test.ts +303 -0
- package/tests/logic/settings-permissions.test.ts +77 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
resolveAppClientContributions,
|
|
4
|
+
defineAppRoutes,
|
|
5
|
+
type AppClientRegisteredRoute,
|
|
6
|
+
} from '@nocobase/app-client/plugins';
|
|
7
|
+
import applicationRoutes from '../../client/routes.js';
|
|
8
|
+
import enUS from '../../client/locales/en-US.js';
|
|
9
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
10
|
+
import {
|
|
11
|
+
pagePermissionResources,
|
|
12
|
+
pagePermissionTitle,
|
|
13
|
+
} from '../../client/pages/settings/page-permissions.js';
|
|
14
|
+
import { pagePermissionTitles } from '../../client/pages/settings/page-permission-titles.js';
|
|
15
|
+
|
|
16
|
+
const flatten = (
|
|
17
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
18
|
+
): AppClientRegisteredRoute[] =>
|
|
19
|
+
routes.flatMap((route) => [route, ...flatten(route.children ?? [])]);
|
|
20
|
+
const localized = (resource: unknown, key: string): unknown =>
|
|
21
|
+
key
|
|
22
|
+
.split('.')
|
|
23
|
+
.reduce<unknown>(
|
|
24
|
+
(value, part) =>
|
|
25
|
+
value && typeof value === 'object'
|
|
26
|
+
? Reflect.get(value, part)
|
|
27
|
+
: undefined,
|
|
28
|
+
resource,
|
|
29
|
+
);
|
|
30
|
+
const application = resolveAppClientContributions([
|
|
31
|
+
{
|
|
32
|
+
packageName: 'title-check-app',
|
|
33
|
+
source: 'application',
|
|
34
|
+
routes: applicationRoutes,
|
|
35
|
+
},
|
|
36
|
+
]);
|
|
37
|
+
const pages = flatten(application.routes).filter(
|
|
38
|
+
(route) =>
|
|
39
|
+
route.source === 'application' &&
|
|
40
|
+
route.componentLoader &&
|
|
41
|
+
typeof route.authz === 'object' &&
|
|
42
|
+
route.authz.resource.type === 'page',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
describe('page permission labels', () => {
|
|
46
|
+
it('names every application page permission and translates it in both languages', () => {
|
|
47
|
+
const missing = pages
|
|
48
|
+
.filter((route) => !pagePermissionTitle(route, pagePermissionTitles))
|
|
49
|
+
.map((route) => route.name);
|
|
50
|
+
expect(
|
|
51
|
+
missing,
|
|
52
|
+
'Add a navigation/breadcrumb title or a page-permission-titles entry; never rename authorization ids to fix labels.',
|
|
53
|
+
).toEqual([]);
|
|
54
|
+
for (const route of pages) {
|
|
55
|
+
const title = pagePermissionTitle(route, pagePermissionTitles);
|
|
56
|
+
if (!title || typeof title === 'string')
|
|
57
|
+
throw new Error(`Missing permission title: ${route.name}`);
|
|
58
|
+
for (const locale of [enUS, zhCN]) {
|
|
59
|
+
const value = localized(locale, title.key);
|
|
60
|
+
expect(value, `${route.name}: ${title.key}`).toBeTypeOf('string');
|
|
61
|
+
expect(String(value).trim()).not.toBe('');
|
|
62
|
+
expect(value).not.toBe(title.key);
|
|
63
|
+
expect(value).not.toBe(route.id);
|
|
64
|
+
expect(String(value)).not.toContain('{{');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const names = new Set(
|
|
68
|
+
flatten(application.routes).map((route) => route.name),
|
|
69
|
+
);
|
|
70
|
+
for (const name of Object.keys(pagePermissionTitles))
|
|
71
|
+
expect(names.has(name), `Stale permission title: ${name}`).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('preserves each unique application page-resource identity', () => {
|
|
75
|
+
const expected = pages.map((route) => {
|
|
76
|
+
if (typeof route.authz !== 'object')
|
|
77
|
+
throw new Error('Expected page authorization');
|
|
78
|
+
return { type: 'page', id: route.authz.resource.id, actions: ['access'] };
|
|
79
|
+
});
|
|
80
|
+
expect(
|
|
81
|
+
pagePermissionResources(
|
|
82
|
+
application.routes,
|
|
83
|
+
pagePermissionTitles,
|
|
84
|
+
(path) => `Unnamed (${path})`,
|
|
85
|
+
).map(({ type, id, actions }) => ({
|
|
86
|
+
type,
|
|
87
|
+
id,
|
|
88
|
+
actions: actions.map((action) => action.name),
|
|
89
|
+
})),
|
|
90
|
+
).toEqual([
|
|
91
|
+
...new Map(expected.map((resource) => [resource.id, resource])).values(),
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const fixture = () =>
|
|
96
|
+
resolveAppClientContributions([
|
|
97
|
+
{
|
|
98
|
+
packageName: 'private-app',
|
|
99
|
+
source: 'application',
|
|
100
|
+
routes: [
|
|
101
|
+
{
|
|
102
|
+
parent: 'app',
|
|
103
|
+
routes: [
|
|
104
|
+
{
|
|
105
|
+
name: 'expenses',
|
|
106
|
+
path: '/expenses',
|
|
107
|
+
auth: 'required',
|
|
108
|
+
authz: {
|
|
109
|
+
resource: { type: 'page', id: 'legacy-expenses' },
|
|
110
|
+
action: 'access',
|
|
111
|
+
},
|
|
112
|
+
navigation: { title: 'expenses.title' },
|
|
113
|
+
componentLoader: async () => ({ default: () => null }),
|
|
114
|
+
children: [
|
|
115
|
+
{
|
|
116
|
+
name: 'expenseEdit',
|
|
117
|
+
path: ':id/edit',
|
|
118
|
+
authz: {
|
|
119
|
+
resource: { type: 'page', id: 'legacy-edit' },
|
|
120
|
+
action: 'access',
|
|
121
|
+
},
|
|
122
|
+
componentLoader: async () => ({ default: () => null }),
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
it('names an overlay without adding navigation/breadcrumbs or changing resources', () => {
|
|
133
|
+
const input = fixture();
|
|
134
|
+
const route = input.routes[0]!.children![0]!;
|
|
135
|
+
const identities = flatten(input.routes).map(
|
|
136
|
+
({ id, name, path, authz }) => ({ id, name, path, authz }),
|
|
137
|
+
);
|
|
138
|
+
const result = pagePermissionResources(
|
|
139
|
+
input.routes,
|
|
140
|
+
{ expenseEdit: 'expenses.editTitle' },
|
|
141
|
+
(path) => `Unnamed (${path})`,
|
|
142
|
+
);
|
|
143
|
+
expect(result.map(({ id, actions }) => ({ id, actions }))).toEqual([
|
|
144
|
+
{
|
|
145
|
+
id: 'legacy-expenses',
|
|
146
|
+
actions: [{ name: 'access', title: { key: 'settings.access' } }],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'legacy-edit',
|
|
150
|
+
actions: [{ name: 'access', title: { key: 'settings.access' } }],
|
|
151
|
+
},
|
|
152
|
+
]);
|
|
153
|
+
expect(result[1]!.title).toEqual({
|
|
154
|
+
key: 'expenses.editTitle',
|
|
155
|
+
ns: 'private-app',
|
|
156
|
+
});
|
|
157
|
+
expect(route.navigation).toBeUndefined();
|
|
158
|
+
expect(route.breadcrumb).toBeUndefined();
|
|
159
|
+
expect(
|
|
160
|
+
flatten(input.routes).map(({ id, name, path, authz }) => ({
|
|
161
|
+
id,
|
|
162
|
+
name,
|
|
163
|
+
path,
|
|
164
|
+
authz,
|
|
165
|
+
})),
|
|
166
|
+
).toEqual(identities);
|
|
167
|
+
expect(
|
|
168
|
+
pagePermissionResources(
|
|
169
|
+
input.routes,
|
|
170
|
+
{ expenseEdit: 'expenses.editTitle' },
|
|
171
|
+
(path) => `Unnamed (${path})`,
|
|
172
|
+
),
|
|
173
|
+
).toEqual(result);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('reports missing metadata and renders a localized fallback without dropping the permission', () => {
|
|
177
|
+
const input = fixture();
|
|
178
|
+
const overlay = input.routes[0]!.children![0]!;
|
|
179
|
+
expect(pagePermissionTitle(overlay, {})).toBeUndefined();
|
|
180
|
+
const resources = pagePermissionResources(
|
|
181
|
+
input.routes,
|
|
182
|
+
{},
|
|
183
|
+
(path) => `未命名页面(${path})`,
|
|
184
|
+
);
|
|
185
|
+
expect(resources[1]!.id).toBe('legacy-edit');
|
|
186
|
+
expect(resources[1]!.title).toBe('未命名页面(/expenses/:id/edit)');
|
|
187
|
+
expect(resources[1]!.title).not.toContain('private-app:');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('does not treat object prototype properties as configured labels', () => {
|
|
191
|
+
const route = fixture().routes[0]!.children![0]!;
|
|
192
|
+
expect(
|
|
193
|
+
pagePermissionTitle({ ...route, name: 'constructor' }, {}),
|
|
194
|
+
).toBeUndefined();
|
|
195
|
+
expect(
|
|
196
|
+
pagePermissionTitle({ ...route, name: 'toString' }, {}),
|
|
197
|
+
).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('does not apply application-name overrides to plugin routes or expose non-page resources', () => {
|
|
201
|
+
const input = fixture();
|
|
202
|
+
const overlay = input.routes[0]!.children![0]!;
|
|
203
|
+
expect(
|
|
204
|
+
pagePermissionTitle(
|
|
205
|
+
{ ...overlay, source: 'plugin', packageName: 'plugin' },
|
|
206
|
+
{ expenseEdit: 'expenses.editTitle' },
|
|
207
|
+
),
|
|
208
|
+
).toBeUndefined();
|
|
209
|
+
expect(
|
|
210
|
+
pagePermissionResources(
|
|
211
|
+
[
|
|
212
|
+
{ ...overlay, authz: 'skip' },
|
|
213
|
+
{
|
|
214
|
+
...overlay,
|
|
215
|
+
authz: {
|
|
216
|
+
resource: { type: 'settings', id: 'members' },
|
|
217
|
+
action: 'read',
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
{},
|
|
222
|
+
() => 'Unnamed',
|
|
223
|
+
),
|
|
224
|
+
).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('lists inherited and repeated page resources once while retaining independent child grants', () => {
|
|
229
|
+
const input = resolveAppClientContributions([
|
|
230
|
+
{
|
|
231
|
+
packageName: 'shared-pages',
|
|
232
|
+
source: 'application',
|
|
233
|
+
routes: defineAppRoutes([
|
|
234
|
+
{
|
|
235
|
+
name: 'repairs',
|
|
236
|
+
path: '/repairs',
|
|
237
|
+
authz: {
|
|
238
|
+
resource: { type: 'page', id: 'repairs' },
|
|
239
|
+
action: 'access',
|
|
240
|
+
},
|
|
241
|
+
navigation: { title: 'repairs.mine' },
|
|
242
|
+
componentLoader: async () => ({ default: () => null }),
|
|
243
|
+
children: [
|
|
244
|
+
{
|
|
245
|
+
name: 'repairCreate',
|
|
246
|
+
path: 'create',
|
|
247
|
+
componentLoader: async () => ({ default: () => null }),
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: 'repairDetail',
|
|
251
|
+
path: ':id',
|
|
252
|
+
componentLoader: async () => ({ default: () => null }),
|
|
253
|
+
children: [
|
|
254
|
+
{
|
|
255
|
+
name: 'repairConfirm',
|
|
256
|
+
path: 'confirm',
|
|
257
|
+
componentLoader: async () => ({ default: () => null }),
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: 'repairAudit',
|
|
263
|
+
path: 'audit',
|
|
264
|
+
authz: {
|
|
265
|
+
resource: { type: 'page', id: 'repair-audit' },
|
|
266
|
+
action: 'access',
|
|
267
|
+
},
|
|
268
|
+
navigation: { title: 'repairs.audit' },
|
|
269
|
+
componentLoader: async () => ({ default: () => null }),
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: 'repairsAlias',
|
|
275
|
+
path: '/repairs-alias',
|
|
276
|
+
authz: {
|
|
277
|
+
resource: { type: 'page', id: 'repairs' },
|
|
278
|
+
action: 'access',
|
|
279
|
+
},
|
|
280
|
+
componentLoader: async () => ({ default: () => null }),
|
|
281
|
+
},
|
|
282
|
+
]),
|
|
283
|
+
},
|
|
284
|
+
]);
|
|
285
|
+
const before = flatten(input.routes).map(({ id, name, authz }) => ({
|
|
286
|
+
id,
|
|
287
|
+
name,
|
|
288
|
+
authz,
|
|
289
|
+
}));
|
|
290
|
+
const result = pagePermissionResources(
|
|
291
|
+
input.routes,
|
|
292
|
+
{},
|
|
293
|
+
(path) => `Unnamed (${path})`,
|
|
294
|
+
);
|
|
295
|
+
expect(result.map(({ id }) => id)).toEqual(['repairs', 'repair-audit']);
|
|
296
|
+
expect(result[0]!.title).toEqual({ key: 'repairs.mine', ns: 'shared-pages' });
|
|
297
|
+
expect(
|
|
298
|
+
flatten(input.routes).map(({ id, name, authz }) => ({ id, name, authz })),
|
|
299
|
+
).toEqual(before);
|
|
300
|
+
expect(
|
|
301
|
+
pagePermissionResources(input.routes, {}, (path) => `Unnamed (${path})`),
|
|
302
|
+
).toEqual(result);
|
|
303
|
+
});
|
|
@@ -166,3 +166,80 @@ it('replaces a raw collection scope caption with localized data-range text', ()
|
|
|
166
166
|
'Record scope',
|
|
167
167
|
);
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
it('preserves an existing policy when enabling the same permission again', () => {
|
|
171
|
+
const original = [
|
|
172
|
+
{
|
|
173
|
+
resource: { type: 'composite', id: 'orders' },
|
|
174
|
+
actions: [
|
|
175
|
+
{
|
|
176
|
+
action: 'edit',
|
|
177
|
+
policy: {
|
|
178
|
+
type: 'composite',
|
|
179
|
+
scopes: {
|
|
180
|
+
records: {
|
|
181
|
+
type: 'recordAccess',
|
|
182
|
+
key: 'mine',
|
|
183
|
+
params: { ownerField: 'assignee' },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
extra: true,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
expect(setPermission(original, resource, 'edit', true)).toBe(original);
|
|
193
|
+
expect(
|
|
194
|
+
setScope(
|
|
195
|
+
original,
|
|
196
|
+
JSON.stringify(['composite', 'orders', 'edit', 'records']),
|
|
197
|
+
'mine',
|
|
198
|
+
),
|
|
199
|
+
).toBe(original);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it.each(['retiredScope', { type: 'legacyPolicy', filter: { owner: 'me' } }])(
|
|
203
|
+
'shows a preserved selection for an unrecognized scope without rewriting it: %j',
|
|
204
|
+
(value) => {
|
|
205
|
+
const catalog = {
|
|
206
|
+
sections: [
|
|
207
|
+
{
|
|
208
|
+
name: 'business',
|
|
209
|
+
title: 'Business',
|
|
210
|
+
subsections: [
|
|
211
|
+
{ name: 'orders', title: 'Orders', resources: [resource] },
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
recordAccess: [
|
|
216
|
+
{ key: 'mine', title: 'Mine' },
|
|
217
|
+
{ key: 'all', title: 'All' },
|
|
218
|
+
],
|
|
219
|
+
} as unknown as Parameters<typeof permissionSections>[0];
|
|
220
|
+
const original = [
|
|
221
|
+
{
|
|
222
|
+
resource: { type: 'composite', id: 'orders' },
|
|
223
|
+
actions: [
|
|
224
|
+
{
|
|
225
|
+
action: 'edit',
|
|
226
|
+
policy: { type: 'composite', scopes: { records: value } },
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
const before = JSON.stringify(original);
|
|
232
|
+
const sections = permissionSections(catalog, original, (text) =>
|
|
233
|
+
typeof text === 'string' ? text : text.key,
|
|
234
|
+
);
|
|
235
|
+
const scope = sections[0]!.groups[0]!.rows[0]!.scopes![0]!;
|
|
236
|
+
expect(
|
|
237
|
+
scope.levels.find((level) => level.value === scope.level)?.label,
|
|
238
|
+
).toBe('settings.preservedRecordScope');
|
|
239
|
+
expect(setScope(original, scope.id, scope.level!)).toBe(original);
|
|
240
|
+
expect(JSON.stringify(original)).toBe(before);
|
|
241
|
+
expect(
|
|
242
|
+
setScope(original, scope.id, 'all')[0]!.actions[0]!.policy!.scopes,
|
|
243
|
+
).toEqual({ records: 'all' });
|
|
244
|
+
},
|
|
245
|
+
);
|