@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
@@ -0,0 +1,5 @@
1
+ # Notification template administration
2
+
3
+ When notification business is used, register the real scenarios alongside their shared sending renderers and deliver the read-only template directory with synthetic preview. Read the installed Notification Skill’s `references/template-directory.md` for registration, safety and coverage requirements. The app-owned `NotificationManagementProvider` projects only `client.notificationTemplates.invitationConfigured` from the same channel selector used by Users sending and the directory. Default's runtime route factory calls `includeNotificationTemplateRoutes` with that bootstrap flag; a configured invitation mailer (including a configured but disabled legacy channel) is real business use. No transport configuration enters the browser. Restart after changing server mailer configuration. The helper reuses `notificationManagement` and preserves existing log children, paths and grants; when integrating a richer settings module container, insert `notificationTemplateRoute` into its template view once. Preserve the host's settings navigation container. The template page permission and API permission are both `page:notification.templates/access`; grant it only to intended administrators. Default does not register unused business settings. Examples registers task assignment/update and Users invitation scenarios.
4
+
5
+ For an existing application, follow the installed upgrade Skill’s version-selected workflow and `references/notification-template-upgrade.md`. Reconcile source, dependency ranges and Skills; retain custom templates, workflows, grants and data. Updating packages alone does not insert or update application pages.
@@ -12,7 +12,7 @@ The route-tree provider remembers each visited ancestor's query while its descen
12
12
 
13
13
  `client/layouts/components/layout-header.tsx` is a presentation container accepting children. The sidebar is shadcn's Sidebar: `components/app-sidebar.tsx` wraps its provider, desktop icon mode and phone sheet, and `components/navigation-menu.tsx` draws the permission-filtered route tree with `SidebarMenu*`: a top-level group with no page of its own is a section, its entries always shown under a `SidebarGroupLabel` heading, while clickable parents and nested groups are controlled `Collapsible` sub-menus, and an item `client/lib/navigation-transform.ts` marks `section` deeper in the tree is a heading over its direct links inside that menu; a count beside an entry, such as unread items, is passed by the App layout in `NavigationMenu`'s `badges`, keyed by route name. Keep `client/components/ui/sidebar.tsx` as the shadcn CLI writes it except for removing its unused origin-wide `sidebar_state` cookie write; persistence belongs to the application-scoped preference. Customize from outside: widths go through the provider's `style` in spacing units, the phone sheet is rendered in `app-sidebar.tsx` with a translated title, and the provider's Ctrl/Cmd+B shortcut is stopped at `document`. The App layout owns menus, branding, permissions and redirects. Desktop icon mode hides top-level section headings and keeps their entries' icons, shows leaf labels as zero-delay tooltips and opens groups in hover and keyboard-focus popovers, preserving filtered navigation, parent-page links, nested groups and the selected group's highlight. Keep this behavior aligned across layouts. Desktop collapse state is shared through `useSidebarPreference` at `nocobase:sidebar:collapsed:app:<basename>` across layouts and tabs of the same application. `createApp(runtime)` provides a stable store using `runtime.basename`, with only empty/root and boundary slashes normalized; paths and case remain intact. Missing new keys default to expanded without migrating the old shared preference; whether the phone sheet is open stays local to each layout.
14
14
 
15
- The header carries no Settings or Inbox entry. An application has no separate settings area: a page that configures something is an ordinary route in `client/routes.ts` with `navigation`, placed in the sidebar like any other page, for example under a Settings navigation group. The `/inbox` page declares no navigation; give it an entry where the application wants one.
15
+ The header carries no Settings or Inbox entry, and there is no separate settings layout. A page that configures something is an ordinary route in `client/routes.ts` registered into System settings: the `settings` group (`client/pages/settings/shell-routes.ts`) with its `settingsHome` page and category groups. `client/lib/navigation-transform.ts` folds that group into one sidebar entry opening the home, which stays selected on every page below it; the home (`client/pages/settings/settings-home.tsx`) shows the modules a viewer may open as cards by category, and `SettingsModulePage` turns the pages of a module group into URL-backed tabs. Both read the shell's single page permission check through `RouteTreeProvider`'s `access`. See [System settings navigation](../skills/nocobase-app-development/references/settings-navigation.md). The `/inbox` page declares no navigation; give it an entry where the application wants one.
16
16
 
17
17
  `client/lib/navigation-transform.ts` is the application-owned hook for customizing the permission-filtered navigation tree. The shared sidebar supports static section labels with direct links as well as collapsible groups.
18
18
 
@@ -20,9 +20,11 @@ Navigation groups retain their expanded or collapsed state while the navigation
20
20
 
21
21
  ## Account menu and session
22
22
 
