Compare commits

...
6 Commits
Author SHA256 Message Date
jhkaktus c82de902a8 feat: free-text timestamps instead of datetime pickers
Users wanted to type things like "unbekannt" or comments for the date
fields. Start/End are now plain text inputs (max 120 chars) and are
stored/displayed verbatim - no Date parsing, no formatting. Existing
records that contain ISO strings keep working: they render exactly as
stored and refill the edit form unchanged. Also removes a duplicated
block in setup() that had slipped in earlier (double reload/flash).

Verified: node --check, no datetime-local/Date-parsing remnants,
crypto roundtrip with free-text and legacy ISO timestamps, pytest 3/3.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:58:19 +02:00
jhkaktus 8d488f44ff fix: edit and delete buttons now actually fire their handlers
The shared el() helper passed every attr through setAttribute(); for
event handlers like onclick a function value gets stringified into an
attribute the DOM compiles as an expression that is evaluated but never
called - so Bearbeiten/Löschen (wired through el()) were silent no-ops,
while addEventListener-wired buttons (Neuer Datensatz, Sperren) worked.

el() now assigns on*-function attrs as properties (node[k] = v), preserving
the real handler. Verified with a DOM-semantics shim: fixed path fires,
legacy setAttribute path provably compiles but never calls.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:43:21 +02:00
jhkaktus 168927453a fix: backdrop now actually hidden, no longer covers the page
The #editor-backdrop rule set display:flex, which has higher specificity
than the UA style for the hidden attribute - so the modal overlay was
permanently visible on load and blocked the whole UI underneath (user-
reported: "the new record field hides everything"). Adding
#editor-backdrop[hidden] { display:none } makes the attribute win again;
opening/closing the editor still uses flex centering.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:37:57 +02:00
jhkaktus 2df603b32b style+docs: consistent pre-screen styling and first-start UX docs
Setup screen shares the unlock panel look; README gains an "Erster
Start (UX)" section explaining first visit vs returning visit behaviour.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:30:51 +02:00
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
jhkaktus 863cd1fd3a feat: first-time setup screen for creating a new passphrase
UX fix from user feedback: previously the app only showed an "Entsperren"
form, leaving first-time users without any passphrase confused. The page
now loads with two distinct pre-screens decided by an initial API call:

- empty DB -> "Willkommen" setup form: new passphrase, typed twice,
  minimum 8 characters, with no-reset warning
