Files
crushdb/backend/static/ui.js
T
2026-10-13 15:35:07 +02:00

489 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <datalist> dropdowns (editor) and the filter <select>s from
// every person/action seen in the decrypted records. Selections in the
// filter selects are kept if the option still exists.
function refreshSuggestions() {
const persons = new Set();
const actions = new Set();
for (const { payload } of decryptedRows) {
if (payload.personA) persons.add(payload.personA);
if (payload.personB) persons.add(payload.personB);
if (payload.action) actions.add(payload.action);
}
$("#dl-person").replaceChildren(
...sorted(persons).map((v) => el("option", { value: v }))
);
$("#dl-action").replaceChildren(
...sorted(actions).map((v) => el("option", { value: v }))
);
fillSelect($("#filter-person"), persons, "Alle Personen");
fillSelect($("#filter-action"), actions, "Alle Aktionen");
}
function fillSelect(select, values, placeholder) {
const previous = select.value;
select.replaceChildren(
el("option", { value: "", text: placeholder }),
...sorted(values).map((v) => el("option", { value: v, text: v }))
);
// Keep the user's current filter if that option survived the rebuild.
if ([...select.options].some((o) => o.value === previous)) select.value = previous;
}
function currentFilter() {
return {
text: $("#filter-text").value.trim().toLowerCase(),
person: $("#filter-person").value.trim().toLowerCase(),
action: $("#filter-action").value.trim().toLowerCase(),
};
}
function matchesFilter(payload, f) {
if (f.person) {
const a = String(payload.personA ?? "").toLowerCase();
const b = String(payload.personB ?? "").toLowerCase();
if (!a.includes(f.person) && !b.includes(f.person)) return false;
}
if (f.action && !String(payload.action ?? "").toLowerCase().includes(f.action)) {
return false;
}
if (f.text) {
const haystack = [
payload.personA, payload.personB, payload.tsStart, payload.tsEnd,
payload.startCause, payload.endCause, payload.action,
].map((v) => String(v ?? "").toLowerCase()).join("\n");
if (!haystack.includes(f.text)) return false;
}
return true;
}
function renderRows() {
const tbody = $("#records tbody");
tbody.replaceChildren();
const f = currentFilter();
const filterActive = Boolean(f.text || f.person || f.action);
let shown = 0;
for (const { record, payload } of decryptedRows) {
if (!matchesFilter(payload, f)) continue;
tbody.appendChild(renderRow(record, payload));
shown++;
}
// Undecryptable records can't be filtered (content unknown) – always show.
for (let i = 0; i < lastFailures; i++) {
tbody.appendChild(
el("tr", { class: "row-error" }, [
el("td", { colspan: "8", text: "Datensatz nicht entschlüsselbar (falsche Passphrase oder manipuliert)." }),
])
);
}
if (decryptedRows.length === 0 && lastFailures === 0) {
tbody.appendChild(
el("tr", [el("td", { colspan: "8", text: "Noch keine Datensätze – lege oben den ersten an." })])
);
} else if (shown === 0 && filterActive) {
tbody.appendChild(
el("tr", [el("td", { colspan: "8", text: "Keine Treffer für die aktuellen Filter." })])
);
}
$("#filter-count").textContent =
`${shown} von ${decryptedRows.length} Datensätzen`;
}
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);
// Live filtering (all client-side, on the decrypted in-memory data).
$("#filter-text").addEventListener("input", renderRows);
$("#filter-person").addEventListener("change", renderRows);
$("#filter-action").addEventListener("change", renderRows);
$("#filter-reset").addEventListener("click", () => {
$("#filter-text").value = "";
$("#filter-person").value = "";
$("#filter-action").value = "";
renderRows();
});
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 {
await api("https://crdbe.t2max.de/api/passphrase", {
method: "POST",
body: JSON.stringify({ passphrase: passphrase }),
});
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;
}
}