383 lines
13 KiB
JavaScript
383 lines
13 KiB
JavaScript
// Benutzerverwaltung für Administratoren.
|
||
"use strict";
|
||
|
||
import { del, get, post, put } from "../api.js";
|
||
import { el, mount } from "../dom.js";
|
||
import { state } from "../store.js";
|
||
|
||
let openId = null;
|
||
|
||
function formatDate(iso) {
|
||
if (!iso) return null;
|
||
return new Date(iso).toLocaleDateString("de-DE", {
|
||
day: "2-digit", month: "2-digit", year: "numeric",
|
||
});
|
||
}
|
||
|
||
/** Wie lange ist das her, grob. Für "seit wann nicht mehr gesehen" ist
|
||
* der Monat die passende Auflösung - der Tag wäre Scheingenauigkeit. */
|
||
function ago(iso) {
|
||
if (!iso) return "nie";
|
||
const days = Math.floor((Date.now() - new Date(iso)) / 86400000);
|
||
if (days < 1) return "heute";
|
||
if (days === 1) return "gestern";
|
||
if (days < 31) return `vor ${days} Tagen`;
|
||
const months = Math.round(days / 30);
|
||
if (months < 24) return `vor ${months} Monaten`;
|
||
return `vor ${Math.round(months / 12)} Jahren`;
|
||
}
|
||
|
||
export async function adminView(root, { back }) {
|
||
let users = [];
|
||
let stats = null;
|
||
let config = null;
|
||
let banner = null;
|
||
|
||
async function reload() {
|
||
[users, stats, config] = await Promise.all([
|
||
get("/api/admin/users"),
|
||
get("/api/admin/stats"),
|
||
get("/api/admin/settings"),
|
||
]);
|
||
}
|
||
|
||
function say(message, kind = "notice") {
|
||
banner = { message, kind };
|
||
render();
|
||
setTimeout(() => { banner = null; render(); }, 10000);
|
||
}
|
||
|
||
async function guarded(fn) {
|
||
try {
|
||
const result = await fn();
|
||
await reload();
|
||
render();
|
||
if (result && result.detail) say(result.detail);
|
||
} catch (err) {
|
||
say(err.message, "error");
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Kennzahlen
|
||
// -------------------------------------------------------------------
|
||
|
||
function statsCard() {
|
||
const tile = (label, value, warn = false) =>
|
||
el("div", { className: `stat${warn && value ? " warn" : ""}` },
|
||
el("span.value", {}, String(value)),
|
||
el("span.label", {}, label));
|
||
|
||
return el("section.card", {},
|
||
el("h1", {}, "Benutzer"),
|
||
el("div.stats", {},
|
||
tile("gesamt", stats.total),
|
||
tile("aktiv", stats.active),
|
||
tile("deaktiviert", stats.inactive),
|
||
tile("nicht eingerichtet", stats.unverified),
|
||
tile("Administratoren", stats.admins)),
|
||
stats.due_deactivation || stats.due_deletion
|
||
? el("p.lead.warn", {},
|
||
"Beim nächsten nächtlichen Durchlauf: ",
|
||
stats.due_deactivation
|
||
? `${stats.due_deactivation} Konto/Konten werden deaktiviert`
|
||
: "",
|
||
stats.due_deactivation && stats.due_deletion ? ", " : "",
|
||
stats.due_deletion
|
||
? `${stats.due_deletion} werden gelöscht`
|
||
: "",
|
||
".")
|
||
: null
|
||
);
|
||
}
|
||
|
||
/** Steht bei den Konten, nicht bei der Bereinigung: Es geht darum,
|
||
* WER hinzukommen darf - das gehört zur Kontenverwaltung. */
|
||
function registrationToggle() {
|
||
const box = el("input", {
|
||
type: "checkbox",
|
||
checked: config.allow_self_registration,
|
||
disabled: config.locked_by_env,
|
||
onchange: (ev) => guarded(async () => {
|
||
await put("/api/admin/settings", {
|
||
allow_self_registration: ev.target.checked,
|
||
});
|
||
say(ev.target.checked
|
||
? "Selbstregistrierung eingeschaltet."
|
||
: "Selbstregistrierung abgeschaltet – Zugang nur noch per Einladung.");
|
||
}),
|
||
});
|
||
|
||
return el("div.reg-toggle", {},
|
||
el("label.checkline", {}, box,
|
||
el("span", {}, "Selbstregistrierung erlauben")),
|
||
config.locked_by_env
|
||
? el("p.sub", {},
|
||
"Über die Umgebungsvariable ALLOW_SELF_REGISTRATION festgelegt " +
|
||
"und hier nicht änderbar.")
|
||
: el("p.sub", {},
|
||
"Ist sie aus, kommen neue Personen nur über „Konto anlegen“ " +
|
||
"oder eine Listeneinladung hinein."));
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Neues Konto
|
||
// -------------------------------------------------------------------
|
||
|
||
function createCard() {
|
||
const email = el("input", { type: "email", placeholder: "person@example.de" });
|
||
const name = el("input", { type: "text", maxLength: 80, placeholder: "freiwillig" });
|
||
const asAdmin = el("input", { type: "checkbox" });
|
||
|
||
return el("section.card", {},
|
||
el("h2", {}, "Konto anlegen"),
|
||
el("p.lead", {},
|
||
"Die Person bekommt eine Willkommensnachricht mit einem Link, über " +
|
||
"den sie ihr Passwort selbst festlegt. Ein vom Administrator " +
|
||
"vergebenes Passwort wäre ihm bekannt und ginge im Klartext per Mail."),
|
||
el("label", {}, "E-Mail-Adresse"), email,
|
||
el("label", {}, "Anzeigename"), name,
|
||
el("label.checkline", {}, asAdmin,
|
||
el("span", {}, "Administratorrechte erteilen")),
|
||
el("button.primary", {
|
||
type: "button",
|
||
onclick: () => {
|
||
const value = email.value.trim();
|
||
if (!value) return;
|
||
guarded(async () => {
|
||
await post("/api/admin/users", {
|
||
email: value,
|
||
display_name: name.value.trim() || null,
|
||
is_admin: asAdmin.checked,
|
||
});
|
||
email.value = "";
|
||
name.value = "";
|
||
asAdmin.checked = false;
|
||
say(`Konto angelegt, Willkommensnachricht an ${value} versendet.`);
|
||
});
|
||
},
|
||
}, "Anlegen und einladen")
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Ein Konto
|
||
// -------------------------------------------------------------------
|
||
|
||
function userActions(user) {
|
||
const self = user.id === state.user.id;
|
||
|
||
const newEmail = el("input", { type: "email", placeholder: "neue Adresse" });
|
||
|
||
const blocks = [
|
||
el("label", {}, "E-Mail-Adresse ändern"),
|
||
el("p.sub", {},
|
||
user.is_admin
|
||
? "Administratorkonto: Die Änderung wird erst wirksam, wenn BEIDE " +
|
||
"Adressen bestätigt haben – die neue und die bisherige."
|
||
: "Die neue Adresse muss bestätigen; die bisherige bekommt einen " +
|
||
"Hinweis, damit eine untergeschobene Änderung auffällt."),
|
||
el("div.row", {}, newEmail,
|
||
el("button.secondary", {
|
||
type: "button",
|
||
onclick: () => {
|
||
const value = newEmail.value.trim();
|
||
if (!value) return;
|
||
guarded(async () => {
|
||
const result = await post(`/api/admin/users/${user.id}/email`,
|
||
{ new_email: value });
|
||
newEmail.value = "";
|
||
return result;
|
||
});
|
||
},
|
||
}, "Ändern")),
|
||
];
|
||
|
||
if (user.pending_email) {
|
||
blocks.push(el("p.lead.warn", {},
|
||
`Adresswechsel zu ${user.pending_email} läuft. `,
|
||
el("button.linklike", {
|
||
type: "button",
|
||
onclick: () => guarded(() => del(`/api/admin/users/${user.id}/email`)),
|
||
}, "Zurückziehen")));
|
||
}
|
||
|
||
if (!user.verified) {
|
||
blocks.push(el("div.menu-actions", {},
|
||
el("button", {
|
||
type: "button",
|
||
onclick: () => guarded(() => post(`/api/admin/users/${user.id}/welcome`)),
|
||
}, "Willkommensnachricht erneut senden")));
|
||
}
|
||
|
||
// Administratorkonten sind vor Deaktivierung und Löschung geschützt.
|
||
if (user.is_admin) {
|
||
blocks.push(el("p.sub", {},
|
||
"Administratorkonten lassen sich nicht deaktivieren oder löschen. " +
|
||
"Sonst könnte sich die Verwaltung selbst aussperren."));
|
||
return el("div.item-menu", {}, blocks);
|
||
}
|
||
|
||
if (self) {
|
||
blocks.push(el("p.sub", {}, "Das eigene Konto lässt sich hier nicht ändern."));
|
||
return el("div.item-menu", {}, blocks);
|
||
}
|
||
|
||
blocks.push(el("div.menu-actions", {},
|
||
user.is_active
|
||
? el("button", {
|
||
type: "button",
|
||
onclick: () => {
|
||
if (!confirm(
|
||
`Konto ${user.email} deaktivieren?\n\n` +
|
||
"Die Person kann sich nicht mehr anmelden. Listen und " +
|
||
"Mitgliedschaften bleiben erhalten."
|
||
)) return;
|
||
guarded(() => post(`/api/admin/users/${user.id}/deactivate`));
|
||
},
|
||
}, "Deaktivieren")
|
||
: el("button", {
|
||
type: "button",
|
||
onclick: () => guarded(() => post(`/api/admin/users/${user.id}/activate`)),
|
||
}, "Reaktivieren")));
|
||
|
||
blocks.push(el("hr.thin", {}));
|
||
blocks.push(el("label", {}, "Konto löschen"));
|
||
blocks.push(el("p.sub", {},
|
||
user.owned_lists
|
||
? `Diesem Konto gehören ${user.owned_lists} Liste(n). Geteilte Listen ` +
|
||
"gehen an das dienstälteste andere Mitglied über, Listen ohne " +
|
||
"weitere Mitglieder werden gelöscht."
|
||
: "Diesem Konto gehören keine Listen."));
|
||
blocks.push(el("button.danger.wide", {
|
||
type: "button",
|
||
onclick: () => {
|
||
const typed = prompt(
|
||
`Konto ${user.email} endgültig löschen?\n\n` +
|
||
"Zur Bestätigung die E-Mail-Adresse eingeben:");
|
||
if (!typed) return;
|
||
guarded(() => post(`/api/admin/users/${user.id}/delete`, {
|
||
lists: "handover",
|
||
confirm_email: typed.trim(),
|
||
}));
|
||
},
|
||
}, "Endgültig löschen"));
|
||
|
||
return el("div.item-menu", {}, blocks);
|
||
}
|
||
|
||
function userRow(user) {
|
||
const facts = [
|
||
user.is_admin ? "Administrator" : null,
|
||
!user.verified ? "noch nicht eingerichtet" : null,
|
||
!user.is_active ? `deaktiviert seit ${formatDate(user.deactivated_at)}` : null,
|
||
user.is_active && user.verified ? `zuletzt ${ago(user.last_seen_at)}` : null,
|
||
user.owned_lists ? `${user.owned_lists} eigene Liste(n)` : null,
|
||
user.memberships ? `${user.memberships} Mitgliedschaft(en)` : null,
|
||
].filter(Boolean);
|
||
|
||
return el("li", {
|
||
className: `admin-user${user.is_active ? "" : " inactive"}`,
|
||
},
|
||
el("button.user-open", {
|
||
type: "button",
|
||
onclick: () => { openId = openId === user.id ? null : user.id; render(); },
|
||
},
|
||
el("span.name", {}, user.display_name || user.email),
|
||
el("span.sub", {},
|
||
user.display_name ? `${user.email} · ` : "",
|
||
facts.join(" · "))),
|
||
openId === user.id ? userActions(user) : null
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Automatik
|
||
// -------------------------------------------------------------------
|
||
|
||
function automationCard() {
|
||
const deactivate = el("input.months", {
|
||
type: "number", min: "0", max: "600",
|
||
value: String(config.auto_deactivate_months),
|
||
});
|
||
const remove = el("input.months", {
|
||
type: "number", min: "0", max: "600",
|
||
value: String(config.auto_delete_months),
|
||
});
|
||
const archive = el("input.months", {
|
||
type: "number", min: "0", max: "600",
|
||
value: String(config.auto_archive_months),
|
||
});
|
||
const archiveDelete = el("input.months", {
|
||
type: "number", min: "0", max: "600",
|
||
value: String(config.archive_delete_months),
|
||
});
|
||
return el("section.card", {},
|
||
el("h2", {}, "Automatische Bereinigung"),
|
||
el("p.lead", {},
|
||
"Läuft einmal täglich. ",
|
||
el("strong", {}, "0 bedeutet abgeschaltet"),
|
||
", nicht „sofort“. Administratorkonten sind ausgenommen."),
|
||
|
||
el("label", {}, "Deaktivieren nach … Monaten ohne Anmeldung"),
|
||
deactivate,
|
||
el("label", {}, "Löschen nach … Monaten Deaktivierung"),
|
||
remove,
|
||
|
||
el("h3.device-heading", {}, "Listen"),
|
||
el("label", {}, "Archivieren nach … Monaten ohne Öffnen"),
|
||
archive,
|
||
el("p.sub", {},
|
||
"Gilt je Person: Die Liste verschwindet nur aus der Übersicht " +
|
||
"derjenigen, die sie nicht geöffnet hat."),
|
||
el("label", {}, "Löschen nach … Monaten im Archiv"),
|
||
archiveDelete,
|
||
el("p.sub", {},
|
||
"Maßgeblich ist allein das Archivdatum des Eigentümers. Er wird " +
|
||
"7 Tage und 1 Tag vorher per E-Mail gewarnt."),
|
||
|
||
el("button.primary", {
|
||
type: "button",
|
||
onclick: () => guarded(async () => {
|
||
await put("/api/admin/settings", {
|
||
auto_deactivate_months: Number(deactivate.value) || 0,
|
||
auto_delete_months: Number(remove.value) || 0,
|
||
auto_archive_months: Number(archive.value) || 0,
|
||
archive_delete_months: Number(archiveDelete.value) || 0,
|
||
});
|
||
say("Einstellungen gespeichert.");
|
||
}),
|
||
}, "Speichern"),
|
||
|
||
el("div.menu-actions", {},
|
||
el("button", {
|
||
type: "button",
|
||
onclick: () => guarded(() => post("/api/admin/cleanup")),
|
||
}, "Jetzt aufräumen"))
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
|
||
function render() {
|
||
mount(root,
|
||
el("header.bar", {},
|
||
el("button.linklike", { type: "button", onclick: back }, "‹ Zurück")),
|
||
|
||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||
|
||
statsCard(),
|
||
el("section.card", {},
|
||
el("h2", {}, "Konten"),
|
||
registrationToggle(),
|
||
el("ul.admin-users", {}, users.map(userRow))),
|
||
createCard(),
|
||
automationCard()
|
||
);
|
||
}
|
||
|
||
await reload();
|
||
render();
|
||
return render;
|
||
}
|