From 1ac5dc1637e4e88ca5e6ceff44cdde039a9d2a71 Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Fri, 11 Sep 2026 18:57:23 +0200 Subject: [PATCH] Version 2.5.0: englische Adressen entlang der Navigation, sparsameres Protokoll --- apps/tesm-license/pyproject.toml | 2 +- .../src/tesm_license/blueprints/backup.py | 8 +- .../src/tesm_license/blueprints/customers.py | 6 +- .../src/tesm_license/blueprints/dashboard.py | 2 +- .../src/tesm_license/blueprints/licenses.py | 20 ++--- .../tesm_license/blueprints/own_license.py | 12 +-- .../tesm_license/blueprints/selfservice.py | 2 + .../src/tesm_license/blueprints/settings.py | 11 +-- .../src/tesm_license/blueprints/tickets.py | 10 +-- .../templates/license/customer_detail.html | 2 +- .../templates/license/settings_system.html | 32 +------- deploy/install.sh | 2 +- docs/BETRIEB.md | 2 +- packages/tesm-core/pyproject.toml | 2 +- .../tesm-core/src/tesm_core/app_factory.py | 24 +++++- .../tesm-core/src/tesm_core/auth/routes.py | 4 +- .../tesm-core/src/tesm_core/auth/service.py | 43 ++++++++--- .../src/tesm_core/security/sessions.py | 25 +++++- .../static/tesm_core/css/components.css | 69 +++++++++++++++++ .../src/tesm_core/static/tesm_core/js/app.js | 76 +++++++++++++++++++ .../tesm_core/static/tesm_core/js/viewer.js | 2 +- .../templates/tesm_core/group_detail.html | 2 +- .../templates/tesm_core/logs_audit.html | 14 ++-- .../tesm_core/templates/tesm_core/macros.html | 66 ++++++++++++++++ packages/tesm-core/src/tesm_core/ui.py | 1 + .../tesm-core/src/tesm_core/views/account.py | 49 ++++++------ .../tesm-core/src/tesm_core/views/admin.py | 34 ++++----- .../src/tesm_core/views/diagnostics.py | 6 +- .../src/tesm_core/views/hostsettings.py | 30 ++++---- .../tesm-core/src/tesm_core/views/logs.py | 34 ++++----- .../tesm-core/src/tesm_core/views/trash.py | 8 +- packages/tesm-licensing-server/pyproject.toml | 4 +- packages/tesm-licensing/pyproject.toml | 2 +- 33 files changed, 431 insertions(+), 175 deletions(-) diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index 491c5ca..926419b 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.4.1" +version = "2.5.0" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm-license/src/tesm_license/blueprints/backup.py b/apps/tesm-license/src/tesm_license/blueprints/backup.py index 3954f06..a131a66 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/backup.py +++ b/apps/tesm-license/src/tesm_license/blueprints/backup.py @@ -13,7 +13,7 @@ from tesm_core.extension import core, db, transaction from ..services import transfer from .common import log_event -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" @@ -94,7 +94,7 @@ def export(): ) -@bp.post("/import/vorschau") +@bp.post("/import/preview") @permission_required("backup.execute") @reauth_required def import_preview(): @@ -131,7 +131,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(): @@ -190,7 +190,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-license/src/tesm_license/blueprints/customers.py b/apps/tesm-license/src/tesm_license/blueprints/customers.py index 672a53b..a10e152 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/customers.py +++ b/apps/tesm-license/src/tesm_license/blueprints/customers.py @@ -9,7 +9,7 @@ from tesm_core.extension import db, transaction from ..services import issuance, reporting from .common import customer_facing_url, log_event -bp = Blueprint("customers", __name__, url_prefix="/kunden") +bp = Blueprint("customers", __name__, url_prefix="/customers") FIELDS = ( "name", @@ -61,7 +61,7 @@ def detail(customer_id: int): ) -@bp.post("/neu") +@bp.post("/new") @permission_required("customers.create") def create(): try: @@ -103,7 +103,7 @@ def update(customer_id: int): return redirect(url_for("customers.detail", customer_id=customer_id)) -@bp.post("//loeschen") +@bp.post("//delete") @permission_required("customers.delete") def delete(customer_id: int): conn = db() diff --git a/apps/tesm-license/src/tesm_license/blueprints/dashboard.py b/apps/tesm-license/src/tesm_license/blueprints/dashboard.py index 984285d..81599fc 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/dashboard.py +++ b/apps/tesm-license/src/tesm_license/blueprints/dashboard.py @@ -16,7 +16,7 @@ from ..services import issuance, reporting bp = Blueprint("dashboard", __name__) -@bp.get("/uebersicht") +@bp.get("/overview") @login_required def index(): conn = db() diff --git a/apps/tesm-license/src/tesm_license/blueprints/licenses.py b/apps/tesm-license/src/tesm_license/blueprints/licenses.py index 2946205..c6ffd13 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/licenses.py +++ b/apps/tesm-license/src/tesm_license/blueprints/licenses.py @@ -38,7 +38,7 @@ from .common import ( vendor_info, ) -bp = Blueprint("licenses", __name__, url_prefix="/lizenzen") +bp = Blueprint("licenses", __name__, url_prefix="/licenses") @bp.get("/") @@ -49,7 +49,7 @@ def overview(): {**row, **issuance.display_state(row)} for row in issuance.list_licenses( conn, - customer_id=request.args.get("kunde", type=int), + customer_id=request.args.get("customer", type=int), ticket_id=request.args.get("ticket"), status=request.args.get("status"), ) @@ -99,7 +99,7 @@ def detail(license_id: str): # --------------------------------------------------------------------------- # -@bp.post("/aus-ticket/") +@bp.post("/from-ticket/") @permission_required("licenses.create") def issue(ticket_id: str): conn = db() @@ -140,7 +140,7 @@ def issue(ticket_id: str): return redirect(url_for("licenses.detail", license_id=result.license_id)) -@bp.get("//datei") +@bp.get("//file") @permission_required("licenses.export") # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS). def download(license_id: str): @@ -181,7 +181,7 @@ def download(license_id: str): ) -@bp.post("//neu-signieren") +@bp.post("//resign") @permission_required("licenses.edit") @reauth_required def resign(license_id: str): @@ -240,7 +240,7 @@ def resign(license_id: str): return redirect(url_for("licenses.detail", license_id=license_id)) -@bp.post("//widerrufen") +@bp.post("//revoke") @permission_required("licenses.edit") def revoke(license_id: str): conn = db() @@ -265,7 +265,7 @@ def revoke(license_id: str): return redirect(url_for("licenses.detail", license_id=license_id)) -@bp.post("//loeschen") +@bp.post("//delete") @permission_required("licenses.delete") def delete(license_id: str): conn = db() @@ -286,7 +286,7 @@ def delete(license_id: str): # --------------------------------------------------------------------------- # -@bp.post("//versenden") +@bp.post("//send") @permission_required("licenses.export") def send_mail(license_id: str): conn = db() @@ -428,7 +428,7 @@ def manual_code(): # --------------------------------------------------------------------------- # -@bp.get("/sub-lizenzserver") +@bp.get("/sub-servers") @permission_required("reports.view") def reports(): conn = db() @@ -440,7 +440,7 @@ def reports(): ) -@bp.get("/sub-lizenzserver/") +@bp.get("/sub-servers/") @permission_required("reports.view") def report_detail(license_id: str): conn = db() diff --git a/apps/tesm-license/src/tesm_license/blueprints/own_license.py b/apps/tesm-license/src/tesm_license/blueprints/own_license.py index 4c3c9a7..9da479d 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/own_license.py +++ b/apps/tesm-license/src/tesm_license/blueprints/own_license.py @@ -36,7 +36,7 @@ from .. import is_master_edition from ..services import master_key from .common import can_issue_license_server_licenses, log_event, signing_key -bp = Blueprint("own_license", __name__, url_prefix="/eigene-lizenz") +bp = Blueprint("own_license", __name__, url_prefix="/own-license") MAX_UPLOAD_BYTES = 256 * 1024 @@ -68,7 +68,7 @@ def overview(): ) -@bp.post("/einspielen") +@bp.post("/install") @permission_required("settings_license.edit") def upload(): extension = core() @@ -129,13 +129,13 @@ def _handshake(action: str): return redirect(url_for("own_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(): @@ -148,7 +148,7 @@ def heartbeat(): return _handshake("heartbeat") -@bp.post("/offline-antwort") +@bp.post("/offline-response") @permission_required("settings_license.edit") def offline_response(): manager = core().license @@ -166,7 +166,7 @@ def offline_response(): return redirect(url_for("own_license.overview")) -@bp.get("/signaturschluessel") +@bp.get("/signing-key") @permission_required("settings_license.view") def signing_key_info(): """Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden. diff --git a/apps/tesm-license/src/tesm_license/blueprints/selfservice.py b/apps/tesm-license/src/tesm_license/blueprints/selfservice.py index fe209e0..ebcc34c 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/selfservice.py +++ b/apps/tesm-license/src/tesm_license/blueprints/selfservice.py @@ -31,6 +31,8 @@ from .common import ( vendor_info, ) +# Die Adresse steht in verschickten Mails und in Tickets -- sie bleibt +# deutsch, waehrend der Rest der Anwendung auf englische Pfade wechselt. bp = Blueprint("selfservice", __name__, url_prefix="/lizenz") RATE_BUCKET = "selfservice" diff --git a/apps/tesm-license/src/tesm_license/blueprints/settings.py b/apps/tesm-license/src/tesm_license/blueprints/settings.py index a4da171..7b409d8 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/settings.py +++ b/apps/tesm-license/src/tesm_license/blueprints/settings.py @@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction from ..services import mail from .common import log_event, master_endpoint, vendor_info -bp = Blueprint("settings", __name__, url_prefix="/einstellungen") +bp = Blueprint("settings", __name__, url_prefix="/administration") MAX_LOGO_BYTES = 200 * 1024 LOGO_MIME = { @@ -68,6 +68,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 {}, sysops_available=bool(sysops and sysops.available), ) @@ -142,7 +143,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(): import time @@ -173,7 +174,7 @@ def set_timezone(): # --------------------------------------------------------------------------- # -@bp.get("/anbieter") +@bp.get("/vendor") @permission_required("settings_vendor.view") def vendor(): extension = core() @@ -195,7 +196,7 @@ def vendor(): ) -@bp.post("/anbieter") +@bp.post("/vendor") @permission_required("settings_vendor.edit") def vendor_save(): extension = core() @@ -245,7 +246,7 @@ def vendor_save(): return redirect(url_for("settings.vendor")) -@bp.post("/anbieter/logo-entfernen") +@bp.post("/vendor/remove-logo") @permission_required("settings_vendor.edit") def vendor_logo_remove(): with transaction() as conn: diff --git a/apps/tesm-license/src/tesm_license/blueprints/tickets.py b/apps/tesm-license/src/tesm_license/blueprints/tickets.py index 6dadf5a..85387bf 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/tickets.py +++ b/apps/tesm-license/src/tesm_license/blueprints/tickets.py @@ -45,7 +45,7 @@ def overview(): ) -@bp.get("/neu") +@bp.get("/new") @permission_required("tickets.create") def new(): conn = db() @@ -60,11 +60,11 @@ def new(): type_labels=type_labels(), default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365), may_create_license_server=can_issue_license_server_licenses(), - preselected_customer=request.args.get("kunde", type=int), + preselected_customer=request.args.get("customer", type=int), ) -@bp.post("/neu") +@bp.post("/new") @permission_required("tickets.create") def create(): conn = db() @@ -186,7 +186,7 @@ def update(ticket_id: str): return redirect(url_for("tickets.detail", ticket_id=ticket_id)) -@bp.post("//sperre") +@bp.post("//lock") @permission_required("tickets.edit") def toggle_block(ticket_id: str): conn = db() @@ -213,7 +213,7 @@ def toggle_block(ticket_id: str): return redirect(url_for("tickets.detail", ticket_id=ticket_id)) -@bp.post("//loeschen") +@bp.post("//delete") @permission_required("tickets.delete") def delete(ticket_id: str): conn = db() diff --git a/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html b/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html index 9672e14..7d8ae7d 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html +++ b/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html @@ -14,7 +14,7 @@ {% block topbar_extra %} {% if current_user.has('tickets.create') %} - + {{ icon('plus', size=15) }} Ticket anlegen {% endif %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/settings_system.html b/apps/tesm-license/src/tesm_license/templates/license/settings_system.html index 1511b55..ed095d0 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/settings_system.html +++ b/apps/tesm-license/src/tesm_license/templates/license/settings_system.html @@ -1,5 +1,5 @@ {% extends "tesm_core/base.html" %} -{% from "tesm_core/macros.html" import field, select_field, checkbox, alert %} +{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, nav_order_card %} {% block page_title %}Systemeinstellungen{% endblock %} {% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %} @@ -104,33 +104,9 @@ {% if current_user.is_admin %} -
-
-
-
{{ icon('sliders', size=17) }} Navigationsreihenfolge
-
Gilt für alle Benutzer
-
-
-
- -
-
    - {% for key in nav_order %} - {% set item = nav_items | selectattr('key', 'equalto', key) | list | first %} - {% if item %} -
  1. - - {{ icon(item.icon, size=15) }} {{ item.label }} -
  2. - {% endif %} - {% endfor %} -
