@nocobase/app-template-default 1.0.0-beta.68 → 1.0.0-beta.69
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 +1 -1
- package/.agents/rules/shell.md +3 -1
- package/AGENTS.md +5 -3
- package/Dockerfile.dockerignore +0 -1
- package/README.MD +3 -1
- package/client/app.ts +15 -0
- package/client/components/data-table/column-header.tsx +91 -0
- package/client/components/data-table/data-table.tsx +216 -0
- package/client/components/data-table/index.ts +13 -0
- package/client/components/data-table/pagination.tsx +165 -0
- package/client/components/data-table/view-options.tsx +77 -0
- package/client/components/permission-editor.tsx +589 -0
- package/client/components/permission-filter.tsx +455 -0
- package/client/components/route-overlay.tsx +26 -13
- package/client/components/ui/checkbox.tsx +25 -0
- package/client/components/ui/sidebar.tsx +1 -4
- package/client/components/ui/switch.tsx +30 -0
- package/client/components/ui/table.tsx +113 -0
- package/client/layouts/components/app-sidebar.tsx +1 -1
- package/client/layouts/use-sidebar-preference.ts +62 -42
- package/client/locales/system/en-US.ts +7 -0
- package/client/locales/system/settings-en-US.ts +141 -0
- package/client/locales/system/settings-zh-CN.ts +135 -0
- package/client/locales/system/zh-CN.ts +7 -0
- package/client/pages/auth/auth-page.css +15 -0
- package/client/pages/auth/login.tsx +1 -1
- package/client/pages/auth/shared.tsx +3 -1
- package/client/pages/settings/hooks.tsx +137 -0
- package/client/pages/settings/permission-filters.tsx +119 -0
- package/client/pages/settings/permissions-model.ts +260 -0
- package/client/pages/settings/role-form.tsx +426 -0
- package/client/pages/settings/roles.tsx +163 -0
- package/client/pages/settings/routes.ts +79 -0
- package/client/pages/settings/shared.tsx +119 -0
- package/client/pages/settings/user-detail.tsx +268 -0
- package/client/pages/settings/user-form.tsx +427 -0
- package/client/pages/settings/users.tsx +451 -0
- package/client/routing/app-router.tsx +4 -1
- package/client/routing/session-preparation.tsx +65 -0
- package/client/styles.css +4 -7
- package/package.json +24 -23
- package/tests/components/app-layout-routes.test.tsx +14 -7
- package/tests/components/data-table.test.tsx +127 -0
- package/tests/components/layout-i18n.test.tsx +8 -1
- package/tests/components/navigation-menu.test.tsx +24 -14
- package/tests/components/permission-editor.test.tsx +477 -0
- package/tests/components/permission-filter-binding.test.tsx +150 -0
- package/tests/components/permission-filter.test.tsx +202 -0
- package/tests/components/settings.test.tsx +294 -0
- package/tests/components/sidebar-permissions.test.tsx +12 -4
- package/tests/components/sidebar-preference.test.tsx +209 -14
- package/tests/components/user-permissions.test.tsx +142 -0
- package/tests/fixtures/theme-tokens.tsx +9 -1
- package/tests/logic/asset-url-build.test.ts +0 -1
- package/tests/logic/client-routes.test.ts +40 -0
- package/tests/logic/client-shell.test.tsx +10 -2
- package/tests/logic/config-example.test.ts +30 -0
- package/tests/logic/dev-client-watch.test.ts +8 -1
- package/tests/logic/route-overlay.test.tsx +42 -0
- package/tests/logic/session-preparation.test.tsx +223 -0
- package/tests/logic/settings-permissions.test.ts +168 -0
- package/vite.config.ts +0 -22
- package/client/agent-annotations-host.ts +0 -25
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { PermissionEditor } from '../../client/components/permission-editor.js';
|
|
6
|
+
const sections = [
|
|
7
|
+
{
|
|
8
|
+
id: 'business',
|
|
9
|
+
title: 'Business actions',
|
|
10
|
+
groups: [
|
|
11
|
+
{
|
|
12
|
+
id: 'orders',
|
|
13
|
+
title: 'Orders',
|
|
14
|
+
rows: [
|
|
15
|
+
{ id: 'read-orders', label: 'Read orders', enabled: true },
|
|
16
|
+
{
|
|
17
|
+
id: 'edit-orders',
|
|
18
|
+
label: 'Edit orders',
|
|
19
|
+
hint: 'Change the owner',
|
|
20
|
+
enabled: false,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'settings',
|
|
28
|
+
title: 'System settings',
|
|
29
|
+
groups: [
|
|
30
|
+
{
|
|
31
|
+
id: 'users',
|
|
32
|
+
title: 'Users',
|
|
33
|
+
rows: [{ id: 'manage-users', label: 'Manage users', enabled: false }],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
describe('permission workspace', () => {
|
|
39
|
+
it('filters across categories without changing hidden permissions and reports only explicit edits', async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
const enabled = vi.fn();
|
|
42
|
+
render(
|
|
43
|
+
<PermissionEditor
|
|
44
|
+
sections={sections}
|
|
45
|
+
onEnabledChange={enabled}
|
|
46
|
+
onLevelChange={vi.fn()}
|
|
47
|
+
/>,
|
|
48
|
+
);
|
|
49
|
+
expect(
|
|
50
|
+
screen.queryByRole('checkbox', { name: 'Manage users' }),
|
|
51
|
+
).not.toBeInTheDocument();
|
|
52
|
+
await user.click(screen.getByRole('button', { name: /System settings/ }));
|
|
53
|
+
expect(
|
|
54
|
+
screen.getByRole('checkbox', { name: 'Manage users' }),
|
|
55
|
+
).not.toBeChecked();
|
|
56
|
+
await user.type(screen.getByRole('textbox'), 'owner');
|
|
57
|
+
expect(
|
|
58
|
+
screen.getByRole('checkbox', { name: /Edit orders/ }),
|
|
59
|
+
).not.toBeChecked();
|
|
60
|
+
expect(enabled).not.toHaveBeenCalled();
|
|
61
|
+
await user.click(screen.getByRole('checkbox', { name: /Edit orders/ }));
|
|
62
|
+
expect(enabled).toHaveBeenCalledExactlyOnceWith('edit-orders', true);
|
|
63
|
+
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
|
|
64
|
+
expect(
|
|
65
|
+
screen.getByRole('checkbox', { name: 'Manage users' }),
|
|
66
|
+
).not.toBeChecked();
|
|
67
|
+
});
|
|
68
|
+
it('keeps counts stable, distinguishes no results, and permits searching a readonly workspace', async () => {
|
|
69
|
+
const user = userEvent.setup();
|
|
70
|
+
const enabled = vi.fn();
|
|
71
|
+
render(
|
|
72
|
+
<PermissionEditor
|
|
73
|
+
sections={sections}
|
|
74
|
+
readOnly
|
|
75
|
+
onEnabledChange={enabled}
|
|
76
|
+
onLevelChange={vi.fn()}
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
await user.type(screen.getByRole('textbox'), 'not found');
|
|
80
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
81
|
+
'No matching permissions',
|
|
82
|
+
);
|
|
83
|
+
expect(
|
|
84
|
+
screen.getByRole('button', { name: /Business actions/ }),
|
|
85
|
+
).toHaveTextContent('1/2');
|
|
86
|
+
await user.clear(screen.getByRole('textbox'));
|
|
87
|
+
expect(
|
|
88
|
+
screen.getByRole('checkbox', { name: 'Read orders' }),
|
|
89
|
+
).toHaveAttribute('aria-disabled', 'true');
|
|
90
|
+
await user.click(screen.getByRole('checkbox', { name: 'Read orders' }));
|
|
91
|
+
expect(enabled).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('compact resource actions', () => {
|
|
96
|
+
it('batches only visible editable changes and shows a mixed group checkbox', async () => {
|
|
97
|
+
const user = userEvent.setup();
|
|
98
|
+
const batch = vi.fn();
|
|
99
|
+
render(
|
|
100
|
+
<PermissionEditor
|
|
101
|
+
sections={[
|
|
102
|
+
{
|
|
103
|
+
...sections[0]!,
|
|
104
|
+
groups: [
|
|
105
|
+
{
|
|
106
|
+
id: 'orders',
|
|
107
|
+
title: 'Orders',
|
|
108
|
+
rows: [
|
|
109
|
+
...sections[0]!.groups[0]!.rows,
|
|
110
|
+
{
|
|
111
|
+
id: 'locked',
|
|
112
|
+
label: 'Protected',
|
|
113
|
+
enabled: false,
|
|
114
|
+
toggleable: false,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
]}
|
|
121
|
+
onEnabledChange={vi.fn()}
|
|
122
|
+
onEnabledBatchChange={batch}
|
|
123
|
+
onLevelChange={vi.fn()}
|
|
124
|
+
/>,
|
|
125
|
+
);
|
|
126
|
+
const group = screen.getByRole('checkbox', {
|
|
127
|
+
name: 'Select visible permissions in Orders',
|
|
128
|
+
});
|
|
129
|
+
expect(group).toHaveAttribute('aria-checked', 'mixed');
|
|
130
|
+
await user.click(group);
|
|
131
|
+
expect(batch).toHaveBeenLastCalledWith([
|
|
132
|
+
{ id: 'edit-orders', enabled: true },
|
|
133
|
+
]);
|
|
134
|
+
await user.type(screen.getByRole('textbox'), 'owner');
|
|
135
|
+
await user.click(
|
|
136
|
+
screen.getByRole('checkbox', {
|
|
137
|
+
name: 'Select visible permissions in Orders',
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
expect(batch).toHaveBeenLastCalledWith([
|
|
141
|
+
{ id: 'edit-orders', enabled: true },
|
|
142
|
+
]);
|
|
143
|
+
expect(
|
|
144
|
+
screen.queryByRole('checkbox', { name: 'Protected' }),
|
|
145
|
+
).not.toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
it('restores stable focus when enabled filtering removes the action or group', async () => {
|
|
148
|
+
const user = userEvent.setup();
|
|
149
|
+
function Draft() {
|
|
150
|
+
const [enabled, setEnabled] = useState(true);
|
|
151
|
+
return (
|
|
152
|
+
<PermissionEditor
|
|
153
|
+
sections={[
|
|
154
|
+
{
|
|
155
|
+
id: 's',
|
|
156
|
+
title: 'Section',
|
|
157
|
+
groups: [
|
|
158
|
+
{
|
|
159
|
+
id: 'g',
|
|
160
|
+
title: 'Group',
|
|
161
|
+
rows: [
|
|
162
|
+
{ id: 'a', label: 'Action', enabled },
|
|
163
|
+
{
|
|
164
|
+
id: 'hidden',
|
|
165
|
+
label: 'Hidden',
|
|
166
|
+
enabled: false,
|
|
167
|
+
toggleable: false,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
]}
|
|
174
|
+
onEnabledChange={(_, on) => setEnabled(on)}
|
|
175
|
+
onEnabledBatchChange={(changes) => setEnabled(changes[0]!.enabled)}
|
|
176
|
+
onLevelChange={vi.fn()}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const view = render(<Draft />);
|
|
181
|
+
await user.click(screen.getByRole('switch', { name: 'Enabled only' }));
|
|
182
|
+
await user.click(screen.getByRole('checkbox', { name: 'Action' }));
|
|
183
|
+
expect(screen.getByRole('textbox')).toHaveFocus();
|
|
184
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
185
|
+
'No matching permissions',
|
|
186
|
+
);
|
|
187
|
+
view.unmount();
|
|
188
|
+
render(<Draft />);
|
|
189
|
+
await user.click(screen.getByRole('switch', { name: 'Enabled only' }));
|
|
190
|
+
screen
|
|
191
|
+
.getByRole('checkbox', { name: 'Select visible permissions in Group' })
|
|
192
|
+
.focus();
|
|
193
|
+
await user.keyboard('[Space]');
|
|
194
|
+
expect(screen.getByRole('textbox')).toHaveFocus();
|
|
195
|
+
});
|
|
196
|
+
it('keeps scopes attached to actions, readonly scopes disabled and control ids instance-local', async () => {
|
|
197
|
+
const scoped = [
|
|
198
|
+
{
|
|
199
|
+
id: 's',
|
|
200
|
+
title: 'Section',
|
|
201
|
+
groups: [
|
|
202
|
+
{
|
|
203
|
+
id: 'g',
|
|
204
|
+
title: 'Orders',
|
|
205
|
+
rows: [
|
|
206
|
+
{
|
|
207
|
+
id: 'edit',
|
|
208
|
+
label: 'Edit',
|
|
209
|
+
enabled: true,
|
|
210
|
+
scopes: [
|
|
211
|
+
{
|
|
212
|
+
id: 'edit-records',
|
|
213
|
+
label: 'Records',
|
|
214
|
+
level: 'mine',
|
|
215
|
+
levels: [
|
|
216
|
+
{ value: 'mine', label: 'Mine' },
|
|
217
|
+
{ value: 'all', label: 'All' },
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
const change = vi.fn();
|
|
228
|
+
const view = render(
|
|
229
|
+
<PermissionEditor
|
|
230
|
+
sections={scoped}
|
|
231
|
+
onEnabledChange={vi.fn()}
|
|
232
|
+
onLevelChange={change}
|
|
233
|
+
/>,
|
|
234
|
+
);
|
|
235
|
+
expect(
|
|
236
|
+
view.container.querySelectorAll('[data-slot=permission-row]'),
|
|
237
|
+
).toHaveLength(1);
|
|
238
|
+
await userEvent.click(
|
|
239
|
+
screen.getByRole('combobox', { name: 'How far Edit · Records reaches' }),
|
|
240
|
+
);
|
|
241
|
+
await userEvent.click(await screen.findByRole('option', { name: 'All' }));
|
|
242
|
+
expect(change).toHaveBeenCalledWith('edit-records', 'all');
|
|
243
|
+
view.unmount();
|
|
244
|
+
render(
|
|
245
|
+
<>
|
|
246
|
+
<PermissionEditor
|
|
247
|
+
sections={scoped}
|
|
248
|
+
readOnly
|
|
249
|
+
onEnabledChange={vi.fn()}
|
|
250
|
+
onLevelChange={vi.fn()}
|
|
251
|
+
/>
|
|
252
|
+
<PermissionEditor
|
|
253
|
+
sections={scoped}
|
|
254
|
+
readOnly
|
|
255
|
+
onEnabledChange={vi.fn()}
|
|
256
|
+
onLevelChange={vi.fn()}
|
|
257
|
+
/>
|
|
258
|
+
</>,
|
|
259
|
+
);
|
|
260
|
+
const boxes = screen.getAllByRole('checkbox', { name: 'Edit' });
|
|
261
|
+
expect(boxes[0]!.id).not.toBe(boxes[1]!.id);
|
|
262
|
+
expect(screen.getAllByRole('combobox')[0]).toBeDisabled();
|
|
263
|
+
expect(
|
|
264
|
+
screen.queryByRole('checkbox', { name: /Select visible/ }),
|
|
265
|
+
).not.toBeInTheDocument();
|
|
266
|
+
});
|
|
267
|
+
it('does not submit an enclosing role form while searching or switching categories', async () => {
|
|
268
|
+
const submit = vi.fn((event) => event.preventDefault());
|
|
269
|
+
render(
|
|
270
|
+
<form onSubmit={submit}>
|
|
271
|
+
<PermissionEditor
|
|
272
|
+
sections={sections}
|
|
273
|
+
onEnabledChange={vi.fn()}
|
|
274
|
+
onLevelChange={vi.fn()}
|
|
275
|
+
/>
|
|
276
|
+
</form>,
|
|
277
|
+
);
|
|
278
|
+
await userEvent.click(
|
|
279
|
+
screen.getByRole('button', { name: /System settings/ }),
|
|
280
|
+
);
|
|
281
|
+
await userEvent.type(screen.getByRole('textbox'), 'no match');
|
|
282
|
+
await userEvent.click(
|
|
283
|
+
screen.getByRole('button', { name: 'Clear filters' }),
|
|
284
|
+
);
|
|
285
|
+
expect(submit).not.toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('shows a single page access checkbox without a duplicate access action or resource counter', async () => {
|
|
290
|
+
const onEnabledChange = vi.fn();
|
|
291
|
+
const onEnabledBatchChange = vi.fn();
|
|
292
|
+
render(
|
|
293
|
+
<PermissionEditor
|
|
294
|
+
sections={[
|
|
295
|
+
{
|
|
296
|
+
id: 'pages',
|
|
297
|
+
title: 'Page access',
|
|
298
|
+
groups: [
|
|
299
|
+
{
|
|
300
|
+
id: 'expenses',
|
|
301
|
+
title: 'Expense registration',
|
|
302
|
+
accessOnly: true,
|
|
303
|
+
rows: [
|
|
304
|
+
{ id: 'page-expenses-access', label: 'Access', enabled: true },
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
]}
|
|
310
|
+
onEnabledChange={onEnabledChange}
|
|
311
|
+
onEnabledBatchChange={onEnabledBatchChange}
|
|
312
|
+
onLevelChange={vi.fn()}
|
|
313
|
+
/>,
|
|
314
|
+
);
|
|
315
|
+
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
|
|
316
|
+
expect(screen.queryByText('Access')).not.toBeInTheDocument();
|
|
317
|
+
expect(screen.queryByText('1 of 1 enabled')).not.toBeInTheDocument();
|
|
318
|
+
await userEvent.click(
|
|
319
|
+
screen.getByRole('checkbox', { name: 'Expense registration' }),
|
|
320
|
+
);
|
|
321
|
+
expect(onEnabledChange).toHaveBeenCalledExactlyOnceWith(
|
|
322
|
+
'page-expenses-access',
|
|
323
|
+
false,
|
|
324
|
+
);
|
|
325
|
+
expect(onEnabledBatchChange).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('keeps multiple inline scopes independently named and editable', async () => {
|
|
329
|
+
const change = vi.fn();
|
|
330
|
+
render(
|
|
331
|
+
<PermissionEditor
|
|
332
|
+
sections={[
|
|
333
|
+
{
|
|
334
|
+
id: 's',
|
|
335
|
+
title: 'Business',
|
|
336
|
+
groups: [
|
|
337
|
+
{
|
|
338
|
+
id: 'g',
|
|
339
|
+
title: 'Expenses',
|
|
340
|
+
rows: [
|
|
341
|
+
{
|
|
342
|
+
id: 'view',
|
|
343
|
+
label: 'View',
|
|
344
|
+
enabled: true,
|
|
345
|
+
scopes: ['Records', 'Attachments'].map((label) => ({
|
|
346
|
+
id: label.toLowerCase(),
|
|
347
|
+
label,
|
|
348
|
+
level: 'own',
|
|
349
|
+
levels: [
|
|
350
|
+
{ value: 'own', label: 'Own' },
|
|
351
|
+
{ value: 'all', label: 'All' },
|
|
352
|
+
],
|
|
353
|
+
})),
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
},
|
|
359
|
+
]}
|
|
360
|
+
onEnabledChange={vi.fn()}
|
|
361
|
+
onLevelChange={change}
|
|
362
|
+
/>,
|
|
363
|
+
);
|
|
364
|
+
expect(screen.getByText('Records')).toBeVisible();
|
|
365
|
+
expect(screen.getByText('Attachments')).toBeVisible();
|
|
366
|
+
await userEvent.click(
|
|
367
|
+
screen.getByRole('combobox', {
|
|
368
|
+
name: 'How far View · Attachments reaches',
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
await userEvent.click(await screen.findByRole('option', { name: 'All' }));
|
|
372
|
+
expect(change).toHaveBeenCalledExactlyOnceWith('attachments', 'all');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('keeps the category heading consistent for a single resource and labels bulk selection explicitly', async () => {
|
|
376
|
+
const batch = vi.fn();
|
|
377
|
+
render(
|
|
378
|
+
<PermissionEditor
|
|
379
|
+
sections={[
|
|
380
|
+
{
|
|
381
|
+
id: 'users',
|
|
382
|
+
title: 'Users',
|
|
383
|
+
groups: [
|
|
384
|
+
{
|
|
385
|
+
id: 'users',
|
|
386
|
+
title: 'Users',
|
|
387
|
+
rows: [
|
|
388
|
+
{
|
|
389
|
+
id: 'read',
|
|
390
|
+
label: 'View users',
|
|
391
|
+
actionKey: 'read',
|
|
392
|
+
enabled: true,
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
id: 'edit',
|
|
396
|
+
label: 'Edit users',
|
|
397
|
+
actionKey: 'update',
|
|
398
|
+
enabled: false,
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
},
|
|
404
|
+
]}
|
|
405
|
+
onEnabledChange={vi.fn()}
|
|
406
|
+
onEnabledBatchChange={batch}
|
|
407
|
+
onLevelChange={vi.fn()}
|
|
408
|
+
/>,
|
|
409
|
+
);
|
|
410
|
+
expect(
|
|
411
|
+
screen.getByRole('heading', { level: 2, name: 'Users' }),
|
|
412
|
+
).toBeInTheDocument();
|
|
413
|
+
expect(screen.getAllByText('1/2')).toHaveLength(1);
|
|
414
|
+
expect(
|
|
415
|
+
screen.getByRole('checkbox', {
|
|
416
|
+
name: 'Select visible permissions in Users',
|
|
417
|
+
}),
|
|
418
|
+
).toHaveAttribute('aria-checked', 'mixed');
|
|
419
|
+
await userEvent.click(screen.getByText('All', { selector: 'span' }));
|
|
420
|
+
expect(batch).toHaveBeenCalledExactlyOnceWith([
|
|
421
|
+
{ id: 'edit', enabled: true },
|
|
422
|
+
]);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('aligns semantic actions across resources without dropping special or filtered actions', async () => {
|
|
426
|
+
const groups = [
|
|
427
|
+
{
|
|
428
|
+
id: 'one',
|
|
429
|
+
title: 'One',
|
|
430
|
+
rows: [
|
|
431
|
+
{
|
|
432
|
+
id: 'edit-one',
|
|
433
|
+
actionKey: 'update',
|
|
434
|
+
label: 'Edit one',
|
|
435
|
+
enabled: true,
|
|
436
|
+
},
|
|
437
|
+
{ id: 'read-one', actionKey: 'read', label: 'View one', enabled: true },
|
|
438
|
+
],
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
id: 'two',
|
|
442
|
+
title: 'Two',
|
|
443
|
+
rows: [
|
|
444
|
+
{ id: 'read-two', actionKey: 'read', label: 'View two', enabled: true },
|
|
445
|
+
{
|
|
446
|
+
id: 'special',
|
|
447
|
+
actionKey: 'export',
|
|
448
|
+
label: 'Export two',
|
|
449
|
+
enabled: false,
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
];
|
|
454
|
+
const view = render(
|
|
455
|
+
<PermissionEditor
|
|
456
|
+
sections={[{ id: 'business', title: 'Business', groups }]}
|
|
457
|
+
onEnabledChange={vi.fn()}
|
|
458
|
+
onLevelChange={vi.fn()}
|
|
459
|
+
/>,
|
|
460
|
+
);
|
|
461
|
+
const column = (name: string) =>
|
|
462
|
+
screen
|
|
463
|
+
.getByRole('checkbox', { name })
|
|
464
|
+
.closest('[data-slot=permission-row]')!
|
|
465
|
+
.parentElement!.style.getPropertyValue('--permission-column');
|
|
466
|
+
expect(column('View one')).toBe(column('View two'));
|
|
467
|
+
expect(column('Edit one')).not.toBe(column('Export two'));
|
|
468
|
+
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
|
|
469
|
+
await userEvent.type(screen.getByRole('textbox'), 'Export');
|
|
470
|
+
expect(
|
|
471
|
+
screen.getByRole('checkbox', { name: 'Export two' }),
|
|
472
|
+
).toBeInTheDocument();
|
|
473
|
+
expect(column('Export two')).toBe('3');
|
|
474
|
+
expect(
|
|
475
|
+
view.container.querySelectorAll('[data-slot=permission-row]'),
|
|
476
|
+
).toHaveLength(1);
|
|
477
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { expect, it, vi } from 'vitest';
|
|
5
|
+
import type {
|
|
6
|
+
AuthorizationOptionsResponse,
|
|
7
|
+
PermissionGrant,
|
|
8
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
9
|
+
import { PermissionEditor } from '../../client/components/permission-editor.js';
|
|
10
|
+
import { configurePermissionScopes } from '../../client/pages/settings/permission-filters.js';
|
|
11
|
+
import {
|
|
12
|
+
permissionSections,
|
|
13
|
+
setScope,
|
|
14
|
+
} from '../../client/pages/settings/permissions-model.js';
|
|
15
|
+
import enUS from '../../client/locales/en-US.js';
|
|
16
|
+
const catalog = {
|
|
17
|
+
sections: [
|
|
18
|
+
{
|
|
19
|
+
name: 'business',
|
|
20
|
+
title: 'Business',
|
|
21
|
+
subsections: [
|
|
22
|
+
{
|
|
23
|
+
name: 'expenses',
|
|
24
|
+
title: 'Expenses',
|
|
25
|
+
resources: [
|
|
26
|
+
{
|
|
27
|
+
type: 'composite',
|
|
28
|
+
id: 'expenses.records',
|
|
29
|
+
title: 'Expenses',
|
|
30
|
+
actions: [{ name: 'view', title: 'View' }],
|
|
31
|
+
dataScopes: {
|
|
32
|
+
view: [
|
|
33
|
+
{
|
|
34
|
+
key: 'records',
|
|
35
|
+
title: 'Records',
|
|
36
|
+
collection: 'expenses',
|
|
37
|
+
fields: ['department'],
|
|
38
|
+
recordAccess: ['recordsIOwn', 'customFilter'],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
recordAccess: [
|
|
49
|
+
{ key: 'recordsIOwn', title: 'Own', collections: ['expenses'] },
|
|
50
|
+
{ key: 'customFilter', title: 'Custom', collections: ['expenses'] },
|
|
51
|
+
],
|
|
52
|
+
} as unknown as AuthorizationOptionsResponse;
|
|
53
|
+
function text(key: string, fallback?: string): string {
|
|
54
|
+
let value: unknown = enUS;
|
|
55
|
+
for (const part of key.split('.'))
|
|
56
|
+
value =
|
|
57
|
+
value && typeof value === 'object' ? Reflect.get(value, part) : undefined;
|
|
58
|
+
return typeof value === 'string' ? value : (fallback ?? key);
|
|
59
|
+
}
|
|
60
|
+
it('collects native custom filter parameters before changing the draft, then saves and reopens them', async () => {
|
|
61
|
+
const user = userEvent.setup(),
|
|
62
|
+
save = vi.fn();
|
|
63
|
+
function Role() {
|
|
64
|
+
const [grants, setGrants] = useState<readonly PermissionGrant[]>([
|
|
65
|
+
{
|
|
66
|
+
resource: { type: 'composite', id: 'expenses.records' },
|
|
67
|
+
actions: [
|
|
68
|
+
{
|
|
69
|
+
action: 'view',
|
|
70
|
+
policy: {
|
|
71
|
+
type: 'composite',
|
|
72
|
+
scopes: { records: 'recordsIOwn', unrelated: { keep: true } },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
const [editing, setEditing] = useState<string>();
|
|
79
|
+
const sections = configurePermissionScopes(
|
|
80
|
+
permissionSections(catalog, grants, (value) =>
|
|
81
|
+
typeof value === 'string' ? value : text(value.key),
|
|
82
|
+
),
|
|
83
|
+
catalog,
|
|
84
|
+
grants,
|
|
85
|
+
text,
|
|
86
|
+
editing,
|
|
87
|
+
setEditing,
|
|
88
|
+
(id, value) => setGrants((current) => setScope(current, id, value)),
|
|
89
|
+
false,
|
|
90
|
+
);
|
|
91
|
+
return (
|
|
92
|
+
<form
|
|
93
|
+
onSubmit={(event) => {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
save(grants);
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<PermissionEditor
|
|
99
|
+
sections={sections}
|
|
100
|
+
onEnabledChange={vi.fn()}
|
|
101
|
+
onLevelChange={(id, level) => {
|
|
102
|
+
if (level === 'customFilter') setEditing(id);
|
|
103
|
+
else setGrants((current) => setScope(current, id, level));
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
<button type='submit'>Save role</button>
|
|
107
|
+
</form>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
render(<Role />);
|
|
111
|
+
const scope = screen.getByRole('combobox', {
|
|
112
|
+
name: 'How far View · Records reaches',
|
|
113
|
+
});
|
|
114
|
+
expect(scope).toHaveTextContent('Own');
|
|
115
|
+
await user.click(scope);
|
|
116
|
+
await user.click(await screen.findByRole('option', { name: 'Custom' }));
|
|
117
|
+
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
|
118
|
+
expect(save).not.toHaveBeenCalled();
|
|
119
|
+
await user.type(screen.getByRole('textbox', { name: 'Value 1' }), 'Finance');
|
|
120
|
+
await user.keyboard('{Enter}');
|
|
121
|
+
expect(save).not.toHaveBeenCalled();
|
|
122
|
+
await user.click(screen.getByRole('button', { name: 'Apply' }));
|
|
123
|
+
expect(scope).toHaveTextContent('Custom');
|
|
124
|
+
await user.click(screen.getByRole('button', { name: 'Save role' }));
|
|
125
|
+
expect(save.mock.calls[0]?.[0][0].actions[0].policy.scopes).toEqual({
|
|
126
|
+
records: {
|
|
127
|
+
type: 'recordAccess',
|
|
128
|
+
key: 'customFilter',
|
|
129
|
+
params: {
|
|
130
|
+
filter: {
|
|
131
|
+
kind: 'group',
|
|
132
|
+
logic: 'and',
|
|
133
|
+
items: [
|
|
134
|
+
{
|
|
135
|
+
kind: 'condition',
|
|
136
|
+
path: ['department'],
|
|
137
|
+
operator: '$eq',
|
|
138
|
+
value: 'Finance',
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
unrelated: { keep: true },
|
|
145
|
+
});
|
|
146
|
+
await user.click(screen.getByRole('button', { name: 'Conditions' }));
|
|
147
|
+
expect(screen.getByRole('textbox', { name: 'Value 1' })).toHaveValue(
|
|
148
|
+
'Finance',
|
|
149
|
+
);
|
|
150
|
+
});
|