@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,436 @@
1
+ 'use client'
2
+
3
+ import { AlertTriangle, CheckCircle2, Info, KeyRound, Loader2, Send } from 'lucide-react'
4
+ import { useId } from 'react'
5
+
6
+ import { SettingsCard, SettingsSaveBar, SettingsToggleRow } from './components.js'
7
+ import {
8
+ EMAIL_PROVIDER_OPTIONS,
9
+ useEmailSettings,
10
+ type EmailProviderKey,
11
+ type UseEmailSettings,
12
+ } from './useEmailSettings.js'
13
+
14
+ const INPUT_CLASS =
15
+ 'w-full max-w-md rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
16
+ const LABEL_CLASS = 'mb-1 block text-sm font-medium text-foreground'
17
+ const HELP_CLASS = 'mt-1 text-sm text-muted-foreground'
18
+
19
+ const PROVIDER_LABEL: Record<EmailProviderKey, string> = {
20
+ resend: 'Resend',
21
+ smtpcom: 'SMTP.com',
22
+ ses: 'Amazon SES',
23
+ smtp: 'SMTP server',
24
+ }
25
+
26
+ function Field({
27
+ id,
28
+ label,
29
+ help,
30
+ children,
31
+ }: {
32
+ id: string
33
+ label: string
34
+ help?: string
35
+ children: React.ReactNode
36
+ }) {
37
+ return (
38
+ <div>
39
+ <label htmlFor={id} className={LABEL_CLASS}>
40
+ {label}
41
+ </label>
42
+ {children}
43
+ {help && <p className={HELP_CLASS}>{help}</p>}
44
+ </div>
45
+ )
46
+ }
47
+
48
+ function SecretField({
49
+ id,
50
+ label,
51
+ value,
52
+ onChange,
53
+ disabled,
54
+ hasStored,
55
+ last4,
56
+ help,
57
+ }: {
58
+ id: string
59
+ label: string
60
+ value: string
61
+ onChange: (v: string) => void
62
+ disabled: boolean
63
+ hasStored: boolean
64
+ last4?: string
65
+ help?: string
66
+ }) {
67
+ return (
68
+ <Field
69
+ id={id}
70
+ label={label}
71
+ help={
72
+ hasStored && !value
73
+ ? `A ${label.toLowerCase()} is saved${last4 ? ` (ends in ${last4})` : ''}. Enter a new value to replace it.`
74
+ : help
75
+ }
76
+ >
77
+ <div className="relative max-w-md">
78
+ <KeyRound
79
+ size={16}
80
+ aria-hidden="true"
81
+ className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
82
+ />
83
+ <input
84
+ id={id}
85
+ type="password"
86
+ autoComplete="off"
87
+ value={value}
88
+ disabled={disabled}
89
+ onChange={(e) => onChange(e.target.value)}
90
+ placeholder={hasStored ? `••••••••${last4 ? last4 : ''}` : 'Enter value'}
91
+ className={`${INPUT_CLASS} pl-9`}
92
+ />
93
+ </div>
94
+ </Field>
95
+ )
96
+ }
97
+
98
+ function Notice({
99
+ tone,
100
+ children,
101
+ }: {
102
+ tone: 'info' | 'warning' | 'success' | 'error'
103
+ children: React.ReactNode
104
+ }) {
105
+ const toneClass =
106
+ tone === 'warning'
107
+ ? 'border-warning/30 bg-warning/10 text-warning'
108
+ : tone === 'success'
109
+ ? 'border-success/30 bg-success/10 text-success'
110
+ : tone === 'error'
111
+ ? 'border-destructive/30 bg-destructive/10 text-destructive'
112
+ : 'border-border bg-muted text-muted-foreground'
113
+ const Icon = tone === 'success' ? CheckCircle2 : tone === 'info' ? Info : AlertTriangle
114
+ return (
115
+ <div
116
+ role="status"
117
+ aria-live="polite"
118
+ className={`flex items-start gap-2.5 rounded-lg border p-3 text-sm ${toneClass}`}
119
+ >
120
+ <Icon size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
121
+ <div className="min-w-0">{children}</div>
122
+ </div>
123
+ )
124
+ }
125
+
126
+ function ProviderFields({ s, canEdit }: { s: UseEmailSettings; canEdit: boolean }) {
127
+ const ids = {
128
+ from: useId(),
129
+ apiKey: useId(),
130
+ channel: useId(),
131
+ region: useId(),
132
+ host: useId(),
133
+ port: useId(),
134
+ user: useId(),
135
+ pass: useId(),
136
+ }
137
+ const { form, setField, stored } = s
138
+ const disabled = !canEdit
139
+ if (form.provider === '') return null
140
+
141
+ const sameProvider = stored.sameProvider
142
+
143
+ return (
144
+ <div className="space-y-4">
145
+ <Field
146
+ id={ids.from}
147
+ label="Sender address"
148
+ help="The From address for invitations, password resets, and form notifications. Must be verified with your provider."
149
+ >
150
+ <input
151
+ id={ids.from}
152
+ type="email"
153
+ value={form.from}
154
+ disabled={disabled}
155
+ onChange={(e) => setField('from', e.target.value)}
156
+ placeholder="noreply@yoursite.com"
157
+ className={INPUT_CLASS}
158
+ />
159
+ </Field>
160
+
161
+ {(form.provider === 'resend' || form.provider === 'smtpcom') && (
162
+ <SecretField
163
+ id={ids.apiKey}
164
+ label="API key"
165
+ value={form.apiKey}
166
+ onChange={(v) => setField('apiKey', v)}
167
+ disabled={disabled}
168
+ hasStored={sameProvider && stored.hasApiKey}
169
+ last4={stored.apiKeyLast4}
170
+ help={
171
+ form.provider === 'resend'
172
+ ? 'Your Resend API key (starts with re_). Stored encrypted.'
173
+ : 'Your SMTP.com v4 API key. Stored encrypted.'
174
+ }
175
+ />
176
+ )}
177
+
178
+ {form.provider === 'smtpcom' && (
179
+ <Field
180
+ id={ids.channel}
181
+ label="Sender channel"
182
+ help="Optional. The SMTP.com channel to send through; defaults to your account default."
183
+ >
184
+ <input
185
+ id={ids.channel}
186
+ type="text"
187
+ value={form.channel}
188
+ disabled={disabled}
189
+ onChange={(e) => setField('channel', e.target.value)}
190
+ placeholder="default"
191
+ className={INPUT_CLASS}
192
+ />
193
+ </Field>
194
+ )}
195
+
196
+ {form.provider === 'ses' && (
197
+ <Field
198
+ id={ids.region}
199
+ label="AWS region"
200
+ help="SES sends with your deployment's AWS credentials (env vars or IAM role); only the region is stored here."
201
+ >
202
+ <input
203
+ id={ids.region}
204
+ type="text"
205
+ value={form.region}
206
+ disabled={disabled}
207
+ onChange={(e) => setField('region', e.target.value)}
208
+ placeholder="us-east-1"
209
+ className={INPUT_CLASS}
210
+ />
211
+ </Field>
212
+ )}
213
+
214
+ {form.provider === 'smtp' && (
215
+ <>
216
+ <div className="grid max-w-md grid-cols-1 gap-4 sm:grid-cols-[1fr_8rem]">
217
+ <Field id={ids.host} label="Host">
218
+ <input
219
+ id={ids.host}
220
+ type="text"
221
+ value={form.host}
222
+ disabled={disabled}
223
+ onChange={(e) => setField('host', e.target.value)}
224
+ placeholder="smtp.example.com"
225
+ className={INPUT_CLASS}
226
+ />
227
+ </Field>
228
+ <Field id={ids.port} label="Port">
229
+ <input
230
+ id={ids.port}
231
+ type="text"
232
+ inputMode="numeric"
233
+ value={form.port}
234
+ disabled={disabled}
235
+ onChange={(e) => setField('port', e.target.value.replace(/[^\d]/g, ''))}
236
+ placeholder="587"
237
+ className={INPUT_CLASS}
238
+ />
239
+ </Field>
240
+ </div>
241
+ <div className="max-w-md">
242
+ <SettingsToggleRow
243
+ label="Implicit TLS"
244
+ description="Enable for port 465. Leave off for STARTTLS (port 587, the common default)."
245
+ checked={form.secure}
246
+ disabled={disabled}
247
+ onChange={(v) => setField('secure', v)}
248
+ />
249
+ </div>
250
+ <Field
251
+ id={ids.user}
252
+ label="Username"
253
+ help="Leave blank for relays that authenticate by IP address."
254
+ >
255
+ <input
256
+ id={ids.user}
257
+ type="text"
258
+ autoComplete="off"
259
+ value={form.smtpUser}
260
+ disabled={disabled}
261
+ onChange={(e) => setField('smtpUser', e.target.value)}
262
+ className={INPUT_CLASS}
263
+ />
264
+ </Field>
265
+ <SecretField
266
+ id={ids.pass}
267
+ label="Password"
268
+ value={form.smtpPass}
269
+ onChange={(v) => setField('smtpPass', v)}
270
+ disabled={disabled}
271
+ hasStored={sameProvider && stored.hasSmtpPass}
272
+ help="Stored encrypted."
273
+ />
274
+ </>
275
+ )}
276
+ </div>
277
+ )
278
+ }
279
+
280
+ export function EmailSettingsTab({ role }: { role?: string }) {
281
+ const canEdit = String(role ?? '').toUpperCase() === 'ADMIN'
282
+ const s = useEmailSettings(canEdit)
283
+ const providerGroupId = useId()
284
+
285
+ if (s.loading) {
286
+ return (
287
+ <div className="flex h-48 items-center justify-center" aria-label="Loading email settings">
288
+ <Loader2 className="text-brand h-6 w-6 animate-spin" aria-hidden="true" />
289
+ </div>
290
+ )
291
+ }
292
+
293
+ if (s.loadError) {
294
+ return (
295
+ <div
296
+ role="alert"
297
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
298
+ >
299
+ <AlertTriangle size={20} className="shrink-0" aria-hidden="true" />
300
+ <span className="flex-1">{s.loadError}</span>
301
+ <button
302
+ onClick={s.reload}
303
+ className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
304
+ >
305
+ Retry
306
+ </button>
307
+ </div>
308
+ )
309
+ }
310
+
311
+ const configured = s.form.provider !== ''
312
+
313
+ return (
314
+ <div className="space-y-4">
315
+ {!canEdit && (
316
+ <Notice tone="info">
317
+ You have read-only access to email settings. Ask an administrator to make changes.
318
+ </Notice>
319
+ )}
320
+
321
+ {s.platformAdapter && (
322
+ <Notice tone="info">
323
+ A platform email adapter is configured in code (<code>platform.email</code>); it takes
324
+ precedence over the settings on this page.
325
+ </Notice>
326
+ )}
327
+
328
+ {!s.platformAdapter && s.env.provider && (
329
+ <Notice tone="info">
330
+ Environment variables currently configure{' '}
331
+ <strong>{PROVIDER_LABEL[s.env.provider]}</strong>. Settings saved here take precedence
332
+ over the environment configuration.
333
+ </Notice>
334
+ )}
335
+
336
+ {configured && !s.factoryAvailable && (
337
+ <Notice tone="warning">
338
+ Sending with these settings requires the email plugin. Add <code>emailPlugin()</code> from{' '}
339
+ <code>@actuate-media/plugin-email</code> to your <code>actuate.config.ts</code>.
340
+ </Notice>
341
+ )}
342
+
343
+ <SettingsCard
344
+ title="Email delivery"
345
+ description="Transactional email provider for invitations, password resets, and form notifications. Credentials are encrypted at rest."
346
+ actions={
347
+ <button
348
+ type="button"
349
+ onClick={() => void s.sendTest()}
350
+ disabled={s.testing || s.dirty || (!configured && !s.env.senderActive)}
351
+ title={s.dirty ? 'Save your changes before sending a test.' : undefined}
352
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
353
+ >
354
+ {s.testing ? (
355
+ <Loader2 size={14} className="animate-spin" aria-hidden="true" />
356
+ ) : (
357
+ <Send size={14} aria-hidden="true" />
358
+ )}
359
+ Send test email
360
+ </button>
361
+ }
362
+ >
363
+ <div className="space-y-5">
364
+ <div>
365
+ <span id={providerGroupId} className={LABEL_CLASS}>
366
+ Provider
367
+ </span>
368
+ <div
369
+ role="radiogroup"
370
+ aria-labelledby={providerGroupId}
371
+ className="flex flex-wrap gap-2"
372
+ >
373
+ {[{ value: '' as const, label: 'None' }, ...EMAIL_PROVIDER_OPTIONS].map((opt) => {
374
+ const active = s.form.provider === opt.value
375
+ return (
376
+ <button
377
+ key={opt.value || 'none'}
378
+ type="button"
379
+ role="radio"
380
+ aria-checked={active}
381
+ disabled={!canEdit}
382
+ onClick={() => s.setField('provider', opt.value)}
383
+ className={`rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
384
+ active
385
+ ? 'border-brand bg-brand/10 text-brand font-medium'
386
+ : 'border-border text-muted-foreground hover:text-foreground hover:bg-accent'
387
+ }`}
388
+ >
389
+ {opt.label}
390
+ </button>
391
+ )
392
+ })}
393
+ </div>
394
+ {!configured && (
395
+ <p className={HELP_CLASS}>
396
+ {s.env.senderActive
397
+ ? 'No provider is configured here, but an environment-configured sender is active.'
398
+ : 'No email provider configured. Transactional email (invites, password resets, form notifications) is skipped.'}
399
+ </p>
400
+ )}
401
+ </div>
402
+
403
+ <ProviderFields s={s} canEdit={canEdit} />
404
+
405
+ {s.testResult && (
406
+ <Notice tone={s.testResult.success ? 'success' : 'error'}>
407
+ {s.testResult.message}
408
+ </Notice>
409
+ )}
410
+ {!s.testResult && s.lastTest.status && (
411
+ <p className="text-muted-foreground text-sm">
412
+ Last test {s.lastTest.status === 'success' ? 'succeeded' : `failed`}
413
+ {s.lastTest.testedAt ? ` on ${new Date(s.lastTest.testedAt).toLocaleString()}` : ''}
414
+ {s.lastTest.status === 'failed' && s.lastTest.error ? ` — ${s.lastTest.error}` : ''}
415
+ </p>
416
+ )}
417
+ </div>
418
+
419
+ {s.saveError && (
420
+ <p className="text-destructive mt-4 text-sm" role="alert">
421
+ {s.saveError}
422
+ </p>
423
+ )}
424
+ </SettingsCard>
425
+
426
+ <SettingsSaveBar
427
+ dirty={s.dirty}
428
+ canEdit={canEdit}
429
+ hasErrors={false}
430
+ saveState={s.saveState}
431
+ onSave={() => void s.save()}
432
+ onReset={s.reset}
433
+ />
434
+ </div>
435
+ )
436
+ }
@@ -84,10 +84,38 @@ export interface SelectedBrandAsset {
84
84
  height?: number
85
85
  }
