@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.
- package/.agents/rules/components.md +2 -2
- package/.agents/rules/shell.md +5 -1
- package/AGENTS.md +5 -3
- package/Dockerfile.dockerignore +0 -1
- package/README.MD +3 -1
- package/client/app.ts +15 -0
- package/client/components/data-table/column-header.tsx +91 -0
- package/client/components/data-table/data-table.tsx +216 -0
- package/client/components/data-table/index.ts +13 -0
- package/client/components/data-table/pagination.tsx +165 -0
- package/client/components/data-table/view-options.tsx +77 -0
- package/client/components/page-header.tsx +1 -1
- package/client/components/permission-editor.tsx +589 -0
- package/client/components/permission-filter.tsx +455 -0
- package/client/components/route-overlay.tsx +26 -13
- package/client/components/status-badge.tsx +7 -1
- package/client/components/ui/checkbox.tsx +25 -0
- package/client/components/ui/sidebar.tsx +1 -4
- package/client/components/ui/switch.tsx +30 -0
- package/client/components/ui/table.tsx +113 -0
- package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
- package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
- package/client/index.tsx +4 -0
- package/client/layouts/components/app-sidebar.tsx +6 -6
- package/client/layouts/components/layout-header.tsx +1 -1
- package/client/layouts/components/navigation-menu.tsx +223 -12
- package/client/layouts/components/sidebar-footer.tsx +1 -1
- package/client/layouts/components/user-menu.tsx +1 -1
- package/client/layouts/use-sidebar-preference.ts +62 -42
- package/client/lib/navigation-transform.ts +8 -0
- package/client/lib/scrollbar.ts +88 -0
- package/client/locales/system/en-US.ts +11 -0
- package/client/locales/system/settings-en-US.ts +141 -0
- package/client/locales/system/settings-zh-CN.ts +135 -0
- package/client/locales/system/zh-CN.ts +22 -12
- package/client/pages/auth/auth-page.css +52 -0
- package/client/pages/auth/login.tsx +1 -1
- package/client/pages/auth/reset-password.tsx +57 -14
- package/client/pages/auth/shared.tsx +26 -24
- package/client/pages/home/index.tsx +2 -2
- package/client/pages/settings/hooks.tsx +137 -0
- package/client/pages/settings/permission-filters.tsx +119 -0
- package/client/pages/settings/permissions-model.ts +260 -0
- package/client/pages/settings/role-form.tsx +426 -0
- package/client/pages/settings/roles.tsx +163 -0
- package/client/pages/settings/routes.ts +79 -0
- package/client/pages/settings/shared.tsx +119 -0
- package/client/pages/settings/user-detail.tsx +268 -0
- package/client/pages/settings/user-form.tsx +427 -0
- package/client/pages/settings/users.tsx +451 -0
- package/client/routing/app-router.tsx +4 -1
- package/client/routing/route-navigation.ts +10 -1
- package/client/routing/session-preparation.tsx +65 -0
- package/client/styles.css +257 -24
- package/client/theme/theme-settings.tsx +12 -7
- package/client/theme/themes/compact.css +9 -14
- package/client/theme/themes/default.css +7 -12
- package/package.json +26 -25
- package/server/app.ts +2 -0
- package/server/config/notification.ts +4 -7
- package/tests/components/app-layout-routes.test.tsx +14 -7
- package/tests/components/auth-i18n.test.tsx +57 -5
- package/tests/components/data-table.test.tsx +127 -0
- package/tests/components/layout-i18n.test.tsx +10 -3
- package/tests/components/navigation-menu.test.tsx +155 -16
- package/tests/components/permission-editor.test.tsx +477 -0
- package/tests/components/permission-filter-binding.test.tsx +150 -0
- package/tests/components/permission-filter.test.tsx +202 -0
- package/tests/components/settings.test.tsx +294 -0
- package/tests/components/sidebar-permissions.test.tsx +17 -19
- package/tests/components/sidebar-preference.test.tsx +209 -14
- package/tests/components/status-badge.test.tsx +22 -0
- package/tests/components/user-permissions.test.tsx +142 -0
- package/tests/fixtures/theme-tokens.tsx +9 -1
- package/tests/logic/asset-url-build.test.ts +0 -1
- package/tests/logic/client-routes.test.ts +40 -0
- package/tests/logic/client-shell.test.tsx +10 -2
- package/tests/logic/config-example.test.ts +30 -0
- package/tests/logic/dev-client-watch.test.ts +8 -1
- package/tests/logic/route-overlay.test.tsx +42 -0
- package/tests/logic/scrollbar.test.ts +88 -0
- package/tests/logic/session-preparation.test.tsx +223 -0
- package/tests/logic/settings-permissions.test.ts +168 -0
- package/tests/logic/theme-tokens.test.ts +1 -0
- package/vite.config.ts +0 -22
- package/vitest.config.ts +1 -0
- 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-
|
|
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
|
+
}
|