23
+ Theme lives in the account menu as a submenu beside Language, rather than a separate header action. `client/theme/theme-settings.tsx` renders menu radio groups for color mode and theme preset; the existing theme providers own selection, persistence and restoration. Keep it inside `DropdownMenuContent` for pointer, touch and keyboard navigation.
24
+
23
25
  The account menu checks Better Auth sign-out results before refreshing the session and shows a localized error toast for API or network failures. Preserve this behavior when upgrading the shell; navigation alone does not revoke a session.
24
26
 
25
- The authorization provider clears the permission snapshot before rendering a new session. Route navigation and page guards subscribe to the authorization revision; preserve these checks when customizing the shell so account changes and permission updates take effect without a reload. Pending checks hide protected content, and failed checks deny access.
27
+ The authorization provider clears the permission snapshot before rendering a new session. Route navigation and page guards subscribe to the authorization revision; preserve these checks when customizing the shell so account changes and permission updates take effect without a reload. Keep `RouteLeaveBoundary` in `AppLayout`, outside all page Activities including nested routes, so `useSettingsLeave` navigation guards, confirmation UI and `beforeunload` survive permission checks. Clear retained guards on denial and session changes. Restored Activity Effects re-run existing data requests. Pending checks hide protected content with React Activity while retaining drafts; failed checks unmount it and deny access. Body portals must detach in an Effect cleanup so they also disappear while the page is hidden; use `BodyPortal` from `#components/body-portal` for application-owned content mounted on `document.body`.
26
28
 
27
29
  The account menu language control in `client/layouts/components/language-switcher.tsx` uses a shadcn submenu with radio items. Render it inside `DropdownMenuContent` to preserve menu keyboard navigation and selection semantics.
28
30
 
package/AGENTS.md CHANGED
@@ -57,7 +57,7 @@ A feature with a page and an API touches five places: a migration for the table,
57
57
 
58
58
  ## System settings are part of feature delivery
59
59
 
60
- A bare create-app shows Home to signed-in users and System settings → App foundation only to unrestricted administrators. The Foundation plugin owns this initial settings group; the navigation transform merges its entry into the application settings group when business administration is registered. As the application develops business capabilities, inspect its current state on every iteration and automatically register the prepared Users and Roles and permissions contribution from `client/pages/settings/routes.ts` once in `client/routes.ts`; preserve it and reconcile missing management surfaces on every iteration, without a first-build flag or one-time setup stage. Whenever an application adds lifecycles, workflows, approvals, notifications, files or other configurable capabilities, automatically include their real administration surfaces without requiring a second user request. Follow [system settings delivery](.agents/skills/nocobase-app-development/references/system-settings.md); in particular, every used process includes a definitions directory, standard diagram, supported parameters and authorized execution history. Do not add demo plugins or empty settings for unused capabilities. The template preinstalls `#components/lifecycle-diagram` (React Flow + ELK) and `client/extensions/nocobase-workflow-management/`: use them by default, preserve their visuals unless the user explicitly requests another style, and connect authorized parameter bindings through `LifecycleDiagram.configuration` to the real editor. These are UI Registry sources; an npm package lookup is not a component availability check.
60
+ A bare create-app shows Home to signed-in users and, only to unrestricted administrators, one System settings entry whose home page holds the App foundation card. Every administration surface joins that structure — one sidebar entry, a home of cards by category, tabs inside a module — as [System settings navigation](.agents/skills/nocobase-app-development/references/settings-navigation.md) describes; never add another sidebar entry for administration. As the application develops business capabilities, inspect its current state on every iteration and automatically register the prepared Users and Permission management contribution from `client/pages/settings/routes.ts` once in `client/routes.ts`; preserve it and reconcile missing management surfaces on every iteration, without a first-build flag or one-time setup stage. Whenever an application adds lifecycles, workflows, approvals, notifications, files or other configurable capabilities, automatically include their real administration surfaces without requiring a second user request. Follow [system settings delivery](.agents/skills/nocobase-app-development/references/system-settings.md); in particular, every used process includes a definitions directory, standard diagram, supported parameters and authorized execution history. Do not add demo plugins or empty settings for unused capabilities. The template preinstalls `#components/lifecycle-diagram` (React Flow + ELK) and `client/extensions/nocobase-workflow-management/`: use them by default, preserve their visuals unless the user explicitly requests another style, and connect authorized parameter bindings through `LifecycleDiagram.configuration` to the real editor. These are UI Registry sources; an npm package lookup is not a component availability check.
61
61
 
62
62
  ## Rules that apply everywhere
63
63
 
