@nocobase/app-template-default 1.0.0-beta.86 → 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 +28 -28
- 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/.agents/rules/shell.md
CHANGED
|
@@ -20,6 +20,8 @@ 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
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. Pending checks hide protected content, and failed checks deny access.
|
package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx
CHANGED
|
@@ -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,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>
|
|
@@ -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 =
|
|
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,14 +1,9 @@
|
|
|
1
1
|
import type { ReactElement } from 'react';
|
|
2
2
|
import { Outlet } from 'react-router';
|
|
3
3
|
|
|
4
|
-
import { ThemeSettings } from '../theme/index.js';
|
|
5
|
-
|
|
6
4
|
export function StandalonePageLayout(): ReactElement {
|
|
7
5
|
return (
|
|
8
6
|
<div className='relative min-h-svh'>
|
|
9
|
-
<div className='fixed top-4 right-4 z-50'>
|
|
10
|
-
<ThemeSettings />
|
|
11
|
-
</div>
|
|
12
7
|
<Outlet />
|
|
13
8
|
</div>
|
|
14
9
|
);
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Monitor, Moon, Palette, Sun } from 'lucide-react';
|
|
2
2
|
import { useTheme } from 'next-themes';
|
|
3
3
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
4
|
-
import {
|
|
5
|
-
import { Button } from '#components/ui/button';
|
|
6
|
-
import { RadioGroup, RadioGroupItem } from '#components/ui/radio-group';
|
|
4
|
+
import type { ReactElement } from 'react';
|
|
7
5
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
DropdownMenuLabel,
|
|
7
|
+
DropdownMenuRadioGroup,
|
|
8
|
+
DropdownMenuRadioItem,
|
|
9
|
+
DropdownMenuSeparator,
|
|
10
|
+
DropdownMenuSub,
|
|
11
|
+
DropdownMenuSubContent,
|
|
12
|
+
DropdownMenuSubTrigger,
|
|
13
|
+
} from '#components/ui/dropdown-menu';
|
|
14
14
|
import { useThemePreset } from './theme-context';
|
|
15
15
|
import { themePresets } from './theme-presets';
|
|
16
16
|
|
|
@@ -20,164 +20,62 @@ const COLOR_MODES = [
|
|
|
20
20
|
{ id: 'system', icon: Monitor },
|
|
21
21
|
] as const;
|
|
22
22
|
|
|
23
|
+
/** Account-menu appearance choices; the existing providers own persistence. */
|
|
23
24
|
export function ThemeSettings(): ReactElement {
|
|
24
25
|
const { theme, setTheme } = useTheme();
|
|
25
26
|
const { preset, setPreset } = useThemePreset();
|
|
26
27
|
const { t } = useTranslation();
|
|
27
|
-
const id = useId();
|
|
28
|
-
const title = t('appearance.title', { defaultValue: 'Appearance' });
|
|
29
|
-
return (
|
|
30
|
-
<Popover>
|
|
31
|
-
<PopoverTrigger
|
|
32
|
-
openOnHover
|
|
33
|
-
delay={0}
|
|
34
|
-
render={
|
|
35
|
-
<Button
|
|
36
|
-
aria-label={title}
|
|
37
|
-
size='icon'
|
|
38
|
-
variant='outline'
|
|
39
|
-
className='size-10 rounded-xl border-border bg-background/60 hover:bg-accent/50'
|
|
40
|
-
/>
|
|
41
|
-
}
|
|
42
|
-
>
|
|
43
|
-
<Palette aria-hidden='true' className='size-5' />
|
|
44
|
-
</PopoverTrigger>
|
|
45
|
-
{/* Keep the panel width independent of density; its content still uses theme tokens. */}
|
|
46
|
-
<PopoverContent
|
|
47
|
-
align='end'
|
|
48
|
-
className='w-xs max-w-[calc(100vw-2rem)] gap-4 p-4'
|
|
49
|
-
>
|
|
50
|
-
<div className='flex items-center justify-between'>
|
|
51
|
-
<PopoverTitle className='text-sm font-semibold text-foreground'>
|
|
52
|
-
{title}
|
|
53
|
-
</PopoverTitle>
|
|
54
|
-
</div>
|
|
55
28
|
|
|
56
|
-
|
|
57
|
-
|
|
29
|
+
return (
|
|
30
|
+
<DropdownMenuSub>
|
|
31
|
+
<DropdownMenuSubTrigger className='gap-2'>
|
|
32
|
+
<Palette aria-hidden='true' className='size-4' />
|
|
33
|
+
{t('appearance.title', { defaultValue: 'Theme' })}
|
|
34
|
+
</DropdownMenuSubTrigger>
|
|
35
|
+
<DropdownMenuSubContent className='min-w-44'>
|
|
36
|
+
<DropdownMenuRadioGroup
|
|
37
|
+
value={theme}
|
|
38
|
+
onValueChange={(value) => setTheme(String(value))}
|
|
39
|
+
aria-label={t('appearance.mode', { defaultValue: 'Color mode' })}
|
|
40
|
+
>
|
|
41
|
+
<DropdownMenuLabel>
|
|
58
42
|
{t('appearance.mode', { defaultValue: 'Color mode' })}
|
|
59
|
-
</
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
{/* `absolute` over the primitive's `relative`, which would keep
|
|
80
|
-
the hidden radio in the row and push the words aside. */}
|
|
81
|
-
<RadioGroupItem value={mode} className='absolute sr-only' />
|
|
82
|
-
<ModeIcon aria-hidden='true' className='size-3.5 shrink-0' />
|
|
83
|
-
<span className='whitespace-nowrap'>
|
|
84
|
-
{t('appearance.' + mode, {
|
|
85
|
-
defaultValue: mode[0].toUpperCase() + mode.slice(1),
|
|
86
|
-
})}
|
|
87
|
-
</span>
|
|
88
|
-
</label>
|
|
89
|
-
);
|
|
90
|
-
})}
|
|
91
|
-
</RadioGroup>
|
|
92
|
-
</fieldset>
|
|
93
|
-
|
|
94
|
-
<fieldset className='space-y-2'>
|
|
95
|
-
<legend className='text-xs font-medium text-muted-foreground'>
|
|
43
|
+
</DropdownMenuLabel>
|
|
44
|
+
{COLOR_MODES.map(({ id: mode, icon: ModeIcon }) => (
|
|
45
|
+
<DropdownMenuRadioItem key={mode} value={mode} closeOnClick={false}>
|
|
46
|
+
<ModeIcon aria-hidden='true' className='size-4' />
|
|
47
|
+
{t('appearance.' + mode, {
|
|
48
|
+
defaultValue: mode[0].toUpperCase() + mode.slice(1),
|
|
49
|
+
})}
|
|
50
|
+
</DropdownMenuRadioItem>
|
|
51
|
+
))}
|
|
52
|
+
</DropdownMenuRadioGroup>
|
|
53
|
+
<DropdownMenuSeparator />
|
|
54
|
+
<DropdownMenuRadioGroup
|
|
55
|
+
value={preset}
|
|
56
|
+
onValueChange={(value) => {
|
|
57
|
+
const selected = themePresets.find((item) => item.id === value);
|
|
58
|
+
if (selected) setPreset(selected.id);
|
|
59
|
+
}}
|
|
60
|
+
aria-label={t('appearance.preset', { defaultValue: 'Theme' })}
|
|
61
|
+
>
|
|
62
|
+
<DropdownMenuLabel>
|
|
96
63
|
{t('appearance.preset', { defaultValue: 'Theme' })}
|
|
97
|
-
</
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
className={cn(
|
|
114
|
-
'group relative flex cursor-pointer flex-col rounded-xl border p-2 text-left transition-all select-none',
|
|
115
|
-
isSelected
|
|
116
|
-
? 'border-primary ring-2 ring-primary/20 bg-primary/5 shadow-xs'
|
|
117
|
-
: 'border-border hover:bg-muted/30',
|
|
118
|
-
)}
|
|
119
|
-
>
|
|
120
|
-
<RadioGroupItem
|
|
121
|
-
value={item.id}
|
|
122
|
-
className='absolute sr-only'
|
|
123
|
-
/>
|
|
124
|
-
<div className='mb-2 h-16 w-full'>
|
|
125
|
-
<div
|
|
126
|
-
aria-hidden='true'
|
|
127
|
-
data-theme={item.id}
|
|
128
|
-
className='theme-preview relative flex h-full overflow-hidden rounded-lg border border-border bg-background shadow-2xs'
|
|
129
|
-
>
|
|
130
|
-
{/* Mini Sidebar */}
|
|
131
|
-
<div className='flex w-6 flex-col gap-1 border-r border-sidebar-border/50 bg-sidebar p-1'>
|
|
132
|
-
<div className='size-2 rounded-xs bg-sidebar-primary-foreground' />
|
|
133
|
-
<div className='h-1 w-full rounded-xs bg-sidebar-foreground/30' />
|
|
134
|
-
<div className='h-1 w-full rounded-xs bg-sidebar-foreground/20' />
|
|
135
|
-
</div>
|
|
136
|
-
{/* Mini Main Content Area */}
|
|
137
|
-
<div className='flex flex-1 flex-col p-1.5'>
|
|
138
|
-
<div className='mb-1 flex items-center justify-between border-b border-border/40 pb-1'>
|
|
139
|
-
<div className='h-1 w-7 rounded-xs bg-foreground/70' />
|
|
140
|
-
<div className='size-1.5 rounded-full bg-primary' />
|
|
141
|
-
</div>
|
|
142
|
-
<div className='flex flex-1 flex-col justify-between'>
|
|
143
|
-
<div className='h-1 w-full rounded-xs bg-muted' />
|
|
144
|
-
<div className='flex items-center justify-between pt-1'>
|
|
145
|
-
<div className='h-1 w-6 rounded-xs bg-muted' />
|
|
146
|
-
<div className='h-3 w-5 rounded-xs bg-primary shadow-xs' />
|
|
147
|
-
</div>
|
|
148
|
-
</div>
|
|
149
|
-
</div>
|
|
150
|
-
</div>
|
|
151
|
-
</div>
|
|
152
|
-
<div className='mt-auto flex min-h-5 items-center justify-between gap-2 px-0.5'>
|
|
153
|
-
<span
|
|
154
|
-
className={cn(
|
|
155
|
-
'text-xs transition-colors',
|
|
156
|
-
isSelected
|
|
157
|
-
? 'font-medium text-foreground'
|
|
158
|
-
: 'text-muted-foreground group-hover:text-foreground',
|
|
159
|
-
)}
|
|
160
|
-
>
|
|
161
|
-
{t(item.labelKey, {
|
|
162
|
-
defaultValue:
|
|
163
|
-
item.id.charAt(0).toUpperCase() + item.id.slice(1),
|
|
164
|
-
})}
|
|
165
|
-
</span>
|
|
166
|
-
{isSelected && (
|
|
167
|
-
<span
|
|
168
|
-
aria-hidden='true'
|
|
169
|
-
className='flex size-3.5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground'
|
|
170
|
-
>
|
|
171
|
-
<Check className='size-2 stroke-[3]' />
|
|
172
|
-
</span>
|
|
173
|
-
)}
|
|
174
|
-
</div>
|
|
175
|
-
</label>
|
|
176
|
-
);
|
|
177
|
-
})}
|
|
178
|
-
</RadioGroup>
|
|
179
|
-
</fieldset>
|
|
180
|
-
</PopoverContent>
|
|
181
|
-
</Popover>
|
|
64
|
+
</DropdownMenuLabel>
|
|
65
|
+
{themePresets.map((item) => (
|
|
66
|
+
<DropdownMenuRadioItem
|
|
67
|
+
key={item.id}
|
|
68
|
+
value={item.id}
|
|
69
|
+
closeOnClick={false}
|
|
70
|
+
>
|
|
71
|
+
{t(item.labelKey, {
|
|
72
|
+
defaultValue:
|
|
73
|
+
item.id.charAt(0).toUpperCase() + item.id.slice(1),
|
|
74
|
+
})}
|
|
75
|
+
</DropdownMenuRadioItem>
|
|
76
|
+
))}
|
|
77
|
+
</DropdownMenuRadioGroup>
|
|
78
|
+
</DropdownMenuSubContent>
|
|
79
|
+
</DropdownMenuSub>
|
|
182
80
|
);
|
|
183
81
|
}
|
package/config.example.yml
CHANGED
|
@@ -69,27 +69,56 @@ secrets:
|
|
|
69
69
|
- version: 1
|
|
70
70
|
key: replace-with-a-unique-secret
|
|
71
71
|
|
|
72
|
+
# Optional shared sender for Auth, Users invitations and email notification channels. This example is commented out;
|
|
73
|
+
# server/config/mailers.ts defaults to an empty configuration, so example values do not enable mail delivery.
|
|
74
|
+
# `system` is only this template's sample name. Values are literal: ${NAME} is not expanded. The mapping explicitly
|
|
75
|
+
# declares MAILERS_SYSTEM_* variables; inject the password through MAILERS_SYSTEM_PASSWORD.
|
|
76
|
+
# mailers:
|
|
77
|
+
# default: system
|
|
78
|
+
# system:
|
|
79
|
+
# provider: smtp
|
|
80
|
+
# host: smtp.example.com
|
|
81
|
+
# port: 587
|
|
82
|
+
# secure: false
|
|
83
|
+
# auth:
|
|
84
|
+
# user: notifications@example.com
|
|
85
|
+
# from: App <notifications@example.com>
|
|
86
|
+
|
|
87
|
+
# For Resend, use a separate entry with provider: resend, apiKey and from; do not mix provider fields:
|
|
88
|
+
# mailers:
|
|
89
|
+
# default: system
|
|
90
|
+
# system:
|
|
91
|
+
# provider: resend
|
|
92
|
+
# apiKey: replace-with-the-api-key
|
|
93
|
+
# from: "Studio <notifications@example.com>"
|
|
94
|
+
#
|
|
95
|
+
# Equivalent SMTP .env example (use the Resend alternative below instead of its provider-specific SMTP fields):
|
|
96
|
+
# MAILERS_DEFAULT=system
|
|
97
|
+
# MAILERS_SYSTEM_PROVIDER=smtp
|
|
98
|
+
# MAILERS_SYSTEM_HOST=smtp.example.com
|
|
99
|
+
# MAILERS_SYSTEM_PORT=587
|
|
100
|
+
# MAILERS_SYSTEM_SECURE=false
|
|
101
|
+
# MAILERS_SYSTEM_USER=notifications@example.com
|
|
102
|
+
# MAILERS_SYSTEM_PASSWORD=replace-with-the-password
|
|
103
|
+
# MAILERS_SYSTEM_FROM=Studio <notifications@example.com>
|
|
104
|
+
# MAILERS_SYSTEM_REPLY_TO=support@example.com
|
|
105
|
+
# MAILERS_SYSTEM_ENABLED=true
|
|
106
|
+
#
|
|
107
|
+
# Resend .env alternative:
|
|
108
|
+
# MAILERS_DEFAULT=system
|
|
109
|
+
# MAILERS_SYSTEM_PROVIDER=resend
|
|
110
|
+
# MAILERS_SYSTEM_API_KEY=replace-with-the-api-key
|
|
111
|
+
# MAILERS_SYSTEM_FROM=Studio <notifications@example.com>
|
|
112
|
+
# MAILERS_SYSTEM_REPLY_TO=support@example.com
|
|
113
|
+
# MAILERS_SYSTEM_ENABLED=true
|
|
114
|
+
|
|
72
115
|
auth:
|
|
73
|
-
#
|
|
74
|
-
# Set app.publicOrigin (or APP_PUBLIC_ORIGIN) before enabling automatic recovery.
|
|
116
|
+
# Set app.publicOrigin before explicitly enabling automatic password recovery.
|
|
75
117
|
# passwordReset:
|
|
76
118
|
# enabled: true
|
|
77
119
|
# email:
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
# port: 587
|
|
81
|
-
# secure: false
|
|
82
|
-
# auth:
|
|
83
|
-
# user: noreply@example.com
|
|
84
|
-
# pass: replace-with-the-smtp-password
|
|
85
|
-
# from: App <noreply@example.com>
|
|
86
|
-
# replyTo: support@example.com
|
|
87
|
-
# For Resend, replace the entire email block with these fields; do not mix SMTP fields:
|
|
88
|
-
# email:
|
|
89
|
-
# provider: resend
|
|
90
|
-
# apiKey: replace-with-the-resend-api-key
|
|
91
|
-
# from: App <noreply@example.com>
|
|
92
|
-
# AUTH_EMAIL_PROVIDER/HOST/PORT/SECURE/USER/PASSWORD/FROM/REPLY_TO/API_KEY override auth.email.
|
|
120
|
+
# mailer: system # omit to use mailers.default
|
|
121
|
+
# Existing inline auth.email and AUTH_EMAIL_* remain supported for compatibility.
|
|
93
122
|
emailAndPassword:
|
|
94
123
|
enabled: true
|
|
95
124
|
autoSignIn: false
|
|
@@ -102,21 +131,11 @@ notification:
|
|
|
102
131
|
inbox:
|
|
103
132
|
type: in-app
|
|
104
133
|
|
|
105
|
-
#
|
|
106
|
-
# Values are read as written: ${NAME} is not expanded. To take a secret from the environment instead, map a
|
|
107
|
-
# variable onto its path in env of server/config/notification.ts, such as SMTP_PASSWORD.
|
|
134
|
+
# Configuring mailers alone does not create a notification channel.
|
|
108
135
|
# system-email:
|
|
109
|
-
# type:
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
# auth:
|
|
113
|
-
# user: notifications@example.com
|
|
114
|
-
# pass: replace-with-the-smtp-password
|
|
115
|
-
# from: notifications@example.com
|
|
116
|
-
# marketing-email:
|
|
117
|
-
# type: resend
|
|
118
|
-
# apiKey: replace-with-the-resend-api-key
|
|
119
|
-
# from: marketing@example.com
|
|
136
|
+
# type: email
|
|
137
|
+
# mailer: system # omit to use mailers.default
|
|
138
|
+
# Legacy type: smtp/resend inline channels and SMTP_PASSWORD/RESEND_API_KEY remain supported.
|
|
120
139
|
|
|
121
140
|
# ops-feishu:
|
|
122
141
|
# type: feishu-webhook
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/app-template-default",
|
|
3
3
|
"displayName": "Default Template",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.87",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"prettier": "@nocobase/dev-config/prettier",
|
|
7
7
|
"engines": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"nocobase": {
|
|
11
11
|
"templateKind": "app",
|
|
12
|
-
"defaultTemplateVersion": "1.0.0-beta.
|
|
12
|
+
"defaultTemplateVersion": "1.0.0-beta.87"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@ai-sdk/react": "4.0.131",
|
|
@@ -74,10 +74,10 @@
|
|
|
74
74
|
"elkjs": "^0.10.2",
|
|
75
75
|
"@types/jsdom": "^30.0.0",
|
|
76
76
|
"@noble/hashes": "2.3.0",
|
|
77
|
-
"@nocobase/app-client": "^4.0.0-beta.
|
|
77
|
+
"@nocobase/app-client": "^4.0.0-beta.7",
|
|
78
78
|
"@nocobase/app-plugin-template-print": "0.0.2-beta.2",
|
|
79
|
-
"@nocobase/app-skills": "0.1.0-beta.
|
|
80
|
-
"@nocobase/app-testing": "0.1.0-beta.
|
|
79
|
+
"@nocobase/app-skills": "0.1.0-beta.58",
|
|
80
|
+
"@nocobase/app-testing": "0.1.0-beta.8",
|
|
81
81
|
"@nocobase/app-websocket": "^0.1.0-beta.1",
|
|
82
82
|
"@nocobase/dev-config": "0.1.0-beta.25",
|
|
83
83
|
"@nocobase/drive": "^0.1.0-beta.7",
|
|
@@ -143,39 +143,39 @@
|
|
|
143
143
|
"better-auth": "1.7.5",
|
|
144
144
|
"hono": "^4.13.2",
|
|
145
145
|
"@nocobase/concurrency": "^0.1.0-beta.1",
|
|
146
|
-
"@nocobase/app-plugin-concurrency": "^0.1.0-beta.
|
|
147
|
-
"@nocobase/app-plugin-agents": "^2.0.0-beta.
|
|
148
|
-
"@nocobase/app-plugin-api-keys": "^1.0.0-beta.
|
|
149
|
-
"@nocobase/app-plugin-authentication": "^2.0.0-beta.
|
|
150
|
-
"@nocobase/app-plugin-authorization": "^1.0.0-beta.
|
|
151
|
-
"@nocobase/app-plugin-database-explorer": "^1.0.0-beta.
|
|
152
|
-
"@nocobase/app-plugin-file": "^1.0.0-beta.
|
|
153
|
-
"@nocobase/app-plugin-foundation": "^0.1.0-beta.
|
|
154
|
-
"@nocobase/app-plugin-i18n": "^1.0.0-beta.
|
|
155
|
-
"@nocobase/app-plugin-knowledge": "^0.1.0-beta.
|
|
156
|
-
"@nocobase/app-plugin-mail": "^1.0.0-beta.
|
|
157
|
-
"@nocobase/app-plugin-notification": "^2.0.0-beta.
|
|
158
|
-
"@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.
|
|
159
|
-
"@nocobase/app-plugin-notification-providers": "^1.0.0-beta.
|
|
160
|
-
"@nocobase/app-plugin-scheduler": "^1.0.0-beta.
|
|
161
|
-
"@nocobase/app-plugin-users": "^2.0.0-beta.
|
|
162
|
-
"@nocobase/app-server": "^
|
|
146
|
+
"@nocobase/app-plugin-concurrency": "^0.1.0-beta.2",
|
|
147
|
+
"@nocobase/app-plugin-agents": "^2.0.0-beta.3",
|
|
148
|
+
"@nocobase/app-plugin-api-keys": "^1.0.0-beta.21",
|
|
149
|
+
"@nocobase/app-plugin-authentication": "^2.0.0-beta.18",
|
|
150
|
+
"@nocobase/app-plugin-authorization": "^1.0.0-beta.35",
|
|
151
|
+
"@nocobase/app-plugin-database-explorer": "^1.0.0-beta.19",
|
|
152
|
+
"@nocobase/app-plugin-file": "^1.0.0-beta.29",
|
|
153
|
+
"@nocobase/app-plugin-foundation": "^0.1.0-beta.13",
|
|
154
|
+
"@nocobase/app-plugin-i18n": "^1.0.0-beta.20",
|
|
155
|
+
"@nocobase/app-plugin-knowledge": "^0.1.0-beta.17",
|
|
156
|
+
"@nocobase/app-plugin-mail": "^1.0.0-beta.21",
|
|
157
|
+
"@nocobase/app-plugin-notification": "^2.0.0-beta.4",
|
|
158
|
+
"@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.2",
|
|
159
|
+
"@nocobase/app-plugin-notification-providers": "^1.0.0-beta.18",
|
|
160
|
+
"@nocobase/app-plugin-scheduler": "^1.0.0-beta.23",
|
|
161
|
+
"@nocobase/app-plugin-users": "^2.0.0-beta.14",
|
|
162
|
+
"@nocobase/app-server": "^5.0.0-beta.0",
|
|
163
163
|
"@nocobase/config": "^0.1.0-beta.3",
|
|
164
|
-
"@nocobase/db": "^1.0.0-beta.
|
|
164
|
+
"@nocobase/db": "^1.0.0-beta.25",
|
|
165
165
|
"@nocobase/db-sqlite": "^0.1.0-beta.6",
|
|
166
166
|
"@nocobase/service-provider": "^0.0.2-beta.2",
|
|
167
167
|
"@nocobase/snowflake": "^1.0.0-beta.4",
|
|
168
|
-
"@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.
|
|
169
|
-
"@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.
|
|
170
|
-
"@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.
|
|
168
|
+
"@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.19",
|
|
169
|
+
"@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.18",
|
|
170
|
+
"@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.17",
|
|
171
171
|
"@nocobase/authorization": "^1.0.0-beta.16",
|
|
172
172
|
"@nocobase/queue": "^1.0.0-beta.10",
|
|
173
173
|
"@nocobase/jobs": "^1.0.0-beta.5",
|
|
174
174
|
"@nocobase/lifecycle": "^0.1.0-beta.4",
|
|
175
175
|
"@nocobase/i18n": "^2.0.0-beta.1",
|
|
176
|
-
"@nocobase/caching": "^
|
|
176
|
+
"@nocobase/caching": "^1.0.0-beta.4",
|
|
177
177
|
"@nocobase/repository-input": "^0.1.0-beta.3",
|
|
178
|
-
"@nocobase/app-cli": "^1.0.0-beta.
|
|
178
|
+
"@nocobase/app-cli": "^1.0.0-beta.32",
|
|
179
179
|
"@nocobase/realtime": "^1.0.0-beta.0"
|
|
180
180
|
},
|
|
181
181
|
"description": "NocoBase 3 application template with the standard product capabilities.",
|
package/server/config/auth.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { bearer, deviceAuthorization, username } from 'better-auth/plugins';
|
|
|
12
12
|
*/
|
|
13
13
|
const CLI_CLIENT_IDS: readonly string[] = ['nocobase-cli'];
|
|
14
14
|
|
|
15
|
-
// defineAuthConfig
|
|
15
|
+
// Use mailers for shared sending; defineAuthConfig retains AUTH_EMAIL_* for legacy inline senders.
|
|
16
16
|
// Automatic recovery requires passwordReset.enabled: true; leave the switch absent for custom callback compatibility.
|
|
17
17
|
const auth: AppConfigFactory<AuthConfig> = defineAuthConfig({
|
|
18
18
|
defaults: {
|
package/server/config/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import mailers from './mailers.js';
|
|
1
2
|
import {
|
|
2
3
|
defaultAppConfigs,
|
|
3
4
|
type AppConfigFactory,
|
|
@@ -26,6 +27,7 @@ import users from './users.js';
|
|
|
26
27
|
import knowledge from './knowledge.js';
|
|
27
28
|
|
|
28
29
|
const defaultConfigs: AppConfigFactory<{
|
|
30
|
+
mailers: ReturnType<typeof mailers>;
|
|
29
31
|
auth: ReturnType<typeof auth>;
|
|
30
32
|
authorization: ReturnType<typeof authorization>;
|
|
31
33
|
notification: ReturnType<typeof notification>;
|
|
@@ -49,6 +51,7 @@ const defaultConfigs: AppConfigFactory<{
|
|
|
49
51
|
users: ReturnType<typeof users>;
|
|
50
52
|
knowledge: ReturnType<typeof knowledge>;
|
|
51
53
|
}> = defaultAppConfigs({
|
|
54
|
+
mailers,
|
|
52
55
|
auth,
|
|
53
56
|
authorization,
|
|
54
57
|
notification,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineMailersConfig,
|
|
3
|
+
mailerEnvironment,
|
|
4
|
+
} from '@nocobase/app-server/mailers/config';
|
|
5
|
+
|
|
6
|
+
// These mappings are explicit and discoverable through `nocobase config env`.
|
|
7
|
+
// An empty section leaves email unavailable until an operator configures a named service.
|
|
8
|
+
export default defineMailersConfig({
|
|
9
|
+
defaults: {},
|
|
10
|
+
env: { ...mailerEnvironment('system', 'MAILERS_SYSTEM') },
|
|
11
|
+
});
|
|
@@ -83,7 +83,9 @@ describe('application shell', () => {
|
|
|
83
83
|
expect(
|
|
84
84
|
await screen.findByRole('button', { name: 'Open account menu' }),
|
|
85
85
|
).not.toHaveAttribute('title');
|
|
86
|
-
expect(
|
|
86
|
+
expect(
|
|
87
|
+
screen.queryByRole('button', { name: 'Theme' }),
|
|
88
|
+
).not.toBeInTheDocument();
|
|
87
89
|
expect(
|
|
88
90
|
screen.queryByRole('link', { name: 'Settings' }),
|
|
89
91
|
).not.toBeInTheDocument();
|
|
@@ -261,7 +263,9 @@ describe('application shell', () => {
|
|
|
261
263
|
expect(
|
|
262
264
|
screen.queryByRole('navigation', { name: 'Application navigation' }),
|
|
263
265
|
).not.toBeInTheDocument();
|
|
264
|
-
expect(
|
|
266
|
+
expect(
|
|
267
|
+
screen.queryByRole('button', { name: 'Theme' }),
|
|
268
|
+
).not.toBeInTheDocument();
|
|
265
269
|
});
|
|
266
270
|
});
|
|
267
271
|
|
|
@@ -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
|
+
});
|