|
|
@@ -394,6 +394,36 @@
|
|
|
</div>
|
|
|
<script>
|
|
|
"use strict";
|
|
|
+// Auth: the server may gate data endpoints with `--token` (required off
|
|
|
+// loopback). The token travels as `?token=...` (SSE EventSource cannot set
|
|
|
+// headers) — a one-shot URL param is promoted to localStorage, and every
|
|
|
+// data URL is wrapped with auth(). The /recipes iframe is same-origin, so
|
|
|
+// the embedded report page reads the same localStorage.
|
|
|
+const TOKEN = (() => {
|
|
|
+ const q = new URLSearchParams(location.search).get("token");
|
|
|
+ if (q) localStorage.setItem("sm121token", q);
|
|
|
+ return localStorage.getItem("sm121token") || "";
|
|
|
+})();
|
|
|
+const auth = (p) => TOKEN ? p + (p.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN) : p;
|
|
|
+function showTokenPrompt() {
|
|
|
+ const d = document.createElement("div");
|
|
|
+ d.style.cssText = "position:fixed;inset:0;z-index:100;background:var(--shade);display:flex;align-items:center;justify-content:center";
|
|
|
+ const card = document.createElement("div");
|
|
|
+ card.style.cssText = "background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:24px;max-width:360px";
|
|
|
+ card.innerHTML = "<h3 style='margin:0 0 8px'>Dashboard token</h3>" +
|
|
|
+ "<p style='color:var(--muted);font-size:13px'>Data endpoints require the token passed to <code>sm121 web --token</code>. It is kept in this browser only (localStorage).</p>" +
|
|
|
+ "<input id='tok' type='password' placeholder='token' autocomplete='off' style='width:100%;box-sizing:border-box;padding:8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px'>";
|
|
|
+ const btn = document.createElement("button");
|
|
|
+ btn.textContent = "Connect";
|
|
|
+ btn.style.cssText = "margin-top:10px;padding:8px 16px;background:var(--accent);color:#fff;border:0;border-radius:6px;cursor:pointer";
|
|
|
+ const apply = () => { const v = document.getElementById("tok").value.trim(); if (v) { localStorage.setItem("sm121token", v); location.reload(); } };
|
|
|
+ btn.onclick = apply;
|
|
|
+ card.querySelector("input").addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); });
|
|
|
+ card.appendChild(btn);
|
|
|
+ d.appendChild(card);
|
|
|
+ document.body.appendChild(d);
|
|
|
+ document.getElementById("tok").focus();
|
|
|
+}
|
|
|
const $ = (s) => document.querySelector(s);
|
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
|
const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "", panel: null, shown: new Set(), flags: {} };
|
|
|
@@ -783,7 +813,7 @@ function connectSSE() {
|
|
|
const bounce = (txt) => {
|
|
|
if (es) es.close();
|
|
|
sse("re", txt);
|
|
|
- es = new EventSource("/events");
|
|
|
+ es = new EventSource(auth("/events"));
|
|
|
// connection is up and a snapshot is already on its way — don't sit on
|
|
|
// "connecting…" for a whole poll interval
|
|
|
es.onopen = () => { last = Date.now(); sse("ok", "live"); };
|
|
|
@@ -1646,7 +1676,7 @@ function detachLog(attachId, hide = true) {
|
|
|
const w = logWins.get(attachId);
|
|
|
if (!w) return;
|
|
|
logWins.delete(attachId);
|
|
|
- if (w.attach) fetch("/api/logs", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ attach_id: w.attach }) }).catch(() => {});
|
|
|
+ if (w.attach) fetch(auth("/api/logs"), { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ attach_id: w.attach }) }).catch(() => {});
|
|
|
if (hide && w.win && w.win.isConnected) { w.win.style.display = "none"; renderDock(); }
|
|
|
}
|
|
|
|
|
|
@@ -1731,7 +1761,7 @@ async function openRaw(g) {
|
|
|
if (!kind) return;
|
|
|
const path = kind === "config" ? "/api/raw/config" : kind === "effective" ? "/api/raw/effective" : "/api/raw/recipe/" + encodeURIComponent(kind.slice("recipe:".length));
|
|
|
try {
|
|
|
- const res = await fetch(path);
|
|
|
+ const res = await fetch(auth(path));
|
|
|
const text = await res.text();
|
|
|
if (mine !== seq) return; // a newer selection already answered
|
|
|
if (!res.ok) { note.textContent = "error: " + text; return; }
|
|
|
@@ -1758,8 +1788,9 @@ function dryOf(cluster) {
|
|
|
return d ? d.checked : false;
|
|
|
}
|
|
|
async function api(path, body) {
|
|
|
- const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
|
+ const res = await fetch(auth(path), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
|
if (!res.ok) {
|
|
|
+ if (res.status === 401) { showTokenPrompt(); throw new Error("unauthorized"); }
|
|
|
let m = res.status;
|
|
|
try { m = (await res.json()).error || m; } catch {}
|
|
|
throw new Error(m);
|
|
|
@@ -1937,7 +1968,8 @@ function initManagedWindows() {
|
|
|
async function boot() {
|
|
|
initManagedWindows();
|
|
|
try {
|
|
|
- const res = await fetch("/api/state");
|
|
|
+ const res = await fetch(auth("/api/state"));
|
|
|
+ if (res.status === 401) { showTokenPrompt(); }
|
|
|
if (res.ok) {
|
|
|
const snap = await res.json();
|
|
|
onState(snap);
|
|
|
@@ -1951,7 +1983,7 @@ async function boot() {
|
|
|
}
|
|
|
} catch {}
|
|
|
try {
|
|
|
- const res = await fetch("/api/recipes");
|
|
|
+ const res = await fetch(auth("/api/recipes"));
|
|
|
if (res.ok) {
|
|
|
const d = await res.json();
|
|
|
state.recipes = (d.recipes || []).sort((a, b) => a.name.localeCompare(b.name));
|
|
|
@@ -1967,7 +1999,7 @@ async function boot() {
|
|
|
restoreWindow("raw", openRaw);
|
|
|
setInterval(async () => {
|
|
|
try {
|
|
|
- const res = await fetch("/api/state");
|
|
|
+ const res = await fetch(auth("/api/state"));
|
|
|
if (res.ok) onState(await res.json());
|
|
|
} catch {}
|
|
|
}, 30000);
|