@nocobase/app-template-default 1.0.0-beta.86 → 1.0.0-beta.88
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/notification-templates.md +5 -0
- package/.agents/rules/shell.md +4 -2
- package/AGENTS.md +3 -1
- package/README.MD +11 -5
- package/client/components/body-portal.tsx +25 -0
- package/client/components/breadcrumbs.tsx +13 -0
- package/client/components/flow-diagram.tsx +2 -2
- package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +1 -1
- package/client/layouts/app-layout.tsx +8 -4
- package/client/layouts/components/header-actions.tsx +0 -2
- package/client/layouts/components/sidebar-footer.tsx +18 -25
- package/client/layouts/components/user-menu.tsx +6 -13
- package/client/lib/navigation-transform.ts +156 -22
- package/client/locales/system/en-US.ts +8 -6
- package/client/locales/system/settings-en-US.ts +17 -0
- package/client/locales/system/settings-zh-CN.ts +15 -0
- package/client/locales/system/zh-CN.ts +8 -6
- package/client/pages/auth/register.tsx +39 -22
- package/client/pages/auth/shared.tsx +1 -3
- package/client/pages/settings/hooks.tsx +3 -68
- package/client/pages/settings/notification-routes.ts +109 -0
- package/client/pages/settings/notification-templates.tsx +26 -0
- package/client/pages/settings/permission-management-page.tsx +15 -90
- package/client/pages/settings/routes.ts +96 -98
- package/client/pages/settings/settings-home.tsx +109 -0
- package/client/pages/settings/settings-module-page.tsx +158 -0
- package/client/pages/settings/shell-routes.ts +53 -0
- package/client/routes.ts +17 -1
- package/client/routing/app-router.tsx +1 -14
- package/client/routing/client-route.tsx +16 -9
- package/client/routing/route-context.tsx +30 -3
- package/client/routing/route-leave-boundary.tsx +249 -0
- package/client/routing/route-navigation.ts +72 -8
- package/client/routing/route-tree.tsx +7 -12
- package/client/routing/standalone-page-layout.tsx +0 -5
- package/client/runtime.ts +2 -2
- package/client/theme/theme-settings.tsx +60 -162
- package/config.example.yml +51 -32
- package/package.json +28 -28
- package/server/config/auth.ts +2 -2
- package/server/config/index.ts +3 -0
- package/server/config/mailers.ts +11 -0
- package/server/providers/index.ts +2 -0
- package/server/providers/notification-management.ts +27 -0
- package/tests/client/account-permissions.test.tsx +1 -1
- package/tests/client/client-auth.test.tsx +10 -1
- package/tests/client/client-routes.test.ts +137 -4
- package/tests/client/client-shell.test.tsx +29 -10
- package/tests/client/client-theme.test.tsx +118 -39
- package/tests/client/foundation-integration.test.ts +38 -17
- package/tests/client/header-hover.test.tsx +33 -5
- package/tests/client/layout-i18n.test.tsx +9 -4
- package/tests/client/permission-management-tabs.test.tsx +421 -16
- package/tests/client/registration-feedback.test.tsx +143 -0
- package/tests/client/settings-home.test.tsx +110 -0
- package/tests/client/settings-navigation.test.ts +230 -0
- package/tests/client/standalone-page-layout.test.tsx +45 -0
- package/tests/client/user-menu-sign-out.test.tsx +38 -14
- package/tests/client/user-menu.test.tsx +65 -0
- package/tests/fixtures/theme-tokens.tsx +13 -1
- package/tests/server/auth-email-config.test.ts +51 -17
- package/tests/server/notification-templates.test.ts +144 -0
- package/tests/client/permission-management-routes.test.ts +0 -64
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
const settings = {
|
|
2
|
+
notificationManagementDescription: '查看已登记的通知模板和合成内容预览。',
|
|
3
|
+
notificationTemplates: '通知模板',
|
|
4
|
+
notificationManagement: '通知管理',
|
|
2
5
|
listChanged: '列表已变化,请重试。',
|
|
3
6
|
setup: {
|
|
4
7
|
title: '功能前置配置',
|
|
@@ -101,6 +104,18 @@ const settings = {
|
|
|
101
104
|
|
|
102
105
|
reload: '重新加载角色',
|
|
103
106
|
title: '系统设置',
|
|
107
|
+
home: {
|
|
108
|
+
description: '账号、权限、业务配置和系统能力都在这里。',
|
|
109
|
+
empty: '暂时没有你可以管理的设置。',
|
|
110
|
+
},
|
|
111
|
+
categories: {
|
|
112
|
+
account: '账号与权限',
|
|
113
|
+
business: '业务配置',
|
|
114
|
+
ai: 'AI',
|
|
115
|
+
system: '系统',
|
|
116
|
+
},
|
|
117
|
+
permissionManagementDescription:
|
|
118
|
+
'设置每个角色能打开的页面、能做的操作和能看到的数据。',
|
|
104
119
|
users: '用户管理',
|
|
105
120
|
roles: '角色与权限',
|
|
106
121
|
usersDescription: '管理用户账户、角色及登录会话。',
|
|
@@ -81,6 +81,9 @@ const systemZhCN: SystemResource = {
|
|
|
81
81
|
'auth.loginDescription': '使用用户名或邮箱和密码登录',
|
|
82
82
|
'auth.registerTitle': '创建账户',
|
|
83
83
|
'auth.registerDescription': '创建账户以开始使用',
|
|
84
|
+
'auth.registrationAccepted': '注册请求已提交',
|
|
85
|
+
'auth.registrationGuidance':
|
|
86
|
+
'请尝试使用刚填写的用户名和密码登录。如果此邮箱已有账户,新填写的信息不会生效,请使用原账户登录或找回密码。如应用提供了验证指引,请先完成验证。',
|
|
84
87
|
'auth.forgotTitle': '忘记密码',
|
|
85
88
|
'auth.forgotDescription': '输入邮箱,如果账户存在,我们将发送重置链接',
|
|
86
89
|
'auth.passwordResetUnavailable': '未开启自助找回,请联系管理员重置密码。',
|
|
@@ -120,9 +123,8 @@ const systemZhCN: SystemResource = {
|
|
|
120
123
|
'auth.continueWith': '使用 {provider} 继续',
|
|
121
124
|
'auth.about': '关于此应用',
|
|
122
125
|
'auth.platform': 'AI 原生应用平台',
|
|
123
|
-
'auth.marketingTitleFirst': '
|
|
124
|
-
'auth.marketingTitleSecond': '
|
|
125
|
-
'auth.marketingTitleThird': '可靠运行',
|
|
126
|
+
'auth.marketingTitleFirst': 'AI + NocoBase',
|
|
127
|
+
'auth.marketingTitleSecond': 'Build reliable business systems',
|
|
126
128
|
'auth.marketingDescription':
|
|
127
129
|
'让 AI 在灵活的前端框架上构建体验,由 NocoBase 保障底层数据、权限与治理',
|
|
128
130
|
'auth.frontend': 'AI 原生前端',
|
|
@@ -142,8 +144,8 @@ const systemZhCN: SystemResource = {
|
|
|
142
144
|
'status.routeFailedDescription':
|
|
143
145
|
'无法加载 {{packageName}} 的路由 {{label}}。',
|
|
144
146
|
shell: {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
brandPartnership: 'AI + <brand>NocoBase</brand>',
|
|
148
|
+
buildReliableSystems: 'Build reliable business systems',
|
|
147
149
|
},
|
|
148
150
|
home: {
|
|
149
151
|
platform: 'NocoBase · AI 原生应用平台',
|
|
@@ -344,7 +346,7 @@ const systemZhCN: SystemResource = {
|
|
|
344
346
|
},
|
|
345
347
|
|
|
346
348
|
appearance: {
|
|
347
|
-
title: '
|
|
349
|
+
title: '主题',
|
|
348
350
|
mode: '颜色模式',
|
|
349
351
|
preset: '主题',
|
|
350
352
|
light: '浅色',
|
|
@@ -5,6 +5,7 @@ import type { ReactElement } from 'react';
|
|
|
5
5
|
import { Link, Navigate } from 'react-router';
|
|
6
6
|
|
|
7
7
|
import { PasswordRegistrationForm } from '#extensions/nocobase-auth-forms/password-registration-form';
|
|
8
|
+
import { AuthFormStatus } from '#extensions/nocobase-auth-forms/form-parts';
|
|
8
9
|
|
|
9
10
|
import { AuthPage } from './shared.js';
|
|
10
11
|
|
|
@@ -17,30 +18,46 @@ export default function RegisterPage(): ReactElement {
|
|
|
17
18
|
|
|
18
19
|
return (
|
|
19
20
|
<AuthPage
|
|
20
|
-
description={t('auth.registerDescription')}
|
|
21
|
-
title={t(
|
|
21
|
+
description={registration.isSuccess ? '' : t('auth.registerDescription')}
|
|
22
|
+
title={t(
|
|
23
|
+
registration.isSuccess
|
|
24
|
+
? 'auth.registrationAccepted'
|
|
25
|
+
: 'auth.registerTitle',
|
|
26
|
+
)}
|
|
22
27
|
>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
{registration.isSuccess ? (
|
|
29
|
+
<div className='space-y-4'>
|
|
30
|
+
<AuthFormStatus type='success'>
|
|
31
|
+
{t('auth.registrationGuidance')}
|
|
32
|
+
</AuthFormStatus>
|
|
33
|
+
<p className='flex justify-center gap-4 text-sm'>
|
|
28
34
|
<Link to='/login'>{t('auth.signIn')}</Link>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
<Link to='/forgot-password'>{t('auth.forgotLink')}</Link>
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
) : (
|
|
39
|
+
<PasswordRegistrationForm
|
|
40
|
+
error={registration.error?.message}
|
|
41
|
+
footer={
|
|
42
|
+
<>
|
|
43
|
+
{t('auth.existingAccount')}{' '}
|
|
44
|
+
<Link to='/login'>{t('auth.signIn')}</Link>
|
|
45
|
+
</>
|
|
46
|
+
}
|
|
47
|
+
labels={{
|
|
48
|
+
confirmPassword: t('auth.confirmPassword'),
|
|
49
|
+
email: t('auth.email'),
|
|
50
|
+
name: t('auth.name'),
|
|
51
|
+
password: t('auth.password'),
|
|
52
|
+
passwordMismatch: t('auth.passwordMismatch'),
|
|
53
|
+
submit: t('auth.createAccount'),
|
|
54
|
+
submitting: t('auth.creatingAccount'),
|
|
55
|
+
username: t('auth.username'),
|
|
56
|
+
}}
|
|
57
|
+
onSubmit={registration.submit}
|
|
58
|
+
submitting={registration.isPending}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
44
61
|
</AuthPage>
|
|
45
62
|
);
|
|
46
63
|
}
|
|
@@ -78,12 +78,10 @@ function BrandPanel(): ReactElement {
|
|
|
78
78
|
<p className='text-xs font-semibold tracking-[0.16em] text-(--auth-brand-highlight) uppercase'>
|
|
79
79
|
{t('auth.platform')}
|
|
80
80
|
</p>
|
|
81
|
-
<h2 className='mt-6 text-
|
|
81
|
+
<h2 className='mt-6 text-3xl leading-tight font-semibold tracking-tight'>
|
|
82
82
|
{t('auth.marketingTitleFirst')}
|
|
83
83
|
<br />
|
|
84
84
|
{t('auth.marketingTitleSecond')}
|
|
85
|
-
<br />
|
|
86
|
-
{t('auth.marketingTitleThird')}
|
|
87
85
|
</h2>
|
|
88
86
|
<p className='mt-6 text-sm leading-7 text-(--auth-brand-muted)'>
|
|
89
87
|
{t('auth.marketingDescription')}
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import { useNavigationGuard } from '@nocobase/app-client';
|
|
2
1
|
import { useCan } from '@nocobase/app-plugin-authorization/client';
|
|
3
|
-
import { useTranslation } from '@nocobase/i18n/client';
|
|
4
|
-
import { useEffect, useRef, useState } from 'react';
|
|
5
|
-
import { useLocation } from 'react-router';
|
|
6
|
-
import { ConfirmAction } from './shared.js';
|
|
7
2
|
|
|
8
3
|
/** Compatibility entry for prepared settings pages and application-owned consumers. */
|
|
9
4
|
export { useListData as useSettingsData } from '#hooks/use-list-data';
|
|
@@ -17,66 +12,6 @@ export function useRolePermission(action: string) {
|
|
|
17
12
|
action,
|
|
18
13
|
}).can;
|
|
19
14
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
busy: boolean,
|
|
24
|
-
onDiscard?: () => void,
|
|
25
|
-
) {
|
|
26
|
-
const { t } = useTranslation();
|
|
27
|
-
const location = useLocation();
|
|
28
|
-
const allowedRef = useRef(false);
|
|
29
|
-
useEffect(() => {
|
|
30
|
-
allowedRef.current = false;
|
|
31
|
-
}, [location.key]);
|
|
32
|
-
const pendingRef = useRef<
|
|
33
|
-
{ promise: Promise<boolean>; resolve: (value: boolean) => void } | undefined
|
|
34
|
-
>(undefined);
|
|
35
|
-
const [open, setOpen] = useState(false);
|
|
36
|
-
const guard = () => {
|
|
37
|
-
if (allowedRef.current) return true;
|
|
38
|
-
if (busy) return false;
|
|
39
|
-
if (!dirty) return true;
|
|
40
|
-
if (pendingRef.current) return pendingRef.current.promise;
|
|
41
|
-
let resolve!: (value: boolean) => void;
|
|
42
|
-
const promise = new Promise<boolean>((done) => {
|
|
43
|
-
resolve = done;
|
|
44
|
-
});
|
|
45
|
-
pendingRef.current = { promise, resolve };
|
|
46
|
-
setOpen(true);
|
|
47
|
-
return promise;
|
|
48
|
-
};
|
|
49
|
-
useNavigationGuard(guard);
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
if (!dirty) return;
|
|
52
|
-
const handler = (event: BeforeUnloadEvent) => {
|
|
53
|
-
event.preventDefault();
|
|
54
|
-
event.returnValue = '';
|
|
55
|
-
};
|
|
56
|
-
window.addEventListener('beforeunload', handler);
|
|
57
|
-
return () => window.removeEventListener('beforeunload', handler);
|
|
58
|
-
}, [dirty]);
|
|
59
|
-
const decide = (value: boolean) => {
|
|
60
|
-
if (value) onDiscard?.();
|
|
61
|
-
allowedRef.current = value;
|
|
62
|
-
pendingRef.current?.resolve(value);
|
|
63
|
-
pendingRef.current = undefined;
|
|
64
|
-
setOpen(false);
|
|
65
|
-
};
|
|
66
|
-
return {
|
|
67
|
-
guard,
|
|
68
|
-
allow: () => {
|
|
69
|
-
allowedRef.current = true;
|
|
70
|
-
},
|
|
71
|
-
dialog: (
|
|
72
|
-
<ConfirmAction
|
|
73
|
-
open={open}
|
|
74
|
-
title={t('settings.discardTitle')}
|
|
75
|
-
description={t('settings.discardHint')}
|
|
76
|
-
busy={false}
|
|
77
|
-
onCancel={() => decide(false)}
|
|
78
|
-
onConfirm={() => decide(true)}
|
|
79
|
-
/>
|
|
80
|
-
),
|
|
81
|
-
};
|
|
82
|
-
}
|
|
15
|
+
|
|
16
|
+
/** Leave protection is owned by the shell while page Effects are suspended. */
|
|
17
|
+
export { usePageLeave as useSettingsLeave } from '../../routing/route-leave-boundary.js';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Bell, FileText, Settings } from 'lucide-react';
|
|
2
|
+
import {
|
|
3
|
+
defineAppRoutes,
|
|
4
|
+
type AppClientRouteContribution,
|
|
5
|
+
type AppClientRouteDefinition,
|
|
6
|
+
} from '@nocobase/app-client/plugins';
|
|
7
|
+
|
|
8
|
+
/** Enable once notification business scenarios are used. Reuse this module for logs and other real views. */
|
|
9
|
+
export const notificationTemplateRoute = {
|
|
10
|
+
name: 'notificationTemplates',
|
|
11
|
+
path: '/settings/notifications/templates',
|
|
12
|
+
auth: 'required' as const,
|
|
13
|
+
authz: {
|
|
14
|
+
resource: { type: 'page', id: 'notification.templates' },
|
|
15
|
+
action: 'access',
|
|
16
|
+
},
|
|
17
|
+
navigation: { title: 'settings.notificationTemplates', icon: FileText },
|
|
18
|
+
breadcrumb: { title: 'settings.notificationTemplates' },
|
|
19
|
+
componentLoader: () => import('./notification-templates.js'),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Add the template view once to the existing module, preserving logs, paths and grants. */
|
|
23
|
+
export function includeNotificationTemplateRoutes(
|
|
24
|
+
contributions: readonly AppClientRouteContribution[],
|
|
25
|
+
invitationConfigured: boolean,
|
|
26
|
+
): readonly AppClientRouteContribution[] {
|
|
27
|
+
const roots = contributions.flatMap((contribution) => contribution.routes);
|
|
28
|
+
const nodes = new Map<
|
|
29
|
+
string,
|
|
30
|
+
{ route: AppClientRouteDefinition; parent?: string }
|
|
31
|
+
>();
|
|
32
|
+
const collect = (
|
|
33
|
+
items: readonly AppClientRouteDefinition[],
|
|
34
|
+
parent?: string,
|
|
35
|
+
): void => {
|
|
36
|
+
for (const route of items) {
|
|
37
|
+
nodes.set(route.name, { route, parent: route.parent ?? parent });
|
|
38
|
+
collect(route.children ?? [], route.name);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
collect(roots);
|
|
42
|
+
const pathOf = (name: string): string => {
|
|
43
|
+
const node = nodes.get(name);
|
|
44
|
+
if (!node) return '';
|
|
45
|
+
const base = node.parent ? pathOf(node.parent) : '';
|
|
46
|
+
return `${base}/${node.route.path ?? ''}`
|
|
47
|
+
.replace(/\/{2,}/g, '/')
|
|
48
|
+
.replace(/\/$/, '');
|
|
49
|
+
};
|
|
50
|
+
const templateUnder = (base: string) => ({
|
|
51
|
+
...notificationTemplateRoute,
|
|
52
|
+
path:
|
|
53
|
+
base && notificationTemplateRoute.path.startsWith(`${base}/`)
|
|
54
|
+
? notificationTemplateRoute.path.slice(base.length)
|
|
55
|
+
: notificationTemplateRoute.path,
|
|
56
|
+
});
|
|
57
|
+
if (nodes.has('notificationTemplates')) return contributions;
|
|
58
|
+
if (nodes.has('notificationManagement')) {
|
|
59
|
+
const add = (
|
|
60
|
+
items: readonly AppClientRouteDefinition[],
|
|
61
|
+
): readonly AppClientRouteDefinition[] =>
|
|
62
|
+
items.map((route) => ({
|
|
63
|
+
...route,
|
|
64
|
+
...(route.name === 'notificationManagement'
|
|
65
|
+
? {
|
|
66
|
+
children: [
|
|
67
|
+
...(route.children ?? []),
|
|
68
|
+
templateUnder(pathOf('notificationManagement')),
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
: route.children
|
|
72
|
+
? { children: add(route.children) }
|
|
73
|
+
: {}),
|
|
74
|
+
}));
|
|
75
|
+
return contributions.map((contribution) =>
|
|
76
|
+
defineAppRoutes(add(contribution.routes)),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!invitationConfigured) return contributions;
|
|
80
|
+
const parent = nodes.has('settingsSystem') ? 'settingsSystem' : 'settings';
|
|
81
|
+
const module = {
|
|
82
|
+
name: 'notificationManagement',
|
|
83
|
+
navigation: {
|
|
84
|
+
title: 'settings.notificationManagement',
|
|
85
|
+
description: 'settings.notificationManagementDescription',
|
|
86
|
+
icon: Bell,
|
|
87
|
+
},
|
|
88
|
+
breadcrumb: { title: 'settings.notificationManagement' },
|
|
89
|
+
children: [templateUnder(pathOf(parent))],
|
|
90
|
+
};
|
|
91
|
+
if (nodes.has(parent))
|
|
92
|
+
return [
|
|
93
|
+
...contributions.slice(0, 1),
|
|
94
|
+
defineAppRoutes([{ ...module, parent }]),
|
|
95
|
+
...contributions.slice(1),
|
|
96
|
+
];
|
|
97
|
+
// A configured built-in invitation is business use even before other settings pages are enabled.
|
|
98
|
+
return [
|
|
99
|
+
...contributions,
|
|
100
|
+
defineAppRoutes([
|
|
101
|
+
{
|
|
102
|
+
name: 'settings',
|
|
103
|
+
navigation: { title: 'settings.title', icon: Settings, order: 100 },
|
|
104
|
+
breadcrumb: { title: 'settings.title' },
|
|
105
|
+
children: [module],
|
|
106
|
+
},
|
|
107
|
+
]),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import { useSearchParams } from 'react-router';
|
|
3
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
4
|
+
import { NotificationTemplates } from '@nocobase/app-plugin-notification/client/templates';
|
|
5
|
+
import { SettingsModulePage } from './settings-module-page.js';
|
|
6
|
+
|
|
7
|
+
export default function NotificationTemplatesPage(): ReactElement {
|
|
8
|
+
const { t } = useTranslation('@nocobase/app-plugin-notification');
|
|
9
|
+
const [query, setQuery] = useSearchParams();
|
|
10
|
+
return (
|
|
11
|
+
// A view of the Notifications settings module: it gains tabs once the module has another view, such as logs.
|
|
12
|
+
<SettingsModulePage title={t('templates.title')}>
|
|
13
|
+
<NotificationTemplates
|
|
14
|
+
selectedId={query.get('template')}
|
|
15
|
+
onSelect={(id) =>
|
|
16
|
+
setQuery((previous) => {
|
|
17
|
+
const next = new URLSearchParams(previous);
|
|
18
|
+
if (id) next.set('template', id);
|
|
19
|
+
else next.delete('template');
|
|
20
|
+
return next;
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
/>
|
|
24
|
+
</SettingsModulePage>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useNavigate, useResolvedPath, useLocation } from 'react-router';
|
|
3
|
-
import { useCan } from '@nocobase/app-plugin-authorization/client';
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
4
2
|
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
3
|
|
|
4
|
+
import { SettingsModulePage } from './settings-module-page.js';
|
|
5
|
+
|
|
6
|
+
/** Permission management: Roles and permissions and Default data scopes as tabs of one settings module. */
|
|
9
7
|
export function PermissionManagementPage({
|
|
10
8
|
tab,
|
|
11
9
|
actions,
|
|
@@ -16,90 +14,17 @@ export function PermissionManagementPage({
|
|
|
16
14
|
children: ReactNode;
|
|
17
15
|
}): ReactElement {
|
|
18
16
|
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
17
|
return (
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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>
|
|
18
|
+
<SettingsModulePage
|
|
19
|
+
title={t('settings.permissionManagement')}
|
|
20
|
+
description={t(
|
|
21
|
+
tab === 'roles'
|
|
22
|
+
? 'settings.rolesDescription'
|
|
23
|
+
: 'settings.defaultAccess.description',
|
|
24
|
+
)}
|
|
25
|
+
actions={actions}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</SettingsModulePage>
|
|
104
29
|
);
|
|
105
30
|
}
|