diff --git a/backend/static/index.html b/backend/static/index.html
new file mode 100644
index 0000000..d75273d
--- /dev/null
+++ b/backend/static/index.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+ crushdb
+
+
+
+ crushdb
+ Verschlüsselte Datensätze zwischen zwei Personen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Person A |
+ Person B |
+ Start |
+ Ende |
+ Aktion |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/static/style.css b/backend/static/style.css
new file mode 100644
index 0000000..ef6c9c8
--- /dev/null
+++ b/backend/static/style.css
@@ -0,0 +1,129 @@
+/* crushdb – simple, clean dark UI */
+
+:root {
+ --bg: #101418;
+ --panel: #1a2028;
+ --panel-2: #202830;
+ --border: #2c3642;
+ --text: #e6edf3;
+ --muted: #8b98a5;
+ --accent: #4da3ff;
+ --danger: #ff5d5d;
+ --ok: #47d68a;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0 auto;
+ max-width: 960px;
+ padding: 2rem 1rem;
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.5;
+}
+
+h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
+.tagline { margin: 0 0 1.5rem; color: var(--muted); }
+
+#flash {
+ min-height: 1.4rem;
+ margin-bottom: 1rem;
+ color: var(--ok);
+}
+#flash.error { color: var(--danger); }
+
+#unlock {
+ max-width: 420px;
+ margin: 3rem auto;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 1.5rem;
+}
+
+#unlock label { display: block; margin-bottom: 0.5rem; }
+#unlock input {
+ width: 100%;
+ margin-bottom: 1rem;
+}
+.hint { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; }
+
+input, button {
+ font: inherit;
+ color: var(--text);
+}
+
+input {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.5rem 0.7rem;
+}
+input:focus { outline: 1px solid var(--accent); }
+
+button {
+ background: var(--accent);
+ color: #0b1016;
+ border: none;
+ border-radius: 6px;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ font-weight: 600;
+}
+button:hover { filter: brightness(1.1); }
+
+#app .toolbar {
+ display: flex;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+#lock-btn { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+th, td {
+ padding: 0.6rem 0.75rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+}
+thead th { background: var(--panel-2); color: var(--muted); font-weight: 600; }
+tbody tr:last-child td { border-bottom: none; }
+tbody tr:hover { background: var(--panel-2); }
+
+.row-error td { color: var(--danger); font-size: 0.9rem; }
+
+td:last-child, td button { white-space: nowrap; }
+td button + button { margin-left: 0.4rem; }
+
+/* modal */
+#editor-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+#editor {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 1.5rem;
+ width: min(440px, 92vw);
+}
+
+#editor h2 { margin-top: 0; font-size: 1.15rem; }
+#editor label { display: block; margin-bottom: 0.75rem; color: var(--muted); }
+#editor input { width: 100%; margin-top: 0.25rem; }
+#editor .actions { display: flex; gap: 0.75rem; margin-top: 1rem; }
+#editor-cancel { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
diff --git a/backend/static/ui.js b/backend/static/ui.js
new file mode 100644
index 0000000..f6450cf
--- /dev/null
+++ b/backend/static/ui.js
@@ -0,0 +1,275 @@
+/**
+ * ui.js – browser UI logic for crushdb.
+ *
+ * Flow:
+ * 1. User enters a passphrase → Argon2id derives the session key in
+ * memory (never sent anywhere, never persisted).
+ * 2. Fetch ciphertext records from /api/records, decrypt each in
+ * memory; decrypt failures are surfaced as "wrong passphrase or
+ * tampered record" without leaking details.
+ * 3. Create/edit records: form values are encrypted client-side
+ * before POST/PUT; the server only ever sees ciphertext.
+ * 4. Delete requires the record's current version (optimistic lock).
+ * 5. "Lock" wipes the passphrase field and releases decrypted rows so
+ * nothing sensitive lingers in the DOM.
+ */
+
+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 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 = ""; }, 4000);
+}
+
+// ---------- session state (memory only) ----------
+
+let sessionKey = null; // Uint8Array(32) or null
+
+function resetSession() {
+ wipeKey(sessionKey);
+ sessionKey = null;
+ $("#records").replaceChildren();
+ $("#app").hidden = true;
+ $("#unlock").hidden = false;
+ $("#passphrase").value = "";
+ $("#passphrase").focus();
+}
+
+// ---------- 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 ----------
+
+function iso(ts) {
+ if (ts === null || ts === "") return "–";
+ const d = new Date(ts);
+ return isNaN(d) ? String(ts) : d.toLocaleString();
+}
+
+function renderRow(record, payload) {
+ const tr = el("tr");
+ const cells = [
+ payload.personA,
+ payload.personB,
+ iso(payload.tsStart),
+ iso(payload.tsEnd),
+ 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: "6", text: "Datensatz nicht entschlüsselbar (falsche Passphrase oder manipuliert)." }),
+ ])
+ );
+ }
+ }
+ 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 ? new Date(payload.tsStart).toISOString().slice(0, 16) : "";
+ $("#f-tsend").value = payload.tsEnd ? new Date(payload.tsEnd).toISOString().slice(0, 16) : "";
+ $("#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) {
+ if (!inputValue) return null;
+ const d = new Date(inputValue);
+ return isNaN(d) ? null : d.toISOString();
+}
+
+// ---------- 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),
+ 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 erstellt.");
+ } 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();
+ }
+}
+
+// ---------- unlock / lock ----------
+
+async function unlock(ev) {
+ ev.preventDefault();
+ const passphrase = $("#passphrase").value;
+ if (!passphrase) { flash("Bitte Passphrase eingeben.", true); return; }
+ // Salt for key derivation: per-record salts are used for the AEAD;
+ // for the session key we use a fixed domain-salt so all records can
+ // be opened with one passphrase. It contains no secret.
+ const domainSalt = new TextEncoder().encode("crushdb://session-key-v1");
+ try {
+ sessionKey = deriveKey(passphrase, domainSalt);
+ $("#passphrase").value = "";
+ $("#unlock").hidden = true;
+ $("#app").hidden = false;
+ await reload();
+ } catch (e) {
+ flash("Entsperren fehlgeschlagen: " + e.message, true);
+ }
+}
+
+function lock() {
+ if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return;
+ resetSession();
+}
+
+// ---------- wire-up ----------
+
+document.addEventListener("DOMContentLoaded", () => {
+ $("#unlock-form").addEventListener("submit", unlock);
+ $("#lock-btn").addEventListener("click", lock);
+ $("#new-btn").addEventListener("click", newRecord);
+ $("#editor").addEventListener("submit", saveRecord);
+ $("#editor-cancel").addEventListener("click", closeEditor);
+ window.addEventListener("beforeunload", () => wipeKey(sessionKey));
+ $("#passphrase").focus();
+});