aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard/dashboard.html
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-09 02:24:51 +0000
committertv <tv@krebsco.de>2026-11-09 02:24:51 +0000
commit8a6ed7cc711e797542a531091b3bdbc73e5f61e4 (patch)
treea599731bdab3b3cbe07a8bf1d42887da907a2ea7 /src/api/dashboard/dashboard.html
parent764d3e07400854b333583b2efaa1c9eae18ae5e1 (diff)
dashboard: keys edited, the last removed with a password, failures kept
The credentials dialog listed a user's keys whole, to add one or remove one but the last, and any failure replaced the dialog with the error, the form lost. * A key is shown short: its type, the first and last four characters of what tells it apart (an Ed25519 key's base64 after the part all of them share), its comment; the whole line in its tooltip. * A key can be edited, its name and its line: saved under the name, a new name written first and the old one removed. * A user with a password on this node (`--htpasswd`) may remove its last key: it logs in by the password. GET /auth/keys says so (`password`), and DELETE /auth/keys/{id} takes it then; the rules no longer keep the last key on every node (no other node can tell who has a password where; under a strict policy the signed entry keeps one still). * What fails says why beside where it was asked (the add form, the edited row, the removed one), which stays as it was, to be set right and tried again.
Diffstat (limited to 'src/api/dashboard/dashboard.html')
-rw-r--r--src/api/dashboard/dashboard.html225
1 files changed, 175 insertions, 50 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index c4e6276..0be3526 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -258,6 +258,22 @@
#dialog table { border-collapse: collapse; width: 100%; }
#dialog td { padding: 2px 6px 2px 0; vertical-align: top; }
#dialog td.key { font-family: var(--mono, monospace); word-break: break-all; color: var(--muted); }
+ /* The keys as wide as the dialog lets them, never wider: the name and the actions
+ their widths, the key the rest, cut in the middle (`keyLine`) as much as it must. */
+ #dialog table.keys { table-layout: fixed; }
+ #dialog table.keys col.name { width: 7em; }
+ #dialog table.keys col.actions { width: 4em; }
+ #dialog table.keys td.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ #dialog table.keys td.actions { text-align: right; padding-right: 0; }
+ /* Being edited: its fields and buttons across the row. */
+ #dialog .key-edit { display: flex; gap: 6px; align-items: baseline; margin: 2px 0; }
+ #dialog .key-edit .key-name { width: 7em; flex: none; }
+ #dialog .key-edit .key-value { flex: 1; min-width: 0; }
+ #dialog table.keys td.key { word-break: normal; overflow: hidden; }
+ .key-line { display: flex; min-width: 0; }
+ .key-line .start { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ min-width: 0; }
+ .key-line .end { flex: none; white-space: pre; }
#dialog form { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
#dialog input { font: inherit; }
#dialog input[name="line"] { flex: 1; min-width: 16em; }
@@ -281,8 +297,9 @@
.editor input[type=text], .editor textarea { width: 100%; font: inherit; color: var(--fg);
background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; }
.editor button { font: inherit; }
- .editor button.delete { color: #fff; background: var(--bad); border: 1px solid var(--bad);
- border-radius: 3px; }
+ .editor button.delete, #dialog button.delete { color: #fff; background: var(--bad);
+ border: 1px solid var(--bad); border-radius: 3px; }
+ #dialog button.delete:disabled { opacity: .5; }
.tree details.editing > :not(summary):not(.editor) { display: none; }
.tree .leaf[title] { cursor: text; }
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
@@ -454,13 +471,19 @@ const handOver = (() => {
// The nodes whose own links this page reads from them (`follow`), kept in the browser,
// and how each goes: declared before anything draws.
const SUBSCRIPTIONS = "continuum-subscriptions";
+// Opened from another node's page (`handOver`), that node is subscribed to too: its
+// links and messages were that page's own, and are another's here.
const subscriptions = (() => {
+ let kept = new Set();
try {
- const kept = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]");
- return new Set(Array.isArray(kept) ? kept.filter(id => typeof id === "string") : []);
- } catch (_) {
- return new Set();
+ const listed = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]");
+ kept = new Set(Array.isArray(listed) ? listed.filter(id => typeof id === "string") : []);
+ } catch (_) { /* none kept */ }
+ if (handOver && typeof handOver.from === "string" && !kept.has(handOver.from)) {
+ kept.add(handOver.from);
+ try { localStorage.setItem(SUBSCRIPTIONS, JSON.stringify([...kept])); } catch (_) {}
}
+ return kept;
})();
// Each subscription followed: `status` (`connecting`, `live`, `unreachable`), `why`
// not, its latest `report` and when it came (`at`), and how to stop it (`stop`).
@@ -1635,6 +1658,31 @@ async function readStore(path, shallow) {
const EMPTY = { map: "{}", list: "[]", text: "\"\"" };
const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" };
+// A red `Delete` that acts on a second click, from half a second to 3 s after the first
+// (`Delete: sure?`): a double-click doesn't delete. `act()`: whether it did; if not, the
+// button is `Delete` again.
+function deleteButton(act) {
+ const button = el("button", { type: "button", class: "delete" }, "Delete");
+ let sure = null, askedAt = 0;
+ button.addEventListener("click", async () => {
+ if (sure === null) {
+ button.textContent = "Delete: sure?";
+ askedAt = performance.now();
+ sure = setTimeout(() => { sure = null; button.textContent = "Delete"; }, 3000);
+ return;
+ }
+ if (performance.now() - askedAt < 500) return;
+ clearTimeout(sure);
+ button.disabled = true;
+ if (!(await act())) {
+ sure = null;
+ button.textContent = "Delete";
+ button.disabled = false;
+ }
+ });
+ return button;
+}
+
// --- Editing: `Edit value` in an entry's menu turns it into a field holding its contents;
// saved with PUT /store/<path> (the whole value, objects too) ---
// A string is edited as text (sent as text/plain), anything else as JSON; "JSON"
@@ -1701,21 +1749,9 @@ function editor(path, value, done, options = {}) {
};
save.addEventListener("click", submit);
cancel.addEventListener("click", () => done(false));
- // Editing what's there: it can be deleted too (DELETE /store/<path>), after a second
- // click to be sure, not too soon after the first: a double-click doesn't delete.
- const remove = options.extra ? null : el("button", { type: "button", class: "delete" },
- "Delete");
- let sure = null, askedAt = 0;
- remove?.addEventListener("click", async () => {
- if (sure === null) {
- remove.textContent = "Delete: sure?";
- askedAt = performance.now();
- sure = setTimeout(() => { sure = null; remove.textContent = "Delete"; }, 3000);
- return;
- }
- if (performance.now() - askedAt < 500) return;
- clearTimeout(sure);
- remove.disabled = save.disabled = true;
+ // Editing what's there: it can be deleted too (DELETE /store/<path>, `deleteButton`).
+ const remove = options.extra ? null : deleteButton(async () => {
+ save.disabled = true;
message.className = "note";
message.textContent = "Deleting…";
try {
@@ -1726,11 +1762,11 @@ function editor(path, value, done, options = {}) {
throw new Error("HTTP " + response.status + (why ? ": " + why : ""));
}
done("deleted", response);
+ return true;
} catch (error) {
- sure = null;
- remove.textContent = "Delete";
- remove.disabled = save.disabled = false;
+ save.disabled = false;
fail("Not deleted: " + error.message);
+ return false;
}
});
// Enter in the short field saves; in the area, Ctrl or ⌘ and Enter does.
@@ -2291,6 +2327,8 @@ function handOff(id, handed) {
pinned: [...pinned],
view: { ...view },
focused: focused === id ? null : focused,
+ // The node this page is of: the next page subscribes to it.
+ from: state ? state.node.id : undefined,
settings,
legend: !document.getElementById("legends").hidden,
figures: [...open],
@@ -2592,8 +2630,10 @@ async function logOut() {
location.assign("../login");
}
}
-// The user's credentials: its keys, added and removed (`/auth/keys`), but the last;
-// under a strict policy, how to sign them anew; where its password is.
+// The user's credentials: its keys (`/auth/keys`): each edited (its name, its line),
+// removed (its last only where it has a password on this node, which it logs in by
+// then), or one added; under a strict policy, how to sign them anew; where its password
+// is. What fails says so where it was asked, which stays as it was, to be set right.
async function credentials() {
const password = el("p", {}, "Your password is in each node's password file " +
"(--htpasswd): its operator changes it there.");
@@ -2607,49 +2647,134 @@ async function credentials() {
return;
}
const keys = Object.entries(answer.keys || {});
- const rows = keys.map(([id, line]) => {
- const remove = el("button", { type: "button" }, "remove");
- if (answer.signed || keys.length < 2) {
- remove.disabled = true;
- remove.title = answer.signed ? "signed: see below" : "your last key: add another first";
- }
- remove.addEventListener("click", () => changeKey(id, null));
- return el("tr", {}, el("td", {}, id), el("td", { class: "key" }, line), el("td", {}, remove));
- });
+ const signed = !!answer.signed;
+ // The columns' widths set once, whatever its rows (one edited spans them all).
+ const table = el("table", { class: "keys" }, el("colgroup", {},
+ el("col", { class: "name" }), el("col", {}), el("col", { class: "actions" })));
+ const row = (id, line) => {
+ const tr = el("tr", {});
+ // Its name, as much of the key as fits, and `edit`, at the right.
+ const shown = () => {
+ const edit = el("button", { type: "button" }, "edit");
+ if (signed) {
+ edit.disabled = true;
+ edit.title = "signed: see below";
+ }
+ edit.addEventListener("click", editing);
+ tr.replaceChildren(el("td", { class: "name", title: id }, id),
+ el("td", { class: "key", title: line }, keyLine(line)),
+ el("td", { class: "actions" }, edit));
+ };
+ // Its name and line in fields, across the row; saved under the name (a new name:
+ // written under it, then the old one removed), or deleted (`deleteButton`; its last
+ // only where the user has a password on this node).
+ const editing = () => {
+ const name = el("input", { size: 12, spellcheck: "false", class: "key-name" });
+ name.value = id;
+ const value = el("input", { spellcheck: "false", class: "key-value" });
+ value.value = line;
+ const save = el("button", { type: "button" }, "save");
+ const cancel = el("button", { type: "button" }, "cancel");
+ const failed = el("span", { class: "error" });
+ const remove = deleteButton(async () => {
+ save.disabled = true;
+ const why = await keyRequest(id, null);
+ if (why === null) { credentials(); return true; }
+ save.disabled = false;
+ failed.textContent = "Not deleted: " + why;
+ return false;
+ });
+ if (keys.length < 2 && !answer.password) {
+ remove.disabled = true;
+ remove.title = "your last key, and you have no password on this node: add another first";
+ }
+ const submit = async () => {
+ const to = name.value.trim(), text = value.value.trim();
+ if (to === "" || text === "") { failed.textContent = "A name and a key, please."; return; }
+ if (to !== id && keys.some(([other]) => other === to)) {
+ failed.textContent = "\"" + to + "\" is a key of yours already."; return;
+ }
+ save.disabled = true;
+ failed.textContent = "";
+ let why = await keyRequest(to, text);
+ if (why === null && to !== id) {
+ const old = await keyRequest(id, null);
+ if (old !== null) why = "saved as \"" + to + "\", but \"" + id + "\" not removed: " + old;
+ }
+ if (why === null) credentials();
+ else { save.disabled = false; failed.textContent = "Not saved: " + why; }
+ };
+ save.addEventListener("click", submit);
+ cancel.addEventListener("click", shown);
+ for (const field of [name, value]) {
+ field.addEventListener("keydown", event => {
+ if (event.key === "Enter") { event.preventDefault(); submit(); }
+ else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); shown(); }
+ });
+ }
+ tr.replaceChildren(el("td", { colspan: "3" },
+ el("div", { class: "key-edit" }, name, value),
+ el("div", { class: "key-edit" }, save, cancel, remove, failed)));
+ value.focus();
+ };
+ shown();
+ return tr;
+ };
+ table.append(...keys.map(([id, line]) => row(id, line)));
const parts = [password, el("h3", {}, "Your keys"),
- keys.length ? el("table", {}, ...rows) : el("p", { class: "note" }, "none")];
- if (answer.signed) {
+ keys.length ? table : el("p", { class: "note" }, "none")];
+ if (signed) {
parts.push(el("p", {}, "This swarm has its users sign their entries: change your " +
"keys by signing yours anew, with a key you hold: continuum-signed key-add NAME " +
"FILE.pub, continuum-signed key-remove NAME."));
} else {
+ const failed = el("p", { class: "error" });
const form = el("form", {},
el("input", { name: "id", placeholder: "name (laptop)", required: "", size: 12 }),
el("input", { name: "line", placeholder: "ssh-ed25519 AAAA… (its .pub)", required: "",
spellcheck: "false" }),
el("button", {}, "add"));
- form.addEventListener("submit", event => {
+ form.addEventListener("submit", async event => {
event.preventDefault();
- changeKey(form.elements.id.value.trim(), form.elements.line.value.trim());
+ const id = form.elements.id.value.trim(), line = form.elements.line.value.trim();
+ if (keys.some(([other]) => other === id)) {
+ failed.textContent = "\"" + id + "\" is a key of yours already: edit it instead.";
+ return;
+ }
+ failed.textContent = "";
+ const why = await keyRequest(id, line);
+ if (why === null) credentials();
+ else failed.textContent = "Not added: " + why;
});
- parts.push(form);
+ parts.push(form, failed);
}
say("Credentials", ...parts);
}
-// Key `id` of the user's set to `line`, or removed (`line` null); the dialog anew.
-async function changeKey(id, line) {
+// A key as much of it as fits: its type and what tells it apart (an Ed25519 key's
+// base64 after the part every one of them shares), cut in the middle where it doesn't
+// fit, its last eight characters and its comment always shown.
+const ED25519_COMMON = "AAAAC3NzaC1lZDI1NTE5AAAAI";
+function keyLine(line) {
+ const [type = "", data = "", ...comment] = line.trim().split(/\s+/);
+ const own = data.startsWith(ED25519_COMMON) ? data.slice(ED25519_COMMON.length) : data;
+ const cut = Math.max(0, own.length - 8);
+ return el("span", { class: "key-line" },
+ el("span", { class: "start" }, type + " " + own.slice(0, cut)),
+ el("span", { class: "end" }, own.slice(cut) + (comment.length ? " " + comment.join(" ") : "")));
+}
+// Key `id` of the user's set to `line`, or removed (`line` null): null if done, else why
+// not.
+async function keyRequest(id, line) {
const init = line === null ? { method: "DELETE" }
: { method: "PUT", headers: { "Content-Type": "text/plain" }, body: line };
- const response = await fetch("../auth/keys/" + encodeURIComponent(id), withToken(init))
- .catch(error => ({ ok: false, statusText: error.message, json: async () => ({}) }));
- if (!response.ok) {
+ try {
+ const response = await fetch("../auth/keys/" + encodeURIComponent(id), withToken(init));
+ if (response.ok) return null;
const answer = await response.json().catch(() => ({}));
- say("Credentials", el("p", { class: "error" },
- (line === null ? "Couldn't remove " : "Couldn't add ") + id + ": " +
- (answer.error || response.status + " " + response.statusText)));
- return;
+ return answer.error || response.status + " " + response.statusText;
+ } catch (error) {
+ return error.message;
}
- credentials();
}
async function obtainTokens() {
clearTimeout(tokenTimer);