@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.
@@ -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.
@@ -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 = getInitials(name);
43
+ const initials = getAvatarInitials(name, {
44
+ cjk: Boolean(identity?.fullName),
45
+ });
43
46
 
44
47
  return (
45
48
  <DropdownMenu>
@@ -74,6 +77,7 @@ export function UserMenu(): ReactElement {
74
77
  </div>
75
78
  <DropdownMenuSeparator />
76
79
  <LanguageSwitcher />
80
+ <ThemeSettings />
77
81
  <DropdownMenuSeparator />
78
82
  <DropdownMenuItem
79
83
  disabled={isLoggingOut}
@@ -120,14 +124,3 @@ export function UserMenu(): ReactElement {
120
124
  </DropdownMenu>
121
125
  );
122
126
  }
123
-
124
- function getInitials(name: string): string {
125
- const words = name.trim().split(/\s+/).filter(Boolean);
126
- if (words.length === 0) {
127
- return '?';
128
- }
129
- return words
130
- .slice(0, 2)
131
- .map((word) => word[0]?.toUpperCase() ?? '')
132
- .join('');
133
- }
@@ -367,7 +367,7 @@ const systemEnUS = {
367
367
  },
368
368
 
369
369
  appearance: {
370
- title: 'Appearance',
370
+ title: 'Theme',
371
371
  mode: 'Color mode',
372
372
  preset: 'Theme',
373
373
  light: 'Light',
@@ -344,7 +344,7 @@ const systemZhCN: SystemResource = {
344
344
  },
345
345
 
346
346
  appearance: {
347
- title: '外观',
347
+ title: '主题',
348
348
  mode: '颜色模式',
349
349
  preset: '主题',
350
350
  light: '浅色',
@@ -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 { Check, Monitor, Moon, Palette, Sun } from 'lucide-react';
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 { useId, type ReactElement } from 'react';
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
- Popover,
9
- PopoverTrigger,
10
- PopoverContent,
11
- PopoverTitle,
12
- } from '#components/ui/popover';
13
- import { cn } from 'cn';
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
- <fieldset className='space-y-2'>
57
- <legend className='text-xs font-medium text-muted-foreground'>
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
- </legend>
60
- <RadioGroup
61
- name={id + '-mode'}
62
- value={theme}
63
- onValueChange={(value) => setTheme(String(value))}
64
- aria-label={t('appearance.mode', { defaultValue: 'Color mode' })}
65
- className='grid-cols-3 gap-1 rounded-lg bg-muted/70 p-1'
66
- >
67
- {COLOR_MODES.map(({ id: mode, icon: ModeIcon }) => {
68
- const isSelected = theme === mode;
69
- return (
70
- <label
71
- key={mode}
72
- className={cn(
73
- 'relative flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors select-none',
74
- isSelected
75
- ? 'bg-background text-foreground shadow-xs'
76
- : 'text-muted-foreground hover:bg-background/40 hover:text-foreground',
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
- </legend>
98
- <RadioGroup
99
- name={id + '-preset'}
100
- value={preset}
101
- onValueChange={(value) => {
102
- const selected = themePresets.find((item) => item.id === value);
103
- if (selected) setPreset(selected.id);
104
- }}
105
- aria-label={t('appearance.preset', { defaultValue: 'Theme' })}
106
- className='grid-cols-2 gap-2.5'
107
- >
108
- {themePresets.map((item) => {
109
- const isSelected = preset === item.id;
110
- return (
111
- <label
112
- key={item.id}
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
  }
@@ -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
- # Authentication owns this sender; notification and mailbox configuration are independent.
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
- # provider: smtp
79
- # host: smtp.example.com
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
- # Uncomment and configure independent email Channels.
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: smtp
110
- # host: smtp.example.com
111
- # port: 587
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.85",
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.85"
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.5",
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.55",
80
- "@nocobase/app-testing": "0.1.0-beta.6",
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",
@@ -142,41 +142,41 @@
142
142
  "@oclif/core": "^4.14.0",
143
143
  "better-auth": "1.7.5",
144
144
  "hono": "^4.13.2",
145
- "@nocobase/concurrency": "^0.0.2-beta.0",
146
- "@nocobase/app-plugin-concurrency": "^0.1.0-beta.0",
147
- "@nocobase/app-plugin-agents": "^2.0.0-beta.0",
148
- "@nocobase/app-plugin-api-keys": "^1.0.0-beta.19",
149
- "@nocobase/app-plugin-authentication": "^2.0.0-beta.16",
150
- "@nocobase/app-plugin-authorization": "^1.0.0-beta.33",
151
- "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.17",
152
- "@nocobase/app-plugin-file": "^1.0.0-beta.27",
153
- "@nocobase/app-plugin-foundation": "^0.1.0-beta.10",
154
- "@nocobase/app-plugin-i18n": "^1.0.0-beta.18",
155
- "@nocobase/app-plugin-knowledge": "^0.1.0-beta.15",
156
- "@nocobase/app-plugin-mail": "^1.0.0-beta.18",
157
- "@nocobase/app-plugin-notification": "^2.0.0-beta.2",
158
- "@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.0",
159
- "@nocobase/app-plugin-notification-providers": "^1.0.0-beta.16",
160
- "@nocobase/app-plugin-scheduler": "^1.0.0-beta.21",
161
- "@nocobase/app-plugin-users": "^2.0.0-beta.11",
162
- "@nocobase/app-server": "^3.0.0-beta.5",
145
+ "@nocobase/concurrency": "^0.1.0-beta.1",
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.24",
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.17",
169
- "@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.16",
170
- "@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.15",
171
- "@nocobase/authorization": "^1.0.0-beta.15",
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
+ "@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": "^0.1.0-beta.3",
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.30",
179
- "@nocobase/realtime": "^0.1.0-beta.3"
178
+ "@nocobase/app-cli": "^1.0.0-beta.32",
179
+ "@nocobase/realtime": "^1.0.0-beta.0"
180
180
  },
181
181
  "description": "NocoBase 3 application template with the standard product capabilities.",
182
182
  "imports": {
@@ -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 supplies AUTH_EMAIL_* mappings and validates the private auth.email sender.
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: {
@@ -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(screen.getByRole('button', { name: 'Appearance' })).toBeVisible();
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(screen.getByRole('button', { name: 'Appearance' })).toBeVisible();
266
+ expect(
267
+ screen.queryByRole('button', { name: 'Theme' }),
268
+ ).not.toBeInTheDocument();
265
269
  });
266
270
  });
267
271