@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.77

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 (61) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/README.MD +2 -2
  4. package/client/components/data-table/data-table.tsx +2 -0
  5. package/client/components/data-table/pagination.tsx +6 -6
  6. package/client/components/default-access-editor.tsx +79 -52
  7. package/client/components/flow-diagram.tsx +645 -83
  8. package/client/components/flow-layout.ts +117 -7
  9. package/client/components/lifecycle-diagram.tsx +175 -37
  10. package/client/components/long-text.tsx +200 -0
  11. package/client/components/permission-editor.tsx +534 -341
  12. package/client/components/record-link.tsx +20 -0
  13. package/client/components/required-role-setup.tsx +93 -0
  14. package/client/components/role-list.tsx +21 -5
  15. package/client/components/setup-readiness-state.ts +135 -0
  16. package/client/components/setup-readiness.tsx +91 -0
  17. package/client/components/ui/combobox.tsx +300 -0
  18. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  19. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  20. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  21. package/client/hooks/use-table-url-state.ts +93 -0
  22. package/client/lib/required-role-setup.ts +98 -0
  23. package/client/lib/return-to.ts +19 -0
  24. package/client/locales/system/en-US.ts +10 -0
  25. package/client/locales/system/settings-en-US.ts +51 -4
  26. package/client/locales/system/settings-zh-CN.ts +46 -5
  27. package/client/locales/system/zh-CN.ts +10 -0
  28. package/client/pages/settings/default-access-form.tsx +14 -2
  29. package/client/pages/settings/default-access-model.ts +6 -2
  30. package/client/pages/settings/default-access.tsx +40 -8
  31. package/client/pages/settings/page-permissions.ts +166 -1
  32. package/client/pages/settings/permission-filters.tsx +9 -2
  33. package/client/pages/settings/permission-workspace.ts +330 -0
  34. package/client/pages/settings/permissions-model.ts +62 -53
  35. package/client/pages/settings/role-form.tsx +31 -95
  36. package/client/pages/settings/roles.tsx +44 -6
  37. package/client/pages/settings/shared.tsx +15 -13
  38. package/client/pages/settings/user-detail.tsx +15 -259
  39. package/client/pages/settings/user-form.tsx +299 -147
  40. package/client/pages/settings/users.tsx +211 -123
  41. package/config.example.yml +6 -5
  42. package/package.json +17 -17
  43. package/server/config/app.ts +1 -1
  44. package/server/config/session.ts +7 -5
  45. package/tests/components/default-access-editor.test.tsx +130 -0
  46. package/tests/components/default-access.test.tsx +141 -2
  47. package/tests/components/permission-editor.test.tsx +613 -572
  48. package/tests/components/permission-filter-binding.test.tsx +20 -0
  49. package/tests/components/settings-continuity.test.tsx +426 -0
  50. package/tests/components/settings-feedback.test.tsx +304 -0
  51. package/tests/components/settings.test.tsx +34 -9
  52. package/tests/components/setup-readiness.test.tsx +370 -0
  53. package/tests/components/table-url-state.test.tsx +95 -0
  54. package/tests/components/user-drawer.test.tsx +526 -0
  55. package/tests/components/user-permissions.test.tsx +47 -13
  56. package/tests/logic/config.test.ts +3 -1
  57. package/tests/logic/page-permission-navigation.test.ts +394 -0
  58. package/tests/logic/permission-workspace.test.ts +570 -0
  59. package/tests/logic/return-to.test.ts +19 -0
  60. package/tests/logic/session-isolation.test.ts +250 -0
  61. package/tsconfig.json +1 -0
@@ -1,10 +1,15 @@
1
1
  /** A controlled permission workspace. Filtering never changes the caller's draft. */
2
- import { InfoIcon, SearchIcon, ShieldCheckIcon } from 'lucide-react';
2
+ import {
3
+ ChevronDownIcon,
4
+ ChevronRightIcon,
5
+ InfoIcon,
6
+ SearchIcon,
7
+ ShieldCheckIcon,
8
+ } from 'lucide-react';
3
9
  import {
4
10
  useId,
5
11
  useRef,
6
12
  useState,
7
- type CSSProperties,
8
13
  type ReactElement,
9
14
  type ReactNode,
10
15
  } from 'react';
@@ -20,7 +25,14 @@ import {
20
25
  SelectValue,
21
26
  } from '#components/ui/select';
22
27
  import { Checkbox } from '#components/ui/checkbox';
