@actuate-media/cms-admin 0.42.4 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/__tests__/views/appearance-settings.render.test.js +47 -0
  3. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
  4. package/dist/__tests__/views/email-settings.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/email-settings.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/email-settings.render.test.js +184 -0
  7. package/dist/__tests__/views/email-settings.render.test.js.map +1 -0
  8. package/dist/actuate-admin.css +1 -1
  9. package/dist/views/Settings.d.ts.map +1 -1
  10. package/dist/views/Settings.js +5 -3
  11. package/dist/views/Settings.js.map +1 -1
  12. package/dist/views/settings/BrandingCard.d.ts +1 -1
  13. package/dist/views/settings/BrandingCard.d.ts.map +1 -1
  14. package/dist/views/settings/BrandingCard.js +6 -2
  15. package/dist/views/settings/BrandingCard.js.map +1 -1
  16. package/dist/views/settings/EmailSettingsTab.d.ts +4 -0
  17. package/dist/views/settings/EmailSettingsTab.d.ts.map +1 -0
  18. package/dist/views/settings/EmailSettingsTab.js +75 -0
  19. package/dist/views/settings/EmailSettingsTab.js.map +1 -0
  20. package/dist/views/settings/useAppearanceSettings.d.ts +10 -0
  21. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  22. package/dist/views/settings/useAppearanceSettings.js +54 -21
  23. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  24. package/dist/views/settings/useEmailSettings.d.ts +58 -0
  25. package/dist/views/settings/useEmailSettings.d.ts.map +1 -0
  26. package/dist/views/settings/useEmailSettings.js +209 -0
  27. package/dist/views/settings/useEmailSettings.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/__tests__/views/appearance-settings.render.test.tsx +67 -0
  30. package/src/__tests__/views/email-settings.render.test.tsx +225 -0
  31. package/src/views/Settings.tsx +13 -0
  32. package/src/views/settings/BrandingCard.tsx +13 -7
  33. package/src/views/settings/EmailSettingsTab.tsx +436 -0
  34. package/src/views/settings/useAppearanceSettings.ts +57 -21
  35. package/src/views/settings/useEmailSettings.ts +296 -0
