diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 11f3ae3..157b1ed 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.4.1" +version = "2.5.0" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/backup.py b/apps/tesm/src/tesm/blueprints/backup.py index 513953c..e514551 100644 --- a/apps/tesm/src/tesm/blueprints/backup.py +++ b/apps/tesm/src/tesm/blueprints/backup.py @@ -13,7 +13,7 @@ from tesm_core.licensing_client import LicenseError from ..services import transfer -bp = Blueprint("backup", __name__, url_prefix="/sicherung") +bp = Blueprint("backup", __name__, url_prefix="/administration/backup") MAX_IMPORT_BYTES = 64 * 1024 * 1024 PREVIEW_SESSION_KEY = "_import_token" @@ -129,7 +129,7 @@ def export(): ) -@bp.post("/import/vorschau") +@bp.post("/import/preview") @permission_required("backup.execute") @reauth_required def import_preview(): @@ -161,7 +161,7 @@ def import_preview(): return redirect(url_for("backup.overview")) -@bp.post("/import/uebernehmen") +@bp.post("/import/apply") @permission_required("backup.execute") @reauth_required def import_apply(): @@ -225,7 +225,7 @@ def import_apply(): return redirect(url_for("backup.overview")) -@bp.post("/import/verwerfen") +@bp.post("/import/discard") @permission_required("backup.execute") def import_discard(): token = session.pop(PREVIEW_SESSION_KEY, "") diff --git a/apps/tesm/src/tesm/blueprints/credentials.py b/apps/tesm/src/tesm/blueprints/credentials.py index 2b11ede..71ec995 100644 --- a/apps/tesm/src/tesm/blueprints/credentials.py +++ b/apps/tesm/src/tesm/blueprints/credentials.py @@ -12,7 +12,7 @@ from tesm_core.extension import core, db, transaction from ..services import inventory -bp = Blueprint("credentials", __name__, url_prefix="/zugangsdaten") +bp = Blueprint("credentials", __name__, url_prefix="/monitoring/credentials") def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: @@ -39,7 +39,7 @@ def overview(): ) -@bp.post("/neu") +@bp.post("/new") @permission_required("credentials.create") def create(): try: @@ -91,7 +91,7 @@ def update(credential_id: int): return redirect(url_for("credentials.overview")) -@bp.post("//loeschen") +@bp.post("//delete") @permission_required("credentials.delete") def delete(credential_id: int): try: @@ -109,7 +109,7 @@ def delete(credential_id: int): return redirect(url_for("credentials.overview")) -@bp.post("//anzeigen") +@bp.post("//reveal") @permission_required("credentials.secrets") # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS). def reveal(credential_id: int): diff --git a/apps/tesm/src/tesm/blueprints/dashboard.py b/apps/tesm/src/tesm/blueprints/dashboard.py index e04dd35..8f76919 100644 --- a/apps/tesm/src/tesm/blueprints/dashboard.py +++ b/apps/tesm/src/tesm/blueprints/dashboard.py @@ -121,7 +121,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]: } -@bp.get("/uebersicht") +@bp.get("/overview") def index(): from tesm_core.auth.models import current_user @@ -138,7 +138,7 @@ def index_view(): return index() -@bp.get("/uebersicht/kacheln") +@bp.get("/overview/tiles") @fragment def tiles(): """Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment.""" diff --git a/apps/tesm/src/tesm/blueprints/devices.py b/apps/tesm/src/tesm/blueprints/devices.py index c16a727..968d2ec 100644 --- a/apps/tesm/src/tesm/blueprints/devices.py +++ b/apps/tesm/src/tesm/blueprints/devices.py @@ -20,7 +20,7 @@ from tesm_core.extension import core, db, transaction from ..services import inventory, monitor, restart, ssh -bp = Blueprint("devices", __name__, url_prefix="/clients") +bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients") def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: @@ -113,7 +113,7 @@ def _credential_from_form(conn: Any) -> int | None: ) -@bp.post("/neu") +@bp.post("/new") @permission_required("devices.create") def create(): try: @@ -161,7 +161,7 @@ def update(device_id: int): return redirect(url_for("devices.overview")) -@bp.post("//aktiv") +@bp.post("//active") @permission_required("devices.edit") def toggle_active(device_id: int): active = request.form.get("active") == "1" @@ -180,7 +180,7 @@ def toggle_active(device_id: int): return redirect(request.referrer or url_for("devices.overview")) -@bp.post("//loeschen") +@bp.post("//delete") @permission_required("devices.delete") def delete(device_id: int): with transaction() as conn: @@ -193,7 +193,7 @@ def delete(device_id: int): return redirect(url_for("devices.overview")) -@bp.post("//neustart") +@bp.post("//restart") @permission_required("devices.execute") def restart_device(device_id: int): """Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde. @@ -351,7 +351,7 @@ def detail(device_id: int): categories=inventory.DEVICE_CATEGORIES, host_keys=host_keys, method_labels=monitor.METHOD_LABELS, - terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")), + terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")), ) @@ -360,7 +360,7 @@ def detail(device_id: int): # --------------------------------------------------------------------------- # -@bp.post("//hostkey/pruefen") +@bp.post("//host-key/probe") @permission_required("devices.edit") def probe_host_key(device_id: int): """Liest den Host-Schluessel des Clients, ohne sich anzumelden. @@ -390,7 +390,7 @@ def probe_host_key(device_id: int): ) -@bp.post("//hostkey/freigeben") +@bp.post("//host-key/trust") @permission_required("devices.edit") @reauth_required def trust_host_key(device_id: int): @@ -424,7 +424,7 @@ def trust_host_key(device_id: int): return redirect(url_for("devices.detail", device_id=device_id)) -@bp.post("//hostkey/verwerfen") +@bp.post("//host-key/forget") @permission_required("devices.edit") def forget_host_key(device_id: int): conn = db() diff --git a/apps/tesm/src/tesm/blueprints/dhcp.py b/apps/tesm/src/tesm/blueprints/dhcp.py index abf86a3..4ac754d 100644 --- a/apps/tesm/src/tesm/blueprints/dhcp.py +++ b/apps/tesm/src/tesm/blueprints/dhcp.py @@ -105,7 +105,7 @@ def overview(): # --------------------------------------------------------------------------- # -@bp.post("/einstellungen") +@bp.post("/settings") @permission_required("dhcp.edit") def save_settings(): extension = core() @@ -122,7 +122,7 @@ def save_settings(): return _write_and_redirect() -@bp.post("/subnetz") +@bp.post("/subnet") @permission_required("dhcp.edit") def add_subnet(): extension = core() @@ -158,7 +158,7 @@ def add_subnet(): return _write_and_redirect() -@bp.post("/subnetz/") +@bp.post("/subnet/") @permission_required("dhcp.edit") def update_subnet(subnet_id: int): extension = core() @@ -194,7 +194,7 @@ def update_subnet(subnet_id: int): return _write_and_redirect() -@bp.post("/subnetz//loeschen") +@bp.post("/subnet//delete") @permission_required("dhcp.edit") def delete_subnet(subnet_id: int): with transaction() as conn: @@ -209,7 +209,7 @@ def delete_subnet(subnet_id: int): # --------------------------------------------------------------------------- # -@bp.post("/option/wert") +@bp.post("/option/value") @permission_required("dhcp.edit") def set_option_value(): conn = db() @@ -248,7 +248,7 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign return gruppen -@bp.post("/option/geraet") +@bp.post("/option/device") @permission_required("dhcp.edit") def set_device_option_value(): """Setzt eine Option fuer ein einzelnes Geraet. @@ -296,7 +296,7 @@ def set_device_option_value(): return _write_and_redirect() -@bp.post("/option/neu") +@bp.post("/option/new") @permission_required("dhcp.edit") def add_custom_option(): conn = db() @@ -327,7 +327,7 @@ def add_custom_option(): return redirect(url_for("dhcp.overview")) -@bp.post("/option//loeschen") +@bp.post("/option//delete") @permission_required("dhcp.edit") def delete_custom_option(option_id: int): conn = db() @@ -352,7 +352,7 @@ def delete_custom_option(option_id: int): # --------------------------------------------------------------------------- # -@bp.post("/reservierung") +@bp.post("/reservation") @permission_required("dhcp.edit") def add_reservation(): conn = db() @@ -412,7 +412,7 @@ def add_reservation(): return _write_and_redirect() -@bp.post("/reservierung//loeschen") +@bp.post("/reservation//delete") @permission_required("dhcp.edit") def delete_reservation(reservation_id: int): with transaction() as conn: @@ -480,13 +480,13 @@ def _write_and_redirect(): return redirect(url_for("dhcp.overview")) -@bp.post("/schreiben") +@bp.post("/write") @permission_required("dhcp.edit") def write_config(): return _write_and_redirect() -@bp.post("/dienst/") +@bp.post("/service/") @permission_required("dhcp.execute") def service_action(action: str): allowed = {"install", "start", "stop"} diff --git a/apps/tesm/src/tesm/blueprints/fileshare.py b/apps/tesm/src/tesm/blueprints/fileshare.py index 0b44f53..51f4762 100644 --- a/apps/tesm/src/tesm/blueprints/fileshare.py +++ b/apps/tesm/src/tesm/blueprints/fileshare.py @@ -23,7 +23,7 @@ from tesm_core.extension import core, db, transaction from ..services import fileshare as service -bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben") +bp = Blueprint("fileshare", __name__, url_prefix="/fileshares") @bp.before_request @@ -60,8 +60,8 @@ def _mount_or_404(token: str) -> service.Mount: def browse(): conn = db() mounts = service.active_mounts(conn, current_user.id) - token = request.args.get("freigabe") or (mounts[0].token if mounts else "") - relative = request.args.get("pfad") or "" + token = request.args.get("share") or (mounts[0].token if mounts else "") + relative = request.args.get("path") or "" listing = None error = "" if token: @@ -85,14 +85,14 @@ def browse(): ) -@bp.get("//herunterladen") +@bp.get("//download") # Herunterladen traegt Daten aus dem System heraus -- in der Rechtematrix ist # das "Exportieren", nicht "Ansehen". Eine Vorschau bleibt im Haus, eine # heruntergeladene Datei nicht. @permission_required("fileshare.export") def download(token: str): mount = _mount_or_404(token) - relative = request.args.get("pfad") or "" + relative = request.args.get("path") or "" try: path = service.resolve_path(mount, relative) except service.FileshareError as exc: @@ -113,11 +113,11 @@ def download(token: str): ) -@bp.get("//vorschau") +@bp.get("//preview") @permission_required("fileshare.view") def preview(token: str): mount = _mount_or_404(token) - relative = request.args.get("pfad") or "" + relative = request.args.get("path") or "" try: path = service.resolve_path(mount, relative) mimetype = service.preview_mime(path.name) @@ -152,15 +152,15 @@ def preview(token: str): ) -@bp.post("//hochladen") +@bp.post("//upload") @permission_required("fileshare.create") def upload(token: str): mount = _mount_or_404(token) - relative = request.form.get("pfad") or "" + relative = request.form.get("path") or "" files = request.files.getlist("files") if not files: flash("Keine Datei ausgewaehlt.", "danger") - return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) + return redirect(url_for("fileshare.browse", share=token, path=relative)) limit = uploads.limit_bytes(db()) stored = 0 @@ -178,15 +178,15 @@ def upload(token: str): stored += 1 except service.FileshareError as exc: flash(str(exc), "danger") - return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) + return redirect(url_for("fileshare.browse", share=token, path=relative)) if stored: _audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)") flash(f"{stored} Datei(en) hochgeladen.", "success") - return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) + return redirect(url_for("fileshare.browse", share=token, path=relative)) -@bp.post("//formular") +@bp.post("//form") @permission_required("fileshare_pdf.create") def save_form(token: str): """Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab. @@ -196,7 +196,7 @@ def save_form(token: str): die alte Datei danach loeschen -- das ist eine bewusste Handlung. """ mount = _mount_or_404(token) - relative = request.form.get("pfad") or "" + relative = request.form.get("path") or "" upload_file = request.files.get("datei") if upload_file is None or not upload_file.filename: return {"error": "Es kam keine Datei an."}, 400 @@ -259,47 +259,47 @@ def save_form(token: str): wohin = ziel.relative_to(mount.path.resolve()).as_posix() _audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice") - return {"message": f"Gespeichert als {wohin}.", "pfad": wohin} + return {"message": f"Gespeichert als {wohin}.", "path": wohin} -@bp.post("//ordner") +@bp.post("//folder") @permission_required("fileshare.create") def make_directory(token: str): mount = _mount_or_404(token) - relative = request.form.get("pfad") or "" + relative = request.form.get("path") or "" try: service.make_directory(mount, relative, request.form.get("name", "")) except (service.FileshareError, OSError) as exc: flash(str(exc), "danger") - return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) + return redirect(url_for("fileshare.browse", share=token, path=relative)) _audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}") flash("Ordner angelegt.", "success") - return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) + return redirect(url_for("fileshare.browse", share=token, path=relative)) -@bp.post("//umbenennen") +@bp.post("//rename") @permission_required("fileshare.edit") def rename(token: str): mount = _mount_or_404(token) - relative = request.form.get("pfad") or "" + relative = request.form.get("path") or "" parent = request.form.get("parent") or "" try: service.rename_entry(mount, relative, request.form.get("name", "")) except (service.FileshareError, OSError) as exc: flash(str(exc), "danger") - return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) + return redirect(url_for("fileshare.browse", share=token, path=parent)) _audit("fileshare.renamed", f"{mount.label}:{relative}") flash("Umbenannt.", "success") - return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) + return redirect(url_for("fileshare.browse", share=token, path=parent)) -@bp.post("//loeschen") +@bp.post("//delete") @permission_required("fileshare.delete") def delete(token: str): mount = _mount_or_404(token) parent = request.form.get("parent") or "" removed = 0 - for relative in request.form.getlist("pfade"): + for relative in request.form.getlist("paths"): try: kind = service.delete_entry(mount, relative) _audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning") @@ -308,10 +308,10 @@ def delete(token: str): flash(str(exc), "danger") if removed: flash(f"{removed} Eintrag/Eintraege geloescht.", "success") - return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) + return redirect(url_for("fileshare.browse", share=token, path=parent)) -@bp.post("//freigeben") +@bp.post("//release") @permission_required("fileshare.view") def release(token: str): mount = _mount_or_404(token) @@ -327,7 +327,7 @@ def release(token: str): # --------------------------------------------------------------------------- # -@bp.get("/zuordnungen") +@bp.get("/mappings") @permission_required("fileshare.edit") def mappings(): from tesm_core.auth import directory @@ -341,7 +341,7 @@ def mappings(): ) -@bp.post("/zuordnungen") +@bp.post("/mappings") @permission_required("fileshare.edit") def mapping_add(): try: @@ -353,18 +353,33 @@ def mapping_add(): label=request.form.get("label", ""), unc=request.form.get("unc", ""), ) - _audit("fileshare.mapping_added", request.form.get("label", ""), severity="notice") + _audit( + "fileshare.mapping_added", + request.form.get("label", ""), + service.describe_mapping( + { + "unc": request.form.get("unc", ""), + "ad_group_name": request.form.get("ad_group_name", ""), + "ad_group_dn": request.form.get("ad_group_dn", ""), + } + ), + severity="notice", + ) flash("Zuordnung gespeichert.", "success") except service.FileshareError as exc: flash(str(exc), "danger") return redirect(url_for("fileshare.mappings")) -@bp.post("/zuordnungen/") +@bp.post("/mappings/") @permission_required("fileshare.edit") def mapping_update(mapping_id: int): try: with transaction() as conn: + # Den Stand vor der Aenderung merken -- danach ist er weg, und + # "geaendert" ohne "was" beantwortet keine einzige Frage, die man + # spaeter an ein Protokoll stellt. + vorher = service.get_mapping(conn, mapping_id) service.update_mapping( conn, mapping_id, @@ -373,18 +388,32 @@ def mapping_update(mapping_id: int): label=request.form.get("label", ""), unc=request.form.get("unc", ""), ) - _audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice") + nachher = service.get_mapping(conn, mapping_id) + _audit( + "fileshare.mapping_updated", + str((nachher or {}).get("label") or request.form.get("label", "")), + service.mapping_changes(vorher, nachher) or "keine inhaltliche Aenderung", + severity="notice", + ) flash("Zuordnung geaendert.", "success") except service.FileshareError as exc: flash(str(exc), "danger") return redirect(url_for("fileshare.mappings")) -@bp.post("/zuordnungen//loeschen") +@bp.post("/mappings//delete") @permission_required("fileshare.edit") def mapping_delete(mapping_id: int): with transaction() as conn: + entfernt = service.get_mapping(conn, mapping_id) service.delete_mapping(conn, mapping_id) - _audit("fileshare.mapping_deleted", str(mapping_id), severity="notice") + # Eine entfernte Zuordnung nimmt allen Mitgliedern der Gruppe den Zugang + # zu dieser Freigabe -- das ist keine Notiz am Rande, sondern eine Warnung. + _audit( + "fileshare.mapping_deleted", + str((entfernt or {}).get("label") or mapping_id), + service.describe_mapping(entfernt), + severity="warning", + ) flash("Zuordnung entfernt.", "success") return redirect(url_for("fileshare.mappings")) diff --git a/apps/tesm/src/tesm/blueprints/hostkeys.py b/apps/tesm/src/tesm/blueprints/hostkeys.py index 0fd07cb..a8aa2b8 100644 --- a/apps/tesm/src/tesm/blueprints/hostkeys.py +++ b/apps/tesm/src/tesm/blueprints/hostkeys.py @@ -31,7 +31,7 @@ from tesm_core.extension import core, db, transaction from ..services import inventory, ssh -bp = Blueprint("hostkeys", __name__, url_prefix="/hostschluessel") +bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys") KIND_DEVICE = "client" KIND_SWITCH = "switch" @@ -142,7 +142,7 @@ def _require(kind: str) -> None: abort(403) -@bp.post("///pruefen") +@bp.post("///probe") @permission_required("devices.view", "switches.view", mode="any") def probe(kind: str, target_id: int): conn = db() @@ -169,7 +169,7 @@ def probe(kind: str, target_id: int): ) -@bp.post("///freigeben") +@bp.post("///trust") @permission_required("devices.view", "switches.view", mode="any") @reauth_required def trust(kind: str, target_id: int): @@ -203,7 +203,7 @@ def trust(kind: str, target_id: int): return redirect(url_for("hostkeys.overview")) -@bp.post("///verwerfen") +@bp.post("///forget") @permission_required("devices.view", "switches.view", mode="any") def forget(kind: str, target_id: int): conn = db() @@ -222,7 +222,7 @@ def forget(kind: str, target_id: int): return redirect(url_for("hostkeys.overview")) -@bp.post("/verwaist/verwerfen") +@bp.post("/orphaned/forget") @permission_required("devices.edit", "switches.edit", mode="any") def forget_orphan(): """Entfernt eine Freigabe, zu der es kein Ziel mehr gibt.""" diff --git a/apps/tesm/src/tesm/blueprints/license.py b/apps/tesm/src/tesm/blueprints/license.py index e7fb108..9e2c5e7 100644 --- a/apps/tesm/src/tesm/blueprints/license.py +++ b/apps/tesm/src/tesm/blueprints/license.py @@ -23,7 +23,7 @@ from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError import tesm_licensing as lic -bp = Blueprint("license", __name__, url_prefix="/lizenz") +bp = Blueprint("license", __name__, url_prefix="/administration/license") MAX_UPLOAD_BYTES = 256 * 1024 @@ -69,7 +69,7 @@ def overview(): ) -@bp.post("/einspielen") +@bp.post("/install") @permission_required("settings_license.edit") def upload(): extension = core() @@ -110,13 +110,13 @@ def upload(): return redirect(url_for("license.overview")) -@bp.post("/aktivieren") +@bp.post("/activate") @permission_required("settings_license.edit") def activate(): return _handshake("activate") -@bp.post("/deaktivieren") +@bp.post("/deactivate") @permission_required("settings_license.edit") @reauth_required def deactivate(): @@ -158,7 +158,7 @@ def _handshake(action: str): return redirect(url_for("license.overview")) -@bp.post("/offline-antwort") +@bp.post("/offline-response") @permission_required("settings_license.edit") def offline_response(): extension = core() @@ -177,7 +177,7 @@ def offline_response(): return redirect(url_for("license.overview")) -@bp.post("/serverzertifikat") +@bp.post("/server-certificate") @permission_required("settings_license.edit") def server_certificate(): """Hinterlegt das Zertifikat des Lizenzservers. @@ -222,7 +222,7 @@ def server_certificate(): return redirect(url_for("license.overview")) -@bp.post("/aussteller//entfernen") +@bp.post("/issuer//remove") @admin_required @reauth_required def remove_trusted_key(key_id: str): diff --git a/apps/tesm/src/tesm/blueprints/logs.py b/apps/tesm/src/tesm/blueprints/logs.py index ac296e6..2a2164d 100644 --- a/apps/tesm/src/tesm/blueprints/logs.py +++ b/apps/tesm/src/tesm/blueprints/logs.py @@ -13,12 +13,12 @@ from tesm_core.extension import core, db from ..services import kea, monitor -bp = Blueprint("tesmlogs", __name__, url_prefix="/protokolle") +bp = Blueprint("tesmlogs", __name__, url_prefix="/logs") MAX_TAIL_LINES = 2000 -@bp.get("/kea") +@bp.get("/dhcp") @permission_required("logs_dhcp.view") def dhcp(): extension = core() @@ -38,7 +38,7 @@ def dhcp(): return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error) -@bp.get("/neustarts") +@bp.get("/restarts") @permission_required("devices.view") def restarts(): """Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung. @@ -48,7 +48,7 @@ def restarts(): der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht, der wissen will, warum ein Geraet weg war. """ - method = (request.args.get("methode") or "").strip() + method = (request.args.get("method") or "").strip() if method and method not in monitor.METHOD_LABELS: method = "" sql = ( diff --git a/apps/tesm/src/tesm/blueprints/maintenance.py b/apps/tesm/src/tesm/blueprints/maintenance.py index 2377846..47a9f92 100644 --- a/apps/tesm/src/tesm/blueprints/maintenance.py +++ b/apps/tesm/src/tesm/blueprints/maintenance.py @@ -25,7 +25,7 @@ from tesm_core.extension import core, db, transaction from ..services import inventory, maintenance as service, ssh, windowsops, monitor -bp = Blueprint("maintenance", __name__, url_prefix="/wartung") +bp = Blueprint("maintenance", __name__, url_prefix="/maintenance") @bp.before_request @@ -85,7 +85,7 @@ def overview(): category_labels=dict(inventory.CREDENTIAL_CATEGORIES), category_effects=inventory.CATEGORY_EFFECTS, update_category=inventory.MAINTENANCE_CATEGORY, - terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), + terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")), windows_tool_missing=_windows_tool_missing(conn), windows_package=windowsops.REQUIRED_PACKAGE, ) @@ -105,7 +105,7 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def] ) -@bp.post("/paket-nachinstallieren") +@bp.post("/install-package") @permission_required("maintenance.execute") def install_windows_tool(): """Installiert das Paket, das der Fehlermeldung nach fehlt. @@ -165,7 +165,7 @@ def status(): ) -@bp.get("/auftrag/") +@bp.get("/job/") @permission_required("maintenance.view", "maintenance.execute", mode="any") def job_detail(job_id: str): conn = db() @@ -175,7 +175,7 @@ def job_detail(job_id: str): return render_template("tesm/maintenance_job.html", job=job) -@bp.get("/auftrag//ausgabe") +@bp.get("/job//output") @permission_required("maintenance.view", "maintenance.execute", mode="any") @fragment def job_output(job_id: str): @@ -276,14 +276,14 @@ def _start(kind: str) -> None: flash(f"{started} Auftrag/Auftraege gestartet.", "success") -@bp.post("/aktualisieren") +@bp.post("/update") @permission_required("maintenance.execute") def start_update(): _start(service.JOB_KIND_UPDATE) return redirect(url_for("maintenance.overview")) -@bp.post("/neustarten") +@bp.post("/restart") @permission_required("maintenance.execute") def start_reboot(): _start(service.JOB_KIND_REBOOT) diff --git a/apps/tesm/src/tesm/blueprints/settings.py b/apps/tesm/src/tesm/blueprints/settings.py index 0baaad9..682dc20 100644 --- a/apps/tesm/src/tesm/blueprints/settings.py +++ b/apps/tesm/src/tesm/blueprints/settings.py @@ -18,7 +18,7 @@ from tesm_core.auth.service import client_ip from tesm_core.extension import core, db, transaction from tesm_core.licensing_client import LicenseError -bp = Blueprint("settings", __name__, url_prefix="/einstellungen") +bp = Blueprint("settings", __name__, url_prefix="/administration") def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None: @@ -79,9 +79,12 @@ def system(): "monitor_failures_before_restart", "monitor_auto_restart", "dashboard_public", - "terminal_enabled", + "terminal_switches_enabled", + "terminal_clients_enabled", "terminal_idle_timeout", "trash_retention_days", + "session_idle_minutes", + "session_absolute_hours", "log_rotate_interval", "log_rotate_keep", ], @@ -92,6 +95,7 @@ def system(): timezones=_timezone_choices(), nav_items=extension.nav.items, nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(), + nav_child_order=extension.settings.get(conn, "nav_child_order") or {}, license_hint=_license_hint("monitor_interval"), sysops_available=bool(sysops and sysops.available), ) @@ -109,9 +113,12 @@ def system_save(): "monitor_failures_before_restart", "monitor_auto_restart", "dashboard_public", - "terminal_enabled", + "terminal_switches_enabled", + "terminal_clients_enabled", "terminal_idle_timeout", "trash_retention_days", + "session_idle_minutes", + "session_absolute_hours", "log_rotate_interval", "log_rotate_keep", ): @@ -181,7 +188,7 @@ def set_hostname(): return redirect(url_for("settings.system")) -@bp.post("/system/zeitzone") +@bp.post("/system/timezone") @permission_required("settings_system.execute") def set_timezone(): extension = core() @@ -210,7 +217,7 @@ def set_timezone(): return redirect(url_for("settings.system")) -@bp.post("/system/pruefung-jetzt") +@bp.post("/system/check-now") @permission_required("settings_system.execute") def run_check_now(): """Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund.""" diff --git a/apps/tesm/src/tesm/blueprints/switches.py b/apps/tesm/src/tesm/blueprints/switches.py index d4f076e..552906c 100644 --- a/apps/tesm/src/tesm/blueprints/switches.py +++ b/apps/tesm/src/tesm/blueprints/switches.py @@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction from ..services import inventory, ssh, switchcli -bp = Blueprint("switches", __name__, url_prefix="/switche") +bp = Blueprint("switches", __name__, url_prefix="/monitoring/switches") def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: @@ -54,11 +54,11 @@ def overview(): "tesm/switches.html", switches=inventory.list_switches(conn), credentials=inventory.list_credentials(conn), - terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), + terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")), ) -@bp.post("/neu") +@bp.post("/new") @permission_required("switches.create") def create(): try: @@ -98,7 +98,7 @@ def update(switch_id: int): return redirect(request.referrer or url_for("switches.overview")) -@bp.post("//loeschen") +@bp.post("//delete") @permission_required("switches.delete") def delete(switch_id: int): try: @@ -142,7 +142,7 @@ def detail(switch_id: int): devices=devices, credentials=inventory.list_credentials(conn), safe_commands=switchcli.SAFE_COMMANDS, - terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), + terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")), ) @@ -151,7 +151,7 @@ def detail(switch_id: int): # --------------------------------------------------------------------------- # -@bp.post("//hostkey/pruefen") +@bp.post("//host-key/probe") @permission_required("switches.edit") def probe_host_key(switch_id: int): conn = db() @@ -176,7 +176,7 @@ def probe_host_key(switch_id: int): ) -@bp.post("//hostkey/freigeben") +@bp.post("//host-key/trust") @permission_required("switches.edit") @reauth_required def trust_host_key(switch_id: int): @@ -215,7 +215,7 @@ def trust_host_key(switch_id: int): return redirect(url_for("switches.detail", switch_id=switch_id)) -@bp.post("//hostkey/verwerfen") +@bp.post("//host-key/forget") @permission_required("switches.edit") def forget_host_key(switch_id: int): conn = db() @@ -234,7 +234,7 @@ def forget_host_key(switch_id: int): # --------------------------------------------------------------------------- # -@bp.post("//kommando") +@bp.post("//command") @permission_required("switches.execute") @reauth_required def run_command(switch_id: int): diff --git a/apps/tesm/src/tesm/blueprints/terminal.py b/apps/tesm/src/tesm/blueprints/terminal.py index 929a4a2..5ccc1fe 100644 --- a/apps/tesm/src/tesm/blueprints/terminal.py +++ b/apps/tesm/src/tesm/blueprints/terminal.py @@ -24,7 +24,10 @@ Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst. Gemeinsame Sicherheitsmerkmale: * Frische Passwortbestaetigung, zusaetzlich zum Recht. -* Abschaltbar ueber die Einstellung ``terminal_enabled``. +* Abschaltbar -- getrennt fuer Switche (``terminal_switches_enabled``) und + Clients (``terminal_clients_enabled``). Das Freigeben von Host-Schluesseln + haengt an eigenen Routen und bleibt davon unberuehrt: es wird fuer den + PoE-Neustart gebraucht. * Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite. @@ -128,6 +131,18 @@ def _load_target(conn: Any, kind: str, target_id: int) -> Target | None: ) +def _konsole_erlaubt(conn: Any, kind: str) -> bool: + """Ist die Konsole fuer diese Art Ziel eingeschaltet? + + Zwei Schalter, weil beides unabhaengig voneinander gebraucht wird: eine + Werkstatt will vielleicht auf Switche, aber nicht auf Clients. + """ + schluessel = ( + "terminal_switches_enabled" if kind == KIND_SWITCH else "terminal_clients_enabled" + ) + return bool(core().settings.get(conn, schluessel)) + + def _client_module_active() -> bool: manager = core().license return manager is None or manager.module_active("maintenance") @@ -136,7 +151,7 @@ def _client_module_active() -> bool: def _render_console(target: Target): # type: ignore[no-untyped-def] extension = core() conn = db() - if not extension.settings.get(conn, "terminal_enabled"): + if not _konsole_erlaubt(conn, target.kind): abort(404) if not target.credential_id: abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.") @@ -151,7 +166,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def] ) -@bp.get("/switche//konsole") +@bp.get("/monitoring/switches//console") @permission_required("switches.execute") @reauth_required def switch_console(switch_id: int): @@ -161,7 +176,7 @@ def switch_console(switch_id: int): return _render_console(target) -@bp.get("/clients//konsole") +@bp.get("/monitoring/clients//console") @permission_required("maintenance.execute") @reauth_required def device_console(device_id: int): @@ -217,7 +232,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un if not reauth_is_fresh(): _send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.") return - if not extension.settings.get(conn, "terminal_enabled"): + if not _konsole_erlaubt(conn, kind): _send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.") return if kind == KIND_DEVICE and not _client_module_active(): diff --git a/apps/tesm/src/tesm/navigation.py b/apps/tesm/src/tesm/navigation.py index 4125f3e..4fc847e 100644 --- a/apps/tesm/src/tesm/navigation.py +++ b/apps/tesm/src/tesm/navigation.py @@ -45,10 +45,10 @@ def _has_fileshare_mounts(user: Any) -> bool: NAVIGATION = NavTree( [ - NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"), + NavItem(key="index", label="Statusübersicht", icon="grid", endpoint="dashboard.index"), NavItem( key="monitoring", - label="Ueberwachung", + label="Überwachung", icon="cpu", children=( NavItem( @@ -74,21 +74,21 @@ NAVIGATION = NavTree( ), NavItem( key="hostkeys", - label="Host-Schluessel", + label="Host-Schlüssel", icon="shield", endpoint="hostkeys.overview", permissions=("devices.view", "switches.view"), ), - NavItem( - key="maintenance", - label="Wartung", - icon="wrench", - endpoint="maintenance.overview", - permissions=("maintenance.view", "maintenance.execute"), - condition=_module("maintenance"), - ), ), ), + NavItem( + key="maintenance", + label="Wartung", + icon="wrench", + endpoint="maintenance.overview", + permissions=("maintenance.view", "maintenance.execute"), + condition=_module("maintenance"), + ), NavItem( key="dhcp", label="DHCP-Server", @@ -147,7 +147,7 @@ NAVIGATION = NavTree( ), NavItem( key="logs_dhcp", - label="Kea-DHCP", + label="DHCP", icon="network", endpoint="tesmlogs.dhcp", permissions=("logs_dhcp.view",), diff --git a/apps/tesm/src/tesm/permissions.py b/apps/tesm/src/tesm/permissions.py index c8d82cb..d201546 100644 --- a/apps/tesm/src/tesm/permissions.py +++ b/apps/tesm/src/tesm/permissions.py @@ -31,7 +31,7 @@ DEVICES = Resource( key="devices", label="Clients", actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), - hint="Ueberwachte PoE-Endgeraete", + hint="Überwachte PoE-Endgeräte", action_hints={ A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische " "Neustart bei Ausfall ist davon unabhaengig und laeuft immer.", @@ -75,7 +75,7 @@ DHCP = Resource( key="dhcp", label="DHCP-Server", actions=(A.VIEW, A.EDIT, A.EXECUTE), - hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)", + hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)", action_hints={ A.EDIT: "Konfiguration aendern und schreiben.", A.EXECUTE: "Dienst installieren, starten oder stoppen.", @@ -120,13 +120,13 @@ FILESHARE_PDF = Resource( LOGS_DHCP = Resource( key="logs_dhcp", - label="Kea-DHCP-Protokoll", + label="DHCP-Protokoll", actions=(A.VIEW,), ) MONITORING_AREA = Area( key="monitoring", - label="Ueberwachung", + label="Überwachung", icon="cpu", hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der " "gesamte Bereich verborgen.", diff --git a/apps/tesm/src/tesm/schema.py b/apps/tesm/src/tesm/schema.py index 3383899..6524da5 100644 --- a/apps/tesm/src/tesm/schema.py +++ b/apps/tesm/src/tesm/schema.py @@ -273,9 +273,26 @@ INSERT OR IGNORE INTO group_permissions (group_id, permission) """ +# Aus einem Schalter werden zwei. Wer die Konsole abgeschaltet hatte, soll sie +# nicht durch ein Update zurueckbekommen -- der alte Wert gilt deshalb fuer +# beide weiter. Fehlt er, greift die Vorgabe der Einstellung. +V5 = """ +INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by) + SELECT 'terminal_switches_enabled', value, datetime('now'), 'migration' + FROM settings WHERE key = 'terminal_enabled'; + +INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by) + SELECT 'terminal_clients_enabled', value, datetime('now'), 'migration' + FROM settings WHERE key = 'terminal_enabled'; + +DELETE FROM settings WHERE key = 'terminal_enabled'; +""" + + TESM_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=1, name="baseline", sql=V1), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=3, name="restart-events", sql=V3), Migration(version=4, name="fileshare-pdf-rechte", sql=V4), + Migration(version=5, name="terminal-getrennt", sql=V5), ) diff --git a/apps/tesm/src/tesm/services/fileshare.py b/apps/tesm/src/tesm/services/fileshare.py index 9667ab2..c956679 100644 --- a/apps/tesm/src/tesm/services/fileshare.py +++ b/apps/tesm/src/tesm/services/fileshare.py @@ -33,7 +33,7 @@ import sqlite3 from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path, PurePosixPath -from typing import Any, Iterable +from typing import Any, Iterable, Mapping from tesm_core.db import Database from tesm_core.extension import core @@ -208,6 +208,50 @@ def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]: ) +#: Felder einer Zuordnung mit der Beschriftung, unter der sie in der +#: Oberflaeche stehen. Das Protokoll soll dieselben Woerter benutzen wie das +#: Formular -- sonst muss man beim Lesen uebersetzen. +MAPPING_FELDER: tuple[tuple[str, str], ...] = ( + ("label", "Anzeigename"), + ("unc", "Pfad"), + ("ad_group_name", "Gruppe"), + ("ad_group_dn", "Gruppen-DN"), +) + + +def get_mapping(conn: sqlite3.Connection, mapping_id: int) -> dict[str, Any] | None: + return Database.one(conn, "SELECT * FROM fileshare_mappings WHERE id=?", (mapping_id,)) + + +def describe_mapping(row: Mapping[str, Any] | None) -> str: + """Eine Zuordnung in einer Zeile -- fuer das Protokoll. + + Ohne sie stand beim Loeschen nur die Nummer der Zuordnung im Protokoll. + Die ist nach dem Loeschen nirgends mehr nachschlagbar; damit war der + Eintrag da, aber wertlos. + """ + if row is None: + return "" + teile = [str(row["unc"] or "")] + gruppe = str(row["ad_group_name"] or row["ad_group_dn"] or "") + if gruppe: + teile.append(f"Gruppe: {gruppe}") + return " -- ".join(teil for teil in teile if teil) + + +def mapping_changes(vorher: Mapping[str, Any] | None, nachher: Mapping[str, Any] | None) -> str: + """Was sich geaendert hat, Feld fuer Feld: ``Anzeigename: alt -> neu``.""" + if vorher is None or nachher is None: + return "" + geaendert = [] + for feld, beschriftung in MAPPING_FELDER: + alt = str(vorher[feld] or "") + neu = str(nachher[feld] or "") + if alt != neu: + geaendert.append(f"{beschriftung}: {alt or '(leer)'} -> {neu or '(leer)'}") + return "; ".join(geaendert) + + def add_mapping( conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str ) -> int: @@ -389,7 +433,39 @@ def mount_for_login( if not password: return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."] + # Was zuerst kommt: das Ende der Sitzung oder die eigene Obergrenze. Die + # Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die + # Obergrenze sorgt dafuer, dass eine sehr lange Sitzung nicht beliebig + # lange auf fremden Zugangsdaten sitzt. hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12) + frist = _now() + timedelta(hours=hours) + # Die Sitzungszeile gibt es hier noch nicht: eingebunden wird waehrend der + # Anmeldung, geschrieben wird sie erst beim Speichern der Antwort. Die + # Dauer steht aber in derselben Einstellung, aus der die Sitzung sie + # gleich bekommt -- und falls die Zeile doch schon steht (erneutes + # Einbinden), gilt deren Ende. + zeile = ( + Database.one( + conn, "SELECT absolute_expires_at FROM user_sessions WHERE id=?", (session_id,) + ) + if session_id + else None + ) + if zeile is not None and zeile["absolute_expires_at"]: + try: + sitzungsende = datetime.fromisoformat(str(zeile["absolute_expires_at"])) + if sitzungsende.tzinfo is None: + sitzungsende = sitzungsende.replace(tzinfo=timezone.utc) + frist = min(frist, sitzungsende) + except ValueError: + pass + else: + try: + dauer = int(extension.settings.get(conn, "session_absolute_hours") or 0) + except (TypeError, ValueError): + dauer = 0 + if dauer > 0: + frist = min(frist, _now() + timedelta(hours=dauer)) root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares")) messages: list[str] = [] @@ -419,7 +495,7 @@ def mount_for_login( str(share["unc"]), str(target), _iso(_now()), - _iso(_now() + timedelta(hours=hours)), + _iso(frist), ), ) messages.append(f"{share['label']}: eingebunden") @@ -474,7 +550,10 @@ def sweep_expired(database: Any) -> int: released = 0 with database.session() as conn: # Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie - # entstanden ist. Vorher hing sie an einer eigenen Frist von zwoelf + # entstanden ist. Ohne Sitzungsangabe gilt allein die eigene Frist: + # "keine Sitzung eingetragen" ist etwas anderes als "Sitzung + # abgelaufen". Diese Verwechslung hat jede frische Einbindung binnen + # Minuten wieder geloest, weil die ID beim Anmelden noch nicht stand. Vorher hing sie an einer eigenen Frist von zwoelf # Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung # bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers # weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen; @@ -487,10 +566,11 @@ def sweep_expired(database: Any) -> int: LEFT JOIN user_sessions s ON s.id = m.session_id WHERE m.released_at IS NULL AND (m.expires_at <= ? - OR s.id IS NULL - OR s.revoked_at IS NOT NULL - OR s.idle_expires_at <= ? - OR s.absolute_expires_at <= ?) + OR (COALESCE(m.session_id, '') <> '' + AND (s.id IS NULL + OR s.revoked_at IS NOT NULL + OR s.idle_expires_at <= ? + OR s.absolute_expires_at <= ?))) """, (_iso(_now()), _iso(_now()), _iso(_now())), ) diff --git a/apps/tesm/src/tesm/settings_spec.py b/apps/tesm/src/tesm/settings_spec.py index 61b244f..a0776ef 100644 --- a/apps/tesm/src/tesm/settings_spec.py +++ b/apps/tesm/src/tesm/settings_spec.py @@ -100,12 +100,23 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( ), # -- Terminal ----------------------------------------------------------- # SettingSpec( - "terminal_enabled", + "terminal_switches_enabled", True, "bool", - "Browser-Terminal fuer Switche aktiv", + "Browser-Konsole fuer Switche zulassen", hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische " - "Passwortbestaetigung und wird vollstaendig protokolliert.", + "Passwortbestaetigung und wird vollstaendig protokolliert. Das Freigeben von " + "Host-Schluesseln bleibt davon unberuehrt -- es wird fuer den PoE-Neustart " + "gebraucht.", + section="monitoring", + ), + SettingSpec( + "terminal_clients_enabled", + True, + "bool", + "Browser-Konsole fuer Clients zulassen", + hint="Braucht zusaetzlich das Modul Wartung und das Recht 'Ausfuehren' auf Wartung. " + "Das Freigeben von Host-Schluesseln bleibt davon unberuehrt.", section="monitoring", ), SettingSpec( diff --git a/apps/tesm/src/tesm/templates/tesm/fileshare.html b/apps/tesm/src/tesm/templates/tesm/fileshare.html index 018263e..55ee255 100644 --- a/apps/tesm/src/tesm/templates/tesm/fileshare.html +++ b/apps/tesm/src/tesm/templates/tesm/fileshare.html @@ -23,7 +23,7 @@
{% for mount in mounts %} {{ mount.label }} + href="{{ url_for('fileshare.browse', share=mount.token) }}">{{ mount.label }} {% endfor %}
@@ -33,9 +33,9 @@
{{ current.unc }}
@@ -69,8 +69,8 @@ {% if current_user.has('fileshare.delete') %}{% endif %} - + {{ icon('chevron-right', size=14) }} eine Ebene höher @@ -79,11 +79,11 @@ {% for entry in listing.directories %} {% if current_user.has('fileshare.delete') %} - + {% endif %} {{ icon('folder', size=15) }} - {{ entry.name }} + {{ entry.name }} – {{ entry.modified | dt }} @@ -93,7 +93,7 @@ {% for entry in listing.files %} {% if current_user.has('fileshare.delete') %} - + {% endif %} {# Der Name ist die grösste Fläche der Zeile -- er soll auch das Naheliegende tun. Was sich anzeigen lässt, öffnet die @@ -102,17 +102,17 @@ {% if entry.previewable %} {% else %} {% if current_user.has('fileshare.export') %} + href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"> {{ icon('file-text', size=15) }} {{ entry.name }} {% else %} @@ -130,11 +130,11 @@ {% if entry.previewable %} {% endif %} {% if current_user.has('fileshare.edit') %} @@ -147,7 +147,7 @@ {% endif %} {% if current_user.has('fileshare.export') %} {{ icon('download', size=15) }} {% endif %} @@ -182,7 +182,7 @@
- +
@@ -199,7 +199,7 @@ {% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %} - + diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html index b78855f..ec13d48 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html @@ -235,3 +235,69 @@ die Spalte gehört zu einer anderen Zeile desselben Bereichs.
{%- endmacro %} + +{# -------------------------------------------------------------------------- + Navigationsreihenfolge. Die Reihenfolge der versteckten Felder ist die + Reihenfolge -- verschiebt sich eine Zeile, verschiebt sich das Feld mit ihr. + Ziehen mit der Maus oder die zwei Knoepfe je Zeile; auf einem Tablet sind + die Knoepfe der verlaessliche Weg. + -------------------------------------------------------------------------- #} +{% macro _reorder_row(item, field_name, label) -%} +
  • + + 1 + + {{ icon(item.icon, size=15) }} {{ item.label }} + + + + +
  • +{%- endmacro %} + +{% macro nav_order_card(items, order, child_order, action, csrf_field, csrf_value, + sub='Gilt für alle Benutzer; jeder sieht weiter nur, was er darf') -%} +
    +
    +
    +
    {{ icon('sliders', size=17) }} Navigationsreihenfolge
    +
    {{ sub }}
    +
    +
    +
    + +
    +
      + {% for key in order %} + {% set item = items | selectattr('key', 'equalto', key) | list | first %} + {% if item %} + {{ _reorder_row(item, 'order', item.label) }} + {% if item.children %} +
    1. +
        + {% set kinder = child_order.get(item.key) or (item.children | map(attribute='key') | list) %} + {% for child_key in kinder %} + {% set child = item.children | selectattr('key', 'equalto', child_key) | list | first %} + {% if child %} + {{ _reorder_row(child, 'child_order_' ~ item.key, item.label ~ ' / ' ~ child.label) }} + {% endif %} + {% endfor %} +
      +
    2. + {% endif %} + {% endif %} + {% endfor %} +
    +

    + Zeilen ziehen oder mit den Pfeilen verschieben, dann speichern. Untergeordnete + Punkte werden innerhalb ihrer Gruppe sortiert. +

    +
    + +
    +
    +{%- endmacro %} diff --git a/packages/tesm-core/src/tesm_core/ui.py b/packages/tesm-core/src/tesm_core/ui.py index 77a8e60..5076181 100644 --- a/packages/tesm-core/src/tesm_core/ui.py +++ b/packages/tesm-core/src/tesm_core/ui.py @@ -38,6 +38,7 @@ ICONS: dict[str, str] = { "menu": '', "chevron-right": '', "chevron-down": '', + "chevron-up": '', "sun": '', "moon": '', "monitor": '', diff --git a/packages/tesm-core/src/tesm_core/views/account.py b/packages/tesm-core/src/tesm_core/views/account.py index fa0e9ae..d1881e2 100644 --- a/packages/tesm-core/src/tesm_core/views/account.py +++ b/packages/tesm-core/src/tesm_core/views/account.py @@ -31,7 +31,7 @@ from ..extension import core, db, transaction from ..rbac import service as rbac from ..security import passwords, sessions as session_store, totp -bp = Blueprint("account", __name__, url_prefix="/konto") +bp = Blueprint("account", __name__, url_prefix="/account") ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"} MAX_AVATAR_BYTES = 2 * 1024 * 1024 @@ -54,6 +54,19 @@ def _avatar_dir() -> Path: def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None: + """Protokolliert eine Aenderung am **eigenen** Konto. + + Bewusst sparsam benutzt. Das Aenderungsprotokoll beantwortet die Frage + "wer hat woran etwas geaendert" -- und wer sein eigenes Passwort setzt, + ein Profilbild tauscht oder seine Anschrift korrigiert, aendert nichts, + was jemand anderen betrifft. Solche Zeilen fuellten das Protokoll und + schoben die Eintraege aus dem Blick, um die es geht. + + Was hier trotzdem stehen bleibt: alles rund um den zweiten Faktor. Ob ein + Konto mit oder ohne zweiten Faktor geschuetzt ist, aendert die Staerke der + Anmeldung -- das ist die eine Selbstbedienung, nach der man nach einem + Vorfall sucht. + """ audit.log( conn, actor=current_user.username, @@ -80,7 +93,7 @@ def overview(): ) -@bp.post("/stammdaten") +@bp.post("/profile") @login_required def update_profile(): if current_user.is_directory_account: @@ -101,12 +114,11 @@ def update_profile(): current_user.id, ), ) - _audit(conn, "account.profile_updated") flash("Stammdaten gespeichert.", "success") return redirect(url_for("account.overview")) -@bp.post("/profilbild") +@bp.post("/avatar") @login_required def upload_avatar(): upload = request.files.get("avatar") @@ -142,14 +154,13 @@ def upload_avatar(): "UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?", (target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id), ) - _audit(conn, "account.avatar_updated") if previous: (_avatar_dir() / str(previous)).unlink(missing_ok=True) flash("Profilbild aktualisiert.", "success") return redirect(url_for("account.overview")) -@bp.get("/profilbild/") +@bp.get("/avatar/") @login_required def avatar(filename: str): safe = secure_filename(filename) @@ -163,7 +174,7 @@ def avatar(filename: str): # --------------------------------------------------------------------------- # -@bp.get("/sicherheit") +@bp.get("/security") @login_required def security(): conn = db() @@ -199,7 +210,7 @@ def security(): ) -@bp.post("/passwort") +@bp.post("/password") @login_required def change_password(): conn = db() @@ -229,21 +240,15 @@ def change_password(): revoked = session_store.revoke_all_for_user( conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change" ) - _audit( - conn, - "account.password_changed", - f"{revoked} weitere Sitzungen beendet", - severity="notice", - ) except rbac.ValidationError as exc: flash(str(exc), "danger") return redirect(url_for("account.security")) - flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success") + flash(f"Passwort geaendert. {revoked} weitere Sitzung(en) wurden beendet.", "success") return redirect(url_for("account.security")) -@bp.post("/zwei-faktor/starten") +@bp.post("/two-factor/start") @login_required @reauth_required def totp_start(): @@ -252,7 +257,7 @@ def totp_start(): return redirect(url_for("account.security")) -@bp.post("/zwei-faktor/bestaetigen") +@bp.post("/two-factor/confirm") @login_required @reauth_required def totp_confirm(): @@ -295,7 +300,7 @@ def totp_confirm(): return redirect(url_for("account.security")) -@bp.post("/zwei-faktor/deaktivieren") +@bp.post("/two-factor/disable") @login_required @reauth_required def totp_disable(): @@ -317,7 +322,7 @@ def totp_disable(): return redirect(url_for("account.security")) -@bp.post("/zwei-faktor/codes-erneuern") +@bp.post("/two-factor/recovery-codes") @login_required @reauth_required def recovery_codes(): @@ -341,7 +346,7 @@ def recovery_codes(): return redirect(url_for("account.security")) -@bp.post("/sitzungen//beenden") +@bp.post("/sessions//end") @login_required def revoke_session(sid: str): conn = db() @@ -350,18 +355,16 @@ def revoke_session(sid: str): abort(404) with transaction(): session_store.revoke(conn, sid, reason="self") - _audit(conn, "account.session_revoked", sid[:12]) flash("Sitzung beendet.", "success") return redirect(url_for("account.security")) -@bp.post("/sitzungen/alle-beenden") +@bp.post("/sessions/end-all") @login_required def revoke_other_sessions(): with transaction() as conn: count = session_store.revoke_all_for_user( conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self" ) - _audit(conn, "account.sessions_revoked", f"{count} Sitzungen") flash(f"{count} weitere Sitzung(en) beendet.", "success") return redirect(url_for("account.security")) diff --git a/packages/tesm-core/src/tesm_core/views/admin.py b/packages/tesm-core/src/tesm_core/views/admin.py index a670f32..b25d175 100644 --- a/packages/tesm-core/src/tesm_core/views/admin.py +++ b/packages/tesm-core/src/tesm_core/views/admin.py @@ -16,7 +16,7 @@ from ..rbac import service as rbac from ..rbac.model import ACTION_META, Action from ..security import sessions as session_store -bp = Blueprint("admin", __name__, url_prefix="/verwaltung") +bp = Blueprint("admin", __name__, url_prefix="/administration") def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None: @@ -47,7 +47,7 @@ def _int_list(name: str) -> list[int]: # --------------------------------------------------------------------------- # -@bp.get("/benutzer") +@bp.get("/users") @permission_required("users.view") def users(): conn = db() @@ -60,7 +60,7 @@ def users(): ) -@bp.post("/benutzer/neu") +@bp.post("/users/new") @permission_required("users.create") def user_create(): conn = db() @@ -94,7 +94,7 @@ def user_create(): return redirect(url_for("admin.users")) -@bp.post("/benutzer/") +@bp.post("/users/") @permission_required("users.edit") def user_update(user_id: int): conn = db() @@ -137,7 +137,7 @@ def user_update(user_id: int): return redirect(url_for("admin.users")) -@bp.post("/benutzer//loeschen") +@bp.post("/users//delete") @permission_required("users.delete") def user_delete(user_id: int): conn = db() @@ -163,7 +163,7 @@ def user_delete(user_id: int): return redirect(url_for("admin.users")) -@bp.post("/benutzer//sitzungen-beenden") +@bp.post("/users//end-sessions") @permission_required("users.edit") def user_revoke_sessions(user_id: int): conn = db() @@ -184,7 +184,7 @@ def user_revoke_sessions(user_id: int): return redirect(url_for("admin.users")) -@bp.get("/benutzer/verzeichnis-suche") +@bp.get("/users/directory-search") @permission_required("users.create") def directory_search(): conn = db() @@ -200,7 +200,7 @@ def directory_search(): return jsonify({"results": results}) -@bp.post("/benutzer/verzeichnis-uebernehmen") +@bp.post("/users/directory-import") @permission_required("users.create") def directory_adopt(): """Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen.""" @@ -238,7 +238,7 @@ def directory_adopt(): # --------------------------------------------------------------------------- # -@bp.get("/gruppen") +@bp.get("/groups") @permission_required("groups.view") def groups(): conn = db() @@ -254,7 +254,7 @@ def groups(): ) -@bp.get("/gruppen/") +@bp.get("/groups/") @permission_required("groups.view") def group_detail(group_id: int): conn = db() @@ -275,11 +275,11 @@ def group_detail(group_id: int): (group_id,), ), can_edit=current_user.has("groups.edit"), - unlocked=request.args.get("freischalten") == "1", + unlocked=request.args.get("unlocked") == "1", ) -@bp.post("/gruppen/neu") +@bp.post("/groups/new") @permission_required("groups.create") def group_create(): conn = db() @@ -301,7 +301,7 @@ def group_create(): return redirect(url_for("admin.groups")) -@bp.post("/gruppen/") +@bp.post("/groups/") @permission_required("groups.edit") def group_update(group_id: int): conn = db() @@ -334,7 +334,7 @@ def group_update(group_id: int): return redirect(url_for("admin.group_detail", group_id=group_id)) -@bp.post("/gruppen//loeschen") +@bp.post("/groups//delete") @permission_required("groups.delete") def group_delete(group_id: int): conn = db() @@ -354,7 +354,7 @@ def group_delete(group_id: int): return redirect(url_for("admin.groups")) -@bp.post("/gruppen//vorlage-anwenden") +@bp.post("/groups//apply-preset") @admin_required def group_apply_preset(group_id: int): """Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch.""" @@ -386,7 +386,7 @@ def group_apply_preset(group_id: int): # --------------------------------------------------------------------------- # -@bp.get("/sitzungen") +@bp.get("/sessions") @admin_required def sessions(): conn = db() @@ -399,7 +399,7 @@ def sessions(): return render_template("tesm_core/sessions.html", sessions=rows) -@bp.post("/sitzungen//beenden") +@bp.post("/sessions//end") @admin_required def session_revoke(sid: str): with transaction() as conn: diff --git a/packages/tesm-core/src/tesm_core/views/diagnostics.py b/packages/tesm-core/src/tesm_core/views/diagnostics.py index 1d6adae..b113592 100644 --- a/packages/tesm-core/src/tesm_core/views/diagnostics.py +++ b/packages/tesm-core/src/tesm_core/views/diagnostics.py @@ -24,7 +24,7 @@ from ..keystore import check_private_file from ..security import ratelimit from ..security import sessions as session_store -bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose") +bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics") @bp.get("/") @@ -88,7 +88,7 @@ def overview(): ) -@bp.post("/aufraeumen") +@bp.post("/cleanup") @admin_required def housekeeping(): """Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle.""" @@ -116,7 +116,7 @@ def housekeeping(): return redirect(url_for("diagnostics.overview")) -@bp.post("/schluessel-rotieren") +@bp.post("/rotate-keys") @admin_required def rotate_keys(): """Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu. diff --git a/packages/tesm-core/src/tesm_core/views/hostsettings.py b/packages/tesm-core/src/tesm_core/views/hostsettings.py index ab448a3..401b20d 100644 --- a/packages/tesm-core/src/tesm_core/views/hostsettings.py +++ b/packages/tesm-core/src/tesm_core/views/hostsettings.py @@ -29,7 +29,7 @@ from ..licensing_client import LicenseError from ..rbac import service as rbac from ..system import hostnet, webserver -bp = Blueprint("hostsettings", __name__, url_prefix="/einstellungen") +bp = Blueprint("hostsettings", __name__, url_prefix="/administration") PENDING_NETWORK_KEY = "pending_network_revert" @@ -64,7 +64,7 @@ def _license_hint(feature: str) -> str: # --------------------------------------------------------------------------- # -@bp.get("/netzwerk") +@bp.get("/network") @permission_required("settings_network.view") def network(): extension = core() @@ -80,7 +80,7 @@ def network(): ) -@bp.post("/netzwerk") +@bp.post("/network") @permission_required("settings_network.edit") def network_apply(): extension = core() @@ -147,7 +147,7 @@ def network_apply(): return redirect(url_for("hostsettings.network")) -@bp.post("/netzwerk/bestaetigen") +@bp.post("/network/confirm") @permission_required("settings_network.edit") def network_confirm(): if core().registry.pop(PENDING_NETWORK_KEY, None) is None: @@ -221,7 +221,7 @@ def _web_config() -> webserver.WebConfig: ) -@bp.get("/webserver") +@bp.get("/web") @permission_required("settings_web.view") def web(): extension = core() @@ -240,7 +240,7 @@ def web(): ) -@bp.post("/webserver") +@bp.post("/web") @permission_required("settings_web.edit") def web_save(): extension = core() @@ -302,7 +302,7 @@ def web_save(): return redirect(url_for("hostsettings.web")) -@bp.post("/webserver/uebernehmen") +@bp.post("/web/apply") @permission_required("settings_web.execute") def web_apply(): problem = _license_hint("nginx") @@ -327,7 +327,7 @@ def web_apply(): return redirect(url_for("hostsettings.web")) -@bp.post("/webserver/zertifikat") +@bp.post("/web/certificate") @permission_required("settings_web.execute") def web_certificate(): problem = _license_hint("nginx") @@ -364,7 +364,7 @@ def web_certificate(): return redirect(url_for("hostsettings.web")) -@bp.post("/webserver/selbstsigniert") +@bp.post("/web/self-signed") @permission_required("settings_web.execute") def web_self_signed(): """Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain. @@ -414,7 +414,7 @@ def web_self_signed(): # --------------------------------------------------------------------------- # -@bp.get("/verzeichnisdienst") +@bp.get("/directory") @permission_required("settings_directory.view") def directory_settings(): extension = core() @@ -435,7 +435,7 @@ def directory_settings(): ) -@bp.post("/verzeichnisdienst") +@bp.post("/directory") @permission_required("settings_directory.edit") def directory_save(): problem = _license_hint("directory") @@ -481,7 +481,7 @@ def directory_save(): return redirect(url_for("hostsettings.directory_settings")) -@bp.post("/verzeichnisdienst/test") +@bp.post("/directory/test") @permission_required("settings_directory.execute") def directory_test(): result = directory.test_connection(db()) @@ -489,7 +489,7 @@ def directory_test(): return redirect(url_for("hostsettings.directory_settings")) -@bp.get("/verzeichnisdienst/gruppensuche") +@bp.get("/directory/group-search") @permission_required("settings_directory.edit", "fileshare.edit", mode="any") def directory_group_search(): """Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder. @@ -504,7 +504,7 @@ def directory_group_search(): ) -@bp.post("/verzeichnisdienst/zuordnung") +@bp.post("/directory/mapping") @permission_required("settings_directory.edit") def directory_mapping_add(): conn = db() @@ -534,7 +534,7 @@ def directory_mapping_add(): return redirect(url_for("hostsettings.directory_settings")) -@bp.post("/verzeichnisdienst/zuordnung//loeschen") +@bp.post("/directory/mapping//delete") @permission_required("settings_directory.edit") def directory_mapping_delete(mapping_id: int): with transaction() as conn: diff --git a/packages/tesm-core/src/tesm_core/views/logs.py b/packages/tesm-core/src/tesm_core/views/logs.py index 0f3b7f3..427f69f 100644 --- a/packages/tesm-core/src/tesm_core/views/logs.py +++ b/packages/tesm-core/src/tesm_core/views/logs.py @@ -26,7 +26,7 @@ from ..auth.service import client_ip from ..extension import core, db, transaction from ..security.sessions import fragment -bp = Blueprint("logs", __name__, url_prefix="/protokolle") +bp = Blueprint("logs", __name__, url_prefix="/logs") MAX_TAIL_LINES = 2000 @@ -38,7 +38,7 @@ def _live_log(): return log -@bp.get("/laufend") +@bp.get("/live") @permission_required("logs_live.view") def live(): lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) @@ -52,7 +52,7 @@ def live(): ) -@bp.get("/laufend/inhalt") +@bp.get("/live/content") @permission_required("logs_live.view") @fragment def live_fragment(): @@ -63,7 +63,7 @@ def live_fragment(): ) -@bp.get("/verlauf") +@bp.get("/history") @permission_required("logs_history.view", "logs_history.export", mode="any") def history(): extension = core() @@ -82,7 +82,7 @@ def history(): ) -@bp.get("/verlauf/datei/") +@bp.get("/history/file/") @permission_required("logs_history.view") def history_file(name: str): extension = core() @@ -101,7 +101,7 @@ def history_file(name: str): ) -@bp.get("/verlauf/archiv/") +@bp.get("/history/archive/") @permission_required("logs_history.view") def history_archive(day: str): if len(day) != 10 or day.count("-") != 2: @@ -112,7 +112,7 @@ def history_archive(day: str): return render_template("tesm_core/logs_archive.html", day=day, rows=rows) -@bp.post("/verlauf/archiv-export") +@bp.post("/history/archive-export") @permission_required("logs_history.export") def history_export(): """Packt die archivierten Audit-Tage in ein ZIP. @@ -152,7 +152,7 @@ def history_export(): ) -@bp.post("/verlauf/archiv-loeschen") +@bp.post("/history/archive-delete") @permission_required("logs_history.delete") def history_purge(): removed = 0 @@ -176,20 +176,20 @@ def history_purge(): return redirect(url_for("logs.history")) -@bp.get("/aenderungen") +@bp.get("/audit") @permission_required("logs_audit.view", "logs_audit.export", mode="any") def audit_view(): conn = db() - page = max(1, int(request.args.get("seite") or 1)) + page = max(1, int(request.args.get("page") or 1)) per_page = 100 return render_template( "tesm_core/logs_audit.html", rows=audit.search( conn, query=(request.args.get("q") or "").strip(), - action=(request.args.get("aktion") or "").strip(), - actor=(request.args.get("benutzer") or "").strip(), - severity=(request.args.get("stufe") or "").strip(), + action=(request.args.get("action") or "").strip(), + actor=(request.args.get("actor") or "").strip(), + severity=(request.args.get("severity") or "").strip(), limit=per_page, offset=(page - 1) * per_page, ), @@ -200,14 +200,14 @@ def audit_view(): per_page=per_page, filters={ "q": request.args.get("q") or "", - "aktion": request.args.get("aktion") or "", - "benutzer": request.args.get("benutzer") or "", - "stufe": request.args.get("stufe") or "", + "action": request.args.get("action") or "", + "actor": request.args.get("actor") or "", + "severity": request.args.get("severity") or "", }, ) -@bp.get("/aenderungen/export") +@bp.get("/audit/export") @permission_required("logs_audit.export") def audit_export(): conn = db() diff --git a/packages/tesm-core/src/tesm_core/views/trash.py b/packages/tesm-core/src/tesm_core/views/trash.py index b2559a2..03ae60d 100644 --- a/packages/tesm-core/src/tesm_core/views/trash.py +++ b/packages/tesm-core/src/tesm_core/views/trash.py @@ -28,7 +28,7 @@ from ..db import Database from ..extension import core, db, transaction from ..rbac import service as rbac -bp = Blueprint("trash", __name__, url_prefix="/papierkorb") +bp = Blueprint("trash", __name__, url_prefix="/administration/trash") @dataclass(frozen=True, slots=True) @@ -191,7 +191,7 @@ def _provider_or_404(key: str) -> TrashProvider: return provider -@bp.post("///wiederherstellen") +@bp.post("///restore") @permission_required("trash.edit") def restore(kind: str, ident: str): extension = core() @@ -222,7 +222,7 @@ def restore(kind: str, ident: str): return redirect(url_for("trash.overview")) -@bp.post("///endgueltig-loeschen") +@bp.post("///purge") @permission_required("trash.delete") def purge(kind: str, ident: str): extension = core() @@ -252,7 +252,7 @@ def purge(kind: str, ident: str): return redirect(url_for("trash.overview")) -@bp.post("/leeren") +@bp.post("/empty") @permission_required("trash.delete") def purge_all(): extension = core() diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index 67c8ceb..1b9416c 100644 --- a/packages/tesm-licensing/pyproject.toml +++ b/packages/tesm-licensing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing" -version = "2.4.1" +version = "2.5.0" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"]