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