@@ -0,0 +1,209 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { cmsApi } from '../../lib/api.js';
4
+ export const EMAIL_PROVIDER_OPTIONS = [
5
+ { value: 'resend', label: 'Resend' },
6
+ { value: 'smtpcom', label: 'SMTP.com' },
7
+ { value: 'ses', label: 'Amazon SES' },
8
+ { value: 'smtp', label: 'SMTP server' },
9
+ ];
10
+ const EMPTY_FORM = {
11
+ provider: '',
12
+ from: '',
13
+ apiKey: '',
14
+ channel: '',
15
+ region: '',
16
+ host: '',
17
+ port: '',
18
+ secure: false,
19
+ smtpUser: '',
20
+ smtpPass: '',
21
+ };
22
+ function toForm(s) {
23
+ return {
24
+ provider: s.provider ?? '',
25
+ from: s.from ?? '',
26
+ apiKey: '',
27
+ channel: s.channel ?? '',
28
+ region: s.region ?? '',
29
+ host: s.host ?? '',
30
+ port: s.port != null ? String(s.port) : '',
31
+ secure: s.secure ?? false,
32
+ smtpUser: s.smtpUser ?? '',
33
+ smtpPass: '',
34
+ };
35
+ }
36
+ function snapshot(f) {
37
+ return JSON.stringify(f);
38
+ }
39
+ export function useEmailSettings(canEdit) {
40
+ const [loading, setLoading] = useState(true);
41
+ const [loadError, setLoadError] = useState(null);
42
+ const [form, setForm] = useState(EMPTY_FORM);
43
+ const [meta, setMeta] = useState({
44
+ envProvider: null,
45
+ factoryAvailable: false,
46
+ envSenderActive: false,
47
+ platformAdapter: false,
48
+ });
49
+ const [storedSecrets, setStoredSecrets] = useState({
50
+ provider: null,
51
+ hasApiKey: false,
52
+ apiKeyLast4: undefined,
53
+ hasSmtpPass: false,
54
+ });
55
+ const [lastTest, setLastTest] = useState({});
56
+ const [saveState, setSaveState] = useState('idle');
57
+ const [saveError, setSaveError] = useState(null);
58
+ const [testing, setTesting] = useState(false);
59
+ const [testResult, setTestResult] = useState(null);
60
+ const baselineRef = useRef(snapshot(EMPTY_FORM));
61
+ const applyResponse = useCallback((d) => {
62
+ const nextForm = toForm(d.settings);
63
+ setForm(nextForm);
64
+ baselineRef.current = snapshot(nextForm);
65
+ setMeta({
66
+ envProvider: d.envProvider,
67
+ factoryAvailable: d.factoryAvailable,
68
+ envSenderActive: d.envSenderActive,
69
+ platformAdapter: d.platformAdapter,
70
+ });
71
+ setStoredSecrets({
72
+ provider: d.settings.provider,
73
+ hasApiKey: d.settings.hasApiKey,
74
+ apiKeyLast4: d.settings.apiKeyLast4,
75
+ hasSmtpPass: d.settings.hasSmtpPass,
76
+ });
77
+ setLastTest({
78
+ status: d.settings.lastTestStatus,
79
+ testedAt: d.settings.lastTestedAt,
80
+ error: d.settings.lastTestError,
81
+ });
82
+ }, []);
83
+ const load = useCallback(async () => {
84
+ setLoading(true);
85
+ setLoadError(null);
86
+ const res = await cmsApi('/email/settings');
87
+ if (res.error || !res.data) {
88
+ setLoadError(res.error ?? 'Failed to load email settings');
89
+ setLoading(false);
90
+ return;
91
+ }
92
+ applyResponse(res.data);
93
+ setSaveState('idle');
94
+ setLoading(false);
95
+ }, [applyResponse]);
96
+ useEffect(() => {
97
+ void load();
98
+ }, [load]);
99
+ const setField = useCallback((key, value) => {
100
+ setForm((prev) => ({ ...prev, [key]: value }));
101
+ setSaveState('idle');
102
+ setSaveError(null);
103
+ setTestResult(null);
104
+ }, []);
105
+ const dirty = useMemo(() => snapshot(form) !== baselineRef.current, [form]);
106
+ const save = useCallback(async () => {
107
+ if (!canEdit)
108
+ return;
109
+ setSaveState('saving');
110
+ setSaveError(null);
111
+ let body;
112
+ if (form.provider === '') {
113
+ body = { provider: null };
114
+ }
115
+ else {
116
+ body = {
117
+ provider: form.provider,
118
+ from: form.from.trim(),
119
+ // Secrets: omit = keep stored value; include only when re-entered.
120
+ ...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
121
+ ...(form.provider === 'smtpcom' && form.channel.trim()
122
+ ? { channel: form.channel.trim() }
123
+ : {}),
124
+ ...(form.provider === 'ses' ? { region: form.region.trim() } : {}),
125
+ ...(form.provider === 'smtp'
126
+ ? {
127
+ host: form.host.trim(),
128
+ ...(form.port.trim() ? { port: Number(form.port) } : {}),
129
+ secure: form.secure,
130
+ ...(form.smtpUser.trim() ? { smtpUser: form.smtpUser.trim() } : {}),
131
+ ...(form.smtpPass ? { smtpPass: form.smtpPass } : {}),
132
+ }
133
+ : {}),
134
+ };
135
+ }
136
+ const res = await cmsApi('/email/settings', {
137
+ method: 'PUT',
138
+ body: JSON.stringify(body),
139
+ });
140
+ if (res.error || !res.data) {
141
+ setSaveState('error');
142
+ setSaveError(res.error ?? 'Failed to save email settings');
143
+ return;
144
+ }
145
+ const nextForm = toForm(res.data.settings);
146
+ setForm(nextForm);
147
+ baselineRef.current = snapshot(nextForm);
148
+ setStoredSecrets({
149
+ provider: res.data.settings.provider,
150
+ hasApiKey: res.data.settings.hasApiKey,
151
+ apiKeyLast4: res.data.settings.apiKeyLast4,
152
+ hasSmtpPass: res.data.settings.hasSmtpPass,
153
+ });
154
+ setLastTest({});
155
+ setSaveState('success');
156
+ }, [canEdit, form]);
157
+ const sendTest = useCallback(async () => {
158
+ setTesting(true);
159
+ setTestResult(null);
160
+ const res = await cmsApi('/email/settings/test', {
161
+ method: 'POST',
162
+ body: JSON.stringify({}),
163
+ });
164
+ setTesting(false);
165
+ if (res.error || !res.data?.success) {
166
+ const message = res.error ?? 'Test send failed';
167
+ setTestResult({ success: false, message });
168
+ setLastTest({ status: 'failed', testedAt: new Date().toISOString(), error: message });
169
+ return;
170
+ }
171
+ setTestResult({
172
+ success: true,
173
+ message: `Test email sent to ${res.data.sentTo ?? 'your address'}.`,
174
+ });
175
+ setLastTest({ status: 'success', testedAt: new Date().toISOString() });
176
+ }, []);
177
+ const reset = useCallback(() => {
178
+ setForm(JSON.parse(baselineRef.current));
179
+ setSaveState('idle');
180
+ setSaveError(null);
181
+ setTestResult(null);
182
+ }, []);
183
+ return {
184
+ loading,
185
+ loadError,
186
+ form,
187
+ stored: {
188
+ hasApiKey: storedSecrets.hasApiKey,
189
+ apiKeyLast4: storedSecrets.apiKeyLast4,
190
+ hasSmtpPass: storedSecrets.hasSmtpPass,
191
+ sameProvider: form.provider !== '' && form.provider === storedSecrets.provider,
192
+ },
193
+ env: { provider: meta.envProvider, senderActive: meta.envSenderActive },
194
+ factoryAvailable: meta.factoryAvailable,
195
+ platformAdapter: meta.platformAdapter,
196
+ lastTest,
197
+ dirty,
198
+ saveState,
199
+ saveError,
200
+ testing,
201
+ testResult,
202
+ setField,
203
+ save,
204
+ sendTest,
205
+ reset,
206
+ reload: () => void load(),
207
+ };
208
+ }
209
+ //# sourceMappingURL=useEmailSettings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useEmailSettings.js","sourceRoot":"","sources":["../../../src/views/settings/useEmailSettings.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.42.4",
3
+ "version": "0.44.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -89,7 +89,7 @@
89
89
  "tailwindcss": "^4.0.0",
