// 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 }); // stream GETs a newline-delimited JSON response (?stream=1 endpoints) and // calls onEvent with each value as it arrives. It resolves at the end. export async function stream(path, query, onEvent) { const q = new URLSearchParams(query || {}); const resp = await fetch("/api/v1" + path + (q.toString() ? "?" + q : ""), { headers: { "Accept": "application/x-ndjson" }, credentials: "same-origin" }); if (!resp.ok) { const ct = resp.headers.get("Content-Type") || ""; const data = ct.includes("application/json") ? await resp.json() : await resp.text(); if (resp.status === 401) onUnauthorized(); throw new APIError(resp.status, data); } const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = ""; for (;;) { const { value, done } = await reader.read(); if (value) buf += dec.decode(value, { stream: true }); let nl; while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (line) onEvent(JSON.parse(line)); } if (done) break; } if (buf.trim()) onEvent(JSON.parse(buf)); } // 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; }