Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5ae10814f | ||
|
|
37a200e983 | ||
|
|
de9c0d4fbd | ||
|
|
724052ae28 | ||
|
|
e16236fa65 | ||
|
|
c52b3626b2 | ||
|
|
1ac5dc1637 | ||
|
|
f2d5324449 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-license"
|
name = "tesm-license"
|
||||||
version = "2.4.0"
|
version = "2.7.1"
|
||||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
from ..services import transfer
|
from ..services import transfer
|
||||||
from .common import log_event
|
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
|
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
||||||
PREVIEW_SESSION_KEY = "_import_token"
|
PREVIEW_SESSION_KEY = "_import_token"
|
||||||
@@ -57,7 +57,7 @@ def overview():
|
|||||||
|
|
||||||
@bp.post("/export")
|
@bp.post("/export")
|
||||||
@permission_required("backup.export")
|
@permission_required("backup.export")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def export():
|
def export():
|
||||||
conn = db()
|
conn = db()
|
||||||
allowed = {category.key for category in _allowed_categories()}
|
allowed = {category.key for category in _allowed_categories()}
|
||||||
@@ -94,7 +94,7 @@ def export():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/vorschau")
|
@bp.post("/import/preview")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def import_preview():
|
def import_preview():
|
||||||
@@ -131,7 +131,7 @@ def import_preview():
|
|||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/uebernehmen")
|
@bp.post("/import/apply")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def import_apply():
|
def import_apply():
|
||||||
@@ -190,7 +190,7 @@ def import_apply():
|
|||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/verwerfen")
|
@bp.post("/import/discard")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup.execute")
|
||||||
def import_discard():
|
def import_discard():
|
||||||
token = session.pop(PREVIEW_SESSION_KEY, "")
|
token = session.pop(PREVIEW_SESSION_KEY, "")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from tesm_core.extension import db, transaction
|
|||||||
from ..services import issuance, reporting
|
from ..services import issuance, reporting
|
||||||
from .common import customer_facing_url, log_event
|
from .common import customer_facing_url, log_event
|
||||||
|
|
||||||
bp = Blueprint("customers", __name__, url_prefix="/kunden")
|
bp = Blueprint("customers", __name__, url_prefix="/customers")
|
||||||
|
|
||||||
FIELDS = (
|
FIELDS = (
|
||||||
"name",
|
"name",
|
||||||
@@ -61,7 +61,7 @@ def detail(customer_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neu")
|
@bp.post("/new")
|
||||||
@permission_required("customers.create")
|
@permission_required("customers.create")
|
||||||
def create():
|
def create():
|
||||||
try:
|
try:
|
||||||
@@ -103,7 +103,7 @@ def update(customer_id: int):
|
|||||||
return redirect(url_for("customers.detail", customer_id=customer_id))
|
return redirect(url_for("customers.detail", customer_id=customer_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:customer_id>/loeschen")
|
@bp.post("/<int:customer_id>/delete")
|
||||||
@permission_required("customers.delete")
|
@permission_required("customers.delete")
|
||||||
def delete(customer_id: int):
|
def delete(customer_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from ..services import issuance, reporting
|
|||||||
bp = Blueprint("dashboard", __name__)
|
bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/uebersicht")
|
@bp.get("/overview")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
conn = db()
|
conn = db()
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ from .common import (
|
|||||||
vendor_info,
|
vendor_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
bp = Blueprint("licenses", __name__, url_prefix="/lizenzen")
|
bp = Blueprint("licenses", __name__, url_prefix="/licenses")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@@ -49,7 +49,7 @@ def overview():
|
|||||||
{**row, **issuance.display_state(row)}
|
{**row, **issuance.display_state(row)}
|
||||||
for row in issuance.list_licenses(
|
for row in issuance.list_licenses(
|
||||||
conn,
|
conn,
|
||||||
customer_id=request.args.get("kunde", type=int),
|
customer_id=request.args.get("customer", type=int),
|
||||||
ticket_id=request.args.get("ticket"),
|
ticket_id=request.args.get("ticket"),
|
||||||
status=request.args.get("status"),
|
status=request.args.get("status"),
|
||||||
)
|
)
|
||||||
@@ -99,7 +99,7 @@ def detail(license_id: str):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aus-ticket/<ticket_id>")
|
@bp.post("/from-ticket/<ticket_id>")
|
||||||
@permission_required("licenses.create")
|
@permission_required("licenses.create")
|
||||||
def issue(ticket_id: str):
|
def issue(ticket_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -140,9 +140,9 @@ def issue(ticket_id: str):
|
|||||||
return redirect(url_for("licenses.detail", license_id=result.license_id))
|
return redirect(url_for("licenses.detail", license_id=result.license_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<license_id>/datei")
|
@bp.get("/<license_id>/file")
|
||||||
@permission_required("licenses.export")
|
@permission_required("licenses.export")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def download(license_id: str):
|
def download(license_id: str):
|
||||||
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
|
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ def download(license_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/neu-signieren")
|
@bp.post("/<license_id>/resign")
|
||||||
@permission_required("licenses.edit")
|
@permission_required("licenses.edit")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def resign(license_id: str):
|
def resign(license_id: str):
|
||||||
@@ -240,7 +240,7 @@ def resign(license_id: str):
|
|||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/widerrufen")
|
@bp.post("/<license_id>/revoke")
|
||||||
@permission_required("licenses.edit")
|
@permission_required("licenses.edit")
|
||||||
def revoke(license_id: str):
|
def revoke(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -265,7 +265,7 @@ def revoke(license_id: str):
|
|||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/loeschen")
|
@bp.post("/<license_id>/delete")
|
||||||
@permission_required("licenses.delete")
|
@permission_required("licenses.delete")
|
||||||
def delete(license_id: str):
|
def delete(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -286,7 +286,7 @@ def delete(license_id: str):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/versenden")
|
@bp.post("/<license_id>/send")
|
||||||
@permission_required("licenses.export")
|
@permission_required("licenses.export")
|
||||||
def send_mail(license_id: str):
|
def send_mail(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -428,7 +428,7 @@ def manual_code():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sub-lizenzserver")
|
@bp.get("/sub-servers")
|
||||||
@permission_required("reports.view")
|
@permission_required("reports.view")
|
||||||
def reports():
|
def reports():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -440,7 +440,7 @@ def reports():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sub-lizenzserver/<license_id>")
|
@bp.get("/sub-servers/<license_id>")
|
||||||
@permission_required("reports.view")
|
@permission_required("reports.view")
|
||||||
def report_detail(license_id: str):
|
def report_detail(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from .. import is_master_edition
|
|||||||
from ..services import master_key
|
from ..services import master_key
|
||||||
from .common import can_issue_license_server_licenses, log_event, signing_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
|
MAX_UPLOAD_BYTES = 256 * 1024
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/einspielen")
|
@bp.post("/install")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.edit")
|
||||||
def upload():
|
def upload():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -129,13 +129,13 @@ def _handshake(action: str):
|
|||||||
return redirect(url_for("own_license.overview"))
|
return redirect(url_for("own_license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aktivieren")
|
@bp.post("/activate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.edit")
|
||||||
def activate():
|
def activate():
|
||||||
return _handshake("activate")
|
return _handshake("activate")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/deaktivieren")
|
@bp.post("/deactivate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.edit")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def deactivate():
|
def deactivate():
|
||||||
@@ -148,7 +148,7 @@ def heartbeat():
|
|||||||
return _handshake("heartbeat")
|
return _handshake("heartbeat")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/offline-antwort")
|
@bp.post("/offline-response")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.edit")
|
||||||
def offline_response():
|
def offline_response():
|
||||||
manager = core().license
|
manager = core().license
|
||||||
@@ -166,7 +166,7 @@ def offline_response():
|
|||||||
return redirect(url_for("own_license.overview"))
|
return redirect(url_for("own_license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/signaturschluessel")
|
@bp.get("/signing-key")
|
||||||
@permission_required("settings_license.view")
|
@permission_required("settings_license.view")
|
||||||
def signing_key_info():
|
def signing_key_info():
|
||||||
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
|
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from .common import (
|
|||||||
vendor_info,
|
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")
|
bp = Blueprint("selfservice", __name__, url_prefix="/lizenz")
|
||||||
|
|
||||||
RATE_BUCKET = "selfservice"
|
RATE_BUCKET = "selfservice"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
from ..services import mail
|
from ..services import mail
|
||||||
from .common import log_event, master_endpoint, vendor_info
|
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
|
MAX_LOGO_BYTES = 200 * 1024
|
||||||
LOGO_MIME = {
|
LOGO_MIME = {
|
||||||
@@ -54,6 +54,8 @@ def system():
|
|||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
"log_rotate_interval",
|
"log_rotate_interval",
|
||||||
"log_rotate_keep",
|
"log_rotate_keep",
|
||||||
|
"session_idle_minutes",
|
||||||
|
"session_absolute_hours",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
specs={spec.key: spec for spec in extension.settings.specs()},
|
specs={spec.key: spec for spec in extension.settings.specs()},
|
||||||
@@ -68,6 +70,7 @@ def system():
|
|||||||
timezones=_timezone_choices(),
|
timezones=_timezone_choices(),
|
||||||
nav_items=extension.nav.items,
|
nav_items=extension.nav.items,
|
||||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
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),
|
sysops_available=bool(sysops and sysops.available),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,6 +100,8 @@ def system_save():
|
|||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
"log_rotate_interval",
|
"log_rotate_interval",
|
||||||
"log_rotate_keep",
|
"log_rotate_keep",
|
||||||
|
"session_idle_minutes",
|
||||||
|
"session_absolute_hours",
|
||||||
):
|
):
|
||||||
spec = extension.settings.spec(key)
|
spec = extension.settings.spec(key)
|
||||||
if spec.kind == "bool":
|
if spec.kind == "bool":
|
||||||
@@ -142,7 +147,7 @@ def set_hostname():
|
|||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system/zeitzone")
|
@bp.post("/system/timezone")
|
||||||
@permission_required("settings_system.execute")
|
@permission_required("settings_system.execute")
|
||||||
def set_timezone():
|
def set_timezone():
|
||||||
import time
|
import time
|
||||||
@@ -173,7 +178,7 @@ def set_timezone():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/anbieter")
|
@bp.get("/vendor")
|
||||||
@permission_required("settings_vendor.view")
|
@permission_required("settings_vendor.view")
|
||||||
def vendor():
|
def vendor():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -195,7 +200,7 @@ def vendor():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/anbieter")
|
@bp.post("/vendor")
|
||||||
@permission_required("settings_vendor.edit")
|
@permission_required("settings_vendor.edit")
|
||||||
def vendor_save():
|
def vendor_save():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -245,7 +250,7 @@ def vendor_save():
|
|||||||
return redirect(url_for("settings.vendor"))
|
return redirect(url_for("settings.vendor"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/anbieter/logo-entfernen")
|
@bp.post("/vendor/remove-logo")
|
||||||
@permission_required("settings_vendor.edit")
|
@permission_required("settings_vendor.edit")
|
||||||
def vendor_logo_remove():
|
def vendor_logo_remove():
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/neu")
|
@bp.get("/new")
|
||||||
@permission_required("tickets.create")
|
@permission_required("tickets.create")
|
||||||
def new():
|
def new():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -60,11 +60,11 @@ def new():
|
|||||||
type_labels=type_labels(),
|
type_labels=type_labels(),
|
||||||
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
|
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
|
||||||
may_create_license_server=can_issue_license_server_licenses(),
|
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")
|
@permission_required("tickets.create")
|
||||||
def create():
|
def create():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -186,7 +186,7 @@ def update(ticket_id: str):
|
|||||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<ticket_id>/sperre")
|
@bp.post("/<ticket_id>/lock")
|
||||||
@permission_required("tickets.edit")
|
@permission_required("tickets.edit")
|
||||||
def toggle_block(ticket_id: str):
|
def toggle_block(ticket_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -213,7 +213,7 @@ def toggle_block(ticket_id: str):
|
|||||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<ticket_id>/loeschen")
|
@bp.post("/<ticket_id>/delete")
|
||||||
@permission_required("tickets.delete")
|
@permission_required("tickets.delete")
|
||||||
def delete(ticket_id: str):
|
def delete(ticket_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def _is_master(_user: Any) -> bool:
|
|||||||
|
|
||||||
NAVIGATION = NavTree(
|
NAVIGATION = NavTree(
|
||||||
[
|
[
|
||||||
NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"),
|
NavItem(key="index", label="Übersicht", icon="grid", endpoint="dashboard.index"),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="customers",
|
key="customers",
|
||||||
label="Kunden",
|
label="Kunden",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{% if current_user.has('tickets.create') %}
|
{% if current_user.has('tickets.create') %}
|
||||||
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', kunde=customer.id) }}">
|
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', customer=customer.id) }}">
|
||||||
{{ icon('plus', size=15) }} Ticket anlegen
|
{{ icon('plus', size=15) }} Ticket anlegen
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% 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_title %}Systemeinstellungen{% endblock %}
|
||||||
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
|
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
|
||||||
@@ -60,9 +60,6 @@
|
|||||||
selected=values.log_rotate_interval) }}
|
selected=values.log_rotate_interval) }}
|
||||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||||
type='number') }}
|
type='number') }}
|
||||||
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
|
||||||
value=values.session_idle_minutes, type='number',
|
|
||||||
hint_text=specs['session_idle_minutes'].hint) }}
|
|
||||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||||
value=values.trash_retention_days, type='number') }}
|
value=values.trash_retention_days, type='number') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,6 +70,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
|
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
|
||||||
|
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
|
||||||
|
sucht sie nicht unter "Protokolle". #}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
|
||||||
|
<div class="card__sub">Wie lange eine Anmeldung gilt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body">
|
||||||
|
<div class="form-grid">
|
||||||
|
{{ field('session_idle_minutes', 'Automatische Abmeldung nach Leerlauf (Minuten)',
|
||||||
|
value=values.session_idle_minutes, type='number',
|
||||||
|
attrs='min="5" max="1440" step="1"',
|
||||||
|
hint_text=specs['session_idle_minutes'].hint) }}
|
||||||
|
{{ field('session_absolute_hours', 'Längstmögliche Sitzungsdauer (Stunden)',
|
||||||
|
value=values.session_absolute_hours, type='number',
|
||||||
|
attrs='min="1" max="168" step="1"',
|
||||||
|
hint_text=specs['session_absolute_hours'].hint) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
||||||
@@ -104,33 +129,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<section class="card">
|
{{ nav_order_card(nav_items, nav_order, nav_child_order,
|
||||||
<div class="card__header">
|
url_for('admin.nav_order'), csrf_field_name, csrf_token(),
|
||||||
<div class="flex-1">
|
sub='Gilt für alle Benutzer') }}
|
||||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
|
||||||
<div class="card__sub">Gilt für alle Benutzer</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
|
||||||
<div class="card__body">
|
|
||||||
<ol class="stack stack--tight">
|
|
||||||
{% for key in nav_order %}
|
|
||||||
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
|
|
||||||
{% if item %}
|
|
||||||
<li class="row row--tight">
|
|
||||||
<input type="hidden" name="order" value="{{ item.key }}">
|
|
||||||
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<div class="card__footer">
|
|
||||||
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -620,7 +620,7 @@ for attempt in $(seq 1 20); do
|
|||||||
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
||||||
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
||||||
if systemctl is-active --quiet "$UNIT" &&
|
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
|
HEALTHY=1
|
||||||
info "Antwort nach ${attempt}x"
|
info "Antwort nach ${attempt}x"
|
||||||
break
|
break
|
||||||
|
|||||||
+1
-1
@@ -446,7 +446,7 @@ Unverzichtbar:
|
|||||||
**Die Anwendung antwortet nicht.**
|
**Die Anwendung antwortet nicht.**
|
||||||
```bash
|
```bash
|
||||||
journalctl -u tesm -n 200 --no-pager
|
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.
|
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.4.0"
|
version = "2.7.1"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import secrets
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
from flask import Blueprint, Flask, g, render_template, request
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from flask import Blueprint, Flask, g, redirect, render_template, request
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from . import audit, logging_setup, ui
|
from . import audit, legacy_urls, logging_setup, ui
|
||||||
from .config import CoreConfig
|
from .config import CoreConfig
|
||||||
from .core_schema import CORE_MIGRATIONS
|
from .core_schema import CORE_MIGRATIONS
|
||||||
from .db import Database
|
from .db import Database
|
||||||
@@ -62,6 +64,17 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=3650,
|
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(
|
SettingSpec(
|
||||||
"session_idle_minutes",
|
"session_idle_minutes",
|
||||||
30,
|
30,
|
||||||
@@ -135,7 +148,7 @@ def create_base_app(
|
|||||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||||
#: Anwendungen.
|
#: Anwendungen.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.4.0",
|
app_version: str = "2.7.1",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
@@ -230,12 +243,21 @@ def create_base_app(
|
|||||||
wert = 0
|
wert = 0
|
||||||
return wert if wert > 0 else config.security.session_idle_minutes
|
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(
|
app.session_interface = SqliteSessionInterface(
|
||||||
database,
|
database,
|
||||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||||
idle_minutes=config.security.session_idle_minutes,
|
idle_minutes=config.security.session_idle_minutes,
|
||||||
absolute_hours=config.security.session_absolute_hours,
|
absolute_hours=config.security.session_absolute_hours,
|
||||||
idle_lookup=_leerlauf,
|
idle_lookup=_leerlauf,
|
||||||
|
absolute_lookup=_gesamtdauer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Schema und Startzustand ------------------------------------------ #
|
# -- Schema und Startzustand ------------------------------------------ #
|
||||||
@@ -336,6 +358,16 @@ def create_base_app(
|
|||||||
# -- Fehlerseiten ------------------------------------------------------ #
|
# -- Fehlerseiten ------------------------------------------------------ #
|
||||||
@app.errorhandler(HTTPException)
|
@app.errorhandler(HTTPException)
|
||||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||||
|
if error.code == 404:
|
||||||
|
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
|
||||||
|
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
|
||||||
|
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
|
||||||
|
ziel = legacy_urls.neue_adresse(request.path)
|
||||||
|
if ziel is not None:
|
||||||
|
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
|
||||||
|
if parameter:
|
||||||
|
ziel = ziel + "?" + urlencode(parameter)
|
||||||
|
return redirect(ziel)
|
||||||
if error.code == 413:
|
if error.code == 413:
|
||||||
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
||||||
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
||||||
@@ -399,7 +431,7 @@ def create_base_app(
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
trash_views.register_core_providers()
|
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
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _verlangt_wiederanmeldung(permission: str) -> bool:
|
||||||
|
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
|
||||||
|
try:
|
||||||
|
from ..extension import core
|
||||||
|
|
||||||
|
return bool(core().permissions.verlangt_wiederanmeldung(permission))
|
||||||
|
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _wiederanmeldung_frisch() -> bool:
|
||||||
|
from .service import reauth_is_fresh # zyklischen Import vermeiden
|
||||||
|
|
||||||
|
return reauth_is_fresh()
|
||||||
|
|
||||||
|
|
||||||
|
def _bitte_bestaetigen(permission: str) -> Any:
|
||||||
|
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
|
||||||
|
|
||||||
|
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
|
||||||
|
nichts damit anzufangen.
|
||||||
|
"""
|
||||||
|
if is_automatic(request) or (
|
||||||
|
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
|
||||||
|
):
|
||||||
|
abort(403, description="Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.")
|
||||||
|
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||||
|
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||||
|
return redirect(url_for("auth.reauth"))
|
||||||
|
|
||||||
|
|
||||||
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
||||||
"""Erzwingt Rechte serverseitig.
|
"""Erzwingt Rechte serverseitig.
|
||||||
|
|
||||||
@@ -238,6 +269,14 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
|
|||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
|
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
|
||||||
|
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
|
||||||
|
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
|
||||||
|
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
|
||||||
|
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
|
||||||
|
# sie per Mail zu verschicken nicht.
|
||||||
|
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
|
||||||
|
if heikel and not _wiederanmeldung_frisch():
|
||||||
|
return _bitte_bestaetigen(heikel[0])
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -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():
|
def second_factor():
|
||||||
conn = db()
|
conn = db()
|
||||||
user_row = service.pending_totp_user(conn)
|
user_row = service.pending_totp_user(conn)
|
||||||
@@ -116,7 +116,7 @@ def logout_get():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
@bp.route("/confirm", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def reauth():
|
def reauth():
|
||||||
target = _safe_next(session.get("_after_reauth"))
|
target = _safe_next(session.get("_after_reauth"))
|
||||||
|
|||||||
@@ -257,6 +257,24 @@ def _verify_credentials(
|
|||||||
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
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(
|
def complete_login(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
user_row: dict[str, Any],
|
user_row: dict[str, Any],
|
||||||
@@ -275,21 +293,26 @@ def complete_login(
|
|||||||
"""
|
"""
|
||||||
from ..security import csrf
|
from ..security import csrf
|
||||||
|
|
||||||
|
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
|
||||||
|
erstanmeldung = _ist_erstanmeldung(user_row)
|
||||||
|
|
||||||
with core().database.transaction(conn):
|
with core().database.transaction(conn):
|
||||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||||
)
|
)
|
||||||
audit.log(
|
if erstanmeldung:
|
||||||
conn,
|
audit.log(
|
||||||
actor=str(user_row["username"]),
|
conn,
|
||||||
actor_id=int(user_row["id"]),
|
actor=str(user_row["username"]),
|
||||||
action="auth.login",
|
actor_id=int(user_row["id"]),
|
||||||
target=str(user_row["username"]),
|
action="auth.first_login",
|
||||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
target=str(user_row["username"]),
|
||||||
ip=ip,
|
detail=f"Erste Anmeldung -- Quelle: {user_row.get('auth_source') or 'local'}",
|
||||||
request_id=getattr(g, "request_id", ""),
|
ip=ip,
|
||||||
)
|
request_id=getattr(g, "request_id", ""),
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
|
|
||||||
session.clear()
|
session.clear()
|
||||||
session[SESSION_USER_KEY] = int(user_row["id"])
|
session[SESSION_USER_KEY] = int(user_row["id"])
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
|
||||||
|
|
||||||
|
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
|
||||||
|
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
|
||||||
|
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
|
||||||
|
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
|
||||||
|
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
|
||||||
|
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
|
||||||
|
|
||||||
|
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
|
||||||
|
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
|
||||||
|
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
|
||||||
|
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
|
||||||
|
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
|
||||||
|
beidem entscheiden.
|
||||||
|
|
||||||
|
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
|
||||||
|
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
|
||||||
|
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
|
||||||
|
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
|
||||||
|
"/dateifreigaben": (
|
||||||
|
"/fileshares",
|
||||||
|
{
|
||||||
|
"herunterladen": "download",
|
||||||
|
"vorschau": "preview",
|
||||||
|
"hochladen": "upload",
|
||||||
|
"formular": "form",
|
||||||
|
"ordner": "folder",
|
||||||
|
"umbenennen": "rename",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"freigeben": "release",
|
||||||
|
"zuordnungen": "mappings",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/hostschluessel": (
|
||||||
|
"/monitoring/host-keys",
|
||||||
|
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
|
||||||
|
),
|
||||||
|
"/zugangsdaten": (
|
||||||
|
"/monitoring/credentials",
|
||||||
|
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
|
||||||
|
),
|
||||||
|
"/switche": (
|
||||||
|
"/monitoring/switches",
|
||||||
|
{
|
||||||
|
"neu": "new",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"hostkey": "host-key",
|
||||||
|
"pruefen": "probe",
|
||||||
|
"freigeben": "trust",
|
||||||
|
"verwerfen": "forget",
|
||||||
|
"kommando": "command",
|
||||||
|
"konsole": "console",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/clients": (
|
||||||
|
"/monitoring/clients",
|
||||||
|
{
|
||||||
|
"neu": "new",
|
||||||
|
"aktiv": "active",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"neustart": "restart",
|
||||||
|
"hostkey": "host-key",
|
||||||
|
"pruefen": "probe",
|
||||||
|
"freigeben": "trust",
|
||||||
|
"verwerfen": "forget",
|
||||||
|
"konsole": "console",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/wartung": (
|
||||||
|
"/maintenance",
|
||||||
|
{
|
||||||
|
"paket-nachinstallieren": "install-package",
|
||||||
|
"auftrag": "job",
|
||||||
|
"ausgabe": "output",
|
||||||
|
"aktualisieren": "update",
|
||||||
|
"neustarten": "restart",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/protokolle": (
|
||||||
|
"/logs",
|
||||||
|
{
|
||||||
|
"kea": "dhcp",
|
||||||
|
"neustarts": "restarts",
|
||||||
|
"laufend": "live",
|
||||||
|
"inhalt": "content",
|
||||||
|
"verlauf": "history",
|
||||||
|
"datei": "file",
|
||||||
|
"archiv": "archive",
|
||||||
|
"archiv-export": "archive-export",
|
||||||
|
"archiv-loeschen": "archive-delete",
|
||||||
|
"aenderungen": "audit",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/verwaltung": (
|
||||||
|
"/administration",
|
||||||
|
{
|
||||||
|
"benutzer": "users",
|
||||||
|
"neu": "new",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"sitzungen-beenden": "end-sessions",
|
||||||
|
"verzeichnis-suche": "directory-search",
|
||||||
|
"verzeichnis-uebernehmen": "directory-import",
|
||||||
|
"gruppen": "groups",
|
||||||
|
"vorlage-anwenden": "apply-preset",
|
||||||
|
"sitzungen": "sessions",
|
||||||
|
"beenden": "end",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/einstellungen": (
|
||||||
|
"/administration",
|
||||||
|
{
|
||||||
|
"zeitzone": "timezone",
|
||||||
|
"pruefung-jetzt": "check-now",
|
||||||
|
"netzwerk": "network",
|
||||||
|
"bestaetigen": "confirm",
|
||||||
|
"webserver": "web",
|
||||||
|
"uebernehmen": "apply",
|
||||||
|
"zertifikat": "certificate",
|
||||||
|
"selbstsigniert": "self-signed",
|
||||||
|
"verzeichnisdienst": "directory",
|
||||||
|
"gruppensuche": "group-search",
|
||||||
|
"zuordnung": "mapping",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"anbieter": "vendor",
|
||||||
|
"logo-entfernen": "remove-logo",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/sicherung": (
|
||||||
|
"/administration/backup",
|
||||||
|
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
|
||||||
|
),
|
||||||
|
"/papierkorb": (
|
||||||
|
"/administration/trash",
|
||||||
|
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
|
||||||
|
),
|
||||||
|
"/diagnose": (
|
||||||
|
"/administration/diagnostics",
|
||||||
|
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
|
||||||
|
),
|
||||||
|
"/eigene-lizenz": (
|
||||||
|
"/own-license",
|
||||||
|
{
|
||||||
|
"einspielen": "install",
|
||||||
|
"aktivieren": "activate",
|
||||||
|
"deaktivieren": "deactivate",
|
||||||
|
"offline-antwort": "offline-response",
|
||||||
|
"signaturschluessel": "signing-key",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/lizenzen": (
|
||||||
|
"/licenses",
|
||||||
|
{
|
||||||
|
"aus-ticket": "from-ticket",
|
||||||
|
"datei": "file",
|
||||||
|
"neu-signieren": "resign",
|
||||||
|
"widerrufen": "revoke",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"versenden": "send",
|
||||||
|
"sub-lizenzserver": "sub-servers",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/lizenz": (
|
||||||
|
"/administration/license",
|
||||||
|
{
|
||||||
|
"einspielen": "install",
|
||||||
|
"aktivieren": "activate",
|
||||||
|
"deaktivieren": "deactivate",
|
||||||
|
"offline-antwort": "offline-response",
|
||||||
|
"serverzertifikat": "server-certificate",
|
||||||
|
"aussteller": "issuer",
|
||||||
|
"entfernen": "remove",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
|
||||||
|
"/konto": (
|
||||||
|
"/account",
|
||||||
|
{
|
||||||
|
"stammdaten": "profile",
|
||||||
|
"profilbild": "avatar",
|
||||||
|
"sicherheit": "security",
|
||||||
|
"passwort": "password",
|
||||||
|
"zwei-faktor": "two-factor",
|
||||||
|
"starten": "start",
|
||||||
|
"bestaetigen": "confirm",
|
||||||
|
"deaktivieren": "disable",
|
||||||
|
"codes-erneuern": "recovery-codes",
|
||||||
|
"sitzungen": "sessions",
|
||||||
|
"beenden": "end",
|
||||||
|
"alle-beenden": "end-all",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
|
||||||
|
"/gesundheit": ("/health", {}),
|
||||||
|
"/bestaetigen": ("/confirm", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Abfrageparameter, die mit umbenannt wurden.
|
||||||
|
PARAMETER = {
|
||||||
|
"freigabe": "share",
|
||||||
|
"pfad": "path",
|
||||||
|
"aktion": "action",
|
||||||
|
"benutzer": "actor",
|
||||||
|
"stufe": "severity",
|
||||||
|
"seite": "page",
|
||||||
|
"methode": "method",
|
||||||
|
"kunde": "customer",
|
||||||
|
"freischalten": "unlocked",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
|
||||||
|
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
|
||||||
|
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
|
||||||
|
#: in die Verwaltung.
|
||||||
|
STRENG = {"/lizenz"}
|
||||||
|
|
||||||
|
|
||||||
|
def neue_adresse(pfad: str) -> str | None:
|
||||||
|
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
|
||||||
|
for alt, (neu, segmente) in WURZELN.items():
|
||||||
|
if pfad != alt and not pfad.startswith(alt + "/"):
|
||||||
|
continue
|
||||||
|
rest = pfad[len(alt) :]
|
||||||
|
teile = rest.split("/")
|
||||||
|
if alt in STRENG:
|
||||||
|
erstes = next((teil for teil in teile if teil), "")
|
||||||
|
if erstes and erstes not in segmente:
|
||||||
|
return None
|
||||||
|
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
|
||||||
|
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
|
||||||
@@ -77,7 +77,7 @@ SETTINGS_NETWORK = Resource(
|
|||||||
|
|
||||||
BACKUP = Resource(
|
BACKUP = Resource(
|
||||||
key="backup",
|
key="backup",
|
||||||
label="Sicherung und Uebertragung",
|
label="Sicherung und Übertragung",
|
||||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
||||||
hint="Verschluesselter Export und Import",
|
hint="Verschluesselter Export und Import",
|
||||||
action_hints={
|
action_hints={
|
||||||
@@ -121,7 +121,7 @@ LOGS_HISTORY = Resource(
|
|||||||
|
|
||||||
LOGS_AUDIT = Resource(
|
LOGS_AUDIT = Resource(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Aenderungsprotokoll",
|
label="Änderungsprotokoll",
|
||||||
actions=(A.VIEW, A.EXPORT),
|
actions=(A.VIEW, A.EXPORT),
|
||||||
hint="Wer hat wann was geaendert",
|
hint="Wer hat wann was geaendert",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ class Resource:
|
|||||||
hint: str = ""
|
hint: str = ""
|
||||||
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
||||||
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
||||||
|
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
|
||||||
|
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
|
||||||
|
#: stilles Weglassen in der Route: eine Ausnahme von einer
|
||||||
|
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
|
||||||
|
ohne_wiederanmeldung: tuple[Action, ...] = ()
|
||||||
|
|
||||||
def permission(self, action: Action) -> str:
|
def permission(self, action: Action) -> str:
|
||||||
return f"{self.key}.{action.value}"
|
return f"{self.key}.{action.value}"
|
||||||
@@ -159,6 +164,25 @@ class PermissionTree:
|
|||||||
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
||||||
return self._resources.get(key)
|
return self._resources.get(key)
|
||||||
|
|
||||||
|
def verlangt_wiederanmeldung(self, permission: str) -> bool:
|
||||||
|
"""Braucht dieses Recht eine frische Passwortbestaetigung?
|
||||||
|
|
||||||
|
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
|
||||||
|
Route -- sonst vergisst sie die naechste, die dazukommt.
|
||||||
|
"""
|
||||||
|
resource_key, _, action_value = permission.rpartition(".")
|
||||||
|
entry = self._resources.get(resource_key)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
_, resource = entry
|
||||||
|
try:
|
||||||
|
action = Action(action_value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if action in resource.ohne_wiederanmeldung:
|
||||||
|
return False
|
||||||
|
return action in SENSITIVE_ACTIONS
|
||||||
|
|
||||||
def label_for(self, permission: str) -> str:
|
def label_for(self, permission: str) -> str:
|
||||||
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
||||||
if permission.endswith(".view") and permission[:-5] in self._by_key:
|
if permission.endswith(".view") and permission[:-5] in self._by_key:
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
idle_minutes: int,
|
idle_minutes: int,
|
||||||
absolute_hours: int,
|
absolute_hours: int,
|
||||||
idle_lookup: Any = None,
|
idle_lookup: Any = None,
|
||||||
|
absolute_lookup: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.database = database
|
self.database = database
|
||||||
self.cookie_name = cookie_name
|
self.cookie_name = cookie_name
|
||||||
@@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||||
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||||
self._idle_lookup = idle_lookup
|
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:
|
def _idle(self, conn: Any) -> int:
|
||||||
if self._idle_lookup is None:
|
if self._idle_lookup is None:
|
||||||
@@ -280,7 +291,10 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
||||||
with self.database.session() as conn, self.database.transaction(conn):
|
with self.database.session() as conn, self.database.transaction(conn):
|
||||||
if session.new or not session.sid:
|
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(
|
conn.execute(
|
||||||
"INSERT INTO user_sessions "
|
"INSERT INTO user_sessions "
|
||||||
"(id, user_id, created_at, last_seen_at, idle_expires_at, "
|
"(id, user_id, created_at, last_seen_at, idle_expires_at, "
|
||||||
@@ -292,7 +306,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
_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("_ip", ""),
|
||||||
session.get("_ua", ""),
|
session.get("_ua", ""),
|
||||||
payload,
|
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=?",
|
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
||||||
(_iso(_now()), reason, old_sid),
|
(_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.new = True
|
||||||
session.modified = True
|
session.modified = True
|
||||||
return old_sid
|
return old_sid
|
||||||
|
|||||||
@@ -847,6 +847,25 @@
|
|||||||
flex: 1 1 12rem;
|
flex: 1 1 12rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
|
||||||
|
Speichern, nicht zu den Feldern davor. */
|
||||||
|
.viewer__saveaktionen {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.viewer__saveknoepfe {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
|
||||||
|
.viewer__saveaktionen .check.is-gefragt {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--r-xs);
|
||||||
|
}
|
||||||
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
||||||
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
||||||
.viewer__feld {
|
.viewer__feld {
|
||||||
@@ -1315,3 +1334,174 @@
|
|||||||
.hide-sm { display: none !important; }
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Unterschrift --------------------------------------------------------- */
|
||||||
|
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
|
||||||
|
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
|
||||||
|
Feld nicht. */
|
||||||
|
|
||||||
|
.viewer__sign {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signfelder {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signpad {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
border: 1px dashed var(--border-strong, var(--border));
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: #fff;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signaktionen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdfscroll--setzen {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdfscroll--setzen .page {
|
||||||
|
outline: 2px dashed var(--accent, #c8791f);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
|
||||||
|
Zoomen an ihrer Stelle bleibt. */
|
||||||
|
.viewer__unterschrift {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
|
||||||
|
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
|
||||||
|
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
|
||||||
|
Kennzeichnung nicht auch noch die Bedienung blockieren. */
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert::after,
|
||||||
|
.viewer__pdf--veraendert::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert::after {
|
||||||
|
background: rgba(47, 158, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--veraendert {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--veraendert::after {
|
||||||
|
background: rgba(201, 42, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung--gut {
|
||||||
|
color: var(--success);
|
||||||
|
background: rgba(47, 158, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung--schlecht {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(201, 42, 42, 0.12);
|
||||||
|
}
|
||||||
@@ -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 ---------------------------------------------------------------- */
|
/* -- Start ---------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -893,6 +968,7 @@ function boot() {
|
|||||||
initPalette();
|
initPalette();
|
||||||
initSubmitGuards();
|
initSubmitGuards();
|
||||||
initTileFilters();
|
initTileFilters();
|
||||||
|
initReorder();
|
||||||
initLogTails();
|
initLogTails();
|
||||||
unsavedGuard.init();
|
unsavedGuard.init();
|
||||||
initViewer();
|
initViewer();
|
||||||
|
|||||||
@@ -228,7 +228,12 @@ async function pdflibLaden(adresse) {
|
|||||||
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
|
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
|
||||||
* ein PDF ohne Formular -- nichts mehr zu verstellen.
|
* ein PDF ohne Formular -- nichts mehr zu verstellen.
|
||||||
*/
|
*/
|
||||||
async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = {}) {
|
async function nachbearbeiten(
|
||||||
|
bytes,
|
||||||
|
bilder,
|
||||||
|
adresse,
|
||||||
|
{ abschliessen = false, unterschriften = [], zeuge = "" } = {}
|
||||||
|
) {
|
||||||
const PDFLib = await pdflibLaden(adresse);
|
const PDFLib = await pdflibLaden(adresse);
|
||||||
const dokument = await PDFLib.PDFDocument.load(bytes);
|
const dokument = await PDFLib.PDFDocument.load(bytes);
|
||||||
const formular = dokument.getForm();
|
const formular = dokument.getForm();
|
||||||
@@ -263,6 +268,40 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unterschriften: Bild und darunter der angegebene Name mit Zeitpunkt. Das
|
||||||
|
// Bild allein waere eine Zeichnung ohne Aussage -- auf Papier steht neben
|
||||||
|
// der Unterschrift auch der Name in Druckbuchstaben.
|
||||||
|
if (unterschriften.length) {
|
||||||
|
const schrift = await dokument.embedFont(PDFLib.StandardFonts.Helvetica);
|
||||||
|
for (const eintrag of unterschriften) {
|
||||||
|
const seite = dokument.getPage(eintrag.seite - 1);
|
||||||
|
const png = await dokument.embedPng(eintrag.bytes);
|
||||||
|
const breite = eintrag.breite;
|
||||||
|
const hoehe = (png.height / png.width) * breite;
|
||||||
|
seite.drawImage(png, { x: eintrag.x, y: eintrag.y, width: breite, height: hoehe });
|
||||||
|
const zeilen = [`${eintrag.name} -- ${eintrag.zeit}`];
|
||||||
|
if (zeuge) zeilen.push(`In TESM abgelegt durch ${zeuge}`);
|
||||||
|
let unten = eintrag.y - 2;
|
||||||
|
for (const zeile of zeilen) {
|
||||||
|
unten -= 9;
|
||||||
|
seite.drawText(zeile, {
|
||||||
|
x: eintrag.x,
|
||||||
|
y: unten,
|
||||||
|
size: 8,
|
||||||
|
font: schrift,
|
||||||
|
color: PDFLib.rgb(0.1, 0.1, 0.1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Ein Strich wie auf dem Formular: er trennt Zeichnung und Klartext.
|
||||||
|
seite.drawLine({
|
||||||
|
start: { x: eintrag.x, y: eintrag.y - 1 },
|
||||||
|
end: { x: eintrag.x + breite, y: eintrag.y - 1 },
|
||||||
|
thickness: 0.5,
|
||||||
|
color: PDFLib.rgb(0.4, 0.4, 0.4),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (abschliessen) {
|
if (abschliessen) {
|
||||||
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
|
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
|
||||||
// gewollt: so kann niemand mehr etwas verstellen.
|
// gewollt: so kann niemand mehr etwas verstellen.
|
||||||
@@ -271,6 +310,101 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
|
|||||||
return dokument.save();
|
return dokument.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die gezeichnete Unterschrift als PNG -- auf das Gezeichnete beschnitten.
|
||||||
|
*
|
||||||
|
* Ungeschnitten waere jede Unterschrift so gross wie die Zeichenflaeche, mit
|
||||||
|
* viel Luft ringsum. Im Dokument sieht das aus wie ein verrutschter Stempel.
|
||||||
|
*/
|
||||||
|
function unterschriftAlsPng(leinwand, kasten) {
|
||||||
|
const rand = 6;
|
||||||
|
const links = Math.max(0, Math.floor(kasten.x1 - rand));
|
||||||
|
const oben = Math.max(0, Math.floor(kasten.y1 - rand));
|
||||||
|
const breite = Math.min(leinwand.width - links, Math.ceil(kasten.x2 - kasten.x1 + rand * 2));
|
||||||
|
const hoehe = Math.min(leinwand.height - oben, Math.ceil(kasten.y2 - kasten.y1 + rand * 2));
|
||||||
|
const ziel = document.createElement("canvas");
|
||||||
|
ziel.width = Math.max(1, breite);
|
||||||
|
ziel.height = Math.max(1, hoehe);
|
||||||
|
ziel
|
||||||
|
.getContext("2d")
|
||||||
|
.drawImage(leinwand, links, oben, ziel.width, ziel.height, 0, 0, ziel.width, ziel.height);
|
||||||
|
// Ueber eine data:-Adresse und nicht ueber blob: -- die Sicherheitsregeln
|
||||||
|
// der Anwendung erlauben blob: nicht, und ein stilles "img-src" waere ein
|
||||||
|
// Loch, das man fuer eine Unterschrift nicht aufmacht.
|
||||||
|
const adresse = ziel.toDataURL("image/png");
|
||||||
|
const roh = atob(adresse.slice(adresse.indexOf(",") + 1));
|
||||||
|
const bytes = new Uint8Array(roh.length);
|
||||||
|
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i);
|
||||||
|
return { bytes, vorschau: adresse, breite: ziel.width, hoehe: ziel.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichenflaeche fuer die Unterschrift -- Finger, Stift oder Maus.
|
||||||
|
*
|
||||||
|
* ``touch-action: none`` steht im Stylesheet: ohne das scrollt ein iPad die
|
||||||
|
* Seite, statt einen Strich zu zeichnen.
|
||||||
|
*/
|
||||||
|
function unterschriftPad(leinwand) {
|
||||||
|
const stift = leinwand.getContext("2d");
|
||||||
|
let kasten = null;
|
||||||
|
let zeichnet = false;
|
||||||
|
|
||||||
|
const punkt = (event) => {
|
||||||
|
const rechteck = leinwand.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: ((event.clientX - rechteck.left) / rechteck.width) * leinwand.width,
|
||||||
|
y: ((event.clientY - rechteck.top) / rechteck.height) * leinwand.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const merken = ({ x, y }) => {
|
||||||
|
kasten = kasten
|
||||||
|
? {
|
||||||
|
x1: Math.min(kasten.x1, x),
|
||||||
|
y1: Math.min(kasten.y1, y),
|
||||||
|
x2: Math.max(kasten.x2, x),
|
||||||
|
y2: Math.max(kasten.y2, y),
|
||||||
|
}
|
||||||
|
: { x1: x, y1: y, x2: x, y2: y };
|
||||||
|
};
|
||||||
|
|
||||||
|
stift.lineWidth = 2.5;
|
||||||
|
stift.lineCap = "round";
|
||||||
|
stift.lineJoin = "round";
|
||||||
|
stift.strokeStyle = "#111";
|
||||||
|
|
||||||
|
leinwand.addEventListener("pointerdown", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
leinwand.setPointerCapture(event.pointerId);
|
||||||
|
zeichnet = true;
|
||||||
|
const p = punkt(event);
|
||||||
|
merken(p);
|
||||||
|
stift.beginPath();
|
||||||
|
stift.moveTo(p.x, p.y);
|
||||||
|
});
|
||||||
|
leinwand.addEventListener("pointermove", (event) => {
|
||||||
|
if (!zeichnet) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const p = punkt(event);
|
||||||
|
merken(p);
|
||||||
|
stift.lineTo(p.x, p.y);
|
||||||
|
stift.stroke();
|
||||||
|
});
|
||||||
|
const beenden = () => {
|
||||||
|
zeichnet = false;
|
||||||
|
};
|
||||||
|
leinwand.addEventListener("pointerup", beenden);
|
||||||
|
leinwand.addEventListener("pointercancel", beenden);
|
||||||
|
leinwand.addEventListener("pointerleave", beenden);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leeren: () => {
|
||||||
|
stift.clearRect(0, 0, leinwand.width, leinwand.height);
|
||||||
|
kasten = null;
|
||||||
|
},
|
||||||
|
leer: () => kasten === null,
|
||||||
|
alsPng: () => (kasten ? unterschriftAlsPng(leinwand, kasten) : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* -- PDF ------------------------------------------------------------------- */
|
/* -- PDF ------------------------------------------------------------------- */
|
||||||
|
|
||||||
let pdfjsGeladen = null;
|
let pdfjsGeladen = null;
|
||||||
@@ -448,6 +582,65 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
});
|
});
|
||||||
await bildknoepfeAufsetzen();
|
await bildknoepfeAufsetzen();
|
||||||
|
|
||||||
|
/* Unterschriften: gezeichnet wird ausserhalb, gesetzt wird hier. Die Stelle
|
||||||
|
bestimmt ein Tipp ins Dokument -- ein festes Feld gibt es in den wenigsten
|
||||||
|
Vorlagen, und "unten rechts" ist bei einem Formular mit drei
|
||||||
|
Unterschriftszeilen die falsche Annahme. */
|
||||||
|
const unterschriften = [];
|
||||||
|
let wartendeUnterschrift = null;
|
||||||
|
|
||||||
|
const platzieren = async (event) => {
|
||||||
|
if (!wartendeUnterschrift) return;
|
||||||
|
const seitenEl = event.target.closest(".page");
|
||||||
|
if (!seitenEl) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const nummer = Number(seitenEl.dataset.pageNumber || 0);
|
||||||
|
if (!nummer) return;
|
||||||
|
const rechteck = seitenEl.getBoundingClientRect();
|
||||||
|
const anteilX = (event.clientX - rechteck.left) / rechteck.width;
|
||||||
|
const anteilY = (event.clientY - rechteck.top) / rechteck.height;
|
||||||
|
const seite = await dokument.getPage(nummer);
|
||||||
|
const blick = seite.getViewport({ scale: 1 });
|
||||||
|
// Ein menschliches Mass: rund fuenf Zentimeter breit, hoechstens knapp
|
||||||
|
// zwei hoch. Ohne die Hoehengrenze wird aus einem hohen Gekritzel ein
|
||||||
|
// Stempel, der die halbe Seite einnimmt -- das Seitenverhaeltnis kommt ja
|
||||||
|
// aus dem Zuschnitt, nicht aus der Vorlage.
|
||||||
|
const MAX_BREITE = 150;
|
||||||
|
const MAX_HOEHE = 50;
|
||||||
|
let breite = Math.min(MAX_BREITE, blick.width * 0.3);
|
||||||
|
let hoehe = (wartendeUnterschrift.hoehe / wartendeUnterschrift.breite) * breite;
|
||||||
|
if (hoehe > MAX_HOEHE) {
|
||||||
|
breite *= MAX_HOEHE / hoehe;
|
||||||
|
hoehe = MAX_HOEHE;
|
||||||
|
}
|
||||||
|
const eintrag = {
|
||||||
|
...wartendeUnterschrift,
|
||||||
|
seite: nummer,
|
||||||
|
breite,
|
||||||
|
// Der Tipp meint die Mitte der Unterschrift, nicht ihre Ecke.
|
||||||
|
x: Math.max(4, anteilX * blick.width - breite / 2),
|
||||||
|
y: Math.max(24, (1 - anteilY) * blick.height - hoehe / 2),
|
||||||
|
};
|
||||||
|
unterschriften.push(eintrag);
|
||||||
|
behaelter.classList.remove("viewer__pdfscroll--setzen");
|
||||||
|
wartendeUnterschrift = null;
|
||||||
|
|
||||||
|
// Sichtbar machen, wo sie gelandet ist -- sonst tippt man ins Blaue.
|
||||||
|
const marke = document.createElement("img");
|
||||||
|
marke.className = "viewer__unterschrift";
|
||||||
|
marke.src = eintrag.vorschau;
|
||||||
|
marke.alt = "";
|
||||||
|
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
||||||
|
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
||||||
|
marke.style.width = `${(breite / blick.width) * 100}%`;
|
||||||
|
seitenEl.append(marke);
|
||||||
|
behaelter.dispatchEvent(new CustomEvent("unterschrieben", { detail: eintrag }));
|
||||||
|
};
|
||||||
|
|
||||||
|
behaelter.addEventListener("click", (event) => {
|
||||||
|
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hinein: () => {
|
hinein: () => {
|
||||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||||
@@ -463,12 +656,22 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
|
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
|
||||||
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
|
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
|
||||||
*/
|
*/
|
||||||
speichern: async (pdflibAdresse, { abschliessen = false } = {}) => {
|
speichern: async (pdflibAdresse, { abschliessen = false, zeuge = "" } = {}) => {
|
||||||
const bytes = await dokument.saveDocument();
|
const bytes = await dokument.saveDocument();
|
||||||
// Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht.
|
// Ohne Bild, ohne Unterschrift und ohne Abschluss braucht es pdf-lib nicht.
|
||||||
if (!bilder.size && !abschliessen) return bytes;
|
if (!bilder.size && !abschliessen && !unterschriften.length) return bytes;
|
||||||
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen });
|
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, {
|
||||||
|
abschliessen,
|
||||||
|
unterschriften,
|
||||||
|
zeuge,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
/** Wartet der Betrachter auf den Tipp, wohin die Unterschrift soll? */
|
||||||
|
unterschriftAnnehmen: (daten) => {
|
||||||
|
wartendeUnterschrift = daten;
|
||||||
|
behaelter.classList.add("viewer__pdfscroll--setzen");
|
||||||
|
},
|
||||||
|
unterschriften: () => unterschriften.map(({ name, zeit }) => ({ name, zeit })),
|
||||||
/** Wurde ueberhaupt etwas eingetragen? */
|
/** Wurde ueberhaupt etwas eingetragen? */
|
||||||
veraendert: () => {
|
veraendert: () => {
|
||||||
const s = dokument.annotationStorage;
|
const s = dokument.annotationStorage;
|
||||||
@@ -505,10 +708,38 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const ordnerFeld = stueck("ordner");
|
const ordnerFeld = stueck("ordner");
|
||||||
const nameFeld = stueck("name");
|
const nameFeld = stueck("name");
|
||||||
const ersetzenFeld = stueck("ersetzen");
|
const ersetzenFeld = stueck("ersetzen");
|
||||||
|
const ersetzenKasten = stueck("ersetzenfeld");
|
||||||
|
const pruefzeile = stueck("pruefung");
|
||||||
const jetzt = stueck("jetzt");
|
const jetzt = stueck("jetzt");
|
||||||
const abbrechen = stueck("abbrechen");
|
const abbrechen = stueck("abbrechen");
|
||||||
const modusFeld = stueck("modus");
|
const modusFeld = stueck("modus");
|
||||||
|
const zielFeld = stueck("ziel");
|
||||||
|
const unterschreibenKnopf = stueck("unterschreiben");
|
||||||
|
const unterschriftPanel = stueck("unterschriftpanel");
|
||||||
|
const vornameFeld = stueck("vorname");
|
||||||
|
const nachnameFeld = stueck("nachname");
|
||||||
|
const padLeinwand = stueck("signpad");
|
||||||
|
const padLeeren = stueck("signleeren");
|
||||||
|
const padSetzen = stueck("signsetzen");
|
||||||
|
const padAbbrechen = stueck("signabbrechen");
|
||||||
|
const padHinweis = stueck("signhinweis");
|
||||||
const basis = rumpf.dataset.viewerPdfjs || "";
|
const basis = rumpf.dataset.viewerPdfjs || "";
|
||||||
|
const konto = rumpf.dataset.viewerKonto || "";
|
||||||
|
const bezeugen = rumpf.dataset.viewerZeuge === "1";
|
||||||
|
// Die Uhr des Servers, nicht die des Tablets: eine verstellte Uhr am Geraet
|
||||||
|
// soll nicht das Datum unter einer Unterschrift bestimmen.
|
||||||
|
const serverStart = Date.parse(rumpf.dataset.viewerZeit || "") || Date.now();
|
||||||
|
const geladenSeit = Date.now();
|
||||||
|
const jetztText = () => {
|
||||||
|
const zeitpunkt = new Date(serverStart + (Date.now() - geladenSeit));
|
||||||
|
return zeitpunkt.toLocaleString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
|
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
|
||||||
// soll nicht als leere Flaeche enden.
|
// soll nicht als leere Flaeche enden.
|
||||||
@@ -535,9 +766,12 @@ export function initFilePreview(wurzel = document) {
|
|||||||
rahmen.hidden = art !== "text";
|
rahmen.hidden = art !== "text";
|
||||||
leiste.hidden = art === "text";
|
leiste.hidden = art === "text";
|
||||||
if (speichern) speichern.hidden = art !== "pdf";
|
if (speichern) speichern.hidden = art !== "pdf";
|
||||||
|
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
|
||||||
|
if (unterschriftPanel) unterschriftPanel.hidden = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const aufraeumen = () => {
|
const aufraeumen = () => {
|
||||||
|
pruefungZuruecksetzen();
|
||||||
bild.removeAttribute("src");
|
bild.removeAttribute("src");
|
||||||
bilder.zuruecksetzen();
|
bilder.zuruecksetzen();
|
||||||
rahmen.src = "about:blank";
|
rahmen.src = "about:blank";
|
||||||
@@ -555,6 +789,52 @@ export function initFilePreview(wurzel = document) {
|
|||||||
aktuell = null;
|
aktuell = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Unterschrieben und unveraendert? Die Antwort kommt aus dem
|
||||||
|
Aenderungsprotokoll: dort steht die Pruefsumme der abgelegten Fassung.
|
||||||
|
Stimmt sie, bekommt die Seite einen gruenen Schleier -- man soll es sehen,
|
||||||
|
bevor man liest, nicht danach. */
|
||||||
|
|
||||||
|
const pruefungZuruecksetzen = () => {
|
||||||
|
pdfBehaelter.classList.remove("viewer__pdf--unveraendert", "viewer__pdf--veraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = true;
|
||||||
|
pruefzeile.textContent = "";
|
||||||
|
pruefzeile.className = "viewer__pruefung";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pruefen = async (adresse) => {
|
||||||
|
pruefungZuruecksetzen();
|
||||||
|
if (!adresse) return;
|
||||||
|
try {
|
||||||
|
const stand = await window.tesm.api(adresse);
|
||||||
|
if (stand.status === "unveraendert") {
|
||||||
|
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = false;
|
||||||
|
pruefzeile.className = "viewer__pruefung viewer__pruefung--gut";
|
||||||
|
const namen = (stand.namen || []).join(", ");
|
||||||
|
pruefzeile.textContent =
|
||||||
|
`Unveraendert seit der Unterschrift${namen ? ` von ${namen}` : ""}` +
|
||||||
|
` -- Pruefsumme stimmt mit dem Aenderungsprotokoll ueberein.`;
|
||||||
|
}
|
||||||
|
} else if (stand.status === "veraendert") {
|
||||||
|
pdfBehaelter.classList.add("viewer__pdf--veraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = false;
|
||||||
|
pruefzeile.className = "viewer__pruefung viewer__pruefung--schlecht";
|
||||||
|
pruefzeile.textContent =
|
||||||
|
"Diese Datei wurde nach der Unterschrift veraendert -- die Pruefsumme " +
|
||||||
|
"weicht vom Aenderungsprotokoll ab.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
// Keine Aussage ist besser als eine falsche: ohne Antwort bleibt die
|
||||||
|
// Seite schlicht ungekennzeichnet.
|
||||||
|
console.error("Signaturpruefung", fehler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const oeffnen = async (ausloeser) => {
|
const oeffnen = async (ausloeser) => {
|
||||||
aufraeumen();
|
aufraeumen();
|
||||||
const art = ausloeser.dataset.fillKind || "text";
|
const art = ausloeser.dataset.fillKind || "text";
|
||||||
@@ -570,6 +850,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
||||||
|
pruefen(ausloeser.dataset.fillPruefung || "");
|
||||||
try {
|
try {
|
||||||
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
|
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
|
||||||
formulare: rumpf.dataset.viewerFormulare === "1",
|
formulare: rumpf.dataset.viewerFormulare === "1",
|
||||||
@@ -624,14 +905,74 @@ export function initFilePreview(wurzel = document) {
|
|||||||
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
||||||
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
||||||
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
||||||
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
|
// Der Zusatz sagt, was in der Datei steht: unterschrieben schlaegt
|
||||||
|
// ausgefuellt. Und einmal reicht -- wer eine Fassung noch einmal oeffnet
|
||||||
|
// und speichert, bekam sonst "...-ausgefuellt-ausgefuellt".
|
||||||
|
const rumpfname = datei.replace(/\.pdf$/i, "");
|
||||||
|
const zusatz = pdf?.unterschriften().length ? "-unterschrieben" : "-ausgefuellt";
|
||||||
|
const bereits = new RegExp(`${zusatz}$`, "i").test(rumpfname);
|
||||||
|
// Eine unterschriebene Fassung aus einer ausgefuellten: der alte Zusatz
|
||||||
|
// haette sonst zweimal dran gestanden ("-ausgefuellt-unterschrieben").
|
||||||
|
const blank = rumpfname.replace(/-(ausgefuellt|unterschrieben)$/i, "");
|
||||||
|
nameFeld.value = bereits ? rumpfname : `${blank}${zusatz}`;
|
||||||
ersetzenFeld.checked = false;
|
ersetzenFeld.checked = false;
|
||||||
|
ersetzenKasten?.classList.remove("is-gefragt");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */
|
||||||
|
|
||||||
|
const pad = padLeinwand ? unterschriftPad(padLeinwand) : null;
|
||||||
|
|
||||||
|
const panelZeigen = (offen) => {
|
||||||
|
if (!unterschriftPanel) return;
|
||||||
|
unterschriftPanel.hidden = !offen;
|
||||||
|
if (offen) {
|
||||||
|
pad?.leeren();
|
||||||
|
if (padHinweis) padHinweis.textContent = "";
|
||||||
|
vornameFeld?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
unterschreibenKnopf?.addEventListener("click", () => {
|
||||||
|
if (!pdf) return;
|
||||||
|
panelZeigen(unterschriftPanel?.hidden !== false);
|
||||||
|
});
|
||||||
|
|
||||||
|
padLeeren?.addEventListener("click", () => pad?.leeren());
|
||||||
|
padAbbrechen?.addEventListener("click", () => panelZeigen(false));
|
||||||
|
|
||||||
|
padSetzen?.addEventListener("click", () => {
|
||||||
|
if (!pdf || !pad) return;
|
||||||
|
const name = [vornameFeld?.value, nachnameFeld?.value]
|
||||||
|
.map((wert) => (wert || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
// Beides ist noetig, und beides wird hier gesagt statt stumm abgelehnt.
|
||||||
|
if (!name) {
|
||||||
|
if (padHinweis) padHinweis.textContent = "Bitte Vor- und Nachnamen eintragen.";
|
||||||
|
vornameFeld?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const zeichnung = pad.alsPng();
|
||||||
|
if (!zeichnung) {
|
||||||
|
if (padHinweis) padHinweis.textContent = "Bitte im Feld unterschreiben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pdf.unterschriftAnnehmen({ ...zeichnung, name, zeit: jetztText() });
|
||||||
|
panelZeigen(false);
|
||||||
|
window.tesm.showToast("Jetzt auf die Stelle im Dokument tippen.", "info");
|
||||||
|
});
|
||||||
|
|
||||||
speichern?.addEventListener("click", () => {
|
speichern?.addEventListener("click", () => {
|
||||||
if (!pdf || !aktuell) return;
|
if (!pdf || !aktuell) return;
|
||||||
vorschlagen();
|
vorschlagen();
|
||||||
zeile.hidden = false;
|
zeile.hidden = false;
|
||||||
|
if (modusFeld && pdf.unterschriften().length) {
|
||||||
|
// Mit Unterschrift gibt es nur einen Weg -- und das Feld soll nicht
|
||||||
|
// etwas anderes behaupten.
|
||||||
|
modusFeld.value = "fertig";
|
||||||
|
modusFeld.disabled = true;
|
||||||
|
}
|
||||||
nameFeld.focus();
|
nameFeld.focus();
|
||||||
nameFeld.select();
|
nameFeld.select();
|
||||||
});
|
});
|
||||||
@@ -640,6 +981,31 @@ export function initFilePreview(wurzel = document) {
|
|||||||
zeile.hidden = true;
|
zeile.hidden = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Zielfreigabe: die Ordnerliste gehoert zur gewaehlten Freigabe, nicht zur
|
||||||
|
geoeffneten. Geholt wird sie erst bei Bedarf -- alle Listen im
|
||||||
|
Seitenaufbau mitzuschicken waere Ballast fuer einen seltenen Fall. */
|
||||||
|
const ordnerListe = ordnerFeld ? document.getElementById(ordnerFeld.getAttribute("list")) : null;
|
||||||
|
|
||||||
|
zielFeld?.addEventListener("change", async () => {
|
||||||
|
if (!ordnerListe) return;
|
||||||
|
const vorlage = zielFeld.dataset.urlVorlage || "";
|
||||||
|
if (!vorlage) return;
|
||||||
|
ordnerFeld.value = "";
|
||||||
|
try {
|
||||||
|
const antwort = await window.tesm.api(vorlage.replace("__token__", zielFeld.value));
|
||||||
|
ordnerListe.replaceChildren(
|
||||||
|
...(antwort.folders || []).map((eintrag) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = eintrag;
|
||||||
|
return option;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
// Kein Beinbruch: das Feld nimmt auch getippte Pfade.
|
||||||
|
console.error("Ordnerliste", fehler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
jetzt?.addEventListener("click", async () => {
|
jetzt?.addEventListener("click", async () => {
|
||||||
if (!pdf || !aktuell) return;
|
if (!pdf || !aktuell) return;
|
||||||
const ziel = speichern.dataset.url;
|
const ziel = speichern.dataset.url;
|
||||||
@@ -647,13 +1013,20 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const vorher = jetzt.textContent;
|
const vorher = jetzt.textContent;
|
||||||
jetzt.textContent = "Speichern ...";
|
jetzt.textContent = "Speichern ...";
|
||||||
try {
|
try {
|
||||||
|
const gesetzt = pdf.unterschriften();
|
||||||
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
|
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
|
||||||
abschliessen: modusFeld?.value === "fertig",
|
// Unterschrieben heisst abgeschlossen: ein Dokument, in dem nach der
|
||||||
|
// Unterschrift noch Felder aenderbar waeren, ist keine Unterschrift
|
||||||
|
// wert.
|
||||||
|
abschliessen: modusFeld?.value === "fertig" || gesetzt.length > 0,
|
||||||
|
zeuge: bezeugen ? konto : "",
|
||||||
});
|
});
|
||||||
const daten = new FormData();
|
const daten = new FormData();
|
||||||
daten.append("pfad", aktuell.pfad);
|
daten.append("path", aktuell.pfad);
|
||||||
daten.append("ordner", ordnerFeld.value.trim());
|
daten.append("ordner", ordnerFeld.value.trim());
|
||||||
daten.append("name", nameFeld.value.trim());
|
daten.append("name", nameFeld.value.trim());
|
||||||
|
if (zielFeld?.value) daten.append("ziel", zielFeld.value);
|
||||||
|
if (gesetzt.length) daten.append("unterschriften", JSON.stringify(gesetzt));
|
||||||
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
|
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
|
||||||
daten.append(
|
daten.append(
|
||||||
"datei",
|
"datei",
|
||||||
@@ -669,6 +1042,12 @@ export function initFilePreview(wurzel = document) {
|
|||||||
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
|
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
|
||||||
console.error("Formular speichern", fehler);
|
console.error("Formular speichern", fehler);
|
||||||
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
|
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
|
||||||
|
// Genau dieser Fall hat eine Antwort auf der Zeile -- dann zeigen wir
|
||||||
|
// auch darauf, statt es bei einer Meldung zu belassen.
|
||||||
|
if (fehler.status === 409) {
|
||||||
|
ersetzenKasten?.classList.add("is-gefragt");
|
||||||
|
ersetzenFeld?.focus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
jetzt.disabled = false;
|
jetzt.disabled = false;
|
||||||
jetzt.textContent = vorher;
|
jetzt.textContent = vorher;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
||||||
Anpassungen bleiben erhalten.
|
Anpassungen bleiben erhalten.
|
||||||
{% if current_user.has('groups.edit') %}
|
{% if current_user.has('groups.edit') %}
|
||||||
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}">
|
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, unlocked=1) }}">
|
||||||
{{ icon('unlock', size=14) }} Rechte freischalten
|
{{ icon('unlock', size=14) }} Rechte freischalten
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -26,19 +26,19 @@
|
|||||||
{{ icon('search', size=15) }}
|
{{ icon('search', size=15) }}
|
||||||
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
|
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
|
||||||
</span>
|
</span>
|
||||||
<select class="select" name="aktion" style="width:auto;max-width:240px">
|
<select class="select" name="action" style="width:auto;max-width:240px">
|
||||||
<option value="">alle Aktionen</option>
|
<option value="">alle Aktionen</option>
|
||||||
{% for action in actions %}
|
{% for action in actions %}
|
||||||
<option value="{{ action }}" {% if action == filters.aktion %}selected{% endif %}>{{ action }}</option>
|
<option value="{{ action }}" {% if action == filters.action %}selected{% endif %}>{{ action }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<select class="select" name="stufe" style="width:auto">
|
<select class="select" name="severity" style="width:auto">
|
||||||
<option value="">alle Stufen</option>
|
<option value="">alle Stufen</option>
|
||||||
{% for level in ['info', 'notice', 'warning', 'critical'] %}
|
{% for level in ['info', 'notice', 'warning', 'critical'] %}
|
||||||
<option value="{{ level }}" {% if level == filters.stufe %}selected{% endif %}>{{ level }}</option>
|
<option value="{{ level }}" {% if level == filters.severity %}selected{% endif %}>{{ level }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
<input class="input" type="text" name="actor" value="{{ filters.actor }}"
|
||||||
placeholder="Benutzer" style="width:150px">
|
placeholder="Benutzer" style="width:150px">
|
||||||
<button class="btn btn--sm" type="submit">Filtern</button>
|
<button class="btn btn--sm" type="submit">Filtern</button>
|
||||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
||||||
@@ -88,10 +88,10 @@
|
|||||||
<span class="fs-sm muted">Seite {{ page }}</span>
|
<span class="fs-sm muted">Seite {{ page }}</span>
|
||||||
<span class="row row--tight">
|
<span class="row row--tight">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurück</a>
|
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page-1, **filters) }}">Zurück</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if rows | length == per_page %}
|
{% if rows | length == per_page %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
|
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page+1, **filters) }}">Weiter</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -235,3 +235,69 @@
|
|||||||
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
||||||
</div>
|
</div>
|
||||||
{%- endmacro %}
|
{%- 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) -%}
|
||||||
|
<li class="reorder__row" data-reorder-item draggable="true">
|
||||||
|
<span class="reorder__grip" aria-hidden="true">{{ icon('menu', size=14) }}</span>
|
||||||
|
<span class="reorder__pos" data-reorder-position-text>1</span>
|
||||||
|
<input type="hidden" name="{{ field_name }}" value="{{ item.key }}">
|
||||||
|
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
|
||||||
|
<span class="reorder__moves">
|
||||||
|
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="up"
|
||||||
|
aria-label="{{ label }} nach oben">{{ icon('chevron-up', size=14) }}</button>
|
||||||
|
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="down"
|
||||||
|
aria-label="{{ label }} nach unten">{{ icon('chevron-down', size=14) }}</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{%- 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') -%}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
||||||
|
<div class="card__sub">{{ sub }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ action }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field }}" value="{{ csrf_value }}">
|
||||||
|
<div class="card__body">
|
||||||
|
<ol class="reorder" data-behavior="reorder">
|
||||||
|
{% for key in order %}
|
||||||
|
{% set item = items | selectattr('key', 'equalto', key) | list | first %}
|
||||||
|
{% if item %}
|
||||||
|
{{ _reorder_row(item, 'order', item.label) }}
|
||||||
|
{% if item.children %}
|
||||||
|
<li class="reorder__group">
|
||||||
|
<ol class="reorder reorder--child" data-behavior="reorder">
|
||||||
|
{% 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 %}
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
<p class="fs-xs faint mt-3">
|
||||||
|
Zeilen ziehen oder mit den Pfeilen verschieben, dann speichern. Untergeordnete
|
||||||
|
Punkte werden innerhalb ihrer Gruppe sortiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card__footer">
|
||||||
|
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -38,6 +38,7 @@ ICONS: dict[str, str] = {
|
|||||||
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
||||||
"chevron-right": '<path d="m9 6 6 6-6 6"/>',
|
"chevron-right": '<path d="m9 6 6 6-6 6"/>',
|
||||||
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
||||||
|
"chevron-up": '<path d="m6 15 6-6 6 6"/>',
|
||||||
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>',
|
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>',
|
||||||
"moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
|
"moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
|
||||||
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from ..extension import core, db, transaction
|
|||||||
from ..rbac import service as rbac
|
from ..rbac import service as rbac
|
||||||
from ..security import passwords, sessions as session_store, totp
|
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"}
|
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
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:
|
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(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor=current_user.username,
|
actor=current_user.username,
|
||||||
@@ -80,7 +93,7 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/stammdaten")
|
@bp.post("/profile")
|
||||||
@login_required
|
@login_required
|
||||||
def update_profile():
|
def update_profile():
|
||||||
if current_user.is_directory_account:
|
if current_user.is_directory_account:
|
||||||
@@ -101,12 +114,11 @@ def update_profile():
|
|||||||
current_user.id,
|
current_user.id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit(conn, "account.profile_updated")
|
|
||||||
flash("Stammdaten gespeichert.", "success")
|
flash("Stammdaten gespeichert.", "success")
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/profilbild")
|
@bp.post("/avatar")
|
||||||
@login_required
|
@login_required
|
||||||
def upload_avatar():
|
def upload_avatar():
|
||||||
upload = request.files.get("avatar")
|
upload = request.files.get("avatar")
|
||||||
@@ -142,14 +154,13 @@ def upload_avatar():
|
|||||||
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
|
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
|
||||||
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
|
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
|
||||||
)
|
)
|
||||||
_audit(conn, "account.avatar_updated")
|
|
||||||
if previous:
|
if previous:
|
||||||
(_avatar_dir() / str(previous)).unlink(missing_ok=True)
|
(_avatar_dir() / str(previous)).unlink(missing_ok=True)
|
||||||
flash("Profilbild aktualisiert.", "success")
|
flash("Profilbild aktualisiert.", "success")
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/profilbild/<path:filename>")
|
@bp.get("/avatar/<path:filename>")
|
||||||
@login_required
|
@login_required
|
||||||
def avatar(filename: str):
|
def avatar(filename: str):
|
||||||
safe = secure_filename(filename)
|
safe = secure_filename(filename)
|
||||||
@@ -163,7 +174,7 @@ def avatar(filename: str):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sicherheit")
|
@bp.get("/security")
|
||||||
@login_required
|
@login_required
|
||||||
def security():
|
def security():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -199,7 +210,7 @@ def security():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/passwort")
|
@bp.post("/password")
|
||||||
@login_required
|
@login_required
|
||||||
def change_password():
|
def change_password():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -229,21 +240,15 @@ def change_password():
|
|||||||
revoked = session_store.revoke_all_for_user(
|
revoked = session_store.revoke_all_for_user(
|
||||||
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
|
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:
|
except rbac.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("account.security"))
|
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"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/starten")
|
@bp.post("/two-factor/start")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def totp_start():
|
def totp_start():
|
||||||
@@ -252,7 +257,7 @@ def totp_start():
|
|||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/bestaetigen")
|
@bp.post("/two-factor/confirm")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def totp_confirm():
|
def totp_confirm():
|
||||||
@@ -295,7 +300,7 @@ def totp_confirm():
|
|||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/deaktivieren")
|
@bp.post("/two-factor/disable")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def totp_disable():
|
def totp_disable():
|
||||||
@@ -317,7 +322,7 @@ def totp_disable():
|
|||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/codes-erneuern")
|
@bp.post("/two-factor/recovery-codes")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def recovery_codes():
|
def recovery_codes():
|
||||||
@@ -341,7 +346,7 @@ def recovery_codes():
|
|||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/sitzungen/<sid>/beenden")
|
@bp.post("/sessions/<sid>/end")
|
||||||
@login_required
|
@login_required
|
||||||
def revoke_session(sid: str):
|
def revoke_session(sid: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -350,18 +355,16 @@ def revoke_session(sid: str):
|
|||||||
abort(404)
|
abort(404)
|
||||||
with transaction():
|
with transaction():
|
||||||
session_store.revoke(conn, sid, reason="self")
|
session_store.revoke(conn, sid, reason="self")
|
||||||
_audit(conn, "account.session_revoked", sid[:12])
|
|
||||||
flash("Sitzung beendet.", "success")
|
flash("Sitzung beendet.", "success")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/sitzungen/alle-beenden")
|
@bp.post("/sessions/end-all")
|
||||||
@login_required
|
@login_required
|
||||||
def revoke_other_sessions():
|
def revoke_other_sessions():
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
count = session_store.revoke_all_for_user(
|
count = session_store.revoke_all_for_user(
|
||||||
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
|
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")
|
flash(f"{count} weitere Sitzung(en) beendet.", "success")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
@@ -16,7 +16,7 @@ from ..rbac import service as rbac
|
|||||||
from ..rbac.model import ACTION_META, Action
|
from ..rbac.model import ACTION_META, Action
|
||||||
from ..security import sessions as session_store
|
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:
|
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")
|
@permission_required("users.view")
|
||||||
def users():
|
def users():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -60,7 +60,7 @@ def users():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/neu")
|
@bp.post("/users/new")
|
||||||
@permission_required("users.create")
|
@permission_required("users.create")
|
||||||
def user_create():
|
def user_create():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -94,7 +94,7 @@ def user_create():
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/<int:user_id>")
|
@bp.post("/users/<int:user_id>")
|
||||||
@permission_required("users.edit")
|
@permission_required("users.edit")
|
||||||
def user_update(user_id: int):
|
def user_update(user_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -137,7 +137,7 @@ def user_update(user_id: int):
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/<int:user_id>/loeschen")
|
@bp.post("/users/<int:user_id>/delete")
|
||||||
@permission_required("users.delete")
|
@permission_required("users.delete")
|
||||||
def user_delete(user_id: int):
|
def user_delete(user_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -163,7 +163,7 @@ def user_delete(user_id: int):
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/<int:user_id>/sitzungen-beenden")
|
@bp.post("/users/<int:user_id>/end-sessions")
|
||||||
@permission_required("users.edit")
|
@permission_required("users.edit")
|
||||||
def user_revoke_sessions(user_id: int):
|
def user_revoke_sessions(user_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -184,7 +184,7 @@ def user_revoke_sessions(user_id: int):
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/benutzer/verzeichnis-suche")
|
@bp.get("/users/directory-search")
|
||||||
@permission_required("users.create")
|
@permission_required("users.create")
|
||||||
def directory_search():
|
def directory_search():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -200,7 +200,7 @@ def directory_search():
|
|||||||
return jsonify({"results": results})
|
return jsonify({"results": results})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/verzeichnis-uebernehmen")
|
@bp.post("/users/directory-import")
|
||||||
@permission_required("users.create")
|
@permission_required("users.create")
|
||||||
def directory_adopt():
|
def directory_adopt():
|
||||||
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
|
"""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")
|
@permission_required("groups.view")
|
||||||
def groups():
|
def groups():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -254,7 +254,7 @@ def groups():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/gruppen/<int:group_id>")
|
@bp.get("/groups/<int:group_id>")
|
||||||
@permission_required("groups.view")
|
@permission_required("groups.view")
|
||||||
def group_detail(group_id: int):
|
def group_detail(group_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -275,11 +275,11 @@ def group_detail(group_id: int):
|
|||||||
(group_id,),
|
(group_id,),
|
||||||
),
|
),
|
||||||
can_edit=current_user.has("groups.edit"),
|
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")
|
@permission_required("groups.create")
|
||||||
def group_create():
|
def group_create():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -301,7 +301,7 @@ def group_create():
|
|||||||
return redirect(url_for("admin.groups"))
|
return redirect(url_for("admin.groups"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/<int:group_id>")
|
@bp.post("/groups/<int:group_id>")
|
||||||
@permission_required("groups.edit")
|
@permission_required("groups.edit")
|
||||||
def group_update(group_id: int):
|
def group_update(group_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -334,7 +334,7 @@ def group_update(group_id: int):
|
|||||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/<int:group_id>/loeschen")
|
@bp.post("/groups/<int:group_id>/delete")
|
||||||
@permission_required("groups.delete")
|
@permission_required("groups.delete")
|
||||||
def group_delete(group_id: int):
|
def group_delete(group_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -354,7 +354,7 @@ def group_delete(group_id: int):
|
|||||||
return redirect(url_for("admin.groups"))
|
return redirect(url_for("admin.groups"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/<int:group_id>/vorlage-anwenden")
|
@bp.post("/groups/<int:group_id>/apply-preset")
|
||||||
@admin_required
|
@admin_required
|
||||||
def group_apply_preset(group_id: int):
|
def group_apply_preset(group_id: int):
|
||||||
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
|
"""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
|
@admin_required
|
||||||
def sessions():
|
def sessions():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -399,7 +399,7 @@ def sessions():
|
|||||||
return render_template("tesm_core/sessions.html", sessions=rows)
|
return render_template("tesm_core/sessions.html", sessions=rows)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/sitzungen/<sid>/beenden")
|
@bp.post("/sessions/<sid>/end")
|
||||||
@admin_required
|
@admin_required
|
||||||
def session_revoke(sid: str):
|
def session_revoke(sid: str):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from ..keystore import check_private_file
|
|||||||
from ..security import ratelimit
|
from ..security import ratelimit
|
||||||
from ..security import sessions as session_store
|
from ..security import sessions as session_store
|
||||||
|
|
||||||
bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose")
|
bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@@ -88,7 +88,7 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aufraeumen")
|
@bp.post("/cleanup")
|
||||||
@admin_required
|
@admin_required
|
||||||
def housekeeping():
|
def housekeeping():
|
||||||
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
|
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
|
||||||
@@ -116,7 +116,7 @@ def housekeeping():
|
|||||||
return redirect(url_for("diagnostics.overview"))
|
return redirect(url_for("diagnostics.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/schluessel-rotieren")
|
@bp.post("/rotate-keys")
|
||||||
@admin_required
|
@admin_required
|
||||||
def rotate_keys():
|
def rotate_keys():
|
||||||
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
|
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from ..licensing_client import LicenseError
|
|||||||
from ..rbac import service as rbac
|
from ..rbac import service as rbac
|
||||||
from ..system import hostnet, webserver
|
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"
|
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")
|
@permission_required("settings_network.view")
|
||||||
def network():
|
def network():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -80,7 +80,7 @@ def network():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/netzwerk")
|
@bp.post("/network")
|
||||||
@permission_required("settings_network.edit")
|
@permission_required("settings_network.edit")
|
||||||
def network_apply():
|
def network_apply():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -147,7 +147,7 @@ def network_apply():
|
|||||||
return redirect(url_for("hostsettings.network"))
|
return redirect(url_for("hostsettings.network"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/netzwerk/bestaetigen")
|
@bp.post("/network/confirm")
|
||||||
@permission_required("settings_network.edit")
|
@permission_required("settings_network.edit")
|
||||||
def network_confirm():
|
def network_confirm():
|
||||||
if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
|
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")
|
@permission_required("settings_web.view")
|
||||||
def web():
|
def web():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -240,7 +240,7 @@ def web():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver")
|
@bp.post("/web")
|
||||||
@permission_required("settings_web.edit")
|
@permission_required("settings_web.edit")
|
||||||
def web_save():
|
def web_save():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -302,7 +302,7 @@ def web_save():
|
|||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver/uebernehmen")
|
@bp.post("/web/apply")
|
||||||
@permission_required("settings_web.execute")
|
@permission_required("settings_web.execute")
|
||||||
def web_apply():
|
def web_apply():
|
||||||
problem = _license_hint("nginx")
|
problem = _license_hint("nginx")
|
||||||
@@ -327,7 +327,7 @@ def web_apply():
|
|||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver/zertifikat")
|
@bp.post("/web/certificate")
|
||||||
@permission_required("settings_web.execute")
|
@permission_required("settings_web.execute")
|
||||||
def web_certificate():
|
def web_certificate():
|
||||||
problem = _license_hint("nginx")
|
problem = _license_hint("nginx")
|
||||||
@@ -364,7 +364,7 @@ def web_certificate():
|
|||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver/selbstsigniert")
|
@bp.post("/web/self-signed")
|
||||||
@permission_required("settings_web.execute")
|
@permission_required("settings_web.execute")
|
||||||
def web_self_signed():
|
def web_self_signed():
|
||||||
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
|
"""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")
|
@permission_required("settings_directory.view")
|
||||||
def directory_settings():
|
def directory_settings():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -435,7 +435,7 @@ def directory_settings():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verzeichnisdienst")
|
@bp.post("/directory")
|
||||||
@permission_required("settings_directory.edit")
|
@permission_required("settings_directory.edit")
|
||||||
def directory_save():
|
def directory_save():
|
||||||
problem = _license_hint("directory")
|
problem = _license_hint("directory")
|
||||||
@@ -481,7 +481,7 @@ def directory_save():
|
|||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verzeichnisdienst/test")
|
@bp.post("/directory/test")
|
||||||
@permission_required("settings_directory.execute")
|
@permission_required("settings_directory.execute")
|
||||||
def directory_test():
|
def directory_test():
|
||||||
result = directory.test_connection(db())
|
result = directory.test_connection(db())
|
||||||
@@ -489,7 +489,7 @@ def directory_test():
|
|||||||
return redirect(url_for("hostsettings.directory_settings"))
|
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")
|
@permission_required("settings_directory.edit", "fileshare.edit", mode="any")
|
||||||
def directory_group_search():
|
def directory_group_search():
|
||||||
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
|
"""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")
|
@permission_required("settings_directory.edit")
|
||||||
def directory_mapping_add():
|
def directory_mapping_add():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -534,7 +534,7 @@ def directory_mapping_add():
|
|||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verzeichnisdienst/zuordnung/<int:mapping_id>/loeschen")
|
@bp.post("/directory/mapping/<int:mapping_id>/delete")
|
||||||
@permission_required("settings_directory.edit")
|
@permission_required("settings_directory.edit")
|
||||||
def directory_mapping_delete(mapping_id: int):
|
def directory_mapping_delete(mapping_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ..auth.service import client_ip
|
|||||||
from ..extension import core, db, transaction
|
from ..extension import core, db, transaction
|
||||||
from ..security.sessions import fragment
|
from ..security.sessions import fragment
|
||||||
|
|
||||||
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
|
bp = Blueprint("logs", __name__, url_prefix="/logs")
|
||||||
|
|
||||||
MAX_TAIL_LINES = 2000
|
MAX_TAIL_LINES = 2000
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ def _live_log():
|
|||||||
return log
|
return log
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/laufend")
|
@bp.get("/live")
|
||||||
@permission_required("logs_live.view")
|
@permission_required("logs_live.view")
|
||||||
def live():
|
def live():
|
||||||
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
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")
|
@permission_required("logs_live.view")
|
||||||
@fragment
|
@fragment
|
||||||
def live_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")
|
@permission_required("logs_history.view", "logs_history.export", mode="any")
|
||||||
def history():
|
def history():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -82,7 +82,7 @@ def history():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verlauf/datei/<path:name>")
|
@bp.get("/history/file/<path:name>")
|
||||||
@permission_required("logs_history.view")
|
@permission_required("logs_history.view")
|
||||||
def history_file(name: str):
|
def history_file(name: str):
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -101,7 +101,7 @@ def history_file(name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verlauf/archiv/<day>")
|
@bp.get("/history/archive/<day>")
|
||||||
@permission_required("logs_history.view")
|
@permission_required("logs_history.view")
|
||||||
def history_archive(day: str):
|
def history_archive(day: str):
|
||||||
if len(day) != 10 or day.count("-") != 2:
|
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)
|
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")
|
@permission_required("logs_history.export")
|
||||||
def history_export():
|
def history_export():
|
||||||
"""Packt die archivierten Audit-Tage in ein ZIP.
|
"""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")
|
@permission_required("logs_history.delete")
|
||||||
def history_purge():
|
def history_purge():
|
||||||
removed = 0
|
removed = 0
|
||||||
@@ -176,20 +176,20 @@ def history_purge():
|
|||||||
return redirect(url_for("logs.history"))
|
return redirect(url_for("logs.history"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/aenderungen")
|
@bp.get("/audit")
|
||||||
@permission_required("logs_audit.view", "logs_audit.export", mode="any")
|
@permission_required("logs_audit.view", "logs_audit.export", mode="any")
|
||||||
def audit_view():
|
def audit_view():
|
||||||
conn = db()
|
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
|
per_page = 100
|
||||||
return render_template(
|
return render_template(
|
||||||
"tesm_core/logs_audit.html",
|
"tesm_core/logs_audit.html",
|
||||||
rows=audit.search(
|
rows=audit.search(
|
||||||
conn,
|
conn,
|
||||||
query=(request.args.get("q") or "").strip(),
|
query=(request.args.get("q") or "").strip(),
|
||||||
action=(request.args.get("aktion") or "").strip(),
|
action=(request.args.get("action") or "").strip(),
|
||||||
actor=(request.args.get("benutzer") or "").strip(),
|
actor=(request.args.get("actor") or "").strip(),
|
||||||
severity=(request.args.get("stufe") or "").strip(),
|
severity=(request.args.get("severity") or "").strip(),
|
||||||
limit=per_page,
|
limit=per_page,
|
||||||
offset=(page - 1) * per_page,
|
offset=(page - 1) * per_page,
|
||||||
),
|
),
|
||||||
@@ -200,14 +200,14 @@ def audit_view():
|
|||||||
per_page=per_page,
|
per_page=per_page,
|
||||||
filters={
|
filters={
|
||||||
"q": request.args.get("q") or "",
|
"q": request.args.get("q") or "",
|
||||||
"aktion": request.args.get("aktion") or "",
|
"action": request.args.get("action") or "",
|
||||||
"benutzer": request.args.get("benutzer") or "",
|
"actor": request.args.get("actor") or "",
|
||||||
"stufe": request.args.get("stufe") or "",
|
"severity": request.args.get("severity") or "",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/aenderungen/export")
|
@bp.get("/audit/export")
|
||||||
@permission_required("logs_audit.export")
|
@permission_required("logs_audit.export")
|
||||||
def audit_export():
|
def audit_export():
|
||||||
conn = db()
|
conn = db()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from ..db import Database
|
|||||||
from ..extension import core, db, transaction
|
from ..extension import core, db, transaction
|
||||||
from ..rbac import service as rbac
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -191,7 +191,7 @@ def _provider_or_404(key: str) -> TrashProvider:
|
|||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<ident>/wiederherstellen")
|
@bp.post("/<kind>/<ident>/restore")
|
||||||
@permission_required("trash.edit")
|
@permission_required("trash.edit")
|
||||||
def restore(kind: str, ident: str):
|
def restore(kind: str, ident: str):
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -222,7 +222,7 @@ def restore(kind: str, ident: str):
|
|||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<ident>/endgueltig-loeschen")
|
@bp.post("/<kind>/<ident>/purge")
|
||||||
@permission_required("trash.delete")
|
@permission_required("trash.delete")
|
||||||
def purge(kind: str, ident: str):
|
def purge(kind: str, ident: str):
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -252,7 +252,7 @@ def purge(kind: str, ident: str):
|
|||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/leeren")
|
@bp.post("/empty")
|
||||||
@permission_required("trash.delete")
|
@permission_required("trash.delete")
|
||||||
def purge_all():
|
def purge_all():
|
||||||
extension = core()
|
extension = core()
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing-server"
|
name = "tesm-licensing-server"
|
||||||
version = "2.4.0"
|
version = "2.7.1"
|
||||||
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["tesm-licensing==2.4.0", "cryptography>=41"]
|
dependencies = ["tesm-licensing==2.7.1", "cryptography>=41"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.4.0"
|
version = "2.7.1"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
Reference in new issue
Block a user