90
90
  "typescript": "^5.7.0",
91
91
  "vitest": "^4.1.0",
92
- "@actuate-media/cms-core": "0.61.0",
92
+ "@actuate-media/cms-core": "0.62.0",
93
93
  "@actuate-media/component-blocks": "0.2.1"
94
94
  },
95
95
  "scripts": {
@@ -41,6 +41,7 @@ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?
41
41
  vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any) }))
42
42
 
43
43
  const { AppearanceTab } = await import('../../views/settings/AppearanceTab.js')
44
+ const { LOGO_MIME, FAVICON_MIME } = await import('../../views/settings/useAppearanceSettings.js')
44
45
 
45
46
  function baseData(overrides: Record<string, unknown> = {}) {
46
47
  return {
@@ -165,6 +166,72 @@ describe('AppearanceTab', () => {
165
166
  )
166
167
  })
167
168
 
169
+ it('accepts modern image formats for logos (and a crisp subset for favicons)', () => {
170
+ // WebP is a first-class brand format — general media uploads are converted
171
+ // to WebP anyway, so re-selecting them from the library must work.
172
+ expect(LOGO_MIME).toEqual(
173
+ expect.arrayContaining([
174
+ 'image/svg+xml',
175
+ 'image/png',
176
+ 'image/webp',
177
+ 'image/jpeg',
178
+ 'image/gif',
179
+ 'image/avif',
180
+ ]),
181
+ )
182
+ expect(FAVICON_MIME).toEqual(
183
+ expect.arrayContaining(['image/svg+xml', 'image/png', 'image/webp']),
184
+ )
185
+ // Favicons need crisp small-size rendering — lossy photographic formats stay out.
186
+ expect(FAVICON_MIME).not.toContain('image/jpeg')
187
+ })
188
+
189
+ it('uploads a WebP logo through the Media Center', async () => {
190
+ // happy-dom's Image never decodes blobs — fire onload so shape detection
191
+ // (raster path, used for all non-SVG formats) completes.
192
+ vi.stubGlobal(
193
+ 'Image',
194
+ class {
195
+ naturalWidth = 400
196
+ naturalHeight = 100
197
+ onload: (() => void) | null = null
198
+ onerror: (() => void) | null = null
199
+ set src(_v: string) {
200
+ queueMicrotask(() => this.onload?.())
201
+ }
202
+ },
203
+ )
204
+
205
+ render(<AppearanceTab role="ADMIN" />)
206
+ await screen.findByText('Branding')
207
+
208
+ const input = screen.getByLabelText('Primary logo') as HTMLInputElement
209
+ const file = new File([new Uint8Array([0x52, 0x49, 0x46, 0x46])], 'logo.webp', {
210
+ type: 'image/webp',
211
+ })
212
+ fireEvent.change(input, { target: { files: [file] } })
213
+
214
+ await waitFor(() =>
215
+ expect(cmsApi).toHaveBeenCalledWith(
216
+ '/media/upload',
217
+ expect.objectContaining({ method: 'POST' }),
218
+ ),
219
+ )
220
+ vi.unstubAllGlobals()
221
+ })
222
+
223
+ it('rejects an unsupported upload type client-side with a readable error', async () => {
224
+ render(<AppearanceTab role="ADMIN" />)
225
+ await screen.findByText('Branding')
226
+
227
+ const input = screen.getByLabelText('Primary logo') as HTMLInputElement
228
+ const file = new File(['%PDF-1.4'], 'brochure.pdf', { type: 'application/pdf' })
229
+ fireEvent.change(input, { target: { files: [file] } })
230
+
231
+ expect(await screen.findByText(/unsupported file type/i)).toBeTruthy()
232
+ expect(cmsApi.mock.calls.some(([endpoint]) => endpoint === '/media/upload')).toBe(false)
233
+ })
234
+
168
235
  it('is read-only for non-admins', async () => {
169
236
  render(<AppearanceTab role="EDITOR" />)
170
237
 
@@ -0,0 +1,225 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ /**
6
+ * Settings > Email: provider picker + per-provider credential fields backed by
7
+ * GET/PUT /email/settings and POST /email/settings/test. Secrets are
8
+ * write-only — the API returns only `hasApiKey` / `apiKeyLast4`, never the key.
9
+ */
10
+
11
+ interface EmailSettingsData {
12
+ settings: Record<string, unknown>
13
+ envProvider: string | null
14
+ factoryAvailable: boolean
15
+ envSenderActive: boolean
16
+ platformAdapter: boolean
17
+ }
18
+
19
+ const EMPTY_RESPONSE: EmailSettingsData = {
20
+ settings: { provider: null, hasApiKey: false, hasSmtpPass: false },
21
+ envProvider: null,
22
+ factoryAvailable: true,
23
+ envSenderActive: false,
24
+ platformAdapter: false,
25
+ }
26
+
27
+ let getResponse: EmailSettingsData = EMPTY_RESPONSE
28
+ let putResponse: { settings: Record<string, unknown> } | null = null
29
+ let testResponse: { data?: unknown; error?: string; status: number } = {
30
+ data: { success: true, sentTo: 'admin@example.com' },
31
+ status: 200,
32
+ }
33
+
34
+ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?: string }) => {
35
+ const method = options?.method ?? 'GET'
36
+ if (endpoint === '/email/settings' && method === 'GET') {
37
+ return { data: getResponse, status: 200 }
38
+ }
39
+ if (endpoint === '/email/settings' && method === 'PUT') {
40
+ return putResponse
41
+ ? { data: putResponse, status: 200 }
42
+ : { error: 'unexpected PUT', status: 500 }
43
+ }
44
+ if (endpoint === '/email/settings/test' && method === 'POST') {
45
+ return testResponse
46
+ }
47
+ return { data: {}, status: 200 }
48
+ })
49
+
50
+ vi.mock('../../lib/api.js', () => ({
51
+ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any),
52
+ ensureCsrfToken: vi.fn(async () => {}),
53
+ }))
54
+
55
+ const { EmailSettingsTab } = await import('../../views/settings/EmailSettingsTab.js')
56
+
57
+ function deepClone<T>(v: T): T {
58
+ return JSON.parse(JSON.stringify(v))
59
+ }
60
+
61
+ beforeEach(() => {
62
+ cmsApi.mockClear()
63
+ getResponse = deepClone(EMPTY_RESPONSE)
64
+ putResponse = null
65
+ testResponse = { data: { success: true, sentTo: 'admin@example.com' }, status: 200 }
66
+ })
67
+
68
+ describe('EmailSettingsTab', () => {
69
+ it('renders the provider picker with the unconfigured state', async () => {
70
+ render(<EmailSettingsTab role="ADMIN" />)
71
+ expect(await screen.findByRole('radio', { name: 'None' })).toBeTruthy()
72
+ expect(screen.getByRole('radio', { name: 'Resend' })).toBeTruthy()
73
+ expect(screen.getByRole('radio', { name: 'SMTP.com' })).toBeTruthy()
74
+ expect(screen.getByRole('radio', { name: 'Amazon SES' })).toBeTruthy()
75
+ expect(screen.getByRole('radio', { name: 'SMTP server' })).toBeTruthy()
76
+ expect(screen.getByText(/No email provider configured/)).toBeTruthy()
77
+ })
78
+
79
+ it('saves an SMTP.com configuration with the entered key (PUT payload contract)', async () => {
80
+ putResponse = {
81
+ settings: {
82
+ provider: 'smtpcom',
83
+ from: 'noreply@example.com',
84
+ channel: 'transactional',
85
+ hasApiKey: true,
86
+ apiKeyLast4: '-key',
87
+ hasSmtpPass: false,
88
+ },
89
+ }
90
+ render(<EmailSettingsTab role="ADMIN" />)
91
+ fireEvent.click(await screen.findByRole('radio', { name: 'SMTP.com' }))
92
+
93
+ fireEvent.change(screen.getByLabelText('Sender address'), {
94
+ target: { value: 'noreply@example.com' },
95
+ })
96
+ fireEvent.change(screen.getByLabelText('API key'), {
97
+ target: { value: 'smtpcom-secret-key' },
98
+ })
99
+ fireEvent.change(screen.getByLabelText('Sender channel'), {
100
+ target: { value: 'transactional' },
101
+ })
102
+
103
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
104
+
105
+ await waitFor(() => {
106
+ const put = cmsApi.mock.calls.find(
107
+ ([e, o]) => e === '/email/settings' && (o as any)?.method === 'PUT',
108
+ )
109
+ expect(put).toBeTruthy()
110
+ expect(JSON.parse((put![1] as any).body)).toEqual({
111
+ provider: 'smtpcom',
112
+ from: 'noreply@example.com',
113
+ apiKey: 'smtpcom-secret-key',
114
+ channel: 'transactional',
115
+ })
116
+ })
117
+
118
+ // After save the key input is cleared and the masked hint appears.
119
+ expect((screen.getByLabelText('API key') as HTMLInputElement).value).toBe('')
120
+ expect(await screen.findByText(/ends in -key/)).toBeTruthy()
121
+ })
122
+
123
+ it('omits the apiKey from the payload when a stored key is kept', async () => {
124
+ getResponse = {
125
+ ...deepClone(EMPTY_RESPONSE),
126
+ settings: {
127
+ provider: 'resend',
128
+ from: 'old@example.com',
129
+ hasApiKey: true,
130
+ apiKeyLast4: 'k3y9',
131
+ hasSmtpPass: false,
132
+ },
133
+ }
134
+ putResponse = {
135
+ settings: {
136
+ provider: 'resend',
137
+ from: 'new@example.com',
138
+ hasApiKey: true,
139
+ apiKeyLast4: 'k3y9',
140
+ hasSmtpPass: false,
141
+ },
142
+ }
143
+ render(<EmailSettingsTab role="ADMIN" />)
144
+ const from = await screen.findByLabelText('Sender address')
145
+ expect(await screen.findByText(/ends in k3y9/)).toBeTruthy()
146
+
147
+ fireEvent.change(from, { target: { value: 'new@example.com' } })
148
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
149
+
150
+ await waitFor(() => {
151
+ const put = cmsApi.mock.calls.find(
152
+ ([e, o]) => e === '/email/settings' && (o as any)?.method === 'PUT',
153
+ )
154
+ expect(put).toBeTruthy()
155
+ const body = JSON.parse((put![1] as any).body)
156
+ expect(body).toEqual({ provider: 'resend', from: 'new@example.com' })
157
+ expect(body).not.toHaveProperty('apiKey')
158
+ })
159
+ })
160
+
161
+ it('sends a test email and shows the result', async () => {
162
+ getResponse = {
163
+ ...deepClone(EMPTY_RESPONSE),
164
+ settings: {
165
+ provider: 'resend',
166
+ from: 'a@b.co',
167
+ hasApiKey: true,
168
+ apiKeyLast4: 'k3y9',
169
+ hasSmtpPass: false,
170
+ },
171
+ }
172
+ render(<EmailSettingsTab role="ADMIN" />)
173
+ fireEvent.click(await screen.findByRole('button', { name: /Send test email/ }))
174
+ await waitFor(() =>
175
+ expect(cmsApi).toHaveBeenCalledWith(
176
+ '/email/settings/test',
177
+ expect.objectContaining({ method: 'POST' }),
178
+ ),
179
+ )
180
+ expect(await screen.findByText(/Test email sent to admin@example.com/)).toBeTruthy()
181
+ })
182
+
183
+ it('surfaces a failed test send', async () => {
184
+ getResponse = {
185
+ ...deepClone(EMPTY_RESPONSE),
186
+ settings: {
187
+ provider: 'resend',
188
+ from: 'a@b.co',
189
+ hasApiKey: true,
190
+ hasSmtpPass: false,
191
+ },
192
+ }
193
+ testResponse = { error: 'SMTP.com API error (401): bad key', status: 502 }
194
+ render(<EmailSettingsTab role="ADMIN" />)
195
+ fireEvent.click(await screen.findByRole('button', { name: /Send test email/ }))
196
+ expect(await screen.findByText(/SMTP\.com API error \(401\)/)).toBeTruthy()
197
+ })
198
+
199
+ it('shows the env-managed notice and disables editing for non-admins', async () => {
200
+ getResponse = {
201
+ ...deepClone(EMPTY_RESPONSE),
202
+ envProvider: 'resend',
203
+ envSenderActive: true,
204
+ }
205
+ render(<EmailSettingsTab role="EDITOR" />)
206
+ expect(await screen.findByText(/read-only access/)).toBeTruthy()
207
+ expect(screen.getByText(/Environment variables currently configure/)).toBeTruthy()
208
+ expect((screen.getByRole('radio', { name: 'Resend' }) as HTMLButtonElement).disabled).toBe(true)
209
+ })
210
+
211
+ it('warns when the email plugin is missing for a configured provider', async () => {
212
+ getResponse = {
213
+ ...deepClone(EMPTY_RESPONSE),
214
+ factoryAvailable: false,
215
+ settings: {
216
+ provider: 'resend',
217
+ from: 'a@b.co',
218
+ hasApiKey: true,
219
+ hasSmtpPass: false,
220
+ },
221
+ }
222
+ render(<EmailSettingsTab role="ADMIN" />)
223
+ expect(await screen.findByText(/requires the email plugin/)).toBeTruthy()
224
+ })
225
+ })
@@ -7,6 +7,7 @@ import {
7
7
  AlertTriangle,
8
8
  Download,
9
9
  Layers,
10
+ Mail,
10
11
  Users as UsersIcon,
11
12
  KeyRound,
12
13
  } from 'lucide-react'
