@actuate-media/cms-admin 0.43.0 → 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 +9 -0
- 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/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/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/email-settings.render.test.tsx +225 -0
- package/src/views/Settings.tsx +13 -0
- package/src/views/settings/EmailSettingsTab.tsx +436 -0
- package/src/views/settings/useEmailSettings.ts +296 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { cmsApi } from '../../lib/api.js'
|
|
6
|
+
|
|
7
|
+
export type EmailProviderKey = 'resend' | 'smtpcom' | 'ses' | 'smtp'
|
|
8
|
+
export type SaveState = 'idle' | 'saving' | 'success' | 'error'
|
|
9
|
+
|
|
10
|
+
export const EMAIL_PROVIDER_OPTIONS: Array<{ value: EmailProviderKey; label: string }> = [
|
|
11
|
+
{ value: 'resend', label: 'Resend' },
|
|
12
|
+
{ value: 'smtpcom', label: 'SMTP.com' },
|
|
13
|
+
{ value: 'ses', label: 'Amazon SES' },
|
|
14
|
+
{ value: 'smtp', label: 'SMTP server' },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
/** Form state. Secrets are write-only: inputs start empty and never echo stored values. */
|
|
18
|
+
export interface EmailSettingsForm {
|
|
19
|
+
/** Empty string = email delivery not configured here. */
|
|
20
|
+
provider: EmailProviderKey | ''
|
|
21
|
+
from: string
|
|
22
|
+
apiKey: string
|
|
23
|
+
channel: string
|
|
24
|
+
region: string
|
|
25
|
+
host: string
|
|
26
|
+
port: string
|
|
27
|
+
secure: boolean
|
|
28
|
+
smtpUser: string
|
|
29
|
+
smtpPass: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface EmailSettingsResponse {
|
|
33
|
+
settings: {
|
|
34
|
+
provider: EmailProviderKey | null
|
|
35
|
+
from?: string
|
|
36
|
+
apiKeyLast4?: string
|
|
37
|
+
hasApiKey: boolean
|
|
38
|
+
channel?: string
|
|
39
|
+
region?: string
|
|
40
|
+
host?: string
|
|
41
|
+
port?: number
|
|
42
|
+
secure?: boolean
|
|
43
|
+
smtpUser?: string
|
|
44
|
+
hasSmtpPass: boolean
|
|
45
|
+
lastTestStatus?: 'success' | 'failed'
|
|
46
|
+
lastTestedAt?: string
|
|
47
|
+
lastTestError?: string
|
|
48
|
+
}
|
|
49
|
+
envProvider: EmailProviderKey | null
|
|
50
|
+
factoryAvailable: boolean
|
|
51
|
+
envSenderActive: boolean
|
|
52
|
+
platformAdapter: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const EMPTY_FORM: EmailSettingsForm = {
|
|
56
|
+
provider: '',
|
|
57
|
+
from: '',
|
|
58
|
+
apiKey: '',
|
|
59
|
+
channel: '',
|
|
60
|
+
region: '',
|
|
61
|
+
host: '',
|
|
62
|
+
port: '',
|
|
63
|
+
secure: false,
|
|
64
|
+
smtpUser: '',
|
|
65
|
+
smtpPass: '',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toForm(s: EmailSettingsResponse['settings']): EmailSettingsForm {
|
|
69
|
+
return {
|
|
70
|
+
provider: s.provider ?? '',
|
|
71
|
+
from: s.from ?? '',
|
|
72
|
+
apiKey: '',
|
|
73
|
+
channel: s.channel ?? '',
|
|
74
|
+
region: s.region ?? '',
|
|
75
|
+
host: s.host ?? '',
|
|
76
|
+
port: s.port != null ? String(s.port) : '',
|
|
77
|
+
secure: s.secure ?? false,
|
|
78
|
+
smtpUser: s.smtpUser ?? '',
|
|
79
|
+
smtpPass: '',
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function snapshot(f: EmailSettingsForm): string {
|
|
84
|
+
return JSON.stringify(f)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface UseEmailSettings {
|
|
88
|
+
loading: boolean
|
|
89
|
+
loadError: string | null
|
|
90
|
+
form: EmailSettingsForm
|
|
91
|
+
/** Masked metadata about stored secrets (never the secret itself). */
|
|
92
|
+
stored: {
|
|
93
|
+
hasApiKey: boolean
|
|
94
|
+
apiKeyLast4?: string
|
|
95
|
+
hasSmtpPass: boolean
|
|
96
|
+
sameProvider: boolean
|
|
97
|
+
}
|
|
98
|
+
env: { provider: EmailProviderKey | null; senderActive: boolean }
|
|
99
|
+
factoryAvailable: boolean
|
|
100
|
+
platformAdapter: boolean
|
|
101
|
+
lastTest: { status?: 'success' | 'failed'; testedAt?: string; error?: string }
|
|
102
|
+
dirty: boolean
|
|
103
|
+
saveState: SaveState
|
|
104
|
+
saveError: string | null
|
|
105
|
+
testing: boolean
|
|
106
|
+
testResult: { success: boolean; message: string } | null
|
|
107
|
+
setField: <K extends keyof EmailSettingsForm>(key: K, value: EmailSettingsForm[K]) => void
|
|
108
|
+
save: () => Promise<void>
|
|
109
|
+
sendTest: () => Promise<void>
|
|
110
|
+
reset: () => void
|
|
111
|
+
reload: () => void
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function useEmailSettings(canEdit: boolean): UseEmailSettings {
|
|
115
|
+
const [loading, setLoading] = useState(true)
|
|
116
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
117
|
+
const [form, setForm] = useState<EmailSettingsForm>(EMPTY_FORM)
|
|
118
|
+
const [meta, setMeta] = useState<Omit<EmailSettingsResponse, 'settings'>>({
|
|
119
|
+
envProvider: null,
|
|
120
|
+
factoryAvailable: false,
|
|
121
|
+
envSenderActive: false,
|
|
122
|
+
platformAdapter: false,
|
|
123
|
+
})
|
|
124
|
+
const [storedSecrets, setStoredSecrets] = useState({
|
|
125
|
+
provider: null as EmailProviderKey | null,
|
|
126
|
+
hasApiKey: false,
|
|
127
|
+
apiKeyLast4: undefined as string | undefined,
|
|
128
|
+
hasSmtpPass: false,
|
|
129
|
+
})
|
|
130
|
+
const [lastTest, setLastTest] = useState<UseEmailSettings['lastTest']>({})
|
|
131
|
+
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
132
|
+
const [saveError, setSaveError] = useState<string | null>(null)
|
|
133
|
+
const [testing, setTesting] = useState(false)
|
|
134
|
+
const [testResult, setTestResult] = useState<UseEmailSettings['testResult']>(null)
|
|
135
|
+
|
|
136
|
+
const baselineRef = useRef<string>(snapshot(EMPTY_FORM))
|
|
137
|
+
|
|
138
|
+
const applyResponse = useCallback((d: EmailSettingsResponse) => {
|
|
139
|
+
const nextForm = toForm(d.settings)
|
|
140
|
+
setForm(nextForm)
|
|
141
|
+
baselineRef.current = snapshot(nextForm)
|
|
142
|
+
setMeta({
|
|
143
|
+
envProvider: d.envProvider,
|
|
144
|
+
factoryAvailable: d.factoryAvailable,
|
|
145
|
+
envSenderActive: d.envSenderActive,
|
|
146
|
+
platformAdapter: d.platformAdapter,
|
|
147
|
+
})
|
|
148
|
+
setStoredSecrets({
|
|
149
|
+
provider: d.settings.provider,
|
|
150
|
+
hasApiKey: d.settings.hasApiKey,
|
|
151
|
+
apiKeyLast4: d.settings.apiKeyLast4,
|
|
152
|
+
hasSmtpPass: d.settings.hasSmtpPass,
|
|
153
|
+
})
|
|
154
|
+
setLastTest({
|
|
155
|
+
status: d.settings.lastTestStatus,
|
|
156
|
+
testedAt: d.settings.lastTestedAt,
|
|
157
|
+
error: d.settings.lastTestError,
|
|
158
|
+
})
|
|
159
|
+
}, [])
|
|
160
|
+
|
|
161
|
+
const load = useCallback(async () => {
|
|
162
|
+
setLoading(true)
|
|
163
|
+
setLoadError(null)
|
|
164
|
+
const res = await cmsApi<EmailSettingsResponse>('/email/settings')
|
|
165
|
+
if (res.error || !res.data) {
|
|
166
|
+
setLoadError(res.error ?? 'Failed to load email settings')
|
|
167
|
+
setLoading(false)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
applyResponse(res.data)
|
|
171
|
+
setSaveState('idle')
|
|
172
|
+
setLoading(false)
|
|
173
|
+
}, [applyResponse])
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
void load()
|
|
177
|
+
}, [load])
|
|
178
|
+
|
|
179
|
+
const setField = useCallback(
|
|
180
|
+
<K extends keyof EmailSettingsForm>(key: K, value: EmailSettingsForm[K]) => {
|
|
181
|
+
setForm((prev) => ({ ...prev, [key]: value }))
|
|
182
|
+
setSaveState('idle')
|
|
183
|
+
setSaveError(null)
|
|
184
|
+
setTestResult(null)
|
|
185
|
+
},
|
|
186
|
+
[],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const dirty = useMemo(() => snapshot(form) !== baselineRef.current, [form])
|
|
190
|
+
|
|
191
|
+
const save = useCallback(async () => {
|
|
192
|
+
if (!canEdit) return
|
|
193
|
+
setSaveState('saving')
|
|
194
|
+
setSaveError(null)
|
|
195
|
+
|
|
196
|
+
let body: Record<string, unknown>
|
|
197
|
+
if (form.provider === '') {
|
|
198
|
+
body = { provider: null }
|
|
199
|
+
} else {
|
|
200
|
+
body = {
|
|
201
|
+
provider: form.provider,
|
|
202
|
+
from: form.from.trim(),
|
|
203
|
+
// Secrets: omit = keep stored value; include only when re-entered.
|
|
204
|
+
...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
|
|
205
|
+
...(form.provider === 'smtpcom' && form.channel.trim()
|
|
206
|
+
? { channel: form.channel.trim() }
|
|
207
|
+
: {}),
|
|
208
|
+
...(form.provider === 'ses' ? { region: form.region.trim() } : {}),
|
|
209
|
+
...(form.provider === 'smtp'
|
|
210
|
+
? {
|
|
211
|
+
host: form.host.trim(),
|
|
212
|
+
...(form.port.trim() ? { port: Number(form.port) } : {}),
|
|
213
|
+
secure: form.secure,
|
|
214
|
+
...(form.smtpUser.trim() ? { smtpUser: form.smtpUser.trim() } : {}),
|
|
215
|
+
...(form.smtpPass ? { smtpPass: form.smtpPass } : {}),
|
|
216
|
+
}
|
|
217
|
+
: {}),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const res = await cmsApi<{ settings: EmailSettingsResponse['settings'] }>('/email/settings', {
|
|
222
|
+
method: 'PUT',
|
|
223
|
+
body: JSON.stringify(body),
|
|
224
|
+
})
|
|
225
|
+
if (res.error || !res.data) {
|
|
226
|
+
setSaveState('error')
|
|
227
|
+
setSaveError(res.error ?? 'Failed to save email settings')
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
const nextForm = toForm(res.data.settings)
|
|
231
|
+
setForm(nextForm)
|
|
232
|
+
baselineRef.current = snapshot(nextForm)
|
|
233
|
+
setStoredSecrets({
|
|
234
|
+
provider: res.data.settings.provider,
|
|
235
|
+
hasApiKey: res.data.settings.hasApiKey,
|
|
236
|
+
apiKeyLast4: res.data.settings.apiKeyLast4,
|
|
237
|
+
hasSmtpPass: res.data.settings.hasSmtpPass,
|
|
238
|
+
})
|
|
239
|
+
setLastTest({})
|
|
240
|
+
setSaveState('success')
|
|
241
|
+
}, [canEdit, form])
|
|
242
|
+
|
|
243
|
+
const sendTest = useCallback(async () => {
|
|
244
|
+
setTesting(true)
|
|
245
|
+
setTestResult(null)
|
|
246
|
+
const res = await cmsApi<{ success: boolean; sentTo?: string }>('/email/settings/test', {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
body: JSON.stringify({}),
|
|
249
|
+
})
|
|
250
|
+
setTesting(false)
|
|
251
|
+
if (res.error || !res.data?.success) {
|
|
252
|
+
const message = res.error ?? 'Test send failed'
|
|
253
|
+
setTestResult({ success: false, message })
|
|
254
|
+
setLastTest({ status: 'failed', testedAt: new Date().toISOString(), error: message })
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
setTestResult({
|
|
258
|
+
success: true,
|
|
259
|
+
message: `Test email sent to ${res.data.sentTo ?? 'your address'}.`,
|
|
260
|
+
})
|
|
261
|
+
setLastTest({ status: 'success', testedAt: new Date().toISOString() })
|
|
262
|
+
}, [])
|
|
263
|
+
|
|
264
|
+
const reset = useCallback(() => {
|
|
265
|
+
setForm(JSON.parse(baselineRef.current) as EmailSettingsForm)
|
|
266
|
+
setSaveState('idle')
|
|
267
|
+
setSaveError(null)
|
|
268
|
+
setTestResult(null)
|
|
269
|
+
}, [])
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
loading,
|
|
273
|
+
loadError,
|
|
274
|
+
form,
|
|
275
|
+
stored: {
|
|
276
|
+
hasApiKey: storedSecrets.hasApiKey,
|
|
277
|
+
apiKeyLast4: storedSecrets.apiKeyLast4,
|
|
278
|
+
hasSmtpPass: storedSecrets.hasSmtpPass,
|
|
279
|
+
sameProvider: form.provider !== '' && form.provider === storedSecrets.provider,
|
|
280
|
+
},
|
|
281
|
+
env: { provider: meta.envProvider, senderActive: meta.envSenderActive },
|
|
282
|
+
factoryAvailable: meta.factoryAvailable,
|
|
283
|
+
platformAdapter: meta.platformAdapter,
|
|
284
|
+
lastTest,
|
|
285
|
+
dirty,
|
|
286
|
+
saveState,
|
|
287
|
+
saveError,
|
|
288
|
+
testing,
|
|
289
|
+
testResult,
|
|
290
|
+
setField,
|
|
291
|
+
save,
|
|
292
|
+
sendTest,
|
|
293
|
+
reset,
|
|
294
|
+
reload: () => void load(),
|
|
295
|
+
}
|
|
296
|
+
}
|