From 8a6ed7cc711e797542a531091b3bdbc73e5f61e4 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 9 Nov 2026 02:24:51 +0000 Subject: 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. --- src/api/dashboard/dashboard.html | 225 ++++++++++++++++++++++++++++++--------- 1 file changed, 175 insertions(+), 50 deletions(-) (limited to 'src/api/dashboard') 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/ (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/), 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/, `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); -- cgit v1.3.1