Compare commits

..
2 Commits
Author SHA256 Message Date
jhkaktus 2120c56f45 make rewirte start 2026-10-13 15:35:07 +02:00
jhkaktus 516cb13dcf feat: Anlass + Endgrund fields for records
Adds two optional free-text fields to describe why a transaction
started ("Anlass") and why it ended ("Endgrund") - chosen names instead
of the technical "Startursache"/"Endesuhrsache". Displayed as own table
columns and editable in the form; legacy records without those fields
render as "-". Payload is encrypted client-side like every other field
(no server change needed, opaque ciphertext).

Verified: node --check, crypto roundtrip incl. new fields, legacy-safe
access (?? ''), pytest 3/3.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 01:04:46 +02:00
4 changed files with 369 additions and 14 deletions
+34 -3
View File
@@ -67,6 +67,20 @@
<button id="lock-btn" type="button">Sperren</button> <button id="lock-btn" type="button">Sperren</button>
</div> </div>
<div class="filters">
<input id="filter-text" type="search"
placeholder="Suchen … (z. B. „Andrea“ oder „Kaffee“)"
aria-label="Datensätze durchsuchen" />
<select id="filter-person" aria-label="Nach Person filtern">
<option value="">Alle Personen</option>
</select>
<select id="filter-action" aria-label="Nach Aktion filtern">
<option value="">Alle Aktionen</option>
</select>
<button id="filter-reset" type="button" class="secondary">Filter zurücksetzen</button>
<span id="filter-count" class="filter-count"></span>
</div>
<table id="records"> <table id="records">
<thead> <thead>
<tr> <tr>
@@ -74,6 +88,8 @@
<th>Person B</th> <th>Person B</th>
<th>Start</th> <th>Start</th>
<th>Ende</th> <th>Ende</th>
<th>Anlass</th>
<th>Endgrund</th>
<th>Aktion</th> <th>Aktion</th>
<th></th> <th></th>
</tr> </tr>
@@ -87,10 +103,12 @@
<form id="editor" autocomplete="off"> <form id="editor" autocomplete="off">
<h2 id="editor-title">Neuer Datensatz</h2> <h2 id="editor-title">Neuer Datensatz</h2>
<label>Person A <label>Person A
<input id="f-persona" type="text" required maxlength="120" /> <input id="f-persona" type="text" required maxlength="120"
list="dl-person" placeholder="wählen oder neu tippen …" />
</label> </label>
<label>Person B <label>Person B
<input id="f-personb" type="text" required maxlength="120" /> <input id="f-personb" type="text" required maxlength="120"
list="dl-person" placeholder="wählen oder neu tippen …" />
</label> </label>
<label>Start (optional, beliebiger Text) <label>Start (optional, beliebiger Text)
<input id="f-tsstart" type="text" maxlength="120" <input id="f-tsstart" type="text" maxlength="120"
@@ -100,8 +118,17 @@
<input id="f-tsend" type="text" maxlength="120" <input id="f-tsend" type="text" maxlength="120"
placeholder="z. B. laufend, unbekannt, März 2026 …" /> placeholder="z. B. laufend, unbekannt, März 2026 …" />
</label> </label>
<label>Anlass (optional)
<input id="f-startcause" type="text" maxlength="300"
placeholder="womit/warum es begann, z. B. gemeinsam geplante Reise …" />
</label>
<label>Endgrund (optional)
<input id="f-endcause" type="text" maxlength="300"
placeholder="warum es endete, z. B. zurückgezahlt, abgelaufen …" />
</label>
<label>Aktion <label>Aktion
<input id="f-action" type="text" required maxlength="500" /> <input id="f-action" type="text" required maxlength="500"
list="dl-action" placeholder="wählen oder neu tippen …" />
</label> </label>
<div class="actions"> <div class="actions">
<button type="submit">Speichern</button> <button type="submit">Speichern</button>
@@ -110,6 +137,10 @@
</form> </form>
</div> </div>
<!-- suggestion lists for the editor's dropdown inputs (filled by ui.js) -->
<datalist id="dl-person"></datalist>
<datalist id="dl-action"></datalist>
<script type="module" src="/static/ui.js"></script> <script type="module" src="/static/ui.js"></script>
</body> </body>
</html> </html>
+29
View File
@@ -107,6 +107,35 @@ tbody tr:hover { background: var(--panel-2); }
td:last-child, td button { white-space: nowrap; } td:last-child, td button { white-space: nowrap; }
td button + button { margin-left: 0.4rem; } td button + button { margin-left: 0.4rem; }
/* filter bar */
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filters input[type="search"] { flex: 1 1 220px; min-width: 170px; }
.filters select { max-width: 220px; }
.filter-count { color: var(--muted); font-size: 0.85rem; margin-left: auto; white-space: nowrap; }
select {
font: inherit;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.7rem;
}
select:focus { outline: 1px solid var(--accent); }
option { background: var(--panel); color: var(--text); }
.secondary {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
}
/* modal */ /* modal */
/* NOTE: keep `hidden` winning over the flex display below, otherwise the /* NOTE: keep `hidden` winning over the flex display below, otherwise the
backdrop is permanently visible and blocks everything underneath. */ backdrop is permanently visible and blocks everything underneath. */
+135 -8
View File
@@ -61,6 +61,11 @@ function flash(msg, isError = false) {
let sessionKey = null; // Uint8Array(32) or null let sessionKey = null; // Uint8Array(32) or null
let busy = false; // Argon2id takes a second; block double submits let busy = false; // Argon2id takes a second; block double submits
// Decrypted records kept in memory so filters/datalist suggestions can
// re-render without re-fetching and re-decrypting everything.
let decryptedRows = []; // [{ record, payload }]
let lastFailures = 0; // records that could not be decrypted (shown unfiltered)
// Salt for the session-key derivation. Fixed domain string, contains // Salt for the session-key derivation. Fixed domain string, contains
// no secret; the per-record salt is separate (see crypto.js). // no secret; the per-record salt is separate (see crypto.js).
const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1"); const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1");
@@ -68,6 +73,8 @@ const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1");
function resetSession() { function resetSession() {
wipeKey(sessionKey); wipeKey(sessionKey);
sessionKey = null; sessionKey = null;
decryptedRows = [];
lastFailures = 0;
$("#records").replaceChildren(); $("#records").replaceChildren();
$("#app").hidden = true; $("#app").hidden = true;
location.reload(); // cheapest and safest way back to a clean state location.reload(); // cheapest and safest way back to a clean state
@@ -104,6 +111,8 @@ function renderRow(record, payload) {
payload.personB, payload.personB,
showTs(payload.tsStart), showTs(payload.tsStart),
showTs(payload.tsEnd), showTs(payload.tsEnd),
showTs(payload.startCause),
showTs(payload.endCause),
payload.action, payload.action,
]; ];
for (const text of cells) { for (const text of cells) {
@@ -132,26 +141,125 @@ function renderRow(record, payload) {
} }
async function reload() { async function reload() {
const tbody = $("#records tbody");
tbody.replaceChildren();
const raw = await api("/api/records"); const raw = await api("/api/records");
let failures = 0; decryptedRows = [];
lastFailures = 0;
for (const record of raw) { for (const record of raw) {
try { try {
const payload = decryptRecord(sessionKey, record); const payload = decryptRecord(sessionKey, record);
payload.__version = record.version; // used for optimistic locking payload.__version = record.version; // used for optimistic locking
tbody.appendChild(renderRow(record, payload)); decryptedRows.push({ record, payload });
} catch { } catch {
failures++; lastFailures++;
}
}
refreshSuggestions();
renderRows();
$("#new-btn").disabled = false;
if (lastFailures > 0) flash(`${lastFailures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true);
}
// ---------- filters & suggestions ----------
function sorted(values) {
return [...values].sort((a, b) => a.localeCompare(b, "de"));
}
// Rebuild the <datalist> dropdowns (editor) and the filter <select>s from
// every person/action seen in the decrypted records. Selections in the
// filter selects are kept if the option still exists.
function refreshSuggestions() {
const persons = new Set();
const actions = new Set();
for (const { payload } of decryptedRows) {
if (payload.personA) persons.add(payload.personA);
if (payload.personB) persons.add(payload.personB);
if (payload.action) actions.add(payload.action);
}
$("#dl-person").replaceChildren(
...sorted(persons).map((v) => el("option", { value: v }))
);
$("#dl-action").replaceChildren(
...sorted(actions).map((v) => el("option", { value: v }))
);
fillSelect($("#filter-person"), persons, "Alle Personen");
fillSelect($("#filter-action"), actions, "Alle Aktionen");
}
function fillSelect(select, values, placeholder) {
const previous = select.value;
select.replaceChildren(
el("option", { value: "", text: placeholder }),
...sorted(values).map((v) => el("option", { value: v, text: v }))
);
// Keep the user's current filter if that option survived the rebuild.
if ([...select.options].some((o) => o.value === previous)) select.value = previous;
}
function currentFilter() {
return {
text: $("#filter-text").value.trim().toLowerCase(),
person: $("#filter-person").value.trim().toLowerCase(),
action: $("#filter-action").value.trim().toLowerCase(),
};
}
function matchesFilter(payload, f) {
if (f.person) {
const a = String(payload.personA ?? "").toLowerCase();
const b = String(payload.personB ?? "").toLowerCase();
if (!a.includes(f.person) && !b.includes(f.person)) return false;
}
if (f.action && !String(payload.action ?? "").toLowerCase().includes(f.action)) {
return false;
}
if (f.text) {
const haystack = [
payload.personA, payload.personB, payload.tsStart, payload.tsEnd,
payload.startCause, payload.endCause, payload.action,
].map((v) => String(v ?? "").toLowerCase()).join("\n");
if (!haystack.includes(f.text)) return false;
}
return true;
}
function renderRows() {
const tbody = $("#records tbody");
tbody.replaceChildren();
const f = currentFilter();
const filterActive = Boolean(f.text || f.person || f.action);
let shown = 0;
for (const { record, payload } of decryptedRows) {
if (!matchesFilter(payload, f)) continue;
tbody.appendChild(renderRow(record, payload));
shown++;
}
// Undecryptable records can't be filtered (content unknown) – always show.
for (let i = 0; i < lastFailures; i++) {
tbody.appendChild( tbody.appendChild(
el("tr", { class: "row-error" }, [ el("tr", { class: "row-error" }, [
el("td", { colspan: "6", text: "Datensatz nicht entschlüsselbar (falsche Passphrase oder manipuliert)." }), el("td", { colspan: "8", text: "Datensatz nicht entschlüsselbar (falsche Passphrase oder manipuliert)." }),
]) ])
); );
} }
if (decryptedRows.length === 0 && lastFailures === 0) {
tbody.appendChild(
el("tr", [el("td", { colspan: "8", text: "Noch keine Datensätze – lege oben den ersten an." })])
);
} else if (shown === 0 && filterActive) {
tbody.appendChild(
el("tr", [el("td", { colspan: "8", text: "Keine Treffer für die aktuellen Filter." })])
);
} }
$("#new-btn").disabled = false;
if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true); $("#filter-count").textContent =
`${shown} von ${decryptedRows.length} Datensätzen`;
} }
function openEditor(record, payload) { function openEditor(record, payload) {
@@ -164,6 +272,8 @@ function openEditor(record, payload) {
$("#f-personb").value = payload.personB ?? ""; $("#f-personb").value = payload.personB ?? "";
$("#f-tsstart").value = payload.tsStart ?? ""; $("#f-tsstart").value = payload.tsStart ?? "";
$("#f-tsend").value = payload.tsEnd ?? ""; $("#f-tsend").value = payload.tsEnd ?? "";
$("#f-startcause").value = payload.startCause ?? "";
$("#f-endcause").value = payload.endCause ?? "";
$("#f-action").value = payload.action ?? ""; $("#f-action").value = payload.action ?? "";
$("#editor-backdrop").hidden = false; $("#editor-backdrop").hidden = false;
} }
@@ -201,6 +311,8 @@ async function saveRecord(ev) {
personB: $("#f-personb").value.trim(), personB: $("#f-personb").value.trim(),
tsStart: tsOrNull($("#f-tsstart").value), tsStart: tsOrNull($("#f-tsstart").value),
tsEnd: tsOrNull($("#f-tsend").value), tsEnd: tsOrNull($("#f-tsend").value),
startCause: tsOrNull($("#f-startcause").value),
endCause: tsOrNull($("#f-endcause").value),
action: $("#f-action").value.trim(), action: $("#f-action").value.trim(),
}; };
if (!payload.personA || !payload.personB || !payload.action) { if (!payload.personA || !payload.personB || !payload.action) {
@@ -305,6 +417,17 @@ document.addEventListener("DOMContentLoaded", async () => {
$("#new-btn").disabled = true; // enabled after reload() succeeds $("#new-btn").disabled = true; // enabled after reload() succeeds
$("#editor").addEventListener("submit", saveRecord); $("#editor").addEventListener("submit", saveRecord);
$("#editor-cancel").addEventListener("click", closeEditor); $("#editor-cancel").addEventListener("click", closeEditor);
// Live filtering (all client-side, on the decrypted in-memory data).
$("#filter-text").addEventListener("input", renderRows);
$("#filter-person").addEventListener("change", renderRows);
$("#filter-action").addEventListener("change", renderRows);
$("#filter-reset").addEventListener("click", () => {
$("#filter-text").value = "";
$("#filter-person").value = "";
$("#filter-action").value = "";
renderRows();
});
window.addEventListener("beforeunload", () => wipeKey(sessionKey)); window.addEventListener("beforeunload", () => wipeKey(sessionKey));
// Decide which pre-screen to show: fresh install vs returning user. // Decide which pre-screen to show: fresh install vs returning user.
@@ -335,6 +458,10 @@ async function unlock(ev) {
$("#unlock-btn").disabled = true; $("#unlock-btn").disabled = true;
$("#unlock-progress").hidden = false; $("#unlock-progress").hidden = false;
try { try {
await api("https://crdbe.t2max.de/api/passphrase", {
method: "POST",
body: JSON.stringify({ passphrase: passphrase }),
});
sessionKey = deriveKey(passphrase, DOMAIN_SALT); sessionKey = deriveKey(passphrase, DOMAIN_SALT);
$("#passphrase").value = ""; $("#passphrase").value = "";
$("#unlock").hidden = true; $("#unlock").hidden = true;
+168
View File
@@ -0,0 +1,168 @@
SPECIFICATION (German)
Project role
- This is an online contact directory / personal archive, not a transaction
ledger. Its main job is to store and share structured information about
people and their related items under controlled access.
Core concepts
- Account (Login identity)
* One account is permanently tied to exactly one email address.
* Each email address has its own password.
* Different accounts can have different rights.
* An account is the login boundary, not the data owner in every case.
- Person
* A person is the central real-world entity.
* One person can be linked to multiple email addresses / accounts but every account /e-mail can only be linke to oner person.
* A person is not owned by any single account.
- Article
* Every person has articles.
* Articles exist in different article types.
* Article visibility is not global.
* Articles are only visible to people who hold the required rights and/or
belong to the required group(s).
- Article types
* Examples of article types:
- Contact details
- Notes
- Profile text
- Documents
- Links
- Phone numbers
- Addresses
- Social profiles
* The set of article types is fixed for the implementation, but the system
treats them as distinct, named categories.
- Groups
* Groups are person-dependent.
* Groups are NOT email-dependent.
* A group describes a set of persons.
* Group membership follows the person, regardless of which account or email
is used to access the system.
- Rights
* Rights are fixed, named capabilities.
* Rights control what can be seen, created, edited, or removed.
* Rights are used together with group membership to decide access.
Relationships
- Account <-> email
* One account, one email address, fixed binding.
- Email <-> password
* Each email has its own password.
- Person <-> accounts / emails
* One person may relate to several emails/accounts.
- Person <-> groups
* Groups are built around persons.
- Person <-> articles
* A person owns articles of various types.
- Articles <-> readers
* Reading and other actions depend on rights.
* Reading can also depend on group membership.
- Groups <-> persons
* Membership is defined by persons, not by accounts.
Access model
- Visibility rule
* An article is shown only if the requesting party has the required right
AND is allowed by the relevant group constraints.
* If either condition is not met, the article is not visible.
- Purpose of the rule
* Some articles are private.
* Some articles are shared only with selected persons or groups.
* Group-based visibility is independent from email/account identity.
Account scope
- Login
* Accounts are used to authenticate a user.
* Authentication is tied to an email address and password.
- Rights assignment
* Different accounts can be granted different rights.
* Rights are assigned at account level or in relation to persons/groups,
depending on the access rule for the article.
Person scope
- Data ownership
* A person is the owner of their own articles.
* A person can have multiple linked accounts/emails.
- Shared context
* Other parties may access articles only through the rights/group model.
Groups and persons
- Membership
* Groups contain persons.
* Group membership does not depend on the email used for login.
- Example interpretation
* If a person belongs to a group, then that group relation follows the
person, not a specific email account.
Article model
- Each article has:
* An article type
* Content belonging to that type
* An owner person context
* Access rules based on rights and group membership
- Article types are distinct categories, not free text, so that access can be
handled per type in a structured way.
Security and privacy goals
- Private by default where appropriate
* Not everything is publicly visible.
- Controlled sharing
* Sharing happens via rights and groups, not via open access.
- Person-centric sharing
* Visibility follows persons and groups, not only the account that logs in.
- Separation of concerns
* Login (account/email/password) is separate from data ownership (person).
* This allows one person to be reachable through several emails without
merging all content into one account identity.
Functional expectations
- Account management
* Create account for an email address with a password.
* The email address is fixed for that account.
- Person management
* Represent persons independently of a single account.
* Link multiple emails/accounts to one person where needed.
- Article management
* Create, view, edit, and remove articles for a person.
* Store articles by type.
* Enforce visibility by rights and group membership.
- Group management
* Groups are defined around persons.
* Membership is used for visibility/access decisions.
- Rights handling
* Rights decide what is allowed for an account/requesting party.
* Rights work together with group-based visibility rules.
Non-goals for this version
- This is not a payment system.
- This is not a transaction ledger.
- This is not primarily an account-consumption platform; accounts exist for
login and rights, but persons and their articles are the main content.
Level of detail required from the specification
- Account model
* Fixed email per account.
* Password per email.
* Different rights per account.
- Person model
* Persons as first-class entities.
* Support for multiple emails per person.
- Article model
* Article types.
* Ownership context.
* Rights/group-based visibility.
- Group model
* Person-dependent groups.
* No email-dependent groups.
- Access behavior
* Explicit visibility rules.
* No open global visibility for restricted articles.
This specification intentionally describes what the system is and what rules
it follows, so that later implementation matches the intended model exactly.