/** * ui.js – browser UI logic for crushdb. * * Two distinct screens before the app opens: * * 1. "setup" (first visit): No encrypted records exist on the server * yet. The user picks a NEW passphrase (typed twice to avoid * typos). This passphrase then protects every future record. * * 2. "unlock" (returning): encrypted records exist, but the user's * key isn't known to the browser (it never is – that's the whole * point of client-side encryption). The user types their * existing passphrase. * * Which screen to show is decided by a cheap server call: * GET /api/records → empty array ⇒ setup, else unlock. * * The passphrase itself NEVER leaves the browser: Argon2id derives a * 256-bit session key in memory, and that key exists only here. */ import { deriveKey, encryptRecord, decryptRecord, randomSalt, randomUuid, wipeKey, } from "./crypto.js"; // ---------- tiny DOM helpers ---------- const $ = (sel) => document.querySelector(sel); function el(tag, attrs = {}, children = []) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === "class") node.className = v; else if (k === "text") node.textContent = v; else if (k.startsWith("on") && typeof v === "function") { // Event handlers MUST be assigned as properties: setAttribute(k, fn) // stringifies the function ("... => ...") and never binds a listener, // which silently killed the Bearbeiten/Löschen buttons. node[k] = v; } else node.setAttribute(k, v); } for (const child of children) node.appendChild(child); return node; } function flash(msg, isError = false) { const box = $("#flash"); box.textContent = msg; box.className = isError ? "flash error" : "flash"; setTimeout(() => { box.textContent = ""; }, 5000); } // ---------- session state (memory only) ---------- let sessionKey = null; // Uint8Array(32) or null let busy = false; // Argon2id takes a second; block double submits // Decrypted records kept in memory so filters/datalist suggestions can // re-render without re-fetching and re-decrypting everything. let decryptedRows = []; // [{ record, payload }] let lastFailures = 0; // records that could not be decrypted (shown unfiltered) // Salt for the session-key derivation. Fixed domain string, contains // no secret; the per-record salt is separate (see crypto.js). const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1"); function resetSession() { wipeKey(sessionKey); sessionKey = null; decryptedRows = []; lastFailures = 0; $("#records").replaceChildren(); $("#app").hidden = true; location.reload(); // cheapest and safest way back to a clean state } // ---------- API helpers ---------- async function api(path, opts = {}) { const res = await fetch(path, { headers: opts.body ? { "Content-Type": "application/json" } : {}, ...opts, }); if (!res.ok) { let detail = ""; try { detail = (await res.json()).error ?? ""; } catch { /* noop */ } throw new Error(`HTTP ${res.status}${detail ? ": " + detail : ""}`); } if (res.status === 204) return null; return res.json(); } // ---------- rendering ---------- // Timestamps are free text now ("unknown", "Sommer 2025", …). Display // them verbatim; nothing to parse or format. function showTs(value) { return (value === null || value === undefined || value === "") ? "–" : String(value); } function renderRow(record, payload) { const tr = el("tr"); const cells = [ payload.personA, payload.personB, showTs(payload.tsStart), showTs(payload.tsEnd), showTs(payload.startCause), showTs(payload.endCause), payload.action, ]; for (const text of cells) { const td = el("td"); td.textContent = text ?? ""; // textContent = no HTML injection tr.appendChild(td); } const ops = el("td"); ops.appendChild( el("button", { text: "Bearbeiten", type: "button", onclick: () => openEditor(record, payload), }) ); ops.appendChild( el("button", { text: "Löschen", type: "button", onclick: () => removeRecord(record), }) ); tr.appendChild(ops); return tr; } async function reload() { const raw = await api("/api/records"); decryptedRows = []; lastFailures = 0; for (const record of raw) { try { const payload = decryptRecord(sessionKey, record); payload.__version = record.version; // used for optimistic locking decryptedRows.push({ record, payload }); } catch { lastFailures++; } } refreshSuggestions(); renderRows(); $("#new-btn").disabled = false; if (lastFailures > 0) flash(`${lastFailures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true); } // ---------- filters & suggestions ---------- function sorted(values) { return [...values].sort((a, b) => a.localeCompare(b, "de")); } // Rebuild the dropdowns (editor) and the filter