Files
crushdb/backend/static/ui.js
T
jhkaktus 535a9c9db0 feat: client logic for setup vs unlock flow and wrong-passphrase handling
ui.js now (a) decides setup-vs-unlock from GET /api/records emptiness,
(b) runs the setup flow (double entry check, min length, TOCTOU re-check
that the DB stayed empty), (c) detects a fully failed unlock (all rows
undecryptable = wrong passphrase) and returns to a clean state, and
(d) blocks double submits while Argon2id derives the key.

Verified: node --check passes, live server serves both screens and a
crypto roundtrip proves setup passphrase unlocks on second visit while
a wrong passphrase is rejected.

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

360 lines
11 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 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 ----------
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)." }),
])
);
}
}
$("#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 ? 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 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;
}
$("#passphrase").value = "";
$("#setup").hidden = true;
$("#app").hidden = false;
await reload();
$("#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;
}
}