/** * 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 // 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; $("#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 tbody = $("#records tbody"); tbody.replaceChildren(); const raw = await api("/api/records"); let failures = 0; for (const record of raw) { try { const payload = decryptRecord(sessionKey, record); payload.__version = record.version; // used for optimistic locking tbody.appendChild(renderRow(record, payload)); } catch { failures++; tbody.appendChild( el("tr", { class: "row-error" }, [ el("td", { colspan: "8", text: "Datensatz nicht entschlüsselbar (falsche Passphrase oder manipuliert)." }), ]) ); } } $("#new-btn").disabled = false; if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true); } function openEditor(record, payload) { const form = $("#editor"); form.dataset.uuid = record.uuid; form.dataset.version = payload.__version; form.dataset.salt = record.salt; $("#editor-title").textContent = "Datensatz bearbeiten"; $("#f-persona").value = payload.personA ?? ""; $("#f-personb").value = payload.personB ?? ""; $("#f-tsstart").value = payload.tsStart ?? ""; $("#f-tsend").value = payload.tsEnd ?? ""; $("#f-startcause").value = payload.startCause ?? ""; $("#f-endcause").value = payload.endCause ?? ""; $("#f-action").value = payload.action ?? ""; $("#editor-backdrop").hidden = false; } function newRecord() { const form = $("#editor"); form.reset(); form.dataset.uuid = randomUuid(); form.dataset.version = ""; form.dataset.salt = ""; $("#editor-title").textContent = "Neuer Datensatz"; $("#editor-backdrop").hidden = false; } function closeEditor() { $("#editor-backdrop").hidden = true; $("#editor").reset(); } function tsOrNull(inputValue) { // Free-text field: trim to null or the plain string. const v = inputValue.trim(); return v === "" ? null : v; } // ---------- record operations ---------- async function saveRecord(ev) { ev.preventDefault(); if (!sessionKey) { flash("Bitte zuerst entsperren.", true); return; } const form = $("#editor"); const payload = { personA: $("#f-persona").value.trim(), personB: $("#f-personb").value.trim(), tsStart: tsOrNull($("#f-tsstart").value), tsEnd: tsOrNull($("#f-tsend").value), startCause: tsOrNull($("#f-startcause").value), endCause: tsOrNull($("#f-endcause").value), action: $("#f-action").value.trim(), }; if (!payload.personA || !payload.personB || !payload.action) { flash("Person A, Person B und Aktion sind erforderlich.", true); return; } const isEdit = form.dataset.version !== ""; const salt = isEdit ? new Uint8Array(atob(form.dataset.salt).split("").map((c) => c.charCodeAt(0))) : randomSalt(); const enc = encryptRecord(sessionKey, form.dataset.uuid, payload, salt); try { if (isEdit) { enc.version = Number(form.dataset.version); await api(`/api/records/${encodeURIComponent(enc.uuid)}`, { method: "PUT", body: JSON.stringify(enc) }); } else { await api("/api/records", { method: "POST", body: JSON.stringify(enc) }); } closeEditor(); await reload(); flash(isEdit ? "Datensatz aktualisiert." : "Datensatz gespeichert."); } catch (e) { closeEditor(); if (String(e).includes("409")) { flash("Konflikt: Der Datensatz wurde zwischenzeitlich geändert. Liste wird neu geladen.", true); } else { flash("Speichern fehlgeschlagen: " + e.message, true); } await reload(); } } async function removeRecord(record) { if (!sessionKey) { flash("Bitte zuerst entsperren.", true); return; } if (!confirm("Diesen Datensatz wirklich löschen?")) return; try { const current = await api(`/api/records/${encodeURIComponent(record.uuid)}`); await api(`/api/records/${encodeURIComponent(record.uuid)}`, { method: "DELETE", body: JSON.stringify({ version: current.version }), }); await reload(); flash("Datensatz gelöscht."); } catch (e) { if (String(e).includes("409")) flash("Konflikt beim Löschen (Version geändert). Bitte erneut versuchen.", true); else flash("Löschen fehlgeschlagen: " + e.message, true); await reload(); } } // ---------- first-time setup (create a NEW passphrase) ---------- async function setup(ev) { ev.preventDefault(); if (busy) return; const p1 = $("#setup-pass1").value; const p2 = $("#setup-pass2").value; if (p1 !== p2) { flash("Die beiden Passphrasen stimmen nicht überein.", true); return; } if (p1.length < 8) { flash("Bitte mindestens 8 Zeichen verwenden.", true); return; } busy = true; $("#setup-btn").disabled = true; $("#setup-progress").hidden = false; try { sessionKey = deriveKey(p1, DOMAIN_SALT); // Verify no records existed while we were deriving (TOCTOU-safe): // if someone else created data in parallel, fall back to unlock. const existing = await api("/api/records"); if (existing.length > 0) { flash("Es existieren bereits Daten. Bitte entsperren statt neu einrichten.", true); resetSession(); return; } $("#setup-pass1").value = ""; $("#setup-pass2").value = ""; $("#setup").hidden = true; $("#app").hidden = false; await reload(); flash("Konto eingerichtet. Ab jetzt schützt deine Passphrase alle Daten."); } finally { busy = false; $("#setup-btn").disabled = false; $("#setup-progress").hidden = true; } } // ---------- lock (wipe key, back to clean state) ---------- function lock() { if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return; resetSession(); } // ---------- wire-up ---------- document.addEventListener("DOMContentLoaded", async () => { $("#setup-form").addEventListener("submit", setup); $("#unlock-form").addEventListener("submit", unlock); $("#lock-btn").addEventListener("click", lock); $("#new-btn").addEventListener("click", newRecord); $("#new-btn").disabled = true; // enabled after reload() succeeds $("#editor").addEventListener("submit", saveRecord); $("#editor-cancel").addEventListener("click", closeEditor); window.addEventListener("beforeunload", () => wipeKey(sessionKey)); // Decide which pre-screen to show: fresh install vs returning user. try { const raw = await api("/api/records"); const firstVisit = raw.length === 0; $("#setup").hidden = !firstVisit; $("#unlock").hidden = firstVisit; if (firstVisit) $("#setup-pass1").focus(); else $("#passphrase").focus(); } catch (e) { // Server unreachable → default to unlock screen; retry will tell more. $("#setup").hidden = true; $("#unlock").hidden = false; flash("Server nicht erreichbar: " + e.message, true); } }); // ---------- unlock (returning user) ---------- async function unlock(ev) { ev.preventDefault(); if (busy) return; const passphrase = $("#passphrase").value; if (!passphrase) { flash("Bitte Passphrase eingeben.", true); return; } busy = true; $("#unlock-btn").disabled = true; $("#unlock-progress").hidden = false; try { sessionKey = deriveKey(passphrase, DOMAIN_SALT); $("#passphrase").value = ""; $("#unlock").hidden = true; $("#app").hidden = false; await reload(); // If nothing decrypts, the passphrase is (almost certainly) wrong. const tbody = $("#records tbody"); const failed = tbody.querySelectorAll("tr.row-error").length; const total = tbody.children.length; if (total > 0 && failed === total) { // Everything undecryptable ⇒ wrong passphrase; go back cleanly. flash("Entsperren fehlgeschlagen: falsche Passphrase?"); resetSession(); } } catch (e) { flash("Entsperren fehlgeschlagen: " + e.message, true); resetSession(); } finally { busy = false; $("#unlock-btn").disabled = false; $("#unlock-progress").hidden = true; } }