@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,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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
URL.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
|
361
|
-
//
|
|
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
|
|
392
|
-
//
|
|
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(
|
|
430
|
+
setSaveError(
|
|
431
|
+
`Unsupported file type. ${isFavicon ? 'Favicons' : 'Brand assets'} must be ${formatAllowed(allowed)}.`,
|
|
432
|
+
)
|
|
397
433
|
return
|
|
398
434
|
}
|
|
399
435
|
setSaveError(null)
|