@@ -138,3 +138,5 @@ Standard Repository writes require an operation key through the registered submi
138
138
  Assign application business roles to explicitly identified users through the prepared User management role-assignment UI. Never seed a business permission set onto `authenticated:*`, broaden the inherited platform `member` role, or write startup reconciliation that grants access to everyone merely to make a feature usable. Unchecked actions in one role do not revoke grants inherited from another. If the user explicitly requests a global default role, first deliver a permission-protected administration control that shows its recipients and allows changing or removing the assignment; show inherited roles and their source in user access details. Without that UI, use explicit user assignments and explain the limitation. Keep page access independent from business operations.
139
139
 
140
140
  Run `pnpm exec vitest run tests/server/authorization-assignment-safety.test.ts` after permission initialization changes and before delivery. It boots the real application on an isolated database and inspects initialized wildcard assignments and the inherited member role. Keep this test in generated applications. An intentional global default requires explicit user authorization, a working administration surface and corresponding acceptance tests before deliberately updating the expected baseline; do not delete, skip or loosen the guard to make tests pass. Test an ordinary user with only the configured role: inspect all effective grant sources and verify both hidden buttons and rejected direct API calls.
141
+
142
+ For notification business, follow [notification template delivery](.agents/rules/notification-templates.md), including source-owned scenario registration and synthetic-only preview.
package/README.MD CHANGED
@@ -139,11 +139,11 @@ The three files named `runtime.ts`, `plugins.ts`, and `routes.ts` are compositio
139
139
 
140
140
  ### Everyday development
141
141
 
142
- | Script | What it does |
143
- | ------------------ | ------------------------------------------------------------------------------------ |
144
- | `pnpm dev` | Start the API server and client with hot reload |
145
- | `pnpm build` | Build the client and server into `dist/` |
146
- | `pnpm start` | Run the built server; requires `pnpm build` first |
142
+ | Script | What it does |
143
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
144
+ | `pnpm dev` | Start the API server and client with hot reload |
145
+ | `pnpm build` | Build the client and server into `dist/` |
146
+ | `pnpm start` | Run the built server; requires `pnpm build` first |
147
147
  | `pnpm build --tar` | Build, then pack `dist/`, `config.example.yml` and the `Dockerfile` with its `.dockerignore` into `storage/exports/dist.tar.gz` |
148
148
 
149
149
  ### Database
@@ -453,3 +453,9 @@ Set `NOCOBASE_STRICT_STARTUP=true` when running `pnpm dev` or `pnpm start` in au
453
453
  ## Internal workspace packages
454
454
 
455
455
  The application can also be the root of a pnpm workspace, with internal packages under `packages/*` and the existing `client/`, `server/` and `database/` directories unchanged. New projects receive this workspace pattern automatically. Internal packages use `workspace:*` dependencies and may remain private. Run installation, development and deployment builds from the application root; `pnpm check` includes the internal packages' own checks. See [internal packages](.agents/rules/workspace.md) for configuration and development conventions.
456
+
457
+ ### Registration feedback
458
+
459
+ Password registration signs in new accounts by default (`auth.emailAndPassword.autoSignIn: true` in `server/config/auth.ts` and `config.example.yml`). An existing email returns an explicit error, exposing whether that email is registered. For enumeration protection, explicitly set `autoSignIn: false` and configure email verification with a sender or `onExistingUserSignUp` to notify the email owner. Existing explicit overrides remain effective; no configuration migration is performed. The authentication plugin warns at startup about registration with auto sign-in disabled and neither verification nor an existing-user notification.
460
+
461
+ The registration page treats `usePasswordRegistration().isSuccess` as request acceptance only. A refreshed session navigates signed-in users into the application. Visitors who remain signed out see neutral sign-in and recovery guidance, without an account-creation or email-delivery promise. Applications customizing this page must keep that distinction.
@@ -0,0 +1,25 @@
1
+ import {
2
+ useLayoutEffect,
3
+ useState,
4
+ type ReactElement,
5
+ type ReactNode,
6
+ } from 'react';
7
+ import { createPortal } from 'react-dom';
8
+
9
+ export interface BodyPortalProps {
10
+ readonly children: ReactNode;
11
+ }
12
+
13
+ /** Detach body content when its page is hidden by Activity, retaining its React state for the next permission result. */
14
+ export function BodyPortal({ children }: BodyPortalProps): ReactElement {
15
+ const [container] = useState(() => {
16
+ const element = document.createElement('div');
17
+ element.style.display = 'contents';
18
+ return element;
19
+ });
20
+ useLayoutEffect(() => {
21
+ document.body.append(container);
22
+ return () => container.remove();
23
+ }, [container]);
24
+ return createPortal(children, container);
25
+ }
@@ -19,6 +19,7 @@ import {
19
19
  DropdownMenuItem,
20
20
  DropdownMenuTrigger,
21
21
  } from '#components/ui/dropdown-menu';
