aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/dashboard.md9
-rw-r--r--src/api/dashboard/dashboard.html155
2 files changed, 144 insertions, 20 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 @@
<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),