@@ -18,6 +19,7 @@ import { RelationshipField } from '../fields/RelationshipField.js'
18
19
  import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
19
20
  import { SeoSettingsTab } from './settings/SeoSettingsTab.js'
20
21
  import { AISettingsTab } from './settings/AISettingsTab.js'
22
+ import { EmailSettingsTab } from './settings/EmailSettingsTab.js'
21
23
  import { AppearanceTab } from './settings/AppearanceTab.js'
22
24
  import { SecurityTab } from './settings/SecurityTab.js'
23
25
  import { SettingsCard } from './settings/components.js'
@@ -85,6 +87,7 @@ export function Settings({
85
87
  'users',
86
88
  'api-keys',
87
89
  'seo',
90
+ 'email',
88
91
  'ai',
89
92
  'updates',
90
93
  ] as const,
@@ -214,6 +217,12 @@ export function Settings({
214
217
  <Tabs.Trigger value="seo" className={tabTriggerClass}>
215
218
  SEO
216
219
  </Tabs.Trigger>
220
+ <Tabs.Trigger value="email" className={tabTriggerClass}>
221
+ <span className="flex items-center gap-1.5">
222
+ <Mail className="h-4 w-4" />
223
+ Email
224
+ </span>
225
+ </Tabs.Trigger>
217
226
  <Tabs.Trigger value="ai" className={tabTriggerClass}>
218
227
  <span className="flex items-center gap-1.5">
219
228
  <Bot className="h-4 w-4" />
@@ -311,6 +320,10 @@ export function Settings({
311
320
  <SeoSettingsTab onNavigate={onNavigate} role={session?.user?.role} />
312
321
  </Tabs.Content>
313
322
 
323
+ <Tabs.Content value="email" className="space-y-4">
324
+ <EmailSettingsTab role={session?.user?.role} />
325
+ </Tabs.Content>
326
+
314
327
  <Tabs.Content value="ai" className="space-y-4">
315
328
  <AISettingsTab role={session?.user?.role} />
316
329
  </Tabs.Content>
@@ -14,13 +14,18 @@ import { useRef, useState } from 'react'
14
14
 
15
15
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
16
16
  import { SettingsCard, SettingsControlRow, SettingsSubsection } from './components.js'
17
- import type {
18
- BrandAssetField,
19
- SelectedBrandAsset,
20
- UseAppearanceSettings,
17
+ import {
18
+ FAVICON_MIME,
19
+ LOGO_MIME,
20
+ type BrandAssetField,
21
+ type SelectedBrandAsset,
22
+ type UseAppearanceSettings,
21
23
  } from './useAppearanceSettings.js'
22
24
 
23
- const LOGO_ACCEPT = 'image/svg+xml,image/png'
25
+ // Derive the file-input accept strings from the validation allow-lists so the
26
+ // picker and the validator can never drift apart.
27
+ const LOGO_ACCEPT = LOGO_MIME.join(',')
28
+ const FAVICON_ACCEPT = FAVICON_MIME.join(',')
24
29
 
25
30
  function previewSurface(dark: boolean): string {
26
31
  return dark ? 'bg-foreground border-border' : 'bg-muted border-border'
@@ -243,7 +248,7 @@ export function BrandingCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit
243
248
  <div className="space-y-5">
244
249
  <LogoField
245
250
  label="Primary logo"
246
- helper="SVG or PNG, up to 1MB. Best for the public header and full admin sidebar."
251
+ helper="SVG, PNG, WebP, JPEG, GIF, or AVIF, up to 1MB. Best for the public header and full admin sidebar."
247
252
  field="publicPrimaryLogoAssetId"
248
253
  warning={warningFor('publicPrimaryLogoAssetId')}
249
254
  {...logoFieldProps}
@@ -251,11 +256,12 @@ export function BrandingCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit
251
256
 
252
257
  <LogoField
253
258
  label="Favicon / app icon source"
254
- helper="32×32 PNG or SVG. A larger square image is fine — it scales down cleanly."
259
+ helper="32×32 SVG, PNG, or WebP. A larger square image is fine — it scales down cleanly."
255
260
  field="faviconSourceAssetId"
256
261
  square
257
262
  warning={warningFor('faviconSourceAssetId')}
258
263
  {...logoFieldProps}
264
+ accept={FAVICON_ACCEPT}
259
265
  />
260
266
 
261
267
  {/* Advanced logo variants disclosure (Logo mark + Dark mode logo). */}