86
86
 
87
- const LOGO_MIME = ['image/svg+xml', 'image/png']
88
- const FAVICON_MIME = ['image/svg+xml', 'image/png']
87
+ /**
88
+ * Formats accepted for brand logos. Everything here is also validated
89
+ * server-side (allowlist + magic bytes) in cms-core's upload security.
90
+ * Brand uploads are stored as-is (`skipOptimize`), so each format keeps
91
+ * its native fidelity — including WebP, which general media uploads are
92
+ * converted to anyway.
93
+ */
94
+ export const LOGO_MIME = [
95
+ 'image/svg+xml',
96
+ 'image/png',
97
+ 'image/webp',
98
+ 'image/jpeg',
99
+ 'image/gif',
100
+ 'image/avif',
101
+ ]
102
+ /** Favicons need crisp edges at small sizes — vector or lossless/modern raster only. */
103
+ export const FAVICON_MIME = ['image/svg+xml', 'image/png', 'image/webp']
89
104
  const MAX_BYTES = 1024 * 1024 // 1 MB
90
105
 
106
+ const MIME_LABEL: Record<string, string> = {
107
+ 'image/svg+xml': 'SVG',
108
+ 'image/png': 'PNG',
109
+ 'image/webp': 'WebP',
110
+ 'image/jpeg': 'JPEG',
111
+ 'image/gif': 'GIF',
112
+ 'image/avif': 'AVIF',
113
+ }
114
+
115
+ function formatAllowed(mimes: string[]): string {
116
+ return mimes.map((m) => MIME_LABEL[m] ?? m).join(', ')
117
+ }
118
+
91
119
  export interface UseAppearanceSettings {
92
120
  loading: boolean
93
121
  loadError: string | null
@@ -163,19 +191,25 @@ async function detectShape(file: File): Promise<LogoShape> {
163
191
  }
164
192
  return 'unknown'
165
193
  }