-
- -
-
+ {{ nav_order_card(nav_items, nav_order, nav_child_order, + url_for('admin.nav_order'), csrf_field_name, csrf_token(), + sub='Gilt für alle Benutzer') }} {% endif %} diff --git a/deploy/install.sh b/deploy/install.sh index e673898..97d7904 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -620,7 +620,7 @@ for attempt in $(seq 1 20); do # pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete # eine andere Instanz, und ein toter Dienst galt als gesund. if systemctl is-active --quiet "$UNIT" && - curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then + curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then HEALTHY=1 info "Antwort nach ${attempt}x" break diff --git a/docs/BETRIEB.md b/docs/BETRIEB.md index 2327020..1593ccf 100644 --- a/docs/BETRIEB.md +++ b/docs/BETRIEB.md @@ -446,7 +446,7 @@ Unverzichtbar: **Die Anwendung antwortet nicht.** ```bash journalctl -u tesm -n 200 --no-pager -curl -s http://127.0.0.1:5000/gesundheit +curl -s http://127.0.0.1:5000/health ``` Antwortet gunicorn, aber der Browser nicht, liegt es an nginx. diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index 64c242f..ffa2816 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.4.1" +version = "2.5.0" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py index b85db96..1d251ce 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -62,6 +62,17 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = ( minimum=1, maximum=3650, ), + SettingSpec( + "session_absolute_hours", + 12, + "int", + "Laengstmoegliche Sitzungsdauer (Stunden)", + hint="Danach ist Schluss, auch bei staendiger Arbeit -- eine Sitzung soll nicht " + "ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.", + section="system", + minimum=1, + maximum=168, + ), SettingSpec( "session_idle_minutes", 30, @@ -135,7 +146,7 @@ def create_base_app( #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Anwendungen. auth_tagline: str = "", - app_version: str = "2.4.1", + app_version: str = "2.5.0", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, @@ -230,12 +241,21 @@ def create_base_app( wert = 0 return wert if wert > 0 else config.security.session_idle_minutes + def _gesamtdauer(conn: Any) -> int: + """Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung.""" + try: + wert = int(settings.get(conn, "session_absolute_hours") or 0) + except (TypeError, ValueError): + wert = 0 + return wert if wert > 0 else config.security.session_absolute_hours + app.session_interface = SqliteSessionInterface( database, cookie_name=app.config["SESSION_COOKIE_NAME"], idle_minutes=config.security.session_idle_minutes, absolute_hours=config.security.session_absolute_hours, idle_lookup=_leerlauf, + absolute_lookup=_gesamtdauer, ) # -- Schema und Startzustand ------------------------------------------ # @@ -399,7 +419,7 @@ def create_base_app( with app.app_context(): trash_views.register_core_providers() - app.add_url_rule("/gesundheit", "health", _health, methods=["GET"]) + app.add_url_rule("/health", "health", _health, methods=["GET"]) return app diff --git a/packages/tesm-core/src/tesm_core/auth/routes.py b/packages/tesm-core/src/tesm_core/auth/routes.py index 264c058..122e266 100644 --- a/packages/tesm-core/src/tesm_core/auth/routes.py +++ b/packages/tesm-core/src/tesm_core/auth/routes.py @@ -66,7 +66,7 @@ def login(): ) -@bp.route("/login/zweiter-faktor", methods=["GET", "POST"]) +@bp.route("/login/second-factor", methods=["GET", "POST"]) def second_factor(): conn = db() user_row = service.pending_totp_user(conn) @@ -116,7 +116,7 @@ def logout_get(): return redirect(url_for("auth.login")) -@bp.route("/bestaetigen", methods=["GET", "POST"]) +@bp.route("/confirm", methods=["GET", "POST"]) @login_required def reauth(): target = _safe_next(session.get("_after_reauth")) diff --git a/packages/tesm-core/src/tesm_core/auth/service.py b/packages/tesm-core/src/tesm_core/auth/service.py index c824af0..abf686b 100644 --- a/packages/tesm-core/src/tesm_core/auth/service.py +++ b/packages/tesm-core/src/tesm_core/auth/service.py @@ -257,6 +257,24 @@ def _verify_credentials( return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns) + +def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool: + """Meldet sich dieses Konto zum ersten Mal ueberhaupt an? + + Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung + ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte + Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr + entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die + Anwendung, der vorher nicht darin war. + + Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen + weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante + Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war, + steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht. + """ + return not str(user_row.get("last_login_at") or "").strip() + + def complete_login( conn: sqlite3.Connection, user_row: dict[str, Any], @@ -275,21 +293,26 @@ def complete_login( """ from ..security import csrf + # Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel. + erstanmeldung = _ist_erstanmeldung(user_row) + with core().database.transaction(conn): session_store.rotate(conn, session, reason="login") # type: ignore[arg-type] conn.execute( "UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"]) ) - audit.log( - conn, - actor=str(user_row["username"]), - actor_id=int(user_row["id"]), - action="auth.login", - target=str(user_row["username"]), - detail=f"Quelle: {user_row.get('auth_source') or 'local'}", - ip=ip, - request_id=getattr(g, "request_id", ""), - ) + if erstanmeldung: + audit.log( + conn, + actor=str(user_row["username"]), + actor_id=int(user_row["id"]), + action="auth.first_login", + target=str(user_row["username"]), + detail=f"Erste Anmeldung -- Quelle: {user_row.get('auth_source') or 'local'}", + ip=ip, + request_id=getattr(g, "request_id", ""), + severity="notice", + ) session.clear() session[SESSION_USER_KEY] = int(user_row["id"]) diff --git a/packages/tesm-core/src/tesm_core/security/sessions.py b/packages/tesm-core/src/tesm_core/security/sessions.py index 2450f7d..ccb88e6 100644 --- a/packages/tesm-core/src/tesm_core/security/sessions.py +++ b/packages/tesm-core/src/tesm_core/security/sessions.py @@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface): idle_minutes: int, absolute_hours: int, idle_lookup: Any = None, + absolute_lookup: Any = None, ) -> None: self.database = database self.cookie_name = cookie_name @@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface): #: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und #: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene. self._idle_lookup = idle_lookup + self._absolute_lookup = absolute_lookup + + def _absolute(self, conn: Any) -> int: + """Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze.""" + if self._absolute_lookup is None: + return self.absolute_hours + try: + return int(self._absolute_lookup(conn)) or self.absolute_hours + except Exception: # pragma: no cover - Einstellung unlesbar + return self.absolute_hours def _idle(self, conn: Any) -> int: if self._idle_lookup is None: @@ -280,7 +291,10 @@ class SqliteSessionInterface(SessionInterface): payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True) with self.database.session() as conn, self.database.transaction(conn): if session.new or not session.sid: - session.sid = secrets.token_urlsafe(SESSION_ID_BYTES) + # Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim + # Anmelden steht sie schon, und andere haben sie sich gemerkt. + if not session.sid: + session.sid = secrets.token_urlsafe(SESSION_ID_BYTES) conn.execute( "INSERT INTO user_sessions " "(id, user_id, created_at, last_seen_at, idle_expires_at, " @@ -292,7 +306,7 @@ class SqliteSessionInterface(SessionInterface): _iso(now), _iso(now), _iso(now + timedelta(minutes=self._idle(conn))), - _iso(now + timedelta(hours=self.absolute_hours)), + _iso(now + timedelta(hours=self._absolute(conn))), session.get("_ip", ""), session.get("_ua", ""), payload, @@ -371,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p "UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?", (_iso(_now()), reason, old_sid), ) - session.sid = "" + # Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort. + # Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die + # Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren + # Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste + # sie binnen Minuten wieder. + session.sid = secrets.token_urlsafe(SESSION_ID_BYTES) session.new = True session.modified = True return old_sid diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css index 7d682b4..5873a4b 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css @@ -1315,3 +1315,72 @@ .hide-sm { display: none !important; } } } + +/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */ +/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst. + Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie + etwas anderes als das, was sie sortiert. */ + +.reorder { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.reorder__row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--surface-2); + cursor: grab; +} + +.reorder__row > .flex-1 { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.reorder__row.is-dragging { + opacity: 0.5; +} + +.reorder__grip { + color: var(--text-muted); + display: inline-flex; +} + +.reorder__pos { + min-width: 1.6em; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-size: var(--fs-xs); +} + +.reorder__moves { + display: inline-flex; + gap: 2px; +} + +.reorder__group { + list-style: none; +} + +.reorder--child { + margin: 4px 0 4px 28px; +} + +.reorder--child .reorder__row { + background: transparent; + border-style: dashed; +} diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js index 40f006c..ef1e071 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js @@ -856,6 +856,81 @@ function initTileFilters() { }); } +/* -- Reihenfolge aendern -------------------------------------------------- */ + +/** + * Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek. + * + * Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das + * Formular schickt sie als wiederholtes Feld, und der Server liest sie als + * Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm. + * + * Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und + * zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem + * Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der + * Tastatur ebenso. + */ +function initReorder() { + $$('[data-behavior="reorder"]').forEach((liste) => { + // Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare + // Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser. + const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]")); + + const nummerieren = () => { + zeilen().forEach((zeile, index) => { + const nummer = zeile.querySelector("[data-reorder-position-text]"); + if (nummer) nummer.textContent = String(index + 1); + const hoch = zeile.querySelector('[data-reorder-move="up"]'); + const runter = zeile.querySelector('[data-reorder-move="down"]'); + if (hoch) hoch.disabled = index === 0; + if (runter) runter.disabled = index === zeilen().length - 1; + }); + }; + + liste.addEventListener("click", (event) => { + const knopf = event.target.closest("[data-reorder-move]"); + if (!knopf) return; + const zeile = knopf.closest("[data-reorder-item]"); + if (!zeile || zeile.parentElement !== liste) return; + event.preventDefault(); + if (knopf.dataset.reorderMove === "up") { + const davor = zeile.previousElementSibling; + if (davor) liste.insertBefore(zeile, davor); + } else { + const danach = zeile.nextElementSibling; + if (danach) liste.insertBefore(danach, zeile); + } + nummerieren(); + zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus(); + }); + + let gezogen = null; + liste.addEventListener("dragstart", (event) => { + const zeile = event.target.closest("[data-reorder-item]"); + if (!zeile || zeile.parentElement !== liste) return; + gezogen = zeile; + gezogen.classList.add("is-dragging"); + if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; + }); + liste.addEventListener("dragend", () => { + if (gezogen) gezogen.classList.remove("is-dragging"); + gezogen = null; + nummerieren(); + }); + liste.addEventListener("dragover", (event) => { + if (!gezogen) return; + event.preventDefault(); + const ziel = event.target.closest("[data-reorder-item]"); + if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return; + const kasten = ziel.getBoundingClientRect(); + const obereHaelfte = event.clientY < kasten.top + kasten.height / 2; + liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling); + }); + + nummerieren(); + }); +} + /* -- Start ---------------------------------------------------------------- */ /** @@ -893,6 +968,7 @@ function boot() { initPalette(); initSubmitGuards(); initTileFilters(); + initReorder(); initLogTails(); unsavedGuard.init(); initViewer(); diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js index 22a6931..9b761ec 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js @@ -651,7 +651,7 @@ export function initFilePreview(wurzel = document) { abschliessen: modusFeld?.value === "fertig", }); const daten = new FormData(); - daten.append("pfad", aktuell.pfad); + daten.append("path", aktuell.pfad); daten.append("ordner", ordnerFeld.value.trim()); daten.append("name", nameFeld.value.trim()); if (ersetzenFeld.checked) daten.append("ersetzen", "1"); diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html index 2f1e572..709644f 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html @@ -24,7 +24,7 @@ nicht bei jedem Neustart auf die Vorlage zurückgesetzt – Anpassungen bleiben erhalten. {% if current_user.has('groups.edit') %} - + {{ icon('unlock', size=14) }} Rechte freischalten {% endif %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html index 364e7ea..c6a2f0e 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html @@ -26,19 +26,19 @@ {{ icon('search', size=15) }} - {% for action in actions %} - + {% endfor %} - {% for level in ['info', 'notice', 'warning', 'critical'] %} - + {% endfor %} - Zurücksetzen @@ -88,10 +88,10 @@ Seite {{ page }} {% if page > 1 %} - Zurück + Zurück {% endif %} {% if rows | length == per_page %} - Weiter + Weiter {% endif %} 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-server/pyproject.toml b/packages/tesm-licensing-server/pyproject.toml index 6d97124..80aef6c 100644 --- a/packages/tesm-licensing-server/pyproject.toml +++ b/packages/tesm-licensing-server/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing-server" -version = "2.4.1" +version = "2.5.0" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" requires-python = ">=3.11" -dependencies = ["tesm-licensing==2.4.1", "cryptography>=41"] +dependencies = ["tesm-licensing==2.5.0", "cryptography>=41"] [tool.setuptools.packages.find] where = ["src"] 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"]