Version 2.5.0: englische Adressen entlang der Navigation, sparsameres Protokoll
This commit is contained in:
1 parent
bec74e6936
commit
61a47debdc
44 files changed
+741
-303
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.4.1"
|
||||
version = "2.5.0"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -62,6 +62,17 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
minimum=1,
|
||||
maximum=3650,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_absolute_hours",
|
||||
12,
|
||||
"int",
|
||||
"Laengstmoegliche Sitzungsdauer (Stunden)",
|
||||
hint="Danach ist Schluss, auch bei staendiger Arbeit -- eine Sitzung soll nicht "
|
||||
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
|
||||
section="system",
|
||||
minimum=1,
|
||||
maximum=168,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
@@ -135,7 +146,7 @@ def create_base_app(
|
||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||
#: Anwendungen.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.4.1",
|
||||
app_version: str = "2.5.0",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
@@ -230,12 +241,21 @@ def create_base_app(
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_idle_minutes
|
||||
|
||||
def _gesamtdauer(conn: Any) -> int:
|
||||
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
|
||||
try:
|
||||
wert = int(settings.get(conn, "session_absolute_hours") or 0)
|
||||
except (TypeError, ValueError):
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_absolute_hours
|
||||
|
||||
app.session_interface = SqliteSessionInterface(
|
||||
database,
|
||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||
idle_minutes=config.security.session_idle_minutes,
|
||||
absolute_hours=config.security.session_absolute_hours,
|
||||
idle_lookup=_leerlauf,
|
||||
absolute_lookup=_gesamtdauer,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
@@ -399,7 +419,7 @@ def create_base_app(
|
||||
with app.app_context():
|
||||
trash_views.register_core_providers()
|
||||
|
||||
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
|
||||
app.add_url_rule("/health", "health", _health, methods=["GET"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def login():
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
|
||||
@bp.route("/login/second-factor", methods=["GET", "POST"])
|
||||
def second_factor():
|
||||
conn = db()
|
||||
user_row = service.pending_totp_user(conn)
|
||||
@@ -116,7 +116,7 @@ def logout_get():
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
||||
@bp.route("/confirm", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def reauth():
|
||||
target = _safe_next(session.get("_after_reauth"))
|
||||
|
||||
@@ -257,6 +257,24 @@ def _verify_credentials(
|
||||
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
||||
|
||||
|
||||
|
||||
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
|
||||
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
|
||||
|
||||
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
|
||||
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
|
||||
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
|
||||
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
|
||||
Anwendung, der vorher nicht darin war.
|
||||
|
||||
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
|
||||
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
|
||||
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
|
||||
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
|
||||
"""
|
||||
return not str(user_row.get("last_login_at") or "").strip()
|
||||
|
||||
|
||||
def complete_login(
|
||||
conn: sqlite3.Connection,
|
||||
user_row: dict[str, Any],
|
||||
@@ -275,21 +293,26 @@ def complete_login(
|
||||
"""
|
||||
from ..security import csrf
|
||||
|
||||
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
|
||||
erstanmeldung = _ist_erstanmeldung(user_row)
|
||||
|
||||
with core().database.transaction(conn):
|
||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.login",
|
||||
target=str(user_row["username"]),
|
||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
ip=ip,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
)
|
||||
if erstanmeldung:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.first_login",
|
||||
target=str(user_row["username"]),
|
||||
detail=f"Erste Anmeldung -- Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
ip=ip,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
session.clear()
|
||||
session[SESSION_USER_KEY] = int(user_row["id"])
|
||||
|
||||
@@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
idle_minutes: int,
|
||||
absolute_hours: int,
|
||||
idle_lookup: Any = None,
|
||||
absolute_lookup: Any = None,
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.cookie_name = cookie_name
|
||||
@@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
|
||||
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||
self._idle_lookup = idle_lookup
|
||||
self._absolute_lookup = absolute_lookup
|
||||
|
||||
def _absolute(self, conn: Any) -> int:
|
||||
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
|
||||
if self._absolute_lookup is None:
|
||||
return self.absolute_hours
|
||||
try:
|
||||
return int(self._absolute_lookup(conn)) or self.absolute_hours
|
||||
except Exception: # pragma: no cover - Einstellung unlesbar
|
||||
return self.absolute_hours
|
||||
|
||||
def _idle(self, conn: Any) -> int:
|
||||
if self._idle_lookup is None:
|
||||
@@ -280,7 +291,10 @@ class SqliteSessionInterface(SessionInterface):
|
||||
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
||||
with self.database.session() as conn, self.database.transaction(conn):
|
||||
if session.new or not session.sid:
|
||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||
# Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
|
||||
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
|
||||
if not session.sid:
|
||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||
conn.execute(
|
||||
"INSERT INTO user_sessions "
|
||||
"(id, user_id, created_at, last_seen_at, idle_expires_at, "
|
||||
@@ -292,7 +306,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
_iso(now),
|
||||
_iso(now),
|
||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
||||
_iso(now + timedelta(hours=self._absolute(conn))),
|
||||
session.get("_ip", ""),
|
||||
session.get("_ua", ""),
|
||||
payload,
|
||||
@@ -371,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
|
||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
||||
(_iso(_now()), reason, old_sid),
|
||||
)
|
||||
session.sid = ""
|
||||
# Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
|
||||
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
|
||||
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
|
||||
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
|
||||
# sie binnen Minuten wieder.
|
||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||
session.new = True
|
||||
session.modified = True
|
||||
return old_sid
|
||||
|
||||
@@ -1315,3 +1315,72 @@
|
||||
.hide-sm { display: none !important; }
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
|
||||
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
|
||||
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
|
||||
etwas anderes als das, was sie sortiert. */
|
||||
|
||||
.reorder {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reorder__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface-2);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.reorder__row > .flex-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.reorder__row.is-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.reorder__grip {
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.reorder__pos {
|
||||
min-width: 1.6em;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.reorder__moves {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.reorder__group {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reorder--child {
|
||||
margin: 4px 0 4px 28px;
|
||||
}
|
||||
|
||||
.reorder--child .reorder__row {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
@@ -856,6 +856,81 @@ function initTileFilters() {
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Reihenfolge aendern -------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
|
||||
*
|
||||
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
|
||||
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
|
||||
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
|
||||
*
|
||||
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
|
||||
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
|
||||
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
|
||||
* Tastatur ebenso.
|
||||
*/
|
||||
function initReorder() {
|
||||
$$('[data-behavior="reorder"]').forEach((liste) => {
|
||||
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
|
||||
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
|
||||
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
|
||||
|
||||
const nummerieren = () => {
|
||||
zeilen().forEach((zeile, index) => {
|
||||
const nummer = zeile.querySelector("[data-reorder-position-text]");
|
||||
if (nummer) nummer.textContent = String(index + 1);
|
||||
const hoch = zeile.querySelector('[data-reorder-move="up"]');
|
||||
const runter = zeile.querySelector('[data-reorder-move="down"]');
|
||||
if (hoch) hoch.disabled = index === 0;
|
||||
if (runter) runter.disabled = index === zeilen().length - 1;
|
||||
});
|
||||
};
|
||||
|
||||
liste.addEventListener("click", (event) => {
|
||||
const knopf = event.target.closest("[data-reorder-move]");
|
||||
if (!knopf) return;
|
||||
const zeile = knopf.closest("[data-reorder-item]");
|
||||
if (!zeile || zeile.parentElement !== liste) return;
|
||||
event.preventDefault();
|
||||
if (knopf.dataset.reorderMove === "up") {
|
||||
const davor = zeile.previousElementSibling;
|
||||
if (davor) liste.insertBefore(zeile, davor);
|
||||
} else {
|
||||
const danach = zeile.nextElementSibling;
|
||||
if (danach) liste.insertBefore(danach, zeile);
|
||||
}
|
||||
nummerieren();
|
||||
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
|
||||
});
|
||||
|
||||
let gezogen = null;
|
||||
liste.addEventListener("dragstart", (event) => {
|
||||
const zeile = event.target.closest("[data-reorder-item]");
|
||||
if (!zeile || zeile.parentElement !== liste) return;
|
||||
gezogen = zeile;
|
||||
gezogen.classList.add("is-dragging");
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
liste.addEventListener("dragend", () => {
|
||||
if (gezogen) gezogen.classList.remove("is-dragging");
|
||||
gezogen = null;
|
||||
nummerieren();
|
||||
});
|
||||
liste.addEventListener("dragover", (event) => {
|
||||
if (!gezogen) return;
|
||||
event.preventDefault();
|
||||
const ziel = event.target.closest("[data-reorder-item]");
|
||||
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
|
||||
const kasten = ziel.getBoundingClientRect();
|
||||
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
|
||||
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
|
||||
});
|
||||
|
||||
nummerieren();
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Start ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -893,6 +968,7 @@ function boot() {
|
||||
initPalette();
|
||||
initSubmitGuards();
|
||||
initTileFilters();
|
||||
initReorder();
|
||||
initLogTails();
|
||||
unsavedGuard.init();
|
||||
initViewer();
|
||||
|
||||
@@ -651,7 +651,7 @@ export function initFilePreview(wurzel = document) {
|
||||
abschliessen: modusFeld?.value === "fertig",
|
||||
});
|
||||
const daten = new FormData();
|
||||
daten.append("pfad", aktuell.pfad);
|
||||
daten.append("path", aktuell.pfad);
|
||||
daten.append("ordner", ordnerFeld.value.trim());
|
||||
daten.append("name", nameFeld.value.trim());
|
||||
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
||||
Anpassungen bleiben erhalten.
|
||||
{% 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
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -26,19 +26,19 @@
|
||||
{{ icon('search', size=15) }}
|
||||
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
|
||||
</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>
|
||||
{% 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 %}
|
||||
</select>
|
||||
<select class="select" name="stufe" style="width:auto">
|
||||
<select class="select" name="severity" style="width:auto">
|
||||
<option value="">alle Stufen</option>
|
||||
{% 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 %}
|
||||
</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">
|
||||
<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>
|
||||
@@ -88,10 +88,10 @@
|
||||
<span class="fs-sm muted">Seite {{ page }}</span>
|
||||
<span class="row row--tight">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -235,3 +235,69 @@
|
||||
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
||||
</div>
|
||||
{%- 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"/>',
|
||||
"chevron-right": '<path d="m9 6 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"/>',
|
||||
"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"/>',
|
||||
|
||||
@@ -31,7 +31,7 @@ from ..extension import core, db, transaction
|
||||
from ..rbac import service as rbac
|
||||
from ..security import passwords, sessions as session_store, totp
|
||||
|
||||
bp = Blueprint("account", __name__, url_prefix="/konto")
|
||||
bp = Blueprint("account", __name__, url_prefix="/account")
|
||||
|
||||
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||
@@ -54,6 +54,19 @@ def _avatar_dir() -> Path:
|
||||
|
||||
|
||||
def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
|
||||
"""Protokolliert eine Aenderung am **eigenen** Konto.
|
||||
|
||||
Bewusst sparsam benutzt. Das Aenderungsprotokoll beantwortet die Frage
|
||||
"wer hat woran etwas geaendert" -- und wer sein eigenes Passwort setzt,
|
||||
ein Profilbild tauscht oder seine Anschrift korrigiert, aendert nichts,
|
||||
was jemand anderen betrifft. Solche Zeilen fuellten das Protokoll und
|
||||
schoben die Eintraege aus dem Blick, um die es geht.
|
||||
|
||||
Was hier trotzdem stehen bleibt: alles rund um den zweiten Faktor. Ob ein
|
||||
Konto mit oder ohne zweiten Faktor geschuetzt ist, aendert die Staerke der
|
||||
Anmeldung -- das ist die eine Selbstbedienung, nach der man nach einem
|
||||
Vorfall sucht.
|
||||
"""
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
@@ -80,7 +93,7 @@ def overview():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/stammdaten")
|
||||
@bp.post("/profile")
|
||||
@login_required
|
||||
def update_profile():
|
||||
if current_user.is_directory_account:
|
||||
@@ -101,12 +114,11 @@ def update_profile():
|
||||
current_user.id,
|
||||
),
|
||||
)
|
||||
_audit(conn, "account.profile_updated")
|
||||
flash("Stammdaten gespeichert.", "success")
|
||||
return redirect(url_for("account.overview"))
|
||||
|
||||
|
||||
@bp.post("/profilbild")
|
||||
@bp.post("/avatar")
|
||||
@login_required
|
||||
def upload_avatar():
|
||||
upload = request.files.get("avatar")
|
||||
@@ -142,14 +154,13 @@ def upload_avatar():
|
||||
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
|
||||
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
|
||||
)
|
||||
_audit(conn, "account.avatar_updated")
|
||||
if previous:
|
||||
(_avatar_dir() / str(previous)).unlink(missing_ok=True)
|
||||
flash("Profilbild aktualisiert.", "success")
|
||||
return redirect(url_for("account.overview"))
|
||||
|
||||
|
||||
@bp.get("/profilbild/<path:filename>")
|
||||
@bp.get("/avatar/<path:filename>")
|
||||
@login_required
|
||||
def avatar(filename: str):
|
||||
safe = secure_filename(filename)
|
||||
@@ -163,7 +174,7 @@ def avatar(filename: str):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/sicherheit")
|
||||
@bp.get("/security")
|
||||
@login_required
|
||||
def security():
|
||||
conn = db()
|
||||
@@ -199,7 +210,7 @@ def security():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/passwort")
|
||||
@bp.post("/password")
|
||||
@login_required
|
||||
def change_password():
|
||||
conn = db()
|
||||
@@ -229,21 +240,15 @@ def change_password():
|
||||
revoked = session_store.revoke_all_for_user(
|
||||
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
|
||||
)
|
||||
_audit(
|
||||
conn,
|
||||
"account.password_changed",
|
||||
f"{revoked} weitere Sitzungen beendet",
|
||||
severity="notice",
|
||||
)
|
||||
except rbac.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success")
|
||||
flash(f"Passwort geaendert. {revoked} weitere Sitzung(en) wurden beendet.", "success")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/zwei-faktor/starten")
|
||||
@bp.post("/two-factor/start")
|
||||
@login_required
|
||||
@reauth_required
|
||||
def totp_start():
|
||||
@@ -252,7 +257,7 @@ def totp_start():
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/zwei-faktor/bestaetigen")
|
||||
@bp.post("/two-factor/confirm")
|
||||
@login_required
|
||||
@reauth_required
|
||||
def totp_confirm():
|
||||
@@ -295,7 +300,7 @@ def totp_confirm():
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/zwei-faktor/deaktivieren")
|
||||
@bp.post("/two-factor/disable")
|
||||
@login_required
|
||||
@reauth_required
|
||||
def totp_disable():
|
||||
@@ -317,7 +322,7 @@ def totp_disable():
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/zwei-faktor/codes-erneuern")
|
||||
@bp.post("/two-factor/recovery-codes")
|
||||
@login_required
|
||||
@reauth_required
|
||||
def recovery_codes():
|
||||
@@ -341,7 +346,7 @@ def recovery_codes():
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/sitzungen/<sid>/beenden")
|
||||
@bp.post("/sessions/<sid>/end")
|
||||
@login_required
|
||||
def revoke_session(sid: str):
|
||||
conn = db()
|
||||
@@ -350,18 +355,16 @@ def revoke_session(sid: str):
|
||||
abort(404)
|
||||
with transaction():
|
||||
session_store.revoke(conn, sid, reason="self")
|
||||
_audit(conn, "account.session_revoked", sid[:12])
|
||||
flash("Sitzung beendet.", "success")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/sitzungen/alle-beenden")
|
||||
@bp.post("/sessions/end-all")
|
||||
@login_required
|
||||
def revoke_other_sessions():
|
||||
with transaction() as conn:
|
||||
count = session_store.revoke_all_for_user(
|
||||
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
|
||||
)
|
||||
_audit(conn, "account.sessions_revoked", f"{count} Sitzungen")
|
||||
flash(f"{count} weitere Sitzung(en) beendet.", "success")
|
||||
return redirect(url_for("account.security"))
|
||||
@@ -16,7 +16,7 @@ from ..rbac import service as rbac
|
||||
from ..rbac.model import ACTION_META, Action
|
||||
from ..security import sessions as session_store
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/verwaltung")
|
||||
bp = Blueprint("admin", __name__, url_prefix="/administration")
|
||||
|
||||
|
||||
def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||
@@ -47,7 +47,7 @@ def _int_list(name: str) -> list[int]:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/benutzer")
|
||||
@bp.get("/users")
|
||||
@permission_required("users.view")
|
||||
def users():
|
||||
conn = db()
|
||||
@@ -60,7 +60,7 @@ def users():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/benutzer/neu")
|
||||
@bp.post("/users/new")
|
||||
@permission_required("users.create")
|
||||
def user_create():
|
||||
conn = db()
|
||||
@@ -94,7 +94,7 @@ def user_create():
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
@bp.post("/benutzer/<int:user_id>")
|
||||
@bp.post("/users/<int:user_id>")
|
||||
@permission_required("users.edit")
|
||||
def user_update(user_id: int):
|
||||
conn = db()
|
||||
@@ -137,7 +137,7 @@ def user_update(user_id: int):
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
@bp.post("/benutzer/<int:user_id>/loeschen")
|
||||
@bp.post("/users/<int:user_id>/delete")
|
||||
@permission_required("users.delete")
|
||||
def user_delete(user_id: int):
|
||||
conn = db()
|
||||
@@ -163,7 +163,7 @@ def user_delete(user_id: int):
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
@bp.post("/benutzer/<int:user_id>/sitzungen-beenden")
|
||||
@bp.post("/users/<int:user_id>/end-sessions")
|
||||
@permission_required("users.edit")
|
||||
def user_revoke_sessions(user_id: int):
|
||||
conn = db()
|
||||
@@ -184,7 +184,7 @@ def user_revoke_sessions(user_id: int):
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
@bp.get("/benutzer/verzeichnis-suche")
|
||||
@bp.get("/users/directory-search")
|
||||
@permission_required("users.create")
|
||||
def directory_search():
|
||||
conn = db()
|
||||
@@ -200,7 +200,7 @@ def directory_search():
|
||||
return jsonify({"results": results})
|
||||
|
||||
|
||||
@bp.post("/benutzer/verzeichnis-uebernehmen")
|
||||
@bp.post("/users/directory-import")
|
||||
@permission_required("users.create")
|
||||
def directory_adopt():
|
||||
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
|
||||
@@ -238,7 +238,7 @@ def directory_adopt():
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/gruppen")
|
||||
@bp.get("/groups")
|
||||
@permission_required("groups.view")
|
||||
def groups():
|
||||
conn = db()
|
||||
@@ -254,7 +254,7 @@ def groups():
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/gruppen/<int:group_id>")
|
||||
@bp.get("/groups/<int:group_id>")
|
||||
@permission_required("groups.view")
|
||||
def group_detail(group_id: int):
|
||||
conn = db()
|
||||
@@ -275,11 +275,11 @@ def group_detail(group_id: int):
|
||||
(group_id,),
|
||||
),
|
||||
can_edit=current_user.has("groups.edit"),
|
||||
unlocked=request.args.get("freischalten") == "1",
|
||||
unlocked=request.args.get("unlocked") == "1",
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/gruppen/neu")
|
||||
@bp.post("/groups/new")
|
||||
@permission_required("groups.create")
|
||||
def group_create():
|
||||
conn = db()
|
||||
@@ -301,7 +301,7 @@ def group_create():
|
||||
return redirect(url_for("admin.groups"))
|
||||
|
||||
|
||||
@bp.post("/gruppen/<int:group_id>")
|
||||
@bp.post("/groups/<int:group_id>")
|
||||
@permission_required("groups.edit")
|
||||
def group_update(group_id: int):
|
||||
conn = db()
|
||||
@@ -334,7 +334,7 @@ def group_update(group_id: int):
|
||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||
|
||||
|
||||
@bp.post("/gruppen/<int:group_id>/loeschen")
|
||||
@bp.post("/groups/<int:group_id>/delete")
|
||||
@permission_required("groups.delete")
|
||||
def group_delete(group_id: int):
|
||||
conn = db()
|
||||
@@ -354,7 +354,7 @@ def group_delete(group_id: int):
|
||||
return redirect(url_for("admin.groups"))
|
||||
|
||||
|
||||
@bp.post("/gruppen/<int:group_id>/vorlage-anwenden")
|
||||
@bp.post("/groups/<int:group_id>/apply-preset")
|
||||
@admin_required
|
||||
def group_apply_preset(group_id: int):
|
||||
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
|
||||
@@ -386,7 +386,7 @@ def group_apply_preset(group_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/sitzungen")
|
||||
@bp.get("/sessions")
|
||||
@admin_required
|
||||
def sessions():
|
||||
conn = db()
|
||||
@@ -399,7 +399,7 @@ def sessions():
|
||||
return render_template("tesm_core/sessions.html", sessions=rows)
|
||||
|
||||
|
||||
@bp.post("/sitzungen/<sid>/beenden")
|
||||
@bp.post("/sessions/<sid>/end")
|
||||
@admin_required
|
||||
def session_revoke(sid: str):
|
||||
with transaction() as conn:
|
||||
|
||||
@@ -24,7 +24,7 @@ from ..keystore import check_private_file
|
||||
from ..security import ratelimit
|
||||
from ..security import sessions as session_store
|
||||
|
||||
bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose")
|
||||
bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics")
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@@ -88,7 +88,7 @@ def overview():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/aufraeumen")
|
||||
@bp.post("/cleanup")
|
||||
@admin_required
|
||||
def housekeeping():
|
||||
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
|
||||
@@ -116,7 +116,7 @@ def housekeeping():
|
||||
return redirect(url_for("diagnostics.overview"))
|
||||
|
||||
|
||||
@bp.post("/schluessel-rotieren")
|
||||
@bp.post("/rotate-keys")
|
||||
@admin_required
|
||||
def rotate_keys():
|
||||
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
|
||||
|
||||
@@ -29,7 +29,7 @@ from ..licensing_client import LicenseError
|
||||
from ..rbac import service as rbac
|
||||
from ..system import hostnet, webserver
|
||||
|
||||
bp = Blueprint("hostsettings", __name__, url_prefix="/einstellungen")
|
||||
bp = Blueprint("hostsettings", __name__, url_prefix="/administration")
|
||||
|
||||
PENDING_NETWORK_KEY = "pending_network_revert"
|
||||
|
||||
@@ -64,7 +64,7 @@ def _license_hint(feature: str) -> str:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/netzwerk")
|
||||
@bp.get("/network")
|
||||
@permission_required("settings_network.view")
|
||||
def network():
|
||||
extension = core()
|
||||
@@ -80,7 +80,7 @@ def network():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/netzwerk")
|
||||
@bp.post("/network")
|
||||
@permission_required("settings_network.edit")
|
||||
def network_apply():
|
||||
extension = core()
|
||||
@@ -147,7 +147,7 @@ def network_apply():
|
||||
return redirect(url_for("hostsettings.network"))
|
||||
|
||||
|
||||
@bp.post("/netzwerk/bestaetigen")
|
||||
@bp.post("/network/confirm")
|
||||
@permission_required("settings_network.edit")
|
||||
def network_confirm():
|
||||
if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
|
||||
@@ -221,7 +221,7 @@ def _web_config() -> webserver.WebConfig:
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/webserver")
|
||||
@bp.get("/web")
|
||||
@permission_required("settings_web.view")
|
||||
def web():
|
||||
extension = core()
|
||||
@@ -240,7 +240,7 @@ def web():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/webserver")
|
||||
@bp.post("/web")
|
||||
@permission_required("settings_web.edit")
|
||||
def web_save():
|
||||
extension = core()
|
||||
@@ -302,7 +302,7 @@ def web_save():
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
|
||||
@bp.post("/webserver/uebernehmen")
|
||||
@bp.post("/web/apply")
|
||||
@permission_required("settings_web.execute")
|
||||
def web_apply():
|
||||
problem = _license_hint("nginx")
|
||||
@@ -327,7 +327,7 @@ def web_apply():
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
|
||||
@bp.post("/webserver/zertifikat")
|
||||
@bp.post("/web/certificate")
|
||||
@permission_required("settings_web.execute")
|
||||
def web_certificate():
|
||||
problem = _license_hint("nginx")
|
||||
@@ -364,7 +364,7 @@ def web_certificate():
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
|
||||
@bp.post("/webserver/selbstsigniert")
|
||||
@bp.post("/web/self-signed")
|
||||
@permission_required("settings_web.execute")
|
||||
def web_self_signed():
|
||||
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
|
||||
@@ -414,7 +414,7 @@ def web_self_signed():
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/verzeichnisdienst")
|
||||
@bp.get("/directory")
|
||||
@permission_required("settings_directory.view")
|
||||
def directory_settings():
|
||||
extension = core()
|
||||
@@ -435,7 +435,7 @@ def directory_settings():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/verzeichnisdienst")
|
||||
@bp.post("/directory")
|
||||
@permission_required("settings_directory.edit")
|
||||
def directory_save():
|
||||
problem = _license_hint("directory")
|
||||
@@ -481,7 +481,7 @@ def directory_save():
|
||||
return redirect(url_for("hostsettings.directory_settings"))
|
||||
|
||||
|
||||
@bp.post("/verzeichnisdienst/test")
|
||||
@bp.post("/directory/test")
|
||||
@permission_required("settings_directory.execute")
|
||||
def directory_test():
|
||||
result = directory.test_connection(db())
|
||||
@@ -489,7 +489,7 @@ def directory_test():
|
||||
return redirect(url_for("hostsettings.directory_settings"))
|
||||
|
||||
|
||||
@bp.get("/verzeichnisdienst/gruppensuche")
|
||||
@bp.get("/directory/group-search")
|
||||
@permission_required("settings_directory.edit", "fileshare.edit", mode="any")
|
||||
def directory_group_search():
|
||||
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
|
||||
@@ -504,7 +504,7 @@ def directory_group_search():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/verzeichnisdienst/zuordnung")
|
||||
@bp.post("/directory/mapping")
|
||||
@permission_required("settings_directory.edit")
|
||||
def directory_mapping_add():
|
||||
conn = db()
|
||||
@@ -534,7 +534,7 @@ def directory_mapping_add():
|
||||
return redirect(url_for("hostsettings.directory_settings"))
|
||||
|
||||
|
||||
@bp.post("/verzeichnisdienst/zuordnung/<int:mapping_id>/loeschen")
|
||||
@bp.post("/directory/mapping/<int:mapping_id>/delete")
|
||||
@permission_required("settings_directory.edit")
|
||||
def directory_mapping_delete(mapping_id: int):
|
||||
with transaction() as conn:
|
||||
|
||||
@@ -26,7 +26,7 @@ from ..auth.service import client_ip
|
||||
from ..extension import core, db, transaction
|
||||
from ..security.sessions import fragment
|
||||
|
||||
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
|
||||
bp = Blueprint("logs", __name__, url_prefix="/logs")
|
||||
|
||||
MAX_TAIL_LINES = 2000
|
||||
|
||||
@@ -38,7 +38,7 @@ def _live_log():
|
||||
return log
|
||||
|
||||
|
||||
@bp.get("/laufend")
|
||||
@bp.get("/live")
|
||||
@permission_required("logs_live.view")
|
||||
def live():
|
||||
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
||||
@@ -52,7 +52,7 @@ def live():
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/laufend/inhalt")
|
||||
@bp.get("/live/content")
|
||||
@permission_required("logs_live.view")
|
||||
@fragment
|
||||
def live_fragment():
|
||||
@@ -63,7 +63,7 @@ def live_fragment():
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/verlauf")
|
||||
@bp.get("/history")
|
||||
@permission_required("logs_history.view", "logs_history.export", mode="any")
|
||||
def history():
|
||||
extension = core()
|
||||
@@ -82,7 +82,7 @@ def history():
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/verlauf/datei/<path:name>")
|
||||
@bp.get("/history/file/<path:name>")
|
||||
@permission_required("logs_history.view")
|
||||
def history_file(name: str):
|
||||
extension = core()
|
||||
@@ -101,7 +101,7 @@ def history_file(name: str):
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/verlauf/archiv/<day>")
|
||||
@bp.get("/history/archive/<day>")
|
||||
@permission_required("logs_history.view")
|
||||
def history_archive(day: str):
|
||||
if len(day) != 10 or day.count("-") != 2:
|
||||
@@ -112,7 +112,7 @@ def history_archive(day: str):
|
||||
return render_template("tesm_core/logs_archive.html", day=day, rows=rows)
|
||||
|
||||
|
||||
@bp.post("/verlauf/archiv-export")
|
||||
@bp.post("/history/archive-export")
|
||||
@permission_required("logs_history.export")
|
||||
def history_export():
|
||||
"""Packt die archivierten Audit-Tage in ein ZIP.
|
||||
@@ -152,7 +152,7 @@ def history_export():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/verlauf/archiv-loeschen")
|
||||
@bp.post("/history/archive-delete")
|
||||
@permission_required("logs_history.delete")
|
||||
def history_purge():
|
||||
removed = 0
|
||||
@@ -176,20 +176,20 @@ def history_purge():
|
||||
return redirect(url_for("logs.history"))
|
||||
|
||||
|
||||
@bp.get("/aenderungen")
|
||||
@bp.get("/audit")
|
||||
@permission_required("logs_audit.view", "logs_audit.export", mode="any")
|
||||
def audit_view():
|
||||
conn = db()
|
||||
page = max(1, int(request.args.get("seite") or 1))
|
||||
page = max(1, int(request.args.get("page") or 1))
|
||||
per_page = 100
|
||||
return render_template(
|
||||
"tesm_core/logs_audit.html",
|
||||
rows=audit.search(
|
||||
conn,
|
||||
query=(request.args.get("q") or "").strip(),
|
||||
action=(request.args.get("aktion") or "").strip(),
|
||||
actor=(request.args.get("benutzer") or "").strip(),
|
||||
severity=(request.args.get("stufe") or "").strip(),
|
||||
action=(request.args.get("action") or "").strip(),
|
||||
actor=(request.args.get("actor") or "").strip(),
|
||||
severity=(request.args.get("severity") or "").strip(),
|
||||
limit=per_page,
|
||||
offset=(page - 1) * per_page,
|
||||
),
|
||||
@@ -200,14 +200,14 @@ def audit_view():
|
||||
per_page=per_page,
|
||||
filters={
|
||||
"q": request.args.get("q") or "",
|
||||
"aktion": request.args.get("aktion") or "",
|
||||
"benutzer": request.args.get("benutzer") or "",
|
||||
"stufe": request.args.get("stufe") or "",
|
||||
"action": request.args.get("action") or "",
|
||||
"actor": request.args.get("actor") or "",
|
||||
"severity": request.args.get("severity") or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/aenderungen/export")
|
||||
@bp.get("/audit/export")
|
||||
@permission_required("logs_audit.export")
|
||||
def audit_export():
|
||||
conn = db()
|
||||
|
||||
@@ -28,7 +28,7 @@ from ..db import Database
|
||||
from ..extension import core, db, transaction
|
||||
from ..rbac import service as rbac
|
||||
|
||||
bp = Blueprint("trash", __name__, url_prefix="/papierkorb")
|
||||
bp = Blueprint("trash", __name__, url_prefix="/administration/trash")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -191,7 +191,7 @@ def _provider_or_404(key: str) -> TrashProvider:
|
||||
return provider
|
||||
|
||||
|
||||
@bp.post("/<kind>/<ident>/wiederherstellen")
|
||||
@bp.post("/<kind>/<ident>/restore")
|
||||
@permission_required("trash.edit")
|
||||
def restore(kind: str, ident: str):
|
||||
extension = core()
|
||||
@@ -222,7 +222,7 @@ def restore(kind: str, ident: str):
|
||||
return redirect(url_for("trash.overview"))
|
||||
|
||||
|
||||
@bp.post("/<kind>/<ident>/endgueltig-loeschen")
|
||||
@bp.post("/<kind>/<ident>/purge")
|
||||
@permission_required("trash.delete")
|
||||
def purge(kind: str, ident: str):
|
||||
extension = core()
|
||||
@@ -252,7 +252,7 @@ def purge(kind: str, ident: str):
|
||||
return redirect(url_for("trash.overview"))
|
||||
|
||||
|
||||
@bp.post("/leeren")
|
||||
@bp.post("/empty")
|
||||
@permission_required("trash.delete")
|
||||
def purge_all():
|
||||
extension = core()
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.4.1"
|
||||
version = "2.5.0"
|
||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["cryptography>=42"]
|
||||
|
||||
Reference in new issue
Block a user