Files
crushdb/backend/static/ui.js
T
jhkaktus a963baa8de feat: browser UI – unlock, table view, create/edit/delete with client-side crypto
Vanilla HTML/CSS/JS frontend served by Flask:
- Unlock screen: passphrase -> Argon2id session key, held in memory
  only (never sent to the server); "Sperren" wipes key and DOM rows.
- Records table (Person A/B, start/end, action) rendered via
  textContent only (no HTML injection); undecryptable rows are flagged.
- Create/edit modal encrypts payloads with XChaCha20-Poly1305 before
  any network call; updates carry the server version (optimistic lock,
  conflict toast on 409); delete fetches current version first.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:02:40 +02:00

276 lines
8.3 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.
*
* 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();
});