407 lines
14 KiB
JavaScript
407 lines
14 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";
|
||
import { appBar } from "../appbar.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
|
||
// -------------------------------------------------------------------
|
||
|
||
/** Fristen aus dem Server: Beschriftung, Einheit und Grenzen kommen
|
||
* von dort, damit nicht das eine steht und das andere gilt. */
|
||
function durationsCard() {
|
||
const fields = new Map();
|
||
|
||
const group = (title, keys, lead) => {
|
||
const rows = config.durations.filter((d) => keys.includes(d.key));
|
||
if (!rows.length) return null;
|
||
|
||
return el("div.duration-group", {},
|
||
el("h3.device-heading", {}, title),
|
||
lead ? el("p.sub", {}, lead) : null,
|
||
rows.map((entry) => {
|
||
const input = el("input.months", {
|
||
type: "number",
|
||
min: String(entry.minimum),
|
||
max: String(entry.maximum),
|
||
value: String(entry.value),
|
||
title: `${entry.minimum} bis ${entry.maximum} ${entry.unit}`,
|
||
});
|
||
fields.set(entry.key, input);
|
||
|
||
return el("div.duration-row", {},
|
||
el("div.duration-text", {},
|
||
el("span.duration-label", {}, `${entry.label} … ${entry.unit}`),
|
||
el("span.duration-note", {},
|
||
entry.note,
|
||
entry.zero_means ? ` · 0 = ${entry.zero_means}` : "",
|
||
entry.value !== entry.default
|
||
? ` · Vorgabe ${entry.default}` : "")),
|
||
input);
|
||
}));
|
||
};
|
||
|
||
return el("section.card", {},
|
||
el("h2", {}, "Fristen"),
|
||
el("p.lead", {},
|
||
"Diese Werte gelten zur Laufzeit und werden auch für die " +
|
||
"Datenschutzerklärung gebraucht. Der Aufräumlauf setzt sie " +
|
||
"einmal täglich um."),
|
||
|
||
group("Konten",
|
||
["auto_deactivate_months", "auto_delete_months"]),
|
||
group("Listen",
|
||
["auto_archive_months", "archive_delete_months"]),
|
||
group("Aufbewahrung",
|
||
["cleanup_deleted_days", "cleanup_ops_days", "product_cache_days"]),
|
||
group("Sitzungen und Links",
|
||
["session_days", "invite_days", "welcome_days", "verify_hours",
|
||
"reset_hours", "email_change_hours"]),
|
||
|
||
el("button.primary", {
|
||
type: "button",
|
||
onclick: () => guarded(async () => {
|
||
const durations = {};
|
||
for (const [key, input] of fields) {
|
||
durations[key] = Number(input.value) || 0;
|
||
}
|
||
await put("/api/admin/settings", { durations });
|
||
say("Fristen gespeichert.");
|
||
}),
|
||
}, "Speichern"),
|
||
|
||
el("div.menu-actions", {},
|
||
el("button", {
|
||
type: "button",
|
||
onclick: () => guarded(() => post("/api/admin/cleanup")),
|
||
}, "Jetzt aufräumen")),
|
||
|
||
// Was hier nicht einstellbar ist, soll trotzdem sichtbar sein -
|
||
// sonst wirkt die Übersicht vollständig, ohne es zu sein.
|
||
Object.keys(config.fixed || {}).length
|
||
? el("div", {},
|
||
el("h3.device-heading", {}, "Fest eingestellt"),
|
||
el("ul.fixed-list", {},
|
||
Object.entries(config.fixed).map(([label, value]) =>
|
||
el("li", {},
|
||
el("span.name", {}, label),
|
||
el("span.sub", {}, value)))))
|
||
: null
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
|
||
function render() {
|
||
mount(root,
|
||
appBar({
|
||
back: { label: "Einstellungen", onClick: back },
|
||
title: "Benutzerverwaltung",
|
||
}),
|
||
|
||
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(),
|
||
durationsCard()
|
||
);
|
||
}
|
||
|
||
await reload();
|
||
render();
|
||
return render;
|
||
}
|