@firedrill-tools/resend 0.1.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.
- package/LICENSE +201 -0
- package/README.md +198 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +1687 -0
- package/firedrill/conformance.suite.json +19 -0
- package/firedrill/contacts-outage.scenario.json +11 -0
- package/firedrill/quota-exhausted.scenario.json +17 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/resend-audience.drill.json +407 -0
- package/firedrill/resend-contacts-outage.drill.json +63 -0
- package/firedrill/resend-denied.drill.json +68 -0
- package/firedrill/resend-domains-and-keys.drill.json +307 -0
- package/firedrill/resend-email-flow.drill.json +286 -0
- package/firedrill/resend-fresh-install.drill.json +73 -0
- package/firedrill/resend-invalid-key.drill.json +803 -0
- package/firedrill/resend-large-page.drill.json +53 -0
- package/firedrill/resend-quota-exhausted.drill.json +82 -0
- package/firedrill/resend-rate-limited.drill.json +81 -0
- package/firedrill/resend-restricted-key.drill.json +441 -0
- package/firedrill/resend-tight-limits.drill.json +163 -0
- package/firedrill/tight-limits.scenario.json +16 -0
- package/firedrill/tools/resend/app/assets/ATTRIBUTION.md +30 -0
- package/firedrill/tools/resend/app/assets/fonts/Inter-OFL.txt +93 -0
- package/firedrill/tools/resend/app/assets/fonts/JetBrainsMono-OFL.txt +93 -0
- package/firedrill/tools/resend/app/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/assets/resend-icon-white.svg +3 -0
- package/firedrill/tools/resend/app/assets/resend-wordmark-white.svg +3 -0
- package/firedrill/tools/resend/app/assets/resend.svg +3 -0
- package/firedrill/tools/resend/app/site/app.js +90 -0
- package/firedrill/tools/resend/app/site/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/site/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/site/assets/resend-icon-white.svg +3 -0
- package/firedrill/tools/resend/app/site/assets/resend-wordmark-white.svg +3 -0
- package/firedrill/tools/resend/app/site/assets/resend.svg +3 -0
- package/firedrill/tools/resend/app/site/contact-panel.js +86 -0
- package/firedrill/tools/resend/app/site/icons.js +70 -0
- package/firedrill/tools/resend/app/site/index.html +56 -0
- package/firedrill/tools/resend/app/site/list.js +41 -0
- package/firedrill/tools/resend/app/site/styles.css +67 -0
- package/firedrill/tools/resend/app/site/ui.js +259 -0
- package/firedrill/tools/resend/app/site/view-audience.js +160 -0
- package/firedrill/tools/resend/app/site/view-domains.js +106 -0
- package/firedrill/tools/resend/app/site/view-email-detail.js +126 -0
- package/firedrill/tools/resend/app/site/view-emails.js +83 -0
- package/firedrill/tools/resend/app/site/view-keys.js +91 -0
- package/firedrill/tools/resend/app/site/views.css +141 -0
- package/firedrill/tools/resend/behavior.mjs +114 -0
- package/firedrill/tools/resend/lib/check.mjs +75 -0
- package/firedrill/tools/resend/lib/core.mjs +112 -0
- package/firedrill/tools/resend/lib/json-depth.mjs +45 -0
- package/firedrill/tools/resend/lib/page.mjs +66 -0
- package/firedrill/tools/resend/lib/time.mjs +60 -0
- package/firedrill/tools/resend/lib/wire.mjs +98 -0
- package/firedrill/tools/resend/ops/contacts.mjs +199 -0
- package/firedrill/tools/resend/ops/domains.mjs +125 -0
- package/firedrill/tools/resend/ops/email-model.mjs +100 -0
- package/firedrill/tools/resend/ops/emails.mjs +139 -0
- package/firedrill/tools/resend/ops/keys-segments.mjs +97 -0
- package/firedrill/tools/resend/resend.tool.json +4804 -0
- package/firedrill/world.json +2148 -0
- package/firedrill.json +5 -0
- package/package.json +62 -0
- package/starter.json +1686 -0
- package/test/conformance.mjs +480 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Audience: Contacts (search, segment filter, add, open panel) and Segments (list, create, delete).
|
|
2
|
+
import { refresh } from "./app.js";
|
|
3
|
+
import { openContact } from "./contact-panel.js";
|
|
4
|
+
import { icon } from "./icons.js";
|
|
5
|
+
import { Cursor, debounce, pagerBar, table } from "./list.js";
|
|
6
|
+
import { badge, button, call, confirmDialog, describe, el, emptyState, errorState, field, guard, modal, mutate, skeletonRows, timeCell, toast } from "./ui.js";
|
|
7
|
+
|
|
8
|
+
const contacts = { query: "", segmentId: "", cursor: new Cursor(), token: 0 };
|
|
9
|
+
const segCursor = new Cursor();
|
|
10
|
+
|
|
11
|
+
export async function allSegments() {
|
|
12
|
+
const out = [];
|
|
13
|
+
let after;
|
|
14
|
+
for (let page = 0; page < 50; page += 1) {
|
|
15
|
+
const list = await call("segments.list", { limit: 100, ...(after ? { after } : {}) });
|
|
16
|
+
out.push(...list.data);
|
|
17
|
+
if (!list.has_more || list.data.length === 0) break;
|
|
18
|
+
after = list.data[list.data.length - 1].id;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function tabs(active) {
|
|
24
|
+
const t = (label, href) => el("a", { text: label, attrs: { href, role: "tab", "aria-selected": String(label === active), class: "tab-link" } });
|
|
25
|
+
return el("div", { class: "tabs", attrs: { role: "tablist" } }, [t("Contacts", "#/audience/contacts"), t("Segments", "#/audience/segments"), el("button", { text: "Topics", attrs: { type: "button", "data-soon": "Topics" } }), el("button", { text: "Properties", attrs: { type: "button", "data-soon": "Properties" } })]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function renderContacts(_m, view, { refresh: again = false } = {}) {
|
|
29
|
+
document.title = "Contacts · Resend (synthetic)";
|
|
30
|
+
if (!again || !view.querySelector("#contacts-table")) {
|
|
31
|
+
const search = el("input", { class: "input", attrs: { type: "search", placeholder: "Search by email or name...", "aria-label": "Search contacts" } });
|
|
32
|
+
search.value = contacts.query;
|
|
33
|
+
search.addEventListener("input", debounce(() => { contacts.query = search.value.trim(); contacts.cursor.reset(); void loadContacts(); }));
|
|
34
|
+
const segment = el("select", { class: "select", attrs: { id: "contact-segment", "aria-label": "Filter by segment" } }, [el("option", { text: "All contacts", attrs: { value: "" } })]);
|
|
35
|
+
segment.addEventListener("change", () => { contacts.segmentId = segment.value; contacts.cursor.reset(); void loadContacts(); });
|
|
36
|
+
view.replaceChildren(
|
|
37
|
+
el("div", { class: "page-head" }, [el("h1", { text: "Audience" }), el("div", { class: "head-actions" }, [button("Export", { iconName: "download", attrs: { "data-soon": "Export" } }), button("Add contacts", { variant: "primary", iconName: "plus", onClick: () => void addContact() })])]),
|
|
38
|
+
tabs("Contacts"),
|
|
39
|
+
el("div", { class: "toolbar" }, [el("div", { class: "search" }, [icon("search", 14), search]), segment]),
|
|
40
|
+
el("div", { attrs: { id: "contacts-status" } }),
|
|
41
|
+
el("div", { attrs: { id: "contacts-table" } }, [table(["Email", "Name", "Status", "Added"], skeletonRows(4, 8), "Contacts")]),
|
|
42
|
+
el("div", { attrs: { id: "contacts-pager" } }),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const segs = await allSegments();
|
|
47
|
+
const select = view.querySelector("#contact-segment");
|
|
48
|
+
select.replaceChildren(el("option", { text: "All contacts", attrs: { value: "" } }), ...segs.map((s) => el("option", { text: s.name, attrs: { value: s.id } })));
|
|
49
|
+
if (!segs.some((s) => s.id === contacts.segmentId)) contacts.segmentId = "";
|
|
50
|
+
select.value = contacts.segmentId;
|
|
51
|
+
} catch { /* the contact list reports its own error */ }
|
|
52
|
+
await loadContacts();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function loadContacts() {
|
|
56
|
+
const token = ++contacts.token;
|
|
57
|
+
const args = { limit: 20, ...contacts.cursor.args() };
|
|
58
|
+
if (contacts.query) args.query = contacts.query;
|
|
59
|
+
if (contacts.segmentId) args.segmentId = contacts.segmentId;
|
|
60
|
+
const status = document.querySelector("#contacts-status");
|
|
61
|
+
const holder = document.querySelector("#contacts-table");
|
|
62
|
+
const pager = document.querySelector("#contacts-pager");
|
|
63
|
+
if (!holder) return;
|
|
64
|
+
let list;
|
|
65
|
+
try { list = await call("contacts.list", args); } catch (error) {
|
|
66
|
+
if (token !== contacts.token) return;
|
|
67
|
+
status.replaceChildren(errorState(error, () => void loadContacts()));
|
|
68
|
+
holder.replaceChildren();
|
|
69
|
+
pager.replaceChildren();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (token !== contacts.token) return;
|
|
73
|
+
status.replaceChildren();
|
|
74
|
+
if (list.data.length === 0 && contacts.cursor.page === 1) {
|
|
75
|
+
holder.replaceChildren(contacts.query || contacts.segmentId ? emptyState("No contacts found", "No contacts match this search or segment.") : emptyState("No contacts yet", "Add contacts to build your audience.", button("Add contacts", { variant: "primary", iconName: "plus", onClick: () => void addContact() })));
|
|
76
|
+
pager.replaceChildren();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const body = el("tbody", {}, list.data.map((c) => {
|
|
80
|
+
const name = [c.first_name, c.last_name].filter(Boolean).join(" ");
|
|
81
|
+
const row = el("tr", { class: "row", attrs: { tabindex: "0", "aria-label": `Contact ${c.email}` } }, [
|
|
82
|
+
el("td", {}, [el("div", { class: "cell-main" }, [el("span", { class: "tile avatar", text: (name || c.email).charAt(0).toUpperCase() }), el("span", { text: c.email })])]),
|
|
83
|
+
el("td", { class: "muted", text: name || "—" }),
|
|
84
|
+
el("td", {}, [badge(c.unsubscribed ? "unsubscribed" : "subscribed")]),
|
|
85
|
+
el("td", { class: "muted" }, [timeCell(c.created_at)]),
|
|
86
|
+
]);
|
|
87
|
+
const open = () => void openContact(c.id).then((changed) => { if (changed) void refresh(); });
|
|
88
|
+
row.addEventListener("click", open);
|
|
89
|
+
row.addEventListener("keydown", (e) => { if (e.key === "Enter") open(); });
|
|
90
|
+
return row;
|
|
91
|
+
}));
|
|
92
|
+
holder.replaceChildren(table(["Email", "Name", "Status", "Added"], body, "Contacts"));
|
|
93
|
+
pager.replaceChildren(pagerBar(contacts.cursor, list, () => void loadContacts(), "contacts"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function addContact() {
|
|
97
|
+
let segs = [];
|
|
98
|
+
try { segs = await allSegments(); } catch { segs = []; }
|
|
99
|
+
const done = await modal("Add contact", (close) => {
|
|
100
|
+
const email = el("input", { class: "input", attrs: { id: "contact-email", type: "email", placeholder: "steve.wozniak@example.com", autocomplete: "off" } });
|
|
101
|
+
const first = el("input", { class: "input", attrs: { id: "contact-first", autocomplete: "off" } });
|
|
102
|
+
const last = el("input", { class: "input", attrs: { id: "contact-last", autocomplete: "off" } });
|
|
103
|
+
const boxes = segs.map((s) => el("label", { class: "check" }, [el("input", { attrs: { type: "checkbox", value: s.id } }), el("span", { text: s.name })]));
|
|
104
|
+
const error = el("p", { class: "form-error", attrs: { role: "alert" } });
|
|
105
|
+
const submit = button("Add", { variant: "primary" });
|
|
106
|
+
submit.type = "submit";
|
|
107
|
+
const form = el("form", {}, [field("Email", email), el("div", { class: "two" }, [field("First name", first), field("Last name", last)]), segs.length ? el("fieldset", { class: "field checks" }, [el("legend", { text: "Segments" }), ...boxes]) : null, error, el("div", { class: "dialog-actions" }, [button("Cancel", { onClick: () => close() }), submit])]);
|
|
108
|
+
form.addEventListener("submit", async (event) => {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
submit.disabled = true;
|
|
111
|
+
error.textContent = "";
|
|
112
|
+
const args = { email: email.value.trim() };
|
|
113
|
+
if (first.value.trim()) args.firstName = first.value.trim();
|
|
114
|
+
if (last.value.trim()) args.lastName = last.value.trim();
|
|
115
|
+
const segmentIds = boxes.map((b) => b.querySelector("input")).filter((i) => i.checked).map((i) => i.value);
|
|
116
|
+
if (segmentIds.length) args.segmentIds = segmentIds;
|
|
117
|
+
try { await guard(() => mutate("contacts.create", args)); close(true); } catch (e) { error.textContent = describe(e); submit.disabled = false; }
|
|
118
|
+
});
|
|
119
|
+
return form;
|
|
120
|
+
});
|
|
121
|
+
if (done) { toast("Contact added"); await refresh(); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function renderSegments(_m, view) {
|
|
125
|
+
document.title = "Segments · Resend (synthetic)";
|
|
126
|
+
const head = el("div", { class: "page-head" }, [el("h1", { text: "Audience" }), el("div", { class: "head-actions" }, [button("Create segment", { variant: "primary", iconName: "plus", onClick: () => void createSegment() })])]);
|
|
127
|
+
if (!view.querySelector("#segments-marker")) view.replaceChildren(head, tabs("Segments"), el("div", { attrs: { id: "segments-marker" } }, [table(["Name", "Created", ""], skeletonRows(3, 3), "Segments")]));
|
|
128
|
+
const holder = view.querySelector("#segments-marker");
|
|
129
|
+
let list;
|
|
130
|
+
try { list = await call("segments.list", { limit: 20, ...segCursor.args() }); } catch (error) { holder.replaceChildren(errorState(error, () => void renderSegments(_m, view))); return; }
|
|
131
|
+
if (list.data.length === 0 && segCursor.page === 1) { holder.replaceChildren(emptyState("No segments yet", "Group contacts into segments to target broadcasts.")); return; }
|
|
132
|
+
const body = el("tbody", {}, list.data.map((s) => el("tr", {}, [
|
|
133
|
+
el("td", {}, [el("div", { class: "cell-main" }, [el("span", { class: "tile" }, [icon("users", 15)]), el("button", { class: "link-btn", text: s.name, attrs: { type: "button", "aria-label": `Show contacts in ${s.name}` }, on: { click: () => { contacts.segmentId = s.id; contacts.cursor.reset(); location.hash = "#/audience/contacts"; } } })])]),
|
|
134
|
+
el("td", { class: "muted" }, [timeCell(s.created_at)]),
|
|
135
|
+
el("td", { class: "actions" }, [el("button", { class: "icon-btn", attrs: { type: "button", "aria-label": `Delete segment ${s.name}`, title: "Delete segment" }, on: { click: () => void removeSegment(s) } }, [icon("trash", 15)])]),
|
|
136
|
+
])));
|
|
137
|
+
holder.replaceChildren(table(["Name", "Created", ""], body, "Segments"), pagerBar(segCursor, list, () => void renderSegments(_m, view), "segments"));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function createSegment() {
|
|
141
|
+
const done = await modal("Create segment", (close) => {
|
|
142
|
+
const name = el("input", { class: "input", attrs: { id: "segment-name", maxlength: "100", autocomplete: "off", placeholder: "Beta testers" } });
|
|
143
|
+
const error = el("p", { class: "form-error", attrs: { role: "alert" } });
|
|
144
|
+
const submit = button("Create", { variant: "primary" });
|
|
145
|
+
submit.type = "submit";
|
|
146
|
+
const form = el("form", {}, [field("Name", name), error, el("div", { class: "dialog-actions" }, [button("Cancel", { onClick: () => close() }), submit])]);
|
|
147
|
+
form.addEventListener("submit", async (event) => {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
submit.disabled = true;
|
|
150
|
+
try { await guard(() => mutate("segments.create", { name: name.value.trim() })); close(true); } catch (e) { error.textContent = describe(e); submit.disabled = false; }
|
|
151
|
+
});
|
|
152
|
+
return form;
|
|
153
|
+
});
|
|
154
|
+
if (done) { toast("Segment created"); await refresh(); }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function removeSegment(s) {
|
|
158
|
+
if (!(await confirmDialog("Delete segment", `Delete "${s.name}"? Contacts stay in your audience; only the segment is removed.`, "Delete segment"))) return;
|
|
159
|
+
try { await guard(() => mutate("segments.remove", { id: s.id })); toast("Segment deleted"); await refresh(); } catch (e) { toast(describe(e), { error: true }); }
|
|
160
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Domains: list with Add domain, and the domain page with DNS records, Verify and Delete.
|
|
2
|
+
import { refresh } from "./app.js";
|
|
3
|
+
import { icon } from "./icons.js";
|
|
4
|
+
import { Cursor, linkRow, pagerBar, table } from "./list.js";
|
|
5
|
+
import { badge, button, call, confirmDialog, copyButton, describe, el, emptyState, errorState, field, formatDate, guard, modal, mutate, skeletonRows, timeCell, toast } from "./ui.js";
|
|
6
|
+
|
|
7
|
+
const REGIONS = [["us-east-1", "North Virginia (us-east-1)"], ["eu-west-1", "Ireland (eu-west-1)"], ["sa-east-1", "São Paulo (sa-east-1)"], ["ap-northeast-1", "Tokyo (ap-northeast-1)"]];
|
|
8
|
+
const regionName = (id) => (REGIONS.find(([v]) => v === id)?.[1] ?? id);
|
|
9
|
+
const cursor = new Cursor();
|
|
10
|
+
|
|
11
|
+
export async function renderDomains(_m, view) {
|
|
12
|
+
document.title = "Domains · Resend (synthetic)";
|
|
13
|
+
const head = el("div", { class: "page-head" }, [el("h1", { text: "Domains" }), el("div", { class: "head-actions" }, [button("Add domain", { variant: "primary", iconName: "plus", onClick: () => void addDomain() })])]);
|
|
14
|
+
const holder = el("div", {}, [table(["Domain", "Status", "Region", "Created"], skeletonRows(4, 4), "Domains")]);
|
|
15
|
+
if (!view.querySelector(".page-head")) view.replaceChildren(head, holder); else view.replaceChildren(head, view.children[1] ?? holder);
|
|
16
|
+
let list;
|
|
17
|
+
try {
|
|
18
|
+
list = await call("domains.list", { limit: 20, ...cursor.args() });
|
|
19
|
+
} catch (error) {
|
|
20
|
+
view.replaceChildren(head, errorState(error, () => void renderDomains(_m, view)));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (list.data.length === 0 && cursor.page === 1) {
|
|
24
|
+
view.replaceChildren(head, emptyState("No domains yet", "Add a domain to start sending emails from your own address.", button("Add domain", { variant: "primary", iconName: "plus", onClick: () => void addDomain() })));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const body = el("tbody", {}, list.data.map((d) => linkRow([
|
|
28
|
+
el("td", {}, [el("div", { class: "cell-main" }, [el("span", { class: "tile" }, [icon("globe", 15)]), el("span", { text: d.name })])]),
|
|
29
|
+
el("td", {}, [badge(d.status)]),
|
|
30
|
+
el("td", { class: "muted", text: regionName(d.region) }),
|
|
31
|
+
el("td", { class: "muted" }, [timeCell(d.created_at)]),
|
|
32
|
+
], `#/domains/${encodeURIComponent(d.id)}`, `Domain ${d.name}, ${d.status}`)));
|
|
33
|
+
view.replaceChildren(head, table(["Domain", "Status", "Region", "Created"], body, "Domains"), pagerBar(cursor, list, () => void renderDomains(_m, view), "domains"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function addDomain() {
|
|
37
|
+
const created = await modal("Add domain", (close) => {
|
|
38
|
+
const name = el("input", { class: "input", attrs: { id: "domain-name", placeholder: "updates.example.com", autocomplete: "off" } });
|
|
39
|
+
const region = el("select", { class: "select", attrs: { id: "domain-region" } }, REGIONS.map(([v, t]) => el("option", { text: t, attrs: { value: v } })));
|
|
40
|
+
region.style.width = "100%";
|
|
41
|
+
const error = el("p", { class: "form-error", attrs: { role: "alert" } });
|
|
42
|
+
const submit = button("Add", { variant: "primary" });
|
|
43
|
+
const form = el("form", {}, [field("Name", name, "Use a subdomain like updates.example.com to separate sending reputation."), field("Region", region), error, el("div", { class: "dialog-actions" }, [button("Cancel", { onClick: () => close() }), submit])]);
|
|
44
|
+
submit.type = "submit";
|
|
45
|
+
form.addEventListener("submit", async (event) => {
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
submit.disabled = true;
|
|
48
|
+
error.textContent = "";
|
|
49
|
+
try {
|
|
50
|
+
close(await guard(() => mutate("domains.create", { name: name.value.trim(), region: region.value })));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
error.textContent = describe(e);
|
|
53
|
+
submit.disabled = false;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return form;
|
|
57
|
+
}, { description: "Send emails from your own domain once its DNS records are verified." });
|
|
58
|
+
if (created) {
|
|
59
|
+
toast(`Domain ${created.name} added`);
|
|
60
|
+
location.hash = `#/domains/${encodeURIComponent(created.id)}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function renderDomainDetail(id, view) {
|
|
65
|
+
document.title = "Domain · Resend (synthetic)";
|
|
66
|
+
let domain;
|
|
67
|
+
try {
|
|
68
|
+
domain = await call("domains.get", { id });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
view.replaceChildren(backLink(), errorState(error, () => void renderDomainDetail(id, view)));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const verify = button("Verify DNS Records", { iconName: "refresh", onClick: async () => {
|
|
74
|
+
verify.disabled = true;
|
|
75
|
+
try { await guard(() => mutate("domains.verify", { id })); toast("Verification started"); await refresh(); } catch (e) { toast(describe(e), { error: true }); verify.disabled = false; }
|
|
76
|
+
} });
|
|
77
|
+
const remove = button("Delete", { variant: "ghost", iconName: "trash", onClick: async () => {
|
|
78
|
+
if (!(await confirmDialog("Delete domain", `Are you sure you want to delete ${domain.name}? Emails can no longer be sent from this domain.`, "Delete domain"))) return;
|
|
79
|
+
try { await guard(() => mutate("domains.remove", { id })); toast(`Domain ${domain.name} deleted`); location.hash = "#/domains"; } catch (e) { toast(describe(e), { error: true }); }
|
|
80
|
+
} });
|
|
81
|
+
const meta = el("div", { class: "meta-grid" }, [
|
|
82
|
+
metaItem("Created", el("span", { text: formatDate(domain.created_at) })),
|
|
83
|
+
metaItem("Status", badge(domain.status)),
|
|
84
|
+
metaItem("Region", el("span", { text: regionName(domain.region) })),
|
|
85
|
+
metaItem("Sending", badge(domain.capabilities?.sending === "enabled" ? "delivered" : "canceled", domain.capabilities?.sending === "enabled" ? "Enabled" : "Disabled")),
|
|
86
|
+
]);
|
|
87
|
+
const banner = domain.status === "failed" ? el("div", { class: "callout callout-error", attrs: { role: "alert" } }, [icon("alert", 16), el("div", {}, [el("strong", { text: "DNS verification failed" }), el("p", { text: "We couldn't find the records below at your DNS provider. Check the values and verify again." })])])
|
|
88
|
+
: domain.status === "not_started" ? el("div", { class: "callout callout-info" }, [icon("clock", 16), el("div", {}, [el("strong", { text: "Add these DNS records" }), el("p", { text: "Add the records to your DNS provider, then click Verify DNS Records." })])]) : null;
|
|
89
|
+
const rows = el("tbody", {}, (domain.records ?? []).map((r) => el("tr", {}, [
|
|
90
|
+
el("td", { text: r.type }),
|
|
91
|
+
el("td", {}, [el("span", { class: "cell-main" }, [el("span", { class: "mono", text: r.name }), copyButton(r.name, `Copy ${r.record} name`)])]),
|
|
92
|
+
el("td", {}, [el("span", { class: "cell-main" }, [el("span", { class: "mono", text: r.value }), copyButton(r.value, `Copy ${r.record} value`)])]),
|
|
93
|
+
el("td", { class: "muted", text: r.ttl }),
|
|
94
|
+
el("td", { class: "muted", text: r.priority ?? "" }),
|
|
95
|
+
el("td", {}, [badge(r.status)]),
|
|
96
|
+
])));
|
|
97
|
+
const groups = el("section", { class: "section" }, [el("h2", { text: "DNS Records" }), table(["Type", "Name", "Content", "TTL", "Priority", "Status"], rows, "DNS records")]);
|
|
98
|
+
view.replaceChildren(backLink(), el("div", { class: "detail-head" }, [el("span", { class: "tile lg" }, [icon("globe", 26)]), el("div", { class: "detail-title" }, [el("p", { class: "eyebrow", text: "Domain" }), el("h1", { text: domain.name })]), el("div", { class: "head-actions" }, [remove, verify])]), ...[meta, banner, groups].filter(Boolean));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function metaItem(label, value) {
|
|
102
|
+
return el("div", { class: "meta-item" }, [el("p", { class: "eyebrow", text: label }), el("div", {}, [value])]);
|
|
103
|
+
}
|
|
104
|
+
function backLink() {
|
|
105
|
+
return el("a", { class: "back", attrs: { href: "#/domains" } }, [icon("chevronLeft", 14), el("span", { text: "Domains" })]);
|
|
106
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Email detail: header, meta, event timeline, Preview / Plain Text / HTML tabs, cancel and reschedule for scheduled emails.
|
|
2
|
+
import { refresh } from "./app.js";
|
|
3
|
+
import { icon } from "./icons.js";
|
|
4
|
+
import { metaItem } from "./view-domains.js";
|
|
5
|
+
import { badge, button, call, clock, confirmDialog, copyButton, describe, el, errorState, field, formatDate, guard, labelFor, modal, mutate, toast } from "./ui.js";
|
|
6
|
+
|
|
7
|
+
const ALLOWED = new Set(["P", "BR", "A", "STRONG", "B", "EM", "I", "U", "H1", "H2", "H3", "H4", "UL", "OL", "LI", "TABLE", "TBODY", "THEAD", "TR", "TD", "TH", "DIV", "SPAN", "SMALL", "HR", "BLOCKQUOTE", "CODE", "PRE"]);
|
|
8
|
+
|
|
9
|
+
/** Rebuilds an allowlisted, attribute-free DOM from the email HTML; links keep their text and never navigate. */
|
|
10
|
+
function previewHtml(html) {
|
|
11
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
12
|
+
const walk = (source, target, depth) => {
|
|
13
|
+
if (depth > 64) return;
|
|
14
|
+
for (const node of source.childNodes) {
|
|
15
|
+
if (node.nodeType === Node.TEXT_NODE) target.append(document.createTextNode(node.textContent));
|
|
16
|
+
else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
17
|
+
if (!ALLOWED.has(node.tagName)) { if (!["SCRIPT", "STYLE", "HEAD", "TITLE"].includes(node.tagName)) walk(node, target, depth + 1); continue; }
|
|
18
|
+
const copy = document.createElement(node.tagName === "A" ? "span" : node.tagName.toLowerCase());
|
|
19
|
+
if (node.tagName === "A") { copy.className = "fake-link"; copy.title = node.getAttribute("href") ?? ""; }
|
|
20
|
+
walk(node, copy, depth + 1);
|
|
21
|
+
target.append(copy);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const root = el("div", { class: "email-preview" });
|
|
26
|
+
walk(doc.body, root, 0);
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function timeline(email) {
|
|
31
|
+
const steps = [{ name: "Sent", at: email.created_at, tone: "sent" }];
|
|
32
|
+
if (email.scheduled_at) steps.unshift({ name: "Scheduled", at: email.created_at, tone: "scheduled" }), (steps[1].at = email.scheduled_at);
|
|
33
|
+
const last = email.last_event;
|
|
34
|
+
if (last === "scheduled") steps.pop();
|
|
35
|
+
else if (last === "canceled") steps[steps.length - 1] = { name: "Canceled", at: null, tone: "canceled" };
|
|
36
|
+
else if (last !== "sent" && last !== "queued") steps.push({ name: labelFor(last), at: steps[steps.length - 1].at, tone: last });
|
|
37
|
+
const list = el("ol", { class: "timeline" });
|
|
38
|
+
steps.forEach((s, i) => {
|
|
39
|
+
list.append(el("li", {}, [
|
|
40
|
+
el("span", { class: `dot tone-${s.tone === "delivered" ? "green" : s.tone === "bounced" ? "red" : s.tone === "scheduled" ? "blue" : "gray"}` }, [icon(s.tone === "bounced" ? "alert" : s.tone === "scheduled" ? "clock" : s.tone === "canceled" ? "ban" : s.tone === "delivered" ? "check" : "send", 13)]),
|
|
41
|
+
el("div", {}, [el("strong", { text: s.name }), el("p", { class: "muted", text: s.at ? formatDate(s.at) : "—" })]),
|
|
42
|
+
]));
|
|
43
|
+
if (i < steps.length - 1) list.append(el("li", { class: "line", attrs: { "aria-hidden": "true" } }));
|
|
44
|
+
});
|
|
45
|
+
return list;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function renderEmailDetail(id, view) {
|
|
49
|
+
document.title = "Email · Resend (synthetic)";
|
|
50
|
+
const back = el("a", { class: "back", attrs: { href: "#/emails" } }, [icon("chevronLeft", 14), el("span", { text: "Emails" })]);
|
|
51
|
+
let email;
|
|
52
|
+
try {
|
|
53
|
+
email = await call("emails.get", { id });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
view.replaceChildren(back, errorState(error, () => void renderEmailDetail(id, view)));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const actions = el("div", { class: "head-actions" });
|
|
59
|
+
if (email.last_event === "scheduled") {
|
|
60
|
+
actions.append(
|
|
61
|
+
button("Cancel", { iconName: "ban", onClick: async () => {
|
|
62
|
+
if (!(await confirmDialog("Cancel scheduled email", "This email will not be sent. You can't undo this action.", "Cancel email"))) return;
|
|
63
|
+
try { await guard(() => mutate("emails.cancel", { id })); toast("Email canceled"); await refresh(); } catch (e) { toast(describe(e), { error: true }); }
|
|
64
|
+
} }),
|
|
65
|
+
button("Reschedule", { iconName: "calendar", variant: "primary", onClick: () => void reschedule(email) }),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const list = (values) => (values && values.length ? values.join(", ") : "—");
|
|
69
|
+
const meta = el("div", { class: "meta-grid" }, [
|
|
70
|
+
metaItem("From", el("span", { text: email.from })),
|
|
71
|
+
metaItem("Subject", el("span", { text: email.subject })),
|
|
72
|
+
metaItem("To", el("span", { text: list(email.to) })),
|
|
73
|
+
metaItem("ID", el("span", { class: "cell-main" }, [el("span", { class: "mono", text: email.id }), copyButton(email.id, "Copy email ID")])),
|
|
74
|
+
]);
|
|
75
|
+
const extra = [];
|
|
76
|
+
if (email.cc?.length) extra.push(metaItem("Cc", el("span", { text: list(email.cc) })));
|
|
77
|
+
if (email.bcc?.length) extra.push(metaItem("Bcc", el("span", { text: list(email.bcc) })));
|
|
78
|
+
if (email.reply_to?.length) extra.push(metaItem("Reply-To", el("span", { text: list(email.reply_to) })));
|
|
79
|
+
if (email.tags?.length) extra.push(metaItem("Tags", el("span", { class: "tags" }, email.tags.map((t) => badge("queued", `${t.name}: ${t.value}`)))));
|
|
80
|
+
const panels = {
|
|
81
|
+
preview: () => (email.html ? previewHtml(email.html) : el("pre", { class: "plain", text: email.text ?? "" })),
|
|
82
|
+
text: () => el("pre", { class: "plain", text: email.text ?? "This email has no plain text version." }),
|
|
83
|
+
html: () => el("pre", { class: "plain mono", text: email.html ?? "This email has no HTML version." }),
|
|
84
|
+
};
|
|
85
|
+
const body = el("div", { class: "content-panel" });
|
|
86
|
+
const tabs = el("div", { class: "tabs", attrs: { role: "tablist", "aria-label": "Email content" } });
|
|
87
|
+
const select = (name) => {
|
|
88
|
+
for (const b of tabs.children) b.setAttribute("aria-selected", String(b.dataset.tab === name));
|
|
89
|
+
body.replaceChildren(panels[name]());
|
|
90
|
+
};
|
|
91
|
+
for (const [name, label] of [["preview", "Preview"], ["text", "Plain Text"], ["html", "HTML"]]) {
|
|
92
|
+
tabs.append(el("button", { text: label, attrs: { type: "button", role: "tab", "data-tab": name }, on: { click: () => select(name) } }));
|
|
93
|
+
}
|
|
94
|
+
tabs.append(el("button", { text: "Insights", attrs: { type: "button", role: "tab", "aria-selected": "false", "data-soon": "Insights" } }));
|
|
95
|
+
select("preview");
|
|
96
|
+
view.replaceChildren(...[
|
|
97
|
+
back,
|
|
98
|
+
el("div", { class: "detail-head" }, [el("span", { class: "tile lg" }, [icon("mail", 26)]), el("div", { class: "detail-title" }, [el("p", { class: "eyebrow", text: "Email" }), el("h1", { text: email.to[0] ?? "—" })]), actions]),
|
|
99
|
+
meta,
|
|
100
|
+
extra.length ? el("div", { class: "meta-grid" }, extra) : null,
|
|
101
|
+
el("section", { class: "section" }, [el("h2", { text: "Email Events" }), timeline(email)]),
|
|
102
|
+
el("section", { class: "section" }, [tabs, body]),
|
|
103
|
+
].filter(Boolean));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function reschedule(email) {
|
|
107
|
+
const base = new Date(Math.max(clock.nowMs, Date.parse((email.scheduled_at ?? "").replace(" ", "T").replace(/\+00$/, "Z")) || 0));
|
|
108
|
+
const done = await modal("Reschedule email", (close) => {
|
|
109
|
+
const input = el("input", { class: "input", attrs: { id: "reschedule-at", type: "datetime-local", step: "60" } });
|
|
110
|
+
input.value = base.toISOString().slice(0, 16);
|
|
111
|
+
const error = el("p", { class: "form-error", attrs: { role: "alert" } });
|
|
112
|
+
const submit = button("Reschedule", { variant: "primary" });
|
|
113
|
+
submit.type = "submit";
|
|
114
|
+
const form = el("form", {}, [field("Send at (UTC)", input, "Up to 30 days in the future."), error, el("div", { class: "dialog-actions" }, [button("Cancel", { onClick: () => close() }), submit])]);
|
|
115
|
+
form.addEventListener("submit", async (event) => {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
submit.disabled = true;
|
|
118
|
+
try {
|
|
119
|
+
await guard(() => mutate("emails.update", { id: email.id, scheduledAt: `${input.value}:00Z` }));
|
|
120
|
+
close(true);
|
|
121
|
+
} catch (e) { error.textContent = describe(e); submit.disabled = false; }
|
|
122
|
+
});
|
|
123
|
+
return form;
|
|
124
|
+
});
|
|
125
|
+
if (done) { toast("Email rescheduled"); await refresh(); }
|
|
126
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Emails: Sending list (search, status filter, cursor pages) and the email detail page.
|
|
2
|
+
import { icon } from "./icons.js";
|
|
3
|
+
import { Cursor, debounce, linkRow, pagerBar, table } from "./list.js";
|
|
4
|
+
import { badge, button, call, el, emptyState, errorState, notSimulated, skeletonRows, timeCell } from "./ui.js";
|
|
5
|
+
|
|
6
|
+
const STATUSES = [["", "All statuses"], ["delivered", "Delivered"], ["bounced", "Bounced"], ["scheduled", "Scheduled"], ["canceled", "Canceled"], ["sent", "Sent"]];
|
|
7
|
+
const state = { query: "", status: "", cursor: new Cursor(), token: 0 };
|
|
8
|
+
const HEAD = ["To", "Status", "Subject", "Sent"];
|
|
9
|
+
|
|
10
|
+
export async function renderEmails(_m, view, { refresh = false } = {}) {
|
|
11
|
+
document.title = "Emails · Resend (synthetic)";
|
|
12
|
+
if (!refresh || !view.querySelector("#emails-body")) view.replaceChildren(buildFrame());
|
|
13
|
+
await loadList();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildFrame() {
|
|
17
|
+
const search = el("input", { class: "input", attrs: { id: "email-search", type: "search", placeholder: "Search...", "aria-label": "Search emails by recipient or subject", value: state.query } });
|
|
18
|
+
search.value = state.query;
|
|
19
|
+
search.addEventListener("input", debounce(() => { state.query = search.value.trim(); state.cursor.reset(); void loadList(); }));
|
|
20
|
+
const status = el("select", { class: "select", attrs: { id: "email-status", "aria-label": "Filter by status" } }, STATUSES.map(([v, t]) => el("option", { text: t, attrs: { value: v } })));
|
|
21
|
+
status.value = state.status;
|
|
22
|
+
status.addEventListener("change", () => { state.status = status.value; state.cursor.reset(); void loadList(); });
|
|
23
|
+
const range = button("Last 15 days", { iconName: "calendar", attrs: { class: "btn btn-secondary fake-select", "data-soon": "Date range filter" } });
|
|
24
|
+
const keys = button("All API Keys", { attrs: { class: "btn btn-secondary fake-select", "data-soon": "API key filter" } });
|
|
25
|
+
const exportBtn = el("button", { class: "icon-btn", attrs: { type: "button", "aria-label": "Export", title: "Export", "data-soon": "Export" } }, [icon("download", 16)]);
|
|
26
|
+
const tabs = el("div", { class: "tabs", attrs: { role: "tablist" } }, [
|
|
27
|
+
el("button", { text: "Sending", attrs: { type: "button", role: "tab", "aria-selected": "true" } }),
|
|
28
|
+
el("button", { text: "Receiving", attrs: { type: "button", role: "tab", "aria-selected": "false", "data-soon": "Receiving" } }),
|
|
29
|
+
]);
|
|
30
|
+
return el("div", {}, [
|
|
31
|
+
el("div", { class: "page-head" }, [el("h1", { text: "Emails" }), el("div", { class: "head-actions" }, [button("Docs", { iconName: "book", variant: "ghost", attrs: { "data-soon": "Docs" } })])]),
|
|
32
|
+
tabs,
|
|
33
|
+
el("div", { class: "toolbar" }, [el("div", { class: "search" }, [icon("search", 14), search]), range, status, keys, exportBtn]),
|
|
34
|
+
el("div", { attrs: { id: "emails-status" } }),
|
|
35
|
+
table(HEAD, skeletonRows(4, 8), "Emails"),
|
|
36
|
+
el("div", { attrs: { id: "emails-pager" } }),
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function loadList() {
|
|
41
|
+
const token = ++state.token;
|
|
42
|
+
const tableEl = document.querySelector(".table");
|
|
43
|
+
if (!tableEl) return;
|
|
44
|
+
const args = { limit: 20, ...state.cursor.args() };
|
|
45
|
+
if (state.query) args.query = state.query;
|
|
46
|
+
if (state.status) args.status = state.status;
|
|
47
|
+
let list;
|
|
48
|
+
try {
|
|
49
|
+
list = await call("emails.list", args);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (token !== state.token) return;
|
|
52
|
+
document.querySelector("#emails-status").replaceChildren(errorState(error, () => void loadList()));
|
|
53
|
+
tableEl.tBodies[0].replaceWith(el("tbody", {}, [el("tr", {}, [el("td", { class: "muted", text: "No emails could be loaded.", attrs: { colspan: "4" } })])]));
|
|
54
|
+
document.querySelector("#emails-pager").replaceChildren();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (token !== state.token) return;
|
|
58
|
+
document.querySelector("#emails-status").replaceChildren();
|
|
59
|
+
if (list.data.length === 0 && state.cursor.page === 1) {
|
|
60
|
+
const filtered = state.query || state.status;
|
|
61
|
+
tableEl.closest(".table-wrap").hidden = true;
|
|
62
|
+
document.querySelector("#emails-pager").replaceChildren(filtered
|
|
63
|
+
? emptyState("No emails found", "No emails match the current filters. Try a different search or status.")
|
|
64
|
+
: emptyState("No emails yet", "Emails you send through the API will show up here.", button("Send your first email", { variant: "primary", attrs: { "data-soon": "Onboarding" } })));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
tableEl.closest(".table-wrap").hidden = false;
|
|
68
|
+
const body = el("tbody");
|
|
69
|
+
for (const email of list.data) {
|
|
70
|
+
const to = email.to.length > 1 ? `${email.to[0]} +${email.to.length - 1}` : email.to[0] ?? "—";
|
|
71
|
+
body.append(linkRow([
|
|
72
|
+
el("td", {}, [el("div", { class: "cell-main" }, [el("span", { class: "tile" }, [icon("mail", 15)]), el("span", { text: to })])]),
|
|
73
|
+
el("td", {}, [badge(email.last_event)]),
|
|
74
|
+
el("td", { class: "muted", text: email.subject }),
|
|
75
|
+
el("td", { class: "muted" }, [timeCell(email.scheduled_at && email.last_event === "scheduled" ? email.scheduled_at : email.created_at)]),
|
|
76
|
+
], `#/emails/${encodeURIComponent(email.id)}`, `Email to ${to}: ${email.subject}`));
|
|
77
|
+
}
|
|
78
|
+
tableEl.tBodies[0].replaceWith(body);
|
|
79
|
+
document.querySelector("#emails-pager").replaceChildren(pagerBar(state.cursor, list, () => void loadList(), "emails"));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { renderEmailDetail } from "./view-email-detail.js";
|
|
83
|
+
export { notSimulated };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// API Keys: list, create (token shown once), delete.
|
|
2
|
+
import { refresh, workspace } from "./app.js";
|
|
3
|
+
import { icon } from "./icons.js";
|
|
4
|
+
import { Cursor, pagerBar, table } from "./list.js";
|
|
5
|
+
import { badge, button, call, confirmDialog, copyButton, describe, el, emptyState, errorState, field, guard, modal, mutate, skeletonRows, timeCell, toast } from "./ui.js";
|
|
6
|
+
|
|
7
|
+
const cursor = new Cursor();
|
|
8
|
+
const HEAD = ["Name", "Token", "Permission", "Last used", "Created", ""];
|
|
9
|
+
|
|
10
|
+
export async function renderApiKeys(_m, view) {
|
|
11
|
+
document.title = "API Keys · Resend (synthetic)";
|
|
12
|
+
const head = el("div", { class: "page-head" }, [el("h1", { text: "API Keys" }), el("div", { class: "head-actions" }, [button("Docs", { iconName: "book", variant: "ghost", attrs: { "data-soon": "Docs" } }), button("Create API Key", { variant: "primary", iconName: "plus", onClick: () => void createKey() })])]);
|
|
13
|
+
if (!view.querySelector(".table-wrap")) view.replaceChildren(head, table(HEAD, skeletonRows(6, 3), "API keys"));
|
|
14
|
+
let list;
|
|
15
|
+
try {
|
|
16
|
+
list = await call("api_keys.list", { limit: 20, ...cursor.args() });
|
|
17
|
+
} catch (error) {
|
|
18
|
+
view.replaceChildren(head, errorState(error, () => void renderApiKeys(_m, view)));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (list.data.length === 0 && cursor.page === 1) {
|
|
22
|
+
view.replaceChildren(head, emptyState("No API keys yet", "Create an API key to authenticate requests to the API.", button("Create API Key", { variant: "primary", iconName: "plus", onClick: () => void createKey() })));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const own = workspace.value?.apiKey;
|
|
26
|
+
const body = el("tbody", {}, list.data.map((key) => {
|
|
27
|
+
const more = el("button", { class: "icon-btn", attrs: { type: "button", "aria-label": `Delete API key ${key.name}`, title: "Delete API key" } }, [icon("trash", 15)]);
|
|
28
|
+
more.addEventListener("click", () => void removeKey(key));
|
|
29
|
+
const permission = own && own.id === key.id ? own.permission : null;
|
|
30
|
+
return el("tr", {}, [
|
|
31
|
+
el("td", {}, [el("div", { class: "cell-main" }, [el("span", { class: "tile" }, [icon("key", 15)]), el("span", { text: key.name })])]),
|
|
32
|
+
el("td", { class: "muted mono", text: "re_••••••••••••" }),
|
|
33
|
+
el("td", {}, [permission ? badge(permission, permission === "full_access" ? "Full access" : "Sending access") : el("span", { class: "muted", text: "—", attrs: { title: "Permission is not part of the list response" } })]),
|
|
34
|
+
el("td", { class: "muted" }, [key.last_used_at ? timeCell(key.last_used_at) : el("span", { text: "Never" })]),
|
|
35
|
+
el("td", { class: "muted" }, [timeCell(key.created_at)]),
|
|
36
|
+
el("td", { class: "actions" }, [more]),
|
|
37
|
+
]);
|
|
38
|
+
}));
|
|
39
|
+
view.replaceChildren(head, table(HEAD, body, "API keys"), pagerBar(cursor, list, () => void renderApiKeys(_m, view), "API keys"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function domainOptions() {
|
|
43
|
+
const options = [];
|
|
44
|
+
let after;
|
|
45
|
+
for (let page = 0; page < 50; page += 1) {
|
|
46
|
+
const list = await call("domains.list", { limit: 100, ...(after ? { after } : {}) });
|
|
47
|
+
options.push(...list.data);
|
|
48
|
+
if (!list.has_more || list.data.length === 0) break;
|
|
49
|
+
after = list.data[list.data.length - 1].id;
|
|
50
|
+
}
|
|
51
|
+
return options;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function createKey() {
|
|
55
|
+
let domains = [];
|
|
56
|
+
try { domains = await domainOptions(); } catch { domains = []; }
|
|
57
|
+
const created = await modal("Add API Key", (close) => {
|
|
58
|
+
const name = el("input", { class: "input", attrs: { id: "key-name", placeholder: "Your API Key name", maxlength: "50", autocomplete: "off" } });
|
|
59
|
+
const permission = el("select", { class: "select", attrs: { id: "key-permission" } }, [el("option", { text: "Full access", attrs: { value: "full_access" } }), el("option", { text: "Sending access", attrs: { value: "sending_access" } })]);
|
|
60
|
+
const domain = el("select", { class: "select", attrs: { id: "key-domain" } }, [el("option", { text: "All Domains", attrs: { value: "" } }), ...domains.map((d) => el("option", { text: d.name, attrs: { value: d.id } }))]);
|
|
61
|
+
permission.style.width = domain.style.width = "100%";
|
|
62
|
+
const domainField = field("Domain", domain);
|
|
63
|
+
domainField.hidden = true;
|
|
64
|
+
permission.addEventListener("change", () => { domainField.hidden = permission.value !== "sending_access"; });
|
|
65
|
+
const error = el("p", { class: "form-error", attrs: { role: "alert" } });
|
|
66
|
+
const submit = button("Add", { variant: "primary" });
|
|
67
|
+
submit.type = "submit";
|
|
68
|
+
const form = el("form", {}, [field("Name", name), field("Permission", permission), domainField, error, el("div", { class: "dialog-actions" }, [button("Cancel", { onClick: () => close() }), submit])]);
|
|
69
|
+
form.addEventListener("submit", async (event) => {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
submit.disabled = true;
|
|
72
|
+
error.textContent = "";
|
|
73
|
+
const args = { name: name.value.trim(), permission: permission.value };
|
|
74
|
+
if (permission.value === "sending_access" && domain.value) args.domainId = domain.value;
|
|
75
|
+
try { close({ ...(await guard(() => mutate("api_keys.create", args))), name: args.name }); } catch (e) { error.textContent = describe(e); submit.disabled = false; }
|
|
76
|
+
});
|
|
77
|
+
return form;
|
|
78
|
+
});
|
|
79
|
+
if (!created) return;
|
|
80
|
+
await refresh();
|
|
81
|
+
await modal("View API Key", (close) => [
|
|
82
|
+
el("p", { class: "dialog-text", text: "You can only see this key once. Store it safely." }),
|
|
83
|
+
el("div", { class: "token-box" }, [el("span", { class: "mono", text: created.token }), copyButton(created.token, "Copy API key")]),
|
|
84
|
+
el("div", { class: "dialog-actions" }, [button("Done", { variant: "primary", onClick: () => close() })]),
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function removeKey(key) {
|
|
89
|
+
if (!(await confirmDialog("Delete API Key", `Are you sure you want to delete "${key.name}"? Any requests using this key will stop working.`, "Delete API Key"))) return;
|
|
90
|
+
try { await guard(() => mutate("api_keys.remove", { id: key.id })); toast("API key deleted"); await refresh(); } catch (e) { toast(describe(e), { error: true }); }
|
|
91
|
+
}
|