- existing ciphertext records -> unlock form as before

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:30:51 +02:00
4 changed files with 194 additions and 60 deletions
+10
View File
@@ -8,6 +8,16 @@ Alle Datensätze werden **im Browser** verschlüsselt
Der Server sieht ausschließlich Chiffretext – auch bei vollständiger
Serverkompromittierung bleiben die Inhalte ohne Passphrase vertraulich.
## Erster Start (UX)
- **Beim ersten Besuch** (Datenbank leer): Die App zeigt "Willkommen" und
fragt zweimal nach einer neuen Passphrase (mindestens 8 Zeichen).
Diese schützt ab sofort alle Datensätze – es gibt keine Zurücksetzung.
- **Bei weiteren Besuchen**: Die App bittet einmal um die bekannte
Passphrase ("Entsperren").
- Der Server speichert niemals die Passphrase selbst, nur verschlüsselte
Datensätze.
## Starten (HTTP; TLS über Reverse Proxy)
```bash
+45 -14
View File
@@ -12,18 +12,47 @@
<section id="flash" class="flash" aria-live="polite"></section>
<!-- unlock screen: passphrase stays in memory only -->
<!-- First visit: NO records exist yet → user chooses a new passphrase -->
<section id="setup" hidden>
<form id="setup-form" autocomplete="off">
<h2>Willkommen bei crushdb!</h2>
<p class="intro">
Du benutzt crushdb zum ersten Mal. Wähle jetzt eine Passphrase –
sie ist das Master-Passwort für alle deine Daten. Es gibt keine
Zurücksetzung, speichere sie sicher!
</p>
<label>Neue Passphrase
<input type="password" id="setup-pass1" autocomplete="new-password"
required minlength="8" placeholder="mindestens 8 Zeichen" />
</label>
<label>Passphrase wiederholen
<input type="password" id="setup-pass2" autocomplete="new-password"
required placeholder="gleiche Passphrase nochmal" />
</label>
<button type="submit" id="setup-btn">Konto einrichten →</button>
<p id="setup-progress" class="hint" hidden>Schlüssel wird abgeleitet …</p>
<p class="hint">
Wichtig: Die Passphrase wird <strong>nicht am Server gespeichert</strong>.
Sie bleibt nur in deinem Browser-Arbeitsspeicher. Wer sie vergisst,
verliert Zugriff auf alle Daten (absichtlich so).
</p>
</form>
</section>
<!-- Returning user: records exist but are undecryptable → enter passphrase -->
<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>
<h2>Entsperren</h2>
<p class="intro">
Du hast bereits verschlüsselte Datensätze hinterlegt.
Gib deine Passphrase ein, um sie zu lesen.
</p>
<label>Passphrase
<input type="password" id="passphrase" autocomplete="current-password"
required placeholder="deine Passphrase …" />
</label>
<button type="submit" id="unlock-btn">Entsperren</button>
<p id="unlock-progress" class="hint" hidden>Schlüssel wird abgeleitet …</p>
<p class="hint">
Die Passphrase verlässt niemals deinen Browser. Sie wird per
Argon2id lokal abgeleitet und nur im Arbeitsspeicher gehalten.
@@ -63,11 +92,13 @@
<label>Person B
<input id="f-personb" type="text" required maxlength="120" />
</label>
<label>Start (optional)
<input id="f-tsstart" type="datetime-local" />
<label>Start (optional, beliebiger Text)
<input id="f-tsstart" type="text" maxlength="120"
placeholder="z. B. 12.03.2026, Sommer 2025, unbekannt …" />
</label>
<label>Ende (optional)
<input id="f-tsend" type="datetime-local" />
<label>Ende (optional, beliebiger Text)
<input id="f-tsend" type="text" maxlength="120"
placeholder="z. B. laufend, unbekannt, März 2026 …" />
</label>
<label>Aktion
<input id="f-action" type="text" required maxlength="500" />
+11 -4
View File
@@ -34,7 +34,7 @@ h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
}
#flash.error { color: var(--danger); }
#unlock {
#unlock, #setup {
max-width: 420px;
margin: 3rem auto;
background: var(--panel);
@@ -43,10 +43,13 @@ h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
padding: 1.5rem;
}
#unlock label { display: block; margin-bottom: 0.5rem; }
#unlock input {
#unlock h2, #setup h2 { margin: 0 0 0.5rem; font-size: 1.15rem; }
.intro { color: var(--muted); font-size: 0.9rem; margin: 0 0 1rem; }
#unlock label, #setup label { display: block; margin-bottom: 0.75rem; }
#unlock input, #setup input {
width: 100%;
margin-bottom: 1rem;
margin-top: 0.25rem;
}
.hint { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; }
@@ -105,6 +108,10 @@ td:last-child, td button { white-space: nowrap; }
td button + button { margin-left: 0.4rem; }
/* modal */
/* NOTE: keep `hidden` winning over the flex display below, otherwise the
backdrop is permanently visible and blocks everything underneath. */
#editor-backdrop[hidden] { display: none; }
#editor-backdrop {
position: fixed;
inset: 0;
+128 -42
View File
@@ -1,17 +1,22 @@
/**
* 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.
* 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 {
@@ -32,6 +37,12 @@ function el(tag, attrs = {}, children = []) {
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);
@@ -42,21 +53,24 @@ function flash(msg, isError = false) {
const box = $("#flash");
box.textContent = msg;
box.className = isError ? "flash error" : "flash";
setTimeout(() => { box.textContent = ""; }, 4000);
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;
$("#unlock").hidden = false;
$("#passphrase").value = "";
$("#passphrase").focus();
location.reload(); // cheapest and safest way back to a clean state
}
// ---------- API helpers ----------
@@ -77,10 +91,10 @@ async function api(path, opts = {}) {
// ---------- rendering ----------
function iso(ts) {
if (ts === null || ts === "") return "–";
const d = new Date(ts);
return isNaN(d) ? String(ts) : d.toLocaleString();
// 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) {
@@ -88,8 +102,8 @@ function renderRow(record, payload) {
const cells = [
payload.personA,
payload.personB,
iso(payload.tsStart),
iso(payload.tsEnd),
showTs(payload.tsStart),
showTs(payload.tsEnd),
payload.action,
];
for (const text of cells) {
@@ -136,6 +150,7 @@ async function reload() {
);
}
}
$("#new-btn").disabled = false;
if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true);
}
@@ -147,8 +162,8 @@ function openEditor(record, payload) {
$("#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-tsstart").value = payload.tsStart ?? "";
$("#f-tsend").value = payload.tsEnd ?? "";
$("#f-action").value = payload.action ?? "";
$("#editor-backdrop").hidden = false;
}
@@ -169,9 +184,9 @@ function closeEditor() {
}
function tsOrNull(inputValue) {
if (!inputValue) return null;
const d = new Date(inputValue);
return isNaN(d) ? null : d.toISOString();
// Free-text field: trim to null or the plain string.
const v = inputValue.trim();
return v === "" ? null : v;
}
// ---------- record operations ----------
@@ -206,7 +221,7 @@ async function saveRecord(ev) {
}
closeEditor();
await reload();
flash(isEdit ? "Datensatz aktualisiert." : "Datensatz erstellt.");
flash(isEdit ? "Datensatz aktualisiert." : "Datensatz gespeichert.");
} catch (e) {
closeEditor();
if (String(e).includes("409")) {
@@ -236,27 +251,45 @@ async function removeRecord(record) {
}
}
// ---------- unlock / lock ----------
// ---------- first-time setup (create a NEW passphrase) ----------
async function unlock(ev) {
async function setup(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");
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(passphrase, domainSalt);
$("#passphrase").value = "";
$("#unlock").hidden = true;
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();
} catch (e) {
flash("Entsperren fehlgeschlagen: " + e.message, true);
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();
@@ -264,12 +297,65 @@ function lock() {
// ---------- wire-up ----------
document.addEventListener("DOMContentLoaded", () => {
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));
$("#passphrase").focus();
// 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;
}
}