Files
steward/internal/web/static/assets/pages/users.js
T
abrown38andClaude Opus 5.5 3c0808591f Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three
levels, leases in SQLite saved before the ACK, ping and decline conflict
checks, validated changes with history and undo, users and tokens, an
audit log, a Kea importer, a sandboxed systemd unit and Debian packages.
Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh).

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

86 lines
4.8 KiB
JavaScript

// Users and everyone's tokens (administrators).
import { h, mount, table, time, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge } from "../ui.js";
import { get, api, catalog } from "../api.js";
import { rerender } from "../app.js";
export async function render(el) {
const [users, tokens, cat] = await Promise.all([get("/users"), get("/tokens"), catalog()]);
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Users"), h("div", { class: "muted" }, "Administrators may do everything. Others get a role or grants: config, leases and audit, each read or write.")),
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => userDialog(null, cat) }, "New user"))),
h("div", { class: "panel" }, table([
{ label: "User", render: (u) => [h("strong", null, u.username), u.disabled ? [" ", badge("disabled", "off")] : null] },
{ label: "May", render: (u) => u.admin ? badge("administrator", "accent") : h("span", { class: "mono small" }, u.grant_strings.join(" ") || "nothing") },
{ label: "Password", render: (u) => u.has_password ? "yes" : h("span", { class: "muted" }, "tokens only") },
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : "never" },
{ label: "Comment", render: (u) => u.comment },
{ label: "", tdClass: "actions-cell", render: (u) => [
h("button", { class: "small reveal", onclick: () => userDialog(u, cat) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(u) }, "Delete")] },
], users)),
h("h2", null, "API tokens"),
h("div", { class: "panel" }, table([
{ label: "User", render: (t) => t.username },
{ label: "Name", render: (t) => t.name },
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), h("span", { class: "muted small" }, " from " + t.last_used_ip)] : "never" },
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
], tokens, { empty: "No tokens." })));
}
export async function revoke(t) {
if (!await confirmDialog("Revoke token", "Revoke " + t.username + "'s token “" + t.name + "”? Anything using it is refused from now on.", "Revoke", "danger")) return;
try {
await api("DELETE", "/tokens/" + t.id);
toast("Revoked");
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
async function del(u) {
if (!await confirmDialog("Delete user", "Delete " + u.username + " and their tokens?", "Delete", "danger")) return;
try {
await api("DELETE", "/users/" + u.id);
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
async function userDialog(u, cat) {
const name = input({ value: u ? u.username : "", autocomplete: "off" });
const pw = input({ type: "password", autocomplete: "new-password", placeholder: u ? "unchanged" : "at least 8 characters" });
const admin = checkbox("Administrator", u ? u.admin : false);
const role = select([["", "custom grants"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
const grants = input({ value: u ? u.grant_strings.join(" ") : "", placeholder: "config:read leases:write", class: "mono" });
const disabled = checkbox("Disabled (logs them out)", u ? u.disabled : false);
const comment = input({ value: u ? u.comment : "" });
const err = h("div");
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
field("Username", name),
field("Password", pw, u ? "Leave empty to keep it." : "Leave empty for a service account that only uses API tokens."),
h("label"), admin,
field("Role", role),
field("Grants", grants, "resource:access, separated by spaces; resources config, leases, audit; access read or write."),
u ? [h("label"), disabled] : null,
field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: u ? "Save" : "Add user", kind: "primary", onclick: async () => {
mount(err);
const body = { username: name.value.trim(), admin: admin.input.checked, comment: comment.value };
if (role.value) body.role = role.value;
else body.grants = grants.value.split(/[\s,]+/).filter(Boolean);
if (pw.value || !u) body.password = pw.value;
if (u) body.disabled = disabled.input.checked;
try {
await api(u ? "PATCH" : "POST", u ? "/users/" + u.id : "/users", { body });
rerender();
return true;
} catch (e) {
mount(err, errorBox(e));
return false;
}
} },
], { sticky: true });
}