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>
This commit is contained in:
2026-10-11 00:02:40 +02:00
parent 7cbc2eecd2
commit a963baa8de
3 changed files with 488 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>crushdb</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<h1>crushdb</h1>
<p class="tagline">Verschlüsselte Datensätze zwischen zwei Personen</p>
<section id="flash" class="flash" aria-live="polite"></section>
<!-- unlock screen: passphrase stays in memory only -->
<section id="unlock">
<form id="unlock-form" autocomplete="off">
<label for="passphrase">Passphrase</label>
<input
type="password"
id="passphrase"
autocomplete="off"
required
placeholder="Passphrase eingeben …"
/>
<button type="submit">Entsperren</button>
<p class="hint">
Die Passphrase verlässt niemals deinen Browser. Sie wird per
Argon2id lokal abgeleitet und nur im Arbeitsspeicher gehalten.
</p>
</form>
</section>
<!-- main app, hidden until unlocked -->
<section id="app" hidden>
<div class="toolbar">
<button id="new-btn" type="button">+ Neuer Datensatz</button>
<button id="lock-btn" type="button">Sperren</button>
</div>
<table id="records">
<thead>
<tr>
<th>Person A</th>
<th>Person B</th>
<th>Start</th>
<th>Ende</th>
<th>Aktion</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- create/edit modal -->
<div id="editor-backdrop" hidden>
<form id="editor" autocomplete="off">
<h2 id="editor-title">Neuer Datensatz</h2>
<label>Person A
<input id="f-persona" type="text" required maxlength="120" />
</label>
<label>Person B
<input id="f-personb" type="text" required maxlength="120" />
</label>
<label>Start (optional)
<input id="f-tsstart" type="datetime-local" />
</label>
<label>Ende (optional)
<input id="f-tsend" type="datetime-local" />
</label>
<label>Aktion
<input id="f-action" type="text" required maxlength="500" />
</label>
<div class="actions">
<button type="submit">Speichern</button>
<button type="button" id="editor-cancel">Abbrechen</button>
</div>
</form>
</div>
<script type="module" src="/static/ui.js"></script>
</body>
</html>
+129
View File
@@ -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); }
+275
View File
@@ -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();
});