@nocobase/app-template-default 1.0.0-beta.82 → 1.0.0-beta.84
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 +9 -1
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- 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/permission-editor.tsx +396 -210
- 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/hooks/use-list-data.ts +77 -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 +10 -0
- package/client/locales/system/settings-en-US.ts +8 -0
- package/client/locales/system/settings-zh-CN.ts +8 -0
- package/client/locales/system/zh-CN.ts +8 -0
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +56 -27
- 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 -58
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +11 -3
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +39 -27
- package/client/routing/session-preparation.tsx +10 -3
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +13 -12
- 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/default-access.test.tsx +60 -4
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -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/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +5 -4
- package/tests/client/settings-feedback.test.tsx +25 -0
- 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/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/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
|
@@ -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,10 +534,12 @@ 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);
|
|
541
|
+
const singleRange =
|
|
542
|
+
hasControls && (row.scopes?.length ?? 0) + (row.levels ? 1 : 0) === 1;
|
|
505
543
|
const describedBy =
|
|
506
544
|
[
|
|
507
545
|
row.hint ? `${controlId}-hint` : undefined,
|
|
@@ -514,16 +552,17 @@ function PermissionRow({
|
|
|
514
552
|
<div
|
|
515
553
|
data-slot='permission-row'
|
|
516
554
|
className={cn(
|
|
517
|
-
'grid min-w-0
|
|
518
|
-
presentation === '
|
|
519
|
-
|
|
520
|
-
: cn(
|
|
521
|
-
'rounded-lg border p-2.5',
|
|
522
|
-
row.enabled ? 'border-primary/20 bg-primary/5' : 'bg-card',
|
|
523
|
-
),
|
|
555
|
+
'group/field grid min-w-0 gap-2 rounded-md px-2 py-2 hover:bg-hover',
|
|
556
|
+
presentation === 'inline' && 'inline-flex items-start',
|
|
557
|
+
singleRange && 'sm:flex sm:flex-wrap sm:items-start sm:gap-x-5',
|
|
524
558
|
)}
|
|
525
559
|
>
|
|
526
|
-
<div
|
|
560
|
+
<div
|
|
561
|
+
className={cn(
|
|
562
|
+
'flex min-w-0 items-start gap-3',
|
|
563
|
+
singleRange && 'sm:max-w-lg',
|
|
564
|
+
)}
|
|
565
|
+
>
|
|
527
566
|
<Checkbox
|
|
528
567
|
id={controlId}
|
|
529
568
|
className='mt-0.5 shrink-0'
|
|
@@ -532,20 +571,28 @@ function PermissionRow({
|
|
|
532
571
|
onCheckedChange={(on: boolean) => onEnabledChange(row.id, on)}
|
|
533
572
|
aria-describedby={describedBy}
|
|
534
573
|
/>
|
|
535
|
-
<div className='grid min-w-0 gap-1'>
|
|
536
|
-
<
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
<
|
|
574
|
+
<div className='grid min-w-0 flex-1 gap-1'>
|
|
575
|
+
<div className='flex min-w-0 flex-wrap items-baseline gap-x-4 gap-y-1'>
|
|
576
|
+
<label
|
|
577
|
+
htmlFor={controlId}
|
|
578
|
+
className='cursor-pointer text-sm leading-5 font-normal break-words'
|
|
579
|
+
>
|
|
580
|
+
{row.label}
|
|
581
|
+
</label>
|
|
582
|
+
{row.path && (
|
|
583
|
+
<code className='text-xs text-muted-foreground [overflow-wrap:anywhere]'>
|
|
584
|
+
{row.path}
|
|
585
|
+
</code>
|
|
586
|
+
)}
|
|
587
|
+
{row.hint && (
|
|
588
|
+
<div
|
|
589
|
+
id={`${controlId}-hint`}
|
|
590
|
+
className='min-w-0 basis-48 grow text-xs leading-5 text-muted-foreground'
|
|
591
|
+
>
|
|
545
592
|
{row.hint}
|
|
546
|
-
</
|
|
547
|
-
|
|
548
|
-
|
|
593
|
+
</div>
|
|
594
|
+
)}
|
|
595
|
+
</div>
|
|
549
596
|
{row.toggleable === false && (
|
|
550
597
|
<p
|
|
551
598
|
id={`${controlId}-managed`}
|
|
@@ -557,7 +604,13 @@ function PermissionRow({
|
|
|
557
604
|
</div>
|
|
558
605
|
</div>
|
|
559
606
|
{hasControls && (
|
|
560
|
-
<div
|
|
607
|
+
<div
|
|
608
|
+
data-slot='permission-scopes'
|
|
609
|
+
className={cn(
|
|
610
|
+
'grid min-w-0 gap-x-5 gap-y-2 pl-7 sm:grid-cols-2',
|
|
611
|
+
singleRange && 'w-full sm:w-80 sm:shrink-0 sm:grid-cols-1 sm:pl-0',
|
|
612
|
+
)}
|
|
613
|
+
>
|
|
561
614
|
{row.levels && (
|
|
562
615
|
<LevelControl
|
|
563
616
|
label={labels.levelFor(row.label)}
|
|
@@ -568,8 +621,11 @@ function PermissionRow({
|
|
|
568
621
|
/>
|
|
569
622
|
)}
|
|
570
623
|
{row.scopes?.map((scope) => (
|
|
571
|
-
<div
|
|
572
|
-
|
|
624
|
+
<div
|
|
625
|
+
key={scope.id}
|
|
626
|
+
className='grid min-w-0 grid-cols-[minmax(4rem,auto)_minmax(0,1fr)] items-center gap-2'
|
|
627
|
+
>
|
|
628
|
+
<span className='max-w-36 text-xs break-words text-muted-foreground'>
|
|
573
629
|
{scope.label}
|
|
574
630
|
</span>
|
|
575
631
|
<div className='flex min-w-0 items-center gap-1'>
|
|
@@ -594,9 +650,12 @@ function PermissionRow({
|
|
|
594
650
|
{[
|
|
595
651
|
...new Set(row.scopes?.map((scope) => scope.hint).filter(Boolean)),
|
|
596
652
|
].map((hint) => (
|
|
597
|
-
<
|
|
653
|
+
<p
|
|
654
|
+
key={hint}
|
|
655
|
+
className='text-xs leading-5 text-muted-foreground sm:col-span-full'
|
|
656
|
+
>
|
|
598
657
|
{hint}
|
|
599
|
-
</
|
|
658
|
+
</p>
|
|
600
659
|
))}
|
|
601
660
|
</div>
|
|
602
661
|
)}
|
|
@@ -615,7 +674,7 @@ export function PermissionEditor({
|
|
|
615
674
|
const words = { ...DEFAULT_LABELS, ...labels };
|
|
616
675
|
const [selected, setSelected] = useState(sections[0]?.id ?? '');
|
|
617
676
|
const [query, setQuery] = useState('');
|
|
618
|
-
const [
|
|
677
|
+
const [status, setStatus] = useState('all');
|
|
619
678
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(
|
|
620
679
|
() => new Set(),
|
|
621
680
|
);
|
|
@@ -628,7 +687,7 @@ export function PermissionEditor({
|
|
|
628
687
|
? selected
|
|
629
688
|
: available[0]?.id;
|
|
630
689
|
const search = query.trim().toLocaleLowerCase();
|
|
631
|
-
const filtering = Boolean(search) ||
|
|
690
|
+
const filtering = Boolean(search) || status !== 'all';
|
|
632
691
|
const matches = (values: readonly (string | undefined)[]): boolean =>
|
|
633
692
|
!search ||
|
|
634
693
|
values.some((value) => value?.toLocaleLowerCase().includes(search));
|
|
@@ -643,7 +702,7 @@ export function PermissionEditor({
|
|
|
643
702
|
...group,
|
|
644
703
|
rows: group.rows.filter(
|
|
645
704
|
(row) =>
|
|
646
|
-
(
|
|
705
|
+
(status === 'all' || row.enabled === (status === 'granted')) &&
|
|
647
706
|
matches([
|
|
648
707
|
section.title,
|
|
649
708
|
section.description,
|
|
@@ -652,6 +711,7 @@ export function PermissionEditor({
|
|
|
652
711
|
group.actionHint,
|
|
653
712
|
row.label,
|
|
654
713
|
row.hint,
|
|
714
|
+
row.path,
|
|
655
715
|
...(context.get(row.id) ?? []),
|
|
656
716
|
...(row.scopes?.map((scope) => scope.label) ?? []),
|
|
657
717
|
]),
|
|
@@ -663,38 +723,150 @@ export function PermissionEditor({
|
|
|
663
723
|
.filter((section) => section.groups.length);
|
|
664
724
|
const clearFilters = (): void => {
|
|
665
725
|
setQuery('');
|
|
666
|
-
|
|
726
|
+
setStatus('all');
|
|
667
727
|
searchRef.current?.focus();
|
|
668
728
|
};
|
|
729
|
+
const disclose = (key: string): void =>
|
|
730
|
+
setCollapsed((current) => {
|
|
731
|
+
const next = new Set(current);
|
|
732
|
+
if (next.has(key)) next.delete(key);
|
|
733
|
+
else next.add(key);
|
|
734
|
+
return next;
|
|
735
|
+
});
|
|
736
|
+
const groupKey = (section: string, group: string): string =>
|
|
737
|
+
`${id}-${section}-group-${group}`;
|
|
738
|
+
const branchKeys = (
|
|
739
|
+
nodes: readonly PermissionEditorNavigationNode[],
|
|
740
|
+
prefix: string,
|
|
741
|
+
): string[] =>
|
|
742
|
+
nodes.flatMap((node) =>
|
|
743
|
+
node.children?.length
|
|
744
|
+
? [`${prefix}-${node.id}`, ...branchKeys(node.children, prefix)]
|
|
745
|
+
: [],
|
|
746
|
+
);
|
|
747
|
+
const activeSection = available.find((section) => section.id === active);
|
|
748
|
+
const collapseKeys = activeSection
|
|
749
|
+
? activeSection.navigation
|
|
750
|
+
? branchKeys(activeSection.navigation, `${id}-${activeSection.id}`)
|
|
751
|
+
: activeSection.groups
|
|
752
|
+
.filter((group) => !group.accessOnly || group.rows.length !== 1)
|
|
753
|
+
.map((group) => groupKey(activeSection.id, group.id))
|
|
754
|
+
: [];
|
|
755
|
+
const allCollapsed =
|
|
756
|
+
collapseKeys.length > 0 && collapseKeys.every((key) => collapsed.has(key));
|
|
757
|
+
const changeEnabled = (rowId: string, enabled: boolean): void => {
|
|
758
|
+
if (status !== 'all') searchRef.current?.focus();
|
|
759
|
+
onEnabledChange(rowId, enabled);
|
|
760
|
+
};
|
|
761
|
+
const navigationCounts = (
|
|
762
|
+
section: PermissionEditorSection,
|
|
763
|
+
): ReadonlyMap<string, { enabled: number; total: number }> => {
|
|
764
|
+
const rows = new Map(
|
|
765
|
+
section.groups.flatMap((group) =>
|
|
766
|
+
group.rows.map((row) => [row.id, row] as const),
|
|
767
|
+
),
|
|
768
|
+
);
|
|
769
|
+
const counts = new Map<string, { enabled: number; total: number }>();
|
|
770
|
+
const visit = (node: PermissionEditorNavigationNode): Set<string> => {
|
|
771
|
+
const ids = new Set(
|
|
772
|
+
node.rowId && rows.has(node.rowId) ? [node.rowId] : [],
|
|
773
|
+
);
|
|
774
|
+
for (const child of node.children ?? [])
|
|
775
|
+
for (const childId of visit(child)) ids.add(childId);
|
|
776
|
+
counts.set(node.id, {
|
|
777
|
+
enabled: [...ids].filter((rowId) => rows.get(rowId)?.enabled).length,
|
|
778
|
+
total: ids.size,
|
|
779
|
+
});
|
|
780
|
+
return ids;
|
|
781
|
+
};
|
|
782
|
+
for (const node of section.navigation ?? []) visit(node);
|
|
783
|
+
return counts;
|
|
784
|
+
};
|
|
785
|
+
const rowProps = {
|
|
786
|
+
readOnly,
|
|
787
|
+
readOnlyHintId: `${id}-readonly`,
|
|
788
|
+
labels: words,
|
|
789
|
+
onEnabledChange: changeEnabled,
|
|
790
|
+
onLevelChange,
|
|
791
|
+
};
|
|
669
792
|
return (
|
|
670
|
-
<
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
793
|
+
<Tabs
|
|
794
|
+
value={active ?? ''}
|
|
795
|
+
onValueChange={(value) => {
|
|
796
|
+
setSelected(String(value));
|
|
797
|
+
setQuery('');
|
|
798
|
+
setStatus('all');
|
|
799
|
+
}}
|
|
800
|
+
className='min-w-0 gap-3'
|
|
801
|
+
>
|
|
802
|
+
{available.length > 0 && (
|
|
803
|
+
<div className='overflow-x-auto pb-1'>
|
|
804
|
+
<TabsList variant='line' aria-label={words.navigation}>
|
|
805
|
+
{available.map((section) => (
|
|
806
|
+
<TabsTrigger key={section.id} value={section.id}>
|
|
807
|
+
{section.title}
|
|
808
|
+
</TabsTrigger>
|
|
809
|
+
))}
|
|
810
|
+
</TabsList>
|
|
811
|
+
</div>
|
|
812
|
+
)}
|
|
813
|
+
<div className='flex min-w-0 flex-wrap items-center gap-3 border-b pb-3'>
|
|
814
|
+
<InputGroup className='min-w-0 basis-full sm:basis-56 sm:flex-1'>
|
|
815
|
+
<InputGroupAddon>
|
|
816
|
+
<SearchIcon aria-hidden />
|
|
817
|
+
</InputGroupAddon>
|
|
818
|
+
<InputGroupInput
|
|
678
819
|
ref={searchRef}
|
|
679
|
-
className='pl-9'
|
|
680
820
|
value={query}
|
|
681
821
|
aria-label={words.search}
|
|
682
822
|
placeholder={words.search}
|
|
683
823
|
onChange={(event) => setQuery(event.target.value)}
|
|
684
824
|
/>
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
825
|
+
{query && (
|
|
826
|
+
<InputGroupAddon align='inline-end'>
|
|
827
|
+
<InputGroupButton
|
|
828
|
+
type='button'
|
|
829
|
+
size='icon-xs'
|
|
830
|
+
aria-label={words.clearSearch}
|
|
831
|
+
onClick={() => {
|
|
832
|
+
setQuery('');
|
|
833
|
+
searchRef.current?.focus();
|
|
834
|
+
}}
|
|
835
|
+
>
|
|
836
|
+
<XIcon aria-hidden />
|
|
837
|
+
</InputGroupButton>
|
|
838
|
+
</InputGroupAddon>
|
|
839
|
+
)}
|
|
840
|
+
</InputGroup>
|
|
841
|
+
<Tabs
|
|
842
|
+
value={status}
|
|
843
|
+
onValueChange={(value) => setStatus(String(value))}
|
|
689
844
|
>
|
|
690
|
-
<
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
845
|
+
<TabsList aria-label={words.authorizationStatus}>
|
|
846
|
+
<TabsTrigger value='all'>{words.selectAll}</TabsTrigger>
|
|
847
|
+
<TabsTrigger value='granted'>{words.granted}</TabsTrigger>
|
|
848
|
+
<TabsTrigger value='ungranted'>{words.ungranted}</TabsTrigger>
|
|
849
|
+
</TabsList>
|
|
850
|
+
</Tabs>
|
|
851
|
+
<Button
|
|
852
|
+
type='button'
|
|
853
|
+
variant='outline'
|
|
854
|
+
size='sm'
|
|
855
|
+
disabled={filtering || !collapseKeys.length}
|
|
856
|
+
onClick={() =>
|
|
857
|
+
setCollapsed((current) => {
|
|
858
|
+
const next = new Set(current);
|
|
859
|
+
for (const key of collapseKeys) {
|
|
860
|
+
if (allCollapsed) next.delete(key);
|
|
861
|
+
else next.add(key);
|
|
862
|
+
}
|
|
863
|
+
return next;
|
|
864
|
+
})
|
|
865
|
+
}
|
|
866
|
+
>
|
|
867
|
+
{allCollapsed ? words.expandAll : words.collapseAll}
|
|
868
|
+
</Button>
|
|
869
|
+
{filtering && visible.length > 0 && (
|
|
698
870
|
<Button
|
|
699
871
|
type='button'
|
|
700
872
|
variant='ghost'
|
|
@@ -705,167 +877,176 @@ export function PermissionEditor({
|
|
|
705
877
|
</Button>
|
|
706
878
|
)}
|
|
707
879
|
</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'>
|
|
880
|
+
<TabsContent value={active ?? ''} className='min-w-0'>
|
|
881
|
+
<div className='min-w-0 md:max-h-[min(65dvh,48rem)] md:overflow-y-auto md:pr-2'>
|
|
738
882
|
{readOnly && (
|
|
739
883
|
<p
|
|
740
884
|
id={`${id}-readonly`}
|
|
741
|
-
className='
|
|
885
|
+
className='mb-3 text-xs text-muted-foreground'
|
|
742
886
|
>
|
|
743
887
|
{words.readOnly}
|
|
744
888
|
</p>
|
|
745
889
|
)}
|
|
746
890
|
{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))]',
|
|
891
|
+
visible.map((section) => {
|
|
892
|
+
const original = available.find(
|
|
893
|
+
(item) => item.id === section.id,
|
|
894
|
+
)!;
|
|
895
|
+
const allRows = original.groups.flatMap((group) => group.rows);
|
|
896
|
+
return (
|
|
897
|
+
<section
|
|
898
|
+
key={section.id}
|
|
899
|
+
aria-label={section.title}
|
|
900
|
+
className='grid min-w-0 gap-3 py-1'
|
|
901
|
+
>
|
|
902
|
+
<div className='flex flex-wrap items-baseline justify-between gap-2'>
|
|
903
|
+
{filtering && (
|
|
904
|
+
<h2 className='text-sm font-normal'>{section.title}</h2>
|
|
792
905
|
)}
|
|
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
|
-
|
|
906
|
+
{section.description && (
|
|
907
|
+
<p className='text-xs text-muted-foreground'>
|
|
908
|
+
{section.description}
|
|
909
|
+
</p>
|
|
910
|
+
)}
|
|
911
|
+
<span className='ml-auto text-xs text-muted-foreground'>
|
|
912
|
+
{words.summary(
|
|
913
|
+
allRows.filter((row) => row.enabled).length,
|
|
914
|
+
allRows.length,
|
|
915
|
+
)}
|
|
916
|
+
</span>
|
|
917
|
+
</div>
|
|
918
|
+
{section.navigation ? (
|
|
919
|
+
<PermissionNavigationSection
|
|
920
|
+
{...rowProps}
|
|
921
|
+
section={section}
|
|
922
|
+
prefix={`${id}-${section.id}`}
|
|
923
|
+
collapsed={collapsed}
|
|
924
|
+
filtering={filtering}
|
|
925
|
+
onDisclosure={disclose}
|
|
926
|
+
counts={navigationCounts(original)}
|
|
927
|
+
/>
|
|
928
|
+
) : (
|
|
929
|
+
<div className='divide-y rounded-lg border'>
|
|
930
|
+
{section.groups.map((group) => {
|
|
931
|
+
const key = groupKey(section.id, group.id);
|
|
932
|
+
const complete = original.groups.find(
|
|
933
|
+
(item) => item.id === group.id,
|
|
934
|
+
)!;
|
|
935
|
+
const inline = complete.rows.every(
|
|
936
|
+
(row) =>
|
|
937
|
+
!row.hint &&
|
|
938
|
+
!row.scopes?.length &&
|
|
939
|
+
!row.levels?.length,
|
|
940
|
+
);
|
|
941
|
+
const accessRow =
|
|
942
|
+
group.accessOnly && group.rows.length === 1
|
|
943
|
+
? group.rows[0]
|
|
944
|
+
: undefined;
|
|
945
|
+
const closed =
|
|
946
|
+
!accessRow && !filtering && collapsed.has(key);
|
|
947
|
+
const rows = accessRow
|
|
948
|
+
? [
|
|
949
|
+
{
|
|
950
|
+
...accessRow,
|
|
951
|
+
label: group.title ?? accessRow.label,
|
|
952
|
+
hint: group.hint ?? accessRow.hint,
|
|
953
|
+
},
|
|
954
|
+
]
|
|
955
|
+
: group.rows;
|
|
956
|
+
return (
|
|
833
957
|
<div
|
|
834
|
-
|
|
835
|
-
|
|
958
|
+
key={group.id}
|
|
959
|
+
data-slot='permission-resource'
|
|
960
|
+
className='min-w-0 p-2'
|
|
836
961
|
>
|
|
837
|
-
{
|
|
838
|
-
<div
|
|
839
|
-
<
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
962
|
+
{!accessRow && (
|
|
963
|
+
<div className='flex min-w-0 flex-wrap items-center gap-2 rounded-md px-1 py-1 hover:bg-hover'>
|
|
964
|
+
<Button
|
|
965
|
+
type='button'
|
|
966
|
+
variant='ghost'
|
|
967
|
+
size='icon-sm'
|
|
968
|
+
disabled={filtering}
|
|
969
|
+
aria-label={
|
|
970
|
+
closed
|
|
971
|
+
? words.expandGroup(
|
|
972
|
+
group.title ?? group.id,
|
|
973
|
+
)
|
|
974
|
+
: words.collapseGroup(
|
|
975
|
+
group.title ?? group.id,
|
|
976
|
+
)
|
|
977
|
+
}
|
|
978
|
+
aria-expanded={!closed}
|
|
979
|
+
aria-controls={closed ? undefined : key}
|
|
980
|
+
onClick={() => disclose(key)}
|
|
981
|
+
>
|
|
982
|
+
{closed ? (
|
|
983
|
+
<ChevronRightIcon aria-hidden />
|
|
984
|
+
) : (
|
|
985
|
+
<ChevronDownIcon aria-hidden />
|
|
986
|
+
)}
|
|
987
|
+
</Button>
|
|
988
|
+
{group.title && (
|
|
989
|
+
<h3 className='text-sm font-normal break-words'>
|
|
990
|
+
{group.title}
|
|
991
|
+
</h3>
|
|
992
|
+
)}
|
|
993
|
+
{group.hint && (
|
|
994
|
+
<span className='text-xs text-muted-foreground'>
|
|
995
|
+
{group.hint}
|
|
996
|
+
</span>
|
|
997
|
+
)}
|
|
998
|
+
<span className='ml-auto text-xs text-muted-foreground'>
|
|
999
|
+
{words.summary(
|
|
1000
|
+
complete.rows.filter((row) => row.enabled)
|
|
1001
|
+
.length,
|
|
1002
|
+
complete.rows.length,
|
|
1003
|
+
)}
|
|
1004
|
+
</span>
|
|
852
1005
|
</div>
|
|
853
|
-
)
|
|
1006
|
+
)}
|
|
1007
|
+
{!closed && (
|
|
1008
|
+
<div id={key} className='min-w-0'>
|
|
1009
|
+
{group.actionHint && (
|
|
1010
|
+
<p className='px-2 py-1 text-xs text-muted-foreground'>
|
|
1011
|
+
{group.actionHint}
|
|
1012
|
+
</p>
|
|
1013
|
+
)}
|
|
1014
|
+
<div
|
|
1015
|
+
data-slot='permission-actions'
|
|
1016
|
+
className={cn(
|
|
1017
|
+
'min-w-0',
|
|
1018
|
+
inline
|
|
1019
|
+
? 'flex flex-wrap items-start gap-x-3'
|
|
1020
|
+
: 'grid',
|
|
1021
|
+
)}
|
|
1022
|
+
>
|
|
1023
|
+
{rows.map((row) => (
|
|
1024
|
+
<PermissionRow
|
|
1025
|
+
key={row.id}
|
|
1026
|
+
{...rowProps}
|
|
1027
|
+
row={row}
|
|
1028
|
+
controlId={`${id}-${row.id}`}
|
|
1029
|
+
presentation={inline ? 'inline' : 'tile'}
|
|
1030
|
+
/>
|
|
1031
|
+
))}
|
|
1032
|
+
</div>
|
|
1033
|
+
</div>
|
|
1034
|
+
)}
|
|
854
1035
|
</div>
|
|
855
|
-
|
|
856
|
-
)
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
)
|
|
1036
|
+
);
|
|
1037
|
+
})}
|
|
1038
|
+
</div>
|
|
1039
|
+
)}
|
|
1040
|
+
{section.footer && (
|
|
1041
|
+
<div className='text-xs text-muted-foreground'>
|
|
1042
|
+
{section.footer}
|
|
1043
|
+
</div>
|
|
1044
|
+
)}
|
|
1045
|
+
</section>
|
|
1046
|
+
);
|
|
1047
|
+
})
|
|
867
1048
|
) : (
|
|
868
|
-
<Empty role='status' className='min-h-40 border p-6
|
|
1049
|
+
<Empty role='status' className='min-h-40 border p-6'>
|
|
869
1050
|
<EmptyHeader>
|
|
870
1051
|
<EmptyMedia variant='icon'>
|
|
871
1052
|
<ShieldCheckIcon aria-hidden />
|
|
@@ -877,10 +1058,15 @@ export function PermissionEditor({
|
|
|
877
1058
|
{filtering ? words.clear : words.emptyDescription}
|
|
878
1059
|
</EmptyDescription>
|
|
879
1060
|
</EmptyHeader>
|
|
1061
|
+
{filtering && (
|
|
1062
|
+
<Button type='button' variant='outline' onClick={clearFilters}>
|
|
1063
|
+
{words.clear}
|
|
1064
|
+
</Button>
|
|
1065
|
+
)}
|
|
880
1066
|
</Empty>
|
|
881
1067
|
)}
|
|
882
1068
|
</div>
|
|
883
|
-
</
|
|
884
|
-
</
|
|
1069
|
+
</TabsContent>
|
|
1070
|
+
</Tabs>
|
|
885
1071
|
);
|
|
886
1072
|
}
|