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

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 (38) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.MD +3 -1
  3. package/cli/plugins.ts +2 -1
  4. package/client/components/default-access-editor.tsx +183 -0
  5. package/client/components/permission-editor.tsx +216 -150
  6. package/client/components/role-list.tsx +85 -0
  7. package/client/lib/navigation-transform.ts +29 -1
  8. package/client/locales/system/settings-en-US.ts +74 -2
  9. package/client/locales/system/settings-zh-CN.ts +66 -2
  10. package/client/pages/settings/default-access-form.tsx +499 -0
  11. package/client/pages/settings/default-access-model.ts +154 -0
  12. package/client/pages/settings/default-access.tsx +407 -0
  13. package/client/pages/settings/permission-filters.tsx +43 -37
  14. package/client/pages/settings/permission-management-page.tsx +105 -0
  15. package/client/pages/settings/permissions-model.ts +37 -1
  16. package/client/pages/settings/role-form.tsx +46 -18
  17. package/client/pages/settings/roles.tsx +26 -43
  18. package/client/pages/settings/routes.ts +78 -19
  19. package/client/pages/settings/shared.tsx +3 -0
  20. package/client/pages/settings/users.tsx +1 -1
  21. package/client/plugins.ts +2 -0
  22. package/client/routing/route-navigation.ts +21 -1
  23. package/package.json +25 -23
  24. package/server/plugins.ts +2 -0
  25. package/tests/components/default-access.test.tsx +554 -0
  26. package/tests/components/page-harness.test.tsx +132 -112
  27. package/tests/components/permission-editor.test.tsx +233 -73
  28. package/tests/components/permission-management-tabs.test.tsx +128 -0
  29. package/tests/components/role-list.test.tsx +136 -0
  30. package/tests/components/roles.test.tsx +85 -0
  31. package/tests/components/user-permissions.test.tsx +12 -0
  32. package/tests/logic/authorization-assignment-safety.test.ts +82 -0
  33. package/tests/logic/client-routes.test.ts +8 -0
  34. package/tests/logic/default-access-model.test.ts +167 -0
  35. package/tests/logic/foundation-integration.test.ts +62 -0
  36. package/tests/logic/permission-management-routes.test.ts +64 -0
  37. package/tests/logic/settings-permissions.test.ts +89 -0
  38. package/vitest.config.ts +2 -2
@@ -1,5 +1,5 @@
1
1
  /** A controlled permission workspace. Filtering never changes the caller's draft. */
