SHA256
rewirite to new name
This commit is contained in:
+480
-464
@@ -1,488 +1,504 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
import {
|
||||
deriveKey,
|
||||
encryptRecord,
|
||||
decryptRecord,
|
||||
randomSalt,
|
||||
randomUuid,
|
||||
wipeKey,
|
||||
} from "./crypto.js";
|
||||
(function () {
|
||||
const msgEl = document.getElementById("app-msg") || document.getElementById("login-msg");
|
||||
const isLoginPage = !!document.getElementById("login-form");
|
||||
|
||||
// ---------- tiny DOM helpers ----------
|
||||
let currentAccount = null;
|
||||
|
||||
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 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);
|
||||
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
|
||||
|
||||
// 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
|
||||
// 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;
|
||||
decryptedRows = [];
|
||||
lastFailures = 0;
|
||||
$("#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 ----------
|
||||
|
||||
// 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) {
|
||||
const tr = el("tr");
|
||||
const cells = [
|
||||
payload.personA,
|
||||
payload.personB,
|
||||
showTs(payload.tsStart),
|
||||
showTs(payload.tsEnd),
|
||||
showTs(payload.startCause),
|
||||
showTs(payload.endCause),
|
||||
payload.action,
|
||||
];
|
||||
for (const text of cells) {
|
||||
const td = el("td");
|
||||
td.textContent = text ?? ""; // textContent = no HTML injection
|
||||
tr.appendChild(td);
|
||||
function showMessage(text, isError) {
|
||||
if (!msgEl) return;
|
||||
appendMsg(msgEl, text, isError);
|
||||
}
|
||||
|
||||
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 raw = await api("/api/records");
|
||||
decryptedRows = [];
|
||||
lastFailures = 0;
|
||||
for (const record of raw) {
|
||||
try {
|
||||
const payload = decryptRecord(sessionKey, record);
|
||||
payload.__version = record.version; // used for optimistic locking
|
||||
decryptedRows.push({ record, payload });
|
||||
} catch {
|
||||
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);
|
||||
function clearMessage() {
|
||||
showMessage("", false);
|
||||
}
|
||||
|
||||
$("#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++;
|
||||
function byId(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
// Undecryptable records can't be filtered (content unknown) – always show.
|
||||
for (let i = 0; i < lastFailures; i++) {
|
||||
tbody.appendChild(
|
||||
el("tr", { class: "row-error" }, [
|
||||
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." })])
|
||||
);
|
||||
}
|
||||
|
||||
$("#filter-count").textContent =
|
||||
`${shown} von ${decryptedRows.length} Datensätzen`;
|
||||
}
|
||||
|
||||
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 ?? "";
|
||||
$("#f-tsend").value = payload.tsEnd ?? "";
|
||||
$("#f-startcause").value = payload.startCause ?? "";
|
||||
$("#f-endcause").value = payload.endCause ?? "";
|
||||
$("#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) {
|
||||
// Free-text field: trim to null or the plain string.
|
||||
const v = inputValue.trim();
|
||||
return v === "" ? null : v;
|
||||
}
|
||||
|
||||
// ---------- 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),
|
||||
startCause: tsOrNull($("#f-startcause").value),
|
||||
endCause: tsOrNull($("#f-endcause").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 }),
|
||||
function setOptions(sel, items, valueOf, labelOf, keepSelection) {
|
||||
if (!sel) return;
|
||||
const current = keepSelection ? sel.value : "";
|
||||
sel.innerHTML = "";
|
||||
items.forEach(function (item) {
|
||||
const o = document.createElement("option");
|
||||
o.value = valueOf(item);
|
||||
o.textContent = labelOf(item);
|
||||
sel.appendChild(o);
|
||||
});
|
||||
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();
|
||||
if (current) {
|
||||
for (const o of sel.options) {
|
||||
if (o.value === current) o.selected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- first-time setup (create a NEW passphrase) ----------
|
||||
/* ----------------------------- login page ----------------------------- */
|
||||
|
||||
async function setup(ev) {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
const p1 = $("#setup-pass1").value;
|
||||
const p2 = $("#setup-pass2").value;
|
||||
function initLoginPage() {
|
||||
const form = byId("login-form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
const email = form.email.value.trim();
|
||||
const password = form.password.value;
|
||||
if (!email || !password) {
|
||||
showMessage("email and password required", true);
|
||||
return;
|
||||
}
|
||||
fetch("/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email, password: password }),
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
if (!r.ok) throw new Error(data.error || "login failed");
|
||||
return data;
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
window.location.href = "/";
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "login failed", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (p1 !== p2) { flash("Die beiden Passphrasen stimmen nicht überein.", true); return; }
|
||||
if (p1.length < 8) { flash("Bitte mindestens 8 Zeichen verwenden.", true); return; }
|
||||
/* ------------------------------ main app ------------------------------ */
|
||||
|
||||
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();
|
||||
function initMainApp() {
|
||||
req("/account/me")
|
||||
.then(function (account) {
|
||||
currentAccount = account;
|
||||
startMainApp(account);
|
||||
})
|
||||
.catch(function () {
|
||||
window.location.href = "/login";
|
||||
});
|
||||
}
|
||||
|
||||
function startMainApp(account) {
|
||||
const emailEl = byId("user-email");
|
||||
if (emailEl) emailEl.textContent = account.email;
|
||||
const roleEl = byId("user-role");
|
||||
if (roleEl) roleEl.textContent = account.is_admin ? "admin" : "user";
|
||||
|
||||
renderMyPerson(account);
|
||||
|
||||
bindNoteForm();
|
||||
bindGroupForm();
|
||||
bindInviteForm();
|
||||
if (account.is_admin) {
|
||||
bindAdminPersonForm();
|
||||
bindAttachForm();
|
||||
bindRightForm();
|
||||
loadArticleTypes();
|
||||
populateRightsDropdown();
|
||||
refreshRights();
|
||||
}
|
||||
|
||||
refreshPersons();
|
||||
refreshArticles();
|
||||
refreshGroups();
|
||||
}
|
||||
|
||||
function renderMyPerson(account) {
|
||||
const box = byId("myperson");
|
||||
const form = byId("note-form");
|
||||
const hint = byId("note-hint");
|
||||
if (!account.person_id) {
|
||||
if (box) box.innerHTML = "Your account is not linked to a person yet. Ask an admin to link it.";
|
||||
if (form) {
|
||||
Array.prototype.forEach.call(form.querySelectorAll("input, textarea, button"), function (el) {
|
||||
el.disabled = true;
|
||||
});
|
||||
}
|
||||
if (hint) hint.textContent = "You can add notes, groups and invites once an admin links you to a person.";
|
||||
return;
|
||||
}
|
||||
$("#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;
|
||||
req("/persons/" + encodeURIComponent(account.person_id))
|
||||
.then(function (p) {
|
||||
if (!box) return;
|
||||
box.innerHTML =
|
||||
"<strong>" + escapeHtml(p.name || "(unnamed)") + "</strong>" +
|
||||
'<span class="meta">' +
|
||||
(p.emails && p.emails.length ? "emails: " + escapeHtml(p.emails.join(", ")) : "no email shown") +
|
||||
"</span>";
|
||||
})
|
||||
.catch(function () {
|
||||
if (box) box.textContent = "Person not found.";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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);
|
||||
|
||||
// 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));
|
||||
|
||||
// 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);
|
||||
function loadArticleTypes() {
|
||||
req("/api/types")
|
||||
.then(function (data) {
|
||||
setOptions(
|
||||
byId("note-type"),
|
||||
data.article_types || [],
|
||||
function (t) { return t; },
|
||||
function (t) { return (data.article_type_labels && data.article_type_labels[t]) || t; }
|
||||
);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- unlock (returning user) ----------
|
||||
function populateRightsDropdown() {
|
||||
req("/api/types")
|
||||
.then(function (data) {
|
||||
const rights = data.rights || {};
|
||||
setOptions(
|
||||
byId("right-right"),
|
||||
Object.keys(rights),
|
||||
function (k) { return k; },
|
||||
function (k) { return k + " - " + rights[k]; }
|
||||
);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
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 {
|
||||
await api("https://crdbe.t2max.de/api/passphrase", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ passphrase: passphrase }),
|
||||
function bindNoteForm() {
|
||||
const form = byId("note-form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
const isAdmin = currentAccount && currentAccount.is_admin;
|
||||
const personSel = byId("note-person");
|
||||
const personId = isAdmin && personSel ? personSel.value : currentAccount && currentAccount.person_id;
|
||||
if (!personId) {
|
||||
showMessage("no person linked to your account", true);
|
||||
return;
|
||||
}
|
||||
const typeSel = byId("note-type");
|
||||
const visSel = byId("note-visibility");
|
||||
req("/articles", {
|
||||
method: "POST",
|
||||
body: {
|
||||
person_id: personId,
|
||||
article_type: isAdmin && typeSel ? typeSel.value : "notes",
|
||||
title: form.title.value,
|
||||
content: form.content.value,
|
||||
visibility: isAdmin && visSel ? visSel.value : "private",
|
||||
group_ids: [],
|
||||
},
|
||||
})
|
||||
.then(function () {
|
||||
form.reset();
|
||||
refreshArticles();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to save", true);
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshArticles() {
|
||||
req("/articles")
|
||||
.then(function (data) {
|
||||
const list = byId("articles-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
if (!data.length) {
|
||||
list.innerHTML = '<li class="meta">Nothing here yet.</li>';
|
||||
return;
|
||||
}
|
||||
data.forEach(function (a) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML =
|
||||
"<strong>" + escapeHtml(a.title || "(untitled)") + "</strong> " +
|
||||
'<span class="meta">' +
|
||||
escapeHtml(a.article_type) +
|
||||
" · " + escapeHtml(a.visibility) +
|
||||
"</span>" +
|
||||
(a.content
|
||||
? "<pre style='margin:4px 0;white-space:pre-wrap;font:inherit;background:#fff;border:1px solid #e4e7ee;padding:4px;border-radius:4px;'>" +
|
||||
escapeHtml(a.content) + "</pre>"
|
||||
: "") +
|
||||
(a.visibility === "group" && a.allowed_groups && a.allowed_groups.length
|
||||
? '<span class="meta">groups: ' + escapeHtml(a.allowed_groups.join(", ")) + "</span>"
|
||||
: "");
|
||||
list.appendChild(li);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to load articles", true);
|
||||
});
|
||||
}
|
||||
|
||||
function refreshPersons() {
|
||||
req("/persons")
|
||||
.then(function (data) {
|
||||
const label = function (p) { return p.name || "(unnamed)"; };
|
||||
|
||||
setOptions(byId("invite-person"), data, function (p) { return p.id; }, label, true);
|
||||
|
||||
const noteSel = byId("note-person");
|
||||
setOptions(noteSel, data, function (p) { return p.id; }, label, true);
|
||||
if (noteSel && !noteSel.value && currentAccount && currentAccount.person_id) {
|
||||
noteSel.value = currentAccount.person_id;
|
||||
}
|
||||
|
||||
setOptions(byId("attach-person"), data, function (p) { return p.id; }, label, true);
|
||||
|
||||
const list = byId("admin-persons-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
if (!data.length) {
|
||||
list.innerHTML = '<li class="meta">No persons yet.</li>';
|
||||
return;
|
||||
}
|
||||
data.forEach(function (p) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML =
|
||||
"<strong>" + escapeHtml(p.name || "(unnamed)") + "</strong>" +
|
||||
'<span class="meta">' +
|
||||
(p.emails && p.emails.length ? "emails: " + escapeHtml(p.emails.join(", ")) : "no emails") +
|
||||
"</span>";
|
||||
list.appendChild(li);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to load persons", true);
|
||||
});
|
||||
}
|
||||
|
||||
function bindAdminPersonForm() {
|
||||
const form = byId("admin-person-form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
req("/admin/persons", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: form.name.value,
|
||||
email: form.email.value,
|
||||
password: form.password.value,
|
||||
},
|
||||
})
|
||||
.then(function (person) {
|
||||
form.reset();
|
||||
showMessage("created person: " + (person.name || person.id), false);
|
||||
refreshPersons();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to create person", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindAttachForm() {
|
||||
const form = byId("attach-form");
|
||||
if (!form) return;
|
||||
const send = function (action, okText) {
|
||||
const personId = byId("attach-person").value;
|
||||
const email = byId("attach-email").value.trim();
|
||||
if (!personId || !email) {
|
||||
showMessage("select a person and enter an email", true);
|
||||
return;
|
||||
}
|
||||
req("/persons/" + encodeURIComponent(personId) + "/emails/" + action, {
|
||||
method: "POST",
|
||||
body: { email: email },
|
||||
})
|
||||
.then(function () {
|
||||
showMessage(okText + " " + email, false);
|
||||
refreshPersons();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to change email", true);
|
||||
});
|
||||
};
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
send("attach", "attached");
|
||||
});
|
||||
const detach = byId("detach-email");
|
||||
if (detach) {
|
||||
detach.addEventListener("click", function () {
|
||||
clearMessage();
|
||||
send("detach", "detached");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bindGroupForm() {
|
||||
const form = byId("group-form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
if (!currentAccount || !currentAccount.person_id) {
|
||||
showMessage("no person linked to your account", true);
|
||||
return;
|
||||
}
|
||||
req("/groups", {
|
||||
method: "POST",
|
||||
body: { person_id: currentAccount.person_id, name: form.name.value },
|
||||
})
|
||||
.then(function () {
|
||||
form.reset();
|
||||
refreshGroups();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to create group", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshGroups() {
|
||||
req("/groups")
|
||||
.then(function (data) {
|
||||
setOptions(
|
||||
byId("invite-group"),
|
||||
data,
|
||||
function (g) { return g.id; },
|
||||
function (g) { return g.name || "(unnamed)"; },
|
||||
true
|
||||
);
|
||||
const list = byId("groups-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
if (!data.length) {
|
||||
list.innerHTML = '<li class="meta">No groups yet. Create one above.</li>';
|
||||
return;
|
||||
}
|
||||
data.forEach(function (g) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML =
|
||||
"<strong>" + escapeHtml(g.name || "(unnamed)") + "</strong> " +
|
||||
'<span class="meta">members: ' +
|
||||
(g.members && g.members.length
|
||||
? escapeHtml(g.members.map(function (m) { return m.name || "(unnamed)"; }).join(", "))
|
||||
: "none") +
|
||||
"</span>";
|
||||
list.appendChild(li);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to load groups", true);
|
||||
});
|
||||
}
|
||||
|
||||
function bindInviteForm() {
|
||||
const form = byId("invite-form");
|
||||
if (!form) return;
|
||||
const addMember = function (groupId, personId) {
|
||||
if (!groupId || !personId) {
|
||||
showMessage("select a group and a person", true);
|
||||
return;
|
||||
}
|
||||
req("/groups/" + encodeURIComponent(groupId) + "/members/" + encodeURIComponent(personId), {
|
||||
method: "POST",
|
||||
body: {},
|
||||
})
|
||||
.then(function () {
|
||||
clearMessage();
|
||||
refreshGroups();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to add member", true);
|
||||
});
|
||||
};
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
addMember(byId("invite-group").value, byId("invite-person").value);
|
||||
});
|
||||
const join = byId("join-self");
|
||||
if (join) {
|
||||
join.addEventListener("click", function () {
|
||||
clearMessage();
|
||||
if (!currentAccount || !currentAccount.person_id) {
|
||||
showMessage("no person linked to your account", true);
|
||||
return;
|
||||
}
|
||||
addMember(byId("invite-group").value, currentAccount.person_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRights() {
|
||||
req("/rights")
|
||||
.then(function (data) {
|
||||
const accountSel = byId("right-account");
|
||||
setOptions(
|
||||
accountSel,
|
||||
data,
|
||||
function (a) { return a.id; },
|
||||
function (a) { return a.email + (a.rights && a.rights.length ? " (" + a.rights.join(", ") + ")" : ""); },
|
||||
true
|
||||
);
|
||||
const list = byId("rights-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
data.forEach(function (a) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML =
|
||||
"<strong>" + escapeHtml(a.email) + "</strong> " +
|
||||
'<span class="meta">' +
|
||||
(a.rights && a.rights.length ? escapeHtml(a.rights.join(", ")) : "no rights") +
|
||||
"</span>";
|
||||
list.appendChild(li);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to load rights", true);
|
||||
});
|
||||
}
|
||||
|
||||
function bindRightForm() {
|
||||
const form = byId("right-form");
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
clearMessage();
|
||||
if (!form.account_id.value || !form.right.value) {
|
||||
showMessage("select account and right", true);
|
||||
return;
|
||||
}
|
||||
req("/rights/grant", {
|
||||
method: "POST",
|
||||
body: { account_id: form.account_id.value, right: form.right.value },
|
||||
})
|
||||
.then(refreshRights)
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to grant right", true);
|
||||
});
|
||||
});
|
||||
const revokeBtn = byId("revoke-right");
|
||||
if (revokeBtn) {
|
||||
revokeBtn.addEventListener("click", function () {
|
||||
clearMessage();
|
||||
const accountSel = byId("right-account");
|
||||
const rightSel = byId("right-right");
|
||||
if (!accountSel.value || !rightSel.value) {
|
||||
showMessage("select account and right to revoke", true);
|
||||
return;
|
||||
}
|
||||
req("/rights/revoke", {
|
||||
method: "POST",
|
||||
body: { account_id: accountSel.value, right: rightSel.value },
|
||||
})
|
||||
.then(refreshRights)
|
||||
.catch(function (err) {
|
||||
showMessage(err.message || "failed to revoke right", true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoginPage) {
|
||||
initLoginPage();
|
||||
} else if (byId("app-msg")) {
|
||||
initMainApp();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user