aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 16:18:11 +0000
committertv <tv@krebsco.de>2026-11-08 17:36:16 +0100
commitd0b773065c441d3e153d9d53bca20ead655b17e7 (patch)
tree12c9e2d291a6b6bc0b9969c9b70c17729f8eefb0 /src/api/dashboard
parent63c2272a4feb890277ed1362b48a3524d0b07165 (diff)
dashboard: a user's menu, with its credentials and logging out
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.
Diffstat (limited to 'src/api/dashboard')
-rw-r--r--src/api/dashboard/dashboard.html155
1 files changed, 138 insertions, 17 deletions
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 @@
<span id="node" class="id"></span>
<span id="clock-warning"></span>
<span id="connection"><span id="dot"></span><span id="connection-text">connecting…</span></span>
- <span id="who" class="gloss"></span>
- <button id="logout" class="toggle" type="button" hidden>log out</button>
+ <button id="who" type="button" hidden aria-haspopup="menu"></button>
</header>
+<div id="user-menu" role="menu" hidden></div>
+<dialog id="dialog"></dialog>
<main>
<section id="map">
<div class="map-area">
@@ -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";
+ who.hidden = !token;
+ who.textContent = token ? token.user + " ▾" : "";
}
-document.getElementById("logout").addEventListener("click", async () => {
+// 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),