Files
housecarl/internal/web/static/assets/pages/users.js
T
abrown38andClaude Opus 5.5 34292eeebb Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

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

122 lines
7.0 KiB
JavaScript

// Users and everyone's tokens (administrators).
import { h, mount, table, time, fmtTime, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge, copyBox } 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, sets, log and audit, each read or write; a sets grant can name the sets it covers (sets:write:guests).")),
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: () => rotateDialog(t) }, "Rotate"), " ",
h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] },
], tokens, { empty: "No tokens." })));
}
// rotateDialog gives a token a new secret. It keeps its name, ID and
// permissions; only the programs that use it need the new secret.
export async function rotateDialog(t) {
const grace = select([["", "stops working now"], ["10m", "keeps working for 10 minutes"], ["1h", "keeps working for 1 hour"], ["1d", "keeps working for 1 day"], ["7d", "keeps working for 7 days"]], "");
const expOpts = [["", t.expires_at ? "the same lifetime again, from now" : "never expires (as now)"], ["30d", "30 days from now"], ["90d", "90 days from now"], ["365d", "1 year from now"]];
if (t.expires_at) expOpts.push(["never", "never expires"]);
const exp = select(expOpts, "");
const err = h("div");
const box = h("div");
const go = h("button", { class: "primary", onclick: async () => {
mount(err);
const body = {};
if (grace.value) body.grace = grace.value;
if (exp.value === "never") body.no_expiry = true;
else if (exp.value) body.expires_in = exp.value;
go.disabled = true;
try {
const r = await api("POST", "/tokens/" + t.id + "/rotate", { body });
mount(box, h("div", { class: "msg ok" },
h("p", null, "New secret for " + t.name + ". Copy it now: it is not shown again."),
copyBox(r.secret),
h("p", { class: "small" }, r.previous_secret_valid_until ? ["The old secret keeps working until ", fmtTime(r.previous_secret_valid_until), "."] : "The old secret no longer works.")));
rerender();
} catch (ex) { go.disabled = false; mount(err, errorBox(ex)); }
} }, "Rotate");
mount(box,
h("p", null, "Gives ", h("strong", null, t.name), " a new secret. It keeps its name, ID and permissions, so only the programs that use it need updating."),
h("div", { class: "form-grid" },
field("Old secret", grace, "A grace period lets you update the programs without an outage. If the token may have leaked, let it stop now."),
field("Expires", exp)),
err, h("div", { class: "actions mt" }, go));
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
}
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 log:read sets:write:guests", 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, sets, log, audit; access read or write; sets:write:NAME,NAME for some sets only."),
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 });
}