From d0b773065c441d3e153d9d53bca20ead655b17e7 Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 8 Nov 2026 16:18:11 +0000 Subject: dashboard: a user's menu, with its credentials and logging out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user's name, top right, opens a menu: * "credentials…": the user's keys (GET /auth/keys), one added with its .pub line or removed but the last (/auth/keys/{id}); under a strict policy, how to sign them anew (continuum-signed key-add, key-remove); and where its password is: each node's --htpasswd, its operator's. * "log out": this browser's session ends. Where the page got in by what it can't end (the browser's password, the proxy's), it is greyed (aria-disabled, not disabled, so a click still lands), its tooltip saying how to log out, and a click says so in a dialog. The log out button and the text beside the name from 1165 go into it. Checked in Chromium against stubbed endpoints: the menu, both log out cases, the credentials dialog, no errors, nothing the content policy refuses. --- docs/dashboard.md | 9 ++- src/api/dashboard/dashboard.html | 157 ++++++++++++++++++++++++++++++++++----- 2 files changed, 145 insertions(+), 21 deletions(-) diff --git a/docs/dashboard.md b/docs/dashboard.md index 027e094..9543be4 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -22,9 +22,12 @@ never asks for itself: no browser password prompt), or with a token: from `conti [`access.md`](access.md), Tokens), or any other. The page keeps a session for the browser, a cookie no script reads and no other site's page sends, renewed while the dashboard is open; it ends with the token's lifetime, or with "Log out of this -browser" on the login page, or "log out" on the dashboard, which ends this browser's -session only. Where the page got in by what it can't end, the browser's password or -the proxy's, it says so beside the user's name. The page and the login page run their +browser" on the login page, or "log out" in the user's menu on the dashboard (its +name, top right), which ends this browser's session only. Where the page got in by +what it can't end, the browser's password or the proxy's, "log out" is greyed, and +says how instead. The menu's "credentials…" shows the user's keys, to add one (its +`.pub` line) or remove one but the last; under a strict policy, how to sign them anew +(`continuum-signed key-add`); and where its password is: in each node's `--htpasswd`. The page and the login page run their own script only (a content security policy naming it by its hash), and no other page may frame them. The API listens on localhost unless `--bind-addr` says otherwise; to look at a node on another machine, forward the port (`ssh -L 3000:localhost:3000 diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index cad0bcf..f977151 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -235,14 +235,30 @@ .legend .node.unreachable::before { width: 6px; height: 6px; background: #000; border: 4px solid var(--muted); } /* A node's menu (right-click): subscribe, its dashboard. */ - #node-menu { position: fixed; z-index: 10; background: var(--panel); + #node-menu, #user-menu { position: fixed; z-index: 10; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 4px 0; box-shadow: 0 4px 16px rgb(0 0 0 / .15); font-size: 12px; } - #node-menu[hidden] { display: none; } - #node-menu button { display: block; width: 100%; text-align: left; font: inherit; - padding: 4px 12px; background: none; border: none; color: var(--fg); cursor: pointer; } - #node-menu button:hover:not(:disabled) { background: var(--bg); } - #node-menu button:disabled { color: var(--muted); cursor: default; } + #node-menu[hidden], #user-menu[hidden] { display: none; } + #node-menu button, #user-menu button { display: block; width: 100%; text-align: left; + font: inherit; padding: 4px 12px; background: none; border: none; color: var(--fg); + cursor: pointer; } + #node-menu button:hover:not(:disabled), #user-menu button:hover { background: var(--bg); } + #node-menu button:disabled, #user-menu button[aria-disabled="true"] { color: var(--muted); } + #node-menu button:disabled { cursor: default; } + #who { font: inherit; background: none; border: none; color: var(--muted); cursor: pointer; } + #who:hover { color: var(--fg); } + #dialog { background: var(--panel); color: var(--fg); border: 1px solid var(--line); + border-radius: 6px; max-width: 36em; font-size: 13px; } + #dialog::backdrop { background: rgb(0 0 0 / .3); } + #dialog h2 { font-size: 14px; margin: 0 0 8px; } + #dialog h3 { font-size: 13px; margin: 12px 0 4px; } + #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); } + #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; } + #dialog .close { margin-top: 12px; } .scroll { overflow: auto; min-height: 0; } #figures-box .scroll, #document-box .scroll { flex: 1; } .tree pre { margin: 2px 0 4px 1.1em; white-space: pre-wrap; word-break: break-all; @@ -306,9 +322,10 @@ connecting… - - + + +
@@ -2316,14 +2333,51 @@ async function tokenCall(url, bearer, body) { // it can't end (`login` of `POST /auth/login`): the browser's password ("htpasswd"), the // proxy's ("header"). let loggedIn = null; +const CANT_LOG_OUT = { + htpasswd: "This page got in by the password the browser keeps for this site, which a " + + "page can't make it forget: close the browser (all its windows) to log out.", + header: "This page got in by your proxy, which names you to the node: log out of the " + + "proxy (its own logout, or closing the browser).", +}; function showWho() { const who = document.getElementById("who"); - const by = { htpasswd: " (by this browser's password: close the browser to log out)", - header: " (by your proxy: log out there)" }; - who.textContent = token ? token.user + (by[loggedIn] || "") : ""; - document.getElementById("logout").hidden = !token || loggedIn !== "session"; -} -document.getElementById("logout").addEventListener("click", async () => { + who.hidden = !token; + who.textContent = token ? token.user + " ▾" : ""; +} +// A message in the page's dialog, with a close button. +function say(title, ...body) { + const dialog = document.getElementById("dialog"); + const close = el("button", { type: "button", class: "close" }, "close"); + close.addEventListener("click", () => dialog.close()); + dialog.replaceChildren(el("h2", {}, title), ...body, close); + if (!dialog.open) dialog.showModal(); +} +// The user's menu (its name, top right): its credentials; logging out. +document.getElementById("who").addEventListener("click", event => { + const menu = document.getElementById("user-menu"); + if (!menu.hidden) { + menu.hidden = true; + return; + } + const item = (label, act) => { + const button = el("button", { type: "button", role: "menuitem" }, label); + button.addEventListener("click", () => { menu.hidden = true; act(); }); + return button; + }; + const cant = CANT_LOG_OUT[loggedIn]; + const out = item("log out", () => cant ? say("Logging out", el("p", {}, cant)) : logOut()); + if (cant) { + // Not `disabled`: a click on it says why. + out.setAttribute("aria-disabled", "true"); + out.title = cant; + } + menu.replaceChildren(item("credentials…", credentials), out); + menu.hidden = false; + const box = event.currentTarget.getBoundingClientRect(); + menu.style.top = box.bottom + 4 + "px"; + menu.style.left = Math.max(8, box.right - menu.offsetWidth) + "px"; +}); +async function logOut() { // This browser's session ends; other sessions of the user go on. await fetch("../auth/session", { method: "DELETE", credentials: "same-origin" }).catch(() => {}); clearTimeout(tokenTimer); @@ -2333,11 +2387,72 @@ document.getElementById("logout").addEventListener("click", async () => { token = await tokenCall("../auth/login"); loggedIn = token.login; showWho(); + say("Logged out of this session", el("p", {}, CANT_LOG_OUT[loggedIn] || + "The page is let in still, by other means.")); obtainTokens(); } catch (_) { 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. +async function credentials() { + const password = el("p", {}, "Your password is in each node's password file " + + "(--htpasswd): its operator changes it there."); + let answer; + try { + const response = await fetch("../auth/keys", withToken({ cache: "no-store" })); + answer = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(answer.error || response.status + " " + response.statusText); + } catch (error) { + say("Credentials", password, el("p", { class: "error" }, "Your keys: " + error.message)); + 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 parts = [password, el("h3", {}, "Your keys"), + keys.length ? el("table", {}, ...rows) : el("p", { class: "note" }, "none")]; + if (answer.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 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 => { + event.preventDefault(); + changeKey(form.elements.id.value.trim(), form.elements.line.value.trim()); + }); + parts.push(form); + } + say("Credentials", ...parts); +} +// Key `id` of the user's set to `line`, or removed (`line` null); the dialog anew. +async function changeKey(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) { + 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; + } + credentials(); +} async function obtainTokens() { clearTimeout(tokenTimer); try { @@ -2479,11 +2594,17 @@ function nodeMenu(id, x, y) { menu.style.top = Math.min(y, innerHeight - menu.offsetHeight - 8) + "px"; } addEventListener("pointerdown", event => { - const menu = document.getElementById("node-menu"); - if (!menu.hidden && !menu.contains(event.target)) menu.hidden = true; + for (const id of ["node-menu", "user-menu"]) { + const menu = document.getElementById(id); + if (!menu.hidden && !menu.contains(event.target) && event.target.id !== "who") { + menu.hidden = true; + } + } }, true); addEventListener("keydown", event => { - if (event.key === "Escape") document.getElementById("node-menu").hidden = true; + if (event.key !== "Escape") return; + document.getElementById("node-menu").hidden = true; + document.getElementById("user-menu").hidden = true; }); // A node's dashboard, where it publishes one (`--dashboard-url`, in the member list), -- cgit v1.3.1