From ffac9dd2908053cc928eb4473be2b3047eee2a49 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Wed, 22 Jul 2026 07:55:52 +0200 Subject: [PATCH] Add interactive web server administration --- po/bongo-web/de.po | 44 ++++++++++ po/bongo-web/fr.po | 44 ++++++++++ po/bongo-web/pt_BR.po | 44 ++++++++++ src/apps/admin/admin.c | 41 +++++++-- src/www/bongo-web.py.in | 48 ++++++++++- src/www/bongo_web/admin.py | 140 +++++++++++++++++++++++++++++++ src/www/bongo_web/mail_store.py | 6 +- src/www/static/app.css | 21 +++++ src/www/static/app.js | 79 ++++++++++++++++- src/www/static/index.html | 41 ++++++++- src/www/tests/test_admin.py | 59 +++++++++++++ src/www/tests/test_mail_store.py | 9 +- 12 files changed, 566 insertions(+), 10 deletions(-) create mode 100644 src/www/bongo_web/admin.py create mode 100644 src/www/tests/test_admin.py diff --git a/po/bongo-web/de.po b/po/bongo-web/de.po index acdacf8..d29074d 100644 --- a/po/bongo-web/de.po +++ b/po/bongo-web/de.po @@ -370,3 +370,47 @@ msgid "Compact lists" msgstr "Kompakte Listen" msgid "Display settings saved." msgstr "Darstellungseinstellungen gespeichert." +msgid "User accounts" +msgstr "Benutzerkonten" +msgid "Account name" +msgstr "Kontoname" +msgid "Add user" +msgstr "Benutzer hinzufügen" +msgid "New password" +msgstr "Neues Passwort" +msgid "Confirm password" +msgstr "Passwort bestätigen" +msgid "Change password" +msgstr "Passwort ändern" +msgid "Quota" +msgstr "Speicherlimit" +msgid "Set quota" +msgstr "Speicherlimit setzen" +msgid "Services and agents" +msgstr "Dienste und Agents" +msgid "Agent" +msgstr "Agent" +msgid "Priority" +msgstr "Priorität" +msgid "Disabled" +msgstr "Deaktiviert" +msgid "Check configuration" +msgstr "Konfiguration prüfen" +msgid "Apply configuration" +msgstr "Konfiguration anwenden" +msgid "Agent settings are read from the configuration files; apply them after editing." +msgstr "Agent-Einstellungen werden aus den Konfigurationsdateien gelesen; wenden Sie sie nach dem Bearbeiten an." +msgid "LDAP offline cache" +msgstr "LDAP-Offline-Cache" +msgid "Extend all caches by hours" +msgstr "Alle Caches um Stunden verlängern" +msgid "Extend caches" +msgstr "Caches verlängern" +msgid "No LDAP cache information available." +msgstr "Keine LDAP-Cache-Informationen verfügbar." +msgid "Administration action failed." +msgstr "Administrationsaktion fehlgeschlagen." +msgid "Passwords do not match." +msgstr "Die Passwörter stimmen nicht überein." +msgid "Apply the validated configuration files to the running server?" +msgstr "Die geprüften Konfigurationsdateien auf den laufenden Server anwenden?" diff --git a/po/bongo-web/fr.po b/po/bongo-web/fr.po index e02096b..997cb16 100644 --- a/po/bongo-web/fr.po +++ b/po/bongo-web/fr.po @@ -1726,3 +1726,47 @@ msgid "Compact lists" msgstr "Listes compactes" msgid "Display settings saved." msgstr "Paramètres d’affichage enregistrés." +msgid "User accounts" +msgstr "Comptes utilisateurs" +msgid "Account name" +msgstr "Nom du compte" +msgid "Add user" +msgstr "Ajouter un utilisateur" +msgid "New password" +msgstr "Nouveau mot de passe" +msgid "Confirm password" +msgstr "Confirmer le mot de passe" +msgid "Change password" +msgstr "Modifier le mot de passe" +msgid "Quota" +msgstr "Quota" +msgid "Set quota" +msgstr "Définir le quota" +msgid "Services and agents" +msgstr "Services et agents" +msgid "Agent" +msgstr "Agent" +msgid "Priority" +msgstr "Priorité" +msgid "Disabled" +msgstr "Désactivé" +msgid "Check configuration" +msgstr "Vérifier la configuration" +msgid "Apply configuration" +msgstr "Appliquer la configuration" +msgid "Agent settings are read from the configuration files; apply them after editing." +msgstr "Les réglages des agents sont lus depuis les fichiers de configuration ; appliquez-les après modification." +msgid "LDAP offline cache" +msgstr "Cache LDAP hors ligne" +msgid "Extend all caches by hours" +msgstr "Prolonger tous les caches en heures" +msgid "Extend caches" +msgstr "Prolonger les caches" +msgid "No LDAP cache information available." +msgstr "Aucune information de cache LDAP disponible." +msgid "Administration action failed." +msgstr "L’action d’administration a échoué." +msgid "Passwords do not match." +msgstr "Les mots de passe ne correspondent pas." +msgid "Apply the validated configuration files to the running server?" +msgstr "Appliquer les fichiers de configuration validés au serveur en cours d’exécution ?" diff --git a/po/bongo-web/pt_BR.po b/po/bongo-web/pt_BR.po index 728cb69..c75ac0f 100644 --- a/po/bongo-web/pt_BR.po +++ b/po/bongo-web/pt_BR.po @@ -1211,3 +1211,47 @@ msgid "Compact lists" msgstr "Listas compactas" msgid "Display settings saved." msgstr "Configurações de exibição salvas." +msgid "User accounts" +msgstr "Contas de usuário" +msgid "Account name" +msgstr "Nome da conta" +msgid "Add user" +msgstr "Adicionar usuário" +msgid "New password" +msgstr "Nova senha" +msgid "Confirm password" +msgstr "Confirmar senha" +msgid "Change password" +msgstr "Alterar senha" +msgid "Quota" +msgstr "Cota" +msgid "Set quota" +msgstr "Definir cota" +msgid "Services and agents" +msgstr "Serviços e agentes" +msgid "Agent" +msgstr "Agente" +msgid "Priority" +msgstr "Prioridade" +msgid "Disabled" +msgstr "Desativado" +msgid "Check configuration" +msgstr "Verificar configuração" +msgid "Apply configuration" +msgstr "Aplicar configuração" +msgid "Agent settings are read from the configuration files; apply them after editing." +msgstr "As configurações dos agentes são lidas dos arquivos de configuração; aplique-as após editar." +msgid "LDAP offline cache" +msgstr "Cache LDAP offline" +msgid "Extend all caches by hours" +msgstr "Estender todos os caches por horas" +msgid "Extend caches" +msgstr "Estender caches" +msgid "No LDAP cache information available." +msgstr "Nenhuma informação de cache LDAP disponível." +msgid "Administration action failed." +msgstr "A ação administrativa falhou." +msgid "Passwords do not match." +msgstr "As senhas não coincidem." +msgid "Apply the validated configuration files to the running server?" +msgstr "Aplicar os arquivos de configuração validados ao servidor em execução?" diff --git a/src/apps/admin/admin.c b/src/apps/admin/admin.c index e449e62..0cddaf9 100644 --- a/src/apps/admin/admin.c +++ b/src/apps/admin/admin.c @@ -568,12 +568,34 @@ ClearSecret(char *secret) *cursor++ = '\0'; } +static char * +ReadPasswordLine(void) +{ + char *line = NULL; + size_t allocated = 0; + ssize_t length; + + length = getline(&line, &allocated, stdin); + if (length < 0 || length > 4098) { + if (line) { + ClearSecret(line); + free(line); + } + return NULL; + } + while (length > 0 && (line[length - 1] == '\n' || + line[length - 1] == '\r')) + line[--length] = '\0'; + return line; +} + static int -SetUserPassword(const char *username) +SetUserPassword(const char *username, int read_stdin) { char *first_prompt; char *second_prompt; char *first = NULL; + char *second = NULL; int result = 1; if (!ValidCommandName(username) || InitializeAuth() != 0) @@ -582,16 +604,21 @@ SetUserPassword(const char *username) fprintf(stderr, "bongo-admin: user %s does not exist\n", username); return 1; } - first_prompt = getpass("New password: "); + first_prompt = read_stdin ? ReadPasswordLine() : getpass("New password: "); if (!first_prompt) goto done; first = strdup(first_prompt); ClearSecret(first_prompt); + if (read_stdin) + free(first_prompt); if (!first) goto done; - second_prompt = getpass("Confirm password: "); + second_prompt = read_stdin ? ReadPasswordLine() : + getpass("Confirm password: "); if (!second_prompt) goto done; + if (read_stdin) + second = second_prompt; if (strlen(first) < 12) { fprintf(stderr, "bongo-admin: password must contain at least 12 characters\n"); } else if (strcmp(first, second_prompt) != 0) { @@ -608,6 +635,8 @@ SetUserPassword(const char *username) } ClearSecret(second_prompt); done: + if (second) + free(second); if (first) { ClearSecret(first); free(first); @@ -971,6 +1000,8 @@ main(int argc, char **argv) return AccessConfiguration(argv[2], 0); if (argc == 3 && !strcmp(argv[1], "__config-replace")) return AccessConfiguration(argv[2], 1); + if (argc == 4 && !strcmp(argv[1], "__user-password-stdin")) + return SetUserPassword(argv[3], 1); if (argc == 3 && !strcmp(argv[1], "diagnose") && !strcmp(argv[2], "store")) return DiagnoseStore(); @@ -996,7 +1027,7 @@ main(int argc, char **argv) return ListUsers(); if (argc == 4 && !strcmp(argv[1], "user") && (!strcmp(argv[2], "password") || !strcmp(argv[2], "passwd"))) - return SetUserPassword(argv[3]); + return SetUserPassword(argv[3], 0); if ((argc == 4 || argc == 5) && !strcmp(argv[1], "user") && !strcmp(argv[2], "quota")) return UserQuota(argv[3], argc == 5 ? argv[4] : NULL); @@ -1011,7 +1042,7 @@ main(int argc, char **argv) return ListUsers(); if (argc == 3 && (!strcmp(argv[1], "user-passwd") || !strcmp(argv[1], "up"))) - return SetUserPassword(argv[2]); + return SetUserPassword(argv[2], 0); if (argc == 3 && !strcmp(argv[1], "agent") && !strcmp(argv[2], "list")) return ShowAgents(NULL); diff --git a/src/www/bongo-web.py.in b/src/www/bongo-web.py.in index 644b1d8..63beda7 100644 --- a/src/www/bongo-web.py.in +++ b/src/www/bongo-web.py.in @@ -77,6 +77,9 @@ from bongo_web.external_accounts import (create_account as create_external_accou list_accounts as list_external_accounts, list_identities as list_external_identities) from bongo_web.filters import (delete_filter, list_filters, save_filter) +from bongo_web.admin import (ADMIN_COMMAND, AdminCommandError, + perform as perform_admin, + snapshot as admin_snapshot) from bongo_web.security import (SECOND_FACTOR_VALID, ExpiringSecrets, disable_totp, enable_totp, hosted_login_username, @@ -182,7 +185,15 @@ UI_MESSAGES = ("Email address", "Password", "Sign in", "Sign out", "Mail", "This account is not a server administrator.", "Server status could not be loaded.", "Account", "Host", "Domains", "Version", - "Use the settings gear for personal settings and the task indicator for pending server work.") + "User accounts", "Account name", "Add user", "New password", + "Confirm password", "Change password", "Quota", "Set quota", + "Services and agents", "Agent", "Priority", "Enabled", + "Disabled", "Check configuration", "Apply configuration", + "Agent settings are read from the configuration files; apply them after editing.", + "LDAP offline cache", "Extend all caches by hours", + "Extend caches", "No LDAP cache information available.", + "Administration action failed.", "Passwords do not match.", + "Apply the validated configuration files to the running server?") SUPPORTED_LANGUAGES = ("en", "de", "fr", "pt_BR") ASSET_PATH = re.compile(r"^/assets/(css|img)/([A-Za-z0-9._-]+)$") ASSET_TYPES = {".css": "text/css; charset=utf-8", ".gif": "image/gif", @@ -219,6 +230,9 @@ class BongoWebServer(ThreadingHTTPServer): raise ValueError("administrators must be a non-empty string array") self.administrators = {item.strip().casefold() for item in administrators} + self.admin_command = str(config.get("admin_command", ADMIN_COMMAND)) + if not self.admin_command.startswith("/"): + raise ValueError("admin_command must be an absolute path") if initialize: self.initialize_application() @@ -657,11 +671,13 @@ class BongoWebHandler(BaseHTTPRequestHandler): {"error": "administrator role required"}) else: base = self.server.config.get("public_base_url", "") + details = admin_snapshot(self.server.admin_command) self._json(HTTPStatus.OK, { "user": session["user"], "host": urlsplit(base).hostname or socket.getfqdn(), "domains": self.server.config.get("login_domains", []), "version": "@BONGO_V_STR@", + **details, }, {"Vary": "Cookie"}) elif path.startswith("/api/mail/"): self._mail_api(path) @@ -846,6 +862,9 @@ class BongoWebHandler(BaseHTTPRequestHandler): def do_POST(self): path = self._path() + if path == "/api/admin/action": + self._admin_action() + return if path == "/api/tasks/state": self._task_state() return @@ -1013,6 +1032,33 @@ class BongoWebHandler(BaseHTTPRequestHandler): token, lifetime, self._secure_cookies()), "Vary": "Cookie"}) + def _admin_action(self): + session = self._security_session() + if session is None: + return + if session["user"].casefold() not in self.server.administrators: + self._json(HTTPStatus.FORBIDDEN, + {"error": "administrator role required"}) + return + try: + data = self._request_data() + action = str(data.get("action", "")) + result = perform_admin(action, data, self.server.admin_command) + except (TypeError, ValueError, OverflowError, UnicodeError, + json.JSONDecodeError): + self._json(HTTPStatus.BAD_REQUEST, + {"error": "invalid administration action"}) + return + except AdminCommandError as error: + logging.warning("Administration action failed: user=%s action=%s: %s", + session["user"], action, error) + self._json(HTTPStatus.CONFLICT, {"error": str(error)}) + return + logging.info("Administration action completed: user=%s action=%s", + session["user"], action) + self._json(HTTPStatus.OK, {"message": result or "OK"}, + {"Vary": "Cookie"}) + def _security_session(self): session = self._session() if session is None: diff --git a/src/www/bongo_web/admin.py b/src/www/bongo_web/admin.py new file mode 100644 index 0000000..4132eae --- /dev/null +++ b/src/www/bongo_web/admin.py @@ -0,0 +1,140 @@ +# /**************************************************************************** +# * +# * Copyright (c) 2001 Novell, Inc. All Rights Reserved. +# * +# * This program is free software; you can redistribute it and/or +# * modify it under the terms of version 2 of the GNU General Public License +# * as published by the Free Software Foundation. +# * +# * This program is distributed in the hope that it will be useful, +# * but WITHOUT ANY WARRANTY; without even the implied warranty of +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# * GNU General Public License for more details. +# * +# * You should have received a copy of the GNU General Public License +# * along with this program; if not, contact Novell, Inc. +# * +# * To contact Novell about this file by physical or electronic mail, you +# * may find current contact information at www.novell.com. +# * +# ****************************************************************************/ + +"""Restricted bridge between web administration and ``bongo-admin``. + +The web service never invokes a shell. Every supported operation is selected +from a fixed command table and secrets are supplied through stdin rather than +the process list. +""" + +from __future__ import annotations + +import re +import subprocess + + +ADMIN_COMMAND = "/usr/bin/bongo-admin" +ACCOUNT = re.compile(r"^[^\s/\\]{1,255}$") +QUOTA = re.compile(r"^(?:unlimited|none|[0-9]+(?:[KMGTPE](?:i?[Bb])?)?)$", + re.IGNORECASE) +AGENT = re.compile(r"^(.*?)\s+(enabled|disabled) \(priority (-?[0-9]+)\)$") + + +class AdminCommandError(RuntimeError): + pass + + +def _command(program, *arguments, input_text=None, timeout=15): + if not isinstance(program, str) or not program.startswith("/"): + raise AdminCommandError("invalid administration command") + try: + completed = subprocess.run( + [program, *arguments], input=input_text, capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout, + check=False) + except (OSError, subprocess.SubprocessError) as error: + raise AdminCommandError("administration command unavailable") from error + output = completed.stdout.strip() + error = completed.stderr.strip() + if completed.returncode != 0: + raise AdminCommandError(error or output or "administration command failed") + return output + + +def _account(value): + value = str(value).strip() + if not ACCOUNT.fullmatch(value): + raise ValueError("invalid account name") + return value + + +def _key_values(output): + result = {} + for line in output.splitlines(): + name, separator, value = line.partition(":") + if separator: + result[name.strip().casefold().replace(" ", "_")] = value.strip() + return result + + +def snapshot(program=ADMIN_COMMAND): + """Return independently collected status so one failed panel stays usable.""" + result = {"users": [], "agents": [], "server": {}, "ldap_cache": "", + "errors": []} + commands = (("users", ("user", "list")), + ("agents", ("agent", "list")), + ("server", ("server", "info")), + ("ldap_cache", ("ldap-cache", "status"))) + for name, arguments in commands: + try: + output = _command(program, *arguments) + if name == "users": + result[name] = [line.strip() for line in output.splitlines() + if line.strip()] + elif name == "agents": + agents = [] + for line in output.splitlines(): + match = AGENT.fullmatch(line.strip()) + if match: + agents.append({"name": match.group(1).strip(), + "enabled": match.group(2) == "enabled", + "priority": int(match.group(3))}) + result[name] = agents + elif name == "server": + result[name] = _key_values(output) + else: + result[name] = output + except AdminCommandError as error: + result["errors"].append({"section": name, "message": str(error)}) + return result + + +def perform(action, values, program=ADMIN_COMMAND): + """Perform one explicitly supported administrative mutation.""" + if action == "user_add": + return _command(program, "user", "add", _account(values.get("user", ""))) + if action == "user_password": + user = _account(values.get("user", "")) + password = str(values.get("password", "")) + if not 12 <= len(password) <= 4096 or "\n" in password or "\r" in password: + raise ValueError("invalid password") + return _command(program, "__user-password-stdin", user, + input_text=password + "\n" + password + "\n") + if action == "user_quota": + user = _account(values.get("user", "")) + quota = str(values.get("quota", "")).strip() + if not QUOTA.fullmatch(quota): + raise ValueError("invalid quota") + return _command(program, "user", "quota", user, quota) + if action == "ldap_extend_all": + try: + hours = int(values.get("hours", 48)) + except (TypeError, ValueError) as error: + raise ValueError("invalid cache lifetime") from error + if not 1 <= hours <= 744: + raise ValueError("invalid cache lifetime") + return _command(program, "ldap-cache", "extend-all", str(hours)) + if action == "config_check": + return _command(program, "config", "check", timeout=30) + if action == "config_sync": + return _command(program, "config", "sync", timeout=30) + raise ValueError("unsupported administration action") diff --git a/src/www/bongo_web/mail_store.py b/src/www/bongo_web/mail_store.py index c481c8d..f72ef28 100644 --- a/src/www/bongo_web/mail_store.py +++ b/src/www/bongo_web/mail_store.py @@ -189,8 +189,12 @@ def read_conversation(user, store_cookie, conversation_id, client = None try: client = client_factory(user, store_cookie) + # CONVERSATION is a streamed NMAP command. Consume its terminating + # response before issuing READ commands on the same connection; + # otherwise READ sees the preceding command's final ``1000 OK``. + items = list(client.Conversation(conversation_id)) output = [] - for item in client.Conversation(conversation_id): + for item in items: if int(item.type) != 0x0002: continue content = _message_content(client.Read(item.uid), sanitizer) diff --git a/src/www/static/app.css b/src/www/static/app.css index a8442fe..dc48b4e 100644 --- a/src/www/static/app.css +++ b/src/www/static/app.css @@ -94,6 +94,24 @@ header { grid-column: 1 / -1; display: flex; align-items: center; min-height: 68 .recovery-codes li { margin: .25rem 0; break-inside: avoid; } .recovery-actions { display: flex; align-items: center; gap: .7rem; } .security-status { font-weight: bold; } +.admin-view > h2 { color: #1d3980; } +#admin-summary { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .25rem .8rem; max-width: 50rem; } +#admin-summary dt { font-weight: bold; } +#admin-summary dd { margin: 0; overflow-wrap: anywhere; } +.admin-result { padding: .55rem .7rem; white-space: pre-wrap; border: 1px solid #86a579; background: #eff8ec; } +.admin-result.error { border-color: #c47b7b; background: #fff0f0; } +#admin-panels { display: grid; grid-template-columns: repeat(2, minmax(18rem, 1fr)); gap: 1rem; } +.admin-panel { min-width: 0; padding: .8rem; border: 1px solid #afbdd2; background: #f7f9fc; } +.admin-panel h3 { margin: 0 0 .7rem; color: #1d3980; border-bottom: 1px solid #ccd5e0; } +.admin-panel:first-child { grid-row: span 2; } +.admin-user-list { max-height: 12rem; margin: 0 0 .8rem; padding: .4rem .4rem .4rem 1.8rem; overflow: auto; border: 1px solid #ccd5e0; background: #fff; } +.admin-form { display: grid; grid-template-columns: repeat(2, minmax(10rem, 1fr)) auto; gap: .5rem; align-items: end; margin-top: .65rem; } +.admin-form label { display: flex; flex-direction: column; gap: .2rem; } +.admin-form button { min-height: 2rem; } +.admin-agent-table { width: 100%; border-collapse: collapse; background: #fff; } +.admin-agent-table th, .admin-agent-table td { padding: .3rem .45rem; text-align: left; border: 1px solid #ccd5e0; } +.admin-actions { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .7rem; } +#admin-ldap-cache { max-width: 100%; max-height: 14rem; overflow: auto; padding: .5rem; border: 1px solid #ccd5e0; background: #fff; } .quiet { margin-left: 1rem; padding: .25rem .6rem; color: #e4eaf4; border: 1px solid #7a9ac9; background: transparent; } #section-sidebar { align-self: start; } #sidebar-nav { min-width: 120px; margin: 12px 0 0 12px; padding: .4rem 0 1rem; background: #fff; border: 1px solid #5f85bf; border-width: 1px 0 1px 1px; } @@ -156,4 +174,7 @@ select { padding: .25rem; border: 1px solid #7f8da5; background: #fff; } .organizer-form .wide { grid-column: 1; } .totp-enrollment { grid-template-columns: 1fr; } .recovery-codes { columns: 1; } + #admin-panels { grid-template-columns: 1fr; } + .admin-panel:first-child { grid-row: auto; } + .admin-form { grid-template-columns: 1fr; } } diff --git a/src/www/static/app.js b/src/www/static/app.js index c37ef81..01fd30a 100644 --- a/src/www/static/app.js +++ b/src/www/static/app.js @@ -106,12 +106,14 @@ async function loadAdminStatus() { const response = await fetch("/api/admin/status", {credentials: "same-origin"}); const status = document.querySelector("#admin-status"); const summary = document.querySelector("#admin-summary"); + const panels = document.querySelector("#admin-panels"); if (response.status === 403) { status.textContent = tr("This account is not a server administrator."); summary.replaceChildren(); + panels.hidden = true; return; } - if (!response.ok) { status.textContent = tr("Server status could not be loaded."); return; } + if (!response.ok) { status.textContent = tr("Server status could not be loaded."); panels.hidden = true; return; } const result = await response.json(); status.textContent = tr("Signed in as server administrator."); const entries = [[tr("Account"), result.user], [tr("Host"), result.host || ""], @@ -121,6 +123,54 @@ async function loadAdminStatus() { const definition = document.createElement("dd"); definition.textContent = value; return [term, definition]; })); + const users = result.users || []; + document.querySelector("#admin-user-list").replaceChildren(...users.map(user => { + const item = document.createElement("li"); item.textContent = user; return item; + })); + document.querySelectorAll("#admin-user-password select, #admin-user-quota select").forEach(select => { + const selected = select.value; + select.replaceChildren(...users.map(user => { + const option = document.createElement("option"); option.value = user; option.textContent = user; return option; + })); + if (users.includes(selected)) select.value = selected; + }); + document.querySelector("#admin-agent-list").replaceChildren(...(result.agents || []).map(agent => { + const row = document.createElement("tr"); + for (const value of [agent.name, agent.enabled ? tr("Enabled") : tr("Disabled"), agent.priority]) { + const cell = document.createElement("td"); cell.textContent = String(value); row.append(cell); + } + return row; + })); + document.querySelector("#admin-ldap-cache").textContent = result.ldap_cache || tr("No LDAP cache information available."); + if ((result.errors || []).length) { + showAdminResult(result.errors.map(error => `${error.section}: ${error.message}`).join("\n"), true); + } + panels.hidden = false; +} + +function showAdminResult(message, failed = false) { + const result = document.querySelector("#admin-result"); + result.textContent = message; + result.classList.toggle("error", failed); + result.hidden = !message; +} + +async function adminAction(action, values = {}) { + showAdminResult(""); + const response = await fetch("/api/admin/action", { + method: "POST", credentials: "same-origin", + headers: {"Content-Type": "application/json", "X-Bongo-CSRF": session.csrf_token}, + body: JSON.stringify({action, ...values}) + }); + let result = {}; + try { result = await response.json(); } catch (_) { result = {}; } + if (!response.ok) { + showAdminResult(result.error || tr("Administration action failed."), true); + return false; + } + showAdminResult(result.message || tr("Saved.")); + await loadAdminStatus(); + return true; } function organizerInputValue(value) { @@ -1063,6 +1113,33 @@ document.querySelector("#contacts-refresh").addEventListener("click", loadContac document.querySelector("#contact-form").addEventListener("submit", saveContact); document.querySelector("#contact-delete").addEventListener("click", deleteContact); document.querySelector("#contact-cancel").addEventListener("click", () => { document.querySelector("#contact-form").hidden = true; }); +document.querySelector("#admin-user-add").addEventListener("submit", async event => { + event.preventDefault(); + const form = event.currentTarget; + if (await adminAction("user_add", {user: form.user.value})) form.reset(); +}); +document.querySelector("#admin-user-password").addEventListener("submit", async event => { + event.preventDefault(); + const form = event.currentTarget; + if (form.password.value !== form.confirmation.value) { + showAdminResult(tr("Passwords do not match."), true); return; + } + if (await adminAction("user_password", {user: form.user.value, password: form.password.value})) form.reset(); +}); +document.querySelector("#admin-user-quota").addEventListener("submit", event => { + event.preventDefault(); + const form = event.currentTarget; + adminAction("user_quota", {user: form.user.value, quota: form.quota.value}); +}); +document.querySelector("#admin-ldap-extend").addEventListener("submit", event => { + event.preventDefault(); + adminAction("ldap_extend_all", {hours: event.currentTarget.hours.value}); +}); +document.querySelector("#admin-config-check").addEventListener("click", () => adminAction("config_check")); +document.querySelector("#admin-config-sync").addEventListener("click", () => { + if (window.confirm(tr("Apply the validated configuration files to the running server?"))) adminAction("config_sync"); +}); +document.querySelector("#admin-refresh").addEventListener("click", loadAdminStatus); document.querySelector("#message-back").addEventListener("click", () => { openConversation = null; render(); }); document.querySelector("#load-remote-images").addEventListener("click", () => renderConversation(true)); document.querySelector("#compose-new").addEventListener("click", () => { diff --git a/src/www/static/index.html b/src/www/static/index.html index 8608aa2..7ae9724 100644 --- a/src/www/static/index.html +++ b/src/www/static/index.html @@ -171,7 +171,46 @@

Server administration

-

Use the settings gear for personal settings and the task indicator for pending server work.

+ +