@naviyra/cloud-panel 0.0.0-stage → 0.0.2
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/.env.example +40 -0
- package/README.md +145 -2
- package/apps/agent/package.json +13 -0
- package/apps/agent/src/cloudflare.mjs +733 -0
- package/apps/agent/src/native-worker.mjs +52 -0
- package/apps/agent/src/native.mjs +457 -0
- package/apps/agent/src/runtime.mjs +760 -0
- package/apps/agent/src/server.mjs +195 -0
- package/apps/agent/src/static-server.mjs +53 -0
- package/apps/panel/AGENTS.md +9 -0
- package/apps/panel/CLAUDE.md +1 -0
- package/apps/panel/app/api/[...path]/route.js +445 -0
- package/apps/panel/app/globals.css +2057 -0
- package/apps/panel/app/icon.png +0 -0
- package/apps/panel/app/layout.jsx +18 -0
- package/apps/panel/app/login/page.jsx +108 -0
- package/apps/panel/app/page.jsx +33 -0
- package/apps/panel/app/ui/cloudflare.jsx +926 -0
- package/apps/panel/app/ui/dashboard.jsx +1487 -0
- package/apps/panel/app/ui/databases.jsx +293 -0
- package/apps/panel/app/ui/skeleton.jsx +196 -0
- package/apps/panel/app/ui/theme.jsx +35 -0
- package/apps/panel/lib/agent.mjs +83 -0
- package/apps/panel/lib/auth.mjs +48 -0
- package/apps/panel/lib/database-runtime.mjs +243 -0
- package/apps/panel/lib/databases.mjs +292 -0
- package/apps/panel/lib/db.mjs +6 -0
- package/apps/panel/next.config.mjs +8 -0
- package/apps/panel/package.json +22 -0
- package/apps/panel/public/naviyra-logo.png +0 -0
- package/bin/naviyra.mjs +215 -0
- package/db/001_init.sql +28 -0
- package/db/002_native_runtimes.sql +3 -0
- package/db/003_databases.sql +8 -0
- package/db/004_database_engines.sql +4 -0
- package/deploy/naviyra-agent.service +17 -0
- package/docker-compose.yml +18 -0
- package/docs/managed-runtime.md +52 -0
- package/docs/native-hosts.md +48 -0
- package/package.json +61 -4
- package/packages/runtime/artifacts.json +32 -0
- package/packages/runtime/index.cjs +526 -0
- package/packages/runtime/index.mjs +10 -0
- package/packages/runtime/package.json +6 -0
- package/packages/shared/index.mjs +85 -0
- package/packages/shared/package.json +9 -0
- package/scripts/migrate.mjs +18 -0
- package/scripts/panel-control.mjs +195 -0
- package/scripts/postgres-local.mjs +97 -0
- package/scripts/runtime-download-manifest.mjs +30 -0
- package/scripts/runtime.mjs +20 -0
- package/scripts/seed.mjs +35 -0
- package/scripts/service-supervisor.mjs +164 -0
- package/scripts/setup-local.mjs +215 -0
- package/scripts/start-naviyra.mjs +303 -0
- 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
|
+
}
|