23
- import { Switch } from '#components/ui/switch';
28
+ import { LongText, LongTextPreview } from '#components/long-text';
29
+ import {
30
+ Empty,
31
+ EmptyDescription,
32
+ EmptyHeader,
33
+ EmptyMedia,
34
+ EmptyTitle,
35
+ } from '#components/ui/empty';
24
36
  import {
25
37
  Tooltip,
26
38
  TooltipContent,
@@ -48,13 +60,13 @@ export interface PermissionEditorRow {
48
60
  readonly id: string;
49
61
  readonly label: string;
50
62
  readonly hint?: string | undefined;
51
- /** Stable semantic action key for aligning actions across resources. */
63
+ /** Optional action metadata; presentation preserves the supplied row order. */
52
64
  readonly actionKey?: string | undefined;
53
65
  /** Scope-only controls bound to this action; they are not extra permissions. */
54
66
  readonly scopes?: readonly PermissionEditorScope[];
55
67
  readonly enabled: boolean;
56
68
  readonly toggleable?: boolean;
57
- /** The levels a switched-on row can reach. None leaves the right column empty. */
69
+ /** The levels an enabled row can reach. None leaves the right column empty. */
58
70
  readonly levels?: readonly PermissionEditorLevel[] | undefined;
59
71
  /** The current level, one of `levels`' values. */
60
72
  readonly level?: string | undefined;
@@ -77,10 +89,20 @@ export interface PermissionEditorSection {
77
89
  readonly title: string;
78
90
  readonly description?: string | undefined;
79
91
  readonly groups: readonly PermissionEditorGroup[];
92
+ /** Optional application navigation; row references never imply inherited or bulk grants. */
93
+ readonly navigation?: readonly PermissionEditorNavigationNode[];
80
94
  /** A note under the section's rows. */
81
95
  readonly footer?: ReactNode;
82
96
  }
83
97
 
98
+ export interface PermissionEditorNavigationNode {
99
+ readonly id: string;
100
+ readonly label: string;
101
+ readonly hint?: string | undefined;
102
+ readonly rowId?: string | undefined;
103
+ readonly children?: readonly PermissionEditorNavigationNode[];
104
+ }
105
+
84
106
  export interface PermissionEditorLabels {
85
107
  /** The accessible name of a row's level select. */
86
108
  readonly levelFor: (rowLabel: string) => string;
@@ -88,8 +110,13 @@ export interface PermissionEditorLabels {
88
110
  readonly enabledOnly: string;
89
111
  readonly clear: string;
90
112
  readonly empty: string;
113
+ readonly emptyDescription?: string;
114
+ readonly readOnly?: string;
115
+ readonly managedPermission?: string;
91
116
  readonly noResults: string;
92
117
  readonly navigation: string;
118
+ readonly expandGroup?: (name: string) => string;
119
+ readonly collapseGroup?: (name: string) => string;
93
120
  readonly selectAll?: string;
94
121
  readonly dataRanges?: (count: number) => string;
95
122
  readonly scopeExplanation?: string;
@@ -116,8 +143,14 @@ const DEFAULT_LABELS: Required<PermissionEditorLabels> = {
116
143
  enabledOnly: 'Enabled only',
117
144
  clear: 'Clear filters',
118
145
  empty: 'No permissions to configure',
146
+ emptyDescription:
147
+ 'Ask the application developer to expose the permissions this role can configure.',
148
+ readOnly: 'These permissions are read-only.',
149
+ managedPermission: 'This permission is managed by the application.',
119
150
  noResults: 'No matching permissions',
120
151
  navigation: 'Permission categories',
152
+ expandGroup: (name) => `Expand ${name}`,
153
+ collapseGroup: (name) => `Collapse ${name}`,
121
154
  selectAll: 'All',
122
155
  dataRanges: (count) => `Data ranges (${count})`,
123
156
  scopeExplanation:
@@ -127,6 +160,241 @@ const DEFAULT_LABELS: Required<PermissionEditorLabels> = {
127
160
  summary: (enabled, total) => `${enabled} of ${total} enabled`,
128
161
  };
129
162
 
163
+ function navigationSearchContext(
164
+ nodes: readonly PermissionEditorNavigationNode[],
165
+ ): ReadonlyMap<string, readonly string[]> {
166
+ const result = new Map<string, readonly string[]>();
167
+ const visit = (
168
+ items: readonly PermissionEditorNavigationNode[],
169
+ parents: readonly string[],
170
+ ): void => {
171
+ for (const node of items) {
172
+ const context = [...parents, node.label, node.hint ?? ''];
173
+ if (node.rowId)
174
+ result.set(node.rowId, [...(result.get(node.rowId) ?? []), ...context]);
175
+ visit(node.children ?? [], context);
176
+ }
177
+ };
178
+ visit(nodes, []);
179
+ return result;
180
+ }
181
+
182
+ function visibleNavigation(
183
+ nodes: readonly PermissionEditorNavigationNode[],
184
+ rows: ReadonlyMap<string, PermissionEditorRow>,
185
+ ): readonly PermissionEditorNavigationNode[] {
186
+ const used = new Set<string>();
187
+ const visit = (
188
+ items: readonly PermissionEditorNavigationNode[],
189
+ ): PermissionEditorNavigationNode[] =>
190
+ items.flatMap((node) => {
191
+ const rowId =
192
+ node.rowId && rows.has(node.rowId) && !used.has(node.rowId)
193
+ ? node.rowId
194
+ : undefined;
195
+ if (rowId) used.add(rowId);
196
+ const children = visit(node.children ?? []);
197
+ return rowId || children.length ? [{ ...node, rowId, children }] : [];
198
+ });
199
+ const visible = visit(nodes);
200
+ // An incomplete presentation tree must not silently remove caller-supplied controls.
201
+ return [
202
+ ...visible,
203
+ ...[...rows.values()]
204
+ .filter((row) => !used.has(row.id))
205
+ .map((row) => ({ id: `row:${row.id}`, label: row.label, rowId: row.id })),
206
+ ];
207
+ }
208
+
209
+ function NavigationDisclosure({
210
+ label,
211
+ expanded,
212
+ childrenId,
213
+ onClick,
214
+ }: {
215
+ readonly label: string;
216
+ readonly expanded: boolean;
217
+ readonly childrenId: string;
218
+ readonly onClick: () => void;
219
+ }): ReactElement {
220
+ const triggerId = useId();
221
+ return (
222
+ <TooltipProvider>
223
+ <Tooltip triggerId={triggerId}>
224
+ <TooltipTrigger
225
+ id={triggerId}
226
+ render={
227
+ <Button
228
+ type='button'
229
+ variant='ghost'
230
+ size='icon'
231
+ className='mt-1 size-6 shrink-0'
232
+ aria-label={label}
233
+ aria-expanded={expanded}
234
+ aria-controls={expanded ? childrenId : undefined}
235
+ onClick={onClick}
236
+ />
237
+ }
238
+ >
239
+ {expanded ? (
240
+ <ChevronDownIcon aria-hidden />
241
+ ) : (
242
+ <ChevronRightIcon aria-hidden />
243
+ )}
244
+ </TooltipTrigger>
245
+ <TooltipContent>{label}</TooltipContent>
246
+ </Tooltip>
247
+ </TooltipProvider>
248
+ );
249
+ }
250
+
251
+ function PermissionNavigation({
252
+ nodes,
253
+ rows,
254
+ prefix,
255
+ collapsed,
256
+ filtering,
257
+ onDisclosure,
258
+ nested = false,
259
+ ...rowProps
260
+ }: {
261
+ readonly nodes: readonly PermissionEditorNavigationNode[];
262
+ readonly rows: ReadonlyMap<string, PermissionEditorRow>;
263
+ readonly prefix: string;
264
+ readonly collapsed: ReadonlySet<string>;
265
+ readonly filtering: boolean;
266
+ readonly onDisclosure: (key: string) => void;
267
+ readonly nested?: boolean;
268
+ } & Omit<
269
+ Parameters<typeof PermissionRow>[0],
270
+ 'row' | 'controlId' | 'presentation'
271
+ >): ReactElement {
272
+ const hasBranches = nested || nodes.some((node) => node.children?.length);
273
+ return (
274
+ <ul className='grid min-w-0 gap-0.5'>
275
+ {nodes.map((node) => {
276
+ const row = node.rowId ? rows.get(node.rowId) : undefined;
277
+ const children = node.children ?? [];
278
+ const key = `${prefix}-${node.id}`;
279
+ const expanded = filtering || !collapsed.has(key);
280
+ const childrenId = `${prefix}-branch-${encodeURIComponent(node.id)}`;
281
+ return (
282
+ <li
283
+ key={node.id}
284
+ className={cn('min-w-0', !nested && children.length > 0 && 'mt-2')}
285
+ >
286
+ <div className='flex min-w-0 items-start gap-1'>
287
+ {hasBranches &&
288
+ (children.length ? (
289
+ filtering ? (
290
+ <span className='mt-1 flex size-6 shrink-0 items-center justify-center text-muted-foreground'>
291
+ <ChevronDownIcon aria-hidden className='size-4' />
292
+ </span>
293
+ ) : (
294
+ <NavigationDisclosure
295
+ label={
296
+ expanded
297
+ ? rowProps.labels.collapseGroup(node.label)
298
+ : rowProps.labels.expandGroup(node.label)
299
+ }
300
+ expanded={expanded}
301
+ childrenId={childrenId}
302
+ onClick={() => onDisclosure(key)}
303
+ />
304
+ )
305
+ ) : (
306
+ <span aria-hidden className='mt-1 size-6 shrink-0' />
307
+ ))}
308
+ {row ? (
309
+ <div className='min-w-0 flex-1'>
310
+ <PermissionRow
311
+ {...rowProps}
312
+ controlId={`${prefix}-${row.id}`}
313
+ row={{
314
+ ...row,
315
+ label: node.label,
316
+ hint: node.hint ?? row.hint,
317
+ }}
318
+ presentation='navigation'
319
+ />
320
+ </div>
321
+ ) : (
322
+ <div className='grid min-w-0 flex-1 gap-1 px-2 py-1.5 text-muted-foreground'>
323
+ <LongTextPreview
324
+ label={node.label}
325
+ className='text-xs leading-5 font-medium'
326
+ >
327
+ {node.label}
328
+ </LongTextPreview>
329
+ {node.hint && (
330
+ <LongTextPreview label={node.label} className='text-xs'>
331
+ {node.hint}
332
+ </LongTextPreview>
333
+ )}
334
+ </div>
335
+ )}
336
+ </div>
337
+ {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
+ >
342
+ <PermissionNavigation
343
+ {...rowProps}
344
+ nodes={children}
345
+ rows={rows}
346
+ prefix={prefix}
347
+ collapsed={collapsed}
348
+ filtering={filtering}
349
+ onDisclosure={onDisclosure}
350
+ nested
351
+ />
352
+ </div>
353
+ )}
354
+ </li>
355
+ );
356
+ })}
357
+ </ul>
358
+ );
359
+ }
360
+
361
+ function PermissionNavigationSection({
362
+ section,
363
+ ...props
364
+ }: {
365
+ readonly section: PermissionEditorSection;
366
+ } & Omit<
367
+ Parameters<typeof PermissionNavigation>[0],
368
+ 'nodes' | 'rows'
369
+ >): ReactElement {
370
+ const rows = new Map(
371
+ section.groups.flatMap((group) =>
372
+ group.rows.map(
373
+ (row) =>
374
+ [
375
+ row.id,
376
+ group.accessOnly && group.rows.length === 1
377
+ ? {
378
+ ...row,
379
+ label: group.title ?? row.label,
380
+ hint: group.hint ?? row.hint,
381
+ }
382
+ : row,
383
+ ] as const,
384
+ ),
385
+ ),
386
+ );
387
+ return (
388
+ <div data-slot='permission-navigation' className='min-w-0 py-1'>
389
+ <PermissionNavigation
390
+ {...props}
391
+ rows={rows}
392
+ nodes={visibleNavigation(section.navigation ?? [], rows)}
393
+ />
394
+ </div>
395
+ );
396
+ }
397
+
130
398
  function ScopeHint({
131
399
  label,
132
400
  children,
@@ -216,70 +484,81 @@ function LevelControl({
216
484
  function PermissionRow({
217
485
  row,
218
486
  controlId,
219
- showScopeLabels = false,
220
487
  readOnly,
488
+ readOnlyHintId,
221
489
  labels,
222
490
  onEnabledChange,
223
491
  onLevelChange,
492
+ presentation = 'tile',
224
493
  }: {
225
494
  readonly row: PermissionEditorRow;
226
495
  readonly controlId: string;
227
- readonly showScopeLabels?: boolean;
228
496
  readonly readOnly: boolean;
229
- readonly labels: PermissionEditorLabels;
497
+ readonly readOnlyHintId: string;
498
+ readonly labels: Required<PermissionEditorLabels>;
230
499
  readonly onEnabledChange: (rowId: string, enabled: boolean) => void;
231
500
  readonly onLevelChange: (rowId: string, level: string) => void;
501
+ readonly presentation?: 'tile' | 'navigation';
232
502
  }): ReactElement {
233
- const scopeControls = row.scopes?.map((scope) => (
234
- <div key={scope.id} className='grid min-w-0 gap-1'>
235
- {(showScopeLabels || (row.scopes?.length ?? 0) > 1) && (
236
- <span className='text-xs leading-5 text-muted-foreground'>
237
- {scope.label}
238
- </span>
239
- )}
240
- <div className='flex min-h-7 min-w-0 items-center gap-1'>
241
- <LevelControl
242
- label={labels.levelFor(`${row.label} · ${scope.label}`)}
243
- levels={scope.levels}
244
- value={scope.level ?? ''}
245
- disabled={readOnly}
246
- onChange={(level) => onLevelChange(scope.id, level)}
247
- />
248
- {scope.configuration}
249
- {scope.hint && (
250
- <ScopeHint
251
- label={
252
- labels.scopeHintFor?.(`${row.label} · ${scope.label}`) ??
253
- DEFAULT_LABELS.scopeHintFor(`${row.label} · ${scope.label}`)
254
- }
255
- >
256
- {scope.hint}
257
- </ScopeHint>
258
- )}
259
- </div>
260
- </div>
261
- ));
503
+ const hasControls =
504
+ row.enabled && Boolean(row.levels?.length || row.scopes?.length);
505
+ const describedBy =
506
+ [
507
+ row.hint ? `${controlId}-hint` : undefined,
508
+ readOnly ? readOnlyHintId : undefined,
509
+ row.toggleable === false ? `${controlId}-managed` : undefined,
510
+ ]
511
+ .filter(Boolean)
512
+ .join(' ') || undefined;
262
513
  return (
263
514
  <div
264
515
  data-slot='permission-row'
265
- className='@container flex min-w-0 flex-col gap-1'
516
+ 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
+ ),
524
+ )}
266
525
  >
267
- <div className='grid min-h-5 min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2 gap-y-1 @xs:grid-cols-[auto_auto_minmax(0,1fr)]'>
526
+ <div className='flex min-w-0 items-start gap-2'>
268
527
  <Checkbox
269
528
  id={controlId}
529
+ className='mt-0.5 shrink-0'
270
530
  checked={row.enabled}
271
531
  disabled={readOnly || row.toggleable === false}
272
- onCheckedChange={(on) => onEnabledChange(row.id, on)}
273
- aria-describedby={row.hint ? `${controlId}-hint` : undefined}
532
+ onCheckedChange={(on: boolean) => onEnabledChange(row.id, on)}
533
+ aria-describedby={describedBy}
274
534
  />
275
- <label
276
- htmlFor={controlId}
277
- className='min-w-0 cursor-pointer text-sm leading-5 font-normal break-words @xs:max-w-36'
278
- >
279
- {row.label}
280
- </label>
281
- {row.enabled && row.levels ? (
282
- <div className='col-start-2 min-w-0 @xs:col-start-3'>
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'>
545
+ {row.hint}
546
+ </LongTextPreview>
547
+ </div>
548
+ )}
549
+ {row.toggleable === false && (
550
+ <p
551
+ id={`${controlId}-managed`}
552
+ className='text-xs text-muted-foreground'
553
+ >
554
+ {labels.managedPermission}
555
+ </p>
556
+ )}
557
+ </div>
558
+ </div>
559
+ {hasControls && (
560
+ <div data-slot='permission-scopes' className='grid min-w-0 gap-2 pl-6'>
561
+ {row.levels && (
283
562
  <LevelControl
284
563
  label={labels.levelFor(row.label)}
285
564
  levels={row.levels}
@@ -287,48 +566,45 @@ function PermissionRow({
287
566
  disabled={readOnly}
288
567
  onChange={(level) => onLevelChange(row.id, level)}
289
568
  />
290
- </div>
291
- ) : null}
292
- {row.enabled && row.scopes && row.scopes.length > 1 ? (
293
- <details className='col-start-2 min-w-0 @xs:col-span-2'>
294
- <summary className='cursor-pointer text-xs leading-5 text-muted-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring'>
295
- {labels.dataRanges?.(row.scopes.length) ??
296
- DEFAULT_LABELS.dataRanges(row.scopes.length)}
297
- <span className='mt-1 block'>
298
- {row.scopes
299
- .map(
300
- (scope) =>
301
- `${scope.label}: ${scope.levels.find((level) => level.value === (scope.level ?? ''))?.label ?? ''}`,
302
- )
303
- .join('; ')}
569
+ )}
570
+ {row.scopes?.map((scope) => (
571
+ <div key={scope.id} className='grid min-w-0 gap-1'>
572
+ <span className='text-xs text-muted-foreground'>
573
+ {scope.label}
304
574
  </span>
305
- </summary>
306
- <div className='mt-1 grid min-w-0 gap-2'>
307
- <p className='text-xs leading-5 text-muted-foreground'>
308
- {labels.scopeExplanation ?? DEFAULT_LABELS.scopeExplanation}
309
- </p>
310
- {scopeControls}
575
+ <div className='flex min-w-0 items-center gap-1'>
576
+ <LevelControl
577
+ label={labels.levelFor(`${row.label} · ${scope.label}`)}
578
+ levels={scope.levels}
579
+ value={scope.level ?? ''}
580
+ disabled={readOnly}
581
+ onChange={(level) => onLevelChange(scope.id, level)}
582
+ />
583
+ {scope.configuration}
584
+ {scope.hint && (
585
+ <ScopeHint
586
+ label={labels.scopeHintFor(`${row.label} · ${scope.label}`)}
587
+ >
588
+ {scope.hint}
589
+ </ScopeHint>
590
+ )}
591
+ </div>
311
592
  </div>
312
- </details>
313
- ) : row.enabled && row.scopes?.length === 1 ? (
314
- <div className='col-start-2 min-w-0 @xs:col-start-3'>
315
- {scopeControls}
316
- </div>
317
- ) : null}
318
- </div>
319
- {row.hint && (
320
- <p
321
- id={`${controlId}-hint`}
322
- className='pl-6 text-xs leading-5 text-muted-foreground'
323
- >
324
- {row.hint}
325
- </p>
593
+ ))}
594
+ {[
595
+ ...new Set(row.scopes?.map((scope) => scope.hint).filter(Boolean)),
596
+ ].map((hint) => (
597
+ <LongTextPreview key={hint} label={row.label} className='text-xs'>
598
+ {hint}
599
+ </LongTextPreview>
600
+ ))}
601
+ </div>
326
602
  )}
327
603
  </div>
328
604
  );
329
605
  }
330
606
 
331
- /** A category navigator with a compact, searchable permission list. */
607
+ /** A controlled editor: the application owns its catalog, policies and save lifecycle. */
332
608
  export function PermissionEditor({
333
609
  sections,
334
610
  readOnly = false,
@@ -340,38 +616,56 @@ export function PermissionEditor({
340
616
  const [selected, setSelected] = useState(sections[0]?.id ?? '');
341
617
  const [query, setQuery] = useState('');
342
618
  const [enabledOnly, setEnabledOnly] = useState(false);
619
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(
620
+ () => new Set(),
621
+ );
343
622
  const id = useId();
344
623
  const searchRef = useRef<HTMLInputElement>(null);
345
- const active = sections.some((section) => section.id === selected)
624
+ const available = sections.filter((section) =>
625
+ section.groups.some((group) => group.rows.length > 0),
626
+ );
627
+ const active = available.some((section) => section.id === selected)
346
628
  ? selected
347
- : sections[0]?.id;
629
+ : available[0]?.id;
348
630
  const search = query.trim().toLocaleLowerCase();
349
631
  const filtering = Boolean(search) || enabledOnly;
350
- const visible = sections
632
+ const matches = (values: readonly (string | undefined)[]): boolean =>
633
+ !search ||
634
+ values.some((value) => value?.toLocaleLowerCase().includes(search));
635
+ const visible = available
351
636
  .filter((section) => filtering || section.id === active)
352
- .map((section) => ({
353
- ...section,
354
- groups: section.groups
355
- .map((group) => ({
356
- ...group,
357
- rows: group.rows.filter(
358
- (row) =>
359
- (!enabledOnly || row.enabled) &&
360
- (!search ||
361
- [
637
+ .map((section) => {
638
+ const context = navigationSearchContext(section.navigation ?? []);
639
+ return {
640
+ ...section,
641
+ groups: section.groups
642
+ .map((group) => ({
643
+ ...group,
644
+ rows: group.rows.filter(
645
+ (row) =>
646
+ (!enabledOnly || row.enabled) &&
647
+ matches([
362
648
  section.title,
649
+ section.description,
363
650
  group.title,
364
651
  group.hint,
365
652
  group.actionHint,
366
653
  row.label,
367
654
  row.hint,
655
+ ...(context.get(row.id) ?? []),
368
656
  ...(row.scopes?.map((scope) => scope.label) ?? []),
369
- ].some((text) => text?.toLocaleLowerCase().includes(search))),
370
- ),
371
- }))
372
- .filter((group) => group.rows.length),
373
- }))
657
+ ]),
658
+ ),
659
+ }))
660
+ .filter((group) => group.rows.length),
661
+ };
662
+ })
374
663
  .filter((section) => section.groups.length);
664
+ const clearFilters = (): void => {
665
+ setQuery('');
666
+ setEnabledOnly(false);
667
+ searchRef.current?.focus();
668
+ };
375
669
  return (
376
670
  <div className='@container flex min-w-0 flex-col overflow-hidden rounded-xl border bg-card'>
377
671
  <div className='flex flex-wrap items-center gap-3 border-b p-3'>
@@ -393,7 +687,7 @@ export function PermissionEditor({
393
687
  htmlFor={`${id}-enabled`}
394
688
  className='flex shrink-0 items-center gap-2 text-sm whitespace-nowrap'
395
689
  >
396
- <Switch
690
+ <Checkbox
397
691
  id={`${id}-enabled`}
398
692
  checked={enabledOnly}
399
693
  onCheckedChange={setEnabledOnly}
@@ -405,286 +699,185 @@ export function PermissionEditor({
405
699
  type='button'
406
700
  variant='ghost'
407
701
  size='sm'
408
- onClick={() => {
409
- setQuery('');
410
- setEnabledOnly(false);
411
- }}
702
+ onClick={clearFilters}
412
703
  >
413
704
  {words.clear}
414
705
  </Button>
415
706
  )}
416
707
  </div>
417
708
  <div className='flex max-h-128 min-w-0 flex-col overflow-auto @2xl:flex-row'>
418
- <nav
419
- aria-label={words.navigation}
420
- className='flex shrink-0 gap-1 overflow-x-auto border-b bg-muted/25 p-2 @2xl:sticky @2xl:top-0 @2xl:max-h-128 @2xl:w-52 @2xl:self-start @2xl:flex-col @2xl:border-r @2xl:border-b-0'
421
- >
422
- {sections.map((section) => {
423
- return (
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) => (
424
715
  <Button
425
- type='button'
426
716
  key={section.id}
717
+ type='button'
427
718
  variant='ghost'
428
719
  aria-current={section.id === active ? 'page' : undefined}
720
+ aria-controls={`${id}-content`}
429
721
  onClick={() => {
430
722
  setSelected(section.id);
431
723
  setQuery('');
432
724
  setEnabledOnly(false);
433
725
  }}
434
726
  className={cn(
435
- 'h-auto min-h-8 shrink-0 justify-between gap-2 px-2 py-1.5 text-left text-sm font-normal whitespace-nowrap @2xl:w-full @2xl:min-w-0',
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',
436
728
  section.id === active &&
437
729
  'bg-primary/10 font-medium text-primary hover:bg-primary/15',
438
730
  )}
439
731
  >
440
- <span
441
- className='min-w-0 @2xl:flex-1 @2xl:truncate'
442
- title={section.title}
443
- >
444
- {section.title}
445
- </span>
732
+ {section.title}
446
733
  </Button>
447
- );
448
- })}
449
- </nav>
450
- <div className='min-w-0 flex-1 @container'>
734
+ ))}
735
+ </nav>
736
+ )}
737
+ <div id={`${id}-content`} className='min-w-0 flex-1'>
738
+ {readOnly && (
739
+ <p
740
+ id={`${id}-readonly`}
741
+ className='px-4 pt-3 text-xs text-muted-foreground'
742
+ >
743
+ {words.readOnly}
744
+ </p>
745
+ )}
451
746
  {visible.length ? (
452
- visible.map((section) => {
453
- const original =
454
- sections.find((item) => item.id === section.id) ?? section;
455
- const showScopeLabels = original.groups.some((group) =>
456
- group.rows.some((row) => (row.scopes?.length ?? 0) > 1),
457
- );
458
- const keys = [
459
- ...new Set(
460
- original.groups
461
- .flatMap((group) => group.rows.map((row) => row.actionKey))
462
- .filter((key): key is string => Boolean(key)),
463
- ),
464
- ];
465
- const common = ['read', 'create', 'update', 'delete'];
466
- const columns = [
467
- ...common.filter((key) => keys.includes(key)),
468
- ...keys.filter((key) => !common.includes(key)),
469
- ];
470
- const hasRanges = original.groups.some((group) =>
471
- group.rows.some(
472
- (row) => row.levels?.length || row.scopes?.length,
473
- ),
474
- );
475
- // Unrelated special actions share trailing slots rather than widening every resource.
476
- const commonColumns = common.filter((key) => keys.includes(key));
477
- const extraCount = Math.max(
478
- 0,
479
- ...original.groups.map(
480
- (group) =>
481
- group.rows.filter(
482
- (row) => row.actionKey && !common.includes(row.actionKey),
483
- ).length,
484
- ),
485
- );
486
- const columnCount = hasRanges
487
- ? columns.length
488
- : commonColumns.length + extraCount;
489
- const columnFor = (
490
- row: PermissionEditorRow,
491
- rows: readonly PermissionEditorRow[],
492
- ) => {
493
- if (hasRanges) return columns.indexOf(row.actionKey ?? '');
494
- if (rows.length === 1) return 0;
495
- const standard = commonColumns.indexOf(row.actionKey ?? '');
496
- if (standard >= 0) return standard;
497
- // A resource with only special actions starts at the first column.
498
- const offset = rows.some((item) =>
499
- common.includes(item.actionKey ?? ''),
500
- )
501
- ? commonColumns.length
502
- : 0;
503
- return (
504
- offset +
505
- rows
506
- .filter((item) => !common.includes(item.actionKey ?? ''))
507
- .findIndex((item) => item.id === row.id)
508
- );
509
- };
510
- const aligned =
511
- columns.length > 0 &&
512
- columnCount <= (hasRanges ? 4 : 6) &&
513
- original.groups.every(
514
- (group) =>
515
- group.rows.every((row) => row.actionKey) &&
516
- new Set(group.rows.map((row) => row.actionKey)).size ===
517
- group.rows.length,
518
- );
519
- // Keep action columns only when each has room for its label and scope.
520
- const alignment = !hasRanges
521
- ? '@xl:grid-cols-(--permission-columns)'
522
- : [
523
- '',
524
- '@3xl:grid-cols-(--permission-columns)',
525
- '@3xl:grid-cols-(--permission-columns)',
526
- '@5xl:grid-cols-(--permission-columns)',
527
- '@7xl:grid-cols-(--permission-columns)',
528
- ][columns.length];
529
- const placement = !hasRanges
530
- ? '@xl:col-start-(--permission-column) @xl:row-start-1'
531
- : [
532
- '',
533
- '@3xl:col-start-(--permission-column) @3xl:row-start-1',
534
- '@3xl:col-start-(--permission-column) @3xl:row-start-1',
535
- '@5xl:col-start-(--permission-column) @5xl:row-start-1',
536
- '@7xl:col-start-(--permission-column) @7xl:row-start-1',
537
- ][columns.length];
538
- return (
539
- <section
540
- key={section.id}
541
- aria-label={section.title}
542
- className='flex flex-col gap-2 p-3'
543
- >
544
- {filtering && (
545
- <h2 className='text-sm font-semibold'>{section.title}</h2>
546
- )}
547
- {section.description && (
548
- <p className='text-xs leading-5 text-muted-foreground'>
549
- {section.description}
550
- </p>
551
- )}
552
- <div className='divide-y rounded-lg border'>
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))]',
792
+ )}
793
+ >
553
794
  {section.groups.map((group) => {
554
- const originalGroup = sections
555
- .find((item) => item.id === section.id)
556
- ?.groups.find((item) => item.id === group.id);
557
- const totalRows = originalGroup?.rows ?? group.rows;
558
795
  const accessRow =
559
- group.accessOnly && totalRows.length === 1
796
+ group.accessOnly && group.rows.length === 1
560
797
  ? group.rows[0]
561
798
  : undefined;
562
- if (accessRow)
563
- return (
564
- <div
565
- key={group.id}
566
- className='px-3 py-2'
567
- data-slot='permission-resource'
568
- >
569
- <PermissionRow
570
- controlId={`${id}-${accessRow.id}`}
571
- row={{
572
- ...accessRow,
573
- label: group.title ?? accessRow.label,
574
- hint: group.hint ?? accessRow.hint,
575
- }}
576
- readOnly={readOnly}
577
- labels={words}
578
- onEnabledChange={(rowId, on) => {
579
- if (enabledOnly && !on)
580
- searchRef.current?.focus();
581
- onEnabledChange(rowId, on);
582
- }}
583
- onLevelChange={onLevelChange}
584
- />
585
- </div>
586
- );
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;
587
808
  return (
588
809
  <div
589
810
  key={group.id}
811
+ data-slot='permission-resource'
590
812
  className={cn(
591
- 'grid min-w-0 gap-x-3 gap-y-2 px-3 py-2',
592
- hasRanges
593
- ? '@3xl:grid-cols-[8rem_minmax(0,1fr)]'
594
- : '@xl:grid-cols-[8rem_minmax(0,1fr)]',
813
+ 'grid min-w-0 gap-2',
814
+ !accessRow && 'rounded-lg border p-3',
595
815
  )}
596
- data-slot='permission-resource'
597
816
  >
598
- <div className='flex min-w-0 flex-col gap-1'>
599
- <h3 className='text-sm leading-5 font-medium break-words'>
600
- {group.title ?? section.title}
817
+ {!accessRow && group.title && (
818
+ <h3 className='text-base font-medium'>
819
+ {group.title}
601
820
  </h3>
602
- {group.hint && (
603
- <p className='text-xs text-muted-foreground'>
604
- {group.hint}
605
- </p>
606
- )}
607
- </div>
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. */}
608
833
  <div
609
834
  data-slot='permission-actions'
610
- className={cn(
611
- 'grid min-w-0 items-start gap-x-3 gap-y-2',
612
- hasRanges
613
- ? 'grid-cols-[repeat(auto-fill,minmax(min(100%,14rem),1fr))]'
614
- : 'grid-cols-[repeat(auto-fill,minmax(min(100%,9rem),1fr))]',
615
- aligned && alignment,
616
- )}
617
- style={
618
- {
619
- '--permission-columns': `repeat(${columnCount}, minmax(0, 1fr))`,
620
- } as CSSProperties
621
- }
835
+ className='grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,18rem),1fr))] items-start gap-2'
622
836
  >
623
- {[...group.rows]
624
- .sort(
625
- (a, b) =>
626
- columnFor(a, totalRows) -
627
- columnFor(b, totalRows),
628
- )
629
- .map((row) => (
630
- <div
631
- key={row.id}
632
- className={cn(
633
- 'min-w-0',
634
- aligned && placement,
635
- aligned &&
636
- !hasRanges &&
637
- totalRows.length === 1 &&
638
- '@xl:col-span-full',
639
- )}
640
- style={
641
- {
642
- '--permission-column':
643
- columnFor(row, totalRows) + 1,
644
- } as CSSProperties
645
- }
646
- >
647
- <PermissionRow
648
- controlId={`${id}-${row.id}`}
649
- row={row}
650
- showScopeLabels={showScopeLabels}
651
- readOnly={readOnly}
652
- labels={words}
653
- onEnabledChange={(rowId, on) => {
654
- if (enabledOnly && !on)
655
- searchRef.current?.focus();
656
- onEnabledChange(rowId, on);
657
- }}
658
- onLevelChange={onLevelChange}
659
- />
660
- </div>
661
- ))}
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
+ />
852
+ </div>
853
+ ))}
662
854
  </div>
663
- {group.actionHint && (
664
- <p className='col-span-full text-xs leading-5 text-muted-foreground'>
665
- {group.actionHint}
666
- </p>
667
- )}
668
855
  </div>
669
856
  );
670
857
  })}
671
858
  </div>
672
- {section.footer && (
673
- <div className='text-xs text-muted-foreground'>
674
- {section.footer}
675
- </div>
676
- )}
677
- </section>
678
- );
679
- })
859
+ )}
860
+ {section.footer && (
861
+ <div className='text-xs text-muted-foreground'>
862
+ {section.footer}
863
+ </div>
864
+ )}
865
+ </section>
866
+ ))
680
867
  ) : (
681
- <div
682
- role='status'
683
- className='flex min-h-48 flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground'
684
- >
685
- <ShieldCheckIcon aria-hidden className='size-6' />
686
- {filtering ? words.noResults : words.empty}
687
- </div>
868
+ <Empty role='status' className='min-h-40 border p-6 m-4'>
869
+ <EmptyHeader>
870
+ <EmptyMedia variant='icon'>
871
+ <ShieldCheckIcon aria-hidden />
872
+ </EmptyMedia>
873
+ <EmptyTitle>
874
+ {filtering ? words.noResults : words.empty}
875
+ </EmptyTitle>
876
+ <EmptyDescription>
877
+ {filtering ? words.clear : words.emptyDescription}
878
+ </EmptyDescription>
879
+ </EmptyHeader>
880
+ </Empty>
688
881
  )}
689
882
  </div>
690
883
  </div>