@naviyra/cloud-panel 0.0.0-stage → 0.0.1

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 (55) hide show
  1. package/.env.example +40 -0
  2. package/README.md +134 -2
  3. package/apps/agent/package.json +13 -0
  4. package/apps/agent/src/cloudflare.mjs +733 -0
  5. package/apps/agent/src/native-worker.mjs +52 -0
  6. package/apps/agent/src/native.mjs +457 -0
  7. package/apps/agent/src/runtime.mjs +760 -0
  8. package/apps/agent/src/server.mjs +195 -0
  9. package/apps/agent/src/static-server.mjs +53 -0
  10. package/apps/panel/AGENTS.md +9 -0
  11. package/apps/panel/CLAUDE.md +1 -0
  12. package/apps/panel/app/api/[...path]/route.js +445 -0
  13. package/apps/panel/app/globals.css +2057 -0
  14. package/apps/panel/app/icon.png +0 -0
  15. package/apps/panel/app/layout.jsx +18 -0
  16. package/apps/panel/app/login/page.jsx +108 -0
  17. package/apps/panel/app/page.jsx +33 -0
  18. package/apps/panel/app/ui/cloudflare.jsx +926 -0
  19. package/apps/panel/app/ui/dashboard.jsx +1487 -0
  20. package/apps/panel/app/ui/databases.jsx +293 -0
  21. package/apps/panel/app/ui/skeleton.jsx +196 -0
  22. package/apps/panel/app/ui/theme.jsx +35 -0
  23. package/apps/panel/lib/agent.mjs +83 -0
  24. package/apps/panel/lib/auth.mjs +48 -0
  25. package/apps/panel/lib/database-runtime.mjs +243 -0
  26. package/apps/panel/lib/databases.mjs +292 -0
  27. package/apps/panel/lib/db.mjs +6 -0
  28. package/apps/panel/next.config.mjs +8 -0
  29. package/apps/panel/package.json +22 -0
  30. package/apps/panel/public/naviyra-logo.png +0 -0
  31. package/bin/naviyra.mjs +177 -0
  32. package/db/001_init.sql +28 -0
  33. package/db/002_native_runtimes.sql +3 -0
  34. package/db/003_databases.sql +8 -0
  35. package/db/004_database_engines.sql +4 -0
  36. package/deploy/naviyra-agent.service +17 -0
  37. package/docker-compose.yml +18 -0
  38. package/docs/managed-runtime.md +52 -0
  39. package/docs/native-hosts.md +48 -0
  40. package/package.json +61 -4
  41. package/packages/runtime/artifacts.json +32 -0
  42. package/packages/runtime/index.cjs +526 -0
  43. package/packages/runtime/index.mjs +10 -0
  44. package/packages/runtime/package.json +6 -0
  45. package/packages/shared/index.mjs +85 -0
  46. package/packages/shared/package.json +9 -0
  47. package/scripts/migrate.mjs +18 -0
  48. package/scripts/postgres-local.mjs +97 -0
  49. package/scripts/runtime-download-manifest.mjs +30 -0
  50. package/scripts/runtime.mjs +20 -0
  51. package/scripts/seed.mjs +35 -0
  52. package/scripts/service-supervisor.mjs +164 -0
  53. package/scripts/setup-local.mjs +215 -0
  54. package/scripts/start-naviyra.mjs +303 -0
  55. package/scripts/windows-boot.mjs +259 -0
