@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.
Files changed (104) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +8 -2
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/data-table/data-table.tsx +86 -32
  7. package/client/components/default-access-editor.tsx +13 -2
  8. package/client/components/feedback-form.tsx +474 -0
  9. package/client/components/lifecycle-diagram.tsx +10 -10
  10. package/client/components/list-feedback.tsx +174 -0
  11. package/client/components/permission-editor.tsx +419 -226
  12. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  13. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  14. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  15. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  16. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  17. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  18. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  19. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  20. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  21. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  22. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  23. package/client/hooks/use-list-data.ts +76 -0
  24. package/client/hooks/use-list-location.ts +23 -0
  25. package/client/hooks/use-list-tabs.ts +42 -0
  26. package/client/hooks/use-table-url-state.ts +143 -30
  27. package/client/locales/system/en-US.ts +24 -1
  28. package/client/locales/system/settings-en-US.ts +8 -10
  29. package/client/locales/system/settings-zh-CN.ts +8 -8
  30. package/client/locales/system/zh-CN.ts +22 -1
  31. package/client/pages/settings/default-access-form.tsx +44 -26
  32. package/client/pages/settings/default-access.tsx +100 -93
  33. package/client/pages/settings/hooks.tsx +4 -59
  34. package/client/pages/settings/page-permissions.ts +17 -0
  35. package/client/pages/settings/permissions-model.ts +54 -59
  36. package/client/pages/settings/role-form.tsx +98 -56
  37. package/client/pages/settings/roles.tsx +69 -95
  38. package/client/pages/settings/shared.tsx +43 -33
  39. package/client/pages/settings/user-form.tsx +51 -30
  40. package/client/pages/settings/users.tsx +105 -91
  41. package/client/routing/session-preparation.tsx +10 -3
  42. package/client/runtime.ts +0 -4
  43. package/config.example.yml +24 -0
  44. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  45. package/package.json +24 -21
  46. package/server/app.ts +0 -2
  47. package/server/config/auth.ts +2 -0
  48. package/server/config/concurrency.ts +3 -0
  49. package/server/config/index.ts +3 -0
  50. package/server/config/jobs.ts +1 -13
  51. package/server/config/notification.ts +1 -1
  52. package/server/config/queue.ts +1 -7
  53. package/server/plugins.ts +2 -0
  54. package/server/providers/index.ts +4 -1
  55. package/server/providers/submission.ts +61 -0
  56. package/tests/client/auth-i18n.test.tsx +9 -0
  57. package/tests/client/back-button.test.tsx +3 -3
  58. package/tests/client/data-table.test.tsx +9 -4
  59. package/tests/client/default-access.test.tsx +99 -5
  60. package/tests/client/form-feedback.test.tsx +589 -0
  61. package/tests/client/list-feedback.test.tsx +301 -0
  62. package/tests/client/list-recovery.test.tsx +373 -0
  63. package/tests/client/list-tabs.test.tsx +121 -0
  64. package/tests/client/manual-list-retry.test.tsx +190 -0
  65. package/tests/client/page-permission-navigation.test.ts +8 -0
  66. package/tests/client/permission-editor.test.tsx +191 -21
  67. package/tests/client/permission-filter-binding.test.tsx +1 -1
  68. package/tests/client/permission-workspace.test.ts +13 -0
  69. package/tests/client/role-return.test.tsx +2 -1
  70. package/tests/client/session-preparation.test.tsx +24 -1
  71. package/tests/client/settings-continuity.test.tsx +85 -5
  72. package/tests/client/settings-feedback.test.tsx +32 -15
  73. package/tests/client/settings-permissions.test.ts +35 -0
  74. package/tests/client/settings.test.tsx +2 -1
  75. package/tests/client/table-url-state.test.tsx +117 -3
  76. package/tests/client/user-drawer.test.tsx +2 -1
  77. package/tests/client/user-permissions.test.tsx +30 -5
  78. package/tests/database/mutation-receipts.test.ts +45 -0
  79. package/tests/fixtures/form-feedback/README.md +33 -0
  80. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  81. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  82. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  83. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  84. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  85. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  86. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  87. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  88. package/tests/fixtures/permission-alignment.html +16 -0
  89. package/tests/fixtures/permission-alignment.tsx +124 -0
  90. package/tests/fixtures/submission/README.md +35 -0
  91. package/tests/fixtures/submission/browser.test.ts +478 -0
  92. package/tests/fixtures/submission/migration.ts +61 -0
  93. package/tests/fixtures/submission/page.tsx +138 -0
  94. package/tests/fixtures/submission/playwright.config.ts +11 -0
  95. package/tests/fixtures/submission/routes.ts +208 -0
  96. package/tests/helpers/settings-client.tsx +31 -0
  97. package/tests/playwright/permission-alignment.test.ts +109 -0
  98. package/tests/server/auth-email-config.test.ts +79 -0
  99. package/tests/server/concurrency.test.ts +61 -0
  100. package/tests/server/config.test.ts +34 -14
  101. package/tests/server/member-role.test.ts +7 -1
  102. package/tests/server/submission-settings.test.ts +241 -0
  103. package/client/route-overrides.ts +0 -9
  104. 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 { Input } from '#components/ui/input';
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 { LongText, LongTextPreview } from '#components/long-text';
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 = [...parents, node.label, node.hint ?? ''];
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-medium'
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='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'
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 items-start gap-2 transition-colors',
518
- presentation === 'navigation'
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-2'>
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
- <label
537
- htmlFor={controlId}
538
- className='cursor-pointer text-sm font-medium leading-5 break-words'
539
- >
540
- {row.label}
541
- </label>
542
- {row.hint && (
543
- <div id={`${controlId}-hint`}>
544
- <LongTextPreview label={row.label} className='text-xs'>
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
- </LongTextPreview>
547
- </div>
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 data-slot='permission-scopes' className='grid min-w-0 gap-2 pl-6'>
561
- {row.levels && (
562
- <LevelControl
563
- label={labels.levelFor(row.label)}
564
- levels={row.levels}
565
- value={row.level ?? ''}
566
- disabled={readOnly}
567
- onChange={(level) => onLevelChange(row.id, level)}
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 key={scope.id} className='grid min-w-0 gap-1'>
572
- <span className='text-xs text-muted-foreground'>
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='flex min-w-0 items-center gap-1'>
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
- {scope.configuration}
584
- {scope.hint && (
585
- <ScopeHint
586
- label={labels.scopeHintFor(`${row.label} · ${scope.label}`)}
587
- >
588
- {scope.hint}
589
- </ScopeHint>
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
- <LongTextPreview key={hint} label={row.label} className='text-xs'>
660
+ <p
661
+ key={hint}
662
+ className='col-span-full text-xs leading-5 text-muted-foreground'
663
+ >
598
664
  {hint}
599
- </LongTextPreview>
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 [enabledOnly, setEnabledOnly] = useState(false);
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) || enabledOnly;
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
- (!enabledOnly || row.enabled) &&
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
- setEnabledOnly(false);
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
- <div className='@container flex min-w-0 flex-col overflow-hidden rounded-xl border bg-card'>
671
- <div className='flex flex-wrap items-center gap-3 border-b p-3'>
672
- <div className='relative min-w-48 flex-1'>
673
- <SearchIcon
674
- aria-hidden
675
- className='pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground'
676
- />
677
- <Input
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
- </div>
686
- <label
687
- htmlFor={`${id}-enabled`}
688
- className='flex shrink-0 items-center gap-2 text-sm whitespace-nowrap'
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
- <Checkbox
691
- id={`${id}-enabled`}
692
- checked={enabledOnly}
693
- onCheckedChange={setEnabledOnly}
694
- />
695
- {words.enabledOnly}
696
- </label>
697
- {filtering && (
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
- <div className='flex max-h-128 min-w-0 flex-col overflow-auto @2xl:flex-row'>
709
- {available.length > 0 && (
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='px-4 pt-3 text-xs text-muted-foreground'
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
- <section
749
- key={section.id}
750
- aria-label={section.title}
751
- className='grid min-w-0 gap-3 p-3'
752
- >
753
- {filtering && (
754
- <h2 className='text-base font-medium'>{section.title}</h2>
755
- )}
756
- {section.description && (
757
- <LongText className='text-xs text-muted-foreground'>
758
- {section.description}
759
- </LongText>
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
- {section.groups.map((group) => {
795
- const accessRow =
796
- group.accessOnly && group.rows.length === 1
797
- ? group.rows[0]
798
- : undefined;
799
- const rows = accessRow
800
- ? [
801
- {
802
- ...accessRow,
803
- label: group.title ?? accessRow.label,
804
- hint: group.hint ?? accessRow.hint,
805
- },
806
- ]
807
- : group.rows;
808
- return (
809
- <div
810
- key={group.id}
811
- data-slot='permission-resource'
812
- className={cn(
813
- 'grid min-w-0 gap-2',
814
- !accessRow && 'rounded-lg border p-3',
815
- )}
816
- >
817
- {!accessRow && group.title && (
818
- <h3 className='text-base font-medium'>
819
- {group.title}
820
- </h3>
821
- )}
822
- {!accessRow && group.hint && (
823
- <LongText className='text-xs text-muted-foreground'>
824
- {group.hint}
825
- </LongText>
826
- )}
827
- {!accessRow && group.actionHint && (
828
- <LongText className='text-xs text-muted-foreground'>
829
- {group.actionHint}
830
- </LongText>
831
- )}
832
- {/* The 18rem minimum keeps scope controls usable; enabling an action never changes its column span. */}
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
- data-slot='permission-actions'
835
- className='grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,18rem),1fr))] items-start gap-2'
965
+ key={group.id}
966
+ data-slot='permission-resource'
967
+ className='min-w-0 p-2'
836
968
  >
837
- {rows.map((row) => (
838
- <div key={row.id} className='min-w-0'>
839
- <PermissionRow
840
- controlId={`${id}-${row.id}`}
841
- row={row}
842
- readOnly={readOnly}
843
- readOnlyHintId={`${id}-readonly`}
844
- labels={words}
845
- onEnabledChange={(rowId, on) => {
846
- if (enabledOnly && !on)
847
- searchRef.current?.focus();
848
- onEnabledChange(rowId, on);
849
- }}
850
- onLevelChange={onLevelChange}
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
- </div>
856
- );
857
- })}
858
- </div>
859
- )}
860
- {section.footer && (
861
- <div className='text-xs text-muted-foreground'>
862
- {section.footer}
863
- </div>
864
- )}
865
- </section>
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 m-4'>
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
- </div>
884
- </div>
1076
+ </TabsContent>
1077
+ </Tabs>
885
1078
  );
886
1079
  }