Erste Produktivversion
This commit is contained in:
347
web/html/js/views/admin.js
Normal file
347
web/html/js/views/admin.js
Normal file
@@ -0,0 +1,347 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user