rewirite to new name

This commit is contained in:
2026-10-14 20:29:22 +02:00
parent 2120c56f45
commit 1cdb3fa784
23 changed files with 2521 additions and 5755 deletions
+480 -464
View File
@@ -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();
}
})();