@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.
- package/CHANGELOG.md +15 -0
- package/dist/__tests__/views/appearance-settings.render.test.js +47 -0
- package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/email-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/email-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/email-settings.render.test.js +184 -0
- package/dist/__tests__/views/email-settings.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +5 -3
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/BrandingCard.d.ts +1 -1
- package/dist/views/settings/BrandingCard.d.ts.map +1 -1
- package/dist/views/settings/BrandingCard.js +6 -2
- package/dist/views/settings/BrandingCard.js.map +1 -1
- package/dist/views/settings/EmailSettingsTab.d.ts +4 -0
- package/dist/views/settings/EmailSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/EmailSettingsTab.js +75 -0
- package/dist/views/settings/EmailSettingsTab.js.map +1 -0
- package/dist/views/settings/useAppearanceSettings.d.ts +10 -0
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
- package/dist/views/settings/useAppearanceSettings.js +54 -21
- package/dist/views/settings/useAppearanceSettings.js.map +1 -1
- package/dist/views/settings/useEmailSettings.d.ts +58 -0
- package/dist/views/settings/useEmailSettings.d.ts.map +1 -0
- package/dist/views/settings/useEmailSettings.js +209 -0
- package/dist/views/settings/useEmailSettings.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/views/appearance-settings.render.test.tsx +67 -0
- package/src/__tests__/views/email-settings.render.test.tsx +225 -0
- package/src/views/Settings.tsx +13 -0
- package/src/views/settings/BrandingCard.tsx +13 -7
- package/src/views/settings/EmailSettingsTab.tsx +436 -0
- package/src/views/settings/useAppearanceSettings.ts +57 -21
- 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.
|
|
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.
|
|
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
|
+
})
|
package/src/views/Settings.tsx
CHANGED
|
@@ -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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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). */}
|