aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 15:36:49 +0000
committertv <tv@krebsco.de>2026-11-08 16:47:13 +0100
commit0f55d5741b3dfda617670f92a77c3cd6e462be6d (patch)
tree38d9c2064dad9ad6647de6b593e2d7c5c554fc00
parentd48aa13cad73030095fd2677ec0b4cea95912eba (diff)
dashboard: log out of this browser, and say what can't be
The dashboard had no way to log out; only the login page did. It now shows the user beside the connection, and "log out" where the page got in by its session: that ends this browser's session (DELETE /auth/session), other sessions of the user going on, and opens the login page. Where it got in by what it can't end, the browser's password or the proxy's, it says so instead (the token answer now names `login`, how the user proved it). After logging out, a page still let in by the browser's password says so too. A list of a user's sessions, to end one by one, is in future-work.md.
-rw-r--r--docs/dashboard.md7
-rw-r--r--docs/openapi.yaml1
-rw-r--r--src/api/dashboard/dashboard.html44
-rw-r--r--src/api/login.rs1
4 files changed, 48 insertions, 5 deletions
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 1a0ae86..7f64034 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -21,8 +21,11 @@ dashboard without one is sent to. There, a user logs in with its password
[`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". 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;
+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
+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
<machine>`) or put a TLS proxy in front ([`operations.md`](operations.md), Behind a reverse proxy).
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 13d8a65..2ebf7fc 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1980,6 +1980,7 @@ components:
properties:
token: { type: string, description: '`ct1.…`: send it as `Authorization: Bearer <token>`.' }
user: { type: string }
+ login: { type: string, description: 'How the user proved it, at the login the session began with: `htpasswd`, `header`, `key`, `token`.' }
issuer: { type: string, description: The node that signed it. }
expiresAt: { type: integer, description: Unix ms. }
sessionExpiresAt: { type: integer, description: Unix ms. }
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index 0f31d7c..cad0bcf 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -306,6 +306,8 @@
<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>
</header>
<main>
<section id="map">
@@ -2310,14 +2312,49 @@ async function tokenCall(url, bearer, body) {
if (!response.ok) throw new Error(response.status + " " + (answer.error || response.statusText));
return answer;
}
+// How the page got in: by its session ("session"), which logging out ends, or by what
+// it can't end (`login` of `POST /auth/login`): the browser's password ("htpasswd"), the
+// proxy's ("header").
+let loggedIn = null;
+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 () => {
+ // This browser's session ends; other sessions of the user go on.
+ await fetch("../auth/session", { method: "DELETE", credentials: "same-origin" }).catch(() => {});
+ clearTimeout(tokenTimer);
+ token = narrowToken = null;
+ // In still, by what the page can't end: said so; else to the login page.
+ try {
+ token = await tokenCall("../auth/login");
+ loggedIn = token.login;
+ showWho();
+ obtainTokens();
+ } catch (_) {
+ location.assign("../login");
+ }
+});
async function obtainTokens() {
clearTimeout(tokenTimer);
try {
// Refreshed as the session (`POST /auth/session`), whose cookie lets the page in;
// the first: by that cookie, else by the browser's password or the proxy's.
- token = token && token.expiresAt > Date.now() + 60000
- ? await tokenCall("../auth/session", token.token)
- : await tokenCall("../auth/session").catch(() => tokenCall("../auth/login"));
+ if (token && token.expiresAt > Date.now() + 60000) {
+ token = await tokenCall("../auth/session", token.token);
+ } else {
+ try {
+ token = await tokenCall("../auth/session");
+ loggedIn = "session";
+ } catch (_) {
+ token = await tokenCall("../auth/login");
+ loggedIn = token.login;
+ }
+ }
+ showWho();
narrowToken = await tokenCall("../auth/refresh", token.token, { limit: READ_MEMBERS });
tokenWhy = null;
const life = Math.min(token.expiresAt, narrowToken.expiresAt) - Date.now();
@@ -2330,6 +2367,7 @@ async function obtainTokens() {
} catch (error) {
token = narrowToken = null;
tokenWhy = "no token: " + error.message;
+ showWho();
tokenTimer = setTimeout(obtainTokens, 30000);
}
}
diff --git a/src/api/login.rs b/src/api/login.rs
index 73304e8..4b42178 100644
--- a/src/api/login.rs
+++ b/src/api/login.rs
@@ -179,6 +179,7 @@ fn answer(state: &AppState, claims: &Claims) -> Result<Response, ApiError> {
Ok(Json(json!({
"token": token::issue(key, claims),
"user": claims.user,
+ "login": claims.login,
"issuer": claims.issuer,
"expiresAt": claims.exp,
"sessionExpiresAt": claims.session_exp,