@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,3 +1,8 @@
1
+ import {
2
+ DropdownMenu,
3
+ DropdownMenuTrigger,
4
+ DropdownMenuContent,
5
+ } from '../../client/components/ui/dropdown-menu';
1
6
  import {
2
7
  createAppSidebarPreferenceStore,
3
8
  SidebarPreferenceProvider,
@@ -35,7 +40,14 @@ export default function Fixture() {
35
40
  </AppSidebar>
36
41
  <main className='min-w-0 flex-1 space-y-6 overflow-y-auto p-6'>
37
42
  <div className='flex flex-wrap gap-2'>
38
- <ThemeSettings />
43
+ <DropdownMenu>
44
+ <DropdownMenuTrigger render={<Button />}>
45
+ Account
46
+ </DropdownMenuTrigger>
47
+ <DropdownMenuContent>
48
+ <ThemeSettings />
49
+ </DropdownMenuContent>
50
+ </DropdownMenu>
39
51
  <AppSidebarToggle />
40
52
  </div>
41
53
  <h1 className='text-3xl'>Theme tokens · 主题样式</h1>
@@ -10,6 +10,7 @@ import {
10
10
  buildVariablesManifest,
11
11
  } from '@nocobase/app-server/config';
12
12
  import auth from '../../server/config/auth.js';
13
+ import mailers from '../../server/config/mailers.js';
13
14
 
14
15
  it('inherits private authentication sender mappings without changing other mail consumers', async () => {
15
16
  const defaults = defaultAppConfigs({ auth });
@@ -50,30 +51,63 @@ it('inherits private authentication sender mappings without changing other mail
50
51
  ).toBe(true);
51
52
  });
52
53
 
53
- it('ships separate, valid SMTP and Resend authentication examples', async () => {
54
+ it('ships a valid shared mailer example with private mapped credentials', async () => {
54
55
  const example = await readFile(
55
56
  new URL('../../config.example.yml', import.meta.url),
56
57
  'utf8',
57
58
  );
58
- const blocks = [...example.matchAll(/^ {2}# email:\n(?: {2}# {3}.*\n)+/gm)];
59
- expect(blocks).toHaveLength(2);
60
- const directory = await mkdtemp(
61
- path.join(os.tmpdir(), 'auth-email-example-'),
62
- );
59
+ const block = example.match(/^# mailers:\n(?:#.*\n)+/m)?.[0];
60
+ expect(block).toBeTruthy();
61
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'mailers-example-'));
63
62
  try {
64
- for (const block of blocks) {
65
- const text = 'auth:\n' + block[0].replace(/^ {2}# /gm, ' ');
66
- const file = path.join(directory, 'config.yml');
67
- await writeFile(file, text);
68
- const config = new AppConfig().loadFile(file);
69
- await config.loadAll();
70
- const defaults = defaultAppConfigs({ auth });
71
- config.mergeDefaults(defaults({} as never));
72
- config.defineSections(defaults.sections!);
73
- expect(await config.validate()).toEqual([]);
74
- }
63
+ const file = path.join(directory, 'config.yml');
64
+ await writeFile(file, block!.replace(/^# ?/gm, ''));
65
+ const config = new AppConfig().loadFile(file);
66
+ await config.loadAll();
67
+ const defaults = defaultAppConfigs({ auth, mailers });
68
+ config.mergeDefaults(defaults({} as never));
69
+ config.defineSections(defaults.sections!);
70
+ await config.loadSectionEnvironment({
71
+ MAILERS_SYSTEM_PASSWORD: 'synthetic-password',
72
+ });
73
+ expect(await config.validate()).toEqual([]);
74
+ expect(config.get('mailers.default')).toBe('system');
75
+ expect(config.get('mailers.system.auth.pass')).toBe('synthetic-password');
76
+ expect(JSON.stringify(config.publicValues())).not.toContain(
77
+ 'synthetic-password',
78
+ );
79
+ expect(config.get('auth.passwordReset.enabled')).toBeUndefined();
75
80
  } finally {
76
81
  await rm(directory, { recursive: true, force: true });
77
82
  }
78
83
  expect(example).not.toContain('emailChannel:');
79
84
  });
85
+
86
+ it('accepts an entirely environment-configured shared sender without enabling recovery', async () => {
87
+ const config = new AppConfig();
88
+ await config.loadAll();
89
+ const defaults = defaultAppConfigs({ auth, mailers });
90
+ config.mergeDefaults(defaults({} as never));
91
+ config.defineSections(defaults.sections!);
92
+ await config.loadSectionEnvironment({
93
+ MAILERS_DEFAULT: 'system',
94
+ MAILERS_SYSTEM_PROVIDER: 'smtp',
95
+ MAILERS_SYSTEM_HOST: 'localhost',
96
+ MAILERS_SYSTEM_PORT: '2525',
97
+ MAILERS_SYSTEM_SECURE: 'false',
98
+ });
99
+ expect(await config.validate()).toEqual([]);
100
+ expect(config.get('mailers.system')).toMatchObject({
101
+ provider: 'smtp',
102
+ port: 2525,
103
+ secure: false,
104
+ });
105
+ expect(config.get('auth.passwordReset.enabled')).toBeUndefined();
106
+ expect(config.environmentVariableMappings()).toMatchObject({
107
+ MAILERS_DEFAULT: { path: 'mailers.default' },
108
+ MAILERS_SYSTEM_PASSWORD: {
109
+ path: 'mailers.system.auth.pass',
110
+ secret: true,
111
+ },
112
+ });
113
+ });
@@ -0,0 +1,144 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { fileURLToPath } from 'node:url';
3
+ import {
4
+ signIn,
5
+ DEFAULT_ADMIN_CREDENTIALS,
6
+ } from '@nocobase/app-plugin-authentication/testing';
7
+ import { createAppTest } from '@nocobase/app-testing/server';
8
+ import { describe, expect } from 'vitest';
9
+ import { createStandaloneServer } from '../../server/standalone.js';
10
+
11
+ const common = {
12
+ createServer: createStandaloneServer,
13
+ scope: 'test' as const,
14
+ config: {
15
+ app: { publicOrigin: 'http://localhost' },
16
+ spa: {
17
+ indexPath: fileURLToPath(new URL('../../index.html', import.meta.url)),
18
+ },
19
+ auth: {
20
+ secret: 'notification-template-test-secret-at-least-32-characters',
21
+ },
22
+ },
23
+ };
24
+ describe('bare Default', () => {
25
+ const test = createAppTest(common);
26
+ test('keeps the bare Default navigation disabled without an invitation transport', async ({
27
+ app,
28
+ }) => {
29
+ expect(
30
+ app.config.get('client.notificationTemplates.invitationConfigured'),
31
+ ).toBe(false);
32
+ });
33
+ });
34
+ describe('configured Default', () => {
35
+ const test = createAppTest({
36
+ ...common,
37
+ config: {
38
+ ...common.config,
39
+ mailers: {
40
+ default: 'system',
41
+ system: {
42
+ provider: 'smtp',
43
+ host: '127.0.0.1',
44
+ port: 9,
45
+ auth: { user: 'synthetic', pass: 'synthetic-private-password' },
46
+ },
47
+ },
48
+ },
49
+ });
50
+
51
+ test('boots the real Default invitation directory and protects all three endpoints', async ({
52
+ testApp,
53
+ app,
54
+ request,
55
+ }) => {
56
+ expect(
57
+ app.config.get('client.notificationTemplates.invitationConfigured'),
58
+ ).toBe(true);
59
+ const page = await testApp.fetch(
60
+ new Request(`http://localhost${testApp.publicBasePath}/`),
61
+ );
62
+ expect(page.status).toBe(200);
63
+ const html = await page.text();
64
+ expect(html).toContain('"invitationConfigured":true');
65
+ expect(html).not.toContain('synthetic-private-password');
66
+ const admin = await signIn(testApp, DEFAULT_ADMIN_CREDENTIALS);
67
+ expect((await request('/notifications/templates')).status).toBe(401);
68
+ const list = await admin.fetch('/notifications/templates');
69
+ expect(list.status).toBe(200);
70
+ const body = await list.json();
71
+ expect(JSON.stringify(body)).not.toContain('synthetic-private-password');
72
+ expect(body.data).toEqual(
73
+ expect.arrayContaining([
74
+ expect.objectContaining({
75
+ id: 'users.invitation',
76
+ channels: [
77
+ { name: 'system', type: 'email', configured: true, enabled: true },
78
+ ],
79
+ }),
80
+ ]),
81
+ );
82
+ const detail = await admin.fetch(
83
+ '/notifications/templates/users.invitation',
84
+ );
85
+ expect(detail.status).toBe(200);
86
+ const logsBefore = await (await admin.fetch('/notifications/logs')).json();
87
+ const preview = await admin.fetch(
88
+ '/notifications/templates/users.invitation/preview',
89
+ {
90
+ method: 'POST',
91
+ headers: {
92
+ origin: 'http://localhost',
93
+ 'content-type': 'application/json',
94
+ },
95
+ body: JSON.stringify({ locale: 'en-US' }),
96
+ },
97
+ );
98
+ expect(preview.status).toBe(200);
99
+ expect(JSON.stringify(await preview.json())).toContain('example.invalid');
100
+ expect(await (await admin.fetch('/notifications/logs')).json()).toEqual(
101
+ logsBefore,
102
+ );
103
+ const member = {
104
+ username: 'templateviewer',
105
+ email: 'templateviewer@example.invalid',
106
+ password: 'Synthetic-template-viewer-943!',
107
+ };
108
+ const created = await admin.fetch('/users', {
109
+ method: 'POST',
110
+ headers: {
111
+ origin: 'http://localhost',
112
+ 'content-type': 'application/json',
113
+ 'Idempotency-Key': `${Date.now()}.${randomUUID()}`,
114
+ },
115
+ body: JSON.stringify({
116
+ ...member,
117
+ name: 'Synthetic restricted viewer',
118
+ roleScopes: {},
119
+ }),
120
+ });
121
+ expect(created.status).toBe(201);
122
+ const restricted = await signIn(testApp, member);
123
+ for (const endpoint of [
124
+ '',
125
+ '/users.invitation',
126
+ '/users.invitation/preview',
127
+ ]) {
128
+ const response = await restricted.fetch(
129
+ `/notifications/templates${endpoint}`,
130
+ endpoint.endsWith('/preview')
131
+ ? {
132
+ method: 'POST',
133
+ headers: {
134
+ origin: 'http://localhost',
135
+ 'content-type': 'application/json',
136
+ },
137
+ body: JSON.stringify({ locale: 'en-US' }),
138
+ }
139
+ : undefined,
140
+ );
141
+ expect(response.status).toBe(403);
142
+ }
143
+ });
144
+ });
@@ -1,64 +0,0 @@
1
- import { resolveAppClientContributions } from '@nocobase/app-client/plugins';
2
- import { expect, it } from 'vitest';
3
- import settings from '../../client/pages/settings/routes.js';
4
- import {
5
- buildRouteNavigation,
6
- selectedNavigationId,
7
- routeKey,
8
- } from '../../client/routing/route-navigation.js';
9
- it('groups existing permission pages without changing their URLs or authorization and hides inaccessible children', () => {
10
- const { routes } = resolveAppClientContributions([
11
- { packageName: 'test', source: 'application', routes: [settings] },
12
- ]);
13
- const group = routes[0].children!.find(
14
- (route) => route.name === 'permissionManagement',
15
- )!;
16
- expect(group.componentLoader).toBeUndefined();
17
- expect(
18
- group.children!.map((route) => [route.name, route.path, route.authz]),
19
- ).toEqual([
20
- [
21
- 'settingsRoles',
22
- '/settings/roles',
23
- {
24
- resource: { type: 'settings', id: 'authorization.permission-sets' },
25
- action: 'read',
26
- },
27
- ],
28
- [
29
- 'settingsDefaultAccess',
30
- '/settings/default-access',
31
- {
32
- resource: { type: 'settings', id: 'authorization.default-access' },
33
- action: 'read',
34
- },
35
- ],
36
- ]);
37
- const visible = buildRouteNavigation(routes, new Set())[0].children.find(
38
- ({ route }) => route.name === 'permissionManagement',
39
- )!;
40
- expect(visible.children).toEqual([]);
41
- expect(visible.route.path).toBe('/settings/roles');
42
- expect(visible.route.navigation?.title).toBe('settings.permissionManagement');
43
- expect(selectedNavigationId(routes, '/settings/roles/member/edit')).toBe(
44
- routeKey(visible.route),
45
- );
46
- expect(selectedNavigationId(routes, '/settings/default-access')).toBe(
47
- routeKey(visible.route),
48
- );
49
- const denied = new Set([routeKey(group.children![0])]);
50
- const onlyDefault = buildRouteNavigation(routes, denied)[0].children.find(
51
- ({ route }) => route.name === 'permissionManagement',
52
- )!;
53
- expect(onlyDefault.children).toEqual([]);
54
- expect(onlyDefault.route.path).toBe('/settings/default-access');
55
- expect(selectedNavigationId(routes, '/settings/default-access', denied)).toBe(
56
- routeKey(onlyDefault.route),
57
- );
58
- denied.add(routeKey(group.children![1]));
59
- expect(
60
- buildRouteNavigation(routes, denied)[0].children.map(
61
- ({ route }) => route.name,
62
- ),
63
- ).toEqual(['settingsUsers']);
64
- });