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>
221 lines
7.3 KiB
JavaScript
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;
|
|
}
|