@bygghammar/cms-platform 0.25.0 → 0.26.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 +8 -0
- package/package.json +5 -3
- package/src/backoffice/BackupView.tsx +788 -0
- package/src/backoffice/EmailSettingsView.tsx +772 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
|
|
4
|
+
export const PROVIDER_KINDS = ["smtp", "ses", "lovable", "mailgun", "http"] as const;
|
|
5
|
+
export type ProviderKind = (typeof PROVIDER_KINDS)[number];
|
|
6
|
+
|
|
7
|
+
/** One sending provider row, structurally matching the host's own type. */
|
|
8
|
+
export type EmailProviderLike = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
kind: ProviderKind;
|
|
12
|
+
is_active: boolean;
|
|
13
|
+
sort_order: number;
|
|
14
|
+
from_name: string | null;
|
|
15
|
+
from_email: string | null;
|
|
16
|
+
reply_to: string | null;
|
|
17
|
+
config: Record<string, unknown>;
|
|
18
|
+
secret_ref_name: string | null;
|
|
19
|
+
notes: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type EmailSettingsLike = {
|
|
23
|
+
primary_from_name?: string | null;
|
|
24
|
+
primary_from_email?: string | null;
|
|
25
|
+
backup_from_email?: string | null;
|
|
26
|
+
reply_to?: string | null;
|
|
27
|
+
admin_notify_to?: string | null;
|
|
28
|
+
default_provider_id?: string | null;
|
|
29
|
+
fallback_provider_id?: string | null;
|
|
30
|
+
} | null;
|
|
31
|
+
|
|
32
|
+
export type EmailAddressesInput = {
|
|
33
|
+
primary_from_name: string | null;
|
|
34
|
+
primary_from_email: string | null;
|
|
35
|
+
backup_from_email: string | null;
|
|
36
|
+
reply_to: string | null;
|
|
37
|
+
admin_notify_to: string | null;
|
|
38
|
+
default_provider_id: string | null;
|
|
39
|
+
fallback_provider_id: string | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Host-provided data and actions for the email settings screen. */
|
|
43
|
+
export type EmailSettingsDeps = {
|
|
44
|
+
settings: EmailSettingsLike;
|
|
45
|
+
providers: EmailProviderLike[];
|
|
46
|
+
isSuper: boolean;
|
|
47
|
+
loadingProviders?: boolean;
|
|
48
|
+
saveSettings: (input: { data: EmailAddressesInput }) => Promise<unknown>;
|
|
49
|
+
saveProvider: (input: {
|
|
50
|
+
data: Omit<EmailProviderLike, "id"> & { id?: string };
|
|
51
|
+
}) => Promise<unknown>;
|
|
52
|
+
deleteProvider: (input: { data: { id: string } }) => Promise<unknown>;
|
|
53
|
+
sendTestEmail: (input: { data: { provider_id: string; to: string } }) => Promise<unknown>;
|
|
54
|
+
/** Called after settings change, so the host can refetch. */
|
|
55
|
+
refreshSettings: () => Promise<unknown> | void;
|
|
56
|
+
/** Called after a provider changes, so the host can refetch. */
|
|
57
|
+
refreshProviders: () => Promise<unknown> | void;
|
|
58
|
+
/** Called after a test send, so the host can refetch email logs. */
|
|
59
|
+
refreshLogs?: () => Promise<unknown> | void;
|
|
60
|
+
/** Example addresses shown as placeholders — site specific. */
|
|
61
|
+
placeholders?: { fromEmail?: string; notifyTo?: string };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const KIND_LABEL: Record<ProviderKind, string> = {
|
|
65
|
+
smtp: "SMTP",
|
|
66
|
+
ses: "Amazon SES",
|
|
67
|
+
lovable: "Lovable Emails",
|
|
68
|
+
mailgun: "Mailgun",
|
|
69
|
+
http: "HTTP / Webhook",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const input =
|
|
73
|
+
"mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm focus:border-sea focus:outline-none disabled:bg-ink/5";
|
|
74
|
+
const lbl = "text-[0.65rem] uppercase tracking-[0.22em] text-ink/55";
|
|
75
|
+
|
|
76
|
+
/** Shared Email + providers screen. Data and actions come from the host. */
|
|
77
|
+
export function EmailSettingsView({ deps }: { deps: EmailSettingsDeps }) {
|
|
78
|
+
const { providers, isSuper } = deps;
|
|
79
|
+
|
|
80
|
+
const [addresses, setAddresses] = useState({
|
|
81
|
+
primary_from_name: "",
|
|
82
|
+
primary_from_email: "",
|
|
83
|
+
backup_from_email: "",
|
|
84
|
+
reply_to: "",
|
|
85
|
+
admin_notify_to: "",
|
|
86
|
+
});
|
|
87
|
+
const [defaultId, setDefaultId] = useState<string>("");
|
|
88
|
+
const [fallbackId, setFallbackId] = useState<string>("");
|
|
89
|
+
const [busy, setBusy] = useState(false);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const d = deps.settings;
|
|
93
|
+
if (!d) return;
|
|
94
|
+
setAddresses({
|
|
95
|
+
primary_from_name: d.primary_from_name ?? "",
|
|
96
|
+
primary_from_email: d.primary_from_email ?? "",
|
|
97
|
+
backup_from_email: d.backup_from_email ?? "",
|
|
98
|
+
reply_to: d.reply_to ?? "",
|
|
99
|
+
admin_notify_to: d.admin_notify_to ?? "",
|
|
100
|
+
});
|
|
101
|
+
setDefaultId(d.default_provider_id ?? "");
|
|
102
|
+
setFallbackId(d.fallback_provider_id ?? "");
|
|
103
|
+
}, [deps.settings]);
|
|
104
|
+
|
|
105
|
+
const submitSettings = async () => {
|
|
106
|
+
setBusy(true);
|
|
107
|
+
try {
|
|
108
|
+
await deps.saveSettings({
|
|
109
|
+
data: {
|
|
110
|
+
primary_from_name: addresses.primary_from_name || null,
|
|
111
|
+
primary_from_email: addresses.primary_from_email || null,
|
|
112
|
+
backup_from_email: addresses.backup_from_email || null,
|
|
113
|
+
reply_to: addresses.reply_to || null,
|
|
114
|
+
admin_notify_to: addresses.admin_notify_to || null,
|
|
115
|
+
default_provider_id: defaultId || null,
|
|
116
|
+
fallback_provider_id: fallbackId || null,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
toast.success("Email settings saved");
|
|
120
|
+
await deps.refreshSettings();
|
|
121
|
+
} catch (e) {
|
|
122
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
123
|
+
} finally {
|
|
124
|
+
setBusy(false);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const [editing, setEditing] = useState<EmailProviderLike | null>(null);
|
|
129
|
+
const [creating, setCreating] = useState(false);
|
|
130
|
+
const [duplicating, setDuplicating] = useState<EmailProviderLike | null>(null);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="p-8 md:p-12 w-full space-y-10">
|
|
134
|
+
<div>
|
|
135
|
+
<h2 className="font-serif text-xl">Email settings</h2>
|
|
136
|
+
<p className="mt-2 text-sm text-ink/60">
|
|
137
|
+
Configure multiple sending providers, pick which one is the default, and set a fallback
|
|
138
|
+
that takes over if the default fails. Passwords and API keys are stored as server secrets
|
|
139
|
+
— enter only their secret names here.
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Providers list */}
|
|
144
|
+
<section className="border border-ink/10 bg-white">
|
|
145
|
+
<header className="flex items-center justify-between border-b border-ink/10 px-6 py-4">
|
|
146
|
+
<div>
|
|
147
|
+
<div className={lbl}>Providers</div>
|
|
148
|
+
<div className="mt-1 text-sm text-ink/70">{providers.length} configured</div>
|
|
149
|
+
</div>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
disabled={!isSuper}
|
|
153
|
+
onClick={() => setCreating(true)}
|
|
154
|
+
className="border border-ink bg-ink px-3 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea disabled:opacity-40"
|
|
155
|
+
>
|
|
156
|
+
Add provider
|
|
157
|
+
</button>
|
|
158
|
+
</header>
|
|
159
|
+
|
|
160
|
+
{deps.loadingProviders ? (
|
|
161
|
+
<div className="p-6 text-sm text-ink/60">Loading…</div>
|
|
162
|
+
) : providers.length === 0 ? (
|
|
163
|
+
<div className="p-6 text-sm text-ink/60">
|
|
164
|
+
No providers yet. Add one to start sending email.
|
|
165
|
+
</div>
|
|
166
|
+
) : (
|
|
167
|
+
<ul className="divide-y divide-ink/10">
|
|
168
|
+
{providers.map((p) => (
|
|
169
|
+
<li key={p.id} className="flex items-center justify-between gap-4 px-6 py-4">
|
|
170
|
+
<div className="min-w-0">
|
|
171
|
+
<div className="flex items-center gap-2">
|
|
172
|
+
<span className="font-medium">{p.name}</span>
|
|
173
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">
|
|
174
|
+
{KIND_LABEL[p.kind]}
|
|
175
|
+
</span>
|
|
176
|
+
{!p.is_active && (
|
|
177
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-red-600">
|
|
178
|
+
inactive
|
|
179
|
+
</span>
|
|
180
|
+
)}
|
|
181
|
+
{p.id === defaultId && (
|
|
182
|
+
<span className="border border-sea bg-sea/10 px-1.5 py-0.5 text-[0.6rem] uppercase tracking-[0.2em] text-sea">
|
|
183
|
+
default
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
{p.id === fallbackId && (
|
|
187
|
+
<span className="border border-ink/40 bg-ink/5 px-1.5 py-0.5 text-[0.6rem] uppercase tracking-[0.2em] text-ink/70">
|
|
188
|
+
fallback
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
<div className="mt-1 text-xs text-ink/60 truncate">
|
|
193
|
+
{p.from_name ? `${p.from_name} · ` : ""}
|
|
194
|
+
{p.from_email ?? "—"}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
<div className="flex gap-2">
|
|
198
|
+
<TestSendButton deps={deps} providerId={p.id} disabled={!isSuper} />
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
disabled={!isSuper}
|
|
202
|
+
onClick={() =>
|
|
203
|
+
setDuplicating({
|
|
204
|
+
...p,
|
|
205
|
+
id: "",
|
|
206
|
+
name: `${p.name} (copy)`,
|
|
207
|
+
is_active: false,
|
|
208
|
+
config: { ...(p.config ?? {}) },
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea hover:text-sea disabled:opacity-40"
|
|
212
|
+
>
|
|
213
|
+
Duplicate
|
|
214
|
+
</button>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
onClick={() => setEditing(p)}
|
|
218
|
+
className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea hover:text-sea"
|
|
219
|
+
>
|
|
220
|
+
Edit
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
</li>
|
|
224
|
+
))}
|
|
225
|
+
</ul>
|
|
226
|
+
)}
|
|
227
|
+
</section>
|
|
228
|
+
|
|
229
|
+
{/* Routing */}
|
|
230
|
+
<section className="border border-ink/10 bg-white p-6 space-y-4">
|
|
231
|
+
<div className={lbl}>Routing</div>
|
|
232
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
233
|
+
<label className="block">
|
|
234
|
+
<span className={lbl}>Default provider</span>
|
|
235
|
+
<select
|
|
236
|
+
disabled={!isSuper}
|
|
237
|
+
className={input}
|
|
238
|
+
value={defaultId}
|
|
239
|
+
onChange={(e) => setDefaultId(e.target.value)}
|
|
240
|
+
>
|
|
241
|
+
<option value="">— none —</option>
|
|
242
|
+
{providers.map((p) => (
|
|
243
|
+
<option key={p.id} value={p.id}>
|
|
244
|
+
{p.name} · {KIND_LABEL[p.kind]}
|
|
245
|
+
</option>
|
|
246
|
+
))}
|
|
247
|
+
</select>
|
|
248
|
+
</label>
|
|
249
|
+
<label className="block">
|
|
250
|
+
<span className={lbl}>Fallback provider</span>
|
|
251
|
+
<select
|
|
252
|
+
disabled={!isSuper}
|
|
253
|
+
className={input}
|
|
254
|
+
value={fallbackId}
|
|
255
|
+
onChange={(e) => setFallbackId(e.target.value)}
|
|
256
|
+
>
|
|
257
|
+
<option value="">— none —</option>
|
|
258
|
+
{providers
|
|
259
|
+
.filter((p) => p.id !== defaultId)
|
|
260
|
+
.map((p) => (
|
|
261
|
+
<option key={p.id} value={p.id}>
|
|
262
|
+
{p.name} · {KIND_LABEL[p.kind]}
|
|
263
|
+
</option>
|
|
264
|
+
))}
|
|
265
|
+
</select>
|
|
266
|
+
</label>
|
|
267
|
+
</div>
|
|
268
|
+
<p className="text-xs text-ink/55">
|
|
269
|
+
The fallback provider is used automatically if a send through the default provider fails.
|
|
270
|
+
</p>
|
|
271
|
+
</section>
|
|
272
|
+
|
|
273
|
+
{/* Addresses */}
|
|
274
|
+
<section className="border border-ink/10 bg-white p-6 space-y-4">
|
|
275
|
+
<div className={lbl}>Addresses</div>
|
|
276
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
277
|
+
<label className="block">
|
|
278
|
+
<span className={lbl}>From name</span>
|
|
279
|
+
<input
|
|
280
|
+
disabled={!isSuper}
|
|
281
|
+
className={input}
|
|
282
|
+
value={addresses.primary_from_name}
|
|
283
|
+
onChange={(e) => setAddresses((f) => ({ ...f, primary_from_name: e.target.value }))}
|
|
284
|
+
/>
|
|
285
|
+
</label>
|
|
286
|
+
<label className="block">
|
|
287
|
+
<span className={lbl}>Primary from email</span>
|
|
288
|
+
<input
|
|
289
|
+
disabled={!isSuper}
|
|
290
|
+
className={input}
|
|
291
|
+
value={addresses.primary_from_email}
|
|
292
|
+
onChange={(e) => setAddresses((f) => ({ ...f, primary_from_email: e.target.value }))}
|
|
293
|
+
placeholder={deps.placeholders?.fromEmail}
|
|
294
|
+
/>
|
|
295
|
+
</label>
|
|
296
|
+
<label className="block">
|
|
297
|
+
<span className={lbl}>Backup from email</span>
|
|
298
|
+
<input
|
|
299
|
+
disabled={!isSuper}
|
|
300
|
+
className={input}
|
|
301
|
+
value={addresses.backup_from_email}
|
|
302
|
+
onChange={(e) => setAddresses((f) => ({ ...f, backup_from_email: e.target.value }))}
|
|
303
|
+
/>
|
|
304
|
+
</label>
|
|
305
|
+
<label className="block">
|
|
306
|
+
<span className={lbl}>Reply-to</span>
|
|
307
|
+
<input
|
|
308
|
+
disabled={!isSuper}
|
|
309
|
+
className={input}
|
|
310
|
+
value={addresses.reply_to}
|
|
311
|
+
onChange={(e) => setAddresses((f) => ({ ...f, reply_to: e.target.value }))}
|
|
312
|
+
/>
|
|
313
|
+
</label>
|
|
314
|
+
<label className="block md:col-span-2">
|
|
315
|
+
<span className={lbl}>Admin notification to (comma separated)</span>
|
|
316
|
+
<input
|
|
317
|
+
disabled={!isSuper}
|
|
318
|
+
className={input}
|
|
319
|
+
value={addresses.admin_notify_to}
|
|
320
|
+
onChange={(e) => setAddresses((f) => ({ ...f, admin_notify_to: e.target.value }))}
|
|
321
|
+
placeholder={deps.placeholders?.notifyTo}
|
|
322
|
+
/>
|
|
323
|
+
</label>
|
|
324
|
+
</div>
|
|
325
|
+
</section>
|
|
326
|
+
|
|
327
|
+
<div className="flex items-center gap-3">
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
onClick={submitSettings}
|
|
331
|
+
disabled={busy || !isSuper}
|
|
332
|
+
className="border border-ink bg-ink px-4 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea disabled:opacity-50"
|
|
333
|
+
>
|
|
334
|
+
{busy ? "Saving…" : "Save settings"}
|
|
335
|
+
</button>
|
|
336
|
+
{!isSuper && (
|
|
337
|
+
<span className="text-xs text-ink/55">
|
|
338
|
+
Read-only — only super administrators can change email settings.
|
|
339
|
+
</span>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{(editing || creating || duplicating) && (
|
|
344
|
+
<ProviderDialog
|
|
345
|
+
key={editing?.id ?? (duplicating ? `dup-${duplicating.name}` : "new")}
|
|
346
|
+
deps={deps}
|
|
347
|
+
provider={editing}
|
|
348
|
+
initial={duplicating}
|
|
349
|
+
onClose={() => {
|
|
350
|
+
setEditing(null);
|
|
351
|
+
setCreating(false);
|
|
352
|
+
setDuplicating(null);
|
|
353
|
+
}}
|
|
354
|
+
/>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function TestSendButton({
|
|
361
|
+
deps,
|
|
362
|
+
providerId,
|
|
363
|
+
disabled,
|
|
364
|
+
}: {
|
|
365
|
+
deps: EmailSettingsDeps;
|
|
366
|
+
providerId: string;
|
|
367
|
+
disabled: boolean;
|
|
368
|
+
}) {
|
|
369
|
+
const [open, setOpen] = useState(false);
|
|
370
|
+
const [to, setTo] = useState("");
|
|
371
|
+
const [sending, setSending] = useState(false);
|
|
372
|
+
|
|
373
|
+
const run = async () => {
|
|
374
|
+
setSending(true);
|
|
375
|
+
try {
|
|
376
|
+
await deps.sendTestEmail({ data: { provider_id: providerId, to: to.trim() } });
|
|
377
|
+
toast.success(`Test email sent to ${to.trim()}`);
|
|
378
|
+
setOpen(false);
|
|
379
|
+
setTo("");
|
|
380
|
+
await deps.refreshLogs?.();
|
|
381
|
+
} catch (e) {
|
|
382
|
+
toast.error(e instanceof Error ? e.message : "Test send failed");
|
|
383
|
+
} finally {
|
|
384
|
+
setSending(false);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<>
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
disabled={disabled}
|
|
393
|
+
onClick={() => setOpen(true)}
|
|
394
|
+
className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-[0.22em] text-ink hover:border-sea hover:text-sea disabled:opacity-40"
|
|
395
|
+
>
|
|
396
|
+
Test
|
|
397
|
+
</button>
|
|
398
|
+
{open && (
|
|
399
|
+
<div
|
|
400
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
|
401
|
+
onClick={() => !sending && setOpen(false)}
|
|
402
|
+
>
|
|
403
|
+
<div
|
|
404
|
+
className="w-full max-w-md border border-ink/15 bg-white p-6"
|
|
405
|
+
onClick={(e) => e.stopPropagation()}
|
|
406
|
+
>
|
|
407
|
+
<h3 className="font-serif text-lg">Send test email</h3>
|
|
408
|
+
<p className="mt-2 text-xs text-ink/60">
|
|
409
|
+
Sends a short test message through this provider and records the attempt in the email
|
|
410
|
+
logs.
|
|
411
|
+
</p>
|
|
412
|
+
<label className="mt-4 block">
|
|
413
|
+
<span className={lbl}>Recipient</span>
|
|
414
|
+
<input
|
|
415
|
+
type="email"
|
|
416
|
+
autoFocus
|
|
417
|
+
className={input}
|
|
418
|
+
value={to}
|
|
419
|
+
placeholder="you@example.com"
|
|
420
|
+
onChange={(e) => setTo(e.target.value)}
|
|
421
|
+
/>
|
|
422
|
+
</label>
|
|
423
|
+
<div className="mt-5 flex items-center justify-end gap-2">
|
|
424
|
+
<button
|
|
425
|
+
type="button"
|
|
426
|
+
disabled={sending}
|
|
427
|
+
onClick={() => setOpen(false)}
|
|
428
|
+
className="border border-ink/20 px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink hover:border-ink disabled:opacity-40"
|
|
429
|
+
>
|
|
430
|
+
Cancel
|
|
431
|
+
</button>
|
|
432
|
+
<button
|
|
433
|
+
type="button"
|
|
434
|
+
disabled={sending || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to.trim())}
|
|
435
|
+
onClick={run}
|
|
436
|
+
className="border border-ink bg-ink px-4 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea disabled:opacity-40"
|
|
437
|
+
>
|
|
438
|
+
{sending ? "Sending…" : "Send test"}
|
|
439
|
+
</button>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
)}
|
|
444
|
+
</>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
type ConfigField = {
|
|
449
|
+
key: string;
|
|
450
|
+
label: string;
|
|
451
|
+
placeholder?: string;
|
|
452
|
+
kind?: "text" | "number" | "checkbox";
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const CONFIG_FIELDS: Record<ProviderKind, ConfigField[]> = {
|
|
456
|
+
smtp: [
|
|
457
|
+
{ key: "host", label: "SMTP host", placeholder: "smtp.example.com" },
|
|
458
|
+
{ key: "port", label: "Port (587 recommended)", placeholder: "587", kind: "number" },
|
|
459
|
+
{ key: "username", label: "Username" },
|
|
460
|
+
{ key: "secure", label: "Use STARTTLS (recommended)", kind: "checkbox" },
|
|
461
|
+
],
|
|
462
|
+
ses: [
|
|
463
|
+
{ key: "region", label: "AWS region", placeholder: "eu-north-1" },
|
|
464
|
+
{ key: "access_key_id", label: "Access key ID" },
|
|
465
|
+
],
|
|
466
|
+
lovable: [],
|
|
467
|
+
mailgun: [
|
|
468
|
+
{ key: "domain", label: "Mailgun domain", placeholder: "mg.example.com" },
|
|
469
|
+
{ key: "region", label: "Region", placeholder: "us or eu" },
|
|
470
|
+
],
|
|
471
|
+
http: [
|
|
472
|
+
{ key: "endpoint", label: "Webhook endpoint", placeholder: "https://…" },
|
|
473
|
+
{ key: "method", label: "HTTP method", placeholder: "POST" },
|
|
474
|
+
],
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const SECRET_HINT: Record<ProviderKind, string> = {
|
|
478
|
+
smtp: "SMTP_PASSWORD (secret holding the mailbox password)",
|
|
479
|
+
ses: "AWS_SES_SECRET",
|
|
480
|
+
lovable: "",
|
|
481
|
+
mailgun: "MAILGUN_API_KEY",
|
|
482
|
+
http: "HTTP_EMAIL_SECRET",
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
function ProviderDialog({
|
|
486
|
+
deps,
|
|
487
|
+
provider,
|
|
488
|
+
initial,
|
|
489
|
+
onClose,
|
|
490
|
+
}: {
|
|
491
|
+
deps: EmailSettingsDeps;
|
|
492
|
+
provider: EmailProviderLike | null;
|
|
493
|
+
initial?: EmailProviderLike | null;
|
|
494
|
+
onClose: () => void;
|
|
495
|
+
}) {
|
|
496
|
+
const [form, setForm] = useState<EmailProviderLike>(
|
|
497
|
+
provider ??
|
|
498
|
+
initial ?? {
|
|
499
|
+
id: "",
|
|
500
|
+
name: "",
|
|
501
|
+
kind: "smtp",
|
|
502
|
+
is_active: true,
|
|
503
|
+
sort_order: 100,
|
|
504
|
+
from_name: "",
|
|
505
|
+
from_email: "",
|
|
506
|
+
reply_to: "",
|
|
507
|
+
config: {},
|
|
508
|
+
secret_ref_name: "",
|
|
509
|
+
notes: "",
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
const [busy, setBusy] = useState(false);
|
|
514
|
+
const fields = useMemo(() => CONFIG_FIELDS[form.kind] ?? [], [form.kind]);
|
|
515
|
+
|
|
516
|
+
const submit = async () => {
|
|
517
|
+
setBusy(true);
|
|
518
|
+
try {
|
|
519
|
+
await deps.saveProvider({
|
|
520
|
+
data: {
|
|
521
|
+
id: provider?.id,
|
|
522
|
+
name: form.name,
|
|
523
|
+
kind: form.kind,
|
|
524
|
+
is_active: form.is_active,
|
|
525
|
+
sort_order: Number(form.sort_order) || 100,
|
|
526
|
+
from_name: form.from_name || null,
|
|
527
|
+
from_email: form.from_email || null,
|
|
528
|
+
reply_to: form.reply_to || null,
|
|
529
|
+
config: form.config ?? {},
|
|
530
|
+
secret_ref_name: form.secret_ref_name || null,
|
|
531
|
+
notes: form.notes || null,
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
toast.success(provider ? "Provider updated" : "Provider created");
|
|
535
|
+
await deps.refreshProviders();
|
|
536
|
+
onClose();
|
|
537
|
+
} catch (e) {
|
|
538
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
539
|
+
} finally {
|
|
540
|
+
setBusy(false);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const del = async () => {
|
|
545
|
+
if (!provider) return;
|
|
546
|
+
if (!confirm(`Delete provider "${provider.name}"?`)) return;
|
|
547
|
+
setBusy(true);
|
|
548
|
+
try {
|
|
549
|
+
await deps.deleteProvider({ data: { id: provider.id } });
|
|
550
|
+
toast.success("Provider deleted");
|
|
551
|
+
await deps.refreshProviders();
|
|
552
|
+
await deps.refreshSettings();
|
|
553
|
+
onClose();
|
|
554
|
+
} catch (e) {
|
|
555
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
556
|
+
} finally {
|
|
557
|
+
setBusy(false);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const setConfig = (k: string, v: unknown) =>
|
|
562
|
+
setForm((f) => ({ ...f, config: { ...(f.config ?? {}), [k]: v } }));
|
|
563
|
+
|
|
564
|
+
return (
|
|
565
|
+
<div
|
|
566
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
|
567
|
+
onClick={onClose}
|
|
568
|
+
>
|
|
569
|
+
<div
|
|
570
|
+
className="w-full max-w-2xl bg-white border border-ink/10 shadow-2xl max-h-[90vh] overflow-y-auto"
|
|
571
|
+
onClick={(e) => e.stopPropagation()}
|
|
572
|
+
>
|
|
573
|
+
<header className="flex items-center justify-between border-b border-ink/10 px-6 py-4">
|
|
574
|
+
<h3 className="font-serif text-lg">
|
|
575
|
+
{provider ? "Edit provider" : initial ? "Duplicate provider" : "New provider"}
|
|
576
|
+
</h3>
|
|
577
|
+
<button
|
|
578
|
+
type="button"
|
|
579
|
+
onClick={onClose}
|
|
580
|
+
className="text-xs uppercase tracking-[0.22em] text-ink/60 hover:text-ink"
|
|
581
|
+
>
|
|
582
|
+
Close
|
|
583
|
+
</button>
|
|
584
|
+
</header>
|
|
585
|
+
|
|
586
|
+
<div className="p-6 space-y-5">
|
|
587
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
588
|
+
<label className="block">
|
|
589
|
+
<span className={lbl}>Name</span>
|
|
590
|
+
<input
|
|
591
|
+
className={input}
|
|
592
|
+
value={form.name}
|
|
593
|
+
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
594
|
+
placeholder="Primary SMTP"
|
|
595
|
+
/>
|
|
596
|
+
</label>
|
|
597
|
+
<label className="block">
|
|
598
|
+
<span className={lbl}>Kind</span>
|
|
599
|
+
<select
|
|
600
|
+
className={input}
|
|
601
|
+
value={form.kind}
|
|
602
|
+
onChange={(e) =>
|
|
603
|
+
setForm({ ...form, kind: e.target.value as ProviderKind, config: {} })
|
|
604
|
+
}
|
|
605
|
+
>
|
|
606
|
+
{PROVIDER_KINDS.map((k) => (
|
|
607
|
+
<option key={k} value={k}>
|
|
608
|
+
{KIND_LABEL[k]}
|
|
609
|
+
</option>
|
|
610
|
+
))}
|
|
611
|
+
</select>
|
|
612
|
+
</label>
|
|
613
|
+
<label className="block">
|
|
614
|
+
<span className={lbl}>From name</span>
|
|
615
|
+
<input
|
|
616
|
+
className={input}
|
|
617
|
+
value={form.from_name ?? ""}
|
|
618
|
+
onChange={(e) => setForm({ ...form, from_name: e.target.value })}
|
|
619
|
+
/>
|
|
620
|
+
</label>
|
|
621
|
+
<label className="block">
|
|
622
|
+
<span className={lbl}>From email</span>
|
|
623
|
+
<input
|
|
624
|
+
className={input}
|
|
625
|
+
value={form.from_email ?? ""}
|
|
626
|
+
onChange={(e) => setForm({ ...form, from_email: e.target.value })}
|
|
627
|
+
/>
|
|
628
|
+
</label>
|
|
629
|
+
<label className="block">
|
|
630
|
+
<span className={lbl}>Reply-to</span>
|
|
631
|
+
<input
|
|
632
|
+
className={input}
|
|
633
|
+
value={form.reply_to ?? ""}
|
|
634
|
+
onChange={(e) => setForm({ ...form, reply_to: e.target.value })}
|
|
635
|
+
/>
|
|
636
|
+
</label>
|
|
637
|
+
<label className="block">
|
|
638
|
+
<span className={lbl}>Priority (lower = earlier)</span>
|
|
639
|
+
<input
|
|
640
|
+
type="number"
|
|
641
|
+
className={input}
|
|
642
|
+
value={form.sort_order}
|
|
643
|
+
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) || 0 })}
|
|
644
|
+
/>
|
|
645
|
+
</label>
|
|
646
|
+
</div>
|
|
647
|
+
|
|
648
|
+
{fields.length > 0 && (
|
|
649
|
+
<div className="border-t border-ink/10 pt-5">
|
|
650
|
+
<div className={lbl}>{KIND_LABEL[form.kind]} configuration</div>
|
|
651
|
+
{form.kind === "smtp" && (
|
|
652
|
+
<p className="text-xs text-ink/60">
|
|
653
|
+
Mail is sent over a direct SMTP connection using STARTTLS. Use port 587 (or 2525 /
|
|
654
|
+
25). Port 465 (implicit TLS) is not supported — nearly every provider offers 587
|
|
655
|
+
as an alternative.
|
|
656
|
+
</p>
|
|
657
|
+
)}
|
|
658
|
+
{form.kind === "smtp" && Number(form.config?.["port"]) === 465 && (
|
|
659
|
+
<p className="text-xs text-red-600">
|
|
660
|
+
Port 465 cannot be used here. Change it to 587 before sending a test email.
|
|
661
|
+
</p>
|
|
662
|
+
)}
|
|
663
|
+
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
|
664
|
+
{fields.map((f) => {
|
|
665
|
+
const v = (form.config ?? {})[f.key];
|
|
666
|
+
if (f.kind === "checkbox") {
|
|
667
|
+
return (
|
|
668
|
+
<label key={f.key} className="inline-flex items-center gap-2 md:col-span-2">
|
|
669
|
+
<input
|
|
670
|
+
type="checkbox"
|
|
671
|
+
checked={Boolean(v)}
|
|
672
|
+
onChange={(e) => setConfig(f.key, e.target.checked)}
|
|
673
|
+
/>
|
|
674
|
+
<span className="text-sm">{f.label}</span>
|
|
675
|
+
</label>
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
return (
|
|
679
|
+
<label key={f.key} className="block">
|
|
680
|
+
<span className={lbl}>{f.label}</span>
|
|
681
|
+
<input
|
|
682
|
+
className={input}
|
|
683
|
+
type={f.kind === "number" ? "number" : "text"}
|
|
684
|
+
value={(v as string | number | undefined) ?? ""}
|
|
685
|
+
placeholder={f.placeholder}
|
|
686
|
+
onChange={(e) =>
|
|
687
|
+
setConfig(
|
|
688
|
+
f.key,
|
|
689
|
+
f.kind === "number"
|
|
690
|
+
? e.target.value === ""
|
|
691
|
+
? null
|
|
692
|
+
: Number(e.target.value)
|
|
693
|
+
: e.target.value,
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
/>
|
|
697
|
+
</label>
|
|
698
|
+
);
|
|
699
|
+
})}
|
|
700
|
+
</div>
|
|
701
|
+
</div>
|
|
702
|
+
)}
|
|
703
|
+
|
|
704
|
+
{form.kind !== "lovable" && (
|
|
705
|
+
<label className="block">
|
|
706
|
+
<span className={lbl}>
|
|
707
|
+
Secret name (server secret storing the API key / password)
|
|
708
|
+
</span>
|
|
709
|
+
<input
|
|
710
|
+
className={input}
|
|
711
|
+
value={form.secret_ref_name ?? ""}
|
|
712
|
+
onChange={(e) => setForm({ ...form, secret_ref_name: e.target.value })}
|
|
713
|
+
placeholder={SECRET_HINT[form.kind]}
|
|
714
|
+
/>
|
|
715
|
+
</label>
|
|
716
|
+
)}
|
|
717
|
+
|
|
718
|
+
<label className="block">
|
|
719
|
+
<span className={lbl}>Notes</span>
|
|
720
|
+
<textarea
|
|
721
|
+
className={input}
|
|
722
|
+
rows={2}
|
|
723
|
+
value={form.notes ?? ""}
|
|
724
|
+
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
|
725
|
+
/>
|
|
726
|
+
</label>
|
|
727
|
+
|
|
728
|
+
<label className="inline-flex items-center gap-2">
|
|
729
|
+
<input
|
|
730
|
+
type="checkbox"
|
|
731
|
+
checked={form.is_active}
|
|
732
|
+
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
|
|
733
|
+
/>
|
|
734
|
+
<span className="text-sm">Active</span>
|
|
735
|
+
</label>
|
|
736
|
+
</div>
|
|
737
|
+
|
|
738
|
+
<footer className="flex items-center justify-between border-t border-ink/10 px-6 py-4">
|
|
739
|
+
<div>
|
|
740
|
+
{provider && (
|
|
741
|
+
<button
|
|
742
|
+
type="button"
|
|
743
|
+
onClick={del}
|
|
744
|
+
disabled={busy}
|
|
745
|
+
className="border border-red-500/40 px-3 py-2 text-xs uppercase tracking-[0.22em] text-red-600 hover:bg-red-50 disabled:opacity-50"
|
|
746
|
+
>
|
|
747
|
+
Delete
|
|
748
|
+
</button>
|
|
749
|
+
)}
|
|
750
|
+
</div>
|
|
751
|
+
<div className="flex gap-2">
|
|
752
|
+
<button
|
|
753
|
+
type="button"
|
|
754
|
+
onClick={onClose}
|
|
755
|
+
className="border border-ink/20 px-3 py-2 text-xs uppercase tracking-[0.22em] text-ink/70 hover:border-ink"
|
|
756
|
+
>
|
|
757
|
+
Cancel
|
|
758
|
+
</button>
|
|
759
|
+
<button
|
|
760
|
+
type="button"
|
|
761
|
+
onClick={submit}
|
|
762
|
+
disabled={busy || !form.name}
|
|
763
|
+
className="border border-ink bg-ink px-4 py-2 text-xs uppercase tracking-[0.22em] text-white hover:bg-sea hover:border-sea disabled:opacity-50"
|
|
764
|
+
>
|
|
765
|
+
{busy ? "Saving…" : "Save"}
|
|
766
|
+
</button>
|
|
767
|
+
</div>
|
|
768
|
+
</footer>
|
|
769
|
+
</div>
|
|
770
|
+
</div>
|
|
771
|
+
);
|
|
772
|
+
}
|