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>
86 lines
4.8 KiB
JavaScript
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 });
|
|
}
|