@nocobase/app-template-default 1.0.0-beta.67 → 1.0.0-beta.69

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 (87) hide show
  1. package/.agents/rules/components.md +2 -2
  2. package/.agents/rules/shell.md +5 -1
  3. package/AGENTS.md +5 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/page-header.tsx +1 -1
  13. package/client/components/permission-editor.tsx +589 -0
  14. package/client/components/permission-filter.tsx +455 -0
  15. package/client/components/route-overlay.tsx +26 -13
  16. package/client/components/status-badge.tsx +7 -1
  17. package/client/components/ui/checkbox.tsx +25 -0
  18. package/client/components/ui/sidebar.tsx +1 -4
  19. package/client/components/ui/switch.tsx +30 -0
  20. package/client/components/ui/table.tsx +113 -0
  21. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  22. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  23. package/client/index.tsx +4 -0
  24. package/client/layouts/components/app-sidebar.tsx +6 -6
  25. package/client/layouts/components/layout-header.tsx +1 -1
  26. package/client/layouts/components/navigation-menu.tsx +223 -12
  27. package/client/layouts/components/sidebar-footer.tsx +1 -1
  28. package/client/layouts/components/user-menu.tsx +1 -1
  29. package/client/layouts/use-sidebar-preference.ts +62 -42
  30. package/client/lib/navigation-transform.ts +8 -0
  31. package/client/lib/scrollbar.ts +88 -0
  32. package/client/locales/system/en-US.ts +11 -0
  33. package/client/locales/system/settings-en-US.ts +141 -0
  34. package/client/locales/system/settings-zh-CN.ts +135 -0
  35. package/client/locales/system/zh-CN.ts +22 -12
  36. package/client/pages/auth/auth-page.css +52 -0
  37. package/client/pages/auth/login.tsx +1 -1
  38. package/client/pages/auth/reset-password.tsx +57 -14
  39. package/client/pages/auth/shared.tsx +26 -24
  40. package/client/pages/home/index.tsx +2 -2
  41. package/client/pages/settings/hooks.tsx +137 -0
  42. package/client/pages/settings/permission-filters.tsx +119 -0
  43. package/client/pages/settings/permissions-model.ts +260 -0
  44. package/client/pages/settings/role-form.tsx +426 -0
  45. package/client/pages/settings/roles.tsx +163 -0
  46. package/client/pages/settings/routes.ts +79 -0
  47. package/client/pages/settings/shared.tsx +119 -0
  48. package/client/pages/settings/user-detail.tsx +268 -0
  49. package/client/pages/settings/user-form.tsx +427 -0
  50. package/client/pages/settings/users.tsx +451 -0
  51. package/client/routing/app-router.tsx +4 -1
  52. package/client/routing/route-navigation.ts +10 -1
  53. package/client/routing/session-preparation.tsx +65 -0
  54. package/client/styles.css +257 -24
  55. package/client/theme/theme-settings.tsx +12 -7
  56. package/client/theme/themes/compact.css +9 -14
  57. package/client/theme/themes/default.css +7 -12
  58. package/package.json +26 -25
  59. package/server/app.ts +2 -0
  60. package/server/config/notification.ts +4 -7
  61. package/tests/components/app-layout-routes.test.tsx +14 -7
  62. package/tests/components/auth-i18n.test.tsx +57 -5
  63. package/tests/components/data-table.test.tsx +127 -0
  64. package/tests/components/layout-i18n.test.tsx +10 -3
  65. package/tests/components/navigation-menu.test.tsx +155 -16
  66. package/tests/components/permission-editor.test.tsx +477 -0
  67. package/tests/components/permission-filter-binding.test.tsx +150 -0
  68. package/tests/components/permission-filter.test.tsx +202 -0
  69. package/tests/components/settings.test.tsx +294 -0
  70. package/tests/components/sidebar-permissions.test.tsx +17 -19
  71. package/tests/components/sidebar-preference.test.tsx +209 -14
  72. package/tests/components/status-badge.test.tsx +22 -0
  73. package/tests/components/user-permissions.test.tsx +142 -0
  74. package/tests/fixtures/theme-tokens.tsx +9 -1
  75. package/tests/logic/asset-url-build.test.ts +0 -1
  76. package/tests/logic/client-routes.test.ts +40 -0
  77. package/tests/logic/client-shell.test.tsx +10 -2
  78. package/tests/logic/config-example.test.ts +30 -0
  79. package/tests/logic/dev-client-watch.test.ts +8 -1
  80. package/tests/logic/route-overlay.test.tsx +42 -0
  81. package/tests/logic/scrollbar.test.ts +88 -0
  82. package/tests/logic/session-preparation.test.tsx +223 -0
  83. package/tests/logic/settings-permissions.test.ts +168 -0
  84. package/tests/logic/theme-tokens.test.ts +1 -0
  85. package/vite.config.ts +0 -22
  86. package/vitest.config.ts +1 -0
  87. package/client/agent-annotations-host.ts +0 -25
