Files
jarl/internal/web/static/assets/totp.js
T
abrown38andClaude Opus 5.5 8f9cdd0a17 Add two-factor login (TOTP) with recovery codes
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>
2026-10-09 14:12:24 +00:00

92 lines
4.6 KiB
JavaScript

// Two-factor login (TOTP): the code prompt, setting up an authenticator
// app, and recovery codes.
import { h, mount, modal, input, field, toast, errorBox, copyBox } from "./ui.js";
import { api } from "./api.js";
function codeInput(attrs = {}) {
return input({ inputmode: "numeric", autocomplete: "one-time-code", maxlength: 6, pattern: "[0-9]*", placeholder: "123456", class: "totp-code", ...attrs });
}
// askCode asks for a current code (actions that need a recent one).
// It returns the code, or null when canceled.
export async function askCode(message) {
const code = codeInput();
const r = await modal("Confirm with your authenticator", h("div", null,
message ? h("div", { class: "msg err" }, message) : null,
h("p", null, "This action needs a current code from your authenticator app."),
code), [
{ label: "Cancel", value: null },
{ label: "Confirm", kind: "primary", onclick: () => code.value.trim().length > 0 },
]);
return r === "Confirm" ? code.value.trim() : null;
}
// recoveryBox shows new recovery codes, to copy or download.
export function recoveryBox(codes, username) {
const text = "Jarl recovery codes for " + username + " (each works once)\n\n" + codes.join("\n") + "\n";
const download = () => {
const a = h("a", { href: URL.createObjectURL(new Blob([text], { type: "text/plain" })), download: "jarl-recovery-codes-" + username + ".txt" });
document.body.append(a);
a.click();
a.remove();
};
return h("div", null,
h("div", { class: "msg warn" }, "Save these recovery codes somewhere safe (a password manager, or printed). Each logs you in once if you lose your phone. They are shown only now."),
h("pre", { class: "recovery-codes" }, codes.join("\n")),
h("div", { class: "actions" },
h("button", { type: "button", onclick: async () => {
try { await navigator.clipboard.writeText(text); toast("Copied"); } catch { toast("Copy failed; select the codes instead", "err"); }
} }, "Copy"),
h("button", { type: "button", onclick: download }, "Download")));
}
// setup walks through setting up an authenticator app in el: password,
// QR code, confirming code, recovery codes. onDone runs when finished.
export function setup(el, me, onDone, opts = {}) {
const hasPassword = me.user.has_password !== false;
const pw = input({ type: "password", autocomplete: "current-password" });
const err = h("div");
const start = async () => {
mount(err);
let s;
try {
s = await api("POST", "/me/totp/setup", { body: { password: pw.value } });
} catch (e) { mount(err, errorBox(e)); return; }
showQR(s);
};
const showQR = (s) => {
const code = codeInput();
const err2 = h("div");
const confirm = async () => {
mount(err2);
try {
const r = await api("POST", "/me/totp/enable", { body: { code: code.value } });
mount(el,
h("div", { class: "msg ok" }, "Two-factor login is on. From now on you log in with your password and a code from the app."),
recoveryBox(r.recovery_codes, me.user.username),
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: onDone }, "I have saved the codes")));
} catch (e) { mount(err2, errorBox(e)); code.select(); }
};
code.addEventListener("keydown", (e) => { if (e.key === "Enter") confirm(); });
mount(el,
h("ol", { class: "totp-steps" },
h("li", null, "Scan this QR code with an authenticator app (Aegis, Google Authenticator, 1Password, Bitwarden, …).",
h("div", { class: "totp-qr" }, h("img", { src: s.qr, alt: "QR code for " + s.issuer + ": " + s.account, width: 200, height: 200 })),
h("details", null, h("summary", { class: "small" }, "Can't scan? Enter the key by hand"),
h("div", { class: "small muted" }, "Account: " + s.issuer + ": " + s.account + " · time-based, 6 digits, 30 seconds"),
copyBox(s.secret))),
h("li", null, "Enter the 6-digit code the app shows:", h("div", { class: "totp-confirm" }, code,
h("button", { class: "primary", onclick: confirm }, "Turn on")))),
err2);
code.focus();
};
pw.addEventListener("keydown", (e) => { if (e.key === "Enter") start(); });
mount(el,
hasPassword ? h("div", { class: "form-grid" }, field("Your password", pw, "To make sure it's you.")) : null,
err,
h("div", { class: "actions", style: { marginTop: "8px" } },
opts.cancel ? h("button", { onclick: opts.cancel }, "Cancel") : null,
h("button", { class: "primary", onclick: start }, opts.again ? "Set up a new app" : "Set up")));
if (hasPassword) pw.focus();
}