@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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { useNavigate, useResolvedPath, useLocation } from 'react-router';
|
|
3
|
+
import { useCan } from '@nocobase/app-plugin-authorization/client';
|
|
4
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
5
|
+
import { PageContainer } from '#components/page-container';
|
|
6
|
+
import { PageHeader } from '#components/page-header';
|
|
7
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from '#components/ui/tabs';
|
|
8
|
+
|
|
9
|
+
export function PermissionManagementPage({
|
|
10
|
+
tab,
|
|
11
|
+
actions,
|
|
12
|
+
children,
|
|
13
|
+
}: {
|
|
14
|
+
tab: 'roles' | 'default-access';
|
|
15
|
+
actions?: ReactNode;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}): ReactElement {
|
|
18
|
+
const { t } = useTranslation();
|
|
19
|
+
const navigate = useNavigate();
|
|
20
|
+
const location = useLocation();
|
|
21
|
+
const activeRef = useRef<HTMLButtonElement>(null);
|
|
22
|
+
const focusedRef = useRef(false);
|
|
23
|
+
const current = useResolvedPath('.').pathname;
|
|
24
|
+
const prefix = current.slice(0, current.lastIndexOf('/'));
|
|
25
|
+
const roles = useCan({
|
|
26
|
+
resource: { type: 'settings', id: 'authorization.permission-sets' },
|
|
27
|
+
action: 'read',
|
|
28
|
+
}).can;
|
|
29
|
+
const defaults = useCan({
|
|
30
|
+
resource: { type: 'settings', id: 'authorization.default-access' },
|
|
31
|
+
action: 'read',
|
|
32
|
+
}).can;
|
|
33
|
+
// Route changes remount each existing page; keep keyboard focus on the selected tab.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (
|
|
36
|
+
!focusedRef.current &&
|
|
37
|
+
location.state &&
|
|
38
|
+
typeof location.state === 'object' &&
|
|
39
|
+
Reflect.get(location.state, 'permissionTabNavigation') === true &&
|
|
40
|
+
activeRef.current
|
|
41
|
+
) {
|
|
42
|
+
const frame = requestAnimationFrame(() => {
|
|
43
|
+
if (activeRef.current) {
|
|
44
|
+
const selected = activeRef.current;
|
|
45
|
+
if (
|
|
46
|
+
!selected
|
|
47
|
+
.closest('[role="tablist"]')
|
|
48
|
+
?.contains(selected.ownerDocument.activeElement)
|
|
49
|
+
)
|
|
50
|
+
selected.focus();
|
|
51
|
+
focusedRef.current = true;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return () => cancelAnimationFrame(frame);
|
|
55
|
+
}
|
|
56
|
+
}, [location.state, roles, defaults]);
|
|
57
|
+
return (
|
|
58
|
+
<PageContainer>
|
|
59
|
+
<PageHeader
|
|
60
|
+
title={t('settings.permissionManagement')}
|
|
61
|
+
description={t(
|
|
62
|
+
tab === 'roles'
|
|
63
|
+
? 'settings.rolesDescription'
|
|
64
|
+
: 'settings.defaultAccess.description',
|
|
65
|
+
)}
|
|
66
|
+
actions={actions}
|
|
67
|
+
/>
|
|
68
|
+
<Tabs
|
|
69
|
+
className='gap-6'
|
|
70
|
+
value={tab}
|
|
71
|
+
onValueChange={(value) => {
|
|
72
|
+
if (value === 'roles' || value === 'default-access')
|
|
73
|
+
void navigate(`${prefix}/${value}`, {
|
|
74
|
+
state: { permissionTabNavigation: true },
|
|
75
|
+
});
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<TabsList
|
|
79
|
+
variant='line'
|
|
80
|
+
aria-label={t('settings.permissionManagement')}
|
|
81
|
+
>
|
|
82
|
+
{roles && (
|
|
83
|
+
<TabsTrigger
|
|
84
|
+
ref={tab === 'roles' ? activeRef : undefined}
|
|
85
|
+
value='roles'
|
|
86
|
+
>
|
|
87
|
+
{t('settings.roles')}
|
|
88
|
+
</TabsTrigger>
|
|
89
|
+
)}
|
|
90
|
+
{defaults && (
|
|
91
|
+
<TabsTrigger
|
|
92
|
+
ref={tab === 'default-access' ? activeRef : undefined}
|
|
93
|
+
value='default-access'
|
|
94
|
+
>
|
|
95
|
+
{t('settings.defaultAccess.title')}
|
|
96
|
+
</TabsTrigger>
|
|
97
|
+
)}
|
|
98
|
+
</TabsList>
|
|
99
|
+
<TabsContent value={tab} className='space-y-6'>
|
|
100
|
+
{children}
|
|
101
|
+
</TabsContent>
|
|
102
|
+
</Tabs>
|
|
103
|
+
</PageContainer>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -60,19 +60,43 @@ export function setPermission(
|
|
|
60
60
|
return next.filter((item) => item.actions.length);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
const sectionDescriptions: Readonly<Record<string, string>> = {
|
|
64
|
+
pages: 'pageAccessHint',
|
|
65
|
+
business: 'businessOperationsHint',
|
|
66
|
+
users: 'userAccountsHint',
|
|
67
|
+
administration: 'systemToolsHint',
|
|
68
|
+
};
|
|
69
|
+
|
|
63
70
|
export function permissionSections(
|
|
64
71
|
catalog: AuthorizationOptionsResponse,
|
|
65
72
|
grants: readonly PermissionGrant[],
|
|
66
73
|
text: (value: LocalizedText) => string,
|
|
67
74
|
): readonly PermissionEditorSection[] {
|
|
75
|
+
const sharedActionHint = (resource: CatalogResource): string | undefined => {
|
|
76
|
+
const first = resource.actions[0]?.description;
|
|
77
|
+
if (!first) return undefined;
|
|
78
|
+
const hint = text(first);
|
|
79
|
+
return resource.actions.every(
|
|
80
|
+
(action) => action.description && text(action.description) === hint,
|
|
81
|
+
)
|
|
82
|
+
? hint
|
|
83
|
+
: undefined;
|
|
84
|
+
};
|
|
68
85
|
return catalog.sections
|
|
69
86
|
.map((section) => ({
|
|
70
87
|
id: section.name,
|
|
71
88
|
title: text(section.title),
|
|
89
|
+
description: sectionDescriptions[section.name]
|
|
90
|
+
? text({ key: `settings.${sectionDescriptions[section.name]}` })
|
|
91
|
+
: undefined,
|
|
72
92
|
groups: section.subsections.flatMap((subsection) =>
|
|
73
93
|
subsection.resources.map((resource) => ({
|
|
74
94
|
id: JSON.stringify([subsection.name, resource.type, resource.id]),
|
|
75
95
|
title: text(resource.title),
|
|
96
|
+
actionHint:
|
|
97
|
+
resource.actions.length > 1
|
|
98
|
+
? sharedActionHint(resource)
|
|
99
|
+
: undefined,
|
|
76
100
|
accessOnly:
|
|
77
101
|
resource.type === 'page' &&
|
|
78
102
|
resource.actions.length === 1 &&
|
|
@@ -89,6 +113,11 @@ export function permissionSections(
|
|
|
89
113
|
const row = {
|
|
90
114
|
id: permissionRowId(resource, action.name),
|
|
91
115
|
label: text(action.title),
|
|
116
|
+
hint:
|
|
117
|
+
action.description &&
|
|
118
|
+
!(resource.actions.length > 1 && sharedActionHint(resource))
|
|
119
|
+
? text(action.description)
|
|
120
|
+
: undefined,
|
|
92
121
|
actionKey: action.name,
|
|
93
122
|
enabled: Boolean(granted),
|
|
94
123
|
};
|
|
@@ -143,6 +172,10 @@ export function permissionSections(
|
|
|
143
172
|
})),
|
|
144
173
|
],
|
|
145
174
|
level: value,
|
|
175
|
+
hint:
|
|
176
|
+
value === ''
|
|
177
|
+
? text({ key: 'settings.noAdditionalScopeHint' })
|
|
178
|
+
: undefined,
|
|
146
179
|
};
|
|
147
180
|
})
|
|
148
181
|
: [],
|
|
@@ -203,7 +236,10 @@ export function setPermissions(
|
|
|
203
236
|
const [type, resourceId, action, scope] = JSON.parse(id) as string[];
|
|
204
237
|
if (scope) return current;
|
|
205
238
|
const resource = resources.find(
|
|
206
|
-
(item) =>
|
|
239
|
+
(item) =>
|
|
240
|
+
item.type === type &&
|
|
241
|
+
item.id === resourceId &&
|
|
242
|
+
item.actions.some((entry) => entry.name === action),
|
|
207
243
|
);
|
|
208
244
|
if (
|
|
209
245
|
!resource ||
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
12
12
|
import {
|
|
13
13
|
useAuthorizationClient,
|
|
14
|
+
useCan,
|
|
14
15
|
type AuthorizationOptionsResponse,
|
|
15
16
|
type LocalizedText,
|
|
16
17
|
type PermissionGrant,
|
|
@@ -20,6 +21,7 @@ import { pagePermissionResources } from './page-permissions.js';
|
|
|
20
21
|
import { pagePermissionTitles } from './page-permission-titles.js';
|
|
21
22
|
import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
|
|
22
23
|
import {
|
|
24
|
+
Link,
|
|
23
25
|
useLocation,
|
|
24
26
|
useNavigate,
|
|
25
27
|
useOutletContext,
|
|
@@ -72,6 +74,10 @@ function RoleEditor(): ReactElement {
|
|
|
72
74
|
const options = useSettingsData<{ data: AuthorizationOptionsResponse }>(
|
|
73
75
|
'authorization/permissionSets/options',
|
|
74
76
|
);
|
|
77
|
+
const canReadDefaults = useCan({
|
|
78
|
+
resource: { type: 'settings', id: 'authorization.default-access' },
|
|
79
|
+
action: 'read',
|
|
80
|
+
}).can;
|
|
75
81
|
const canCreate = useRolePermission('create');
|
|
76
82
|
const canUpdate = useRolePermission('update');
|
|
77
83
|
const [nameDraft, setNameDraft] = useState<string>();
|
|
@@ -113,14 +119,35 @@ function RoleEditor(): ReactElement {
|
|
|
113
119
|
title: { key: 'settings.userPermissions' },
|
|
114
120
|
order: 0,
|
|
115
121
|
subsections: [
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
...[
|
|
123
|
+
{
|
|
124
|
+
name: 'users.information',
|
|
125
|
+
title: 'userInformation',
|
|
126
|
+
actions: ['read', 'create', 'update', 'invite', 'delete'],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'users.security',
|
|
130
|
+
title: 'accountSecurity',
|
|
131
|
+
actions: [
|
|
132
|
+
'reset-password',
|
|
133
|
+
'disable',
|
|
134
|
+
'enable',
|
|
135
|
+
'revoke-sessions',
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'users.roles',
|
|
140
|
+
title: 'roleAssignment',
|
|
141
|
+
actions: ['assign-role'],
|
|
142
|
+
},
|
|
143
|
+
].map((group) => ({
|
|
144
|
+
name: group.name,
|
|
145
|
+
title: { key: `settings.${group.title}` },
|
|
119
146
|
resources: [
|
|
120
147
|
{
|
|
121
148
|
type: 'user',
|
|
122
149
|
id: '*',
|
|
123
|
-
title: { key:
|
|
150
|
+
title: { key: `settings.${group.title}` },
|
|
124
151
|
actions: [
|
|
125
152
|
{ name: 'read', title: { key: 'settings.readUsers' } },
|
|
126
153
|
{ name: 'create', title: { key: 'settings.createUsers' } },
|
|
@@ -141,10 +168,10 @@ function RoleEditor(): ReactElement {
|
|
|
141
168
|
name: 'revoke-sessions',
|
|
142
169
|
title: { key: 'settings.revokeSessions' },
|
|
143
170
|
},
|
|
144
|
-
],
|
|
171
|
+
].filter((action) => group.actions.includes(action.name)),
|
|
145
172
|
},
|
|
146
173
|
],
|
|
147
|
-
},
|
|
174
|
+
})),
|
|
148
175
|
],
|
|
149
176
|
},
|
|
150
177
|
...source.sections.map((section) => ({
|
|
@@ -316,13 +343,23 @@ function RoleEditor(): ReactElement {
|
|
|
316
343
|
{t('settings.protectedHint')}
|
|
317
344
|
</p>
|
|
318
345
|
)}
|
|
346
|
+
{canReadDefaults && (
|
|
347
|
+
<div>
|
|
348
|
+
<Link
|
|
349
|
+
to='/default-access'
|
|
350
|
+
className='text-sm text-foreground underline-offset-4 hover:underline'
|
|
351
|
+
>
|
|
352
|
+
{t('settings.defaultAccess.manage')}
|
|
353
|
+
</Link>
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
319
356
|
<PermissionEditor
|
|
320
357
|
sections={sections}
|
|
321
358
|
readOnly={!allowed || busy}
|
|
322
359
|
labels={{
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
360
|
+
dataRanges: (count) => t('settings.dataRanges', { count }),
|
|
361
|
+
scopeExplanation: t('settings.scopeExplanation'),
|
|
362
|
+
scopeHintFor: (name) => t('settings.scopeHintFor', { name }),
|
|
326
363
|
levelFor: (name) => t('settings.levelFor', { name }),
|
|
327
364
|
search: t('settings.permissionSearch'),
|
|
328
365
|
enabledOnly: t('settings.enabledOnly'),
|
|
@@ -330,8 +367,6 @@ function RoleEditor(): ReactElement {
|
|
|
330
367
|
empty: t('settings.noPermissions'),
|
|
331
368
|
noResults: t('settings.noResults'),
|
|
332
369
|
navigation: t('settings.permissionCategories'),
|
|
333
|
-
summary: (enabled, total) =>
|
|
334
|
-
t('settings.permissionSummary', { enabled, total }),
|
|
335
370
|
}}
|
|
336
371
|
onEnabledChange={(id, enabled) => {
|
|
337
372
|
if (!catalog) return;
|
|
@@ -342,13 +377,6 @@ function RoleEditor(): ReactElement {
|
|
|
342
377
|
);
|
|
343
378
|
setDirty(true);
|
|
344
379
|
}}
|
|
345
|
-
onEnabledBatchChange={(changes) => {
|
|
346
|
-
if (!catalog) return;
|
|
347
|
-
setGrantsDraft((current) =>
|
|
348
|
-
setPermissions(current ?? grants, catalog, changes),
|
|
349
|
-
);
|
|
350
|
-
setDirty(true);
|
|
351
|
-
}}
|
|
352
380
|
onLevelChange={(id, value) => {
|
|
353
381
|
if (value === 'customFilter') {
|
|
354
382
|
setEditingScope(id);
|
|
@@ -7,11 +7,10 @@ import type {
|
|
|
7
7
|
import { type ReactElement } from 'react';
|
|
8
8
|
import { Link, Outlet, useLocation, useSearchParams } from 'react-router';
|
|
9
9
|
import { ShieldCheckIcon, PlusIcon } from 'lucide-react';
|
|
10
|
-
import {
|
|
11
|
-
import { PageHeader } from '#components/page-header';
|
|
10
|
+
import { PermissionManagementPage } from './permission-management-page.js';
|
|
12
11
|
import { Input } from '#components/ui/input';
|
|
13
12
|
import { Button } from '#components/ui/button';
|
|
14
|
-
import {
|
|
13
|
+
import { RoleList } from '#components/role-list';
|
|
15
14
|
import {
|
|
16
15
|
Empty,
|
|
17
16
|
EmptyTitle,
|
|
@@ -60,12 +59,10 @@ export default function RolesPage(): ReactElement {
|
|
|
60
59
|
) : null;
|
|
61
60
|
return (
|
|
62
61
|
<>
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
actions={rows.length || search ? create : undefined}
|
|
68
|
-
/>
|
|
62
|
+
<PermissionManagementPage
|
|
63
|
+
tab='roles'
|
|
64
|
+
actions={rows.length || search ? create : undefined}
|
|
65
|
+
>
|
|
69
66
|
<div className='flex items-center gap-3'>
|
|
70
67
|
<Input
|
|
71
68
|
className='sm:max-w-xs'
|
|
@@ -95,39 +92,22 @@ export default function RolesPage(): ReactElement {
|
|
|
95
92
|
) : (
|
|
96
93
|
!request.error &&
|
|
97
94
|
(rows.length ? (
|
|
98
|
-
<
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
{text(role.title ?? role.key)}
|
|
115
|
-
</p>
|
|
116
|
-
<p className='text-xs text-muted-foreground'>
|
|
117
|
-
{role.key}
|
|
118
|
-
</p>
|
|
119
|
-
</div>
|
|
120
|
-
</div>
|
|
121
|
-
<Badge variant='secondary'>
|
|
122
|
-
{t(
|
|
123
|
-
role.protection
|
|
124
|
-
? 'settings.protected'
|
|
125
|
-
: 'settings.custom',
|
|
126
|
-
)}
|
|
127
|
-
</Badge>
|
|
128
|
-
</Link>
|
|
129
|
-
))}
|
|
130
|
-
</div>
|
|
95
|
+
<RoleList
|
|
96
|
+
ariaLabel={t('settings.roles')}
|
|
97
|
+
roles={rows.map((role) => ({
|
|
98
|
+
key: role.key,
|
|
99
|
+
title: text(role.title ?? role.key),
|
|
100
|
+
to: {
|
|
101
|
+
pathname: `${encodeURIComponent(role.key)}/edit`,
|
|
102
|
+
search: location.search,
|
|
103
|
+
},
|
|
104
|
+
protected: Boolean(role.protection),
|
|
105
|
+
}))}
|
|
106
|
+
labels={{
|
|
107
|
+
protected: t('settings.protected'),
|
|
108
|
+
custom: t('settings.custom'),
|
|
109
|
+
}}
|
|
110
|
+
/>
|
|
131
111
|
) : (
|
|
132
112
|
<Empty className='min-h-48 border border-dashed'>
|
|
133
113
|
<EmptyMedia variant='icon'>
|
|
@@ -156,7 +136,7 @@ export default function RolesPage(): ReactElement {
|
|
|
156
136
|
</Empty>
|
|
157
137
|
))
|
|
158
138
|
)}
|
|
159
|
-
</
|
|
139
|
+
</PermissionManagementPage>
|
|
160
140
|
<Outlet context={{ reload: request.reload }} />
|
|
161
141
|
</>
|
|
162
142
|
);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Settings,
|
|
3
|
+
Users,
|
|
4
|
+
ShieldCheck,
|
|
5
|
+
KeyRound,
|
|
6
|
+
ListFilter,
|
|
7
|
+
} from 'lucide-react';
|
|
2
8
|
import {
|
|
3
9
|
defineAppRoutes,
|
|
4
10
|
type AppClientRouteContribution,
|
|
@@ -41,35 +47,88 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
41
47
|
],
|
|
42
48
|
},
|
|
43
49
|
{
|
|
44
|
-
name: '
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
resource: { type: 'settings', id: 'authorization.permission-sets' },
|
|
49
|
-
action: 'read',
|
|
50
|
+
name: 'permissionManagement',
|
|
51
|
+
navigation: {
|
|
52
|
+
title: 'settings.permissionManagement',
|
|
53
|
+
icon: ShieldCheck,
|
|
50
54
|
},
|
|
51
|
-
|
|
52
|
-
breadcrumb: { title: 'settings.roles' },
|
|
53
|
-
componentLoader: () => import('./roles.js'),
|
|
55
|
+
breadcrumb: { title: 'settings.permissionManagement' },
|
|
54
56
|
children: [
|
|
55
57
|
{
|
|
56
|
-
name: '
|
|
57
|
-
path: '
|
|
58
|
+
name: 'settingsRoles',
|
|
59
|
+
path: '/roles',
|
|
60
|
+
auth: 'required',
|
|
58
61
|
authz: {
|
|
59
62
|
resource: {
|
|
60
63
|
type: 'settings',
|
|
61
64
|
id: 'authorization.permission-sets',
|
|
62
65
|
},
|
|
63
|
-
action: '
|
|
66
|
+
action: 'read',
|
|
64
67
|
},
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
navigation: { title: 'settings.roles', icon: KeyRound },
|
|
69
|
+
breadcrumb: { title: 'settings.roles' },
|
|
70
|
+
componentLoader: () => import('./roles.js'),
|
|
71
|
+
children: [
|
|
72
|
+
{
|
|
73
|
+
name: 'settingsRoleCreate',
|
|
74
|
+
path: 'create',
|
|
75
|
+
authz: {
|
|
76
|
+
resource: {
|
|
77
|
+
type: 'settings',
|
|
78
|
+
id: 'authorization.permission-sets',
|
|
79
|
+
},
|
|
80
|
+
action: 'create',
|
|
81
|
+
},
|
|
82
|
+
breadcrumb: { title: 'settings.newRole' },
|
|
83
|
+
componentLoader: () => import('./role-form.js'),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'settingsRoleEdit',
|
|
87
|
+
path: ':key/edit',
|
|
88
|
+
breadcrumb: { title: 'settings.editRole' },
|
|
89
|
+
componentLoader: () => import('./role-form.js'),
|
|
90
|
+
},
|
|
91
|
+
],
|
|
67
92
|
},
|
|
68
93
|
{
|
|
69
|
-
name: '
|
|
70
|
-
path: '
|
|
71
|
-
|
|
72
|
-
|
|
94
|
+
name: 'settingsDefaultAccess',
|
|
95
|
+
path: '/default-access',
|
|
96
|
+
auth: 'required',
|
|
97
|
+
authz: {
|
|
98
|
+
resource: {
|
|
99
|
+
type: 'settings',
|
|
100
|
+
id: 'authorization.default-access',
|
|
101
|
+
},
|
|
102
|
+
action: 'read',
|
|
103
|
+
},
|
|
104
|
+
navigation: {
|
|
105
|
+
title: 'settings.defaultAccess.title',
|
|
106
|
+
icon: ListFilter,
|
|
107
|
+
},
|
|
108
|
+
breadcrumb: { title: 'settings.defaultAccess.title' },
|
|
109
|
+
componentLoader: () => import('./default-access.js'),
|
|
110
|
+
children: [
|
|
111
|
+
{
|
|
112
|
+
name: 'settingsDefaultAccessCreate',
|
|
113
|
+
path: 'create',
|
|
114
|
+
authz: {
|
|
115
|
+
resource: {
|
|
116
|
+
type: 'settings',
|
|
117
|
+
id: 'authorization.default-access',
|
|
118
|
+
},
|
|
119
|
+
action: 'create',
|
|
120
|
+
},
|
|
121
|
+
breadcrumb: { title: 'settings.defaultAccess.new' },
|
|
122
|
+
componentLoader: () => import('./default-access-form.js'),
|
|
123
|
+
},
|
|
124
|
+
// Read permission opens the full rule even when the viewer may not update it.
|
|
125
|
+
{
|
|
126
|
+
name: 'settingsDefaultAccessEdit',
|
|
127
|
+
path: ':key/edit',
|
|
128
|
+
breadcrumb: { title: 'settings.defaultAccess.edit' },
|
|
129
|
+
componentLoader: () => import('./default-access-form.js'),
|
|
130
|
+
},
|
|
131
|
+
],
|
|
73
132
|
},
|
|
74
133
|
],
|
|
75
134
|
},
|
|
@@ -78,6 +78,7 @@ export function ConfirmAction({
|
|
|
78
78
|
title,
|
|
79
79
|
description,
|
|
80
80
|
confirmLabel,
|
|
81
|
+
error,
|
|
81
82
|
busy,
|
|
82
83
|
onCancel,
|
|
83
84
|
onConfirm,
|
|
@@ -86,6 +87,7 @@ export function ConfirmAction({
|
|
|
86
87
|
readonly title: string;
|
|
87
88
|
readonly description: ReactNode;
|
|
88
89
|
readonly confirmLabel?: string;
|
|
90
|
+
readonly error?: ReactNode;
|
|
89
91
|
readonly busy: boolean;
|
|
90
92
|
readonly onCancel: () => void;
|
|
91
93
|
readonly onConfirm: () => void;
|
|
@@ -102,6 +104,7 @@ export function ConfirmAction({
|
|
|
102
104
|
<AlertDialogHeader>
|
|
103
105
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
104
106
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
|
107
|
+
{error}
|
|
105
108
|
</AlertDialogHeader>
|
|
106
109
|
<AlertDialogFooter>
|
|
107
110
|
<AlertDialogCancel disabled={busy}>
|
|
@@ -250,7 +250,7 @@ export default function UsersPage(): ReactElement {
|
|
|
250
250
|
enableHiding: false,
|
|
251
251
|
cell: ({ row }) => (
|
|
252
252
|
<Link
|
|
253
|
-
className='font-medium text-
|
|
253
|
+
className='font-medium text-foreground underline-offset-4 hover:underline'
|
|
254
254
|
to={{
|
|
255
255
|
pathname: row.original.id,
|
|
256
256
|
search: location.search,
|
|
@@ -11,6 +11,8 @@ import { EMPTY_ARRAY } from '#lib/constants';
|
|
|
11
11
|
import { transformNavigation } from '#lib/navigation-transform';
|
|
12
12
|
|
|
13
13
|
export interface RouteNavigationItem {
|
|
14
|
+
/** Route keys represented by a single sidebar entry whose content uses tabs. */
|
|
15
|
+
readonly activeRouteKeys?: readonly string[];
|
|
14
16
|
readonly section?: boolean;
|
|
15
17
|
readonly route: AppClientRegisteredRoute;
|
|
16
18
|
readonly children: readonly RouteNavigationItem[];
|
|
@@ -45,7 +47,25 @@ export function selectedNavigationId(
|
|
|
45
47
|
denied: ReadonlySet<string> = new Set(),
|
|
46
48
|
): string | undefined {
|
|
47
49
|
const matches = matchRouteTree(routes, pathname);
|
|
48
|
-
const
|
|
50
|
+
const items = buildRouteNavigation(routes, denied);
|
|
51
|
+
const represented = (
|
|
52
|
+
nodes: readonly RouteNavigationItem[],
|
|
53
|
+
): AppClientRegisteredRoute | undefined => {
|
|
54
|
+
for (const item of nodes) {
|
|
55
|
+
if (
|
|
56
|
+
item.activeRouteKeys?.some((key) =>
|
|
57
|
+
matches?.some(({ route }) => routeKey(route) === key),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return item.route;
|
|
61
|
+
const child = represented(item.children);
|
|
62
|
+
if (child) return child;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
};
|
|
66
|
+
const active = represented(items);
|
|
67
|
+
if (active) return routeKey(active);
|
|
68
|
+
const pages = navigationPages(items);
|
|
49
69
|
const visible = new Set(pages.map(routeKey));
|
|
50
70
|
const selected =
|
|
51
71
|
matches
|