@@ -0,0 +1,77 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import type { Column, Table as TableInstance } from '@tanstack/react-table';
3
+ import { Settings2Icon } from 'lucide-react';
4
+ import type { ReactElement, ReactNode } from 'react';
5
+
6
+ import { Button } from '#components/ui/button';
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuCheckboxItem,
10
+ DropdownMenuContent,
11
+ DropdownMenuGroup,
12
+ DropdownMenuLabel,
13
+ DropdownMenuSeparator,
14
+ DropdownMenuTrigger,
15
+ } from '#components/ui/dropdown-menu';
16
+ import { cn } from 'cn';
17
+
18
+ export interface DataTableViewOptionsProps<TData> {
19
+ readonly table: TableInstance<TData>;
20
+ readonly className?: string;
21
+ /** Label for a column in the menu; defaults to the column id. */
22
+ readonly getColumnLabel?: (column: Column<TData, unknown>) => ReactNode;
23
+ }
24
+
25
+ /**
26
+ * A "View" menu that toggles the visibility of every hideable accessor column.
27
+ * Render it from the `toolbar` of `DataTable`.
28
+ */
29
+ export function DataTableViewOptions<TData>({
30
+ table,
31
+ className,
32
+ getColumnLabel,
33
+ }: DataTableViewOptionsProps<TData>): ReactElement {
34
+ const { t } = useTranslation();
35
+ const columns = table
36
+ .getAllColumns()
37
+ .filter(
38
+ (column) =>
39
+ typeof column.accessorFn !== 'undefined' && column.getCanHide(),
40
+ );
41
+
42
+ return (
43
+ <DropdownMenu>
44
+ <DropdownMenuTrigger
45
+ render={
46
+ <Button
47
+ variant='outline'
48
+ size='sm'
49
+ className={cn('ml-auto h-8', className)}
50
+ />
51
+ }
52
+ >
53
+ <Settings2Icon />
54
+ {t('dataTable.view', { defaultValue: 'View' })}
55
+ </DropdownMenuTrigger>
56
+ <DropdownMenuContent align='end' className='w-auto min-w-40'>
57
+ {/* The label names this group, and Base UI throws if it sits outside one. */}
58
+ <DropdownMenuGroup>
59
+ <DropdownMenuLabel>
60
+ {t('dataTable.toggleColumns', { defaultValue: 'Toggle columns' })}
61
+ </DropdownMenuLabel>
62
+ <DropdownMenuSeparator />
63
+ {columns.map((column) => (
64
+ <DropdownMenuCheckboxItem
65
+ key={column.id}
66
+ className='capitalize'
67
+ checked={column.getIsVisible()}
68
+ onCheckedChange={(checked) => column.toggleVisibility(checked)}
69
+ >
70
+ {getColumnLabel ? getColumnLabel(column) : column.id}
71
+ </DropdownMenuCheckboxItem>
72
+ ))}
73
+ </DropdownMenuGroup>
74
+ </DropdownMenuContent>
75
+ </DropdownMenu>
76
+ );
77
+ }
@@ -16,7 +16,7 @@ export function PageHeader({
16
16
  return (
17
17
  <header className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
18
18
  <div className='min-w-0'>
19
- <h1 className='font-heading text-3xl font-semibold tracking-[-0.035em]'>
19
+ <h1 className='font-heading text-2xl font-semibold tracking-[-0.035em]'>
20
20
  {title}
21
21
  </h1>
22
22
  {description ? (
@@ -0,0 +1,589 @@
1
+ /** A controlled permission workspace. Filtering never changes the caller's draft. */
2
+ import { SearchIcon, ShieldCheckIcon } from 'lucide-react';
3
+ import {
4
+ useId,
5
+ useRef,
6
+ useState,
7
+ type CSSProperties,
8
+ type ReactElement,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { cn } from 'cn';
12
+ import { Button } from '#components/ui/button';
13
+ import { Input } from '#components/ui/input';
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectGroup,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from '#components/ui/select';
22
+ import { Checkbox } from '#components/ui/checkbox';
23
+ import { Switch } from '#components/ui/switch';
24
+
25
+ export interface PermissionEditorLevel {
26
+ readonly value: string;
27
+ readonly label: string;
28
+ }
29
+
30
+ export interface PermissionEditorScope {
31
+ /** An optional input for parameters of the selected scope. */
32
+ readonly configuration?: ReactNode;
33
+ readonly id: string;
34
+ readonly label: string;
35
+ readonly levels: readonly PermissionEditorLevel[];
36
+ readonly level?: string | undefined;
37
+ }
38
+
39
+ export interface PermissionEditorRow {
40
+ /** Unique permission identifier supplied unchanged to callbacks. */
41
+ readonly id: string;
42
+ readonly label: string;
43
+ readonly hint?: string | undefined;
44
+ /** Stable semantic action key for aligning actions across resources. */
45
+ readonly actionKey?: string | undefined;
46
+ /** Scope-only controls bound to this action; they are not extra permissions. */
47
+ readonly scopes?: readonly PermissionEditorScope[];
48
+ readonly enabled: boolean;
49
+ readonly toggleable?: boolean;
50
+ /** The levels a switched-on row can reach. None leaves the right column empty. */
51
+ readonly levels?: readonly PermissionEditorLevel[] | undefined;
52
+ /** The current level, one of `levels`' values. */
53
+ readonly level?: string | undefined;
54
+ }
55
+
56
+ export interface PermissionEditorGroup {
57
+ readonly id: string;
58
+ /** Rendered as a subheading above the rows; rows without one follow the previous group directly. */
59
+ readonly title?: string | undefined;
60
+ readonly hint?: string | undefined;
61
+ /** A page with one access action uses its title as the only checkbox label. */
62
+ readonly accessOnly?: boolean;
63
+ readonly rows: readonly PermissionEditorRow[];
64
+ }
65
+
66
+ export interface PermissionEditorSection {
67
+ readonly id: string;
68
+ readonly title: string;
69
+ readonly groups: readonly PermissionEditorGroup[];
70
+ /** A note under the section's rows. */
71
+ readonly footer?: ReactNode;
72
+ }
73
+
74
+ export interface PermissionEditorLabels {
75
+ /** The accessible name of a row's level select. */
76
+ readonly levelFor: (rowLabel: string) => string;
77
+ readonly search: string;
78
+ readonly enabledOnly: string;
79
+ readonly clear: string;
80
+ readonly empty: string;
81
+ readonly noResults: string;
82
+ readonly navigation: string;
83
+ readonly selectAll?: string;
84
+ readonly selectVisible?: (groupName: string) => string;
85
+ readonly summary: (enabled: number, total: number) => string;
86
+ }
87
+
88
+ export interface PermissionEditorProps {
89
+ readonly sections: readonly PermissionEditorSection[];
90
+ readonly readOnly?: boolean | undefined;
91
+ readonly onEnabledChange: (rowId: string, enabled: boolean) => void;
92
+ /** One atomic draft update; when absent, group selection is omitted. */
93
+ readonly onEnabledBatchChange?: (
94
+ changes: readonly { id: string; enabled: boolean }[],
95
+ ) => void;
96
+ readonly onLevelChange: (rowId: string, level: string) => void;
97
+ readonly labels?: Partial<PermissionEditorLabels> | undefined;
98
+ }
99
+
100
+ const DEFAULT_LABELS: Required<PermissionEditorLabels> = {
101
+ levelFor: (rowLabel) => `How far ${rowLabel} reaches`,
102
+ search: 'Search categories, groups or permissions',
103
+ enabledOnly: 'Enabled only',
104
+ clear: 'Clear filters',
105
+ empty: 'No permissions to configure',
106
+ noResults: 'No matching permissions',
107
+ navigation: 'Permission categories',
108
+ selectAll: 'All',
109
+ selectVisible: (name) => `Select visible permissions in ${name}`,
110
+ summary: (enabled, total) => `${enabled} of ${total} enabled`,
111
+ };
112
+
113
+ function LevelControl({
114
+ label,
115
+ levels,
116
+ value,
117
+ disabled,
118
+ onChange,
119
+ }: {
120
+ readonly label: string;
121
+ readonly levels: readonly PermissionEditorLevel[];
122
+ readonly value: string;
123
+ readonly disabled: boolean;
124
+ readonly onChange: (value: string) => void;
125
+ }): ReactElement | null {
126
+ if (levels.length === 0) return null;
127
+ if (levels.length === 1)
128
+ return (
129
+ <span className='block truncate px-2.5 text-sm text-muted-foreground'>
130
+ {levels[0]?.label}
131
+ </span>
132
+ );
133
+ return (
134
+ <Select
135
+ items={levels}
136
+ value={value}
137
+ disabled={disabled}
138
+ onValueChange={(next: string | null) => {
139
+ if (next !== null) onChange(next);
140
+ }}
141
+ >
142
+ <SelectTrigger
143
+ size='sm'
144
+ className='h-6 w-auto max-w-48 gap-1 border-0 bg-muted/50 px-2 text-xs shadow-none'
145
+ aria-label={label}
146
+ >
147
+ <SelectValue />
148
+ </SelectTrigger>
149
+ <SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
150
+ <SelectGroup>
151
+ {levels.map((level) => (
152
+ <SelectItem key={level.value} value={level.value}>
153
+ {level.label}
154
+ </SelectItem>
155
+ ))}
156
+ </SelectGroup>
157
+ </SelectContent>
158
+ </Select>
159
+ );
160
+ }
161
+
162
+ function PermissionRow({
163
+ row,
164
+ controlId,
165
+ readOnly,
166
+ labels,
167
+ onEnabledChange,
168
+ onLevelChange,
169
+ }: {
170
+ readonly row: PermissionEditorRow;
171
+ readonly controlId: string;
172
+ readonly readOnly: boolean;
173
+ readonly labels: PermissionEditorLabels;
174
+ readonly onEnabledChange: (rowId: string, enabled: boolean) => void;
175
+ readonly onLevelChange: (rowId: string, level: string) => void;
176
+ }): ReactElement {
177
+ return (
178
+ <div
179
+ data-slot='permission-row'
180
+ className='flex min-w-0 flex-col gap-1 rounded-md px-2 py-1.5'
181
+ >
182
+ <div className='flex min-h-6 min-w-0 flex-wrap items-center gap-2'>
183
+ <Checkbox
184
+ id={controlId}
185
+ checked={row.enabled}
186
+ disabled={readOnly || row.toggleable === false}
187
+ onCheckedChange={(on) => onEnabledChange(row.id, on)}
188
+ aria-describedby={row.hint ? `${controlId}-hint` : undefined}
189
+ />
190
+ <label
191
+ htmlFor={controlId}
192
+ className='min-w-0 cursor-pointer text-sm leading-5 font-medium break-words'
193
+ >
194
+ {row.label}
195
+ </label>
196
+ {row.enabled && row.levels ? (
197
+ <LevelControl
198
+ label={labels.levelFor(row.label)}
199
+ levels={row.levels}
200
+ value={row.level ?? ''}
201
+ disabled={readOnly}
202
+ onChange={(level) => onLevelChange(row.id, level)}
203
+ />
204
+ ) : null}
205
+ {row.enabled &&
206
+ row.scopes?.map((scope) => (
207
+ <div key={scope.id} className='flex min-w-0 items-center gap-1'>
208
+ {row.scopes && row.scopes.length > 1 && (
209
+ <span className='text-xs text-muted-foreground'>
210
+ {scope.label}
211
+ </span>
212
+ )}
213
+ <LevelControl
214
+ label={labels.levelFor(`${row.label} · ${scope.label}`)}
215
+ levels={scope.levels}
216
+ value={scope.level ?? ''}
217
+ disabled={readOnly}
218
+ onChange={(level) => onLevelChange(scope.id, level)}
219
+ />
220
+ {scope.configuration}
221
+ </div>
222
+ ))}
223
+ </div>
224
+ {row.hint && (
225
+ <span
226
+ id={`${controlId}-hint`}
227
+ className='pl-6 text-xs text-muted-foreground'
228
+ >
229
+ {row.hint}
230
+ </span>
231
+ )}
232
+ </div>
233
+ );
234
+ }
235
+
236
+ /** A category navigator with a compact, searchable permission list. */
237
+ export function PermissionEditor({
238
+ sections,
239
+ readOnly = false,
240
+ onEnabledChange,
241
+ onEnabledBatchChange,
242
+ onLevelChange,
243
+ labels,
244
+ }: PermissionEditorProps): ReactElement {
245
+ const words = { ...DEFAULT_LABELS, ...labels };
246
+ const [selected, setSelected] = useState(sections[0]?.id ?? '');
247
+ const [query, setQuery] = useState('');
248
+ const [enabledOnly, setEnabledOnly] = useState(false);
249
+ const id = useId();
250
+ const searchRef = useRef<HTMLInputElement>(null);
251
+ const active = sections.some((section) => section.id === selected)
252
+ ? selected
253
+ : sections[0]?.id;
254
+ const search = query.trim().toLocaleLowerCase();
255
+ const filtering = Boolean(search) || enabledOnly;
256
+ const visible = sections
257
+ .filter((section) => filtering || section.id === active)
258
+ .map((section) => ({
259
+ ...section,
260
+ groups: section.groups
261
+ .map((group) => ({
262
+ ...group,
263
+ rows: group.rows.filter(
264
+ (row) =>
265
+ (!enabledOnly || row.enabled) &&
266
+ (!search ||
267
+ [
268
+ section.title,
269
+ group.title,
270
+ group.hint,
271
+ row.label,
272
+ row.hint,
273
+ ...(row.scopes?.map((scope) => scope.label) ?? []),
274
+ ].some((text) => text?.toLocaleLowerCase().includes(search))),
275
+ ),
276
+ }))
277
+ .filter((group) => group.rows.length),
278
+ }))
279
+ .filter((section) => section.groups.length);
280
+ return (
281
+ <div className='@container flex min-w-0 flex-col overflow-hidden rounded-xl border bg-card'>
282
+ <div className='flex flex-wrap items-center gap-3 border-b p-3'>
283
+ <div className='relative min-w-0 flex-1'>
284
+ <SearchIcon
285
+ aria-hidden
286
+ className='pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground'
287
+ />
288
+ <Input
289
+ ref={searchRef}
290
+ className='pl-9'
291
+ value={query}
292
+ aria-label={words.search}
293
+ placeholder={words.search}
294
+ onChange={(event) => setQuery(event.target.value)}
295
+ />
296
+ </div>
297
+ <label
298
+ htmlFor={`${id}-enabled`}
299
+ className='flex items-center gap-2 text-sm'
300
+ >
301
+ <Switch
302
+ id={`${id}-enabled`}
303
+ checked={enabledOnly}
304
+ onCheckedChange={setEnabledOnly}
305
+ />
306
+ {words.enabledOnly}
307
+ </label>
308
+ {filtering && (
309
+ <Button
310
+ type='button'
311
+ variant='ghost'
312
+ size='sm'
313
+ onClick={() => {
314
+ setQuery('');
315
+ setEnabledOnly(false);
316
+ }}
317
+ >
318
+ {words.clear}
319
+ </Button>
320
+ )}
321
+ </div>
322
+ <div className='flex max-h-128 min-w-0 flex-col overflow-auto @2xl:flex-row'>
323
+ <nav
324
+ aria-label={words.navigation}
325
+ 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-48 @2xl:self-start @2xl:flex-col @2xl:border-r @2xl:border-b-0'
326
+ >
327
+ {sections.map((section) => {
328
+ const rows = section.groups.flatMap((group) => group.rows);
329
+ return (
330
+ <Button
331
+ type='button'
332
+ key={section.id}
333
+ variant='ghost'
334
+ aria-current={section.id === active ? 'page' : undefined}
335
+ onClick={() => {
336
+ setSelected(section.id);
337
+ setQuery('');
338
+ setEnabledOnly(false);
339
+ }}
340
+ className={cn(
341
+ 'h-auto shrink-0 justify-between gap-4 px-3 py-2.5 text-left whitespace-normal',
342
+ section.id === active &&
343
+ 'bg-primary/10 text-primary hover:bg-primary/15',
344
+ )}
345
+ >
346
+ <span>{section.title}</span>
347
+ <span className='rounded-md bg-background px-1.5 py-0.5 text-xs tabular-nums'>
348
+ {rows.filter((row) => row.enabled).length}/{rows.length}
349
+ </span>
350
+ </Button>
351
+ );
352
+ })}
353
+ </nav>
354
+ <div className='min-w-0 flex-1 @container'>
355
+ {visible.length ? (
356
+ visible.map((section) => {
357
+ const original =
358
+ sections.find((item) => item.id === section.id) ?? section;
359
+ const singleResource = original.groups.length === 1;
360
+ const keys = [
361
+ ...new Set(
362
+ original.groups
363
+ .flatMap((group) => group.rows.map((row) => row.actionKey))
364
+ .filter((key): key is string => Boolean(key)),
365
+ ),
366
+ ];
367
+ const common = ['read', 'create', 'update', 'delete'];
368
+ const columns = [
369
+ ...common.filter((key) => keys.includes(key)),
370
+ ...keys.filter((key) => !common.includes(key)),
371
+ ];
372
+ // Dense or unclassified catalogs keep the wrapping layout instead of hiding actions.
373
+ const aligned =
374
+ columns.length > 0 &&
375
+ columns.length <= 7 &&
376
+ original.groups.every(
377
+ (group) =>
378
+ group.rows.every((row) => row.actionKey) &&
379
+ new Set(group.rows.map((row) => row.actionKey)).size ===
380
+ group.rows.length,
381
+ );
382
+ return (
383
+ <section
384
+ key={section.id}
385
+ aria-label={section.title}
386
+ className='flex flex-col gap-3 p-3 @lg:p-4'
387
+ >
388
+ <h2 className='text-sm font-semibold'>{section.title}</h2>
389
+ <div className='divide-y rounded-lg border'>
390
+ {section.groups.map((group) => {
391
+ const selectable = group.rows.filter(
392
+ (row) => row.toggleable !== false,
393
+ );
394
+ const enabled = selectable.filter(
395
+ (row) => row.enabled,
396
+ ).length;
397
+ const originalGroup = sections
398
+ .find((item) => item.id === section.id)
399
+ ?.groups.find((item) => item.id === group.id);
400
+ const totalRows = originalGroup?.rows ?? group.rows;
401
+ const accessRow =
402
+ group.accessOnly && totalRows.length === 1
403
+ ? group.rows[0]
404
+ : undefined;
405
+ if (accessRow)
406
+ return (
407
+ <div
408
+ key={group.id}
409
+ className='p-3'
410
+ data-slot='permission-resource'
411
+ >
412
+ <PermissionRow
413
+ controlId={`${id}-${accessRow.id}`}
414
+ row={{
415
+ ...accessRow,
416
+ label: group.title ?? accessRow.label,
417
+ hint: group.hint ?? accessRow.hint,
418
+ }}
419
+ readOnly={readOnly}
420
+ labels={words}
421
+ onEnabledChange={(rowId, on) => {
422
+ if (enabledOnly && !on)
423
+ searchRef.current?.focus();
424
+ onEnabledChange(rowId, on);
425
+ }}
426
+ onLevelChange={onLevelChange}
427
+ />
428
+ </div>
429
+ );
430
+ return (
431
+ <div
432
+ key={group.id}
433
+ className='grid min-w-0 gap-2 p-3 @xl:grid-cols-[minmax(8rem,1fr)_minmax(0,4fr)]'
434
+ data-slot='permission-resource'
435
+ >
436
+ <div className='flex min-w-0 flex-col justify-center gap-1'>
437
+ <h3 className='text-sm font-medium break-words'>
438
+ {group.title ?? section.title}
439
+ </h3>
440
+ {group.hint && (
441
+ <p className='text-xs text-muted-foreground'>
442
+ {group.hint}
443
+ </p>
444
+ )}
445
+ <div className='flex items-center gap-2 text-xs text-muted-foreground'>
446
+ {onEnabledBatchChange &&
447
+ totalRows.length > 1 &&
448
+ selectable.length > 0 && (
449
+ <Checkbox
450
+ aria-label={words.selectVisible(
451
+ group.title ?? section.title,
452
+ )}
453
+ checked={
454
+ selectable.length > 0 &&
455
+ enabled === selectable.length
456
+ }
457
+ indeterminate={
458
+ enabled > 0 && enabled < selectable.length
459
+ }
460
+ disabled={readOnly}
461
+ id={`${id}-${section.id}-${group.id}-all`}
462
+ title={words.selectVisible(
463
+ group.title ?? section.title,
464
+ )}
465
+ onCheckedChange={(checked) => {
466
+ const changes = selectable
467
+ .filter(
468
+ (row) => row.enabled !== checked,
469
+ )
470
+ .map((row) => ({
471
+ id: row.id,
472
+ enabled: checked,
473
+ }));
474
+ if (changes.length) {
475
+ if (enabledOnly && !checked)
476
+ searchRef.current?.focus();
477
+ onEnabledBatchChange(changes);
478
+ }
479
+ }}
480
+ />
481
+ )}
482
+ {onEnabledBatchChange &&
483
+ totalRows.length > 1 &&
484
+ selectable.length > 0 && (
485
+ <label
486
+ htmlFor={`${id}-${section.id}-${group.id}-all`}
487
+ className='cursor-pointer'
488
+ >
489
+ <span aria-hidden>{words.selectAll}</span>
490
+ <span className='sr-only'>
491
+ {words.selectVisible(
492
+ group.title ?? section.title,
493
+ )}
494
+ </span>
495
+ </label>
496
+ )}
497
+ {!singleResource && totalRows.length > 1 && (
498
+ <span
499
+ className='tabular-nums'
500
+ aria-label={words.summary(
501
+ totalRows.filter((row) => row.enabled)
502
+ .length,
503
+ totalRows.length,
504
+ )}
505
+ >
506
+ {
507
+ totalRows.filter((row) => row.enabled)
508
+ .length
509
+ }
510
+ /{totalRows.length}
511
+ </span>
512
+ )}
513
+ </div>
514
+ </div>
515
+ <div
516
+ className={cn(
517
+ 'flex min-w-0 flex-wrap items-start gap-x-3 gap-y-1',
518
+ aligned &&
519
+ '@4xl:grid @4xl:grid-cols-(--permission-columns) @4xl:gap-x-1',
520
+ )}
521
+ style={
522
+ {
523
+ '--permission-columns': `repeat(${columns.length}, minmax(0, 1fr))`,
524
+ } as CSSProperties
525
+ }
526
+ >
527
+ {(aligned
528
+ ? [...group.rows].sort(
529
+ (a, b) =>
530
+ columns.indexOf(a.actionKey ?? '') -
531
+ columns.indexOf(b.actionKey ?? ''),
532
+ )
533
+ : group.rows
534
+ ).map((row) => (
535
+ <div
536
+ key={row.id}
537
+ className={cn(
538
+ 'min-w-0',
539
+ aligned &&
540
+ '@4xl:col-start-(--permission-column) @4xl:row-start-1',
541
+ )}
542
+ style={
543
+ {
544
+ '--permission-column':
545
+ columns.indexOf(row.actionKey ?? '') + 1,
546
+ } as CSSProperties
547
+ }
548
+ >
549
+ <PermissionRow
550
+ controlId={`${id}-${row.id}`}
551
+ row={row}
552
+ readOnly={readOnly}
553
+ labels={words}
554
+ onEnabledChange={(rowId, on) => {
555
+ if (enabledOnly && !on)
556
+ searchRef.current?.focus();
557
+ onEnabledChange(rowId, on);
558
+ }}
559
+ onLevelChange={onLevelChange}
560
+ />
561
+ </div>
562
+ ))}
563
+ </div>
564
+ </div>
565
+ );
566
+ })}
567
+ </div>
568
+ {section.footer && (
569
+ <div className='text-xs text-muted-foreground'>
570
+ {section.footer}
571
+ </div>
572
+ )}
573
+ </section>
574
+ );
575
+ })
576
+ ) : (
577
+ <div
578
+ role='status'
579
+ className='flex min-h-48 flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground'
580
+ >
581
+ <ShieldCheckIcon aria-hidden className='size-6' />
582
+ {filtering ? words.noResults : words.empty}
583
+ </div>
584
+ )}
585
+ </div>
586
+ </div>
587
+ </div>
588
+ );
589
+ }