Files
einkaufsapp/web/html/js/views/admin.js
2026-08-08 20:31:58 +02:00

348 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
);
}
// -------------------------------------------------------------------
// 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 selfReg = el("input", {
type: "checkbox",
checked: config.allow_self_registration,
disabled: config.locked_by_env,
});
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("label.checkline", {}, selfReg,
el("span", {}, "Selbstregistrierung erlauben")),
config.locked_by_env
? el("p.sub", {},
"Über die Umgebungsvariable ALLOW_SELF_REGISTRATION festgelegt " +
"und hier nicht änderbar.")
: null,
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,
...(config.locked_by_env
? {}
: { allow_self_registration: selfReg.checked }),
});
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"),
el("ul.admin-users", {}, users.map(userRow))),
createCard(),
automationCard()
);
}
await reload();
render();
return render;
}