Admin name at init, token rotation, read-only forms, token role presets
- stewardd init asks for the first administrator's name on a terminal.
- Tokens can be rotated (steward token rotate, POST /tokens/{id}/rotate,
Rotate in the web UI), with a grace period for the old secret, as in
Jarl. Schema migration 2 adds the token columns.
- Read-only users see the settings and a subnet's settings locked,
instead of editable fields without a Save button.
- The New token dialog only offers roles within what the user may do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
db3f245962
commit
8a69038ab5
23 files changed
+418
-28
No files matched your search
@@ -1,8 +1,8 @@
|
||||
// Your account: password and API tokens.
|
||||
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
|
||||
import { get, api, state, catalog } from "../api.js";
|
||||
import { get, api, state, catalog, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { revoke } from "./users.js";
|
||||
import { revoke, rotateDialog } from "./users.js";
|
||||
|
||||
export async function render(el) {
|
||||
const me = state.me;
|
||||
@@ -36,13 +36,21 @@ export async function render(el) {
|
||||
{ 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) : "never" },
|
||||
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
|
||||
{ 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." })));
|
||||
}
|
||||
|
||||
async function newToken(cat) {
|
||||
const name = input({ placeholder: "laptop, backup script…" });
|
||||
const role = select([["", "everything you may do"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
// Only roles within what the user may do: a token can never do more
|
||||
// than its user, so offering more would only mislead.
|
||||
const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => {
|
||||
const [res, acc] = g.split(":");
|
||||
return can(res, acc === "write");
|
||||
}));
|
||||
const role = select([["", "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
|
||||
const err = h("div");
|
||||
await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Settings: the default lease time, conflict checks, authority, and the
|
||||
// options every subnet gets.
|
||||
import { h, mount, input, field, checkbox, errorBox } from "../ui.js";
|
||||
import { h, mount, input, field, checkbox, errorBox, readOnly } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { optionsEditor, fmtSeconds, parseSeconds } from "../widgets.js";
|
||||
import { rerender } from "../app.js";
|
||||
@@ -27,6 +27,7 @@ export async function render(el) {
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
write ? null : h("div", { class: "msg info" }, "You can see the settings but not change them (that needs config:write)."),
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Settings and options"),
|
||||
h("div", { class: "muted" }, "Server-wide settings, and the options every subnet sends. A subnet's or a reservation's own option of the same name wins."))),
|
||||
h("div", { class: "panel" },
|
||||
@@ -40,4 +41,5 @@ export async function render(el) {
|
||||
write ? h("div", { class: "actions", style: { marginTop: "14px" } },
|
||||
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
||||
h("button", { onclick: () => save(true), title: "Check without saving" }, "Check")) : null));
|
||||
if (!write) readOnly(el.querySelector(".panel"));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// A subnet's page: its leases, its reservations and its settings.
|
||||
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce } from "../ui.js";
|
||||
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
|
||||
import { leasesTable } from "./leases.js";
|
||||
@@ -53,11 +53,11 @@ async function reservationsTab(el, s) {
|
||||
|
||||
async function settingsTab(el, s) {
|
||||
const settings = await get("/settings");
|
||||
const form = await subnetForm(s, settings);
|
||||
if (!can("config", true)) {
|
||||
mount(el, h("div", { class: "panel" }, h("pre", null, JSON.stringify(s, null, 2))));
|
||||
mount(el, h("div", { class: "msg info" }, "You can see the subnet's settings but not change them (that needs config:write)."), readOnly(h("div", { class: "panel" }, form)));
|
||||
return;
|
||||
}
|
||||
const form = await subnetForm(s, settings);
|
||||
const err = h("div");
|
||||
const save = async (dry) => {
|
||||
mount(err);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Users and everyone's tokens (administrators).
|
||||
import { h, mount, table, time, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge } from "../ui.js";
|
||||
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";
|
||||
|
||||
@@ -26,10 +26,46 @@ export async function render(el) {
|
||||
{ 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") },
|
||||
{ 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 {
|
||||
|
||||
@@ -351,3 +351,5 @@ table.activity td.detail { white-space: normal; color: var(--muted); }
|
||||
.type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); }
|
||||
.type-RELEASE { color: var(--muted); }
|
||||
td.mono, span.mono { font-family: var(--mono); font-size: 13px; }
|
||||
.mt { margin-top: 10px; }
|
||||
input:disabled, select:disabled, textarea:disabled { background: var(--info-soft); color: var(--muted); cursor: not-allowed; opacity: 1; }
|
||||
@@ -226,3 +226,11 @@ export function backLink(label, href) {
|
||||
export function safeBack(v) {
|
||||
return v && /^#\/[A-Za-z0-9\/?=&%._~+-]*$/.test(v) ? v : null;
|
||||
}
|
||||
|
||||
// readOnly locks a form for someone who may look but not change: every
|
||||
// control is disabled, and the editors' add and remove buttons hidden.
|
||||
export function readOnly(el) {
|
||||
for (const c of el.querySelectorAll("input, select, textarea")) c.disabled = true;
|
||||
for (const b of el.querySelectorAll("button")) b.classList.add("hidden");
|
||||
return el;
|
||||
}
|
||||
Reference in new issue
Block a user