@@ -0,0 +1,926 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import {
4
+ Check,
5
+ Cloud,
6
+ Mail,
7
+ Pencil,
8
+ RefreshCw,
9
+ Search,
10
+ Trash2,
11
+ } from "lucide-react";
12
+ import { EmailSkeleton } from "./skeleton";
13
+
14
+ async function api(url, options = {}) {
15
+ const response = await fetch(`/api/${url}`, {
16
+ ...options,
17
+ headers: { "Content-Type": "application/json", ...options.headers },
18
+ });
19
+ const body = await response.json();
20
+ if (response.status === 401) {
21
+ window.location.href = "/login";
22
+ throw Error("Session expired");
23
+ }
24
+ if (!response.ok) throw Error(body.error || "Request failed");
25
+ return body;
26
+ }
27
+ const ttlLabel = (ttl) => (ttl === 1 ? "Auto" : ttl ? `${ttl}s` : "—");
28
+ function when(value) {
29
+ return value ? new Date(value).toLocaleString() : "—";
30
+ }
31
+ export function CloudflareConnect({ initial, onReady, canWrite }) {
32
+ const [progress, setProgress] = useState(initial?.login || { state: "idle" });
33
+ const [error, setError] = useState(initial?.error || "");
34
+ const [busy, setBusy] = useState(false);
35
+ useEffect(() => {
36
+ if (progress.state !== "running") return undefined;
37
+ const timer = setInterval(async () => {
38
+ try {
39
+ const [status, current] = await Promise.all([
40
+ api("cloudflare/status"),
41
+ api("cloudflare/login"),
42
+ ]);
43
+ setProgress(current);
44
+ if (status.registered) onReady(status);
45
+ else if (current.state === "error") setError(current.error);
46
+ } catch (e) {
47
+ setError(e.message);
48
+ }
49
+ }, 1000);
50
+ return () => clearInterval(timer);
51
+ }, [progress.state, onReady]);
52
+ async function connect() {
53
+ setBusy(true);
54
+ setError("");
55
+ try {
56
+ const started = await api("cloudflare/login", { method: "POST" });
57
+ setProgress(started);
58
+ if (started.skipped || started.registered) {
59
+ onReady(await api("cloudflare/status"));
60
+ return;
61
+ }
62
+ } catch (e) {
63
+ setError(e.message);
64
+ } finally {
65
+ setBusy(false);
66
+ }
67
+ }
68
+ const waiting = progress.state === "running";
69
+ const steps = [
70
+ { label: "Start cloudflared", done: waiting || progress.state === "done" },
71
+ {
72
+ label: "Approve the zone in your browser",
73
+ done: Boolean(progress.url) || progress.state === "done",
74
+ },
75
+ { label: "Open the dashboard", done: progress.state === "done" },
76
+ ];
77
+ return (
78
+ <section className="panel connect-panel">
79
+ <div className="section-heading">
80
+ <h2>
81
+ <Cloud size={18} />
82
+ Connect Cloudflare
83
+ </h2>
84
+ </div>
85
+ <div className="connect-body">
86
+ <p>
87
+ Sign in with <span className="mono">cloudflared tunnel login</span>.
88
+ If this host already has a certificate, the panel skips this step and
89
+ opens the dashboard.
90
+ </p>
91
+ <ol className="connect-steps">
92
+ {steps.map((step) => (
93
+ <li key={step.label} className={step.done ? "done" : ""}>
94
+ <Check size={14} />
95
+ {step.label}
96
+ </li>
97
+ ))}
98
+ </ol>
99
+ {error && <div className="error">{error}</div>}
100
+ {progress.log && <pre className="connect-log">{progress.log}</pre>}
101
+ {progress.url && (
102
+ <a className="primary connect-link" href={progress.url} target="_blank" rel="noreferrer">
103
+ Open Cloudflare login
104
+ </a>
105
+ )}
106
+ <button
107
+ className="primary"
108
+ onClick={connect}
109
+ disabled={!canWrite || busy || waiting}
110
+ >
111
+ <Cloud size={16} />
112
+ {waiting ? "Waiting for approval…" : "Connect with cloudflared"}
113
+ </button>
114
+ {!canWrite && (
115
+ <p>An administrator needs to connect this host to Cloudflare.</p>
116
+ )}
117
+ </div>
118
+ </section>
119
+ );
120
+ }
121
+ export function DnsScreen({ canWrite }) {
122
+ const [data, setData] = useState(null);
123
+ const [detail, setDetail] = useState(null);
124
+ const [draft, setDraft] = useState(null);
125
+ const [error, setError] = useState("");
126
+ const [notice, setNotice] = useState("");
127
+ const [query, setQuery] = useState("");
128
+ const [type, setType] = useState("all");
129
+ const [loading, setLoading] = useState(true);
130
+ const [saving, setSaving] = useState(false);
131
+ async function load() {
132
+ setLoading(true);
133
+ setError("");
134
+ try {
135
+ setData(await api("cloudflare/dns"));
136
+ } catch (e) {
137
+ setError(e.message);
138
+ } finally {
139
+ setLoading(false);
140
+ }
141
+ }
142
+ useEffect(() => {
143
+ load();
144
+ }, []);
145
+ function beginEdit(record, event) {
146
+ event?.stopPropagation();
147
+ setDetail(record);
148
+ setDraft({
149
+ name: record.name,
150
+ content: record.content,
151
+ ttl: record.ttl || 1,
152
+ proxied: record.proxied,
153
+ priority: record.priority ?? 10,
154
+ comment: record.comment || "",
155
+ });
156
+ setNotice("");
157
+ setError("");
158
+ }
159
+ async function open(id) {
160
+ setDraft(null);
161
+ setError("");
162
+ try {
163
+ setDetail(await api(`cloudflare/dns/${id}`));
164
+ } catch (e) {
165
+ setError(e.message);
166
+ }
167
+ }
168
+ async function saveEdit(event) {
169
+ event.preventDefault();
170
+ if (!detail) return;
171
+ setSaving(true);
172
+ setError("");
173
+ try {
174
+ const saved = await api(`cloudflare/dns/${detail.id}`, {
175
+ method: "PUT",
176
+ body: JSON.stringify(draft),
177
+ });
178
+ setDetail(saved);
179
+ setDraft(null);
180
+ setData((current) =>
181
+ current
182
+ ? {
183
+ ...current,
184
+ records: current.records.map((record) =>
185
+ record.id === saved.id ? { ...record, ...saved } : record,
186
+ ),
187
+ }
188
+ : current,
189
+ );
190
+ setNotice("DNS record updated.");
191
+ } catch (e) {
192
+ setError(e.message);
193
+ } finally {
194
+ setSaving(false);
195
+ }
196
+ }
197
+ const records = (data?.records || []).filter((record) => {
198
+ const haystack = `${record.type} ${record.name} ${record.content}`.toLowerCase();
199
+ return (
200
+ (type === "all" || record.type === type) &&
201
+ haystack.includes(query.trim().toLowerCase())
202
+ );
203
+ });
204
+ const types = [...new Set((data?.records || []).map((record) => record.type))];
205
+ return (
206
+ <>
207
+ <div className="page-heading">
208
+ <div>
209
+ <h1>
210
+ DNS records<span className="title-dot">.</span>
211
+ </h1>
212
+ <p>{data?.zone?.name || "Records for the zone authorized at login."}</p>
213
+ </div>
214
+ <button className="secondary" onClick={load} disabled={loading}>
215
+ <RefreshCw size={15} />
216
+ Refresh
217
+ </button>
218
+ </div>
219
+ {error && <div className="error">{error}</div>}
220
+ {notice && <div className="notice">{notice}</div>}
221
+ <section className="panel">
222
+ {loading && data && (
223
+ <div
224
+ className="load-progress"
225
+ role="progressbar"
226
+ aria-label="Loading DNS records"
227
+ >
228
+ <i />
229
+ </div>
230
+ )}
231
+ {(data || !loading) && (
232
+ <div className="toolbar">
233
+ <div className="search">
234
+ <Search size={16} />
235
+ <input
236
+ value={query}
237
+ onChange={(e) => setQuery(e.target.value)}
238
+ placeholder="Search name or content"
239
+ aria-label="Search DNS records"
240
+ />
241
+ </div>
242
+ <select
243
+ value={type}
244
+ onChange={(e) => setType(e.target.value)}
245
+ aria-label="DNS record type"
246
+ >
247
+ <option value="all">All types</option>
248
+ {types.map((item) => (
249
+ <option key={item}>{item}</option>
250
+ ))}
251
+ </select>
252
+ <span className="toolbar-count">{records.length} records</span>
253
+ </div>
254
+ )}
255
+ {!data && loading ? (
256
+ <div className="table-wrap dns-table-wrap">
257
+ <table className="dns-table" aria-busy="true" aria-label="Loading DNS records">
258
+ <thead>
259
+ <tr>
260
+ <th>Type</th>
261
+ <th>Name</th>
262
+ <th>Content</th>
263
+ <th>Proxy</th>
264
+ <th>TTL</th>
265
+ <th></th>
266
+ </tr>
267
+ </thead>
268
+ <tbody>
269
+ {Array.from({ length: 6 }, (_, row) => (
270
+ <tr key={row} className="skeleton-row">
271
+ <td>
272
+ <span className="skeleton" />
273
+ </td>
274
+ <td>
275
+ <span className="skeleton" />
276
+ </td>
277
+ <td>
278
+ <span className="skeleton" />
279
+ </td>
280
+ <td>
281
+ <span className="skeleton" />
282
+ </td>
283
+ <td>
284
+ <span className="skeleton" />
285
+ </td>
286
+ <td />
287
+ </tr>
288
+ ))}
289
+ </tbody>
290
+ </table>
291
+ </div>
292
+ ) : records.length ? (
293
+ <div className="table-wrap dns-table-wrap">
294
+ <table className="dns-table">
295
+ <thead>
296
+ <tr>
297
+ <th>Type</th>
298
+ <th>Name</th>
299
+ <th>Content</th>
300
+ <th>Proxy</th>
301
+ <th>TTL</th>
302
+ <th></th>
303
+ </tr>
304
+ </thead>
305
+ <tbody>
306
+ {records.map((record) => (
307
+ <tr
308
+ key={record.id}
309
+ onClick={() => open(record.id)}
310
+ className={detail?.id === record.id ? "selected-row" : ""}
311
+ >
312
+ <td>{record.type}</td>
313
+ <td className="mono" title={record.name}>
314
+ {record.name}
315
+ </td>
316
+ <td className="mono" title={record.content}>
317
+ {record.content}
318
+ </td>
319
+ <td>
320
+ {record.proxiable
321
+ ? record.proxied
322
+ ? "Proxied"
323
+ : "DNS only"
324
+ : "—"}
325
+ </td>
326
+ <td>{ttlLabel(record.ttl)}</td>
327
+ <td>
328
+ {canWrite && (
329
+ <button
330
+ className="text-button"
331
+ disabled={record.locked}
332
+ title={record.locked ? "Locked by Cloudflare" : "Edit record"}
333
+ onClick={(event) => beginEdit(record, event)}
334
+ >
335
+ <Pencil size={14} />
336
+ Edit
337
+ </button>
338
+ )}
339
+ </td>
340
+ </tr>
341
+ ))}
342
+ </tbody>
343
+ </table>
344
+ </div>
345
+ ) : (
346
+ <div className="empty">
347
+ <Cloud size={30} />
348
+ <h3>No matching records</h3>
349
+ <p>This zone has no DNS records for the current filter.</p>
350
+ </div>
351
+ )}
352
+ </section>
353
+ {detail && (
354
+ <section className="panel dns-detail">
355
+ <div className="section-heading">
356
+ <h2>{draft ? "Edit record" : "Record details"}</h2>
357
+ <span className="tiny-label">{detail.type}</span>
358
+ </div>
359
+ {draft ? (
360
+ <form className="dns-edit" onSubmit={saveEdit}>
361
+ <label>
362
+ Name
363
+ <input
364
+ value={draft.name}
365
+ onChange={(event) =>
366
+ setDraft({ ...draft, name: event.target.value })
367
+ }
368
+ required
369
+ />
370
+ </label>
371
+ <label>
372
+ Content
373
+ <textarea
374
+ value={draft.content}
375
+ onChange={(event) =>
376
+ setDraft({ ...draft, content: event.target.value })
377
+ }
378
+ required
379
+ />
380
+ </label>
381
+ <label>
382
+ TTL
383
+ <select
384
+ value={draft.ttl}
385
+ onChange={(event) =>
386
+ setDraft({ ...draft, ttl: Number(event.target.value) })
387
+ }
388
+ >
389
+ <option value={1}>Auto</option>
390
+ <option value={60}>1 minute</option>
391
+ <option value={300}>5 minutes</option>
392
+ <option value={3600}>1 hour</option>
393
+ <option value={86400}>1 day</option>
394
+ </select>
395
+ </label>
396
+ {detail.proxiable && (
397
+ <label className="check-line">
398
+ <input
399
+ type="checkbox"
400
+ checked={draft.proxied}
401
+ onChange={(event) =>
402
+ setDraft({ ...draft, proxied: event.target.checked })
403
+ }
404
+ />
405
+ Proxied
406
+ </label>
407
+ )}
408
+ {["MX", "SRV", "URI"].includes(detail.type) && (
409
+ <label>
410
+ Priority
411
+ <input
412
+ type="number"
413
+ min="0"
414
+ max="65535"
415
+ value={draft.priority}
416
+ onChange={(event) =>
417
+ setDraft({
418
+ ...draft,
419
+ priority: Number(event.target.value),
420
+ })
421
+ }
422
+ />
423
+ </label>
424
+ )}
425
+ <label>
426
+ Comment
427
+ <input
428
+ value={draft.comment}
429
+ maxLength={500}
430
+ onChange={(event) =>
431
+ setDraft({ ...draft, comment: event.target.value })
432
+ }
433
+ />
434
+ </label>
435
+ <div className="modal-actions">
436
+ <button
437
+ type="button"
438
+ className="secondary"
439
+ onClick={() => setDraft(null)}
440
+ disabled={saving}
441
+ >
442
+ Cancel
443
+ </button>
444
+ <button className="primary" disabled={saving}>
445
+ {saving ? "Saving…" : "Save record"}
446
+ </button>
447
+ </div>
448
+ </form>
449
+ ) : (
450
+ <dl>
451
+ <dt>Name</dt>
452
+ <dd className="mono">{detail.name}</dd>
453
+ <dt>Content</dt>
454
+ <dd className="mono">{detail.content}</dd>
455
+ <dt>TTL</dt>
456
+ <dd>{ttlLabel(detail.ttl)}</dd>
457
+ <dt>Proxy</dt>
458
+ <dd>
459
+ {detail.proxiable
460
+ ? detail.proxied
461
+ ? "Proxied"
462
+ : "DNS only"
463
+ : "Not proxiable"}
464
+ </dd>
465
+ <dt>Priority</dt>
466
+ <dd>{detail.priority ?? "—"}</dd>
467
+ <dt>Comment</dt>
468
+ <dd>{detail.comment || "—"}</dd>
469
+ <dt>Tags</dt>
470
+ <dd>{detail.tags?.length ? detail.tags.join(", ") : "—"}</dd>
471
+ <dt>Locked</dt>
472
+ <dd>{detail.locked ? "Yes" : "No"}</dd>
473
+ <dt>Created</dt>
474
+ <dd>{when(detail.created_on)}</dd>
475
+ <dt>Modified</dt>
476
+ <dd>{when(detail.modified_on)}</dd>
477
+ <dt>Record ID</dt>
478
+ <dd className="mono">{detail.id}</dd>
479
+ </dl>
480
+ )}
481
+ </section>
482
+ )}
483
+ </>
484
+ );
485
+ }
486
+ export function EmailScreen({ canWrite }) {
487
+ const [data, setData] = useState(null);
488
+ const [error, setError] = useState("");
489
+ const [notice, setNotice] = useState("");
490
+ const [busy, setBusy] = useState(false);
491
+ const [loading, setLoading] = useState(true);
492
+ const [authorized, setAuthorized] = useState(false);
493
+ const [apiProgress, setApiProgress] = useState({ state: "idle" });
494
+ const [matcher, setMatcher] = useState("literal");
495
+ async function load() {
496
+ setLoading(true);
497
+ setError("");
498
+ try {
499
+ setData(await api("cloudflare/email"));
500
+ } catch (e) {
501
+ setError(e.message);
502
+ } finally {
503
+ setLoading(false);
504
+ }
505
+ }
506
+ useEffect(() => {
507
+ let stop = false;
508
+ api("cloudflare/status")
509
+ .then((status) => {
510
+ if (stop) return;
511
+ setAuthorized(Boolean(status.api_authorized));
512
+ if (status.api_authorized) load();
513
+ else setLoading(false);
514
+ })
515
+ .catch((e) => {
516
+ if (!stop) {
517
+ setError(e.message);
518
+ setLoading(false);
519
+ }
520
+ });
521
+ return () => {
522
+ stop = true;
523
+ };
524
+ }, []);
525
+ useEffect(() => {
526
+ if (apiProgress.state !== "running") return undefined;
527
+ const timer = setInterval(async () => {
528
+ try {
529
+ const current = await api("cloudflare/api-login");
530
+ setApiProgress(current);
531
+ if (current.state === "done" || current.skipped) {
532
+ setAuthorized(true);
533
+ setNotice("Cloudflare API authorized.");
534
+ await load();
535
+ } else if (current.state === "error") setError(current.error);
536
+ } catch (e) {
537
+ setError(e.message);
538
+ }
539
+ }, 1000);
540
+ return () => clearInterval(timer);
541
+ }, [apiProgress.state]);
542
+ async function authorize() {
543
+ setBusy(true);
544
+ setError("");
545
+ try {
546
+ const started = await api("cloudflare/api-login", { method: "POST" });
547
+ setApiProgress(started);
548
+ if (started.skipped || started.registered) {
549
+ setAuthorized(true);
550
+ setNotice("Cloudflare API authorized.");
551
+ await load();
552
+ }
553
+ } catch (e) {
554
+ setError(e.message);
555
+ } finally {
556
+ setBusy(false);
557
+ }
558
+ }
559
+ async function run(action) {
560
+ setBusy(true);
561
+ setError("");
562
+ setNotice("");
563
+ try {
564
+ await action();
565
+ await load();
566
+ } catch (e) {
567
+ setError(e.message);
568
+ } finally {
569
+ setBusy(false);
570
+ }
571
+ }
572
+ async function saveRule(event) {
573
+ event.preventDefault();
574
+ const form = event.currentTarget;
575
+ const values = Object.fromEntries(new FormData(form));
576
+ await run(async () => {
577
+ await api("cloudflare/email/rules", {
578
+ method: "POST",
579
+ body: JSON.stringify({
580
+ name: values.name,
581
+ matcher: values.matcher,
582
+ local: values.matcher === "all" ? "" : values.local,
583
+ domain: values.matcher === "all" ? "" : values.domain,
584
+ action: values.action,
585
+ destination: values.destination,
586
+ enabled: true,
587
+ }),
588
+ });
589
+ form.reset();
590
+ setMatcher("literal");
591
+ setNotice("Routing rule created.");
592
+ });
593
+ }
594
+ return (
595
+ <>
596
+ <div className="page-heading">
597
+ <div>
598
+ <h1>
599
+ Email routing<span className="title-dot">.</span>
600
+ </h1>
601
+ <p>
602
+ {data?.settings?.name
603
+ ? `Forward mail for ${data.settings.name}.`
604
+ : "Custom addresses, catch-all, and destination addresses."}
605
+ </p>
606
+ </div>
607
+ <button className="secondary" onClick={load} disabled={loading || busy}>
608
+ <RefreshCw size={15} />
609
+ Refresh
610
+ </button>
611
+ </div>
612
+ {error && <div className="error">{error}</div>}
613
+ {notice && <div className="notice">{notice}</div>}
614
+ {!authorized && (
615
+ <section className="panel connect-panel">
616
+ <div className="section-heading">
617
+ <h2>
618
+ <Cloud size={18} />
619
+ Authorize with Wrangler
620
+ </h2>
621
+ </div>
622
+ <div className="connect-body">
623
+ <p>
624
+ Email routing uses <span className="mono">wrangler login</span>.
625
+ The CLI opens a browser. You do not add CLOUDFLARE_API_TOKEN to
626
+ .env. DNS stays on the cloudflared certificate.
627
+ </p>
628
+ {apiProgress.log && (
629
+ <pre className="connect-log">{apiProgress.log}</pre>
630
+ )}
631
+ {apiProgress.url && (
632
+ <a
633
+ className="primary connect-link"
634
+ href={apiProgress.url}
635
+ target="_blank"
636
+ rel="noreferrer"
637
+ >
638
+ Open Wrangler login
639
+ </a>
640
+ )}
641
+ <button
642
+ className="primary"
643
+ onClick={authorize}
644
+ disabled={!canWrite || busy || apiProgress.state === "running"}
645
+ >
646
+ <Cloud size={16} />
647
+ {apiProgress.state === "running"
648
+ ? "Waiting for approval…"
649
+ : "Authorize with Wrangler"}
650
+ </button>
651
+ </div>
652
+ </section>
653
+ )}
654
+ {loading && !data ? (
655
+ <EmailSkeleton />
656
+ ) : (
657
+ data && (
658
+ <div className="stack">
659
+ <section className="panel email-status">
660
+ <div>
661
+ <Mail size={18} />
662
+ <div>
663
+ <strong>{data.settings.enabled ? "Enabled" : "Disabled"}</strong>
664
+ <small>Status: {data.settings.status}</small>
665
+ </div>
666
+ </div>
667
+ <button
668
+ className={data.settings.enabled ? "secondary" : "primary"}
669
+ disabled={!canWrite || busy}
670
+ onClick={() =>
671
+ run(async () => {
672
+ await api("cloudflare/email/routing", {
673
+ method: "POST",
674
+ body: JSON.stringify({ enabled: !data.settings.enabled }),
675
+ });
676
+ setNotice(
677
+ data.settings.enabled
678
+ ? "Email routing disabled."
679
+ : "Email routing enabled. Cloudflare adds the required MX and SPF records.",
680
+ );
681
+ })
682
+ }
683
+ >
684
+ {data.settings.enabled ? "Disable routing" : "Enable routing"}
685
+ </button>
686
+ </section>
687
+ <section className="panel">
688
+ <div className="section-heading">
689
+ <h2>Destination addresses</h2>
690
+ </div>
691
+ {data.address_error && <div className="error">{data.address_error}</div>}
692
+ {canWrite && (
693
+ <form
694
+ className="inline-form address-form"
695
+ onSubmit={(event) => {
696
+ event.preventDefault();
697
+ const form = event.currentTarget;
698
+ const email = new FormData(form).get("email");
699
+ run(async () => {
700
+ await api("cloudflare/email/addresses", {
701
+ method: "POST",
702
+ body: JSON.stringify({ email }),
703
+ });
704
+ form.reset();
705
+ setNotice("Verification email sent to that destination.");
706
+ });
707
+ }}
708
+ >
709
+ <label>
710
+ Add destination
711
+ <input name="email" type="email" required placeholder="you@example.com" />
712
+ </label>
713
+ <button className="primary" disabled={busy}>
714
+ Send verification
715
+ </button>
716
+ </form>
717
+ )}
718
+ {data.addresses.length ? (
719
+ <div className="table-wrap">
720
+ <table className="email-table">
721
+ <thead>
722
+ <tr>
723
+ <th>Email</th>
724
+ <th>Verified</th>
725
+ <th></th>
726
+ </tr>
727
+ </thead>
728
+ <tbody>
729
+ {data.addresses.map((address) => (
730
+ <tr key={address.id}>
731
+ <td>{address.email}</td>
732
+ <td>{address.verified ? when(address.verified) : "Pending"}</td>
733
+ <td>
734
+ {canWrite && (
735
+ <button
736
+ className="text-button danger-text"
737
+ disabled={busy}
738
+ onClick={() => {
739
+ if (window.confirm(`Remove ${address.email}?`))
740
+ run(() =>
741
+ api(`cloudflare/email/addresses/${address.id}`, {
742
+ method: "DELETE",
743
+ }),
744
+ );
745
+ }}
746
+ >
747
+ <Trash2 size={14} />
748
+ Remove
749
+ </button>
750
+ )}
751
+ </td>
752
+ </tr>
753
+ ))}
754
+ </tbody>
755
+ </table>
756
+ </div>
757
+ ) : (
758
+ <div className="empty">
759
+ <Mail size={30} />
760
+ <h3>No destinations yet</h3>
761
+ <p>Add an inbox, then confirm the verification email before using it in a rule.</p>
762
+ </div>
763
+ )}
764
+ </section>
765
+ <section className="panel">
766
+ <div className="section-heading">
767
+ <h2>Routing rules</h2>
768
+ </div>
769
+ {canWrite && (
770
+ <form className="inline-form rules-form" onSubmit={saveRule}>
771
+ <label>
772
+ Rule name
773
+ <input name="name" required maxLength={80} placeholder="Support inbox" />
774
+ </label>
775
+ <label>
776
+ Match
777
+ <select
778
+ name="matcher"
779
+ value={matcher}
780
+ onChange={(event) => setMatcher(event.target.value)}
781
+ >
782
+ <option value="literal">Custom address</option>
783
+ <option value="all">Catch-all</option>
784
+ </select>
785
+ </label>
786
+ {matcher === "literal" && (
787
+ <label>
788
+ Mail address
789
+ <span className="mail-address">
790
+ <input
791
+ name="local"
792
+ required
793
+ maxLength={64}
794
+ placeholder="hello"
795
+ autoComplete="off"
796
+ />
797
+ <span>@</span>
798
+ {(data.domains || []).length > 1 ? (
799
+ <select name="domain" defaultValue={data.domains[0]} aria-label="Domain">
800
+ {data.domains.map((domain) => (
801
+ <option key={domain} value={domain}>
802
+ {domain}
803
+ </option>
804
+ ))}
805
+ </select>
806
+ ) : (
807
+ <>
808
+ <input
809
+ type="hidden"
810
+ name="domain"
811
+ value={(data.domains || [])[0] || data.settings?.name || data.zone || ""}
812
+ />
813
+ <span className="domain-suffix">
814
+ {(data.domains || [])[0] || data.settings?.name || data.zone}
815
+ </span>
816
+ </>
817
+ )}
818
+ </span>
819
+ </label>
820
+ )}
821
+ <label>
822
+ Action
823
+ <select name="action" defaultValue="forward">
824
+ <option value="forward">Forward</option>
825
+ <option value="drop">Drop</option>
826
+ </select>
827
+ </label>
828
+ <label>
829
+ Destination
830
+ <input name="destination" type="email" placeholder="you@example.com" />
831
+ </label>
832
+ <button className="primary" disabled={busy}>
833
+ Add rule
834
+ </button>
835
+ </form>
836
+ )}
837
+ {data.rules.length ? (
838
+ <div className="table-wrap">
839
+ <table className="email-table">
840
+ <thead>
841
+ <tr>
842
+ <th>Name</th>
843
+ <th>Match</th>
844
+ <th>Action</th>
845
+ <th>Enabled</th>
846
+ <th></th>
847
+ </tr>
848
+ </thead>
849
+ <tbody>
850
+ {data.rules.map((rule) => (
851
+ <tr key={rule.id}>
852
+ <td>{rule.name}</td>
853
+ <td>
854
+ {rule.matcher === "all"
855
+ ? "Catch-all"
856
+ : rule.custom_address || rule.matcher}
857
+ </td>
858
+ <td>
859
+ {rule.action === "drop"
860
+ ? "Drop"
861
+ : `Forward to ${rule.destination}`}
862
+ </td>
863
+ <td>{rule.enabled ? "Yes" : "No"}</td>
864
+ <td>
865
+ {canWrite && (
866
+ <span className="row-actions">
867
+ <button
868
+ className="text-button"
869
+ disabled={busy}
870
+ onClick={() =>
871
+ run(async () => {
872
+ await api(`cloudflare/email/rules/${rule.id}`, {
873
+ method: "PUT",
874
+ body: JSON.stringify({
875
+ ...rule,
876
+ enabled: !rule.enabled,
877
+ }),
878
+ });
879
+ setNotice(
880
+ rule.enabled ? "Rule disabled." : "Rule enabled.",
881
+ );
882
+ })
883
+ }
884
+ >
885
+ {rule.enabled ? "Disable" : "Enable"}
886
+ </button>
887
+ <button
888
+ className="text-button danger-text"
889
+ disabled={busy}
890
+ onClick={() => {
891
+ if (
892
+ window.confirm(`Delete “${rule.name}”?`)
893
+ )
894
+ run(() =>
895
+ api(
896
+ `cloudflare/email/rules/${rule.id}?domain=${encodeURIComponent((rule.custom_address || "").split("@").pop() || "")}`,
897
+ { method: "DELETE" },
898
+ ),
899
+ );
900
+ }}
901
+ >
902
+ <Trash2 size={14} />
903
+ Delete
904
+ </button>
905
+ </span>
906
+ )}
907
+ </td>
908
+ </tr>
909
+ ))}
910
+ </tbody>
911
+ </table>
912
+ </div>
913
+ ) : (
914
+ <div className="empty">
915
+ <Mail size={30} />
916
+ <h3>No routing rules</h3>
917
+ <p>Add a custom address or a catch-all forward.</p>
918
+ </div>
919
+ )}
920
+ </section>
921
+ </div>
922
+ )
923
+ )}
924
+ </>
925
+ );
926
+ }