Users can set up an authenticator app from their account page; login then asks for a code after the password, with one-time recovery codes as a fallback. Sensitive actions (tokens, user changes, 2FA resets) need a code from the last 5 minutes. totp.require (none/admins/all) forces setup, and admins can reset a user's 2FA from the UI, the jarl CLI or jarld on the server. The dashboard and jarl status flag users missing 2FA and a shared "admin" account still in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
4.9 KiB
JavaScript
102 lines
4.9 KiB
JavaScript
import { h, mount, field, input, toast, errorBox, time, modal, badge } from "../ui.js";
|
|
import { api, get } from "../api.js";
|
|
import * as totp from "../totp.js";
|
|
import { tokenTable, newTokenForm, grantHelp } from "./users.js";
|
|
|
|
export async function render(el) {
|
|
const me = await get("/auth/whoami");
|
|
const grants = await get("/catalog/grants");
|
|
const tokensEl = h("div");
|
|
const drawTokens = async () => {
|
|
const tokens = await get("/me/tokens");
|
|
mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens));
|
|
};
|
|
const cur = input({ type: "password", autocomplete: "current-password" });
|
|
const pw1 = input({ type: "password", autocomplete: "new-password" });
|
|
const pw2 = input({ type: "password", autocomplete: "new-password" });
|
|
const err = h("div");
|
|
mount(el,
|
|
h("h1", null, "Account: " + me.user.username),
|
|
h("div", { class: "panel" }, h("h2", null, "Permissions"),
|
|
h("div", { class: "grants" }, (me.permissions || []).map((p) => h("div", null, p)))),
|
|
me.method === "session" ? twoFactorPanel(me) : null,
|
|
h("div", { class: "panel" }, h("h2", null, "Change password"),
|
|
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw1, "At least 8 characters."), field("Repeat", pw2)),
|
|
err,
|
|
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => {
|
|
mount(err);
|
|
if (pw1.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_password: cur.value, password: pw1.value } });
|
|
toast("Password changed; other sessions were logged out");
|
|
cur.value = pw1.value = pw2.value = "";
|
|
} catch (e) { mount(err, errorBox(e)); }
|
|
} }, "Change password"))),
|
|
h("div", { class: "panel" }, h("h2", null, "My API tokens"),
|
|
h("p", { class: "muted small" }, "Use a token with the jarl CLI (jarl config set token …) or any HTTP client: Authorization: Bearer <token>."),
|
|
tokensEl),
|
|
grantHelp(grants));
|
|
await drawTokens();
|
|
}
|
|
|
|
// twoFactorPanel shows and changes the user's two-factor login.
|
|
function twoFactorPanel(me) {
|
|
const el = h("div", { class: "panel" });
|
|
const draw = async () => {
|
|
const st = await get("/me/totp");
|
|
const body = h("div");
|
|
const redraw = () => draw();
|
|
if (!st.enabled) {
|
|
mount(el, h("h2", null, "Two-factor login"),
|
|
h("p", null, "Log in with your password and a code from an authenticator app on your phone, so a stolen password alone isn't enough.",
|
|
st.required ? h("strong", null, " This server requires it for your account.") : null),
|
|
body);
|
|
totp.setup(body, me, redraw);
|
|
return;
|
|
}
|
|
const actions = h("div", { class: "actions" });
|
|
mount(el, h("h2", null, "Two-factor login"),
|
|
h("p", null, badge("on", "ok"), " since ", time(st.enabled_at), ". ",
|
|
st.recovery_codes_left + " of 10 recovery codes left.",
|
|
st.required ? h("span", { class: "muted" }, " Required on this server.") : null),
|
|
st.recovery_codes_left <= 3 ? h("div", { class: "msg warn" }, "Only " + st.recovery_codes_left + " recovery codes are left: make new ones.") : null,
|
|
body, actions);
|
|
mount(actions,
|
|
h("button", { onclick: () => {
|
|
mount(actions);
|
|
totp.setup(body, me, redraw, { again: true, cancel: redraw });
|
|
} }, "Move to a new phone"),
|
|
h("button", { onclick: async () => {
|
|
try {
|
|
const r = await api("POST", "/me/totp/recovery-codes");
|
|
mount(actions);
|
|
mount(body, totp.recoveryBox(r.recovery_codes, me.user.username),
|
|
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: redraw }, "I have saved the codes")));
|
|
} catch (e) { toast(e.message, "err", 8000); }
|
|
} }, "New recovery codes"),
|
|
st.required ? null : h("button", { class: "danger", onclick: () => disable(me, redraw) }, "Turn off"));
|
|
};
|
|
draw();
|
|
return el;
|
|
}
|
|
|
|
async function disable(me, done) {
|
|
const pw = input({ type: "password", autocomplete: "current-password" });
|
|
const code = input({ autocomplete: "one-time-code", placeholder: "123456 or a recovery code" });
|
|
const err = h("div");
|
|
const ok = await modal("Turn off two-factor login", h("div", null,
|
|
h("p", null, "You will log in with your password alone."),
|
|
h("div", { class: "form-grid" },
|
|
me.user.has_password ? field("Password", pw) : null,
|
|
field("Code", code, "From your authenticator app, or a recovery code.")), err), [
|
|
{ label: "Cancel", value: null },
|
|
{ label: "Turn off", kind: "danger", onclick: async () => {
|
|
try {
|
|
await api("POST", "/me/totp/disable", { body: { password: pw.value, code: code.value } });
|
|
return true;
|
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
|
} },
|
|
]);
|
|
if (ok) { toast("Two-factor login is off"); done(); }
|
|
}
|