22
+ import { settingsHomeRoute } from '#lib/navigation-transform';
22
23
  import { cn } from 'cn';
23
24
 
24
25
  import {
@@ -60,6 +61,18 @@ export function Breadcrumbs({
60
61
  trail.flatMap((entry): PageBreadcrumbLevel[] => {
61
62
  const title = routeLevelTitle(entry);
62
63
  if (title === undefined || denied?.has(routeKey(entry.route))) return [];
64
+ // The settings group links to its home page, the way back from any settings module.
65
+ const home = settingsHomeRoute(entry.route);
66
+ if (home)
67
+ return [
68
+ {
69
+ label: t(title, {
70
+ ns: entry.route.packageName,
71
+ defaultValue: title,
72
+ }),
73
+ to: { pathname: home.path, search: searches.get(home.path) ?? '' },
74
+ },
75
+ ];
63
76
  return [
64
77
  {
65
78
  label: t(title, {
@@ -8,7 +8,7 @@ import {
8
8
  type ReactElement,
9
9
  type ReactNode,
10
10
  } from 'react';
11
- import { createPortal } from 'react-dom';
11
+ import { BodyPortal } from './body-portal.js';
12
12
  import {
13
13
  BaseEdge,
14
14
  Controls,
@@ -934,7 +934,7 @@ export function FlowDiagram({
934
934
  )}
935
935
  </div>
936
936
  );
937
- return expanded ? createPortal(canvas, document.body) : canvas;
937
+ return expanded ? <BodyPortal>{canvas}</BodyPortal> : canvas;
938
938
  }
939
939
 
940
940
  /** View controls belong inside React Flow so they always use this canvas's viewport. */
@@ -158,7 +158,7 @@ function ImagePreview(inputProps: {
158
158
  <img
159
159
  src={url}
160
160
  alt={file.filename}
161
- className='max-h-[70vh] max-w-full object-contain'
161
+ className='mx-auto block max-h-[70vh] max-w-full object-contain'
162
162
  onError={() => setFailedUrl(url)}
163
163
  />
164
164
  );
@@ -1,5 +1,5 @@
1
1
  import { useSyncServerLocale } from '@nocobase/app-plugin-i18n/client';
2
- import type { ReactElement } from 'react';
2
+ import { useMemo, type ReactElement } from 'react';
3
3
  import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
4
4
  import { Outlet, useLocation } from 'react-router';
5
5
 
@@ -7,6 +7,7 @@ import { BrandWatermark } from '#components/brand-watermark';
7
7
  import { Breadcrumbs } from '#components/breadcrumbs';
8
8
 
9
9
  import { RouteTreeProvider } from '../routing/route-context.js';
10
+ import { RouteLeaveBoundary } from '../routing/route-leave-boundary.js';
10
11
 
11
12
  import { PageBreadcrumbProvider } from '@nocobase/app-client';
12
13
  import { useTranslation } from '@nocobase/i18n/client';
@@ -34,7 +35,8 @@ export function AppLayout({ routes }: AppLayoutProps): ReactElement {
34
35
  useSyncServerLocale();
35
36
 
36
37
  const { t } = useTranslation();
37
- const { items: menuItems, denied } = useRouteNavigation(routes);
38
+ const { items: menuItems, denied, loading } = useRouteNavigation(routes);
39
+ const access = useMemo(() => ({ denied, loading }), [denied, loading]);
38
40
  const selectedKey = selectedNavigationId(
39
41
  routes,
40
42
  useLocation().pathname,
@@ -46,7 +48,7 @@ export function AppLayout({ routes }: AppLayoutProps): ReactElement {
46
48
 
47
49
  return (
48
50
  // The shell owns the business route tree used by its pages and navigation.
49
- <RouteTreeProvider routes={routes}>
51
+ <RouteTreeProvider routes={routes} access={access}>
50
52
  <PageBreadcrumbProvider>
51
53
  <AppSidebarProvider>
52
54
  <AppSidebar label={navigationLabel} footer={<AppSidebarFooter />}>
@@ -74,7 +76,9 @@ export function AppLayout({ routes }: AppLayoutProps): ReactElement {
74
76
  {/* main only positions; the page scrolls in here, so a child page layer laid over main is neither
75
77
  moved by the page's scrolling nor stretched by its height. */}
76
78
  <div className='h-full overflow-y-auto'>
77
- <Outlet />
79
+ <RouteLeaveBoundary hidden={loading}>
80
+ <Outlet />
81
+ </RouteLeaveBoundary>
78
82
  </div>
79
83
  </main>
80
84
  </div>
@@ -1,7 +1,6 @@
1
1
  import type { ReactElement } from 'react';
2
2
  import { TooltipProvider } from '#components/ui/tooltip';
3
3
 
4
- import { ThemeSettings } from '../../theme/index.js';
5
4
  import { UserMenu } from './user-menu.js';
6
5
 
7
6
  /** The header's right-hand actions, shared by every authenticated page. */
@@ -9,7 +8,6 @@ export function HeaderActions(): ReactElement {
9
8
  return (
10
9
  <TooltipProvider>
11
10
  <div className='flex shrink-0 items-center gap-2'>
12
- <ThemeSettings />
13
11
  <UserMenu />
14
12
  </div>
15
13
  </TooltipProvider>
@@ -12,8 +12,8 @@ import {
12
12
  } from '#components/ui/tooltip';
13
13
 
14
14
  /**
15
- * The application's sidebar footer: the NocoBase slogan's two lines, then the template's name and its version, each
16
- * on its own line. In icon mode only the shield remains, and a tooltip carries the same text.
15
+ * The application's sidebar footer: the NocoBase slogan's two lines, then the application's version.
16
+ * In icon mode only the shield remains, and a tooltip carries the same text.
17
17
  */
18
18
  export function AppSidebarFooter(): ReactElement {
19
19
  const { t } = useTranslation();
@@ -21,16 +21,15 @@ export function AppSidebarFooter(): ReactElement {
21
21
  const iconOnly = state === 'collapsed' && !isMobile;
22
22
  // Published by the server from the application's package.json.
23
23
  const publicConfig = useClientApplication().config.public;
24
- const templateName = publicConfig.get('app.displayName', 'Default Template');
25
24
  const templateVersion = publicConfig.get('app.version', '0.0.0');
26
25
  const versionLabel = `v${templateVersion}`;
27
- const buildFreely = t('shell.buildFreely', {
28
- defaultValue: 'AI builds freely.',
29
- });
30
26
  // The tooltip and the icon's accessible name have no link, so the brand's markup is dropped there.
31
- const reliability = t('shell.reliability', {
32
- defaultValue: '<brand>NocoBase</brand> keeps it reliable.',
27
+ const brandPartnership = t('shell.brandPartnership', {
28
+ defaultValue: 'AI + <brand>NocoBase</brand>',
33
29
  }).replace(/<\/?brand>/g, '');
30
+ const buildReliableSystems = t('shell.buildReliableSystems', {
31
+ defaultValue: 'Build reliable business systems',
32
+ });
34
33
 
35
34
  const brandLink = (
36
35
  <a
@@ -53,7 +52,7 @@ export function AppSidebarFooter(): ReactElement {
53
52
  <span
54
53
  aria-label={
55
54
  iconOnly
56
- ? `${buildFreely} ${reliability} ${templateName} ${versionLabel}`
55
+ ? `${brandPartnership} ${buildReliableSystems} ${versionLabel}`
57
56
  : undefined
58
57
  }
59
58
  className='flex h-5 shrink-0 items-center rounded-sm text-sidebar-foreground/80 outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring'
@@ -69,35 +68,29 @@ export function AppSidebarFooter(): ReactElement {
69
68
  hidden={!iconOnly}
70
69
  side='right'
71
70
  >
72
- <span className='font-medium'>{buildFreely}</span>
73
- <span>{reliability}</span>
74
- <span className='mt-1 opacity-70'>
75
- {templateName} {versionLabel}
76
- </span>
71
+ <span className='font-medium'>{brandPartnership}</span>
72
+ <span>{buildReliableSystems}</span>
73
+ <span className='mt-1 opacity-70'>{versionLabel}</span>
77
74
  </TooltipContent>
78
75
  </Tooltip>
79
76
  <div className='min-w-0 flex-1 group-data-[collapsible=icon]:hidden'>
80
77
  <p className='font-medium text-balance text-sidebar-foreground'>
81
- {buildFreely}
82
- </p>
83
- <p className='text-balance text-muted-foreground'>
84
78
  <Trans
85
79
  t={t}
86
- i18nKey='shell.reliability'
87
- defaults='<brand>NocoBase</brand> keeps it reliable.'
80
+ i18nKey='shell.brandPartnership'
81
+ defaults='AI + <brand>NocoBase</brand>'
88
82
  components={{ brand: brandLink }}
89
83
  >
90
- {brandLink} keeps it reliable.
84
+ AI + {brandLink}
91
85
  </Trans>
92
86
  </p>
93
- {/* Name and version stay on one line each; a long name is cut off, with the whole name as its title. */}
87
+ <p className='text-balance text-muted-foreground'>
88
+ {buildReliableSystems}
89
+ </p>
94
90
  <p
95
91
  className='mt-1 truncate text-muted-foreground'
96
- title={templateName}
92
+ title={versionLabel}
97
93
  >
98
- {templateName}
99
- </p>
100
- <p className='truncate text-muted-foreground' title={versionLabel}>
101
94
  {versionLabel}
102
95
  </p>
103
96
  </div>
@@ -1,5 +1,5 @@
1
1
  import { useAuthenticationErrorResolver } from '@nocobase/app-plugin-authentication/client/actions';
2
- import { useToaster } from '@nocobase/app-client';
2
+ import { getAvatarInitials, useToaster } from '@nocobase/app-client';
3
3
  import { useTranslation } from '@nocobase/i18n/client';
4
4
  import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
5
5
  import { LogOut, UserRound } from 'lucide-react';
@@ -13,6 +13,7 @@ import {
13
13
  DropdownMenuTrigger,
14
14
  } from '#components/ui/dropdown-menu';
15
15
 
16
+ import { ThemeSettings } from '../../theme/index.js';
16
17
  import { LanguageSwitcher } from './language-switcher.js';
17
18
 
18
19
  export function UserMenu(): ReactElement {
@@ -39,7 +40,9 @@ export function UserMenu(): ReactElement {
39
40
  identity?.fullName ||
40
41
  identity?.email ||
41
42
  t('account.fallback', { defaultValue: 'Account' });
42
- const initials = getInitials(name);
43
+ const initials = getAvatarInitials(name, {
44
+ cjk: Boolean(identity?.fullName),
45
+ });
43
46
 
44
47
  return (
45
48
  <DropdownMenu>
@@ -74,6 +77,7 @@ export function UserMenu(): ReactElement {
74
77
  </div>
75
78
  <DropdownMenuSeparator />
76
79
  <LanguageSwitcher />
80
+ <ThemeSettings />
77
81
  <DropdownMenuSeparator />
78
82
  <DropdownMenuItem
79
83
  disabled={isLoggingOut}
@@ -120,14 +124,3 @@ export function UserMenu(): ReactElement {
120
124
  </DropdownMenu>
121
125
  );
122
126
  }
123
-
124
- function getInitials(name: string): string {
125
- const words = name.trim().split(/\s+/).filter(Boolean);
126
- if (words.length === 0) {
127
- return '?';
128
- }
129
- return words
130
- .slice(0, 2)
131
- .map((word) => word[0]?.toUpperCase() ?? '')
132
- .join('');
133
- }
@@ -1,36 +1,170 @@
1
1
  import { groupFoundationNavigation } from '@nocobase/app-plugin-foundation/client';
2
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
3
  import type { RouteNavigationItem } from '../routing/route-navigation.js';
3
4
 
5
+ /**
6
+ * The settings route group: one sidebar entry opening its home page. Its direct child groups are categories on that
7
+ * page, the entries inside a category are modules (one card each), and the pages of a module group are its tabs.
8
+ */
9
+ export const SETTINGS_GROUP = 'settings';
10
+ /** The settings home page, a navigation-less child of the settings group. */
11
+ export const SETTINGS_HOME = 'settingsHome';
12
+ /** The category a module registered directly under the settings group is shown in. */
13
+ export const SETTINGS_FALLBACK_CATEGORY = 'settingsSystem';
14
+
15
+ /**
16
+ * Application-owned navigation structure, before the sidebar folds the settings group into one entry. `routes` is the
17
+ * registered tree the permission-filtered `items` came from.
18
+ */
19
+ export function organizeNavigation(
20
+ items: RouteNavigationItem[],
21
+ routes: readonly AppClientRegisteredRoute[],
22
+ ): RouteNavigationItem[] {
23
+ return groupFoundationNavigation(
24
+ withSettingsGroup(items, routes),
25
+ SETTINGS_GROUP,
26
+ );
27
+ }
28
+
4
29
  /** Application-owned navigation customization, applied after permission filtering. */
5
30
  export function transformNavigation(
6
31
  items: RouteNavigationItem[],
32
+ routes: readonly AppClientRegisteredRoute[],
7
33
  ): RouteNavigationItem[] {
8
- return groupFoundationNavigation(collapsePermissionNavigation(items));
34
+ return collapseSettingsNavigation(organizeNavigation(items, routes));
9
35
  }
10
36
 
11
- /** One sidebar entry opens the first readable tab; original routes and guards remain unchanged. */
12
- export function collapsePermissionNavigation(
37
+ /**
38
+ * Permission filtering drops a group with nothing visible in it, which the settings group is until a module joins it.
39
+ * Keep it in place so a plugin's settings entry, such as App Foundation, still has a group to join.
40
+ */
41
+ function withSettingsGroup(
13
42
  items: RouteNavigationItem[],
43
+ routes: readonly AppClientRegisteredRoute[],
14
44
  ): RouteNavigationItem[] {
15
- return items.map((item) => {
16
- if (item.route.name !== 'permissionManagement')
17
- return {
18
- ...item,
19
- children: collapsePermissionNavigation([...item.children]),
20
- };
21
- const first = item.children[0];
22
- if (!first) return item;
23
- return {
24
- route: {
25
- ...first.route,
26
- name: item.route.name,
27
- navigation: item.route.navigation,
45
+ const index = routes.findIndex((route) => settingsHomeRoute(route));
46
+ if (index < 0 || items.some((item) => item.route === routes[index]))
47
+ return items;
48
+ const position = items.filter(
49
+ (item) => routes.indexOf(item.route) < index,
50
+ ).length;
51
+ return [
52
+ ...items.slice(0, position),
53
+ { route: routes[index], children: [] },
54
+ ...items.slice(position),
55
+ ];
56
+ }
57
+
58
+ /** The settings home page of a settings group route, when the application registers one. */
59
+ export function settingsHomeRoute(
60
+ route: AppClientRegisteredRoute,
61
+ ): AppClientRegisteredRoute | undefined {
62
+ if (route.name !== SETTINGS_GROUP || route.componentLoader) return undefined;
63
+ return route.children?.find(
64
+ (child) => child.name === SETTINGS_HOME && child.componentLoader,
65
+ );
66
+ }
67
+
68
+ /**
69
+ * The sidebar shows the settings group as one entry opening its home page; it stays selected on every page below the
70
+ * group. Without a module the viewer may open, the entry disappears. An application without a home page keeps the
71
+ * expanded group.
72
+ */
73
+ export function collapseSettingsNavigation(
74
+ items: RouteNavigationItem[],
75
+ ): RouteNavigationItem[] {
76
+ return items.flatMap((item) => {
77
+ const home = settingsHomeRoute(item.route);
78
+ if (!home) return [item];
79
+ if (item.children.length === 0) return [];
80
+ return [
81
+ {
82
+ route: { ...home, navigation: item.route.navigation },
83
+ children: [],
84
+ activeRouteKeys: [home, ...descendantPages(item.route)].map(
85
+ (route) => `${route.packageName}:${route.path}:${route.id}`,
86
+ ),
28
87
  },
29
- children: [],
30
- activeRouteKeys: item.children.map(
31
- (child) =>
32
- `${child.route.packageName}:${child.route.path}:${child.route.id}`,
33
- ),
34
- };
88
+ ];
35
89
  });
36
90
  }
91
+
92
+ function descendantPages(
93
+ route: AppClientRegisteredRoute,
94
+ ): AppClientRegisteredRoute[] {
95
+ return (route.children ?? []).flatMap((child) => [
96
+ ...(child.componentLoader ? [child] : []),
97
+ ...descendantPages(child),
98
+ ]);
99
+ }
100
+
101
+ export interface SettingsModule {
102
+ /** The module's own route: a page, or the group whose pages are its tabs. */
103
+ readonly route: AppClientRegisteredRoute;
104
+ /** The page a card opens: the module page, or the first tab the viewer may open. */
105
+ readonly target: AppClientRegisteredRoute;
106
+ }
107
+
108
+ export interface SettingsCategory {
109
+ readonly route: AppClientRegisteredRoute;
110
+ readonly modules: readonly SettingsModule[];
111
+ }
112
+
113
+ /**
114
+ * The settings home's cards, from the permission-filtered navigation (`organizeNavigation` output): one category per
115
+ * child group of the settings group, in route order, and the modules the viewer may open in each. A module registered
116
+ * directly under the settings group joins the fallback category. Empty categories are left out.
117
+ */
118
+ export function settingsCatalog(
119
+ items: readonly RouteNavigationItem[],
120
+ ): SettingsCategory[] {
121
+ const settings = items.find((item) => settingsHomeRoute(item.route));
122
+ if (!settings) return [];
123
+ const categories = settings.children.filter(
124
+ (child) => !child.route.componentLoader,
125
+ );
126
+ const loose = settings.children
127
+ .filter((child) => child.route.componentLoader)
128
+ .flatMap(toModule);
129
+ const result = categories.map((category) => ({
130
+ route: category.route,
131
+ modules: category.children.flatMap(toModule),
132
+ }));
133
+ if (loose.length) {
134
+ const fallback = result.find(
135
+ (category) => category.route.name === SETTINGS_FALLBACK_CATEGORY,
136
+ );
137
+ const declared = settings.route.children?.find(
138
+ (child) => child.name === SETTINGS_FALLBACK_CATEGORY,
139
+ );
140
+ if (fallback)
141
+ result[result.indexOf(fallback)] = {
142
+ ...fallback,
143
+ modules: [...fallback.modules, ...loose],
144
+ };
145
+ else
146
+ result.push({
147
+ route: declared ?? settings.route,
148
+ modules: loose,
149
+ });
150
+ }
151
+ return result.filter((category) => category.modules.length > 0);
152
+ }
153
+
154
+ function toModule(item: RouteNavigationItem): SettingsModule[] {
155
+ if (item.route.componentLoader)
156
+ return [{ route: item.route, target: item.route }];
157
+ const target = firstPage(item);
158
+ return target ? [{ route: item.route, target }] : [];
159
+ }
160
+
161
+ function firstPage(
162
+ item: RouteNavigationItem,
163
+ ): AppClientRegisteredRoute | undefined {
164
+ for (const child of item.children) {
165
+ if (child.route.componentLoader) return child.route;
166
+ const nested = firstPage(child);
167
+ if (nested) return nested;
168
+ }
169
+ return undefined;
170
+ }
@@ -88,6 +88,9 @@ const systemEnUS = {
88
88
  'auth.loginDescription': 'Sign in with your username or email and password.',
89
89
  'auth.registerTitle': 'Create an account',
90
90
  'auth.registerDescription': 'Create an account to get started.',
91
+ 'auth.registrationAccepted': 'Registration request submitted',
92
+ 'auth.registrationGuidance':
93
+ 'Try signing in with the username and password you submitted. If this email already has an account, the new details will not take effect. Sign in with your original account or recover your password. Follow any verification instructions provided by the application before signing in.',
91
94
  'auth.forgotTitle': 'Forgot password',
92
95
  'auth.forgotDescription':
93
96
  'Enter your email and we will send a reset link if the account exists.',
@@ -130,9 +133,8 @@ const systemEnUS = {
130
133
  'auth.continueWith': 'Continue with {provider}',
131
134
  'auth.about': 'About this application',
132
135
  'auth.platform': 'AI-native application platform',
133
- 'auth.marketingTitleFirst': 'Let AI build freely.',
134
- 'auth.marketingTitleSecond': 'NocoBase keeps it',
135
- 'auth.marketingTitleThird': 'reliable.',
136
+ 'auth.marketingTitleFirst': 'AI + NocoBase',
137
+ 'auth.marketingTitleSecond': 'Build reliable business systems',
136
138
  'auth.marketingDescription':
137
139
  'Give AI a flexible frontend framework to shape each experience, while NocoBase secures the data, permissions and governance underneath.',
138
140
  'auth.frontend': 'AI-native frontend',
@@ -153,8 +155,8 @@ const systemEnUS = {
153
155
  'status.routeFailedDescription':
154
156
  'Route {{label}} from {{packageName}} could not be loaded.',
155
157
  shell: {
156
- buildFreely: 'AI builds freely.',
157
- reliability: '<brand>NocoBase</brand> keeps it reliable.',
158
+ brandPartnership: 'AI + <brand>NocoBase</brand>',
159
+ buildReliableSystems: 'Build reliable business systems',
158
160
  },
159
161
  home: {
160
162
  platform: 'NocoBase · AI-native application platform',
@@ -367,7 +369,7 @@ const systemEnUS = {
367
369
  },
368
370
 
369
371
  appearance: {
370
- title: 'Appearance',
372
+ title: 'Theme',
371
373
  mode: 'Color mode',
372
374
  preset: 'Theme',
373
375
  light: 'Light',
@@ -1,4 +1,8 @@
1
1
  const settings = {
2
+ notificationManagementDescription:
3
+ 'View registered notification templates and synthetic content previews.',
4
+ notificationTemplates: 'Notification templates',
5
+ notificationManagement: 'Notification management',
2
6
  listChanged: 'The list has changed. Please retry.',
3
7
  setup: {
4
8
  title: 'Feature prerequisites',
@@ -111,6 +115,19 @@ const settings = {
111
115
 
112
116
  reload: 'Reload role',
113
117
  title: 'System settings',
118
+ home: {
119
+ description:
120
+ 'Accounts, permissions, business configuration and system capabilities are all here.',
121
+ empty: 'No settings are available to you yet.',
122
+ },
123
+ categories: {
124
+ account: 'Accounts and permissions',
125
+ business: 'Business configuration',
126
+ ai: 'AI',
127
+ system: 'System',
128
+ },
129
+ permissionManagementDescription:
130
+ 'Set what each role can open and do, and the data it can see.',
114
131
  users: 'Users',
115
132
  roles: 'Roles and permissions',
116
133
  usersDescription: 'Manage accounts, access and active sessions.',