Files
steward/internal/web/static/assets/app.js
T
abrown38andClaude Opus 5.5 7b3f0f764c The web UI's certificate from Let's Encrypt, through Jarl
tls.acme names the certificate and jarl: the Jarl server and a token that
may only write the challenge record. Steward gets the certificate with
DNS-01 (lego), writing the record through Jarl's API into every view that
has the zone, renews it with a third of its lifetime left and swaps it
in without a restart. A check runs before every order: Jarl, the zone,
the token's permission, a test record, the zone's name servers, public
resolvers, clean-up; each step says what to fix. Staging by default.

With a certificate, each listener serves HTTPS and plain HTTP on one port
(redirecting plain HTTP, except from the server itself).

steward cert / cert check / cert renew, Server -> Certificate in the web
UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md
and docs/jarl.md (the Jarl user and the token's grant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:46:36 +01:00

213 lines
8.5 KiB
JavaScript

// Steward web UI: router, layout and login.
import { h, mount, toast, errorBox } from "./ui.js";
import { wordmark, logo } from "./brand.js";
import { api, get, state, can, setUnauthorizedHandler } from "./api.js";
import * as dashboard from "./pages/dashboard.js";
import * as subnets from "./pages/subnets.js";
import * as subnet from "./pages/subnet.js";
import * as leases from "./pages/leases.js";
import * as reservations from "./pages/reservations.js";
import * as activity from "./pages/activity.js";
import * as settings from "./pages/settings.js";
import * as history from "./pages/history.js";
import * as audit from "./pages/audit.js";
import * as users from "./pages/users.js";
import * as account from "./pages/account.js";
import * as certificate from "./pages/certificate.js";
const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
{ pattern: /^subnets$/, page: subnets, nav: "subnets" },
{ pattern: /^subnets\/(\d+)(?:\/(\w+))?$/, page: subnet, nav: "subnets", params: ["id", "tab"] },
{ pattern: /^leases$/, page: leases, nav: "leases" },
{ pattern: /^reservations$/, page: reservations, nav: "reservations" },
{ pattern: /^activity$/, page: activity, nav: "activity" },
{ pattern: /^settings$/, page: settings, nav: "settings" },
{ pattern: /^history$/, page: history, nav: "history" },
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
{ pattern: /^audit$/, page: audit, nav: "audit" },
{ pattern: /^users$/, page: users, nav: "users" },
{ pattern: /^account$/, page: account, nav: "account" },
{ pattern: /^certificate$/, page: certificate, nav: "certificate" },
];
// The top bar. ["key", "#/href", "Label", permission?]
const navItems = [
["dashboard", "#/", "Dashboard"],
["subnets", "#/subnets", "Subnets", "config"],
["leases", "#/leases", "Leases", "leases"],
["reservations", "#/reservations", "Reservations", "config"],
["activity", "#/activity", "Activity", "leases"],
["history", "#/history", "History", "config"],
{ menu: "Server", items: [
["settings", "#/settings", "Settings and options", "config"],
["certificate", "#/certificate", "Certificate", "admin"],
["audit", "#/audit", "Audit log", "audit"],
] },
];
const app = document.getElementById("app");
let mainEl, navEl, current = { key: "" }, routeGen = 0;
function parseHash() {
const raw = location.hash.replace(/^#\/?/, "");
const [path, qs] = raw.split("?");
return { path: path.replace(/\/$/, ""), query: new URLSearchParams(qs || "") };
}
function renderLogin(message) {
const user = h("input", { name: "username", autocomplete: "username", placeholder: "Username", required: true });
const pass = h("input", { name: "password", type: "password", autocomplete: "current-password", placeholder: "Password", required: true });
const err = h("div");
const form = h("form", { onsubmit: async (e) => {
e.preventDefault();
mount(err);
try {
const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
await loggedIn(me);
} catch (ex) {
mount(err, errorBox(ex));
pass.select();
}
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
mount(app, h("div", { class: "login panel" },
logo(104, "login-logo"),
h("h1", null, wordmark()),
h("div", { class: "tag" }, "Keeper of the hold's addresses"),
message ? h("div", { class: "msg warn" }, message) : null,
form));
user.focus();
}
async function loggedIn(me) {
state.me = me;
state.csrf = me.csrf_token || "";
layout();
window.addEventListener("hashchange", route);
await route();
}
function layout() {
navEl = h("nav", { class: "main" });
mainEl = h("main");
const me = state.me;
const header = h("header", { class: "top" },
h("a", { class: "brand", href: "#/", title: "Dashboard" },
logo(30),
wordmark({ rule: false, height: 17, cls: "brand-name" })),
navEl,
h("div", { class: "header-right" },
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
h("a", { href: "#/account" }, "Account"),
me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
mount(app, h("div", { class: "shell" }, header, mainEl, footer(me.build)));
renderNav();
}
// footer names the build: the release, or the commit for other builds.
function footer(b) {
if (!b) return null;
let title = b.tag ? "Steward " + b.version + " (release " + b.tag + ")" : "Steward " + b.version;
if (!b.tag && b.commit) {
title = "Development build of commit " + b.commit + (b.modified ? " with uncommitted changes" : "");
if (b.since) title += b.ahead ? ", " + b.ahead + (b.ahead === 1 ? " commit" : " commits") + " after " + b.since : ", at " + b.since;
}
return h("footer", { class: "site" }, "Powered by ",
h("a", { href: b.url, target: "_blank", rel: "noopener", title }, "Steward " + (b.tag || b.commit ? b.label : b.version)));
}
function renderNav() {
const allowed = (n) => !n[3] || (n[3] === "admin" ? state.me.admin : can(n[3], false));
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
mount(navEl, navItems.map((n) => {
if (!n.menu) return allowed(n) ? link(n) : null;
const items = n.items.filter(allowed);
if (!items.length) return null;
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
}));
}
// menu is a drop-down that closes when an item is picked, on a click
// elsewhere and on Escape.
function menu(label, items, opts = {}) {
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
return d;
}
document.addEventListener("click", (e) => {
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
});
async function logout() {
try { await api("POST", "/auth/logout"); } catch { /* ignore */ }
state.me = null;
state.csrf = "";
window.removeEventListener("hashchange", route);
renderLogin("You have logged out.");
}
// stopTimers lets a page that refreshes itself stop when it is left.
let pageCleanup = null;
export function onLeave(fn) { pageCleanup = fn; }
async function route() {
if (!state.me) return;
if (pageCleanup) { pageCleanup(); pageCleanup = null; }
const { path, query } = parseHash();
for (const r of routes) {
const m = path.match(r.pattern);
if (!m) continue;
const params = {};
(r.params || []).forEach((p, i) => { params[p] = m[i + 1]; });
current = { key: path, nav: r.nav, page: r.page, params, query };
renderNav();
const gen = ++routeGen;
const old = mainEl.firstChild;
const slow = setTimeout(() => { if (gen === routeGen && mainEl.firstChild === old) mount(mainEl, h("div", { class: "muted" }, "Loading…")); }, 300);
try {
await r.page.render(mainEl, params, query);
} catch (e) {
if (gen === routeGen) mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
} finally {
clearTimeout(slow);
}
return;
}
mount(mainEl, h("h1", null, "Not found"), h("p", null, h("a", { href: "#/" }, "Back to the dashboard")));
}
// rerender draws the current page again (after a change).
export function rerender() {
if (current.page) route();
}
setUnauthorizedHandler(() => {
if (state.me) {
state.me = null;
window.removeEventListener("hashchange", route);
renderLogin("Your session has expired. Please log in again.");
}
});
(async function boot() {
try {
await loggedIn(await get("/auth/whoami"));
} catch (e) {
if (e.status === 401) renderLogin();
else mount(app, h("div", { class: "login panel" }, h("h1", null, "Steward"), errorBox(e)));
}
})();
window.addEventListener("unhandledrejection", (e) => {
toast(e.reason && e.reason.message ? e.reason.message : String(e.reason), "err");
});