SHA256
Adds two optional free-text fields to describe why a transaction
started ("Anlass") and why it ended ("Endgrund") - chosen names instead
of the technical "Startursache"/"Endesuhrsache". Displayed as own table
columns and editable in the form; legacy records without those fields
render as "-". Payload is encrypted client-side like every other field
(no server change needed, opaque ciphertext).
Verified: node --check, crypto roundtrip incl. new fields, legacy-safe
access (?? ''), pytest 3/3.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
368 lines
12 KiB
JavaScript
368 lines
12 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|