@nocobase/app-template-default 1.0.0-beta.72 → 1.0.0-beta.74
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/AGENTS.md +1 -1
- package/README.MD +2 -0
- package/client/components/default-access-editor.tsx +183 -0
- package/client/components/permission-editor.tsx +216 -150
- package/client/components/role-list.tsx +61 -0
- package/client/lib/navigation-transform.ts +28 -1
- package/client/locales/system/settings-en-US.ts +73 -2
- package/client/locales/system/settings-zh-CN.ts +65 -2
- package/client/pages/settings/default-access-form.tsx +499 -0
- package/client/pages/settings/default-access-model.ts +154 -0
- package/client/pages/settings/default-access.tsx +407 -0
- package/client/pages/settings/permission-filters.tsx +43 -37
- package/client/pages/settings/permission-management-page.tsx +105 -0
- package/client/pages/settings/permissions-model.ts +37 -1
- package/client/pages/settings/role-form.tsx +46 -18
- package/client/pages/settings/roles.tsx +23 -43
- package/client/pages/settings/routes.ts +78 -19
- package/client/pages/settings/shared.tsx +3 -0
- package/client/pages/settings/users.tsx +1 -1
- package/client/routing/route-navigation.ts +21 -1
- package/package.json +22 -21
- package/tests/components/default-access.test.tsx +554 -0
- package/tests/components/page-harness.test.tsx +132 -112
- package/tests/components/permission-editor.test.tsx +233 -73
- package/tests/components/permission-management-tabs.test.tsx +128 -0
- package/tests/components/role-list.test.tsx +71 -0
- package/tests/components/roles.test.tsx +78 -0
- package/tests/components/user-permissions.test.tsx +12 -0
- package/tests/logic/client-routes.test.ts +8 -0
- package/tests/logic/default-access-model.test.ts +167 -0
- package/tests/logic/permission-management-routes.test.ts +64 -0
- package/tests/logic/settings-permissions.test.ts +89 -0
- package/vitest.config.ts +7 -6
|
@@ -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
|
-
/**
|
|
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
|
|
265
|
+
className='@container flex min-w-0 flex-col gap-1'
|
|
183
266
|
>
|
|
184
|
-
<div className='grid min-h-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
'@
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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-
|
|
542
|
+
className='flex flex-col gap-2 p-3'
|
|
421
543
|
>
|
|
422
|
-
|
|
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='
|
|
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=
|
|
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
|
|
471
|
-
<h3 className='
|
|
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
|
|
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(${
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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,61 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import { Link, type To } from 'react-router';
|
|
3
|
+
import { Badge } from '#components/ui/badge';
|
|
4
|
+
|
|
5
|
+
export interface RoleListRole {
|
|
6
|
+
readonly key: string;
|
|
7
|
+
readonly title: string;
|
|
8
|
+
readonly to: To;
|
|
9
|
+
readonly protected: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RoleListProps {
|
|
13
|
+
readonly roles: readonly RoleListRole[];
|
|
14
|
+
readonly labels: {
|
|
15
|
+
readonly protected: string;
|
|
16
|
+
readonly custom: string;
|
|
17
|
+
};
|
|
18
|
+
readonly ariaLabel?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A compact content list; the host owns authorization, loading and filtering. */
|
|
22
|
+
export function RoleList({
|
|
23
|
+
roles,
|
|
24
|
+
labels,
|
|
25
|
+
ariaLabel,
|
|
26
|
+
}: RoleListProps): ReactElement {
|
|
27
|
+
return (
|
|
28
|
+
<ul
|
|
29
|
+
aria-label={ariaLabel}
|
|
30
|
+
className='@container divide-y rounded-lg border bg-card [&>li:first-child]:rounded-t-lg [&>li:last-child]:rounded-b-lg'
|
|
31
|
+
>
|
|
32
|
+
{roles.map((role) => (
|
|
33
|
+
<li
|
|
34
|
+
key={role.key}
|
|
35
|
+
className='group/role grid min-h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-0.5 px-3 py-2 transition-colors hover:bg-muted/50 @xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_6rem]'
|
|
36
|
+
>
|
|
37
|
+
<Link
|
|
38
|
+
to={role.to}
|
|
39
|
+
className='-my-1 w-fit max-w-full min-w-0 truncate py-1 text-sm leading-5 font-medium text-foreground underline-offset-4 group-hover/role:underline'
|
|
40
|
+
title={role.title}
|
|
41
|
+
>
|
|
42
|
+
{role.title}
|
|
43
|
+
</Link>
|
|
44
|
+
<span
|
|
45
|
+
className='col-start-1 row-start-2 min-w-0 truncate text-xs leading-4 text-muted-foreground @xl:col-start-2 @xl:row-start-1'
|
|
46
|
+
title={role.key}
|
|
47
|
+
>
|
|
48
|
+
{role.key}
|
|
49
|
+
</span>
|
|
50
|
+
<Badge
|
|
51
|
+
variant='secondary'
|
|
52
|
+
data-size='sm'
|
|
53
|
+
className='col-start-2 row-span-2 row-start-1 shrink-0 justify-self-end @xl:col-start-3 @xl:row-span-1'
|
|
54
|
+
>
|
|
55
|
+
{role.protected ? labels.protected : labels.custom}
|
|
56
|
+
</Badge>
|
|
57
|
+
</li>
|
|
58
|
+
))}
|
|
59
|
+
</ul>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -4,5 +4,32 @@ import type { RouteNavigationItem } from '../routing/route-navigation.js';
|
|
|
4
4
|
export function transformNavigation(
|
|
5
5
|
items: RouteNavigationItem[],
|
|
6
6
|
): RouteNavigationItem[] {
|
|
7
|
-
return items;
|
|
7
|
+
return collapsePermissionNavigation(items);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** One sidebar entry opens the first readable tab; original routes and guards remain unchanged. */
|
|
11
|
+
export function collapsePermissionNavigation(
|
|
12
|
+
items: RouteNavigationItem[],
|
|
13
|
+
): RouteNavigationItem[] {
|
|
14
|
+
return items.map((item) => {
|
|
15
|
+
if (item.route.name !== 'permissionManagement')
|
|
16
|
+
return {
|
|
17
|
+
...item,
|
|
18
|
+
children: collapsePermissionNavigation([...item.children]),
|
|
19
|
+
};
|
|
20
|
+
const first = item.children[0];
|
|
21
|
+
if (!first) return item;
|
|
22
|
+
return {
|
|
23
|
+
route: {
|
|
24
|
+
...first.route,
|
|
25
|
+
name: item.route.name,
|
|
26
|
+
navigation: item.route.navigation,
|
|
27
|
+
},
|
|
28
|
+
children: [],
|
|
29
|
+
activeRouteKeys: item.children.map(
|
|
30
|
+
(child) =>
|
|
31
|
+
`${child.route.packageName}:${child.route.path}:${child.route.id}`,
|
|
32
|
+
),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
8
35
|
}
|