@nocobase/app-template-default 1.0.0-beta.83 → 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.
Files changed (77) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +7 -1
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/default-access-editor.tsx +13 -2
  7. package/client/components/feedback-form.tsx +474 -0
  8. package/client/components/lifecycle-diagram.tsx +10 -10
  9. package/client/components/permission-editor.tsx +396 -210
  10. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  11. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  12. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  13. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  14. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  15. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  16. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  17. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  18. package/client/hooks/use-list-data.ts +77 -0
  19. package/client/hooks/use-list-location.ts +23 -0
  20. package/client/hooks/use-list-tabs.ts +42 -0
  21. package/client/hooks/use-table-url-state.ts +143 -30
  22. package/client/locales/system/en-US.ts +10 -0
  23. package/client/locales/system/settings-en-US.ts +8 -0
  24. package/client/locales/system/settings-zh-CN.ts +8 -0
  25. package/client/locales/system/zh-CN.ts +8 -0
  26. package/client/pages/settings/default-access-form.tsx +44 -26
  27. package/client/pages/settings/default-access.tsx +56 -27
  28. package/client/pages/settings/hooks.tsx +4 -59
  29. package/client/pages/settings/page-permissions.ts +17 -0
  30. package/client/pages/settings/permissions-model.ts +54 -58
  31. package/client/pages/settings/role-form.tsx +98 -56
  32. package/client/pages/settings/roles.tsx +11 -3
  33. package/client/pages/settings/shared.tsx +43 -33
  34. package/client/pages/settings/user-form.tsx +51 -30
  35. package/client/pages/settings/users.tsx +39 -27
  36. package/client/routing/session-preparation.tsx +10 -3
  37. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  38. package/package.json +13 -12
  39. package/server/providers/index.ts +4 -1
  40. package/server/providers/submission.ts +61 -0
  41. package/tests/client/auth-i18n.test.tsx +9 -0
  42. package/tests/client/back-button.test.tsx +3 -3
  43. package/tests/client/default-access.test.tsx +60 -4
  44. package/tests/client/form-feedback.test.tsx +589 -0
  45. package/tests/client/list-recovery.test.tsx +373 -0
  46. package/tests/client/list-tabs.test.tsx +121 -0
  47. package/tests/client/page-permission-navigation.test.ts +8 -0
  48. package/tests/client/permission-editor.test.tsx +191 -21
  49. package/tests/client/permission-filter-binding.test.tsx +1 -1
  50. package/tests/client/role-return.test.tsx +2 -1
  51. package/tests/client/session-preparation.test.tsx +24 -1
  52. package/tests/client/settings-continuity.test.tsx +5 -4
  53. package/tests/client/settings-feedback.test.tsx +25 -0
  54. package/tests/client/settings-permissions.test.ts +35 -0
  55. package/tests/client/settings.test.tsx +2 -1
  56. package/tests/client/table-url-state.test.tsx +117 -3
  57. package/tests/client/user-drawer.test.tsx +2 -1
  58. package/tests/client/user-permissions.test.tsx +30 -5
  59. package/tests/database/mutation-receipts.test.ts +45 -0
  60. package/tests/fixtures/form-feedback/README.md +33 -0
  61. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  62. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  63. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  64. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  65. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  66. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  67. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  68. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  69. package/tests/fixtures/submission/README.md +35 -0
  70. package/tests/fixtures/submission/browser.test.ts +478 -0
  71. package/tests/fixtures/submission/migration.ts +61 -0
  72. package/tests/fixtures/submission/page.tsx +138 -0
  73. package/tests/fixtures/submission/playwright.config.ts +11 -0
  74. package/tests/fixtures/submission/routes.ts +208 -0
  75. package/tests/helpers/settings-client.tsx +31 -0
  76. package/tests/server/member-role.test.ts +7 -1
  77. 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 { 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,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 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
- ),
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 className='flex min-w-0 items-start gap-2'>
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
- <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'>
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
- </LongTextPreview>
547
- </div>
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 data-slot='permission-scopes' className='grid min-w-0 gap-2 pl-6'>
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 key={scope.id} className='grid min-w-0 gap-1'>
572
- <span className='text-xs text-muted-foreground'>
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
- <LongTextPreview key={hint} label={row.label} className='text-xs'>
653
+ <p
654
+ key={hint}
655
+ className='text-xs leading-5 text-muted-foreground sm:col-span-full'
656
+ >
598
657
  {hint}
599
- </LongTextPreview>
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 [enabledOnly, setEnabledOnly] = useState(false);
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) || enabledOnly;
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
- (!enabledOnly || row.enabled) &&
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
- setEnabledOnly(false);
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
- <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
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
- </div>
686
- <label
687
- htmlFor={`${id}-enabled`}
688
- className='flex shrink-0 items-center gap-2 text-sm whitespace-nowrap'
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
- <Checkbox
691
- id={`${id}-enabled`}
692
- checked={enabledOnly}
693
- onCheckedChange={setEnabledOnly}
694
- />
695
- {words.enabledOnly}
696
- </label>
697
- {filtering && (
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
- <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'>
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='px-4 pt-3 text-xs text-muted-foreground'
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
- <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))]',
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
- {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. */}
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
- data-slot='permission-actions'
835
- className='grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,18rem),1fr))] items-start gap-2'
958
+ key={group.id}
959
+ data-slot='permission-resource'
960
+ className='min-w-0 p-2'
836
961
  >
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
- />
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
- </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
- ))
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 m-4'>
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
- </div>
884
- </div>
1069
+ </TabsContent>
1070
+ </Tabs>
885
1071
  );
886
1072
  }