@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.
Files changed (63) hide show
  1. package/.agents/rules/notification-templates.md +5 -0
  2. package/.agents/rules/shell.md +4 -2
  3. package/AGENTS.md +3 -1
  4. package/README.MD +11 -5
  5. package/client/components/body-portal.tsx +25 -0
  6. package/client/components/breadcrumbs.tsx +13 -0
  7. package/client/components/flow-diagram.tsx +2 -2
  8. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +1 -1
  9. package/client/layouts/app-layout.tsx +8 -4
  10. package/client/layouts/components/header-actions.tsx +0 -2
  11. package/client/layouts/components/sidebar-footer.tsx +18 -25
  12. package/client/layouts/components/user-menu.tsx +6 -13
  13. package/client/lib/navigation-transform.ts +156 -22
  14. package/client/locales/system/en-US.ts +8 -6
  15. package/client/locales/system/settings-en-US.ts +17 -0
  16. package/client/locales/system/settings-zh-CN.ts +15 -0
  17. package/client/locales/system/zh-CN.ts +8 -6
  18. package/client/pages/auth/register.tsx +39 -22
  19. package/client/pages/auth/shared.tsx +1 -3
  20. package/client/pages/settings/hooks.tsx +3 -68
  21. package/client/pages/settings/notification-routes.ts +109 -0
  22. package/client/pages/settings/notification-templates.tsx +26 -0
  23. package/client/pages/settings/permission-management-page.tsx +15 -90
  24. package/client/pages/settings/routes.ts +96 -98
  25. package/client/pages/settings/settings-home.tsx +109 -0
  26. package/client/pages/settings/settings-module-page.tsx +158 -0
  27. package/client/pages/settings/shell-routes.ts +53 -0
  28. package/client/routes.ts +17 -1
  29. package/client/routing/app-router.tsx +1 -14
  30. package/client/routing/client-route.tsx +16 -9
  31. package/client/routing/route-context.tsx +30 -3
  32. package/client/routing/route-leave-boundary.tsx +249 -0
  33. package/client/routing/route-navigation.ts +72 -8
  34. package/client/routing/route-tree.tsx +7 -12
  35. package/client/routing/standalone-page-layout.tsx +0 -5
  36. package/client/runtime.ts +2 -2
  37. package/client/theme/theme-settings.tsx +60 -162
  38. package/config.example.yml +51 -32
  39. package/package.json +28 -28
  40. package/server/config/auth.ts +2 -2
  41. package/server/config/index.ts +3 -0
  42. package/server/config/mailers.ts +11 -0
  43. package/server/providers/index.ts +2 -0
  44. package/server/providers/notification-management.ts +27 -0
  45. package/tests/client/account-permissions.test.tsx +1 -1
  46. package/tests/client/client-auth.test.tsx +10 -1
  47. package/tests/client/client-routes.test.ts +137 -4
  48. package/tests/client/client-shell.test.tsx +29 -10
  49. package/tests/client/client-theme.test.tsx +118 -39
  50. package/tests/client/foundation-integration.test.ts +38 -17
  51. package/tests/client/header-hover.test.tsx +33 -5
  52. package/tests/client/layout-i18n.test.tsx +9 -4
  53. package/tests/client/permission-management-tabs.test.tsx +421 -16
  54. package/tests/client/registration-feedback.test.tsx +143 -0
  55. package/tests/client/settings-home.test.tsx +110 -0
  56. package/tests/client/settings-navigation.test.ts +230 -0
  57. package/tests/client/standalone-page-layout.test.tsx +45 -0
  58. package/tests/client/user-menu-sign-out.test.tsx +38 -14
  59. package/tests/client/user-menu.test.tsx +65 -0
  60. package/tests/fixtures/theme-tokens.tsx +13 -1
  61. package/tests/server/auth-email-config.test.ts +51 -17
  62. package/tests/server/notification-templates.test.ts +144 -0
  63. 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
- /** Register this contribution as business capabilities are developed; keep a bare Home-only application free of settings. */
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
- name: 'settings',
17
- path: '/settings',
18
- navigation: { title: 'settings.title', icon: Settings, order: 100 },
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: 'settingsUsers',
22
- path: '/users',
23
- auth: 'required',
24
- authz: { resource: { type: 'user', id: '*' }, action: 'read' },
25
- navigation: { title: 'settings.users', icon: Users },
26
- breadcrumb: { title: 'settings.users' },
27
- componentLoader: () => import('./users.js'),
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: 'settingsUserCreate',
31
- path: 'create',
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: 'permissionManagement',
51
- navigation: {
52
- title: 'settings.permissionManagement',
53
- icon: ShieldCheck,
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
- breadcrumb: { title: 'settings.permissionManagement' },
68
+ navigation: { title: 'settings.roles', icon: KeyRound },
69
+ breadcrumb: { title: 'settings.roles' },
70
+ componentLoader: () => import('./roles.js'),
56
71
  children: [
57
72
  {
58
- name: 'settingsRoles',
59
- path: '/roles',
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: 'read',
80
+ action: 'create',
67
81
  },
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
- ],
82
+ breadcrumb: { title: 'settings.newRole' },
83
+ componentLoader: () => import('./role-form.js'),
92
84
  },
93
85
  {
94
- name: 'settingsDefaultAccess',
95
- path: '/default-access',
96
- auth: 'required',
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: 'read',
103
- },
104
- navigation: {
105
- title: 'settings.defaultAccess.title',
106
- icon: ListFilter,
119
+ action: 'create',
107
120
  },
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
- ],
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
- const routes: readonly AppClientRouteContribution[] = [appRoutes];
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) return <ClientPageLoading />;
56
- if (checkAccess && !allowed) return <ClientPageDenied route={route} />;
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
- <ErrorBoundary fallback={<ClientPageError route={route} />}>
66
- {/* The owning package is the page's namespace, so a plugin page translates with a bare useTranslation(). */}
67
- <NamespaceScope ns={route.packageName}>
68
- <Component />
69
- </NamespaceScope>
70
- </ErrorBoundary>
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