Files
jarl/internal/web/static/assets/ui.js
T
abrown38andClaude Opus 5.5 0b2159b1a4 Add token rotation
jarl token rotate, a Rotate button in the web UI and
POST /api/v1/tokens/{id}/rotate give a token a new secret while it keeps
its ID, name, user and grants. The old secret stops at once or after a
grace period of up to 7 days, during which jarld logs who still uses it.
A token that expires gets its lifetime again. --save writes the new
secret where the CLI got the old one (its configuration or --token @FILE),
--write FILE to any file, and --current picks the token the CLI runs
with, so a restricted token can renew itself. A restricted token can't
rotate a wider token of its user.

Also fixes token create --save for named servers, and future times
(token expiry) shown as "just now" in the web UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:58:50 +00:00

250 lines
9.3 KiB
JavaScript

// Small DOM helpers and widgets used by every page.
// h creates an element. attrs may contain class, style, on<event>
// handlers, dataset, value/checked/disabled and any other attribute.
export function h(tag, attrs, ...children) {
const el = document.createElement(tag);
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
if (v === undefined || v === null || v === false) continue;
if (k === "class") el.className = v;
else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
else if (k === "dataset") Object.assign(el.dataset, v);
else if (k === "value") el.value = v;
else if (k === "checked" || k === "disabled" || k === "selected" || k === "multiple" || k === "required" || k === "readOnly") el[k] = !!v;
else el.setAttribute(k, v === true ? "" : v);
}
}
append(el, children);
return el;
}
function append(el, children) {
for (const c of children.flat(Infinity)) {
if (c === null || c === undefined || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export function clear(el) {
while (el.firstChild) el.removeChild(el.firstChild);
return el;
}
export function mount(el, ...children) {
clear(el);
append(el, children);
return el;
}
// ---- formatting
export function fmtTime(iso) {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
export function relTime(iso) {
if (!iso) return "";
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
const s = Math.abs(diff);
if (s < 60) return "just now";
let n;
if (s < 3600) n = Math.floor(s / 60) + " min";
else if (s < 86400) n = Math.floor(s / 3600) + " h";
else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day");
else return fmtTime(iso);
return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry)
}
export function time(iso) {
return h("span", { title: fmtTime(iso), class: "nowrap" }, relTime(iso));
}
export function display(name) {
if (!name || name === ".") return ".";
return name.endsWith(".") ? name.slice(0, -1) : name;
}
export function plural(n, word, words) {
return n + " " + (n === 1 ? word : (words || word + "s"));
}
export function badge(text, kind) {
return h("span", { class: "badge" + (kind ? " " + kind : "") }, text);
}
// ---- toasts
export function toast(message, kind = "ok", ms = 3500) {
const box = document.getElementById("toasts");
const t = h("div", { class: "toast " + kind, onclick: () => t.remove() }, message);
// A repeated message replaces the earlier one instead of stacking.
for (const old of [...box.children]) if (old.textContent === t.textContent) old.remove();
box.append(t);
while (box.children.length > 3) box.firstChild.remove();
setTimeout(() => t.remove(), ms);
}
// ---- modal dialogs
// modal shows a dialog. buttons: [{label, kind, value, onclick}].
// onclick may return false to keep the dialog open, or a promise.
export function modal(title, body, buttons = [{ label: "Close", value: null }], opts = {}) {
return new Promise((resolve) => {
const back = h("div", { class: "modal-back" });
const close = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); };
const onKey = (e) => { if (e.key === "Escape") close(null); };
document.addEventListener("keydown", onKey);
const btns = buttons.map((b) =>
h("button", {
class: b.kind || "", type: "button",
onclick: async (e) => {
if (b.onclick) {
e.target.disabled = true;
try {
const r = await b.onclick();
if (r === false) return;
} finally { e.target.disabled = false; }
}
close(b.value === undefined ? b.label : b.value);
},
}, b.label));
const dialog = h("div", { class: "modal" + (opts.wide ? " wide" : ""), role: "dialog" }, h("h2", null, title), body, h("div", { class: "buttons" }, btns));
back.append(dialog);
back.addEventListener("mousedown", (e) => { if (e.target === back && !opts.sticky) close(null); });
document.body.append(back);
const first = dialog.querySelector("input, select, textarea");
if (first) first.focus();
});
}
export async function confirmDialog(title, message, okLabel = "OK", kind = "primary") {
const r = await modal(title, h("p", null, message), [
{ label: "Cancel", value: false },
{ label: okLabel, kind, value: true },
]);
return r === true;
}
// ---- forms
// field returns a label + control pair for .form-grid layouts.
export function field(label, control, help) {
// The label points at the control itself, also when it is wrapped.
const target = control.matches && !control.matches("input, select, textarea") ? control.querySelector("input, select, textarea") || control : control;
const id = target.id || ("f" + Math.random().toString(36).slice(2, 9));
target.id = id;
return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null];
}
export function input(attrs = {}) {
return h("input", { type: "text", autocomplete: "off", spellcheck: "false", ...attrs });
}
export function select(options, value, attrs = {}) {
const s = h("select", attrs);
for (const o of options) {
const [v, label] = Array.isArray(o) ? o : [o, o];
s.append(h("option", { value: v, selected: String(v) === String(value) }, label));
}
return s;
}
export function textarea(value, attrs = {}) {
return h("textarea", { spellcheck: "false", ...attrs, value: value || "" });
}
export function checkbox(label, checked, attrs = {}) {
const c = h("input", { type: "checkbox", checked, ...attrs });
const l = h("label", { class: "check" }, c, label);
l.input = c;
return l;
}
// lines splits a textarea's value into non-empty trimmed lines.
export function lines(text) {
return text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s !== "");
}
// ---- tables
// table renders rows with columns [{label, render(row) => node|string, class}].
export function table(columns, rows, opts = {}) {
if (!rows.length) return h("div", { class: "empty" }, opts.empty || "Nothing here yet.");
const thead = h("thead", null, h("tr", null, columns.map((c) => h("th", { class: c.class || "" }, c.label))));
const tbody = h("tbody");
rows.forEach((row, i) => {
const tr = h("tr", { class: opts.rowClass ? opts.rowClass(row, i) : "" });
for (const c of columns) {
const v = c.render(row, i);
tr.append(h("td", { class: c.tdClass ? (typeof c.tdClass === "function" ? c.tdClass(row, i) : c.tdClass) : "" }, v));
}
tbody.append(tr);
});
return h("div", { class: "table-wrap" }, h("table", null, thead, tbody));
}
// ---- problems, diffs, errors
export function problemsList(problems) {
if (!problems || !problems.length) return null;
return h("ul", { class: "problems" }, problems.map((p) => {
const where = [p.view && "view " + p.view, p.zone && "zone " + display(p.zone)].filter(Boolean).join(", ");
return h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, where ? h("span", { class: "where" }, where) : null);
}));
}
// checkList shows named-checkconf's errors and warnings (not its routine
// "loaded serial" lines).
export function checkList(msgs) {
const shown = (msgs || []).filter((m) => m.error || !(m.text.startsWith("loaded serial") || m.text === "OK"));
if (!shown.length) return null;
return h("ul", { class: "problems" }, shown.map((m) =>
h("li", { class: m.error ? "error" : "warning" }, h("strong", null, m.error ? "named-checkconf: " : "named-checkconf warning: "), m.text, m.object ? h("span", { class: "where" }, m.object) : null)));
}
export function diffView(text) {
if (!text) return h("div", { class: "muted" }, "No file changes.");
const box = h("div", { class: "diff" });
for (const line of text.replace(/\n$/, "").split("\n")) {
let cls = "";
if (line.startsWith("+++") || line.startsWith("---")) cls = "file";
else if (line.startsWith("+")) cls = "add";
else if (line.startsWith("-")) cls = "del";
else if (line.startsWith("@@")) cls = "hunk";
box.append(h("div", { class: cls }, line || " "));
}
return box;
}
// errorBox shows an API error with its problems and named-checkconf output.
export function errorBox(err) {
if (!err) return null;
const d = err.details || {};
let problems = d.problems || [];
// Don't repeat a single problem that is already the headline.
if (problems.length === 1 && err.message.includes(problems[0].message)) problems = [];
return h("div", { class: "msg err" },
h("div", null, err.message),
problemsList(problems),
checkList(d.check));
}
export function copyBox(value) {
const inp = input({ value, readOnly: true });
const btn = h("button", { type: "button", class: "small", onclick: async () => {
try { await navigator.clipboard.writeText(value); toast("Copied"); } catch { inp.select(); document.execCommand("copy"); toast("Copied"); }
} }, "Copy");
return h("div", { class: "copy-box" }, inp, btn);
}
// debounce delays fn until calls stop for ms.
export function debounce(fn, ms = 250) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}