2
- import { SearchIcon, ShieldCheckIcon } from 'lucide-react';
2
+ import { InfoIcon, SearchIcon, ShieldCheckIcon } from 'lucide-react';
3
3
  import {
4
4
  useId,
5
5
  useRef,
@@ -21,6 +21,12 @@ import {
21
21
  } from '#components/ui/select';
22
22
  import { Checkbox } from '#components/ui/checkbox';
23
23
  import { Switch } from '#components/ui/switch';
24
+ import {
25
+ Tooltip,
26
+ TooltipContent,
27
+ TooltipProvider,
28
+ TooltipTrigger,
29
+ } from '#components/ui/tooltip';
24
30
 
25
31
  export interface PermissionEditorLevel {
26
32
  readonly value: string;
@@ -30,6 +36,7 @@ export interface PermissionEditorLevel {
30
36
  export interface PermissionEditorScope {
31
37
  /** An optional input for parameters of the selected scope. */
32
38
  readonly configuration?: ReactNode;
39
+ readonly hint?: string | undefined;
33
40
  readonly id: string;
34
41
  readonly label: string;
35
42
  readonly levels: readonly PermissionEditorLevel[];
@@ -60,12 +67,15 @@ export interface PermissionEditorGroup {
60
67
  readonly hint?: string | undefined;
61
68
  /** A page with one access action uses its title as the only checkbox label. */
62
69
  readonly accessOnly?: boolean;
70
+ /** An explanation shared by all actions, shown once across the resource row. */
71
+ readonly actionHint?: string | undefined;
63
72
  readonly rows: readonly PermissionEditorRow[];
64
73
  }
65
74
 
66
75
  export interface PermissionEditorSection {
67
76
  readonly id: string;
68
77
  readonly title: string;
78
+ readonly description?: string | undefined;
69
79
  readonly groups: readonly PermissionEditorGroup[];
70
80
  /** A note under the section's rows. */
71
81
  readonly footer?: ReactNode;
@@ -81,6 +91,9 @@ export interface PermissionEditorLabels {
81
91
  readonly noResults: string;
82
92
  readonly navigation: string;
83
93
  readonly selectAll?: string;
94
+ readonly dataRanges?: (count: number) => string;
95
+ readonly scopeExplanation?: string;
96
+ readonly scopeHintFor?: (name: string) => string;
84
97
  readonly selectVisible?: (groupName: string) => string;
85
98
  readonly summary: (enabled: number, total: number) => string;
86
99
  }
@@ -89,7 +102,7 @@ export interface PermissionEditorProps {
89
102
  readonly sections: readonly PermissionEditorSection[];
90
103
  readonly readOnly?: boolean | undefined;
91
104
  readonly onEnabledChange: (rowId: string, enabled: boolean) => void;
92
- /** One atomic draft update; when absent, group selection is omitted. */
105
+ /** Compatibility with existing hosts; the editor now renders individual controls only. */
93
106
  readonly onEnabledBatchChange?: (
94
107
  changes: readonly { id: string; enabled: boolean }[],
95
108
  ) => void;
@@ -106,10 +119,51 @@ const DEFAULT_LABELS: Required<PermissionEditorLabels> = {
106
119
  noResults: 'No matching permissions',
107
120
  navigation: 'Permission categories',
108
121
  selectAll: 'All',
122
+ dataRanges: (count) => `Data ranges (${count})`,
123
+ scopeExplanation:
124
+ 'Each range applies to its corresponding data. The operation must also pass its authorization checks.',
125
+ scopeHintFor: (name) => `About the data range for ${name}`,
109
126
  selectVisible: (name) => `Select visible permissions in ${name}`,
110
127
  summary: (enabled, total) => `${enabled} of ${total} enabled`,
111
128
  };
112
129
 
130
+ function ScopeHint({
131
+ label,
132
+ children,
133
+ }: {
134
+ readonly label: string;
135
+ readonly children: string;
136
+ }): ReactElement {
137
+ const [open, setOpen] = useState(false);
138
+ const triggerId = useId();
139
+ return (
140
+ <TooltipProvider>
141
+ <Tooltip open={open} onOpenChange={setOpen} triggerId={triggerId}>
142
+ <TooltipTrigger
143
+ id={triggerId}
144
+ closeOnClick={false}
145
+ render={
146
+ <Button
147
+ type='button'
148
+ variant='ghost'
149
+ size='icon'
150
+ className='size-6 shrink-0 text-muted-foreground'
151
+ aria-label={label}
152
+ aria-describedby={open ? `${triggerId}-content` : undefined}
153
+ onClick={() => setOpen(true)}
154
+ />
155
+ }
156
+ >
157
+ <InfoIcon aria-hidden className='size-3.5' />
158
+ </TooltipTrigger>
159
+ <TooltipContent role='tooltip' id={`${triggerId}-content`}>
160
+ {children}
161
+ </TooltipContent>
162
+ </Tooltip>
163
+ </TooltipProvider>
164
+ );
165
+ }
166
+
113
167
  function LevelControl({
114
168
  label,
115
169
  levels,
@@ -176,12 +230,41 @@ function PermissionRow({
176
230
  readonly onEnabledChange: (rowId: string, enabled: boolean) => void;
177
231
  readonly onLevelChange: (rowId: string, level: string) => void;
178
232
  }): 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
+ ));
179
262
  return (
180
263
  <div
181
264
  data-slot='permission-row'
182
- className='flex min-w-0 flex-col gap-1 rounded-md px-2 py-1.5'
265
+ className='@container flex min-w-0 flex-col gap-1'
183
266
  >
184
- <div className='grid min-h-6 min-w-0 grid-cols-[auto_auto_minmax(0,1fr)] items-center gap-x-2 @2xl:grid-cols-[auto_minmax(0,1fr)] gap-y-1'>
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)]'>
185
268
  <Checkbox
186
269
  id={controlId}
187
270
  checked={row.enabled}
@@ -191,12 +274,12 @@ function PermissionRow({
191
274
  />
192
275
  <label
193
276
  htmlFor={controlId}
194
- className='min-h-6 min-w-0 max-w-36 cursor-pointer text-sm leading-6 font-medium break-words @2xl:max-w-none'
277
+ className='min-w-0 cursor-pointer text-sm leading-5 font-normal break-words @xs:max-w-36'
195
278
  >
196
279
  {row.label}
197
280
  </label>
198
281
  {row.enabled && row.levels ? (
199
- <div className='col-start-3 min-w-0 @2xl:col-start-2'>
282
+ <div className='col-start-2 min-w-0 @xs:col-start-3'>
200
283
  <LevelControl
201
284
  label={labels.levelFor(row.label)}
202
285
  levels={row.levels}
@@ -206,40 +289,40 @@ function PermissionRow({
206
289
  />
207
290
  </div>
208
291
  ) : null}
209
- {row.enabled &&
210
- row.scopes?.map((scope) => (
211
- <div
212
- key={scope.id}
213
- className={cn(
214
- 'col-start-3 grid min-w-0 gap-1 @2xl:col-start-2',
215
- showScopeLabels && 'col-span-2 col-start-2 @2xl:col-span-1',
216
- )}
217
- >
218
- {(showScopeLabels || (row.scopes && row.scopes.length > 1)) && (
219
- <span className='text-xs leading-5 text-muted-foreground'>
220
- {scope.label}
221
- </span>
222
- )}
223
- <div className='flex min-h-7 min-w-0 items-center gap-1'>
224
- <LevelControl
225
- label={labels.levelFor(`${row.label} · ${scope.label}`)}
226
- levels={scope.levels}
227
- value={scope.level ?? ''}
228
- disabled={readOnly}
229
- onChange={(level) => onLevelChange(scope.id, level)}
230
- />
231
- {scope.configuration}
232
- </div>
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('; ')}
304
+ </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}
233
311
  </div>
234
- ))}
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}
235
318
  </div>
236
319
  {row.hint && (
237
- <span
320
+ <p
238
321
  id={`${controlId}-hint`}
239
- className='pl-6 text-xs text-muted-foreground'
322
+ className='pl-6 text-xs leading-5 text-muted-foreground'
240
323
  >
241
324
  {row.hint}
242
- </span>
325
+ </p>
243
326
  )}
244
327
  </div>
245
328
  );
@@ -250,7 +333,6 @@ export function PermissionEditor({
250
333
  sections,
251
334
  readOnly = false,
252
335
  onEnabledChange,
253
- onEnabledBatchChange,
254
336
  onLevelChange,
255
337
  labels,
256
338
  }: PermissionEditorProps): ReactElement {
@@ -280,6 +362,7 @@ export function PermissionEditor({
280
362
  section.title,
281
363
  group.title,
282
364
  group.hint,
365
+ group.actionHint,
283
366
  row.label,
284
367
  row.hint,
285
368
  ...(row.scopes?.map((scope) => scope.label) ?? []),
@@ -334,10 +417,9 @@ export function PermissionEditor({
334
417
  <div className='flex max-h-128 min-w-0 flex-col overflow-auto @2xl:flex-row'>
335
418
  <nav
336
419
  aria-label={words.navigation}
337
- 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-64 @2xl:self-start @2xl:flex-col @2xl:border-r @2xl:border-b-0'
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'
338
421
  >
339
422
  {sections.map((section) => {
340
- const rows = section.groups.flatMap((group) => group.rows);
341
423
  return (
342
424
  <Button
343
425
  type='button'
@@ -350,9 +432,9 @@ export function PermissionEditor({
350
432
  setEnabledOnly(false);
351
433
  }}
352
434
  className={cn(
353
- 'h-auto shrink-0 justify-between gap-3 px-3 py-2 text-left whitespace-nowrap @2xl:w-full @2xl:min-w-0',
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',
354
436
  section.id === active &&
355
- 'bg-primary/10 text-primary hover:bg-primary/15',
437
+ 'bg-primary/10 font-medium text-primary hover:bg-primary/15',
356
438
  )}
357
439
  >
358
440
  <span
@@ -361,9 +443,6 @@ export function PermissionEditor({
361
443
  >
362
444
  {section.title}
363
445
  </span>
364
- <span className='shrink-0 rounded-md bg-background px-1.5 py-0.5 text-xs tabular-nums'>
365
- {rows.filter((row) => row.enabled).length}/{rows.length}
366
- </span>
367
446
  </Button>
368
447
  );
369
448
  })}
@@ -373,7 +452,6 @@ export function PermissionEditor({
373
452
  visible.map((section) => {
374
453
  const original =
375
454
  sections.find((item) => item.id === section.id) ?? section;
376
- const singleResource = original.groups.length === 1;
377
455
  const showScopeLabels = original.groups.some((group) =>
378
456
  group.rows.some((row) => (row.scopes?.length ?? 0) > 1),
379
457
  );
@@ -389,9 +467,49 @@ export function PermissionEditor({
389
467
  ...common.filter((key) => keys.includes(key)),
390
468
  ...keys.filter((key) => !common.includes(key)),
391
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
+ };
392
510
  const aligned =
393
511
  columns.length > 0 &&
394
- columns.length <= 4 &&
512
+ columnCount <= (hasRanges ? 4 : 6) &&
395
513
  original.groups.every(
396
514
  (group) =>
397
515
  group.rows.every((row) => row.actionKey) &&
@@ -399,35 +517,40 @@ export function PermissionEditor({
399
517
  group.rows.length,
400
518
  );
401
519
  // Keep action columns only when each has room for its label and scope.
402
- const alignment = [
403
- '',
404
- '@3xl:grid-cols-(--permission-columns)',
405
- '@3xl:grid-cols-(--permission-columns)',
406
- '@5xl:grid-cols-(--permission-columns)',
407
- '@7xl:grid-cols-(--permission-columns)',
408
- ][columns.length];
409
- const placement = [
410
- '',
411
- '@3xl:col-start-(--permission-column) @3xl:row-start-1',
412
- '@3xl:col-start-(--permission-column) @3xl:row-start-1',
413
- '@5xl:col-start-(--permission-column) @5xl:row-start-1',
414
- '@7xl:col-start-(--permission-column) @7xl:row-start-1',
415
- ][columns.length];
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];
416
538
  return (
417
539
  <section
418
540
  key={section.id}
419
541
  aria-label={section.title}
420
- className='flex flex-col gap-3 p-3 @lg:p-4'
542
+ className='flex flex-col gap-2 p-3'
421
543
  >
422
- <h2 className='text-sm font-semibold'>{section.title}</h2>
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
+ )}
423
552
  <div className='divide-y rounded-lg border'>
424
553
  {section.groups.map((group) => {
425
- const selectable = group.rows.filter(
426
- (row) => row.toggleable !== false,
427
- );
428
- const enabled = selectable.filter(
429
- (row) => row.enabled,
430
- ).length;
431
554
  const originalGroup = sections
432
555
  .find((item) => item.id === section.id)
433
556
  ?.groups.find((item) => item.id === group.id);
@@ -440,7 +563,7 @@ export function PermissionEditor({
440
563
  return (
441
564
  <div
442
565
  key={group.id}
443
- className='p-3'
566
+ className='px-3 py-2'
444
567
  data-slot='permission-resource'
445
568
  >
446
569
  <PermissionRow
@@ -464,11 +587,16 @@ export function PermissionEditor({
464
587
  return (
465
588
  <div
466
589
  key={group.id}
467
- className='grid min-w-0 gap-2 p-3 @3xl:grid-cols-[9rem_minmax(0,1fr)]'
590
+ 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)]',
595
+ )}
468
596
  data-slot='permission-resource'
469
597
  >
470
- <div className='flex min-w-0 flex-col gap-1 py-1.5'>
471
- <h3 className='min-h-6 text-sm leading-6 font-medium break-words'>
598
+ <div className='flex min-w-0 flex-col gap-1'>
599
+ <h3 className='text-sm leading-5 font-medium break-words'>
472
600
  {group.title ?? section.title}
473
601
  </h3>
474
602
  {group.hint && (
@@ -476,97 +604,27 @@ export function PermissionEditor({
476
604
  {group.hint}
477
605
  </p>
478
606
  )}
479
- <div
480
- className={cn(
481
- 'flex min-h-7 items-center gap-2 text-xs text-muted-foreground',
482
- showScopeLabels && '@3xl:mt-6',
483
- )}
484
- >
485
- {onEnabledBatchChange &&
486
- totalRows.length > 1 &&
487
- selectable.length > 0 && (
488
- <Checkbox
489
- aria-label={words.selectVisible(
490
- group.title ?? section.title,
491
- )}
492
- checked={
493
- selectable.length > 0 &&
494
- enabled === selectable.length
495
- }
496
- indeterminate={
497
- enabled > 0 && enabled < selectable.length
498
- }
499
- disabled={readOnly}
500
- id={`${id}-${section.id}-${group.id}-all`}
501
- title={words.selectVisible(
502
- group.title ?? section.title,
503
- )}
504
- onCheckedChange={(checked) => {
505
- const changes = selectable
506
- .filter(
507
- (row) => row.enabled !== checked,
508
- )
509
- .map((row) => ({
510
- id: row.id,
511
- enabled: checked,
512
- }));
513
- if (changes.length) {
514
- if (enabledOnly && !checked)
515
- searchRef.current?.focus();
516
- onEnabledBatchChange(changes);
517
- }
518
- }}
519
- />
520
- )}
521
- {onEnabledBatchChange &&
522
- totalRows.length > 1 &&
523
- selectable.length > 0 && (
524
- <label
525
- htmlFor={`${id}-${section.id}-${group.id}-all`}
526
- className='cursor-pointer'
527
- >
528
- <span aria-hidden>{words.selectAll}</span>
529
- <span className='sr-only'>
530
- {words.selectVisible(
531
- group.title ?? section.title,
532
- )}
533
- </span>
534
- </label>
535
- )}
536
- {!singleResource && totalRows.length > 1 && (
537
- <span
538
- className='tabular-nums'
539
- aria-label={words.summary(
540
- totalRows.filter((row) => row.enabled)
541
- .length,
542
- totalRows.length,
543
- )}
544
- >
545
- {
546
- totalRows.filter((row) => row.enabled)
547
- .length
548
- }
549
- /{totalRows.length}
550
- </span>
551
- )}
552
- </div>
553
607
  </div>
554
608
  <div
609
+ data-slot='permission-actions'
555
610
  className={cn(
556
- 'grid min-w-0 grid-cols-[repeat(auto-fill,minmax(min(100%,14rem),1fr))] items-start gap-x-2 gap-y-1',
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))]',
557
615
  aligned && alignment,
558
616
  )}
559
617
  style={
560
618
  {
561
- '--permission-columns': `repeat(${columns.length}, minmax(0, 1fr))`,
619
+ '--permission-columns': `repeat(${columnCount}, minmax(0, 1fr))`,
562
620
  } as CSSProperties
563
621
  }
564
622
  >
565
623
  {[...group.rows]
566
624
  .sort(
567
625
  (a, b) =>
568
- columns.indexOf(a.actionKey ?? '') -
569
- columns.indexOf(b.actionKey ?? ''),
626
+ columnFor(a, totalRows) -
627
+ columnFor(b, totalRows),
570
628
  )
571
629
  .map((row) => (
572
630
  <div
@@ -574,12 +632,15 @@ export function PermissionEditor({
574
632
  className={cn(
575
633
  'min-w-0',
576
634
  aligned && placement,
635
+ aligned &&
636
+ !hasRanges &&
637
+ totalRows.length === 1 &&
638
+ '@xl:col-span-full',
577
639
  )}
578
640
  style={
579
641
  {
580
642
  '--permission-column':
581
- columns.indexOf(row.actionKey ?? '') +
582
- 1,
643
+ columnFor(row, totalRows) + 1,
583
644
  } as CSSProperties
584
645
  }
585
646
  >
@@ -599,6 +660,11 @@ export function PermissionEditor({
599
660
  </div>
600
661
  ))}
601
662
  </div>
663
+ {group.actionHint && (
664
+ <p className='col-span-full text-xs leading-5 text-muted-foreground'>
665
+ {group.actionHint}
666
+ </p>
667
+ )}
602
668
  </div>
603
669
  );
604
670
  })}
@@ -0,0 +1,85 @@
1
+ import { useMemo, type ReactElement } from 'react';
2
+ import type { ColumnDef } from '@tanstack/react-table';
3
+ import { Link, type To } from 'react-router';
4
+ import { DataTable, DataTableColumnHeader } from '#components/data-table';
5
+ import { Badge } from '#components/ui/badge';
6
+
7
+ export interface RoleListRole {
8
+ readonly key: string;
9
+ readonly title: string;
10
+ readonly to: To;
11
+ readonly protected: boolean;
12
+ }
13
+
14
+ export interface RoleListProps {
15
+ readonly roles: readonly RoleListRole[];
16
+ readonly labels: {
17
+ readonly name: string;
18
+ readonly key: string;
19
+ readonly type: string;
20
+ readonly protected: string;
21
+ readonly custom: string;
22
+ };
23
+ readonly ariaLabel?: string;
24
+ }
25
+
26
+ /** Standard role table; the host owns authorization, loading and filtering. */
27
+ export function RoleList({
28
+ roles,
29
+ labels,
30
+ ariaLabel,
31
+ }: RoleListProps): ReactElement {
32
+ const columns = useMemo<ColumnDef<RoleListRole>[]>(
33
+ () => [
34
+ {
35
+ accessorKey: 'title',
36
+ header: ({ column }) => (
37
+ <DataTableColumnHeader column={column} title={labels.name} />
38
+ ),
39
+ cell: ({ row }) => (
40
+ <Link
41
+ to={row.original.to}
42
+ className='inline-block max-w-72 truncate align-middle font-medium text-foreground'
43
+ title={row.original.title}
44
+ >
45
+ {row.original.title}
46
+ </Link>
47
+ ),
48
+ },
49
+ {
50
+ accessorKey: 'key',
51
+ header: ({ column }) => (
52
+ <DataTableColumnHeader column={column} title={labels.key} />
53
+ ),
54
+ cell: ({ row }) => (
55
+ <span
56
+ className='inline-block max-w-64 truncate align-middle text-muted-foreground'
57
+ title={row.original.key}
58
+ >
59
+ {row.original.key}
60
+ </span>
61
+ ),
62
+ },
63
+ {
64
+ accessorKey: 'protected',
65
+ header: labels.type,
66
+ cell: ({ row }) => (
67
+ <Badge variant='secondary'>
68
+ {row.original.protected ? labels.protected : labels.custom}
69
+ </Badge>
70
+ ),
71
+ },
72
+ ],
73
+ [labels],
74
+ );
75
+ return (
76
+ <div role='region' aria-label={ariaLabel}>
77
+ <DataTable
78
+ columns={columns}
79
+ data={roles}
80
+ getRowId={(role) => role.key}
81
+ showSelectedCount={false}
82
+ />
83
+ </div>
84
+ );
85
+ }