166
- return new Promise<LogoShape>((resolve) => {
167
- const url = URL.createObjectURL(file)
168
- const img = new Image()
169
- img.onload = () => {
170
- URL.revokeObjectURL(url)
171
- resolve(classifyLogoShape(img.naturalWidth, img.naturalHeight))
172
- }
173
- img.onerror = () => {
174
- URL.revokeObjectURL(url)
175
- resolve('unknown')
176
- }
177
- img.src = url
178
- })
194
+ // Shape detection only powers advisory warnings — it must never block or
195
+ // fail an upload, so any decode problem resolves to 'unknown'.
196
+ try {
197
+ return await new Promise<LogoShape>((resolve) => {
198
+ const url = URL.createObjectURL(file)
199
+ const img = new Image()
200
+ img.onload = () => {
201
+ URL.revokeObjectURL(url)
202
+ resolve(classifyLogoShape(img.naturalWidth, img.naturalHeight))
203
+ }
204
+ img.onerror = () => {
205
+ URL.revokeObjectURL(url)
206
+ resolve('unknown')
207
+ }
208
+ img.src = url
209
+ })
210
+ } catch {
211
+ return 'unknown'
212
+ }
179
213
  }
180
214
 
181
215
  function assetToCache(
@@ -344,7 +378,7 @@ export function useAppearanceSettings(
344
378
  const isFavicon = field === 'faviconSourceAssetId'
345
379
  const allowed = isFavicon ? FAVICON_MIME : LOGO_MIME
346
380
  if (!allowed.includes(file.type)) {
347
- setSaveError(`Unsupported file type. Allowed: ${allowed.join(', ')}.`)
381
+ setSaveError(`Unsupported file type. Allowed: ${formatAllowed(allowed)}.`)
348
382
  return
349
383
  }
350
384
  if (file.size > MAX_BYTES) {
@@ -357,8 +391,8 @@ export function useAppearanceSettings(
357
391
  const shape = await detectShape(file)
358
392
  const form = new FormData()
359
393
  form.append('file', file)
360
- // Preserve original format (SVG/PNG/ICO fidelity) — brand assets must
361
- // not be transcoded to WebP.
394
+ // Preserve the original format — brand assets must not be transcoded
395
+ // (SVG stays vector, PNG keeps transparency, WebP/AVIF stay as-is).
362
396
  form.append('skipOptimize', 'true')
363
397
  const res = await cmsApi<{ id: string; url?: string; mimeType?: string }>('/media/upload', {
364
398
  method: 'POST',
@@ -388,12 +422,14 @@ export function useAppearanceSettings(
388
422
 
389
423
  const selectAsset = useCallback(
390
424
  (field: BrandAssetField, item: SelectedBrandAsset) => {
391
- // Same allow-list as upload: brand assets must be SVG or PNG, regardless
392
- // of what else lives in the media library.
425
+ // Same allow-list as upload, regardless of what else lives in the
426
+ // media library (which also holds documents, video, etc.).
393
427
  const isFavicon = field === 'faviconSourceAssetId'
394
428
  const allowed = isFavicon ? FAVICON_MIME : LOGO_MIME
395
429
  if (!allowed.includes(item.mimeType)) {
396
- setSaveError(`Unsupported file type. Brand assets must be ${allowed.join(' or ')}.`)
430
+ setSaveError(
431
+ `Unsupported file type. ${isFavicon ? 'Favicons' : 'Brand assets'} must be ${formatAllowed(allowed)}.`,
432
+ )
397
433
  return
398
434
  }
399
435
  setSaveError(null)