@nocobase/app-template-default 1.0.0-beta.85 → 1.0.0-beta.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/shell.md +2 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +1 -1
- package/client/layouts/components/header-actions.tsx +0 -2
- package/client/layouts/components/user-menu.tsx +6 -13
- package/client/locales/system/en-US.ts +1 -1
- package/client/locales/system/zh-CN.ts +1 -1
- package/client/routing/standalone-page-layout.tsx +0 -5
- package/client/theme/theme-settings.tsx +60 -162
- package/config.example.yml +50 -31
- package/package.json +31 -31
- package/server/config/auth.ts +1 -1
- package/server/config/index.ts +3 -0
- package/server/config/mailers.ts +11 -0
- package/tests/client/client-shell.test.tsx +6 -2
- package/tests/client/client-theme.test.tsx +118 -39
- package/tests/client/header-hover.test.tsx +33 -5
- package/tests/client/standalone-page-layout.test.tsx +45 -0
- package/tests/client/user-menu-sign-out.test.tsx +38 -14
- package/tests/client/user-menu.test.tsx +65 -0
- package/tests/fixtures/theme-tokens.tsx +13 -1
- package/tests/server/auth-email-config.test.ts +51 -17
- package/tests/server/concurrency.test.ts +65 -1
|
@@ -16,10 +16,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
16
16
|
|
|
17
17
|
import {
|
|
18
18
|
AppThemeProvider,
|
|
19
|
-
ThemeSettings,
|
|
19
|
+
ThemeSettings as ThemeMenu,
|
|
20
20
|
useTheme,
|
|
21
21
|
} from '../../client/theme/index.ts';
|
|
22
22
|
|
|
23
|
+
import {
|
|
24
|
+
DropdownMenu,
|
|
25
|
+
DropdownMenuContent,
|
|
26
|
+
DropdownMenuTrigger,
|
|
27
|
+
} from '../../client/components/ui/dropdown-menu';
|
|
28
|
+
|
|
29
|
+
function ThemeSettings(): ReactElement {
|
|
30
|
+
return (
|
|
31
|
+
<DropdownMenu>
|
|
32
|
+
<DropdownMenuTrigger>Account</DropdownMenuTrigger>
|
|
33
|
+
<DropdownMenuContent>
|
|
34
|
+
<ThemeMenu />
|
|
35
|
+
</DropdownMenuContent>
|
|
36
|
+
</DropdownMenu>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
/**
|
|
24
41
|
* Renders the client configuration the server would, mounted at `/crm` so the theme's storage keys are
|
|
25
42
|
* `nocobase:crm:*`. It replaces any block already on the page, since the client reads the first one it finds.
|
|
@@ -38,19 +55,65 @@ function renderClientConfig(
|
|
|
38
55
|
document.head.append(element);
|
|
39
56
|
}
|
|
40
57
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*/
|
|
45
|
-
async function openAppearance(name = 'Appearance'): Promise<void> {
|
|
46
|
-
act(() => screen.getByRole('button', { name }).focus());
|
|
58
|
+
/** Opens the account menu and its Theme submenu using the keyboard. */
|
|
59
|
+
async function openTheme(name = 'Theme'): Promise<void> {
|
|
60
|
+
act(() => screen.getByRole('button', { name: 'Account' }).focus());
|
|
47
61
|
await userEvent.keyboard('{Enter}');
|
|
62
|
+
act(() => screen.getByRole('menuitem', { name }).focus());
|
|
63
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
describe('app client theme', () => {
|
|
51
67
|
it.each([
|
|
52
|
-
['
|
|
53
|
-
['
|
|
68
|
+
['Light', 'light', 'Compact', 'compact'],
|
|
69
|
+
['Dark', 'dark', 'Compact', 'compact'],
|
|
70
|
+
['System', 'system', 'Compact', 'compact'],
|
|
71
|
+
['Light', 'light', 'Default', 'default'],
|
|
72
|
+
['Dark', 'dark', 'Default', 'default'],
|
|
73
|
+
['System', 'system', 'Default', 'default'],
|
|
74
|
+
])(
|
|
75
|
+
'saves and restores %s mode with %s / %s density',
|
|
76
|
+
async (modeLabel, mode, presetLabel, preset) => {
|
|
77
|
+
const settings = (
|
|
78
|
+
<AppThemeProvider>
|
|
79
|
+
<ThemeSettings />
|
|
80
|
+
</AppThemeProvider>
|
|
81
|
+
);
|
|
82
|
+
const view = render(settings);
|
|
83
|
+
await openTheme();
|
|
84
|
+
await userEvent.click(
|
|
85
|
+
screen.getByRole('menuitemradio', { name: modeLabel }),
|
|
86
|
+
);
|
|
87
|
+
await userEvent.click(
|
|
88
|
+
screen.getByRole('menuitemradio', { name: presetLabel }),
|
|
89
|
+
);
|
|
90
|
+
expect(localStorage.getItem('nocobase:crm:theme:color-scheme')).toBe(
|
|
91
|
+
mode,
|
|
92
|
+
);
|
|
93
|
+
expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe(preset);
|
|
94
|
+
view.unmount();
|
|
95
|
+
initializeTheme(
|
|
96
|
+
'/crm/',
|
|
97
|
+
themePresets.map(({ id }) => id),
|
|
98
|
+
);
|
|
99
|
+
render(settings);
|
|
100
|
+
await openTheme();
|
|
101
|
+
expect(document.documentElement).toHaveClass(
|
|
102
|
+
mode === 'light' ? 'light' : 'dark',
|
|
103
|
+
);
|
|
104
|
+
expect(document.documentElement).toHaveAttribute('data-theme', preset);
|
|
105
|
+
expect(
|
|
106
|
+
screen.getByRole('menuitemradio', { name: modeLabel }),
|
|
107
|
+
).toHaveAttribute('aria-checked', 'true');
|
|
108
|
+
expect(
|
|
109
|
+
screen.getByRole('menuitemradio', { name: presetLabel }),
|
|
110
|
+
).toHaveAttribute('aria-checked', 'true');
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
it.each([
|
|
115
|
+
['en-US', 'Theme', 'Spacious'],
|
|
116
|
+
['zh-CN', '主题', '宽松'],
|
|
54
117
|
])(
|
|
55
118
|
'labels the default preset without implying default selection (%s)',
|
|
56
119
|
async (locale, appearance, label) => {
|
|
@@ -68,28 +131,32 @@ describe('app client theme', () => {
|
|
|
68
131
|
</AppThemeProvider>
|
|
69
132
|
</I18nProvider>,
|
|
70
133
|
);
|
|
71
|
-
await
|
|
72
|
-
const option = await screen.findByRole('
|
|
134
|
+
await openTheme(appearance);
|
|
135
|
+
const option = await screen.findByRole('menuitemradio', { name: label });
|
|
73
136
|
await userEvent.click(option);
|
|
74
|
-
expect(option).
|
|
137
|
+
expect(option).toHaveAttribute('aria-checked', 'true');
|
|
75
138
|
expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('default');
|
|
76
139
|
},
|
|
77
140
|
);
|
|
78
141
|
|
|
79
|
-
it('
|
|
142
|
+
it('selects a preset by keyboard and returns focus on Escape', async () => {
|
|
80
143
|
render(
|
|
81
144
|
<AppThemeProvider>
|
|
82
145
|
<ThemeSettings />
|
|
83
146
|
</AppThemeProvider>,
|
|
84
147
|
);
|
|
85
|
-
await
|
|
86
|
-
const compact = await screen.findByRole('
|
|
148
|
+
await openTheme();
|
|
149
|
+
const compact = await screen.findByRole('menuitemradio', {
|
|
150
|
+
name: 'Compact',
|
|
151
|
+
});
|
|
87
152
|
act(() => compact.focus());
|
|
88
|
-
await userEvent.keyboard('{
|
|
89
|
-
expect(
|
|
153
|
+
await userEvent.keyboard('{ArrowDown}{Enter}');
|
|
154
|
+
expect(
|
|
155
|
+
screen.getByRole('menuitemradio', { name: 'Default' }),
|
|
156
|
+
).toHaveAttribute('aria-checked', 'true');
|
|
90
157
|
expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('default');
|
|
91
158
|
await userEvent.keyboard('{Escape}');
|
|
92
|
-
expect(screen.getByRole('
|
|
159
|
+
expect(screen.getByRole('menuitem', { name: 'Theme' })).toHaveFocus();
|
|
93
160
|
});
|
|
94
161
|
|
|
95
162
|
it('falls back from the removed Ant Design preset without changing mode', async () => {
|
|
@@ -100,11 +167,13 @@ describe('app client theme', () => {
|
|
|
100
167
|
<ThemeSettings />
|
|
101
168
|
</AppThemeProvider>,
|
|
102
169
|
);
|
|
103
|
-
await
|
|
170
|
+
await openTheme();
|
|
104
171
|
expect(
|
|
105
|
-
screen.queryByRole('
|
|
172
|
+
screen.queryByRole('menuitemradio', { name: 'Ant-design' }),
|
|
106
173
|
).not.toBeInTheDocument();
|
|
107
|
-
expect(
|
|
174
|
+
expect(
|
|
175
|
+
await screen.findByRole('menuitemradio', { name: 'Compact' }),
|
|
176
|
+
).toHaveAttribute('aria-checked', 'true');
|
|
108
177
|
expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
|
|
109
178
|
expect(document.documentElement).toHaveClass('light');
|
|
110
179
|
});
|
|
@@ -115,13 +184,15 @@ describe('app client theme', () => {
|
|
|
115
184
|
<ThemeSettings />
|
|
116
185
|
</AppThemeProvider>,
|
|
117
186
|
);
|
|
118
|
-
await
|
|
187
|
+
await openTheme();
|
|
119
188
|
expect(
|
|
120
|
-
screen.queryByRole('
|
|
189
|
+
screen.queryByRole('menuitemradio', { name: 'Ocean' }),
|
|
121
190
|
).not.toBeInTheDocument();
|
|
122
|
-
expect(await screen.findByRole('radio', { name: 'Compact' })).toBeChecked();
|
|
123
191
|
expect(
|
|
124
|
-
await screen.findByRole('
|
|
192
|
+
await screen.findByRole('menuitemradio', { name: 'Compact' }),
|
|
193
|
+
).toHaveAttribute('aria-checked', 'true');
|
|
194
|
+
expect(
|
|
195
|
+
await screen.findByRole('menuitemradio', { name: 'Default' }),
|
|
125
196
|
).toBeInTheDocument();
|
|
126
197
|
expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
|
|
127
198
|
});
|
|
@@ -218,12 +289,12 @@ describe('app client theme', () => {
|
|
|
218
289
|
<ThemeSettings />
|
|
219
290
|
</AppThemeProvider>,
|
|
220
291
|
);
|
|
221
|
-
await
|
|
292
|
+
await openTheme();
|
|
222
293
|
expect(
|
|
223
|
-
await screen.findByRole('
|
|
294
|
+
await screen.findByRole('menuitemradio', {
|
|
224
295
|
name: expected === 'compact' ? 'Compact' : 'Default',
|
|
225
296
|
}),
|
|
226
|
-
).
|
|
297
|
+
).toHaveAttribute('aria-checked', 'true');
|
|
227
298
|
expect(document.documentElement).toHaveAttribute('data-theme', expected);
|
|
228
299
|
expect(localStorage.getItem(key)).toBe(saved ?? null);
|
|
229
300
|
},
|
|
@@ -334,14 +405,16 @@ describe('app client theme', () => {
|
|
|
334
405
|
</AppThemeProvider>,
|
|
335
406
|
);
|
|
336
407
|
|
|
337
|
-
await
|
|
408
|
+
await openTheme();
|
|
338
409
|
await userEvent.click(
|
|
339
|
-
await screen.findByRole('
|
|
410
|
+
await screen.findByRole('menuitemradio', { name: 'Compact' }),
|
|
340
411
|
);
|
|
341
412
|
expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
|
|
342
413
|
expect(document.documentElement).toHaveClass('dark');
|
|
343
414
|
expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('compact');
|
|
344
|
-
await userEvent.click(
|
|
415
|
+
await userEvent.click(
|
|
416
|
+
await screen.findByRole('menuitemradio', { name: 'Light' }),
|
|
417
|
+
);
|
|
345
418
|
expect(document.documentElement).toHaveClass('light');
|
|
346
419
|
expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
|
|
347
420
|
fireEvent(
|
|
@@ -359,10 +432,12 @@ describe('app client theme', () => {
|
|
|
359
432
|
newValue: 'default',
|
|
360
433
|
}),
|
|
361
434
|
);
|
|
362
|
-
expect(
|
|
435
|
+
expect(
|
|
436
|
+
await screen.findByRole('menuitemradio', { name: 'Default' }),
|
|
437
|
+
).toHaveAttribute('aria-checked', 'true');
|
|
363
438
|
await userEvent.keyboard('{Escape}');
|
|
364
439
|
await waitFor(() =>
|
|
365
|
-
expect(screen.getByRole('
|
|
440
|
+
expect(screen.getByRole('menuitem', { name: 'Theme' })).toHaveFocus(),
|
|
366
441
|
);
|
|
367
442
|
});
|
|
368
443
|
|
|
@@ -395,11 +470,13 @@ describe('app client theme', () => {
|
|
|
395
470
|
<ThemeSettings />
|
|
396
471
|
</AppThemeProvider>,
|
|
397
472
|
);
|
|
398
|
-
await
|
|
473
|
+
await openTheme();
|
|
474
|
+
await userEvent.click(
|
|
475
|
+
await screen.findByRole('menuitemradio', { name: 'Default' }),
|
|
476
|
+
);
|
|
399
477
|
await userEvent.click(
|
|
400
|
-
await screen.findByRole('
|
|
478
|
+
await screen.findByRole('menuitemradio', { name: 'Light' }),
|
|
401
479
|
);
|
|
402
|
-
await userEvent.click(await screen.findByRole('radio', { name: 'Light' }));
|
|
403
480
|
expect(document.documentElement).toHaveAttribute('data-theme', 'default');
|
|
404
481
|
expect(document.documentElement).toHaveClass('light');
|
|
405
482
|
});
|
|
@@ -410,9 +487,9 @@ describe('app client theme', () => {
|
|
|
410
487
|
<ThemeSettings />
|
|
411
488
|
</AppThemeProvider>,
|
|
412
489
|
);
|
|
413
|
-
await
|
|
490
|
+
await openTheme();
|
|
414
491
|
await userEvent.click(
|
|
415
|
-
await screen.findByRole('
|
|
492
|
+
await screen.findByRole('menuitemradio', { name: 'Default' }),
|
|
416
493
|
);
|
|
417
494
|
fireEvent(
|
|
418
495
|
window,
|
|
@@ -438,7 +515,9 @@ describe('app client theme', () => {
|
|
|
438
515
|
}),
|
|
439
516
|
);
|
|
440
517
|
await waitFor(() => expect(document.documentElement).toHaveClass('dark'));
|
|
441
|
-
expect(
|
|
518
|
+
expect(
|
|
519
|
+
await screen.findByRole('menuitemradio', { name: 'System' }),
|
|
520
|
+
).toHaveAttribute('aria-checked', 'true');
|
|
442
521
|
});
|
|
443
522
|
});
|
|
444
523
|
|
|
@@ -2,7 +2,7 @@ import { fireEvent, screen } from '@testing-library/react';
|
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
4
4
|
import { AppThemeProvider } from '../../client/theme/theme-provider';
|
|
5
|
-
import {
|
|
5
|
+
import { HeaderActions } from '../../client/layouts/components/header-actions';
|
|
6
6
|
import { UserMenu } from '../../client/layouts/components/user-menu';
|
|
7
7
|
|
|
8
8
|
import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
|
|
@@ -24,10 +24,7 @@ afterEach(() => {
|
|
|
24
24
|
document.documentElement.removeAttribute('data-theme');
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
const panels = [
|
|
28
|
-
['Appearance', ThemeSettings, 'dialog'],
|
|
29
|
-
['Open account menu', UserMenu, 'menu'],
|
|
30
|
-
] as const;
|
|
27
|
+
const panels = [['Open account menu', UserMenu, 'menu']] as const;
|
|
31
28
|
|
|
32
29
|
it.each(panels)(
|
|
33
30
|
'opens and closes %s on hover',
|
|
@@ -75,3 +72,34 @@ it.each(panels)(
|
|
|
75
72
|
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
76
73
|
},
|
|
77
74
|
);
|
|
75
|
+
|
|
76
|
+
it('offers appearance only inside the account menu with keyboard selection', async () => {
|
|
77
|
+
const user = userEvent.setup();
|
|
78
|
+
await renderAuthenticatedClient(
|
|
79
|
+
<AppThemeProvider>
|
|
80
|
+
<HeaderActions />
|
|
81
|
+
</AppThemeProvider>,
|
|
82
|
+
);
|
|
83
|
+
expect(
|
|
84
|
+
screen.queryByRole('button', { name: 'Theme' }),
|
|
85
|
+
).not.toBeInTheDocument();
|
|
86
|
+
await user.tab();
|
|
87
|
+
await user.keyboard('{Enter}');
|
|
88
|
+
const appearance = screen.getByRole('menuitem', { name: 'Theme' });
|
|
89
|
+
appearance.focus();
|
|
90
|
+
await user.keyboard('{ArrowRight}');
|
|
91
|
+
expect(screen.getAllByRole('menuitemradio')).toHaveLength(5);
|
|
92
|
+
await user.keyboard('{ArrowDown}{Enter}');
|
|
93
|
+
expect(document.documentElement).toHaveClass('dark');
|
|
94
|
+
expect(screen.getByRole('menuitemradio', { name: 'Dark' })).toHaveAttribute(
|
|
95
|
+
'aria-checked',
|
|
96
|
+
'true',
|
|
97
|
+
);
|
|
98
|
+
await user.keyboard('{Escape}');
|
|
99
|
+
expect(appearance).toHaveFocus();
|
|
100
|
+
expect(screen.getByRole('menuitem', { name: 'Sign out' })).toBeVisible();
|
|
101
|
+
await user.keyboard('{Escape}');
|
|
102
|
+
expect(
|
|
103
|
+
screen.getByRole('button', { name: 'Open account menu' }),
|
|
104
|
+
).toHaveFocus();
|
|
105
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { MemoryRouter, Route, Routes } from 'react-router';
|
|
3
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { StandalonePageLayout } from '../../client/routing/standalone-page-layout.js';
|
|
6
|
+
import { AppThemeProvider } from '../../client/theme/index.js';
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// jsdom does not implement the media queries used by the real theme provider.
|
|
10
|
+
vi.stubGlobal('matchMedia', () => ({
|
|
11
|
+
matches: false,
|
|
12
|
+
addListener() {},
|
|
13
|
+
removeListener() {},
|
|
14
|
+
}));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.unstubAllGlobals();
|
|
19
|
+
document.documentElement.removeAttribute('class');
|
|
20
|
+
document.documentElement.removeAttribute('style');
|
|
21
|
+
document.documentElement.removeAttribute('data-theme');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it.each(['/login', '/device'])(
|
|
25
|
+
'renders standalone content at %s without an account menu',
|
|
26
|
+
(path) => {
|
|
27
|
+
render(
|
|
28
|
+
<AppThemeProvider>
|
|
29
|
+
<MemoryRouter initialEntries={[path]}>
|
|
30
|
+
<Routes>
|
|
31
|
+
<Route element={<StandalonePageLayout />}>
|
|
32
|
+
<Route path={path} element={<h1>Standalone page</h1>} />
|
|
33
|
+
</Route>
|
|
34
|
+
</Routes>
|
|
35
|
+
</MemoryRouter>
|
|
36
|
+
</AppThemeProvider>,
|
|
37
|
+
);
|
|
38
|
+
expect(
|
|
39
|
+
screen.getByRole('heading', { name: 'Standalone page' }),
|
|
40
|
+
).toBeVisible();
|
|
41
|
+
expect(
|
|
42
|
+
screen.queryByRole('button', { name: 'Theme' }),
|
|
43
|
+
).not.toBeInTheDocument();
|
|
44
|
+
},
|
|
45
|
+
);
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import { AppThemeProvider } from '../../client/theme/index.js';
|
|
1
2
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
|
2
3
|
import userEvent from '@testing-library/user-event';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
5
|
import { UserMenu } from '../../client/layouts/components/user-menu.tsx';
|
|
5
6
|
import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
|
|
6
7
|
|
|
8
|
+
// jsdom has no media queries; the real account menu includes the theme provider.
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.stubGlobal('matchMedia', () => ({
|
|
11
|
+
matches: false,
|
|
12
|
+
addListener() {},
|
|
13
|
+
removeListener() {},
|
|
14
|
+
}));
|
|
15
|
+
});
|
|
16
|
+
|
|
7
17
|
async function openAccountMenu(): Promise<void> {
|
|
8
18
|
act(() => screen.getByRole('button', { name: 'Open account menu' }).focus());
|
|
9
19
|
await userEvent.keyboard('{Enter}');
|
|
@@ -15,7 +25,11 @@ async function signOutFromMenu() {
|
|
|
15
25
|
|
|
16
26
|
describe('account menu sign out', () => {
|
|
17
27
|
it('ends the server session and refreshes the displayed identity', async () => {
|
|
18
|
-
const view = await renderAuthenticatedClient(
|
|
28
|
+
const view = await renderAuthenticatedClient(
|
|
29
|
+
<AppThemeProvider>
|
|
30
|
+
<UserMenu />
|
|
31
|
+
</AppThemeProvider>,
|
|
32
|
+
);
|
|
19
33
|
await signOutFromMenu();
|
|
20
34
|
await waitFor(() => expect(view.identity().session).toBeNull());
|
|
21
35
|
expect(
|
|
@@ -26,13 +40,18 @@ describe('account menu sign out', () => {
|
|
|
26
40
|
expect(screen.queryByText(view.session.user.email)).not.toBeInTheDocument();
|
|
27
41
|
});
|
|
28
42
|
it('reports a rejected origin without pretending the session ended', async () => {
|
|
29
|
-
const view = await renderAuthenticatedClient(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
const view = await renderAuthenticatedClient(
|
|
44
|
+
<AppThemeProvider>
|
|
45
|
+
<UserMenu />
|
|
46
|
+
</AppThemeProvider>,
|
|
47
|
+
{
|
|
48
|
+
beforeRequest(request) {
|
|
49
|
+
// Exercise the real origin guard and error resolver, not a fabricated authentication result.
|
|
50
|
+
if (request.url.endsWith('/sign-out'))
|
|
51
|
+
request.headers.set('origin', 'https://untrusted.example');
|
|
52
|
+
},
|
|
34
53
|
},
|
|
35
|
-
|
|
54
|
+
);
|
|
36
55
|
await signOutFromMenu();
|
|
37
56
|
await waitFor(() => expect(view.toasts()).toHaveLength(1));
|
|
38
57
|
expect(view.toasts()[0]).toMatchObject({
|
|
@@ -50,13 +69,18 @@ describe('account menu sign out', () => {
|
|
|
50
69
|
});
|
|
51
70
|
it('reports a network failure and successfully retries sign-out', async () => {
|
|
52
71
|
let offline = true;
|
|
53
|
-
const view = await renderAuthenticatedClient(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
72
|
+
const view = await renderAuthenticatedClient(
|
|
73
|
+
<AppThemeProvider>
|
|
74
|
+
<UserMenu />
|
|
75
|
+
</AppThemeProvider>,
|
|
76
|
+
{
|
|
77
|
+
beforeRequest(request) {
|
|
78
|
+
// Network loss cannot be produced by the in-process server.
|
|
79
|
+
if (offline && request.url.endsWith('/sign-out'))
|
|
80
|
+
throw new TypeError('Failed to fetch');
|
|
81
|
+
},
|
|
58
82
|
},
|
|
59
|
-
|
|
83
|
+
);
|
|
60
84
|
await signOutFromMenu();
|
|
61
85
|
await waitFor(() => expect(view.toasts()).toHaveLength(1));
|
|
62
86
|
expect(view.toasts()[0]).toMatchObject({
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { AppThemeProvider } from '../../client/theme/index.js';
|
|
2
|
+
import { act, screen, within } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { UserMenu } from '../../client/layouts/components/user-menu.js';
|
|
7
|
+
import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
|
|
8
|
+
|
|
9
|
+
// jsdom has no media queries; the real account menu includes the theme provider.
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.stubGlobal('matchMedia', () => ({
|
|
12
|
+
matches: false,
|
|
13
|
+
addListener() {},
|
|
14
|
+
removeListener() {},
|
|
15
|
+
}));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('uses CJK name endings in the avatar while retaining the full account name and picture', async () => {
|
|
19
|
+
const view = await renderAuthenticatedClient(
|
|
20
|
+
<AppThemeProvider>
|
|
21
|
+
<UserMenu />
|
|
22
|
+
</AppThemeProvider>,
|
|
23
|
+
);
|
|
24
|
+
const user = userEvent.setup();
|
|
25
|
+
const trigger = screen.getByRole('button', { name: 'Open account menu' });
|
|
26
|
+
await user.tab();
|
|
27
|
+
for (const [name, expected] of [
|
|
28
|
+
['周建国', '建国'],
|
|
29
|
+
['欧阳娜娜', '娜娜'],
|
|
30
|
+
['李雷', '李雷'],
|
|
31
|
+
['周 建国', '建国'],
|
|
32
|
+
['Li Lei', 'LL'],
|
|
33
|
+
['周', '周'],
|
|
34
|
+
] as const) {
|
|
35
|
+
await act(async () => {
|
|
36
|
+
const response = await view.identity().client.updateUser({ name });
|
|
37
|
+
expect(response.error).toBeNull();
|
|
38
|
+
await view.identity().refresh();
|
|
39
|
+
});
|
|
40
|
+
expect(trigger).toHaveTextContent(expected);
|
|
41
|
+
await user.keyboard('{Enter}');
|
|
42
|
+
expect(
|
|
43
|
+
within(screen.getByRole('menu')).getByText(name, { exact: true }),
|
|
44
|
+
).toBeVisible();
|
|
45
|
+
await user.keyboard('{Escape}');
|
|
46
|
+
}
|
|
47
|
+
await act(async () => {
|
|
48
|
+
const response = await view.identity().client.updateUser({ name: '' });
|
|
49
|
+
expect(response.error).toBeNull();
|
|
50
|
+
await view.identity().refresh();
|
|
51
|
+
});
|
|
52
|
+
expect(trigger).toHaveTextContent(view.session.user.email[0]!.toUpperCase());
|
|
53
|
+
await act(async () => {
|
|
54
|
+
const response = await view
|
|
55
|
+
.identity()
|
|
56
|
+
.client.updateUser({ image: 'https://example.test/avatar.png' });
|
|
57
|
+
expect(response.error).toBeNull();
|
|
58
|
+
await view.identity().refresh();
|
|
59
|
+
});
|
|
60
|
+
expect(trigger.querySelector('img')).toHaveAttribute(
|
|
61
|
+
'src',
|
|
62
|
+
'https://example.test/avatar.png',
|
|
63
|
+
);
|
|
64
|
+
expect(trigger).not.toHaveTextContent('周');
|
|
65
|
+
});
|
|
@@ -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
|
-
<
|
|
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
|
|
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
|
|
59
|
-
expect(
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
});
|