Files
jarl/internal/web/static/assets/api.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

221 lines
7.3 KiB
JavaScript

// API client and shared application state.
export const state = {
me: null, // whoami response
csrf: "",
draft: null, // the open changeset edits are staged into
immediate: false, // apply edits immediately instead of staging them
rrtypes: null,
listeners: new Set(),
};
export class APIError extends Error {
constructor(status, body) {
const e = (body && body.error) || {};
super(e.message || ("HTTP " + status));
this.status = status;
this.code = e.code || "";
this.details = e;
this.result = e.result || null;
}
}
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
// With two-factor login, some actions (API tokens, users) need a code
// from the last few minutes. The server then answers totp_step_up (or
// totp_invalid for a wrong code); askCode asks for one, and the request is
// sent again with it. It returns null when the user cancels.
let askCode = null;
export function setCodePrompt(fn) { askCode = fn; }
const stepUpCodes = new Set(["totp_step_up", "totp_invalid"]);
// api calls the REST API. opts: {body, stage (bool), query, code}.
export async function api(method, path, opts = {}) {
try {
return await call(method, path, opts);
} catch (e) {
if (!askCode || e.status !== 403 || !stepUpCodes.has(e.code) || path.startsWith("/me/totp/disable")) throw e;
const code = await askCode(e.code === "totp_invalid" ? e.message : "");
if (!code) throw e;
return api(method, path, { ...opts, code });
}
}
async function call(method, path, opts) {
const headers = { "Accept": "application/json" };
let body;
if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(opts.body);
}
if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf;
let url = "/api/v1" + path;
if (opts.query) {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v);
const s = q.toString();
if (s) url += (url.includes("?") ? "&" : "?") + s;
}
if (opts.stage) headers["X-Jarl-Changeset"] = String(opts.stage);
if (opts.code) headers["X-Jarl-TOTP"] = opts.code;
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
if (resp.status === 204) return null;
const ct = resp.headers.get("Content-Type") || "";
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
if (!resp.ok) {
if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized();
throw new APIError(resp.status, data);
}
return data;
}
export const get = (path, query) => api("GET", path, { query });
// draftQuery adds the draft changeset to a query, so reads show the
// configuration as it will be once the pending changes are applied.
export function draftQuery(query = {}) {
if (state.draft && !state.immediate) return { ...query, changeset: state.draft.id };
return query;
}
// ---- drafts
const draftKey = () => "jarl.draft." + (state.me ? state.me.user.id : "");
const immediateKey = () => "jarl.immediate." + (state.me ? state.me.user.id : "");
export function onChange(fn) { state.listeners.add(fn); return () => state.listeners.delete(fn); }
export function notify() { for (const fn of state.listeners) fn(); }
// loadDraft restores the user's draft changeset if it is still open.
export async function loadDraft() {
state.immediate = localStorage.getItem(immediateKey()) === "1";
const id = localStorage.getItem(draftKey());
state.draft = null;
if (id) {
try {
const cs = await get("/changesets/" + id);
if (cs.status === "open") state.draft = cs;
} catch { /* gone */ }
}
if (!state.draft) {
// Pick up the newest open changeset made in the web UI.
try {
const list = await get("/changesets", { status: "open", mine: 1, limit: 20 });
const web = list.find((c) => c.via === "web");
if (web) state.draft = web;
} catch { /* ignore */ }
}
if (state.draft) localStorage.setItem(draftKey(), state.draft.id);
notify();
}
export function setImmediate(on) {
state.immediate = on;
localStorage.setItem(immediateKey(), on ? "1" : "0");
notify();
}
export async function refreshDraft() {
if (!state.draft) return;
try {
const cs = await get("/changesets/" + state.draft.id);
state.draft = cs.status === "open" ? cs : null;
} catch { state.draft = null; }
if (!state.draft) localStorage.removeItem(draftKey());
notify();
}
export function useDraft(cs) {
state.draft = cs;
if (cs) localStorage.setItem(draftKey(), cs.id); else localStorage.removeItem(draftKey());
notify();
}
async function ensureDraft() {
if (state.draft) return state.draft;
const cs = await api("POST", "/changesets", { body: { description: "" } });
cs.fresh = true;
useDraft(cs);
return cs;
}
// dropFreshDraft discards a draft that was created for an edit the server
// then rejected, so no empty changeset is left open.
async function dropFreshDraft(cs) {
if (!cs.fresh || (state.draft && state.draft.id !== cs.id) || (state.draft && (state.draft.ops || []).length)) return;
try { await api("POST", "/changesets/" + cs.id + "/discard"); } catch { /* leave it */ }
useDraft(null);
}
// change sends a configuration change: staged into the draft changeset,
// or applied at once when immediate mode is on (or opts.immediate).
// It returns the API response.
export async function change(method, path, body, opts = {}) {
if (state.immediate || opts.immediate) {
const r = await api(method, path, { body, query: opts.query });
notify();
return r;
}
const cs = await ensureDraft();
try {
const r = await api(method, path, { body, stage: cs.id, query: opts.query });
if (r && r.changeset) state.draft = r.changeset;
notify();
return r;
} catch (e) {
if (e.status === 409 && /is (applied|discarded)/.test(e.message)) {
useDraft(null);
return change(method, path, body, opts);
}
await dropFreshDraft(cs);
throw e;
}
}
// changeMany submits several requests ({op, id, data}) together.
export async function changeMany(ops, description, opts = {}) {
if (state.immediate || opts.immediate) {
const r = await api("POST", "/apply", { body: { ops, description } });
notify();
return r;
}
const cs = await ensureDraft();
let r;
try {
r = await api("POST", "/changesets/" + cs.id + "/ops", { body: { ops } });
} catch (e) {
await dropFreshDraft(cs);
throw e;
}
if (r && r.changeset) state.draft = r.changeset;
notify();
return r;
}
// describeResult turns a mutation response into a short message.
export function describeResult(r) {
if (!r) return "Done";
if (r.staged) {
const n = (r.added || []).length;
return "Staged " + (n === 1 ? "1 change" : n + " changes") + " — review and apply under Changes";
}
if (r.applied) return "Applied (changeset #" + r.changeset_id + ")";
if (r.dry_run) return "Dry run OK";
return "Done";
}
export function can(resource, write) {
const me = state.me;
if (!me) return false;
if (me.admin) return true;
return (me.user.grants || []).some((g) =>
(g.resource === "*" || g.resource === resource) && (!write || g.access === "write"));
}
export async function rrtypes() {
if (!state.rrtypes) state.rrtypes = await get("/catalog/rrtypes");
return state.rrtypes;
}