@nocobase/app-template-default 1.0.0-beta.76 → 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.
- package/client/components/default-access-editor.tsx +79 -52
- package/client/components/long-text.tsx +200 -0
- package/client/components/permission-editor.tsx +534 -341
- package/client/locales/system/settings-en-US.ts +14 -3
- package/client/locales/system/settings-zh-CN.ts +13 -3
- package/client/pages/settings/default-access-form.tsx +10 -1
- package/client/pages/settings/default-access-model.ts +6 -2
- package/client/pages/settings/page-permissions.ts +166 -1
- package/client/pages/settings/permission-filters.tsx +9 -2
- package/client/pages/settings/permission-workspace.ts +330 -0
- package/client/pages/settings/permissions-model.ts +62 -53
- package/client/pages/settings/role-form.tsx +30 -94
- package/package.json +11 -11
- package/server/config/app.ts +1 -1
- package/server/config/session.ts +7 -5
- package/tests/components/default-access-editor.test.tsx +130 -0
- package/tests/components/permission-editor.test.tsx +613 -572
- package/tests/components/permission-filter-binding.test.tsx +20 -0
- package/tests/components/settings.test.tsx +33 -8
- package/tests/components/user-permissions.test.tsx +47 -13
- package/tests/logic/config.test.ts +3 -1
- package/tests/logic/page-permission-navigation.test.ts +394 -0
- package/tests/logic/permission-workspace.test.ts +570 -0
- package/tests/logic/session-isolation.test.ts +250 -0
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/** A controlled permission workspace. Filtering never changes the caller's draft. */
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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=
|
|
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='
|
|
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={
|
|
532
|
+
onCheckedChange={(on: boolean) => onEnabledChange(row.id, on)}
|
|
533
|
+
aria-describedby={describedBy}
|
|
274
534
|
/>
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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
|
|
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
|
-
:
|
|
629
|
+
: available[0]?.id;
|
|
348
630
|
const search = query.trim().toLocaleLowerCase();
|
|
349
631
|
const filtering = Boolean(search) || enabledOnly;
|
|
350
|
-
const
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
(
|
|
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
|
-
]
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
<div className='min-w-0 flex-1
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
)
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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 &&
|
|
796
|
+
group.accessOnly && group.rows.length === 1
|
|
560
797
|
? group.rows[0]
|
|
561
798
|
: undefined;
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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-
|
|
592
|
-
|
|
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
|
-
|
|
599
|
-
<h3 className='text-
|
|
600
|
-
{group.title
|
|
817
|
+
{!accessRow && group.title && (
|
|
818
|
+
<h3 className='text-base font-medium'>
|
|
819
|
+
{group.title}
|
|
601
820
|
</h3>
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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=
|
|
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
|
-
{
|
|
624
|
-
.
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
<
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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>
|