// Jarl web UI: router, layout and login. import { h, mount, toast, errorBox, plural, lastList, modal } from "./ui.js"; import { wordmark, logo } from "./brand.js"; import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt, useDraft } from "./api.js"; import * as totp from "./totp.js"; import * as dashboard from "./pages/dashboard.js"; import * as zones from "./pages/zones.js"; import * as zone from "./pages/zone.js"; import * as views from "./pages/views.js"; import * as acls from "./pages/acls.js"; import * as keys from "./pages/keys.js"; import * as options from "./pages/options.js"; import * as changes from "./pages/changes.js"; import * as history from "./pages/history.js"; import * as audit from "./pages/audit.js"; import * as users from "./pages/users.js"; import * as account from "./pages/account.js"; import * as search from "./pages/search.js"; import * as stats from "./pages/stats.js"; import * as blocking from "./pages/blocking.js"; import * as exposure from "./pages/exposure.js"; const routes = [ { pattern: /^$/, page: dashboard, nav: "dashboard" }, { pattern: /^zones$/, page: zones, nav: "zones" }, { pattern: /^zones\/(\d+)(?:\/(\w+))?$/, page: zone, nav: "zones", params: ["id", "tab"] }, { pattern: /^views$/, page: views, nav: "views" }, { pattern: /^views\/(\d+)(?:\/(\w+))?$/, page: views, nav: "views", params: ["id", "tab"] }, { pattern: /^acls$/, page: acls, nav: "acls" }, { pattern: /^keys$/, page: keys, nav: "keys" }, { pattern: /^options$/, page: options, nav: "options" }, { pattern: /^changes$/, page: changes, nav: "changes" }, { pattern: /^history$/, page: history, nav: "history" }, { pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] }, { pattern: /^audit$/, page: audit, nav: "audit" }, { pattern: /^users$/, page: users, nav: "users" }, { pattern: /^account$/, page: account, nav: "account" }, { pattern: /^search$/, page: search, nav: "" }, { pattern: /^stats$/, page: stats, nav: "stats" }, { pattern: /^blocking$/, page: blocking, nav: "blocking" }, { pattern: /^exposure$/, page: exposure, nav: "exposure" }, ]; const navItems = [ ["dashboard", "#/", "Dashboard"], ["stats", "#/stats", "Statistics"], ["zones", "#/zones", "Zones"], ["blocking", "#/blocking", "Blocking"], ["views", "#/views", "Views"], ["exposure", "#/exposure", "Exposure"], ["acls", "#/acls", "ACLs"], ["keys", "#/keys", "TSIG keys"], ["options", "#/options", "Server options"], ["changes", "#/changes", "Changes"], ["history", "#/history", "History"], ["audit", "#/audit", "Audit log"], ]; const app = document.getElementById("app"); let mainEl, navEl, barEl, current = { key: "" }, routeGen = 0; function parseHash() { const raw = location.hash.replace(/^#\/?/, ""); const [path, qs] = raw.split("?"); return { path: path.replace(/\/$/, ""), query: new URLSearchParams(qs || "") }; } function renderLogin(message) { const user = h("input", { name: "username", autocomplete: "username", placeholder: "Username", required: true }); const pass = h("input", { name: "password", type: "password", autocomplete: "current-password", placeholder: "Password", required: true }); const err = h("div"); const form = h("form", { onsubmit: async (e) => { e.preventDefault(); mount(err); try { const r = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } }); if (r.totp_required) { renderCodeStep(r.challenge); return; } await loggedIn(r); } catch (ex) { mount(err, errorBox(ex)); pass.select(); } } }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err); mount(app, loginPanel(message, form)); user.focus(); } function loginPanel(message, ...body) { return h("div", { class: "login panel" }, logo(104, "login-logo"), h("h1", null, wordmark()), h("div", { class: "tag" }, "Rule your DNS holds"), message ? h("div", { class: "msg warn" }, message) : null, ...body); } // renderCodeStep is the second login step for users with two-factor login. function renderCodeStep(challenge) { const code = h("input", { name: "code", autocomplete: "one-time-code", inputmode: "numeric", placeholder: "6-digit code", required: true, class: "totp-code" }); const err = h("div"); let recovery = false; const hint = h("div", { class: "small muted" }); const toggle = h("a", { href: "#", class: "small", onclick: (e) => { e.preventDefault(); recovery = !recovery; code.placeholder = recovery ? "Recovery code, e.g. k7dm2-xq9pw" : "6-digit code"; code.inputMode = recovery ? "text" : "numeric"; toggle.textContent = recovery ? "Use the authenticator app" : "Lost your phone? Use a recovery code"; code.value = ""; code.focus(); } }, "Lost your phone? Use a recovery code"); mount(hint, "Enter the code from your authenticator app."); const form = h("form", { onsubmit: async (e) => { e.preventDefault(); mount(err); try { const me = await api("POST", "/auth/login/totp", { body: { challenge, code: code.value } }); await loggedIn(me); } catch (ex) { if (ex.code === "login_expired") { renderLogin(ex.message); return; } mount(err, errorBox(ex)); code.select(); } } }, hint, code, h("button", { class: "primary", type: "submit" }, "Verify"), toggle, err); mount(app, loginPanel(null, form)); code.focus(); } async function loggedIn(me) { state.me = me; state.csrf = me.csrf_token || ""; if (me.totp_setup_required) renderSetupRequired(); else await start(); } // renderSetupRequired: the server requires two-factor login for this // user (totp.require), and it isn't set up yet. Nothing else works until // it is. function renderSetupRequired() { const box = h("div"); mount(app, h("div", { class: "login panel wide" }, logo(64, "login-logo"), h("h1", null, "Set up two-factor login"), h("p", null, "This server requires two-factor login for your account. Set up an authenticator app to continue."), box, h("div", { class: "small", style: { marginTop: "12px" } }, h("a", { href: "#", onclick: (e) => { e.preventDefault(); logout(); } }, "Log out")))); totp.setup(box, state.me, async () => { const me = await get("/auth/whoami"); await loggedIn(me); }); } function layout() { navEl = h("nav", { class: "main" }); let modeEl; barEl = h("div"); mainEl = h("main"); const q = h("input", { type: "search", placeholder: "Search names, data…", "aria-label": "Search" }); const me = state.me; const header = h("header", { class: "top" }, h("a", { class: "brand", href: "#/", title: "Dashboard" }, logo(30), wordmark({ rule: false, height: 19, cls: "brand-name" })), navEl, h("div", { class: "spacer" }), modeEl = h("span"), h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q), h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username), me.admin ? h("a", { href: "#/users" }, "Users") : null, h("button", { class: "small", onclick: logout }, "Log out")); mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build))); modeHolder = modeEl; renderNav(); renderBar(); } let modeHolder; // footer names the Jarl build: the release, or the commit for other // builds, linked to it in the repository. function footer(b) { if (!b) return null; let title = b.tag ? "Jarl " + b.version + " (release " + b.tag + ")" : "Jarl " + b.version + " (built without git)"; if (!b.tag && b.commit) { title = "Development build of commit " + b.commit + (b.modified ? " with uncommitted changes" : ""); if (b.since) title += b.ahead ? ", " + b.ahead + (b.ahead === 1 ? " commit" : " commits") + " after " + b.since : ", at " + b.since; } return h("footer", { class: "site" }, "Powered by ", h("a", { href: b.url, target: "_blank", rel: "noopener", title }, "Jarl " + (b.tag || b.commit ? b.label : b.version))); } function renderNav() { mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label))); } // renderBar shows the pending-changes bar (only while there are pending // changes) and the small review/live switch in the header. function renderBar() { if (!barEl) return; const d = state.draft; const n = d ? (d.ops || []).length : 0; if (modeHolder) { const live = state.immediate; mount(modeHolder, h("button", { type: "button", class: "mode-toggle" + (live ? " live" : ""), "aria-pressed": live ? "true" : "false", title: live ? "Edits are applied to named right away. Click to collect them for review first." : "Edits are collected as pending changes and applied from Changes. Click to apply edits right away.", onclick: () => setImmediate(!live), }, h("span", { class: "dot" }), live ? "Live edits" : "Review edits")); } mount(barEl, n > 0 ? h("div", { class: "pending-bar" }, h("strong", null, plural(n, "pending change")), h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."), h("a", { class: "button small", href: "#/changes" }, "Review"), h("button", { class: "primary small apply-now", title: "Apply the pending changes now, without reviewing them first", onclick: (ev) => applyDraft(ev.target, d) }, "Apply now")) : null); } // applyDraft applies the draft changeset from the banner and stays on the // page. If it is refused, a dialog says why and links to Changes. async function applyDraft(btn, d) { btn.disabled = true; btn.textContent = "Applying…"; try { const r = await api("POST", "/changesets/" + d.id + "/apply", { body: {} }); const res = r.result || {}; toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : "")); (r.warnings || []).forEach((w) => toast(w, "warn", 10000)); useDraft(null); rerender(); } catch (e) { btn.disabled = false; btn.textContent = "Apply now"; const review = await modal("The changes were not applied", errorBox(e), [{ label: "Close", value: false }, { label: "Review changes", kind: "primary", value: true }], { wide: true }); if (review) location.hash = "#/changes"; } } async function logout() { try { await api("POST", "/auth/logout"); } catch { /* ignore */ } state.me = null; state.csrf = ""; renderLogin("You have logged out."); } async function route() { if (!state.me) return; const { path, query } = parseHash(); for (const r of routes) { const m = path.match(r.pattern); if (!m) continue; const params = {}; (r.params || []).forEach((p, i) => { params[p] = m[i + 1]; }); current = { key: path, nav: r.nav, page: r.page, params, query }; if (!r.params && r.nav) lastList[r.nav] = location.hash; renderNav(); // Keep the old page until the new one is drawn (switching a zone's // tabs shouldn't blank the page); say "Loading…" only when it's slow. // (Only while the new page has drawn nothing yet: pages that draw // their frame first fill it in afterwards.) const gen = ++routeGen; const old = mainEl.firstChild; const slow = setTimeout(() => { if (gen === routeGen && mainEl.firstChild === old) mount(mainEl, h("div", { class: "muted" }, "Loading…")); }, 300); try { await r.page.render(mainEl, params, query); } catch (e) { if (gen === routeGen) mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e)); } finally { clearTimeout(slow); } return; } mount(mainEl, h("h1", null, "Not found"), h("p", null, h("a", { href: "#/" }, "Back to the dashboard"))); } // rerender redraws the current page (after changes). export function rerender() { if (current.page) route(); } async function start() { layout(); await loadDraft(); onChange(renderBar); window.addEventListener("hashchange", route); await route(); } setUnauthorizedHandler(() => { if (state.me) { state.me = null; renderLogin("Your session has expired. Please log in again."); } }); setCodePrompt(totp.askCode); (async function boot() { try { await loggedIn(await get("/auth/whoami")); } catch (e) { if (e.status === 401) renderLogin(); else mount(app, h("div", { class: "login panel" }, h("h1", null, "Jarl"), errorBox(e))); } })(); window.addEventListener("unhandledrejection", (e) => { toast(e.reason && e.reason.message ? e.reason.message : String(e.reason), "err"); });