// 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(); }