@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.85
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/build-and-deploy.md +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +8 -2
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/data-table/data-table.tsx +86 -32
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/list-feedback.tsx +174 -0
- package/client/components/permission-editor.tsx +419 -226
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
- package/client/hooks/use-list-data.ts +76 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +24 -1
- package/client/locales/system/settings-en-US.ts +8 -10
- package/client/locales/system/settings-zh-CN.ts +8 -8
- package/client/locales/system/zh-CN.ts +22 -1
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +100 -93
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -59
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +69 -95
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +105 -91
- package/client/routing/session-preparation.tsx +10 -3
- package/client/runtime.ts +0 -4
- package/config.example.yml +24 -0
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +24 -21
- package/server/app.ts +0 -2
- package/server/config/auth.ts +2 -0
- package/server/config/concurrency.ts +3 -0
- package/server/config/index.ts +3 -0
- package/server/config/jobs.ts +1 -13
- package/server/config/notification.ts +1 -1
- package/server/config/queue.ts +1 -7
- package/server/plugins.ts +2 -0
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/data-table.test.tsx +9 -4
- package/tests/client/default-access.test.tsx +99 -5
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-feedback.test.tsx +301 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/manual-list-retry.test.tsx +190 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/permission-workspace.test.ts +13 -0
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +85 -5
- package/tests/client/settings-feedback.test.tsx +32 -15
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/permission-alignment.html +16 -0
- package/tests/fixtures/permission-alignment.tsx +124 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/playwright/permission-alignment.test.ts +109 -0
- package/tests/server/auth-email-config.test.ts +79 -0
- package/tests/server/concurrency.test.ts +61 -0
- package/tests/server/config.test.ts +34 -14
- package/tests/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
- package/client/route-overrides.ts +0 -9
- package/client/source-extensions.ts +0 -19
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ChevronRightIcon,
|
|
5
5
|
InfoIcon,
|
|
6
6
|
SearchIcon,
|
|
7
|
+
XIcon,
|
|
7
8
|
ShieldCheckIcon,
|
|
8
9
|
} from 'lucide-react';
|
|
9
10
|
import {
|
|
@@ -15,7 +16,13 @@ import {
|
|
|
15
16
|
} from 'react';
|
|
16
17
|
import { cn } from 'cn';
|
|
17
18
|
import { Button } from '#components/ui/button';
|
|
18
|
-
import {
|
|
19
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from '#components/ui/tabs';
|
|
20
|
+
import {
|
|
21
|
+
InputGroup,
|
|
22
|
+
InputGroupAddon,
|
|
23
|
+
InputGroupButton,
|
|
24
|
+
InputGroupInput,
|
|
25
|
+
} from '#components/ui/input-group';
|
|
19
26
|
import {
|
|
20
27
|
Select,
|
|
21
28
|
SelectContent,
|
|
@@ -25,7 +32,7 @@ import {
|
|
|
25
32
|
SelectValue,
|
|
26
33
|
} from '#components/ui/select';
|
|
27
34
|
import { Checkbox } from '#components/ui/checkbox';
|
|
28
|
-
import {
|
|
35
|
+
import { LongTextPreview } from '#components/long-text';
|
|
29
36
|
import {
|
|
30
37
|
Empty,
|
|
31
38
|
EmptyDescription,
|
|
@@ -59,6 +66,7 @@ export interface PermissionEditorRow {
|
|
|
59
66
|
/** Unique permission identifier supplied unchanged to callbacks. */
|
|
60
67
|
readonly id: string;
|
|
61
68
|
readonly label: string;
|
|
69
|
+
readonly path?: string | undefined;
|
|
62
70
|
readonly hint?: string | undefined;
|
|
63
71
|
/** Optional action metadata; presentation preserves the supplied row order. */
|
|
64
72
|
readonly actionKey?: string | undefined;
|
|
@@ -98,6 +106,7 @@ export interface PermissionEditorSection {
|
|
|
98
106
|
export interface PermissionEditorNavigationNode {
|
|
99
107
|
readonly id: string;
|
|
100
108
|
readonly label: string;
|
|
109
|
+
readonly path?: string | undefined;
|
|
101
110
|
readonly hint?: string | undefined;
|
|
102
111
|
readonly rowId?: string | undefined;
|
|
103
112
|
readonly children?: readonly PermissionEditorNavigationNode[];
|
|
@@ -108,6 +117,12 @@ export interface PermissionEditorLabels {
|
|
|
108
117
|
readonly levelFor: (rowLabel: string) => string;
|
|
109
118
|
readonly search: string;
|
|
110
119
|
readonly enabledOnly: string;
|
|
120
|
+
readonly granted?: string;
|
|
121
|
+
readonly ungranted?: string;
|
|
122
|
+
readonly authorizationStatus?: string;
|
|
123
|
+
readonly clearSearch?: string;
|
|
124
|
+
readonly collapseAll?: string;
|
|
125
|
+
readonly expandAll?: string;
|
|
111
126
|
readonly clear: string;
|
|
112
127
|
readonly empty: string;
|
|
113
128
|
readonly emptyDescription?: string;
|
|
@@ -141,6 +156,12 @@ const DEFAULT_LABELS: Required<PermissionEditorLabels> = {
|
|
|
141
156
|
levelFor: (rowLabel) => `How far ${rowLabel} reaches`,
|
|
142
157
|
search: 'Search categories, groups or permissions',
|
|
143
158
|
enabledOnly: 'Enabled only',
|
|
159
|
+
granted: 'Granted',
|
|
160
|
+
ungranted: 'Ungranted',
|
|
161
|
+
authorizationStatus: 'Authorization status',
|
|
162
|
+
clearSearch: 'Clear search',
|
|
163
|
+
collapseAll: 'Collapse all',
|
|
164
|
+
expandAll: 'Expand all',
|
|
144
165
|
clear: 'Clear filters',
|
|
145
166
|
empty: 'No permissions to configure',
|
|
146
167
|
emptyDescription:
|
|
@@ -169,7 +190,12 @@ function navigationSearchContext(
|
|
|
169
190
|
parents: readonly string[],
|
|
170
191
|
): void => {
|
|
171
192
|
for (const node of items) {
|
|
172
|
-
const context = [
|
|
193
|
+
const context = [
|
|
194
|
+
...parents,
|
|
195
|
+
node.label,
|
|
196
|
+
node.hint ?? '',
|
|
197
|
+
node.path ?? '',
|
|
198
|
+
];
|
|
173
199
|
if (node.rowId)
|
|
174
200
|
result.set(node.rowId, [...(result.get(node.rowId) ?? []), ...context]);
|
|
175
201
|
visit(node.children ?? [], context);
|
|
@@ -256,6 +282,7 @@ function PermissionNavigation({
|
|
|
256
282
|
filtering,
|
|
257
283
|
onDisclosure,
|
|
258
284
|
nested = false,
|
|
285
|
+
counts,
|
|
259
286
|
...rowProps
|
|
260
287
|
}: {
|
|
261
288
|
readonly nodes: readonly PermissionEditorNavigationNode[];
|
|
@@ -265,6 +292,8 @@ function PermissionNavigation({
|
|
|
265
292
|
readonly filtering: boolean;
|
|
266
293
|
readonly onDisclosure: (key: string) => void;
|
|
267
294
|
readonly nested?: boolean;
|
|
295
|
+
readonly counts?:
|
|
296
|
+
ReadonlyMap<string, { enabled: number; total: number }> | undefined;
|
|
268
297
|
} & Omit<
|
|
269
298
|
Parameters<typeof PermissionRow>[0],
|
|
270
299
|
'row' | 'controlId' | 'presentation'
|
|
@@ -314,6 +343,7 @@ function PermissionNavigation({
|
|
|
314
343
|
...row,
|
|
315
344
|
label: node.label,
|
|
316
345
|
hint: node.hint ?? row.hint,
|
|
346
|
+
path: node.path ?? row.path,
|
|
317
347
|
}}
|
|
318
348
|
presentation='navigation'
|
|
319
349
|
/>
|
|
@@ -322,7 +352,7 @@ function PermissionNavigation({
|
|
|
322
352
|
<div className='grid min-w-0 flex-1 gap-1 px-2 py-1.5 text-muted-foreground'>
|
|
323
353
|
<LongTextPreview
|
|
324
354
|
label={node.label}
|
|
325
|
-
className='text-xs leading-5 font-
|
|
355
|
+
className='text-xs leading-5 font-normal'
|
|
326
356
|
>
|
|
327
357
|
{node.label}
|
|
328
358
|
</LongTextPreview>
|
|
@@ -333,12 +363,17 @@ function PermissionNavigation({
|
|
|
333
363
|
)}
|
|
334
364
|
</div>
|
|
335
365
|
)}
|
|
366
|
+
{!nested && !row && counts?.has(node.id) && (
|
|
367
|
+
<span className='ml-auto shrink-0 px-2 py-1.5 text-xs text-muted-foreground'>
|
|
368
|
+
{rowProps.labels.summary(
|
|
369
|
+
counts.get(node.id)!.enabled,
|
|
370
|
+
counts.get(node.id)!.total,
|
|
371
|
+
)}
|
|
372
|
+
</span>
|
|
373
|
+
)}
|
|
336
374
|
</div>
|
|
337
375
|
{expanded && children.length > 0 && (
|
|
338
|
-
<div
|
|
339
|
-
id={childrenId}
|
|
340
|
-
className='mt-0.5 ml-3 min-w-0 border-l pl-2 sm:pl-3'
|
|
341
|
-
>
|
|
376
|
+
<div id={childrenId} className='min-w-0 pl-4 sm:pl-6'>
|
|
342
377
|
<PermissionNavigation
|
|
343
378
|
{...rowProps}
|
|
344
379
|
nodes={children}
|
|
@@ -348,6 +383,7 @@ function PermissionNavigation({
|
|
|
348
383
|
filtering={filtering}
|
|
349
384
|
onDisclosure={onDisclosure}
|
|
350
385
|
nested
|
|
386
|
+
counts={counts}
|
|
351
387
|
/>
|
|
352
388
|
</div>
|
|
353
389
|
)}
|
|
@@ -463,7 +499,7 @@ function LevelControl({
|
|
|
463
499
|
>
|
|
464
500
|
<SelectTrigger
|
|
465
501
|
size='sm'
|
|
466
|
-
className='
|
|
502
|
+
className='w-full min-w-0 max-w-full flex-1'
|
|
467
503
|
aria-label={label}
|
|
468
504
|
>
|
|
469
505
|
<SelectValue />
|
|
@@ -498,7 +534,7 @@ function PermissionRow({
|
|
|
498
534
|
readonly labels: Required<PermissionEditorLabels>;
|
|
499
535
|
readonly onEnabledChange: (rowId: string, enabled: boolean) => void;
|
|
500
536
|
readonly onLevelChange: (rowId: string, level: string) => void;
|
|
501
|
-
readonly presentation?: 'tile' | 'navigation';
|
|
537
|
+
readonly presentation?: 'tile' | 'navigation' | 'inline';
|
|
502
538
|
}): ReactElement {
|
|
503
539
|
const hasControls =
|
|
504
540
|
row.enabled && Boolean(row.levels?.length || row.scopes?.length);
|
|
@@ -514,38 +550,42 @@ function PermissionRow({
|
|
|
514
550
|
<div
|
|
515
551
|
data-slot='permission-row'
|
|
516
552
|
className={cn(
|
|
517
|
-
'grid min-w-0
|
|
518
|
-
presentation === '
|
|
519
|
-
? 'rounded-md px-2 py-1.5 hover:bg-muted/40 focus-within:bg-muted/40'
|
|
520
|
-
: cn(
|
|
521
|
-
'rounded-lg border p-2.5',
|
|
522
|
-
row.enabled ? 'border-primary/20 bg-primary/5' : 'bg-card',
|
|
523
|
-
),
|
|
553
|
+
'group/field grid min-w-0 gap-2 rounded-md px-2 py-2 hover:bg-hover',
|
|
554
|
+
presentation === 'inline' && 'inline-flex items-start',
|
|
524
555
|
)}
|
|
525
556
|
>
|
|
526
|
-
<div className='flex min-w-0 items-start gap-
|
|
557
|
+
<div className='flex min-w-0 items-start gap-3'>
|
|
527
558
|
<Checkbox
|
|
528
559
|
id={controlId}
|
|
529
560
|
className='mt-0.5 shrink-0'
|
|
530
561
|
checked={row.enabled}
|
|
562
|
+
aria-label={row.label}
|
|
531
563
|
disabled={readOnly || row.toggleable === false}
|
|
532
564
|
onCheckedChange={(on: boolean) => onEnabledChange(row.id, on)}
|
|
533
565
|
aria-describedby={describedBy}
|
|
534
566
|
/>
|
|
535
|
-
<div className='grid min-w-0 gap-1'>
|
|
536
|
-
<
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
<
|
|
567
|
+
<div className='grid min-w-0 flex-1 gap-1'>
|
|
568
|
+
<div className='flex min-w-0 flex-wrap items-baseline gap-x-4 gap-y-1'>
|
|
569
|
+
<label
|
|
570
|
+
htmlFor={controlId}
|
|
571
|
+
className='cursor-pointer text-sm leading-5 font-normal break-words'
|
|
572
|
+
>
|
|
573
|
+
{row.label}
|
|
574
|
+
</label>
|
|
575
|
+
{row.path && (
|
|
576
|
+
<code className='text-xs text-muted-foreground [overflow-wrap:anywhere]'>
|
|
577
|
+
{row.path}
|
|
578
|
+
</code>
|
|
579
|
+
)}
|
|
580
|
+
{row.hint && (
|
|
581
|
+
<div
|
|
582
|
+
id={`${controlId}-hint`}
|
|
583
|
+
className='min-w-0 basis-48 grow text-xs leading-5 text-muted-foreground'
|
|
584
|
+
>
|
|
545
585
|
{row.hint}
|
|
546
|
-
</
|
|
547
|
-
|
|
548
|
-
|
|
586
|
+
</div>
|
|
587
|
+
)}
|
|
588
|
+
</div>
|
|
549
589
|
{row.toggleable === false && (
|
|
550
590
|
<p
|
|
551
591
|
id={`${controlId}-managed`}
|
|
@@ -557,22 +597,37 @@ function PermissionRow({
|
|
|
557
597
|
</div>
|
|
558
598
|
</div>
|
|
559
599
|
{hasControls && (
|
|
560
|
-
<div
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
600
|
+
<div
|
|
601
|
+
data-slot='permission-scopes'
|
|
602
|
+
className='grid min-w-0 max-w-[60rem] gap-x-6 gap-y-2 pl-7 @min-[48rem]/permissions:grid-cols-2'
|
|
603
|
+
>
|
|
604
|
+
{row.levels && row.levels.length > 0 && (
|
|
605
|
+
<div className='grid min-w-0 items-start gap-x-3 gap-y-1 @min-[26rem]/permissions:grid-cols-[8.5rem_minmax(0,1fr)]'>
|
|
606
|
+
<span aria-hidden />
|
|
607
|
+
<div className='grid min-w-0 grid-cols-[minmax(0,1fr)_1.5rem] items-start gap-x-1'>
|
|
608
|
+
<LevelControl
|
|
609
|
+
label={labels.levelFor(row.label)}
|
|
610
|
+
levels={row.levels}
|
|
611
|
+
value={row.level ?? ''}
|
|
612
|
+
disabled={readOnly}
|
|
613
|
+
onChange={(level) => onLevelChange(row.id, level)}
|
|
614
|
+
/>
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
569
617
|
)}
|
|
570
618
|
{row.scopes?.map((scope) => (
|
|
571
|
-
<div
|
|
572
|
-
|
|
619
|
+
<div
|
|
620
|
+
key={scope.id}
|
|
621
|
+
data-slot='permission-scope'
|
|
622
|
+
className='grid min-w-0 items-start gap-x-3 gap-y-1 @min-[26rem]/permissions:grid-cols-[8.5rem_minmax(0,1fr)]'
|
|
623
|
+
>
|
|
624
|
+
<span
|
|
625
|
+
title={scope.label}
|
|
626
|
+
className='line-clamp-2 min-w-0 pt-1 text-xs break-words text-muted-foreground'
|
|
627
|
+
>
|
|
573
628
|
{scope.label}
|
|
574
629
|
</span>
|
|
575
|
-
<div className='
|
|
630
|
+
<div className='grid min-w-0 grid-cols-[minmax(0,1fr)_1.5rem] items-start gap-x-1 gap-y-1'>
|
|
576
631
|
<LevelControl
|
|
577
632
|
label={labels.levelFor(`${row.label} · ${scope.label}`)}
|
|
578
633
|
levels={scope.levels}
|
|
@@ -580,13 +635,21 @@ function PermissionRow({
|
|
|
580
635
|
disabled={readOnly}
|
|
581
636
|
onChange={(level) => onLevelChange(scope.id, level)}
|
|
582
637
|
/>
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
638
|
+
<div className='col-start-2 row-start-1'>
|
|
639
|
+
{scope.hint && (
|
|
640
|
+
<ScopeHint
|
|
641
|
+
label={labels.scopeHintFor(
|
|
642
|
+
`${row.label} · ${scope.label}`,
|
|
643
|
+
)}
|
|
644
|
+
>
|
|
645
|
+
{scope.hint}
|
|
646
|
+
</ScopeHint>
|
|
647
|
+
)}
|
|
648
|
+
</div>
|
|
649
|
+
{scope.configuration && (
|
|
650
|
+
<div className='col-start-1 min-w-0'>
|
|
651
|
+
{scope.configuration}
|
|
652
|
+
</div>
|
|
590
653
|
)}
|
|
591
654
|
</div>
|
|
592
655
|
</div>
|
|
@@ -594,9 +657,12 @@ function PermissionRow({
|
|
|
594
657
|
{[
|
|
595
658
|
...new Set(row.scopes?.map((scope) => scope.hint).filter(Boolean)),
|
|
596
659
|
].map((hint) => (
|
|
597
|
-
<
|
|
660
|
+
<p
|
|
661
|
+
key={hint}
|
|
662
|
+
className='col-span-full text-xs leading-5 text-muted-foreground'
|
|
663
|
+
>
|
|
598
664
|
{hint}
|
|
599
|
-
</
|
|
665
|
+
</p>
|
|
600
666
|
))}
|
|
601
667
|
</div>
|
|
602
668
|
)}
|
|
@@ -615,7 +681,7 @@ export function PermissionEditor({
|
|
|
615
681
|
const words = { ...DEFAULT_LABELS, ...labels };
|
|
616
682
|
const [selected, setSelected] = useState(sections[0]?.id ?? '');
|
|
617
683
|
const [query, setQuery] = useState('');
|
|
618
|
-
const [
|
|
684
|
+
const [status, setStatus] = useState('all');
|
|
619
685
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(
|
|
620
686
|
() => new Set(),
|
|
621
687
|
);
|
|
@@ -628,7 +694,7 @@ export function PermissionEditor({
|
|
|
628
694
|
? selected
|
|
629
695
|
: available[0]?.id;
|
|
630
696
|
const search = query.trim().toLocaleLowerCase();
|
|
631
|
-
const filtering = Boolean(search) ||
|
|
697
|
+
const filtering = Boolean(search) || status !== 'all';
|
|
632
698
|
const matches = (values: readonly (string | undefined)[]): boolean =>
|
|
633
699
|
!search ||
|
|
634
700
|
values.some((value) => value?.toLocaleLowerCase().includes(search));
|
|
@@ -643,7 +709,7 @@ export function PermissionEditor({
|
|
|
643
709
|
...group,
|
|
644
710
|
rows: group.rows.filter(
|
|
645
711
|
(row) =>
|
|
646
|
-
(
|
|
712
|
+
(status === 'all' || row.enabled === (status === 'granted')) &&
|
|
647
713
|
matches([
|
|
648
714
|
section.title,
|
|
649
715
|
section.description,
|
|
@@ -652,6 +718,7 @@ export function PermissionEditor({
|
|
|
652
718
|
group.actionHint,
|
|
653
719
|
row.label,
|
|
654
720
|
row.hint,
|
|
721
|
+
row.path,
|
|
655
722
|
...(context.get(row.id) ?? []),
|
|
656
723
|
...(row.scopes?.map((scope) => scope.label) ?? []),
|
|
657
724
|
]),
|
|
@@ -663,38 +730,150 @@ export function PermissionEditor({
|
|
|
663
730
|
.filter((section) => section.groups.length);
|
|
664
731
|
const clearFilters = (): void => {
|
|
665
732
|
setQuery('');
|
|
666
|
-
|
|
733
|
+
setStatus('all');
|
|
667
734
|
searchRef.current?.focus();
|
|
668
735
|
};
|
|
736
|
+
const disclose = (key: string): void =>
|
|
737
|
+
setCollapsed((current) => {
|
|
738
|
+
const next = new Set(current);
|
|
739
|
+
if (next.has(key)) next.delete(key);
|
|
740
|
+
else next.add(key);
|
|
741
|
+
return next;
|
|
742
|
+
});
|
|
743
|
+
const groupKey = (section: string, group: string): string =>
|
|
744
|
+
`${id}-${section}-group-${group}`;
|
|
745
|
+
const branchKeys = (
|
|
746
|
+
nodes: readonly PermissionEditorNavigationNode[],
|
|
747
|
+
prefix: string,
|
|
748
|
+
): string[] =>
|
|
749
|
+
nodes.flatMap((node) =>
|
|
750
|
+
node.children?.length
|
|
751
|
+
? [`${prefix}-${node.id}`, ...branchKeys(node.children, prefix)]
|
|
752
|
+
: [],
|
|
753
|
+
);
|
|
754
|
+
const activeSection = available.find((section) => section.id === active);
|
|
755
|
+
const collapseKeys = activeSection
|
|
756
|
+
? activeSection.navigation
|
|
757
|
+
? branchKeys(activeSection.navigation, `${id}-${activeSection.id}`)
|
|
758
|
+
: activeSection.groups
|
|
759
|
+
.filter((group) => !group.accessOnly || group.rows.length !== 1)
|
|
760
|
+
.map((group) => groupKey(activeSection.id, group.id))
|
|
761
|
+
: [];
|
|
762
|
+
const allCollapsed =
|
|
763
|
+
collapseKeys.length > 0 && collapseKeys.every((key) => collapsed.has(key));
|
|
764
|
+
const changeEnabled = (rowId: string, enabled: boolean): void => {
|
|
765
|
+
if (status !== 'all') searchRef.current?.focus();
|
|
766
|
+
onEnabledChange(rowId, enabled);
|
|
767
|
+
};
|
|
768
|
+
const navigationCounts = (
|
|
769
|
+
section: PermissionEditorSection,
|
|
770
|
+
): ReadonlyMap<string, { enabled: number; total: number }> => {
|
|
771
|
+
const rows = new Map(
|
|
772
|
+
section.groups.flatMap((group) =>
|
|
773
|
+
group.rows.map((row) => [row.id, row] as const),
|
|
774
|
+
),
|
|
775
|
+
);
|
|
776
|
+
const counts = new Map<string, { enabled: number; total: number }>();
|
|
777
|
+
const visit = (node: PermissionEditorNavigationNode): Set<string> => {
|
|
778
|
+
const ids = new Set(
|
|
779
|
+
node.rowId && rows.has(node.rowId) ? [node.rowId] : [],
|
|
780
|
+
);
|
|
781
|
+
for (const child of node.children ?? [])
|
|
782
|
+
for (const childId of visit(child)) ids.add(childId);
|
|
783
|
+
counts.set(node.id, {
|
|
784
|
+
enabled: [...ids].filter((rowId) => rows.get(rowId)?.enabled).length,
|
|
785
|
+
total: ids.size,
|
|
786
|
+
});
|
|
787
|
+
return ids;
|
|
788
|
+
};
|
|
789
|
+
for (const node of section.navigation ?? []) visit(node);
|
|
790
|
+
return counts;
|
|
791
|
+
};
|
|
792
|
+
const rowProps = {
|
|
793
|
+
readOnly,
|
|
794
|
+
readOnlyHintId: `${id}-readonly`,
|
|
795
|
+
labels: words,
|
|
796
|
+
onEnabledChange: changeEnabled,
|
|
797
|
+
onLevelChange,
|
|
798
|
+
};
|
|
669
799
|
return (
|
|
670
|
-
<
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
800
|
+
<Tabs
|
|
801
|
+
value={active ?? ''}
|
|
802
|
+
onValueChange={(value) => {
|
|
803
|
+
setSelected(String(value));
|
|
804
|
+
setQuery('');
|
|
805
|
+
setStatus('all');
|
|
806
|
+
}}
|
|
807
|
+
className='@container/permissions min-w-0 gap-3'
|
|
808
|
+
>
|
|
809
|
+
{available.length > 0 && (
|
|
810
|
+
<div className='overflow-x-auto pb-1'>
|
|
811
|
+
<TabsList variant='line' aria-label={words.navigation}>
|
|
812
|
+
{available.map((section) => (
|
|
813
|
+
<TabsTrigger key={section.id} value={section.id}>
|
|
814
|
+
{section.title}
|
|
815
|
+
</TabsTrigger>
|
|
816
|
+
))}
|
|
817
|
+
</TabsList>
|
|
818
|
+
</div>
|
|
819
|
+
)}
|
|
820
|
+
<div className='flex min-w-0 flex-wrap items-center gap-3 border-b pb-3'>
|
|
821
|
+
<InputGroup className='min-w-0 basis-full sm:basis-56 sm:flex-1'>
|
|
822
|
+
<InputGroupAddon>
|
|
823
|
+
<SearchIcon aria-hidden />
|
|
824
|
+
</InputGroupAddon>
|
|
825
|
+
<InputGroupInput
|
|
678
826
|
ref={searchRef}
|
|
679
|
-
className='pl-9'
|
|
680
827
|
value={query}
|
|
681
828
|
aria-label={words.search}
|
|
682
829
|
placeholder={words.search}
|
|
683
830
|
onChange={(event) => setQuery(event.target.value)}
|
|
684
831
|
/>
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
832
|
+
{query && (
|
|
833
|
+
<InputGroupAddon align='inline-end'>
|
|
834
|
+
<InputGroupButton
|
|
835
|
+
type='button'
|
|
836
|
+
size='icon-xs'
|
|
837
|
+
aria-label={words.clearSearch}
|
|
838
|
+
onClick={() => {
|
|
839
|
+
setQuery('');
|
|
840
|
+
searchRef.current?.focus();
|
|
841
|
+
}}
|
|
842
|
+
>
|
|
843
|
+
<XIcon aria-hidden />
|
|
844
|
+
</InputGroupButton>
|
|
845
|
+
</InputGroupAddon>
|
|
846
|
+
)}
|
|
847
|
+
</InputGroup>
|
|
848
|
+
<Tabs
|
|
849
|
+
value={status}
|
|
850
|
+
onValueChange={(value) => setStatus(String(value))}
|
|
689
851
|
>
|
|
690
|
-
<
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
852
|
+
<TabsList aria-label={words.authorizationStatus}>
|
|
853
|
+
<TabsTrigger value='all'>{words.selectAll}</TabsTrigger>
|
|
854
|
+
<TabsTrigger value='granted'>{words.granted}</TabsTrigger>
|
|
855
|
+
<TabsTrigger value='ungranted'>{words.ungranted}</TabsTrigger>
|
|
856
|
+
</TabsList>
|
|
857
|
+
</Tabs>
|
|
858
|
+
<Button
|
|
859
|
+
type='button'
|
|
860
|
+
variant='outline'
|
|
861
|
+
size='sm'
|
|
862
|
+
disabled={filtering || !collapseKeys.length}
|
|
863
|
+
onClick={() =>
|
|
864
|
+
setCollapsed((current) => {
|
|
865
|
+
const next = new Set(current);
|
|
866
|
+
for (const key of collapseKeys) {
|
|
867
|
+
if (allCollapsed) next.delete(key);
|
|
868
|
+
else next.add(key);
|
|
869
|
+
}
|
|
870
|
+
return next;
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
>
|
|
874
|
+
{allCollapsed ? words.expandAll : words.collapseAll}
|
|
875
|
+
</Button>
|
|
876
|
+
{filtering && visible.length > 0 && (
|
|
698
877
|
<Button
|
|
699
878
|
type='button'
|
|
700
879
|
variant='ghost'
|
|
@@ -705,167 +884,176 @@ export function PermissionEditor({
|
|
|
705
884
|
</Button>
|
|
706
885
|
)}
|
|
707
886
|
</div>
|
|
708
|
-
<
|
|
709
|
-
|
|
710
|
-
<nav
|
|
711
|
-
aria-label={words.navigation}
|
|
712
|
-
className='flex shrink-0 gap-1 overflow-x-auto border-b bg-muted/25 p-2 @2xl:sticky @2xl:top-0 @2xl:w-52 @2xl:self-start @2xl:flex-col @2xl:border-r @2xl:border-b-0'
|
|
713
|
-
>
|
|
714
|
-
{available.map((section) => (
|
|
715
|
-
<Button
|
|
716
|
-
key={section.id}
|
|
717
|
-
type='button'
|
|
718
|
-
variant='ghost'
|
|
719
|
-
aria-current={section.id === active ? 'page' : undefined}
|
|
720
|
-
aria-controls={`${id}-content`}
|
|
721
|
-
onClick={() => {
|
|
722
|
-
setSelected(section.id);
|
|
723
|
-
setQuery('');
|
|
724
|
-
setEnabledOnly(false);
|
|
725
|
-
}}
|
|
726
|
-
className={cn(
|
|
727
|
-
'h-auto min-h-8 shrink-0 justify-start px-3 py-2 text-left text-sm font-normal whitespace-nowrap @2xl:w-full @2xl:whitespace-normal',
|
|
728
|
-
section.id === active &&
|
|
729
|
-
'bg-primary/10 font-medium text-primary hover:bg-primary/15',
|
|
730
|
-
)}
|
|
731
|
-
>
|
|
732
|
-
{section.title}
|
|
733
|
-
</Button>
|
|
734
|
-
))}
|
|
735
|
-
</nav>
|
|
736
|
-
)}
|
|
737
|
-
<div id={`${id}-content`} className='min-w-0 flex-1'>
|
|
887
|
+
<TabsContent value={active ?? ''} className='min-w-0'>
|
|
888
|
+
<div className='min-w-0 md:max-h-[min(65dvh,48rem)] md:overflow-y-auto md:pr-2'>
|
|
738
889
|
{readOnly && (
|
|
739
890
|
<p
|
|
740
891
|
id={`${id}-readonly`}
|
|
741
|
-
className='
|
|
892
|
+
className='mb-3 text-xs text-muted-foreground'
|
|
742
893
|
>
|
|
743
894
|
{words.readOnly}
|
|
744
895
|
</p>
|
|
745
896
|
)}
|
|
746
897
|
{visible.length ? (
|
|
747
|
-
visible.map((section) =>
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
{section.navigation ? (
|
|
762
|
-
<PermissionNavigationSection
|
|
763
|
-
section={section}
|
|
764
|
-
prefix={`${id}-${section.id}`}
|
|
765
|
-
collapsed={collapsed}
|
|
766
|
-
filtering={filtering}
|
|
767
|
-
onDisclosure={(key) =>
|
|
768
|
-
setCollapsed((current) => {
|
|
769
|
-
const next = new Set(current);
|
|
770
|
-
if (next.has(key)) next.delete(key);
|
|
771
|
-
else next.add(key);
|
|
772
|
-
return next;
|
|
773
|
-
})
|
|
774
|
-
}
|
|
775
|
-
readOnly={readOnly}
|
|
776
|
-
readOnlyHintId={`${id}-readonly`}
|
|
777
|
-
labels={words}
|
|
778
|
-
onEnabledChange={(rowId, on) => {
|
|
779
|
-
if (enabledOnly && !on) searchRef.current?.focus();
|
|
780
|
-
onEnabledChange(rowId, on);
|
|
781
|
-
}}
|
|
782
|
-
onLevelChange={onLevelChange}
|
|
783
|
-
/>
|
|
784
|
-
) : (
|
|
785
|
-
<div
|
|
786
|
-
className={cn(
|
|
787
|
-
'grid min-w-0 gap-3',
|
|
788
|
-
section.groups.every(
|
|
789
|
-
(group) => group.accessOnly && group.rows.length === 1,
|
|
790
|
-
) &&
|
|
791
|
-
'grid-cols-[repeat(auto-fit,minmax(min(100%,14rem),1fr))]',
|
|
898
|
+
visible.map((section) => {
|
|
899
|
+
const original = available.find(
|
|
900
|
+
(item) => item.id === section.id,
|
|
901
|
+
)!;
|
|
902
|
+
const allRows = original.groups.flatMap((group) => group.rows);
|
|
903
|
+
return (
|
|
904
|
+
<section
|
|
905
|
+
key={section.id}
|
|
906
|
+
aria-label={section.title}
|
|
907
|
+
className='grid min-w-0 gap-3 py-1'
|
|
908
|
+
>
|
|
909
|
+
<div className='flex flex-wrap items-baseline justify-between gap-2'>
|
|
910
|
+
{filtering && (
|
|
911
|
+
<h2 className='text-sm font-normal'>{section.title}</h2>
|
|
792
912
|
)}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
913
|
+
{section.description && (
|
|
914
|
+
<p className='text-xs text-muted-foreground'>
|
|
915
|
+
{section.description}
|
|
916
|
+
</p>
|
|
917
|
+
)}
|
|
918
|
+
<span className='ml-auto text-xs text-muted-foreground'>
|
|
919
|
+
{words.summary(
|
|
920
|
+
allRows.filter((row) => row.enabled).length,
|
|
921
|
+
allRows.length,
|
|
922
|
+
)}
|
|
923
|
+
</span>
|
|
924
|
+
</div>
|
|
925
|
+
{section.navigation ? (
|
|
926
|
+
<PermissionNavigationSection
|
|
927
|
+
{...rowProps}
|
|
928
|
+
section={section}
|
|
929
|
+
prefix={`${id}-${section.id}`}
|
|
930
|
+
collapsed={collapsed}
|
|
931
|
+
filtering={filtering}
|
|
932
|
+
onDisclosure={disclose}
|
|
933
|
+
counts={navigationCounts(original)}
|
|
934
|
+
/>
|
|
935
|
+
) : (
|
|
936
|
+
<div className='divide-y rounded-lg border'>
|
|
937
|
+
{section.groups.map((group) => {
|
|
938
|
+
const key = groupKey(section.id, group.id);
|
|
939
|
+
const complete = original.groups.find(
|
|
940
|
+
(item) => item.id === group.id,
|
|
941
|
+
)!;
|
|
942
|
+
const inline = complete.rows.every(
|
|
943
|
+
(row) =>
|
|
944
|
+
!row.hint &&
|
|
945
|
+
!row.scopes?.length &&
|
|
946
|
+
!row.levels?.length,
|
|
947
|
+
);
|
|
948
|
+
const accessRow =
|
|
949
|
+
group.accessOnly && group.rows.length === 1
|
|
950
|
+
? group.rows[0]
|
|
951
|
+
: undefined;
|
|
952
|
+
const closed =
|
|
953
|
+
!accessRow && !filtering && collapsed.has(key);
|
|
954
|
+
const rows = accessRow
|
|
955
|
+
? [
|
|
956
|
+
{
|
|
957
|
+
...accessRow,
|
|
958
|
+
label: group.title ?? accessRow.label,
|
|
959
|
+
hint: group.hint ?? accessRow.hint,
|
|
960
|
+
},
|
|
961
|
+
]
|
|
962
|
+
: group.rows;
|
|
963
|
+
return (
|
|
833
964
|
<div
|
|
834
|
-
|
|
835
|
-
|
|
965
|
+
key={group.id}
|
|
966
|
+
data-slot='permission-resource'
|
|
967
|
+
className='min-w-0 p-2'
|
|
836
968
|
>
|
|
837
|
-
{
|
|
838
|
-
<div
|
|
839
|
-
<
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
969
|
+
{!accessRow && (
|
|
970
|
+
<div className='flex min-w-0 flex-wrap items-center gap-2 rounded-md px-1 py-1 hover:bg-hover'>
|
|
971
|
+
<Button
|
|
972
|
+
type='button'
|
|
973
|
+
variant='ghost'
|
|
974
|
+
size='icon-sm'
|
|
975
|
+
disabled={filtering}
|
|
976
|
+
aria-label={
|
|
977
|
+
closed
|
|
978
|
+
? words.expandGroup(
|
|
979
|
+
group.title ?? group.id,
|
|
980
|
+
)
|
|
981
|
+
: words.collapseGroup(
|
|
982
|
+
group.title ?? group.id,
|
|
983
|
+
)
|
|
984
|
+
}
|
|
985
|
+
aria-expanded={!closed}
|
|
986
|
+
aria-controls={closed ? undefined : key}
|
|
987
|
+
onClick={() => disclose(key)}
|
|
988
|
+
>
|
|
989
|
+
{closed ? (
|
|
990
|
+
<ChevronRightIcon aria-hidden />
|
|
991
|
+
) : (
|
|
992
|
+
<ChevronDownIcon aria-hidden />
|
|
993
|
+
)}
|
|
994
|
+
</Button>
|
|
995
|
+
{group.title && (
|
|
996
|
+
<h3 className='text-sm font-normal break-words'>
|
|
997
|
+
{group.title}
|
|
998
|
+
</h3>
|
|
999
|
+
)}
|
|
1000
|
+
{group.hint && (
|
|
1001
|
+
<span className='text-xs text-muted-foreground'>
|
|
1002
|
+
{group.hint}
|
|
1003
|
+
</span>
|
|
1004
|
+
)}
|
|
1005
|
+
<span className='ml-auto text-xs text-muted-foreground'>
|
|
1006
|
+
{words.summary(
|
|
1007
|
+
complete.rows.filter((row) => row.enabled)
|
|
1008
|
+
.length,
|
|
1009
|
+
complete.rows.length,
|
|
1010
|
+
)}
|
|
1011
|
+
</span>
|
|
1012
|
+
</div>
|
|
1013
|
+
)}
|
|
1014
|
+
{!closed && (
|
|
1015
|
+
<div id={key} className='min-w-0'>
|
|
1016
|
+
{group.actionHint && (
|
|
1017
|
+
<p className='px-2 py-1 text-xs text-muted-foreground'>
|
|
1018
|
+
{group.actionHint}
|
|
1019
|
+
</p>
|
|
1020
|
+
)}
|
|
1021
|
+
<div
|
|
1022
|
+
data-slot='permission-actions'
|
|
1023
|
+
className={cn(
|
|
1024
|
+
'min-w-0',
|
|
1025
|
+
inline
|
|
1026
|
+
? 'flex flex-wrap items-start gap-x-3'
|
|
1027
|
+
: 'grid',
|
|
1028
|
+
)}
|
|
1029
|
+
>
|
|
1030
|
+
{rows.map((row) => (
|
|
1031
|
+
<PermissionRow
|
|
1032
|
+
key={row.id}
|
|
1033
|
+
{...rowProps}
|
|
1034
|
+
row={row}
|
|
1035
|
+
controlId={`${id}-${row.id}`}
|
|
1036
|
+
presentation={inline ? 'inline' : 'tile'}
|
|
1037
|
+
/>
|
|
1038
|
+
))}
|
|
1039
|
+
</div>
|
|
852
1040
|
</div>
|
|
853
|
-
)
|
|
1041
|
+
)}
|
|
854
1042
|
</div>
|
|
855
|
-
|
|
856
|
-
)
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
)
|
|
1043
|
+
);
|
|
1044
|
+
})}
|
|
1045
|
+
</div>
|
|
1046
|
+
)}
|
|
1047
|
+
{section.footer && (
|
|
1048
|
+
<div className='text-xs text-muted-foreground'>
|
|
1049
|
+
{section.footer}
|
|
1050
|
+
</div>
|
|
1051
|
+
)}
|
|
1052
|
+
</section>
|
|
1053
|
+
);
|
|
1054
|
+
})
|
|
867
1055
|
) : (
|
|
868
|
-
<Empty role='status' className='min-h-40 border p-6
|
|
1056
|
+
<Empty role='status' className='min-h-40 border p-6'>
|
|
869
1057
|
<EmptyHeader>
|
|
870
1058
|
<EmptyMedia variant='icon'>
|
|
871
1059
|
<ShieldCheckIcon aria-hidden />
|
|
@@ -877,10 +1065,15 @@ export function PermissionEditor({
|
|
|
877
1065
|
{filtering ? words.clear : words.emptyDescription}
|
|
878
1066
|
</EmptyDescription>
|
|
879
1067
|
</EmptyHeader>
|
|
1068
|
+
{filtering && (
|
|
1069
|
+
<Button type='button' variant='outline' onClick={clearFilters}>
|
|
1070
|
+
{words.clear}
|
|
1071
|
+
</Button>
|
|
1072
|
+
)}
|
|
880
1073
|
</Empty>
|
|
881
1074
|
)}
|
|
882
1075
|
</div>
|
|
883
|
-
</
|
|
884
|
-
</
|
|
1076
|
+
</TabsContent>
|
|
1077
|
+
</Tabs>
|
|
885
1078
|
);
|
|
886
1079
|
}
|