diff options
Diffstat (limited to 'src/api')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 155 |
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), |
