@bygghammar/cms-platform 0.25.0 → 0.27.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.
@@ -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
+ }