@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,134 +1,132 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Settings,
|
|
3
|
-
Users,
|
|
4
|
-
ShieldCheck,
|
|
5
|
-
KeyRound,
|
|
6
|
-
ListFilter,
|
|
7
|
-
} from 'lucide-react';
|
|
1
|
+
import { Users, ShieldCheck, KeyRound, ListFilter } from 'lucide-react';
|
|
8
2
|
import {
|
|
9
3
|
defineAppRoutes,
|
|
10
4
|
type AppClientRouteContribution,
|
|
11
5
|
} from '@nocobase/app-client/plugins';
|
|
12
6
|
|
|
13
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Users and Permission management, in the Accounts and permissions category of the System settings shell
|
|
9
|
+
* (`shell-routes.ts`). Register this contribution as business capabilities are developed; a bare application stays
|
|
10
|
+
* without them.
|
|
11
|
+
*/
|
|
14
12
|
const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
15
13
|
{
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
parent: 'settingsAccount',
|
|
15
|
+
name: 'settingsUsers',
|
|
16
|
+
path: '/users',
|
|
17
|
+
auth: 'required',
|
|
18
|
+
authz: { resource: { type: 'user', id: '*' }, action: 'read' },
|
|
19
|
+
navigation: {
|
|
20
|
+
title: 'settings.users',
|
|
21
|
+
description: 'settings.usersDescription',
|
|
22
|
+
icon: Users,
|
|
23
|
+
},
|
|
24
|
+
breadcrumb: { title: 'settings.users' },
|
|
25
|
+
componentLoader: () => import('./users.js'),
|
|
19
26
|
children: [
|
|
20
27
|
{
|
|
21
|
-
name: '
|
|
22
|
-
path: '
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
name: 'settingsUserCreate',
|
|
29
|
+
path: 'create',
|
|
30
|
+
authz: { resource: { type: 'user', id: '*' }, action: 'create' },
|
|
31
|
+
componentLoader: () => import('./user-form.js'),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'settingsUser',
|
|
35
|
+
path: ':userId',
|
|
36
|
+
componentLoader: () => import('./user-detail.js'),
|
|
28
37
|
children: [
|
|
29
38
|
{
|
|
30
|
-
name: '
|
|
31
|
-
path: '
|
|
32
|
-
authz: { resource: { type: 'user', id: '*' }, action: 'create' },
|
|
39
|
+
name: 'settingsUserEdit',
|
|
40
|
+
path: 'edit',
|
|
33
41
|
componentLoader: () => import('./user-form.js'),
|
|
34
42
|
},
|
|
35
|
-
{
|
|
36
|
-
name: 'settingsUser',
|
|
37
|
-
path: ':userId',
|
|
38
|
-
componentLoader: () => import('./user-detail.js'),
|
|
39
|
-
children: [
|
|
40
|
-
{
|
|
41
|
-
name: 'settingsUserEdit',
|
|
42
|
-
path: 'edit',
|
|
43
|
-
componentLoader: () => import('./user-form.js'),
|
|
44
|
-
},
|
|
45
|
-
],
|
|
46
|
-
},
|
|
47
43
|
],
|
|
48
44
|
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
parent: 'settingsAccount',
|
|
49
|
+
name: 'permissionManagement',
|
|
50
|
+
navigation: {
|
|
51
|
+
title: 'settings.permissionManagement',
|
|
52
|
+
description: 'settings.permissionManagementDescription',
|
|
53
|
+
icon: ShieldCheck,
|
|
54
|
+
},
|
|
55
|
+
breadcrumb: { title: 'settings.permissionManagement' },
|
|
56
|
+
children: [
|
|
49
57
|
{
|
|
50
|
-
name: '
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
name: 'settingsRoles',
|
|
59
|
+
path: '/roles',
|
|
60
|
+
auth: 'required',
|
|
61
|
+
authz: {
|
|
62
|
+
resource: {
|
|
63
|
+
type: 'settings',
|
|
64
|
+
id: 'authorization.permission-sets',
|
|
65
|
+
},
|
|
66
|
+
action: 'read',
|
|
54
67
|
},
|
|
55
|
-
|
|
68
|
+
navigation: { title: 'settings.roles', icon: KeyRound },
|
|
69
|
+
breadcrumb: { title: 'settings.roles' },
|
|
70
|
+
componentLoader: () => import('./roles.js'),
|
|
56
71
|
children: [
|
|
57
72
|
{
|
|
58
|
-
name: '
|
|
59
|
-
path: '
|
|
60
|
-
auth: 'required',
|
|
73
|
+
name: 'settingsRoleCreate',
|
|
74
|
+
path: 'create',
|
|
61
75
|
authz: {
|
|
62
76
|
resource: {
|
|
63
77
|
type: 'settings',
|
|
64
78
|
id: 'authorization.permission-sets',
|
|
65
79
|
},
|
|
66
|
-
action: '
|
|
80
|
+
action: 'create',
|
|
67
81
|
},
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
],
|
|
82
|
+
breadcrumb: { title: 'settings.newRole' },
|
|
83
|
+
componentLoader: () => import('./role-form.js'),
|
|
92
84
|
},
|
|
93
85
|
{
|
|
94
|
-
name: '
|
|
95
|
-
path: '/
|
|
96
|
-
|
|
86
|
+
name: 'settingsRoleEdit',
|
|
87
|
+
path: ':key/edit',
|
|
88
|
+
breadcrumb: { title: 'settings.editRole' },
|
|
89
|
+
componentLoader: () => import('./role-form.js'),
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
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',
|
|
97
114
|
authz: {
|
|
98
115
|
resource: {
|
|
99
116
|
type: 'settings',
|
|
100
117
|
id: 'authorization.default-access',
|
|
101
118
|
},
|
|
102
|
-
action: '
|
|
103
|
-
},
|
|
104
|
-
navigation: {
|
|
105
|
-
title: 'settings.defaultAccess.title',
|
|
106
|
-
icon: ListFilter,
|
|
119
|
+
action: 'create',
|
|
107
120
|
},
|
|
108
|
-
breadcrumb: { title: 'settings.defaultAccess.
|
|
109
|
-
componentLoader: () => import('./default-access.js'),
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
],
|
|
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'),
|
|
132
130
|
},
|
|
133
131
|
],
|
|
134
132
|
},
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import { ChevronRight, Settings } from 'lucide-react';
|
|
4
|
+
import { useMemo, type ReactElement } from 'react';
|
|
5
|
+
import { Link } from 'react-router';
|
|
6
|
+
|
|
7
|
+
import { Loading } from '#components/loading';
|
|
8
|
+
import { PageContainer } from '#components/page-container';
|
|
9
|
+
import { PageHeader } from '#components/page-header';
|
|
10
|
+
import { Empty, EmptyHeader, EmptyTitle } from '#components/ui/empty';
|
|
11
|
+
import type { SettingsModule } from '#lib/navigation-transform';
|
|
12
|
+
|
|
13
|
+
import { useRouteAccess, useRouteTree } from '../../routing/route-context.js';
|
|
14
|
+
import {
|
|
15
|
+
buildSettingsCatalog,
|
|
16
|
+
routeKey,
|
|
17
|
+
} from '../../routing/route-navigation.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The System settings home: the settings modules the viewer may open, as cards grouped by category. Everything shown
|
|
21
|
+
* comes from the registered routes and the shell's permission check, so a module appears here once it is registered
|
|
22
|
+
* under a settings category, and disappears with its permission.
|
|
23
|
+
*/
|
|
24
|
+
export default function SettingsHomePage(): ReactElement {
|
|
25
|
+
const { t } = useTranslation();
|
|
26
|
+
const routes = useRouteTree();
|
|
27
|
+
const { denied, loading } = useRouteAccess();
|
|
28
|
+
const categories = useMemo(
|
|
29
|
+
() => buildSettingsCatalog(routes, denied),
|
|
30
|
+
[routes, denied],
|
|
31
|
+
);
|
|
32
|
+
return (
|
|
33
|
+
<PageContainer className='space-y-8'>
|
|
34
|
+
<PageHeader
|
|
35
|
+
title={t('settings.title')}
|
|
36
|
+
description={t('settings.home.description')}
|
|
37
|
+
/>
|
|
38
|
+
{loading ? (
|
|
39
|
+
<Loading />
|
|
40
|
+
) : categories.length === 0 ? (
|
|
41
|
+
<Empty className='border border-dashed'>
|
|
42
|
+
<EmptyHeader>
|
|
43
|
+
<EmptyTitle>{t('settings.home.empty')}</EmptyTitle>
|
|
44
|
+
</EmptyHeader>
|
|
45
|
+
</Empty>
|
|
46
|
+
) : (
|
|
47
|
+
categories.map((category) => {
|
|
48
|
+
const title = routeText(t, category.route, 'title');
|
|
49
|
+
return (
|
|
50
|
+
<section
|
|
51
|
+
key={routeKey(category.route)}
|
|
52
|
+
aria-label={title}
|
|
53
|
+
className='space-y-3'
|
|
54
|
+
>
|
|
55
|
+
<h2 className='font-heading text-base font-semibold'>{title}</h2>
|
|
56
|
+
<ul className='grid gap-3 sm:grid-cols-2 xl:grid-cols-3'>
|
|
57
|
+
{category.modules.map((module) => (
|
|
58
|
+
<li key={routeKey(module.route)} className='min-w-0'>
|
|
59
|
+
<SettingsCard module={module} />
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
</section>
|
|
64
|
+
);
|
|
65
|
+
})
|
|
66
|
+
)}
|
|
67
|
+
</PageContainer>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function SettingsCard({ module }: { module: SettingsModule }): ReactElement {
|
|
72
|
+
const { t } = useTranslation();
|
|
73
|
+
// The card's text is its accessible name: the module and what it is for.
|
|
74
|
+
const title = routeText(t, module.route, 'title');
|
|
75
|
+
const description = routeText(t, module.route, 'description');
|
|
76
|
+
const Icon = module.route.navigation?.icon ?? Settings;
|
|
77
|
+
return (
|
|
78
|
+
<Link
|
|
79
|
+
to={module.target.path}
|
|
80
|
+
className='group flex h-full items-start gap-3 rounded-lg border bg-card p-4 text-card-foreground shadow-xs transition-colors outline-none hover:border-primary/40 hover:bg-accent/40 focus-visible:ring-3 focus-visible:ring-ring/50'
|
|
81
|
+
>
|
|
82
|
+
<span className='flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground group-hover:text-foreground'>
|
|
83
|
+
<Icon className='size-4' />
|
|
84
|
+
</span>
|
|
85
|
+
<span className='min-w-0 flex-1'>
|
|
86
|
+
<span className='block font-medium'>{title}</span>
|
|
87
|
+
{description ? (
|
|
88
|
+
<span className='mt-1 block text-sm leading-6 text-muted-foreground'>
|
|
89
|
+
{description}
|
|
90
|
+
</span>
|
|
91
|
+
) : null}
|
|
92
|
+
</span>
|
|
93
|
+
<ChevronRight
|
|
94
|
+
aria-hidden
|
|
95
|
+
className='mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground'
|
|
96
|
+
/>
|
|
97
|
+
</Link>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A navigation text of a route, translated in the namespace of the package that registered it. */
|
|
102
|
+
function routeText(
|
|
103
|
+
t: ReturnType<typeof useTranslation>['t'],
|
|
104
|
+
route: AppClientRegisteredRoute,
|
|
105
|
+
field: 'title' | 'description',
|
|
106
|
+
): string | undefined {
|
|
107
|
+
const key = route.navigation?.[field];
|
|
108
|
+
return key ? t(key, { ns: route.packageName, defaultValue: key }) : undefined;
|
|
109
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { useLocation, useNavigate } from 'react-router';
|
|
11
|
+
|
|
12
|
+
import { PageContainer } from '#components/page-container';
|
|
13
|
+
import { PageHeader } from '#components/page-header';
|
|
14
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#components/ui/tabs';
|
|
15
|
+
import { settingsHomeRoute } from '#lib/navigation-transform';
|
|
16
|
+
|
|
17
|
+
import { useRouteAccess, useRouteTrail } from '../../routing/route-context.js';
|
|
18
|
+
import { routeKey } from '../../routing/route-navigation.js';
|
|
19
|
+
|
|
20
|
+
const TAB_NAVIGATION = 'settingsTabNavigation';
|
|
21
|
+
|
|
22
|
+
interface SettingsModuleTabsState {
|
|
23
|
+
/** The module group: its pages are the tabs. */
|
|
24
|
+
readonly module?: AppClientRegisteredRoute;
|
|
25
|
+
/** The tabs the viewer may open, in route order. */
|
|
26
|
+
readonly tabs: readonly AppClientRegisteredRoute[];
|
|
27
|
+
/** The tab page the current URL is on. */
|
|
28
|
+
readonly active?: AppClientRegisteredRoute;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The module the current page belongs to, read from the route trail: a settings module registered as a group has its
|
|
33
|
+
* menu pages as tabs. A direct child group of the settings group is a category rather than a module, so a page placed
|
|
34
|
+
* straight in a category has no tabs.
|
|
35
|
+
*/
|
|
36
|
+
function useSettingsModuleTabs(): SettingsModuleTabsState {
|
|
37
|
+
const trail = useRouteTrail();
|
|
38
|
+
const { denied } = useRouteAccess();
|
|
39
|
+
return useMemo(() => {
|
|
40
|
+
const pageIndex = trail.findIndex((entry) => entry.route.componentLoader);
|
|
41
|
+
const page = trail[pageIndex]?.route;
|
|
42
|
+
const group = pageIndex > 0 ? trail[pageIndex - 1].route : undefined;
|
|
43
|
+
const parent = pageIndex > 1 ? trail[pageIndex - 2].route : undefined;
|
|
44
|
+
if (!page || !group || group.componentLoader || settingsHomeRoute(group))
|
|
45
|
+
return { tabs: [], active: page };
|
|
46
|
+
if (parent && settingsHomeRoute(parent)) return { tabs: [], active: page };
|
|
47
|
+
const tabs = (group.children ?? []).filter(
|
|
48
|
+
(child) =>
|
|
49
|
+
child.componentLoader &&
|
|
50
|
+
child.navigation &&
|
|
51
|
+
!denied.has(routeKey(child)),
|
|
52
|
+
);
|
|
53
|
+
return { module: group, tabs, active: page };
|
|
54
|
+
}, [trail, denied]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The frame of a settings module page. When the module has several views it is a route group whose pages are its tabs:
|
|
59
|
+
* each tab is its own URL, so refresh, bookmarks and Back keep the view, and a tab the viewer may not open is not
|
|
60
|
+
* shown. A module with one view the viewer may open shows no tab bar.
|
|
61
|
+
*/
|
|
62
|
+
export function SettingsModulePage({
|
|
63
|
+
title,
|
|
64
|
+
description,
|
|
65
|
+
actions,
|
|
66
|
+
children,
|
|
67
|
+
}: {
|
|
68
|
+
/** Defaults to the module group's navigation title. */
|
|
69
|
+
readonly title?: string;
|
|
70
|
+
readonly description?: ReactNode;
|
|
71
|
+
readonly actions?: ReactNode;
|
|
72
|
+
readonly children: ReactNode;
|
|
73
|
+
}): ReactElement {
|
|
74
|
+
const { t } = useTranslation();
|
|
75
|
+
const navigate = useNavigate();
|
|
76
|
+
const location = useLocation();
|
|
77
|
+
const { module, tabs, active } = useSettingsModuleTabs();
|
|
78
|
+
const { loading } = useRouteAccess();
|
|
79
|
+
const activeRef = useRef<HTMLButtonElement>(null);
|
|
80
|
+
const focusedRef = useRef(false);
|
|
81
|
+
const moduleTitle = module?.navigation
|
|
82
|
+
? t(module.navigation.title, {
|
|
83
|
+
ns: module.packageName,
|
|
84
|
+
defaultValue: module.navigation.title,
|
|
85
|
+
})
|
|
86
|
+
: undefined;
|
|
87
|
+
const heading = title ?? moduleTitle;
|
|
88
|
+
// Until the shell's permission check answers, a tab the viewer may not open could still be listed.
|
|
89
|
+
const showTabs = !loading && tabs.length > 1 && active !== undefined;
|
|
90
|
+
// Each tab is a route, so switching remounts the page; keep keyboard focus on the selected tab.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (
|
|
93
|
+
!focusedRef.current &&
|
|
94
|
+
location.state &&
|
|
95
|
+
typeof location.state === 'object' &&
|
|
96
|
+
Reflect.get(location.state, TAB_NAVIGATION) === true &&
|
|
97
|
+
activeRef.current
|
|
98
|
+
) {
|
|
99
|
+
const frame = requestAnimationFrame(() => {
|
|
100
|
+
const selected = activeRef.current;
|
|
101
|
+
if (selected) {
|
|
102
|
+
if (
|
|
103
|
+
!selected
|
|
104
|
+
.closest('[role="tablist"]')
|
|
105
|
+
?.contains(selected.ownerDocument.activeElement)
|
|
106
|
+
)
|
|
107
|
+
selected.focus();
|
|
108
|
+
focusedRef.current = true;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
return () => cancelAnimationFrame(frame);
|
|
112
|
+
}
|
|
113
|
+
}, [location.state, tabs]);
|
|
114
|
+
const header = (
|
|
115
|
+
<PageHeader title={heading} description={description} actions={actions} />
|
|
116
|
+
);
|
|
117
|
+
// Keep the content under the same frame while permissions reload or the tab count changes.
|
|
118
|
+
const value = active ? routeKey(active) : 'settings-content';
|
|
119
|
+
return (
|
|
120
|
+
<PageContainer>
|
|
121
|
+
{header}
|
|
122
|
+
<Tabs
|
|
123
|
+
className='gap-6'
|
|
124
|
+
value={value}
|
|
125
|
+
onValueChange={(next) => {
|
|
126
|
+
const tab = tabs.find((candidate) => routeKey(candidate) === next);
|
|
127
|
+
if (tab && tab !== active)
|
|
128
|
+
void navigate(tab.path, { state: { [TAB_NAVIGATION]: true } });
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{/* Many tabs scroll sideways on a narrow screen instead of widening the page. */}
|
|
132
|
+
{showTabs ? (
|
|
133
|
+
<div className='-mx-1 max-w-full overflow-x-auto px-1'>
|
|
134
|
+
<TabsList variant='line' aria-label={heading}>
|
|
135
|
+
{tabs.map((tab) => (
|
|
136
|
+
<TabsTrigger
|
|
137
|
+
key={routeKey(tab)}
|
|
138
|
+
ref={tab === active ? activeRef : undefined}
|
|
139
|
+
value={routeKey(tab)}
|
|
140
|
+
>
|
|
141
|
+
{tab.navigation
|
|
142
|
+
? t(tab.navigation.title, {
|
|
143
|
+
ns: tab.packageName,
|
|
144
|
+
defaultValue: tab.navigation.title,
|
|
145
|
+
})
|
|
146
|
+
: null}
|
|
147
|
+
</TabsTrigger>
|
|
148
|
+
))}
|
|
149
|
+
</TabsList>
|
|
150
|
+
</div>
|
|
151
|
+
) : null}
|
|
152
|
+
<TabsContent value={value} className='space-y-6'>
|
|
153
|
+
{children}
|
|
154
|
+
</TabsContent>
|
|
155
|
+
</Tabs>
|
|
156
|
+
</PageContainer>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Settings } from 'lucide-react';
|
|
2
|
+
import {
|
|
3
|
+
defineAppRoutes,
|
|
4
|
+
type AppClientRouteContribution,
|
|
5
|
+
} from '@nocobase/app-client/plugins';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The System settings shell: one sidebar entry opening a home page of cards. Child groups are the home page's
|
|
9
|
+
* categories; register an administration module with `parent` naming its category, such as
|
|
10
|
+
* `parent: 'settingsAccount'`. A module with several views is a group whose pages become its tabs
|
|
11
|
+
* (`SettingsModulePage`). A category the viewer can open nothing in is not shown, so a bare application shows only
|
|
12
|
+
* what its plugins contribute, such as App Foundation for an unrestricted administrator.
|
|
13
|
+
*/
|
|
14
|
+
const settingsShellRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
15
|
+
{
|
|
16
|
+
name: 'settings',
|
|
17
|
+
path: '/settings',
|
|
18
|
+
navigation: { title: 'settings.title', icon: Settings, order: 100 },
|
|
19
|
+
breadcrumb: { title: 'settings.title' },
|
|
20
|
+
children: [
|
|
21
|
+
{
|
|
22
|
+
// Lists only modules the viewer may open, so it needs no grant of its own.
|
|
23
|
+
name: 'settingsHome',
|
|
24
|
+
path: '/',
|
|
25
|
+
auth: 'required',
|
|
26
|
+
authz: 'skip',
|
|
27
|
+
componentLoader: () => import('./settings-home.js'),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'settingsAccount',
|
|
31
|
+
navigation: { title: 'settings.categories.account', order: 10 },
|
|
32
|
+
children: [],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'settingsBusiness',
|
|
36
|
+
navigation: { title: 'settings.categories.business', order: 20 },
|
|
37
|
+
children: [],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'settingsAi',
|
|
41
|
+
navigation: { title: 'settings.categories.ai', order: 30 },
|
|
42
|
+
children: [],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'settingsSystem',
|
|
46
|
+
navigation: { title: 'settings.categories.system', order: 40 },
|
|
47
|
+
children: [],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export default settingsShellRoutes;
|
package/client/routes.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { createAppClientConfig } from '@nocobase/app-client';
|
|
2
|
+
import { readAppClientRuntimeConfig } from '@nocobase/app-client/runtime';
|
|
3
|
+
import { includeNotificationTemplateRoutes } from './pages/settings/notification-routes.js';
|
|
1
4
|
import { Home } from 'lucide-react';
|
|
5
|
+
import settingsShellRoutes from './pages/settings/shell-routes.js';
|
|
2
6
|
import {
|
|
3
7
|
defineAppRoutes,
|
|
4
8
|
type AppClientRouteContribution,
|
|
@@ -64,6 +68,18 @@ const appRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
64
68
|
},
|
|
65
69
|
]);
|
|
66
70
|
|
|
67
|
-
|
|
71
|
+
// The System settings shell stays registered: it shows only the modules a viewer may open, and none until the
|
|
72
|
+
// application or its plugins contribute one. Business administration such as `./pages/settings/routes.js` joins it.
|
|
73
|
+
export function createApplicationRoutes(
|
|
74
|
+
rawConfig: unknown = readAppClientRuntimeConfig(),
|
|
75
|
+
): readonly AppClientRouteContribution[] {
|
|
76
|
+
const config = createAppClientConfig({ rawConfig });
|
|
77
|
+
return includeNotificationTemplateRoutes(
|
|
78
|
+
[appRoutes, settingsShellRoutes],
|
|
79
|
+
config.get<boolean>('notificationTemplates.invitationConfigured') === true,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const routes: readonly AppClientRouteContribution[] = createApplicationRoutes();
|
|
68
84
|
|
|
69
85
|
export default routes;
|
|
@@ -6,9 +6,8 @@ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
|
6
6
|
import { useMemo, type ReactElement } from 'react';
|
|
7
7
|
import { Navigate, Outlet, Route, Routes } from 'react-router';
|
|
8
8
|
|
|
9
|
-
import { EMPTY_ARRAY } from '#lib/constants';
|
|
10
|
-
|
|
11
9
|
import { AppLayout } from '../layouts/app-layout.js';
|
|
10
|
+
import { filterRouteTree } from './route-navigation.js';
|
|
12
11
|
import { renderRouteTree } from './route-tree.js';
|
|
13
12
|
import { StandalonePageLayout } from './standalone-page-layout.js';
|
|
14
13
|
import { SessionPreparation } from './session-preparation.js';
|
|
@@ -66,15 +65,3 @@ export function AppRouter({ clientRoutes }: AppRouterProps): ReactElement {
|
|
|
66
65
|
</Routes>
|
|
67
66
|
);
|
|
68
67
|
}
|
|
69
|
-
|
|
70
|
-
/** Pure groups can span shells; a page and its descendants always share their shell. */
|
|
71
|
-
function filterRouteTree(
|
|
72
|
-
routes: readonly AppClientRegisteredRoute[],
|
|
73
|
-
predicate: (route: AppClientRegisteredRoute) => boolean,
|
|
74
|
-
): AppClientRegisteredRoute[] {
|
|
75
|
-
return routes.flatMap((route) => {
|
|
76
|
-
if (route.componentLoader) return predicate(route) ? [route] : [];
|
|
77
|
-
const children = filterRouteTree(route.children ?? EMPTY_ARRAY, predicate);
|
|
78
|
-
return children.length ? [{ ...route, children }] : [];
|
|
79
|
-
});
|
|
80
|
-
}
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
} from '@nocobase/app-client/plugins';
|
|
6
6
|
import { useCan } from '@nocobase/app-plugin-authorization/client';
|
|
7
7
|
import { NamespaceScope } from '@nocobase/i18n/client';
|
|
8
|
-
import { type ReactElement, useEffect, useState } from 'react';
|
|
8
|
+
import { Activity, type ReactElement, useEffect, useState } from 'react';
|
|
9
9
|
import { ErrorBoundary } from 'react-error-boundary';
|
|
10
10
|
|
|
11
11
|
import { Loading } from '#components/loading';
|
|
@@ -52,8 +52,8 @@ export function ClientRoute({ route }: ClientRouteProps): ReactElement {
|
|
|
52
52
|
return <ClientPageError route={route} />;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
if (checkAccess && accessLoading
|
|
56
|
-
|
|
55
|
+
if (checkAccess && !accessLoading && !allowed)
|
|
56
|
+
return <ClientPageDenied route={route} />;
|
|
57
57
|
|
|
58
58
|
if (!componentModule) {
|
|
59
59
|
return <ClientPageLoading />;
|
|
@@ -61,13 +61,20 @@ export function ClientRoute({ route }: ClientRouteProps): ReactElement {
|
|
|
61
61
|
|
|
62
62
|
const Component = componentModule.default;
|
|
63
63
|
|
|
64
|
+
const checking = checkAccess && accessLoading;
|
|
64
65
|
return (
|
|
65
|
-
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
<>
|
|
67
|
+
{checking ? <ClientPageLoading /> : null}
|
|
68
|
+
{/* Conceal a previously allowed page during rechecks without discarding its draft. A denial unmounts it. */}
|
|
69
|
+
<Activity mode={checking ? 'hidden' : 'visible'}>
|
|
70
|
+
<ErrorBoundary fallback={<ClientPageError route={route} />}>
|
|
71
|
+
{/* The owning package is the page's namespace, so a plugin page translates with a bare useTranslation(). */}
|
|
72
|
+
<NamespaceScope ns={route.packageName}>
|
|
73
|
+
<Component />
|
|
74
|
+
</NamespaceScope>
|
|
75
|
+
</ErrorBoundary>
|
|
76
|
+
</Activity>
|
|
77
|
+
</>
|
|
71
78
|
);
|
|
72
79
|
}
|
|
73
80
|
|