- 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>
72 lines
3.9 KiB
JavaScript
72 lines
3.9 KiB
JavaScript
// 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, can } from "../api.js";
|
|
import { rerender } from "../app.js";
|
|
import { revoke, rotateDialog } from "./users.js";
|
|
|
|
export async function render(el) {
|
|
const me = state.me;
|
|
const [tokens, cat] = await Promise.all([get("/me/tokens"), catalog()]);
|
|
const cur = input({ type: "password", autocomplete: "current-password" });
|
|
const pw = input({ type: "password", autocomplete: "new-password" });
|
|
const pw2 = input({ type: "password", autocomplete: "new-password" });
|
|
const err = h("div");
|
|
const change = async () => {
|
|
mount(err);
|
|
if (pw.value !== pw2.value) { mount(err, h("div", { class: "msg err" }, "The new passwords don't match.")); return; }
|
|
try {
|
|
await api("POST", "/me/password", { body: { current: cur.value, password: pw.value } });
|
|
toast("Password changed; your other sessions are logged out");
|
|
cur.value = pw.value = pw2.value = "";
|
|
} catch (e) { mount(err, errorBox(e)); }
|
|
};
|
|
mount(el,
|
|
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, me.user.username),
|
|
h("div", { class: "muted" }, me.admin ? "Administrator" : "May: " + (me.permissions.join(", ") || "nothing")))),
|
|
me.method === "session" ? h("div", { class: "panel" },
|
|
h("h2", null, "Password"),
|
|
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw), field("Repeat", pw2)), err,
|
|
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: change }, "Change password"))) : null,
|
|
h("div", { class: "panel" },
|
|
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"),
|
|
h("div", { class: "muted" }, "For the steward CLI and scripts: 'steward config set token'.")),
|
|
h("button", { onclick: () => newToken(cat) }, "New token")),
|
|
table([
|
|
{ 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) : "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." })));
|
|
}
|
|
|
|
async function newToken(cat) {
|
|
const name = input({ placeholder: "laptop, backup script…" });
|
|
// 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), [
|
|
{ label: "Cancel", value: null },
|
|
{ label: "Create", kind: "primary", onclick: async () => {
|
|
mount(err);
|
|
try {
|
|
const t = await api("POST", "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
|
|
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again."), copyBox(t.secret)));
|
|
rerender();
|
|
return true;
|
|
} catch (e) {
|
|
mount(err, errorBox(e));
|
|
return false;
|
|
}
|
|
} },
|
|
]);
|
|
}
|