@nocobase/app-template-default 1.0.0-beta.70 → 1.0.0-beta.72
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 +1 -1
- package/README.MD +1 -1
- package/client/components/permission-editor.tsx +111 -73
- package/client/locales/system/settings-en-US.ts +1 -0
- package/client/locales/system/settings-zh-CN.ts +7 -6
- package/client/pages/settings/page-permissions.ts +21 -12
- package/client/pages/settings/permissions-model.ts +60 -31
- package/client/service-provider.ts +0 -1
- package/config.example.yml +20 -0
- package/package.json +29 -33
- package/tests/components/app-title.test.tsx +0 -4
- package/tests/components/inbox.test.tsx +5 -1
- package/tests/components/permission-editor.test.tsx +62 -0
- package/tests/fixtures/theme-tokens.tsx +82 -85
- package/tests/logic/app-server.test.ts +10 -6
- package/tests/logic/client-runtime.test.ts +11 -15
- package/tests/logic/page-permission-titles.test.ts +82 -2
- package/tests/logic/settings-permissions.test.ts +77 -0
package/AGENTS.md
CHANGED
|
@@ -109,7 +109,7 @@ For any UI work, start with [the frontend workflow](.agents/skills/nocobase-app-
|
|
|
109
109
|
|
|
110
110
|
`client/routing/`, `client/layouts/`, `client/theme/`, the server entry points, the `package.json` scripts that call `nocobase`, and the tsconfigs are the scaffolding the template provides. It is still this application's own source — it shipped to the user and they may change it — but it is the part the template evolves, so an edit there is what a future upgrade has to reconcile.
|
|
111
111
|
|
|
112
|
-
Prefer the mechanism the system already provides. Declare a page in `client/routes.ts` and add `navigation` when it needs a menu entry
|
|
112
|
+
Prefer the mechanism the system already provides. Declare a page in `client/routes.ts` and add `navigation` when it needs a menu entry; pages load data through `useApiClient`. A page uses `authz` to restrict access; a plugin page is customized through an option or an override. Before editing the shell to add a menu, check the route and its `navigation` declaration.
|
|
113
113
|
|
|
114
114
|
When the built-in mechanism genuinely cannot express what is being asked, changing this structure is a legitimate answer — not a last resort to apologize for. Do it deliberately, and leave the next agent enough to work with:
|
|
115
115
|
|
package/README.MD
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# NocoBase 3 Application
|
|
2
2
|
|
|
3
|
-
A full-stack NocoBase 3 application. The server runs on `@nocobase/app-server` and the browser client is React with
|
|
3
|
+
A full-stack NocoBase 3 application. The server runs on `@nocobase/app-server` and the browser client is React with shadcn/ui and Tailwind CSS.
|
|
4
4
|
|
|
5
5
|
You own everything in this directory. Add pages, API routes, database tables, and business logic directly here — this is your application's source code, not a framework you extend from the outside.
|
|
6
6
|
|
|
@@ -126,7 +126,7 @@ function LevelControl({
|
|
|
126
126
|
if (levels.length === 0) return null;
|
|
127
127
|
if (levels.length === 1)
|
|
128
128
|
return (
|
|
129
|
-
<span className='
|
|
129
|
+
<span className='flex min-h-7 min-w-0 items-center px-2 text-xs text-muted-foreground'>
|
|
130
130
|
{levels[0]?.label}
|
|
131
131
|
</span>
|
|
132
132
|
);
|
|
@@ -141,7 +141,7 @@ function LevelControl({
|
|
|
141
141
|
>
|
|
142
142
|
<SelectTrigger
|
|
143
143
|
size='sm'
|
|
144
|
-
className='h-
|
|
144
|
+
className='h-7 w-full min-w-0 max-w-full flex-1 gap-1 border-0 bg-muted/50 px-2 text-xs shadow-none'
|
|
145
145
|
aria-label={label}
|
|
146
146
|
>
|
|
147
147
|
<SelectValue />
|
|
@@ -162,6 +162,7 @@ function LevelControl({
|
|
|
162
162
|
function PermissionRow({
|
|
163
163
|
row,
|
|
164
164
|
controlId,
|
|
165
|
+
showScopeLabels = false,
|
|
165
166
|
readOnly,
|
|
166
167
|
labels,
|
|
167
168
|
onEnabledChange,
|
|
@@ -169,6 +170,7 @@ function PermissionRow({
|
|
|
169
170
|
}: {
|
|
170
171
|
readonly row: PermissionEditorRow;
|
|
171
172
|
readonly controlId: string;
|
|
173
|
+
readonly showScopeLabels?: boolean;
|
|
172
174
|
readonly readOnly: boolean;
|
|
173
175
|
readonly labels: PermissionEditorLabels;
|
|
174
176
|
readonly onEnabledChange: (rowId: string, enabled: boolean) => void;
|
|
@@ -179,7 +181,7 @@ function PermissionRow({
|
|
|
179
181
|
data-slot='permission-row'
|
|
180
182
|
className='flex min-w-0 flex-col gap-1 rounded-md px-2 py-1.5'
|
|
181
183
|
>
|
|
182
|
-
<div className='
|
|
184
|
+
<div className='grid min-h-6 min-w-0 grid-cols-[auto_auto_minmax(0,1fr)] items-center gap-x-2 @2xl:grid-cols-[auto_minmax(0,1fr)] gap-y-1'>
|
|
183
185
|
<Checkbox
|
|
184
186
|
id={controlId}
|
|
185
187
|
checked={row.enabled}
|
|
@@ -189,35 +191,45 @@ function PermissionRow({
|
|
|
189
191
|
/>
|
|
190
192
|
<label
|
|
191
193
|
htmlFor={controlId}
|
|
192
|
-
className='min-w-0 cursor-pointer text-sm leading-
|
|
194
|
+
className='min-h-6 min-w-0 max-w-36 cursor-pointer text-sm leading-6 font-medium break-words @2xl:max-w-none'
|
|
193
195
|
>
|
|
194
196
|
{row.label}
|
|
195
197
|
</label>
|
|
196
198
|
{row.enabled && row.levels ? (
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
<div className='col-start-3 min-w-0 @2xl:col-start-2'>
|
|
200
|
+
<LevelControl
|
|
201
|
+
label={labels.levelFor(row.label)}
|
|
202
|
+
levels={row.levels}
|
|
203
|
+
value={row.level ?? ''}
|
|
204
|
+
disabled={readOnly}
|
|
205
|
+
onChange={(level) => onLevelChange(row.id, level)}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
204
208
|
) : null}
|
|
205
209
|
{row.enabled &&
|
|
206
210
|
row.scopes?.map((scope) => (
|
|
207
|
-
<div
|
|
208
|
-
{
|
|
209
|
-
|
|
211
|
+
<div
|
|
212
|
+
key={scope.id}
|
|
213
|
+
className={cn(
|
|
214
|
+
'col-start-3 grid min-w-0 gap-1 @2xl:col-start-2',
|
|
215
|
+
showScopeLabels && 'col-span-2 col-start-2 @2xl:col-span-1',
|
|
216
|
+
)}
|
|
217
|
+
>
|
|
218
|
+
{(showScopeLabels || (row.scopes && row.scopes.length > 1)) && (
|
|
219
|
+
<span className='text-xs leading-5 text-muted-foreground'>
|
|
210
220
|
{scope.label}
|
|
211
221
|
</span>
|
|
212
222
|
)}
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
223
|
+
<div className='flex min-h-7 min-w-0 items-center gap-1'>
|
|
224
|
+
<LevelControl
|
|
225
|
+
label={labels.levelFor(`${row.label} · ${scope.label}`)}
|
|
226
|
+
levels={scope.levels}
|
|
227
|
+
value={scope.level ?? ''}
|
|
228
|
+
disabled={readOnly}
|
|
229
|
+
onChange={(level) => onLevelChange(scope.id, level)}
|
|
230
|
+
/>
|
|
231
|
+
{scope.configuration}
|
|
232
|
+
</div>
|
|
221
233
|
</div>
|
|
222
234
|
))}
|
|
223
235
|
</div>
|
|
@@ -280,7 +292,7 @@ export function PermissionEditor({
|
|
|
280
292
|
return (
|
|
281
293
|
<div className='@container flex min-w-0 flex-col overflow-hidden rounded-xl border bg-card'>
|
|
282
294
|
<div className='flex flex-wrap items-center gap-3 border-b p-3'>
|
|
283
|
-
<div className='relative min-w-
|
|
295
|
+
<div className='relative min-w-48 flex-1'>
|
|
284
296
|
<SearchIcon
|
|
285
297
|
aria-hidden
|
|
286
298
|
className='pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground'
|
|
@@ -296,7 +308,7 @@ export function PermissionEditor({
|
|
|
296
308
|
</div>
|
|
297
309
|
<label
|
|
298
310
|
htmlFor={`${id}-enabled`}
|
|
299
|
-
className='flex items-center gap-2 text-sm'
|
|
311
|
+
className='flex shrink-0 items-center gap-2 text-sm whitespace-nowrap'
|
|
300
312
|
>
|
|
301
313
|
<Switch
|
|
302
314
|
id={`${id}-enabled`}
|
|
@@ -322,7 +334,7 @@ export function PermissionEditor({
|
|
|
322
334
|
<div className='flex max-h-128 min-w-0 flex-col overflow-auto @2xl:flex-row'>
|
|
323
335
|
<nav
|
|
324
336
|
aria-label={words.navigation}
|
|
325
|
-
className='flex shrink-0 gap-1 overflow-x-auto border-b bg-muted/25 p-2 @2xl:sticky @2xl:top-0 @2xl:max-h-128 @2xl:w-
|
|
337
|
+
className='flex shrink-0 gap-1 overflow-x-auto border-b bg-muted/25 p-2 @2xl:sticky @2xl:top-0 @2xl:max-h-128 @2xl:w-64 @2xl:self-start @2xl:flex-col @2xl:border-r @2xl:border-b-0'
|
|
326
338
|
>
|
|
327
339
|
{sections.map((section) => {
|
|
328
340
|
const rows = section.groups.flatMap((group) => group.rows);
|
|
@@ -338,13 +350,18 @@ export function PermissionEditor({
|
|
|
338
350
|
setEnabledOnly(false);
|
|
339
351
|
}}
|
|
340
352
|
className={cn(
|
|
341
|
-
'h-auto shrink-0 justify-between gap-
|
|
353
|
+
'h-auto shrink-0 justify-between gap-3 px-3 py-2 text-left whitespace-nowrap @2xl:w-full @2xl:min-w-0',
|
|
342
354
|
section.id === active &&
|
|
343
355
|
'bg-primary/10 text-primary hover:bg-primary/15',
|
|
344
356
|
)}
|
|
345
357
|
>
|
|
346
|
-
<span
|
|
347
|
-
|
|
358
|
+
<span
|
|
359
|
+
className='min-w-0 @2xl:flex-1 @2xl:truncate'
|
|
360
|
+
title={section.title}
|
|
361
|
+
>
|
|
362
|
+
{section.title}
|
|
363
|
+
</span>
|
|
364
|
+
<span className='shrink-0 rounded-md bg-background px-1.5 py-0.5 text-xs tabular-nums'>
|
|
348
365
|
{rows.filter((row) => row.enabled).length}/{rows.length}
|
|
349
366
|
</span>
|
|
350
367
|
</Button>
|
|
@@ -357,6 +374,9 @@ export function PermissionEditor({
|
|
|
357
374
|
const original =
|
|
358
375
|
sections.find((item) => item.id === section.id) ?? section;
|
|
359
376
|
const singleResource = original.groups.length === 1;
|
|
377
|
+
const showScopeLabels = original.groups.some((group) =>
|
|
378
|
+
group.rows.some((row) => (row.scopes?.length ?? 0) > 1),
|
|
379
|
+
);
|
|
360
380
|
const keys = [
|
|
361
381
|
...new Set(
|
|
362
382
|
original.groups
|
|
@@ -369,16 +389,30 @@ export function PermissionEditor({
|
|
|
369
389
|
...common.filter((key) => keys.includes(key)),
|
|
370
390
|
...keys.filter((key) => !common.includes(key)),
|
|
371
391
|
];
|
|
372
|
-
// Dense or unclassified catalogs keep the wrapping layout instead of hiding actions.
|
|
373
392
|
const aligned =
|
|
374
393
|
columns.length > 0 &&
|
|
375
|
-
columns.length <=
|
|
394
|
+
columns.length <= 4 &&
|
|
376
395
|
original.groups.every(
|
|
377
396
|
(group) =>
|
|
378
397
|
group.rows.every((row) => row.actionKey) &&
|
|
379
398
|
new Set(group.rows.map((row) => row.actionKey)).size ===
|
|
380
399
|
group.rows.length,
|
|
381
400
|
);
|
|
401
|
+
// Keep action columns only when each has room for its label and scope.
|
|
402
|
+
const alignment = [
|
|
403
|
+
'',
|
|
404
|
+
'@3xl:grid-cols-(--permission-columns)',
|
|
405
|
+
'@3xl:grid-cols-(--permission-columns)',
|
|
406
|
+
'@5xl:grid-cols-(--permission-columns)',
|
|
407
|
+
'@7xl:grid-cols-(--permission-columns)',
|
|
408
|
+
][columns.length];
|
|
409
|
+
const placement = [
|
|
410
|
+
'',
|
|
411
|
+
'@3xl:col-start-(--permission-column) @3xl:row-start-1',
|
|
412
|
+
'@3xl:col-start-(--permission-column) @3xl:row-start-1',
|
|
413
|
+
'@5xl:col-start-(--permission-column) @5xl:row-start-1',
|
|
414
|
+
'@7xl:col-start-(--permission-column) @7xl:row-start-1',
|
|
415
|
+
][columns.length];
|
|
382
416
|
return (
|
|
383
417
|
<section
|
|
384
418
|
key={section.id}
|
|
@@ -430,11 +464,11 @@ export function PermissionEditor({
|
|
|
430
464
|
return (
|
|
431
465
|
<div
|
|
432
466
|
key={group.id}
|
|
433
|
-
className='grid min-w-0 gap-2 p-3 @
|
|
467
|
+
className='grid min-w-0 gap-2 p-3 @3xl:grid-cols-[9rem_minmax(0,1fr)]'
|
|
434
468
|
data-slot='permission-resource'
|
|
435
469
|
>
|
|
436
|
-
<div className='flex min-w-0 flex-col
|
|
437
|
-
<h3 className='text-sm font-medium break-words'>
|
|
470
|
+
<div className='flex min-w-0 flex-col gap-1 py-1.5'>
|
|
471
|
+
<h3 className='min-h-6 text-sm leading-6 font-medium break-words'>
|
|
438
472
|
{group.title ?? section.title}
|
|
439
473
|
</h3>
|
|
440
474
|
{group.hint && (
|
|
@@ -442,7 +476,12 @@ export function PermissionEditor({
|
|
|
442
476
|
{group.hint}
|
|
443
477
|
</p>
|
|
444
478
|
)}
|
|
445
|
-
<div
|
|
479
|
+
<div
|
|
480
|
+
className={cn(
|
|
481
|
+
'flex min-h-7 items-center gap-2 text-xs text-muted-foreground',
|
|
482
|
+
showScopeLabels && '@3xl:mt-6',
|
|
483
|
+
)}
|
|
484
|
+
>
|
|
446
485
|
{onEnabledBatchChange &&
|
|
447
486
|
totalRows.length > 1 &&
|
|
448
487
|
selectable.length > 0 && (
|
|
@@ -514,9 +553,8 @@ export function PermissionEditor({
|
|
|
514
553
|
</div>
|
|
515
554
|
<div
|
|
516
555
|
className={cn(
|
|
517
|
-
'
|
|
518
|
-
aligned &&
|
|
519
|
-
'@4xl:grid @4xl:grid-cols-(--permission-columns) @4xl:gap-x-1',
|
|
556
|
+
'grid min-w-0 grid-cols-[repeat(auto-fill,minmax(min(100%,14rem),1fr))] items-start gap-x-2 gap-y-1',
|
|
557
|
+
aligned && alignment,
|
|
520
558
|
)}
|
|
521
559
|
style={
|
|
522
560
|
{
|
|
@@ -524,42 +562,42 @@ export function PermissionEditor({
|
|
|
524
562
|
} as CSSProperties
|
|
525
563
|
}
|
|
526
564
|
>
|
|
527
|
-
{
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
565
|
+
{[...group.rows]
|
|
566
|
+
.sort(
|
|
567
|
+
(a, b) =>
|
|
568
|
+
columns.indexOf(a.actionKey ?? '') -
|
|
569
|
+
columns.indexOf(b.actionKey ?? ''),
|
|
570
|
+
)
|
|
571
|
+
.map((row) => (
|
|
572
|
+
<div
|
|
573
|
+
key={row.id}
|
|
574
|
+
className={cn(
|
|
575
|
+
'min-w-0',
|
|
576
|
+
aligned && placement,
|
|
577
|
+
)}
|
|
578
|
+
style={
|
|
579
|
+
{
|
|
580
|
+
'--permission-column':
|
|
581
|
+
columns.indexOf(row.actionKey ?? '') +
|
|
582
|
+
1,
|
|
583
|
+
} as CSSProperties
|
|
584
|
+
}
|
|
585
|
+
>
|
|
586
|
+
<PermissionRow
|
|
587
|
+
controlId={`${id}-${row.id}`}
|
|
588
|
+
row={row}
|
|
589
|
+
showScopeLabels={showScopeLabels}
|
|
590
|
+
readOnly={readOnly}
|
|
591
|
+
labels={words}
|
|
592
|
+
onEnabledChange={(rowId, on) => {
|
|
593
|
+
if (enabledOnly && !on)
|
|
594
|
+
searchRef.current?.focus();
|
|
595
|
+
onEnabledChange(rowId, on);
|
|
596
|
+
}}
|
|
597
|
+
onLevelChange={onLevelChange}
|
|
598
|
+
/>
|
|
599
|
+
</div>
|
|
600
|
+
))}
|
|
563
601
|
</div>
|
|
564
602
|
</div>
|
|
565
603
|
);
|
|
@@ -97,6 +97,7 @@ const settings = {
|
|
|
97
97
|
selectAllPermissions: 'All',
|
|
98
98
|
selectVisiblePermissions: 'Select visible permissions in {{name}}',
|
|
99
99
|
permissionSummary: '{{enabled}} of {{total}} enabled',
|
|
100
|
+
preservedRecordScope: 'Keep existing configuration',
|
|
100
101
|
noRecordScope: 'No records (not authorized)',
|
|
101
102
|
permissionFilter: {
|
|
102
103
|
limit: 'Up to 20 conditions',
|
|
@@ -88,18 +88,19 @@ const settings = {
|
|
|
88
88
|
total_other: '共 {{count}} 位用户',
|
|
89
89
|
access: '访问',
|
|
90
90
|
permissionSearch: '搜索分类、分组或权限',
|
|
91
|
-
enabledOnly: '
|
|
91
|
+
enabledOnly: '仅显示已授权',
|
|
92
92
|
noPermissions: '暂无可配置权限',
|
|
93
93
|
permissionCategories: '权限分类',
|
|
94
94
|
selectAllPermissions: '全选',
|
|
95
95
|
selectVisiblePermissions: '选择“{{name}}”中显示的权限',
|
|
96
|
-
permissionSummary: '
|
|
97
|
-
noRecordScope: '
|
|
96
|
+
permissionSummary: '已授权 {{enabled}} / {{total}}',
|
|
97
|
+
noRecordScope: '不允许访问数据',
|
|
98
|
+
preservedRecordScope: '保留现有配置',
|
|
98
99
|
permissionFilter: {
|
|
99
100
|
limit: '最多支持 20 个条件',
|
|
100
|
-
configure: '
|
|
101
|
-
title: '
|
|
102
|
-
description: '
|
|
101
|
+
configure: '设置条件',
|
|
102
|
+
title: '设置数据范围',
|
|
103
|
+
description: '仅允许访问符合以下条件的数据,保存角色后生效。',
|
|
103
104
|
all: '满足全部条件',
|
|
104
105
|
any: '满足任一条件',
|
|
105
106
|
field: '字段',
|
|
@@ -24,19 +24,28 @@ export function pagePermissionResources(
|
|
|
24
24
|
titles: PagePermissionTitles,
|
|
25
25
|
unnamed: (path: string) => string,
|
|
26
26
|
): CatalogResource[] {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
const resources = new Map<string, CatalogResource>();
|
|
28
|
+
const visit = (items: readonly AppClientRegisteredRoute[]): void => {
|
|
29
|
+
for (const route of items) {
|
|
30
|
+
if (
|
|
31
|
+
route.componentLoader &&
|
|
32
|
+
typeof route.authz === 'object' &&
|
|
33
|
+
route.authz.resource.type === 'page'
|
|
34
|
+
) {
|
|
35
|
+
const id = route.authz.resource.id;
|
|
36
|
+
// Resolved child routes may inherit the parent's authorization resource.
|
|
37
|
+
// They share one grant, so showing independent checkboxes is misleading.
|
|
38
|
+
if (!resources.has(id))
|
|
39
|
+
resources.set(id, {
|
|
33
40
|
type: 'page',
|
|
34
|
-
id
|
|
41
|
+
id,
|
|
35
42
|
title: pagePermissionTitle(route, titles) ?? unnamed(route.path),
|
|
36
43
|
actions: [{ name: 'access', title: { key: 'settings.access' } }],
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
visit(route.children ?? []);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
visit(routes);
|
|
50
|
+
return [...resources.values()];
|
|
42
51
|
}
|
|
@@ -17,6 +17,15 @@ export function setPermission(
|
|
|
17
17
|
action: string,
|
|
18
18
|
enabled: boolean,
|
|
19
19
|
): readonly PermissionGrant[] {
|
|
20
|
+
const existing =
|
|
21
|
+
grants
|
|
22
|
+
.find(
|
|
23
|
+
(grant) =>
|
|
24
|
+
grant.resource.type === resource.type &&
|
|
25
|
+
grant.resource.id === resource.id,
|
|
26
|
+
)
|
|
27
|
+
?.actions.some((entry) => entry.action === action) ?? false;
|
|
28
|
+
if (existing === enabled) return grants;
|
|
20
29
|
const next = grants.map((grant) => ({
|
|
21
30
|
...grant,
|
|
22
31
|
actions: [...grant.actions],
|
|
@@ -87,40 +96,55 @@ export function permissionSections(
|
|
|
87
96
|
return {
|
|
88
97
|
...row,
|
|
89
98
|
scopes: granted
|
|
90
|
-
? (resource.dataScopes?.[action.name] ?? []).map((scope) =>
|
|
91
|
-
|
|
92
|
-
resource.type,
|
|
93
|
-
resource.id,
|
|
94
|
-
action.name,
|
|
95
|
-
scope.key,
|
|
96
|
-
]),
|
|
97
|
-
label: text(
|
|
98
|
-
typeof scope.title === 'string' &&
|
|
99
|
-
scope.title === scope.collection
|
|
100
|
-
? { key: 'settings.recordScope' }
|
|
101
|
-
: scope.title,
|
|
102
|
-
),
|
|
103
|
-
levels: [
|
|
104
|
-
{
|
|
105
|
-
value: '',
|
|
106
|
-
label: text({ key: 'settings.noRecordScope' }),
|
|
107
|
-
},
|
|
108
|
-
...scope.recordAccess.map((key) => ({
|
|
109
|
-
value: key,
|
|
110
|
-
label: text(
|
|
111
|
-
catalog.recordAccess.find(
|
|
112
|
-
(entry) => entry.key === key,
|
|
113
|
-
)?.title ?? key,
|
|
114
|
-
),
|
|
115
|
-
})),
|
|
116
|
-
],
|
|
117
|
-
level: scopeSelectionKey(
|
|
99
|
+
? (resource.dataScopes?.[action.name] ?? []).map((scope) => {
|
|
100
|
+
const value = scopeSelectionKey(
|
|
118
101
|
granted.policy?.scopes &&
|
|
119
102
|
typeof granted.policy.scopes === 'object'
|
|
120
103
|
? Reflect.get(granted.policy.scopes, scope.key)
|
|
121
104
|
: undefined,
|
|
122
|
-
)
|
|
123
|
-
|
|
105
|
+
);
|
|
106
|
+
const preserved =
|
|
107
|
+
value !== '' && !scope.recordAccess.includes(value);
|
|
108
|
+
return {
|
|
109
|
+
id: JSON.stringify([
|
|
110
|
+
resource.type,
|
|
111
|
+
resource.id,
|
|
112
|
+
action.name,
|
|
113
|
+
scope.key,
|
|
114
|
+
]),
|
|
115
|
+
label: text(
|
|
116
|
+
typeof scope.title === 'string' &&
|
|
117
|
+
scope.title === scope.collection
|
|
118
|
+
? { key: 'settings.recordScope' }
|
|
119
|
+
: scope.title,
|
|
120
|
+
),
|
|
121
|
+
levels: [
|
|
122
|
+
...(preserved
|
|
123
|
+
? [
|
|
124
|
+
{
|
|
125
|
+
value,
|
|
126
|
+
label: text({
|
|
127
|
+
key: 'settings.preservedRecordScope',
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
]
|
|
131
|
+
: []),
|
|
132
|
+
{
|
|
133
|
+
value: '',
|
|
134
|
+
label: text({ key: 'settings.noRecordScope' }),
|
|
135
|
+
},
|
|
136
|
+
...scope.recordAccess.map((key) => ({
|
|
137
|
+
value: key,
|
|
138
|
+
label: text(
|
|
139
|
+
catalog.recordAccess.find(
|
|
140
|
+
(entry) => entry.key === key,
|
|
141
|
+
)?.title ?? key,
|
|
142
|
+
),
|
|
143
|
+
})),
|
|
144
|
+
],
|
|
145
|
+
level: value,
|
|
146
|
+
};
|
|
147
|
+
})
|
|
124
148
|
: [],
|
|
125
149
|
};
|
|
126
150
|
}),
|
|
@@ -136,6 +160,11 @@ export function setScope(
|
|
|
136
160
|
value: unknown,
|
|
137
161
|
): readonly PermissionGrant[] {
|
|
138
162
|
const [type, id, action, scope] = JSON.parse(rowId) as string[];
|
|
163
|
+
if (
|
|
164
|
+
typeof value === 'string' &&
|
|
165
|
+
scopeSelectionKey(getScopeSelection(grants, rowId)) === value
|
|
166
|
+
)
|
|
167
|
+
return grants;
|
|
139
168
|
return grants.map((grant) =>
|
|
140
169
|
grant.resource.type !== type || grant.resource.id !== id
|
|
141
170
|
? grant
|
|
@@ -216,7 +245,7 @@ function scopeSelectionKey(value: unknown): string {
|
|
|
216
245
|
typeof Reflect.get(value, 'key') === 'string'
|
|
217
246
|
)
|
|
218
247
|
return String(Reflect.get(value, 'key'));
|
|
219
|
-
return '';
|
|
248
|
+
return value == null || value === '' ? '' : '__preserved_scope__';
|
|
220
249
|
}
|
|
221
250
|
|
|
222
251
|
/** Legacy bare custom-filter keys require configuration before saving. */
|
|
@@ -19,7 +19,6 @@ export class DefaultClientServiceProvider extends ServiceProvider<ClientApplicat
|
|
|
19
19
|
typeof configuredTitle === 'string' && configuredTitle.trim()
|
|
20
20
|
? configuredTitle.trim()
|
|
21
21
|
: 'NocoBase';
|
|
22
|
-
this.app.refine.setOptions({ title: { text: title } });
|
|
23
22
|
this.previousDocumentTitle = document.title;
|
|
24
23
|
document.title = title;
|
|
25
24
|
return Promise.resolve();
|
package/config.example.yml
CHANGED
|
@@ -22,6 +22,26 @@
|
|
|
22
22
|
# max: 600
|
|
23
23
|
# window: 1m
|
|
24
24
|
|
|
25
|
+
# Realtime events are transient and local to this process. Clients refetch after subscription acknowledgement.
|
|
26
|
+
# realtime:
|
|
27
|
+
# adapter: inMemory # Use redis for multiple application processes.
|
|
28
|
+
# redis:
|
|
29
|
+
# connection:
|
|
30
|
+
# host: 127.0.0.1
|
|
31
|
+
# port: 6379
|
|
32
|
+
# namespace: my-app # Defaults to the application name; keep applications isolated.
|
|
33
|
+
# authorizationTimeoutMs: 5000
|
|
34
|
+
# sessionCheckIntervalMs: 30000
|
|
35
|
+
# limits:
|
|
36
|
+
# maxConcurrentAuthorizations: 256
|
|
37
|
+
# maxPendingAuthorizations: 2048
|
|
38
|
+
# maxRecentEventIds: 10000
|
|
39
|
+
# maxConnections: 10000
|
|
40
|
+
# maxMessageBytes: 65536
|
|
41
|
+
# maxSubscriptionsPerConnection: 64
|
|
42
|
+
# maxBufferedBytesPerConnection: 1048576
|
|
43
|
+
# maxPendingMessagesPerConnection: 64
|
|
44
|
+
|
|
25
45
|
# Values under client are public and embedded in the SPA HTML.
|
|
26
46
|
client:
|
|
27
47
|
app:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/app-template-default",
|
|
3
3
|
"displayName": "Default Template",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.72",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"prettier": "@nocobase/dev-config/prettier",
|
|
7
7
|
"engines": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"nocobase": {
|
|
11
11
|
"templateKind": "app",
|
|
12
|
-
"defaultTemplateVersion": "1.0.0-beta.
|
|
12
|
+
"defaultTemplateVersion": "1.0.0-beta.72"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@ai-sdk/react": "2.0.0",
|
|
@@ -17,11 +17,6 @@
|
|
|
17
17
|
"@dicebear/core": "^9.4.3",
|
|
18
18
|
"@hookform/resolvers": "^5.0.1",
|
|
19
19
|
"@playwright/test": "^1.62.1",
|
|
20
|
-
"@refinedev/cli": "^2.16.48",
|
|
21
|
-
"@refinedev/core": "^5.0.8",
|
|
22
|
-
"@refinedev/react-hook-form": "^5.0.0",
|
|
23
|
-
"@refinedev/react-router": "^2.0.0",
|
|
24
|
-
"@refinedev/react-table": "^6.0.0",
|
|
25
20
|
"@silurus/ooxml": "0.85.1",
|
|
26
21
|
"@tailwindcss/postcss": "^4.1.4",
|
|
27
22
|
"@tailwindcss/vite": "^4.1.8",
|
|
@@ -77,15 +72,15 @@
|
|
|
77
72
|
"tar": "^7.5.22",
|
|
78
73
|
"@xyflow/react": "^12.11.5",
|
|
79
74
|
"elkjs": "^0.10.2",
|
|
80
|
-
"@nocobase/app-client": "^
|
|
75
|
+
"@nocobase/app-client": "^4.0.0-beta.0",
|
|
81
76
|
"@nocobase/app-plugin-template-print": "0.0.2-beta.2",
|
|
82
|
-
"@nocobase/app-skills": "0.1.0-beta.
|
|
83
|
-
"@nocobase/app-testing": "0.1.0-beta.
|
|
84
|
-
"@nocobase/app-websocket": "^0.0
|
|
85
|
-
"@nocobase/dev-config": "0.1.0-beta.
|
|
86
|
-
"@nocobase/drive": "^0.1.0-beta.
|
|
87
|
-
"@nocobase/
|
|
88
|
-
"@nocobase/
|
|
77
|
+
"@nocobase/app-skills": "0.1.0-beta.40",
|
|
78
|
+
"@nocobase/app-testing": "0.1.0-beta.5",
|
|
79
|
+
"@nocobase/app-websocket": "^0.1.0-beta.1",
|
|
80
|
+
"@nocobase/dev-config": "0.1.0-beta.24",
|
|
81
|
+
"@nocobase/drive": "^0.1.0-beta.7",
|
|
82
|
+
"@nocobase/logging": "^0.1.0-beta.7",
|
|
83
|
+
"@nocobase/session": "^0.1.0-beta.3"
|
|
89
84
|
},
|
|
90
85
|
"files": [
|
|
91
86
|
".prettierignore",
|
|
@@ -144,35 +139,36 @@
|
|
|
144
139
|
"@oclif/core": "^4.14.0",
|
|
145
140
|
"better-auth": "1.7.5",
|
|
146
141
|
"hono": "^4.13.2",
|
|
147
|
-
"@nocobase/app-plugin-api-keys": "^1.0.0-beta.
|
|
148
|
-
"@nocobase/app-plugin-authentication": "^2.0.0-beta.
|
|
149
|
-
"@nocobase/app-plugin-authorization": "^1.0.0-beta.
|
|
150
|
-
"@nocobase/app-plugin-database-explorer": "^1.0.0-beta.
|
|
151
|
-
"@nocobase/app-plugin-file": "^1.0.0-beta.
|
|
152
|
-
"@nocobase/app-plugin-i18n": "^1.0.0-beta.
|
|
153
|
-
"@nocobase/app-plugin-mail": "^1.0.0-beta.
|
|
154
|
-
"@nocobase/app-plugin-notification": "^1.0.0-beta.
|
|
155
|
-
"@nocobase/app-plugin-notification-in-app": "^1.0.0-beta.
|
|
156
|
-
"@nocobase/app-plugin-notification-providers": "^0.2.0-beta.
|
|
157
|
-
"@nocobase/app-plugin-scheduler": "^1.0.0-beta.
|
|
158
|
-
"@nocobase/app-plugin-users": "^2.0.0-beta.
|
|
159
|
-
"@nocobase/app-server": "^
|
|
142
|
+
"@nocobase/app-plugin-api-keys": "^1.0.0-beta.18",
|
|
143
|
+
"@nocobase/app-plugin-authentication": "^2.0.0-beta.11",
|
|
144
|
+
"@nocobase/app-plugin-authorization": "^1.0.0-beta.29",
|
|
145
|
+
"@nocobase/app-plugin-database-explorer": "^1.0.0-beta.16",
|
|
146
|
+
"@nocobase/app-plugin-file": "^1.0.0-beta.25",
|
|
147
|
+
"@nocobase/app-plugin-i18n": "^1.0.0-beta.17",
|
|
148
|
+
"@nocobase/app-plugin-mail": "^1.0.0-beta.15",
|
|
149
|
+
"@nocobase/app-plugin-notification": "^1.0.0-beta.28",
|
|
150
|
+
"@nocobase/app-plugin-notification-in-app": "^1.0.0-beta.26",
|
|
151
|
+
"@nocobase/app-plugin-notification-providers": "^0.2.0-beta.13",
|
|
152
|
+
"@nocobase/app-plugin-scheduler": "^1.0.0-beta.18",
|
|
153
|
+
"@nocobase/app-plugin-users": "^2.0.0-beta.6",
|
|
154
|
+
"@nocobase/app-server": "^3.0.0-beta.0",
|
|
160
155
|
"@nocobase/config": "^0.1.0-beta.3",
|
|
161
156
|
"@nocobase/db": "^1.0.0-beta.21",
|
|
162
157
|
"@nocobase/db-sqlite": "^0.1.0-beta.6",
|
|
163
158
|
"@nocobase/service-provider": "^0.0.2-beta.2",
|
|
164
159
|
"@nocobase/snowflake": "^1.0.0-beta.4",
|
|
165
|
-
"@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.
|
|
166
|
-
"@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.
|
|
167
|
-
"@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.
|
|
160
|
+
"@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.14",
|
|
161
|
+
"@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.14",
|
|
162
|
+
"@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.13",
|
|
168
163
|
"@nocobase/authorization": "^1.0.0-beta.14",
|
|
169
164
|
"@nocobase/queue": "^0.1.0-beta.9",
|
|
170
165
|
"@nocobase/jobs": "^0.1.0-beta.3",
|
|
171
166
|
"@nocobase/lifecycle": "^0.1.0-beta.2",
|
|
172
|
-
"@nocobase/i18n": "^
|
|
167
|
+
"@nocobase/i18n": "^2.0.0-beta.0",
|
|
173
168
|
"@nocobase/caching": "^0.1.0-beta.3",
|
|
174
169
|
"@nocobase/repository-input": "^0.1.0-beta.3",
|
|
175
|
-
"@nocobase/app-cli": "^1.0.0-beta.
|
|
170
|
+
"@nocobase/app-cli": "^1.0.0-beta.25",
|
|
171
|
+
"@nocobase/realtime": "^0.1.0-beta.3"
|
|
176
172
|
},
|
|
177
173
|
"description": "NocoBase 3 application template with the standard product capabilities.",
|
|
178
174
|
"imports": {
|
|
@@ -17,9 +17,6 @@ describe('application title', () => {
|
|
|
17
17
|
|
|
18
18
|
await app.start();
|
|
19
19
|
|
|
20
|
-
expect(app.refineConfig.options?.title).toEqual({
|
|
21
|
-
text: 'Configured application',
|
|
22
|
-
});
|
|
23
20
|
expect(document.title).toBe('Configured application');
|
|
24
21
|
|
|
25
22
|
await app.shutdown();
|
|
@@ -33,7 +30,6 @@ describe('application title', () => {
|
|
|
33
30
|
|
|
34
31
|
await app.start();
|
|
35
32
|
|
|
36
|
-
expect(app.refineConfig.options?.title).toEqual({ text: 'NocoBase' });
|
|
37
33
|
expect(document.title).toBe('NocoBase');
|
|
38
34
|
|
|
39
35
|
await app.shutdown();
|
|
@@ -20,7 +20,11 @@ vi.mock('@nocobase/app-plugin-authentication/client', () => ({
|
|
|
20
20
|
vi.mock('@nocobase/app-client', async (importOriginal) => {
|
|
21
21
|
const actual = await importOriginal<typeof import('@nocobase/app-client')>();
|
|
22
22
|
const client = { request: mocks.request };
|
|
23
|
-
const realtime = {
|
|
23
|
+
const realtime = {
|
|
24
|
+
watch: () => ({ close: vi.fn() }),
|
|
25
|
+
subscribe: () => vi.fn(),
|
|
26
|
+
onOpen: () => vi.fn(),
|
|
27
|
+
};
|
|
24
28
|
return {
|
|
25
29
|
...actual,
|
|
26
30
|
useApiClient: () => client,
|
|
@@ -475,3 +475,65 @@ it('aligns semantic actions across resources without dropping special or filtere
|
|
|
475
475
|
view.container.querySelectorAll('[data-slot=permission-row]'),
|
|
476
476
|
).toHaveLength(1);
|
|
477
477
|
});
|
|
478
|
+
|
|
479
|
+
it('keeps scope captions consistent in a category with multiple data ranges, including when filtered', async () => {
|
|
480
|
+
const level = [{ value: 'all', label: 'All records' }];
|
|
481
|
+
render(
|
|
482
|
+
<PermissionEditor
|
|
483
|
+
sections={[
|
|
484
|
+
{
|
|
485
|
+
id: 'business',
|
|
486
|
+
title: 'Business',
|
|
487
|
+
groups: [
|
|
488
|
+
{
|
|
489
|
+
id: 'quotes',
|
|
490
|
+
title: 'Quotes',
|
|
491
|
+
rows: [
|
|
492
|
+
{
|
|
493
|
+
id: 'view',
|
|
494
|
+
label: 'View quotes',
|
|
495
|
+
enabled: true,
|
|
496
|
+
scopes: [
|
|
497
|
+
{
|
|
498
|
+
id: 'view-records',
|
|
499
|
+
label: 'Visible quotes',
|
|
500
|
+
level: 'all',
|
|
501
|
+
levels: level,
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: 'submit',
|
|
507
|
+
label: 'Submit quote',
|
|
508
|
+
enabled: true,
|
|
509
|
+
scopes: [
|
|
510
|
+
{
|
|
511
|
+
id: 'submit-projects',
|
|
512
|
+
label: 'Linked projects',
|
|
513
|
+
level: 'all',
|
|
514
|
+
levels: level,
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
id: 'submit-quotes',
|
|
518
|
+
label: 'Allowed quotes',
|
|
519
|
+
level: 'all',
|
|
520
|
+
levels: level,
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
},
|
|
528
|
+
]}
|
|
529
|
+
onEnabledChange={vi.fn()}
|
|
530
|
+
onLevelChange={vi.fn()}
|
|
531
|
+
/>,
|
|
532
|
+
);
|
|
533
|
+
expect(screen.getByText('Visible quotes')).toBeVisible();
|
|
534
|
+
expect(screen.getByText('Linked projects')).toBeVisible();
|
|
535
|
+
expect(screen.getByText('Allowed quotes')).toBeVisible();
|
|
536
|
+
await userEvent.type(screen.getByRole('textbox'), 'View quotes');
|
|
537
|
+
expect(screen.queryByText('Linked projects')).not.toBeInTheDocument();
|
|
538
|
+
expect(screen.getByText('Visible quotes')).toBeVisible();
|
|
539
|
+
});
|
|
@@ -2,7 +2,6 @@ import {
|
|
|
2
2
|
createAppSidebarPreferenceStore,
|
|
3
3
|
SidebarPreferenceProvider,
|
|
4
4
|
} from '../../client/layouts/use-sidebar-preference.js';
|
|
5
|
-
import { Refine } from '@refinedev/core';
|
|
6
5
|
import { createRoot } from 'react-dom/client';
|
|
7
6
|
import { MemoryRouter } from 'react-router';
|
|
8
7
|
import { Loading } from '../../client/components/loading';
|
|
@@ -28,90 +27,88 @@ export default function Fixture() {
|
|
|
28
27
|
return (
|
|
29
28
|
<MemoryRouter>
|
|
30
29
|
<AppThemeProvider>
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
<
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<div className='
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<div className='
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
</AppSidebarProvider>
|
|
114
|
-
</Refine>
|
|
30
|
+
<AppSidebarProvider>
|
|
31
|
+
<AppSidebar label='Fixture sidebar'>
|
|
32
|
+
<div className='p-2'>
|
|
33
|
+
<Input aria-label='Sidebar content' />
|
|
34
|
+
</div>
|
|
35
|
+
</AppSidebar>
|
|
36
|
+
<main className='min-w-0 flex-1 space-y-6 overflow-y-auto p-6'>
|
|
37
|
+
<div className='flex flex-wrap gap-2'>
|
|
38
|
+
<ThemeSettings />
|
|
39
|
+
<AppSidebarToggle />
|
|
40
|
+
</div>
|
|
41
|
+
<h1 className='text-3xl'>Theme tokens · 主题样式</h1>
|
|
42
|
+
<section
|
|
43
|
+
className='flex h-32 items-center justify-center rounded-lg border bg-background'
|
|
44
|
+
aria-label='Brand loading preview'
|
|
45
|
+
>
|
|
46
|
+
<Loading label='Loading preview' />
|
|
47
|
+
</section>
|
|
48
|
+
<p data-testid='body'>Body text · 中文内容与 English text</p>
|
|
49
|
+
<p className='font-serif'>Serif text</p>
|
|
50
|
+
<code>const theme = 'compact';</code>
|
|
51
|
+
<div className='flex flex-wrap gap-2'>
|
|
52
|
+
<Button>Default action</Button>
|
|
53
|
+
<Button size='sm'>Small action</Button>
|
|
54
|
+
<Button size='xs'>Extra small</Button>
|
|
55
|
+
<Button variant='destructive'>Delete</Button>
|
|
56
|
+
</div>
|
|
57
|
+
<Input
|
|
58
|
+
aria-label='Example input'
|
|
59
|
+
placeholder='输入内容 / Enter text'
|
|
60
|
+
/>
|
|
61
|
+
<Popover>
|
|
62
|
+
<PopoverTrigger render={<Button variant='outline' />}>
|
|
63
|
+
Open popover
|
|
64
|
+
</PopoverTrigger>
|
|
65
|
+
<PopoverContent>
|
|
66
|
+
<PopoverTitle>Portal heading</PopoverTitle>
|
|
67
|
+
<p>Portal body · 浮层内容</p>
|
|
68
|
+
</PopoverContent>
|
|
69
|
+
</Popover>
|
|
70
|
+
<section className='rounded-xl border bg-card p-4 text-card-foreground shadow-md ring-1 ring-ring'>
|
|
71
|
+
<h2 className='text-xl'>Card heading</h2>
|
|
72
|
+
<p className='text-sm'>
|
|
73
|
+
A long description that wraps without clipping when fonts and
|
|
74
|
+
spacing change. 长文本应该正常换行,不应被控件裁切。
|
|
75
|
+
</p>
|
|
76
|
+
</section>
|
|
77
|
+
<svg
|
|
78
|
+
aria-label='Chart palette'
|
|
79
|
+
viewBox='0 0 120 30'
|
|
80
|
+
className='h-8'
|
|
81
|
+
>
|
|
82
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
83
|
+
<rect
|
|
84
|
+
key={i}
|
|
85
|
+
x={(i - 1) * 24}
|
|
86
|
+
width='20'
|
|
87
|
+
height='30'
|
|
88
|
+
fill={`var(--chart-${i})`}
|
|
89
|
+
/>
|
|
90
|
+
))}
|
|
91
|
+
</svg>
|
|
92
|
+
<div className='flex flex-wrap gap-2'>
|
|
93
|
+
<div className='rounded-sm border p-2'>sm</div>
|
|
94
|
+
<div className='rounded-md border p-2'>md</div>
|
|
95
|
+
<div className='rounded-lg border p-2'>lg</div>
|
|
96
|
+
<div className='rounded-xl border p-2'>xl</div>
|
|
97
|
+
<div className='rounded-2xl border p-2'>2xl</div>
|
|
98
|
+
<div className='rounded-3xl border p-2'>3xl</div>
|
|
99
|
+
<div className='rounded-4xl border p-2'>4xl</div>
|
|
100
|
+
</div>
|
|
101
|
+
<div className='flex flex-wrap gap-4'>
|
|
102
|
+
<div className='shadow-2xs p-2'>2xs</div>
|
|
103
|
+
<div className='shadow-xs p-2'>xs</div>
|
|
104
|
+
<div className='shadow-sm p-2'>sm</div>
|
|
105
|
+
<div className='shadow-md p-2'>md</div>
|
|
106
|
+
<div className='shadow-lg p-2'>lg</div>
|
|
107
|
+
<div className='shadow-xl p-2'>xl</div>
|
|
108
|
+
<div className='shadow-2xl p-2'>2xl</div>
|
|
109
|
+
</div>
|
|
110
|
+
</main>
|
|
111
|
+
</AppSidebarProvider>
|
|
115
112
|
</AppThemeProvider>
|
|
116
113
|
</MemoryRouter>
|
|
117
114
|
);
|
|
@@ -423,12 +423,13 @@ describe('app server', () => {
|
|
|
423
423
|
expect(missingEvents).toBeNull();
|
|
424
424
|
});
|
|
425
425
|
|
|
426
|
-
it('subscribes, publishes, and unsubscribes realtime messages', () => {
|
|
426
|
+
it('subscribes, publishes, and unsubscribes realtime messages', async () => {
|
|
427
427
|
const realtime = createRealtimeService();
|
|
428
|
+
realtime.registerTopic(TEST_REALTIME_TOPIC, { audience: 'public' });
|
|
428
429
|
const websocket = createTestWebSocket();
|
|
429
430
|
const connection = realtime.connect(websocket);
|
|
430
431
|
|
|
431
|
-
realtime.handleClientMessage(
|
|
432
|
+
await realtime.handleClientMessage(
|
|
432
433
|
connection,
|
|
433
434
|
JSON.stringify({
|
|
434
435
|
type: 'subscribe',
|
|
@@ -445,7 +446,7 @@ describe('app server', () => {
|
|
|
445
446
|
subscriptionId: expect.any(String),
|
|
446
447
|
});
|
|
447
448
|
|
|
448
|
-
realtime.publish(TEST_REALTIME_TOPIC, 'tick');
|
|
449
|
+
await realtime.publish(TEST_REALTIME_TOPIC, 'tick');
|
|
449
450
|
|
|
450
451
|
expect(websocket.messages[1]).toMatchObject({
|
|
451
452
|
type: 'event',
|
|
@@ -454,7 +455,7 @@ describe('app server', () => {
|
|
|
454
455
|
publishedAt: expect.any(String),
|
|
455
456
|
});
|
|
456
457
|
|
|
457
|
-
realtime.handleClientMessage(
|
|
458
|
+
await realtime.handleClientMessage(
|
|
458
459
|
connection,
|
|
459
460
|
JSON.stringify({
|
|
460
461
|
type: 'unsubscribe',
|
|
@@ -463,7 +464,7 @@ describe('app server', () => {
|
|
|
463
464
|
.subscriptionId,
|
|
464
465
|
}),
|
|
465
466
|
);
|
|
466
|
-
realtime.publish(TEST_REALTIME_TOPIC, 'after unsubscribe');
|
|
467
|
+
await realtime.publish(TEST_REALTIME_TOPIC, 'after unsubscribe');
|
|
467
468
|
|
|
468
469
|
expect(websocket.messages[2]).toMatchObject({
|
|
469
470
|
type: 'unsubscribed',
|
|
@@ -473,7 +474,7 @@ describe('app server', () => {
|
|
|
473
474
|
});
|
|
474
475
|
expect(websocket.messages).toHaveLength(3);
|
|
475
476
|
|
|
476
|
-
realtime.close();
|
|
477
|
+
await realtime.close();
|
|
477
478
|
});
|
|
478
479
|
|
|
479
480
|
it('registers embedded app resources with the scope', async () => {
|
|
@@ -791,6 +792,9 @@ describe('app server', () => {
|
|
|
791
792
|
const app = trackCloseable(
|
|
792
793
|
await createIsolatedStandaloneServer({ viteDevUrl: false }),
|
|
793
794
|
);
|
|
795
|
+
app.application.container
|
|
796
|
+
.resolve(realtimeServiceToken)
|
|
797
|
+
.registerTopic(TEST_REALTIME_TOPIC, { audience: 'public' });
|
|
794
798
|
const serverUrl = await startStandaloneTestServer(app);
|
|
795
799
|
const websocket = new WebSocket(
|
|
796
800
|
`${serverUrl}${app.application.publicBasePath}/ws`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createApp } from '../../client/app.js';
|
|
2
2
|
import { ServiceProvider } from '@nocobase/service-provider';
|
|
3
|
-
import { describe, expect, it
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
4
|
|
|
5
5
|
import { ClientApplication, toasterToken } from '@nocobase/app-client';
|
|
6
6
|
import {
|
|
@@ -47,19 +47,18 @@ describe('app client runtime', () => {
|
|
|
47
47
|
expect(calls).toEqual([]);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it('runs provider lifecycle
|
|
51
|
-
const
|
|
52
|
-
check: vi.fn(),
|
|
53
|
-
getIdentity: vi.fn(),
|
|
54
|
-
login: vi.fn(),
|
|
55
|
-
logout: vi.fn(),
|
|
56
|
-
onError: vi.fn(),
|
|
57
|
-
};
|
|
50
|
+
it('runs provider lifecycle through ClientApplication', async () => {
|
|
51
|
+
const calls: string[] = [];
|
|
58
52
|
class Provider extends ServiceProvider<ClientApplicationType> {
|
|
59
53
|
public readonly name: string = '@example/provider';
|
|
60
54
|
|
|
61
55
|
public override boot(): Promise<void> {
|
|
62
|
-
|
|
56
|
+
calls.push('boot');
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public override shutdown(): Promise<void> {
|
|
61
|
+
calls.push('shutdown');
|
|
63
62
|
return Promise.resolve();
|
|
64
63
|
}
|
|
65
64
|
}
|
|
@@ -77,8 +76,9 @@ describe('app client runtime', () => {
|
|
|
77
76
|
});
|
|
78
77
|
|
|
79
78
|
await app.start();
|
|
80
|
-
expect(
|
|
79
|
+
expect(calls).toEqual(['boot']);
|
|
81
80
|
await app.shutdown();
|
|
81
|
+
expect(calls).toEqual(['boot', 'shutdown']);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
it('uses the default template Application and static plugin declarations', async () => {
|
|
@@ -90,16 +90,12 @@ describe('app client runtime', () => {
|
|
|
90
90
|
expect(app).toBeInstanceOf(ClientApplication);
|
|
91
91
|
await expect(app.start()).resolves.toBeUndefined();
|
|
92
92
|
expect(app.config.get('app.title')).toBe('Configured application');
|
|
93
|
-
expect(app.refineConfig.options?.title).toEqual({
|
|
94
|
-
text: 'Configured application',
|
|
95
|
-
});
|
|
96
93
|
// Plugins and pages report through the registered toaster, and the mounted Toaster renders what it forwards.
|
|
97
94
|
// Without either half, toasts disappear.
|
|
98
95
|
expect(app.services.has(toasterToken)).toBe(true);
|
|
99
96
|
expect(runtime.reactProviders).toContainEqual(
|
|
100
97
|
expect.objectContaining({ name: 'toaster', layer: 'application' }),
|
|
101
98
|
);
|
|
102
|
-
expect(app.refineConfig.resources ?? []).toEqual([]);
|
|
103
99
|
await app.shutdown();
|
|
104
100
|
});
|
|
105
101
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
resolveAppClientContributions,
|
|
4
|
+
defineAppRoutes,
|
|
4
5
|
type AppClientRegisteredRoute,
|
|
5
6
|
} from '@nocobase/app-client/plugins';
|
|
6
7
|
import applicationRoutes from '../../client/routes.js';
|
|
@@ -70,7 +71,7 @@ describe('page permission labels', () => {
|
|
|
70
71
|
expect(names.has(name), `Stale permission title: ${name}`).toBe(true);
|
|
71
72
|
});
|
|
72
73
|
|
|
73
|
-
it('preserves
|
|
74
|
+
it('preserves each unique application page-resource identity', () => {
|
|
74
75
|
const expected = pages.map((route) => {
|
|
75
76
|
if (typeof route.authz !== 'object')
|
|
76
77
|
throw new Error('Expected page authorization');
|
|
@@ -86,7 +87,9 @@ describe('page permission labels', () => {
|
|
|
86
87
|
id,
|
|
87
88
|
actions: actions.map((action) => action.name),
|
|
88
89
|
})),
|
|
89
|
-
).toEqual(
|
|
90
|
+
).toEqual([
|
|
91
|
+
...new Map(expected.map((resource) => [resource.id, resource])).values(),
|
|
92
|
+
]);
|
|
90
93
|
});
|
|
91
94
|
|
|
92
95
|
const fixture = () =>
|
|
@@ -221,3 +224,80 @@ describe('page permission labels', () => {
|
|
|
221
224
|
).toEqual([]);
|
|
222
225
|
});
|
|
223
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
|
+
);
|