@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,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
+ }