Files
einkaufsapp/web/html/js/views/admin.js

407 lines
14 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";
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;
}