Umlaute in der Oberflaeche, Sitzungskopplung, Statusanzeigen
Echte Umlaute in allen Texten der Oberflaeche und in den Markdown-Dateien. Die Einbindung von Dateifreigaben haengt jetzt an der Sitzung: wird sie widerrufen oder laeuft sie ab, verschwinden die Freigaben mit ihr. Neu einstellbar ist die automatische Abmeldung bei Leerlauf (Vorgabe 30 Minuten). Ein Geraet, das nicht antwortet, bietet nur noch "Einschalten (PoE)" an -- Neustart ueber SSH oder RPC braucht ein erreichbares Geraet. Der Zeitgeber der naechsten Pruefung und die Restlaufzeit der Lizenz stehen jetzt oben in der Kopfzeile; der doppelte Knopf darunter ist weg. Millisekunden werden ab einer Sekunde als Sekunden und ab einer Minute als m:ss dargestellt. Ausserdem: Release-Tarball nicht mehr im Repository -- er haengt am Release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -8,7 +8,7 @@ Design-System.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.0.2"
|
||||
|
||||
from .app_factory import create_base_app
|
||||
from .config import CoreConfig, SecurityConfig, load_core_config
|
||||
|
||||
@@ -43,6 +43,11 @@ from .views import hostsettings as hostsettings_views
|
||||
from .views import logs as logs_views
|
||||
from .views import trash as trash_views
|
||||
|
||||
#: Rechteinhaber, in dieser Reihenfolge. Steht an einer Stelle, weil die
|
||||
#: Fusszeile in zwei Vorlagen auftaucht (Anwendungsrahmen und Anmeldeseiten)
|
||||
#: und zwei Texte fuer dieselbe Aussage auseinanderlaufen.
|
||||
COPYRIGHT_NOTICE = "Tim Eertmoed · WiS IT-Solutions GmbH"
|
||||
|
||||
CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
||||
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
||||
@@ -56,6 +61,18 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
minimum=1,
|
||||
maximum=3650,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
||||
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||
"Einbindung.",
|
||||
section="system",
|
||||
minimum=5,
|
||||
maximum=1440,
|
||||
),
|
||||
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
|
||||
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
||||
SettingSpec(
|
||||
@@ -117,7 +134,7 @@ def create_base_app(
|
||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.0.0",
|
||||
app_version: str = "2.0.2",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
@@ -192,11 +209,25 @@ def create_base_app(
|
||||
)
|
||||
app.extensions["tesm"] = extension
|
||||
|
||||
def _leerlauf(conn: Any) -> int:
|
||||
"""Leerlaufgrenze aus den Einstellungen -- ueber die offene Verbindung.
|
||||
|
||||
Bewusst mit der Verbindung, die die Sitzungsverwaltung schon haelt: eine
|
||||
zweite zu oeffnen waere eine verschachtelte Datenbankverbindung, und die
|
||||
gilt hier als Fehler.
|
||||
"""
|
||||
try:
|
||||
wert = int(settings.get(conn, "session_idle_minutes") or 0)
|
||||
except (TypeError, ValueError):
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_idle_minutes
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
@@ -264,6 +295,7 @@ def create_base_app(
|
||||
"auth_tagline": auth_tagline,
|
||||
"app_version": app_version,
|
||||
"current_year": datetime.now(timezone.utc).year,
|
||||
"copyright_notice": COPYRIGHT_NOTICE,
|
||||
"current_user": current_user,
|
||||
"nav_items": nav_items,
|
||||
"palette_entries": palette,
|
||||
|
||||
@@ -42,7 +42,7 @@ AAD_LDAP_BIND = "service_accounts.ldap_bind"
|
||||
|
||||
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
||||
ACCOUNTDISABLE = 0x0002
|
||||
#: OID fuer rekursive Gruppenauflösung in AD.
|
||||
#: OID fuer rekursive Gruppenaufloesung in AD.
|
||||
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||||
|
||||
Status = Literal["ok", "invalid", "unavailable", "disabled"]
|
||||
@@ -349,7 +349,7 @@ def _attr_int(entry: Any, name: str) -> int | None:
|
||||
|
||||
|
||||
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
|
||||
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
"""Rekursive Gruppenaufloesung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
try:
|
||||
connection.search(
|
||||
search_base=base,
|
||||
@@ -359,7 +359,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
|
||||
)
|
||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
||||
return ()
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class SecurityConfig:
|
||||
cookie_secure: bool = True
|
||||
cookie_samesite: str = "Lax"
|
||||
#: Leerlauf-Zeitfenster einer Sitzung.
|
||||
session_idle_minutes: int = 60
|
||||
session_idle_minutes: int = 30
|
||||
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
||||
session_absolute_hours: int = 12
|
||||
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
||||
@@ -147,7 +147,7 @@ def load_core_config(
|
||||
security = SecurityConfig(
|
||||
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
||||
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
|
||||
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60),
|
||||
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 30),
|
||||
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
|
||||
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
||||
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
|
||||
|
||||
@@ -48,6 +48,10 @@ SETTING_STATUS = "license_activation_status"
|
||||
SETTING_PENDING = "license_pending_request"
|
||||
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
|
||||
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
||||
|
||||
#: Ab wann die Restlaufzeit als *Hinweis* erscheint (gruen). Unterhalb von
|
||||
#: ``EXPIRY_WARNING_DAYS`` uebernimmt die Warnung (orange), nach Ablauf Rot.
|
||||
EXPIRY_HINT_DAYS = 90
|
||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||
|
||||
STATUS_INACTIVE = "inactive"
|
||||
@@ -596,14 +600,22 @@ class LicenseManager:
|
||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
||||
|
||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action=f"license.{action}",
|
||||
target=str(request_payload["license_id"]),
|
||||
detail=f"Transport: {transport}",
|
||||
severity="notice",
|
||||
)
|
||||
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||
# Ereignis, sondern der Normalzustand. Im Auditlog stand dadurch
|
||||
# praktisch nur noch er, und die Eintraege, um die es dort geht --
|
||||
# Aktivierung, Deaktivierung, Widerruf --, gingen darin unter. Der
|
||||
# Zeitpunkt des letzten Heartbeats steht ohnehin in den Einstellungen
|
||||
# und auf der Lizenzseite. Ein *fehlgeschlagener* Heartbeat wird
|
||||
# weiterhin protokolliert (siehe die Fehlerpfade oben).
|
||||
if action != "heartbeat":
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action=f"license.{action}",
|
||||
target=str(request_payload["license_id"]),
|
||||
detail=f"Transport: {transport}",
|
||||
severity="notice",
|
||||
)
|
||||
self.reload(conn)
|
||||
return {"ok": True, "transport": transport, "code": "", "message": message}
|
||||
|
||||
@@ -704,6 +716,19 @@ class LicenseManager:
|
||||
except LicenseError as exc:
|
||||
with self.database.transaction(conn):
|
||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
|
||||
# Der *erfolgreiche* Heartbeat wird nicht protokolliert -- er
|
||||
# ist der Normalzustand und hat das Auditlog zugeschuettet.
|
||||
# Ein Fehlschlag dagegen ist genau das, was dort stehen soll:
|
||||
# er kann bedeuten, dass die Lizenz demnaechst in die
|
||||
# Kulanzzeit laeuft.
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="license.heartbeat_failed",
|
||||
target=self._state.license_id or "",
|
||||
detail=str(exc)[:500],
|
||||
severity="warning",
|
||||
)
|
||||
return {"ok": False, "message": str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -761,6 +786,17 @@ class LicenseManager:
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Bitte rechtzeitig verlaengern.",
|
||||
}
|
||||
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
||||
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
||||
# nach einem Problem aussieht.
|
||||
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
||||
return {
|
||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
|
||||
}
|
||||
if state.last_heartbeat_at:
|
||||
try:
|
||||
last = lic.parse_iso(state.last_heartbeat_at)
|
||||
|
||||
@@ -88,11 +88,24 @@ class SqliteSessionInterface(SessionInterface):
|
||||
cookie_name: str,
|
||||
idle_minutes: int,
|
||||
absolute_hours: int,
|
||||
idle_lookup: Any = None,
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.cookie_name = cookie_name
|
||||
self.idle_minutes = idle_minutes
|
||||
self.absolute_hours = absolute_hours
|
||||
#: Optionale Nachschlagefunktion fuer die Leerlaufgrenze. Damit ist die
|
||||
#: 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
|
||||
|
||||
def _idle(self, conn: Any) -> int:
|
||||
if self._idle_lookup is None:
|
||||
return self.idle_minutes
|
||||
try:
|
||||
return int(self._idle_lookup(conn)) or self.idle_minutes
|
||||
except Exception: # pragma: no cover - Einstellung unlesbar
|
||||
return self.idle_minutes
|
||||
|
||||
# -- Flask-Schnittstelle ------------------------------------------- #
|
||||
|
||||
@@ -146,7 +159,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
with self.database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
|
||||
(_iso(now), _iso(now + timedelta(minutes=self.idle_minutes)), sid),
|
||||
(_iso(now), _iso(now + timedelta(minutes=self._idle(conn))), sid),
|
||||
)
|
||||
return session
|
||||
|
||||
@@ -190,7 +203,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
session.get("user_id"),
|
||||
_iso(now),
|
||||
_iso(now),
|
||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
||||
session.get("_ip", ""),
|
||||
session.get("_ua", ""),
|
||||
|
||||
@@ -408,6 +408,15 @@
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
/* Gefuellt statt getoent: "kritisch" muss sich von "Warnung" unterscheiden,
|
||||
und beide sind rot. */
|
||||
.pill--critical {
|
||||
background: var(--danger);
|
||||
color: var(--surface-0);
|
||||
font-weight: 700;
|
||||
}
|
||||
.pill--critical::before { background: currentColor; }
|
||||
|
||||
.pill--warning, .pill--unknown, .pill--orange {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
@@ -436,6 +445,22 @@
|
||||
background: var(--success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Der Knopf sitzt *in* der Pille: kein Rahmen, keine Flaeche, nur das
|
||||
Symbol in der Farbe der Pille. Ein Standardknopf wuerde sie sprengen. */
|
||||
.status-pill__action { display: inline-flex; }
|
||||
.status-pill__action button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
}
|
||||
.status-pill__action button:hover { opacity: 1; }
|
||||
|
||||
.status-pill__dot--pulse { animation: pulse-ring 2.4s var(--ease) infinite; }
|
||||
.status-pill--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); }
|
||||
.status-pill--danger .status-pill__dot { background: var(--danger); }
|
||||
@@ -655,6 +680,37 @@
|
||||
}
|
||||
.modal__title { font-size: var(--fs-md); font-weight: 640; flex: 1; }
|
||||
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
||||
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
|
||||
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
|
||||
.modal__body--flush { padding: 0; max-height: none; }
|
||||
.modal__caption {
|
||||
padding: 0 var(--sp-5) var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
.file-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: min(70dvh, 760px);
|
||||
border: 0;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
|
||||
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
|
||||
unruhig. */
|
||||
.file-name {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-name:hover { text-decoration: underline; }
|
||||
|
||||
.modal__body {
|
||||
padding: var(--sp-5);
|
||||
overflow-y: auto;
|
||||
@@ -1035,6 +1091,16 @@
|
||||
.warning-text { color: var(--warning); }
|
||||
.accent-text { color: var(--accent); }
|
||||
.mono { font-family: var(--font-mono); font-size: 0.94em; }
|
||||
/* Ein Inline-SVG ist im Reset display:block (base.css). In einem Knopf
|
||||
stoert das nicht -- .btn ist inline-flex. In einem einfachen Link reisst
|
||||
es die Beschriftung in die naechste Zeile. Fuer solche Links deshalb
|
||||
ausdruecklich eine Zeile. */
|
||||
.link-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nowrap { white-space: nowrap; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.break-all { overflow-wrap: anywhere; }
|
||||
|
||||
@@ -28,7 +28,7 @@ function store(key, value) {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */
|
||||
/* Privatmodus o. Ae. -- Einstellungen sind dann nur fluechtig. */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,13 +212,21 @@ function initDialogs() {
|
||||
event.preventDefault();
|
||||
const dialog = document.getElementById(opener.dataset.dialogOpen);
|
||||
if (dialog) {
|
||||
// Felder aus data-fill-* des Auslösers in das Formular uebernehmen.
|
||||
// Felder aus data-fill-* des Ausloesers in das Formular uebernehmen.
|
||||
Object.entries(opener.dataset).forEach(([key, value]) => {
|
||||
if (!key.startsWith("fill")) return;
|
||||
const name = key.slice(4);
|
||||
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
|
||||
if (!target) return;
|
||||
if ("value" in target) target.value = value;
|
||||
// Wohin der Wert gehoert, haengt am Element: ein Rahmen will die
|
||||
// Quelle, ein Link das Ziel, ein Feld einen Wert. Ohne diese
|
||||
// Unterscheidung landete die URL als *Beschriftung* im
|
||||
// Herunterladen-Knopf -- und weil das href dabei "#" blieb, lud ein
|
||||
// Klick die Seite selbst als "Herunterladen.htm" herunter.
|
||||
const als = target.dataset.fillAs;
|
||||
if (als === "src") target.src = value;
|
||||
else if (als === "href") target.href = value;
|
||||
else if ("value" in target) target.value = value;
|
||||
else target.textContent = value;
|
||||
});
|
||||
openDialog(dialog);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{# Auswahlfeld fuer eine Verzeichnisgruppe.
|
||||
{# Auswahlfeld für eine Verzeichnisgruppe.
|
||||
|
||||
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthaelt
|
||||
Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten
|
||||
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthält
|
||||
Kommas und Leerzeichen und ein Tippfehler fällt erst bei der nächsten
|
||||
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
|
||||
|
||||
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
|
||||
beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
||||
Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein
|
||||
eingefuegter DN besser als gar keine Zuordnung.
|
||||
beide gefüllt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
||||
Umgebung ohne Dienstkonto ist die Suche nicht möglich, und dann ist ein
|
||||
eingefügter DN besser als gar keine Zuordnung.
|
||||
|
||||
Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kuerzel),
|
||||
Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kürzel),
|
||||
optional name_value / dn_value und required.
|
||||
#}
|
||||
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="card__body">
|
||||
{% if current_user.is_directory_account %}
|
||||
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
|
||||
Anmeldung uebernommen.', 'info') }}
|
||||
Anmeldung übernommen.', 'info') }}
|
||||
{% endif %}
|
||||
<div class="form-grid">
|
||||
{{ field('first_name', 'Vorname', value=profile.first_name,
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{% if current_user.is_admin %}
|
||||
{{ alert('Sie sind Administrator und duerfen unabhaengig von Gruppen alles.', 'warning') }}
|
||||
{{ alert('Sie sind Administrator und dürfen unabhängig von Gruppen alles.', 'warning') }}
|
||||
{% elif permissions %}
|
||||
<div class="row row--tight">
|
||||
{% for permission in permissions %}
|
||||
|
||||
@@ -35,26 +35,26 @@
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Passwort aendern</div></div>
|
||||
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Passwort ändern</div></div>
|
||||
</div>
|
||||
{% if current_user.is_directory_account %}
|
||||
<div class="card__body">
|
||||
{{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geaendert.', 'info') }}
|
||||
{{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geändert.', 'info') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('account.change_password') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ field('current_password', 'Aktuelles Passwort', type='password', required=True,
|
||||
{{ field('current_password', 'Aktülles Passwort', type='password', required=True,
|
||||
autocomplete='current-password') }}
|
||||
{{ field('new_password', 'Neues Passwort', type='password', required=True,
|
||||
autocomplete='new-password',
|
||||
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
|
||||
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
|
||||
autocomplete='new-password') }}
|
||||
{{ alert('Nach der Aenderung werden alle anderen Sitzungen beendet.', 'info') }}
|
||||
{{ alert('Nach der Änderung werden alle anderen Sitzungen beendet.', 'info') }}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Passwort aendern</button></div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Passwort ändern</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{% if pending_secret %}
|
||||
{{ alert('Richten Sie den Eintrag in Ihrer App ein und bestaetigen Sie mit einem aktuellen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }}
|
||||
{{ alert('Richten Sie den Eintrag in Ihrer App ein und bestätigen Sie mit einem aktüllen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }}
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Geheimnis</dt>
|
||||
<dd><span class="mono" id="totp-secret">{{ pending_secret_grouped }}</span>
|
||||
@@ -87,7 +87,7 @@
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input class="input input--mono" type="text" name="code" required maxlength="6"
|
||||
inputmode="numeric" placeholder="123456" style="max-width:130px;text-align:center">
|
||||
<button class="btn btn--primary" type="submit">Bestaetigen</button>
|
||||
<button class="btn btn--primary" type="submit">Bestätigen</button>
|
||||
</form>
|
||||
{% elif totp_enabled %}
|
||||
<dl class="kv fs-sm">
|
||||
@@ -100,7 +100,7 @@
|
||||
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit"
|
||||
data-confirm="Neue Codes erzeugen? Die bisherigen verlieren ihre Gueltigkeit."
|
||||
data-confirm="Neue Codes erzeugen? Die bisherigen verlieren ihre Gültigkeit."
|
||||
data-confirm-title="Codes erneuern" data-confirm-ok="Erneuern" data-confirm-style="primary">
|
||||
Codes erneuern</button>
|
||||
</form>
|
||||
@@ -114,10 +114,10 @@
|
||||
</div>
|
||||
{% else %}
|
||||
{% if require_totp_for_admins and current_user.is_admin %}
|
||||
{{ alert('In dieser Installation ist ein zweiter Faktor fuer Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }}
|
||||
{{ alert('In dieser Installation ist ein zweiter Faktor für Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }}
|
||||
{% endif %}
|
||||
<p class="muted fs-sm">
|
||||
Ein zweiter Faktor schuetzt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird.
|
||||
Ein zweiter Faktor schützt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird.
|
||||
</p>
|
||||
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{# ==========================================================================
|
||||
Grundgeruest aller angemeldeten Seiten.
|
||||
Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
||||
Grundgerüst aller angemeldeten Seiten.
|
||||
Blöcke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
||||
content, dialogs, scripts
|
||||
========================================================================== #}
|
||||
<!doctype html>
|
||||
@@ -23,9 +23,9 @@
|
||||
|
||||
<aside class="sidebar" aria-label="Hauptnavigation">
|
||||
<div class="sidebar__brand">
|
||||
{# Die Wortmarke traegt den Namen selbst -- ein zweiter Textblock daneben
|
||||
waere eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
||||
Themen-Token kommen: zwei per Media Query versteckte SVG waeren bei
|
||||
{# Die Wortmarke trägt den Namen selbst -- ein zweiter Textblock daneben
|
||||
wäre eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
||||
Themen-Token kommen: zwei per Media Query versteckte SVG wären bei
|
||||
"System" und beim Umschalten von Hand falsch. #}
|
||||
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
|
||||
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -126,9 +126,25 @@
|
||||
<div class="topbar__right">
|
||||
{% block topbar_extra %}{% endblock %}
|
||||
{% for pill in status_pills %}
|
||||
{# Eine Pille kann mehr als Text sein: ein Countdown (data-countdown-to
|
||||
wird laufend gezählt) und eine Aktion. Der Knopf steckt bewusst in
|
||||
der Pille -- er gehört zu der Angabe, die er aktualisiert. #}
|
||||
<span class="status-pill {{ pill.css_class }}" {% if pill.title %}title="{{ pill.title }}"{% endif %}>
|
||||
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
|
||||
{{ pill.label }}
|
||||
{% if pill.countdown_to %}
|
||||
<span class="mono" data-countdown-to="{{ pill.countdown_to }}"
|
||||
data-countdown-done="{{ pill.countdown_done or 'jetzt' }}"></span>
|
||||
{% endif %}
|
||||
{% if pill.suffix %}{{ pill.suffix }}{% endif %}
|
||||
{% if pill.action %}
|
||||
<form method="post" action="{{ pill.action }}" data-no-guard class="status-pill__action">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button type="submit" title="{{ pill.action_title or 'Jetzt ausführen' }}"
|
||||
aria-label="{{ pill.action_title or 'Jetzt ausführen' }}"
|
||||
data-busy-label="...">{{ icon('refresh', size=13) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
|
||||
@@ -141,7 +157,7 @@
|
||||
</main>
|
||||
|
||||
<footer class="page-footer">
|
||||
<span>{{ app_name }} {{ app_version }} · © {{ current_year }} WiS GmbH</span>
|
||||
<span>{{ app_name }} {{ app_version }} · © {{ current_year }} {{ copyright_notice }}</span>
|
||||
<span class="row row--tight">
|
||||
{% if migration_warning %}
|
||||
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
|
||||
@@ -163,17 +179,17 @@
|
||||
aria-label="Suchbegriff" autocomplete="off">
|
||||
<div class="palette__list"></div>
|
||||
<div class="palette__hint">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> waehlen</span>
|
||||
<span><kbd>Enter</kbd> oeffnen</span>
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> wählen</span>
|
||||
<span><kbd>Enter</kbd> öffnen</span>
|
||||
<span><kbd>Esc</kbd> schliessen</span>
|
||||
</div>
|
||||
</dialog>
|
||||
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
|
||||
|
||||
{# -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- #}
|
||||
{# -- Bestätigungsdialog (ersetzt window.confirm) ------------------------- #}
|
||||
<dialog class="modal modal--narrow" id="confirm-dialog" aria-labelledby="confirm-title">
|
||||
<div class="modal__header">
|
||||
<div class="modal__title" id="confirm-title" data-confirm-title>Bitte bestaetigen</div>
|
||||
<div class="modal__title" id="confirm-title" data-confirm-title>Bitte bestätigen</div>
|
||||
</div>
|
||||
<div class="modal__body"><p data-confirm-text></p></div>
|
||||
<div class="modal__footer">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% from "tesm_core/macros.html" import alert, pill, stat %}
|
||||
|
||||
{% block page_title %}Diagnose{% endblock %}
|
||||
{% block page_sub %}Schemastand, Protokollintegritaet, Schluessel und Systemzugriff{% endblock %}
|
||||
{% block page_sub %}Schemastand, Protokollintegrität, Schlüssel und Systemzugriff{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid--4">
|
||||
@@ -17,17 +17,17 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('shield', size=17) }} Aenderungsprotokoll
|
||||
{{ icon('shield', size=17) }} Änderungsprotokoll
|
||||
{% if chain.ok %}{{ pill('unversehrt', 'online') }}{% else %}{{ pill('Kette gebrochen', 'danger') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">{{ chain.checked }} Zeilen geprueft (Hash-Kette)</div>
|
||||
<div class="card__sub">{{ chain.checked }} Zeilen geprüft (Hash-Kette)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{% if chain.ok %}
|
||||
<p class="muted fs-sm">
|
||||
Jede Zeile bindet den Hash ihrer Vorgaengerin ein. Eine nachtraeglich entfernte oder
|
||||
geaenderte Zeile wuerde hier auffallen.
|
||||
Jede Zeile bindet den Hash ihrer Vorgängerin ein. Eine nachträglich entfernte oder
|
||||
geänderte Zeile würde hier auffallen.
|
||||
</p>
|
||||
{% else %}
|
||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||
@@ -40,17 +40,17 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('key', size=17) }} Schluessel und Dateirechte
|
||||
{{ icon('key', size=17) }} Schlüssel und Dateirechte
|
||||
{% if key_problems %}{{ pill('Problem', 'danger') }}{% else %}{{ pill('in Ordnung', 'online') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">Aktiver Datenschluessel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})</div>
|
||||
<div class="card__sub">Aktiver Datenschlüssel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})</div>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit"
|
||||
data-confirm="Neuen Datenschluessel anlegen und gespeicherte Geheimnisse neu verschluesseln? Der alte Schluessel bleibt zum Lesen erhalten."
|
||||
data-confirm-title="Schluessel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
||||
data-confirm="Neuen Datenschlüssel anlegen und gespeicherte Geheimnisse neu verschlüsseln? Der alte Schlüssel bleibt zum Lesen erhalten."
|
||||
data-confirm-title="Schlüssel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
||||
{{ icon('refresh', size=14) }} Rotieren
|
||||
</button>
|
||||
</form>
|
||||
@@ -63,8 +63,8 @@
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted fs-sm">
|
||||
Alle Schluesseldateien liegen mit Modus 0600. Feldverschluesselung ist AES-256-GCM mit
|
||||
rotierbarem Schluesselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
||||
Alle Schlüsseldateien liegen mit Modus 0600. Feldverschlüsselung ist AES-256-GCM mit
|
||||
rotierbarem Schlüsselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@
|
||||
<th class="sortable">Komponente</th>
|
||||
<th class="sortable numeric">Version</th>
|
||||
<th class="sortable">Migration</th>
|
||||
<th>Pruefsumme</th>
|
||||
<th>Prüfsumme</th>
|
||||
<th class="sortable">Angewendet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -112,7 +112,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('server', size=17) }} Systemzugriff
|
||||
{% if not sysops_available %}{{ pill('nicht verfuegbar', 'warning') }}
|
||||
{% if not sysops_available %}{{ pill('nicht verfügbar', 'warning') }}
|
||||
{% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }}
|
||||
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
|
||||
</div>
|
||||
@@ -121,7 +121,7 @@
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<p class="muted fs-sm">
|
||||
Der Webprozess laeuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau
|
||||
Der Webprozess läuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau
|
||||
ein Helferskript mit Positivliste – nicht durch die Anwendung selbst.
|
||||
</p>
|
||||
{% if not sysops_available %}
|
||||
@@ -129,10 +129,10 @@
|
||||
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
|
||||
Entwicklungsumgebung.', 'info') }}
|
||||
{% elif sysops_selftest and not sysops_selftest.ok %}
|
||||
{# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
||||
Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend
|
||||
{# Geprüft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
||||
Genau daran lag es einmal: die Seite meldete "erreichbar", während
|
||||
jeder Aufruf scheiterte. #}
|
||||
{{ alert(sysops_selftest.message or 'Der Helfer laesst sich nicht aufrufen.',
|
||||
{{ alert(sysops_selftest.message or 'Der Helfer lässt sich nicht aufrufen.',
|
||||
'danger', 'Systemaktionen funktionieren nicht') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -144,7 +144,7 @@
|
||||
{% if current_user.is_admin %}
|
||||
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Aufraeumen</button>
|
||||
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Aufräumen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -167,14 +167,14 @@
|
||||
{{ icon('key', size=17) }} Lizenzzustand
|
||||
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
|
||||
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
|
||||
{% else %}{{ pill('ungueltig', 'danger') }}{% endif %}
|
||||
{% else %}{{ pill('ungültig', 'danger') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Datei vorhanden</dt><dd>{{ license_state.present | yes_no }}</dd>
|
||||
<dt>Signatur gueltig</dt><dd>{{ license_state.valid | yes_no }}</dd>
|
||||
<dt>Signatur gültig</dt><dd>{{ license_state.valid | yes_no }}</dd>
|
||||
<dt>Aussteller gepinnt</dt><dd>{{ license_state.issuer_pinned | yes_no }}</dd>
|
||||
<dt>Rolle</dt><dd>{{ license_state.role }}</dd>
|
||||
<dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '–' | safe }}</dd>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<div class="alert__body">{{ description }}</div>
|
||||
</div>
|
||||
{% if request_id %}
|
||||
<p class="fs-xs faint center">Vorgangsnummer fuer die Fehlersuche: <span class="mono">{{ request_id }}</span></p>
|
||||
<p class="fs-xs faint center">Vorgangsnummer für die Fehlersuche: <span class="mono">{{ request_id }}</span></p>
|
||||
{% endif %}
|
||||
<div class="row row--end">
|
||||
<a class="btn" href="{{ url_for('auth.login') if code in (401, 403) and not current_user.is_authenticated else back_url }}">
|
||||
{{ 'Zur Anmeldung' if code in (401, 403) and not current_user.is_authenticated else 'Zurueck' }}
|
||||
{{ 'Zur Anmeldung' if code in (401, 403) and not current_user.is_authenticated else 'Zurück' }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
{{ icon('lock', size=18) }}
|
||||
<div class="alert__body">
|
||||
<div class="alert__title">Systemgruppe</div>
|
||||
Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte
|
||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurueckgesetzt –
|
||||
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
|
||||
<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) }}">
|
||||
@@ -49,8 +49,8 @@
|
||||
{% if group.preset_key and current_user.is_admin %}
|
||||
<button class="btn btn--sm" type="submit"
|
||||
formaction="{{ url_for('admin.group_apply_preset', group_id=group.id) }}"
|
||||
data-confirm="Rechte dieser Gruppe auf die Vorlage zuruecksetzen? Bestehende Anpassungen gehen verloren."
|
||||
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zuruecksetzen" data-confirm-style="primary">
|
||||
data-confirm="Rechte dieser Gruppe auf die Vorlage zurücksetzen? Bestehende Anpassungen gehen verloren."
|
||||
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zurücksetzen" data-confirm-style="primary">
|
||||
{{ icon('refresh', size=14) }} Vorlage anwenden
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system,
|
||||
hint_text='Systemgruppen koennen nicht umbenannt werden.' if group.is_system else '') }}
|
||||
hint_text='Systemgruppen können nicht umbenannt werden.' if group.is_system else '') }}
|
||||
{{ textarea_field('description', 'Beschreibung', value=group.description, rows=2,
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %}
|
||||
|
||||
{% block page_title %}Gruppen und Rechte{% endblock %}
|
||||
{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich ueber alle Gruppen eines Benutzers{% endblock %}
|
||||
{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich über alle Gruppen eines Benutzers{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('groups.create') %}
|
||||
@@ -13,9 +13,9 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer
|
||||
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine
|
||||
neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt für
|
||||
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter über eine
|
||||
neue Gruppe selbst höherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
|
||||
<div class="grid grid--2">
|
||||
{% for group in groups %}
|
||||
@@ -37,9 +37,9 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
<form method="post" action="{{ url_for('admin.group_delete', group_id=group.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Gruppe {{ group.name }} loeschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehoerigen Rechte."
|
||||
data-confirm-title="Gruppe loeschen" data-confirm-ok="Loeschen"
|
||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
||||
data-confirm="Gruppe {{ group.name }} löschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehörigen Rechte."
|
||||
data-confirm-title="Gruppe löschen" data-confirm-ok="Löschen"
|
||||
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('groups.create') %}
|
||||
{% call modal('group-new', 'Gruppe anlegen', 'Rechte koennen anschliessend feiner eingestellt werden', 'wide') %}
|
||||
{% call modal('group-new', 'Gruppe anlegen', 'Rechte können anschliessend feiner eingestellt werden', 'wide') %}
|
||||
<form method="post" action="{{ url_for('admin.group_create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
|
||||
@@ -24,6 +24,6 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if public_dashboard_url %}
|
||||
<p class="center fs-sm"><a href="{{ public_dashboard_url }}">Zur oeffentlichen Statusuebersicht</a></p>
|
||||
<p class="center fs-sm"><a href="{{ public_dashboard_url }}">Zur öffentlichen Statusübersicht</a></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% block page_title %}Archiv {{ day }}{% endblock %}
|
||||
{% block page_sub %}{{ rows | length }} archivierte Aenderungen{% endblock %}
|
||||
{% block page_sub %}{{ rows | length }} archivierte Änderungen{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
|
||||
|
||||
{% block page_title %}Aenderungsprotokoll{% endblock %}
|
||||
{% block page_sub %}{{ total }} Eintraege · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
||||
{% block page_title %}Änderungsprotokoll{% endblock %}
|
||||
{% block page_sub %}{{ total }} Einträge · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('logs_audit.export') %}
|
||||
@@ -13,10 +13,10 @@
|
||||
{% block content %}
|
||||
{% if not chain.ok %}
|
||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||
'Das Protokoll wurde veraendert') }}
|
||||
'Das Protokoll wurde verändert') }}
|
||||
{% else %}
|
||||
{{ alert('Jeder Eintrag bindet den Hash seines Vorgaengers ein. Eine nachtraeglich entfernte
|
||||
oder geaenderte Zeile faellt bei der Pruefung auf -- ' ~ chain.checked ~ ' Zeilen geprueft.',
|
||||
{{ alert('Jeder Eintrag bindet den Hash seines Vorgängers ein. Eine nachträglich entfernte
|
||||
oder geänderte Zeile fällt bei der Prüfung auf -- ' ~ chain.checked ~ ' Zeilen geprüft.',
|
||||
'success') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
||||
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') }}">Zuruecksetzen</a>
|
||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
||||
</form>
|
||||
|
||||
<div class="table-wrap">
|
||||
@@ -66,10 +66,15 @@
|
||||
<td class="fs-sm">{{ row.target }}</td>
|
||||
<td class="fs-sm muted break-all">{{ row.detail }}</td>
|
||||
<td>
|
||||
{% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }}
|
||||
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }}
|
||||
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }}
|
||||
{% else %}{{ pill('Info', '') }}{% endif %}
|
||||
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
||||
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
||||
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
||||
eigene, gefüllte Darstellung, sonst wäre es von "Warnung"
|
||||
nicht zu unterscheiden. #}
|
||||
{% if row.severity == 'critical' %}{{ pill('kritisch', 'critical') }}
|
||||
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'danger') }}
|
||||
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
|
||||
{% else %}{{ pill('Info', 'online') }}{% endif %}
|
||||
</td>
|
||||
<td class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
||||
</tr>
|
||||
@@ -77,13 +82,13 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not rows %}{{ empty_state('Keine Eintraege', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %}
|
||||
{% if not rows %}{{ empty_state('Keine Einträge', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %}
|
||||
|
||||
<div class="card__footer" style="justify-content:space-between">
|
||||
<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) }}">Zurueck</a>
|
||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=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>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Rotiert wird per logrotate mit copytruncate -- keiner der schreibenden Prozesse
|
||||
(Ueberwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }}
|
||||
(Überwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }}
|
||||
|
||||
<div class="grid grid--2">
|
||||
<section class="card">
|
||||
@@ -19,7 +19,7 @@
|
||||
{% if rotated %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Datei</th><th class="numeric">Groesse</th><th>Geaendert</th></tr></thead>
|
||||
<thead><tr><th>Datei</th><th class="numeric">Grösse</th><th>Geändert</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in rotated %}
|
||||
<tr>
|
||||
@@ -39,7 +39,7 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('history', size=17) }} Archivierte Aenderungen</div>
|
||||
<div class="card__title">{{ icon('history', size=17) }} Archivierte Änderungen</div>
|
||||
<div class="card__sub">Aus der Datenbank ausgelagerte Kalendertage</div>
|
||||
</div>
|
||||
{% if archived and current_user.has('logs_history.export') %}
|
||||
@@ -52,7 +52,7 @@
|
||||
{% if archived %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Tag</th><th class="numeric">Groesse</th><th></th></tr></thead>
|
||||
<thead><tr><th>Tag</th><th class="numeric">Grösse</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in archived %}
|
||||
<tr>
|
||||
@@ -69,8 +69,8 @@
|
||||
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Alle {{ archived | length }} Archivdateien endgueltig vom Server loeschen? Bitte vorher als ZIP sichern."
|
||||
data-confirm-title="Archiv loeschen" data-confirm-ok="Loeschen">
|
||||
data-confirm="Alle {{ archived | length }} Archivdateien endgültig vom Server löschen? Bitte vorher als ZIP sichern."
|
||||
data-confirm-title="Archiv löschen" data-confirm-ok="Löschen">
|
||||
Archiv vom Server entfernen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -51,8 +51,22 @@
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
|
||||
{# Ein nicht angehakter Haken sendet **nichts**. Wer die gesendeten Felder
|
||||
auswertet, sieht ihn deshalb als "nicht angegeben" -- und liess den alten
|
||||
Wert stehen: Haken raus, speichern, "keine Änderungen". Diese versteckte
|
||||
Marke sagt "dieses Feld war im Formular", der Haken selbst sagt "an".
|
||||
Damit sind alle drei Fälle unterscheidbar: an, aus, nicht Teil des
|
||||
Formulars (Teilformulare dürfen fremde Schalter nicht löschen).
|
||||
|
||||
Kein ``disabled``-Fall: ein deaktivierter Haken darf auch nicht als "aus"
|
||||
gespeichert werden, also fällt die Marke dann ebenfalls weg. #}
|
||||
{% macro _present(name, disabled=False) -%}
|
||||
{% if not disabled %}<input type="hidden" name="{{ name }}__present" value="1">{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro checkbox(name, label, checked=False, value='1', hint_text='', disabled=False) -%}
|
||||
<label class="check {% if disabled %}check--disabled{% endif %}">
|
||||
{{ _present(name, disabled) }}
|
||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||
<span>{{ label }} {{ hint(hint_text) }}</span>
|
||||
@@ -61,6 +75,7 @@
|
||||
|
||||
{% macro switch(name, label, checked=False, value='1', disabled=False) -%}
|
||||
<label class="switch">
|
||||
{{ _present(name, disabled) }}
|
||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||
<span>{{ label }}</span>
|
||||
@@ -196,12 +211,12 @@
|
||||
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kaestchen hat das
|
||||
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das
|
||||
JS beim Einschalten des Bereichs wieder freigegeben, es liess
|
||||
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
|
||||
aus und war nach dem Speichern weg. Was es nicht gibt, ist
|
||||
hier deshalb gar kein Bedienelement. #}
|
||||
<span class="matrix__na" title="Fuer diese Zeile nicht vorgesehen."></span>
|
||||
<span class="matrix__na" title="Für diese Zeile nicht vorgesehen."></span>
|
||||
<span class="sr-only">nicht vorgesehen</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -216,7 +231,7 @@
|
||||
{% for action, meta in action_meta.items() %}
|
||||
<span><b>{{ meta[0] }}</b> {{ meta[1] }} – {{ meta[2] }}</span>
|
||||
{% endfor %}
|
||||
<span><span class="matrix__na"></span> Fuer diese Zeile nicht vorgesehen –
|
||||
die Spalte gehoert zu einer anderen Zeile desselben Bereichs.</span>
|
||||
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen –
|
||||
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
{% block heading %}Bitte bestaetigen{% endblock %}
|
||||
{% block heading %}Bitte bestätigen{% endblock %}
|
||||
{% block subheading %}Diese Aktion verlangt eine erneute Eingabe Ihres Passworts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert--info">
|
||||
{{ icon('info', size=18) }}
|
||||
<div class="alert__body">
|
||||
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein uebernommenes
|
||||
Sitzungscookie allein reicht dafuer nicht aus – die Bestaetigung gilt
|
||||
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein übernommenes
|
||||
Sitzungscookie allein reicht dafür nicht aus – die Bestätigung gilt
|
||||
danach {{ reauth_minutes }} Minuten.
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@
|
||||
</label>
|
||||
<div class="row row--end">
|
||||
<a class="btn" href="{{ cancel_url }}">Abbrechen</a>
|
||||
<button class="btn btn--primary" type="submit">Bestaetigen</button>
|
||||
<button class="btn btn--primary" type="submit">Bestätigen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
inputmode="numeric" autocomplete="one-time-code" maxlength="14"
|
||||
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
|
||||
<span class="field__hint">
|
||||
Alternativ koennen Sie einen Ihrer Wiederherstellungscodes eingeben.
|
||||
Alternativ können Sie einen Ihrer Wiederherstellungscodes eingeben.
|
||||
</span>
|
||||
</label>
|
||||
<button class="btn btn--primary btn--lg btn--block" type="submit">Bestaetigen</button>
|
||||
<button class="btn btn--primary btn--lg btn--block" type="submit">Bestätigen</button>
|
||||
</form>
|
||||
<p class="center fs-sm"><a href="{{ url_for('auth.logout_get') }}">Abbrechen und zurueck zur Anmeldung</a></p>
|
||||
<p class="center fs-sm"><a href="{{ url_for('auth.logout_get') }}">Abbrechen und zurück zur Anmeldung</a></p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Sitzungen liegen in der Datenbank, nicht im Cookie. Ein widerrufener Eintrag ist sofort
|
||||
ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
|
||||
ungültig -- auch wenn das Cookie noch existiert.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
@@ -17,8 +17,8 @@ ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
|
||||
<tr>
|
||||
<th class="sortable">Benutzer</th>
|
||||
<th class="sortable">Angemeldet</th>
|
||||
<th class="sortable">Letzte Aktivitaet</th>
|
||||
<th class="sortable">Laeuft ab</th>
|
||||
<th class="sortable">Letzte Aktivität</th>
|
||||
<th class="sortable">Läuft ab</th>
|
||||
<th class="sortable">IP</th>
|
||||
<th>Client</th>
|
||||
<th class="actions">Aktion</th>
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
|
||||
{{ alert('Same Sign-On, nicht Single Sign-On: der Benutzer tippt sein Verzeichnispasswort auf der
|
||||
normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprueft und
|
||||
normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprüft und
|
||||
niemals hier gespeichert.', 'info') }}
|
||||
|
||||
{% if values.ldap_security == 'none' %}
|
||||
{{ alert('Die Verbindung ist unverschluesselt. Das Passwort geht dabei im Klartext ueber das
|
||||
Netz -- nur fuer Testumgebungen vertretbar.', 'danger', 'Unverschluesselt') }}
|
||||
{{ alert('Die Verbindung ist unverschlüsselt. Das Passwort geht dabei im Klartext über das
|
||||
Netz -- nur für Testumgebungen vertretbar.', 'danger', 'Unverschlüsselt') }}
|
||||
{% elif not values.ldap_verify_cert %}
|
||||
{{ alert('Das Serverzertifikat wird nicht geprueft. Damit schuetzt die Verschluesselung nur
|
||||
{{ alert('Das Serverzertifikat wird nicht geprüft. Damit schützt die Verschlüsselung nur
|
||||
gegen passives Mitlesen, nicht gegen einen Man-in-the-Middle.', 'warning',
|
||||
'Zertifikat ungeprueft') }}
|
||||
'Zertifikat ungeprüft') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
@@ -48,8 +48,8 @@ niemals hier gespeichert.', 'info') }}
|
||||
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
|
||||
disabled=bool(license_hint), attrs='id="ldap-port"') }}
|
||||
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
|
||||
laesst, bekommt eine Verbindung, die auf gut Glueck scheitert --
|
||||
und die Meldung ("Zeitlimit") sagt nichts ueber die Ursache. Ein
|
||||
lässt, bekommt eine Verbindung, die auf gut Glück scheitert --
|
||||
und die Meldung ("Zeitlimit") sagt nichts über die Ursache. Ein
|
||||
absichtlich abweichender Port bleibt erhalten. #}
|
||||
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
|
||||
selected=values.ldap_security, disabled=bool(license_hint),
|
||||
@@ -68,21 +68,21 @@ niemals hier gespeichert.', 'info') }}
|
||||
disabled=bool(license_hint)) }}
|
||||
</div>
|
||||
<div class="stack stack--tight">
|
||||
{{ checkbox('ldap_verify_cert', 'Serverzertifikat pruefen', checked=values.ldap_verify_cert,
|
||||
{{ checkbox('ldap_verify_cert', 'Serverzertifikat prüfen', checked=values.ldap_verify_cert,
|
||||
disabled=bool(license_hint)) }}
|
||||
{{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis uebernehmen',
|
||||
{{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis übernehmen',
|
||||
checked=values.ldap_group_sync, disabled=bool(license_hint)) }}
|
||||
{{ checkbox('ldap_allow_insecure', 'Unverschluesselte Verbindung ausdruecklich erlauben',
|
||||
{{ checkbox('ldap_allow_insecure', 'Unverschlüsselte Verbindung ausdrücklich erlauben',
|
||||
checked=values.ldap_allow_insecure, disabled=bool(license_hint),
|
||||
hint_text=specs['ldap_allow_insecure'].hint) }}
|
||||
</div>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend>Dienstkonto fuer die Suche (nur Lesezugriff noetig)</legend>
|
||||
<legend>Dienstkonto für die Suche (nur Lesezugriff nötig)</legend>
|
||||
<div class="form-grid">
|
||||
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
|
||||
disabled=not current_user.has('settings_directory.secrets')) }}
|
||||
{{ field('bind_password', 'Passwort (leer = unveraendert)', type='password',
|
||||
{{ field('bind_password', 'Passwort (leer = unverändert)', type='password',
|
||||
autocomplete='new-password',
|
||||
disabled=not current_user.has('settings_directory.secrets')) }}
|
||||
</div>
|
||||
@@ -125,7 +125,7 @@ niemals hier gespeichert.', 'info') }}
|
||||
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Zuordnung entfernen? Betroffene Benutzer verlieren die Gruppe bei der naechsten Anmeldung."
|
||||
data-confirm="Zuordnung entfernen? Betroffene Benutzer verlieren die Gruppe bei der nächsten Anmeldung."
|
||||
data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
|
||||
@@ -5,30 +5,30 @@
|
||||
{% block page_sub %}Backend: {{ backend }} · Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Nach einer Aenderung laeuft ein Zeitgeber. Bestaetigen Sie nicht innerhalb von
|
||||
{{ alert('Nach einer Änderung läuft ein Zeitgeber. Bestätigen Sie nicht innerhalb von
|
||||
' ~ revert_seconds ~ ' Sekunden von der neuen Adresse aus, wird automatisch
|
||||
zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
'Automatischer Rueckfall') }}
|
||||
zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
'Automatischer Rückfall') }}
|
||||
|
||||
{% if pending %}
|
||||
<section class="card card--danger">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('alert', size=17) }} Aenderung wartet auf Bestaetigung</div>
|
||||
<div class="card__title">{{ icon('alert', size=17) }} Änderung wartet auf Bestätigung</div>
|
||||
<div class="card__sub">Interface {{ pending.interface }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary" type="submit">Verbindung bestaetigen</button>
|
||||
<button class="btn btn--primary" type="submit">Verbindung bestätigen</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfuegbar -- die Anzeige ist rein lesend.', 'info') }}
|
||||
{{ alert('Systemhelfer nicht verfügbar -- die Anzeige ist rein lesend.', 'info') }}
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
@@ -54,7 +54,7 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurueck.', 'network') }}
|
||||
{{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurück.', 'network') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -62,11 +62,11 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Aendern</div>
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Ändern</div>
|
||||
<div class="card__sub">
|
||||
Es wird nur der Block des gewaehlten Interfaces geschrieben (Datei
|
||||
Es wird nur der Block des gewählten Interfaces geschrieben (Datei
|
||||
<span class="mono">{{ netplan_path }}</span>) – andere Interfaces bleiben
|
||||
unberuehrt.
|
||||
unberührt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,18 +88,18 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
</select>
|
||||
</label>
|
||||
{{ field('address', 'IP-Adresse', placeholder='192.168.1.10') }}
|
||||
{{ field('prefix', 'Praefixlaenge', value='24', type='number') }}
|
||||
{{ field('prefix', 'Präfixlänge', value='24', type='number') }}
|
||||
{{ field('gateway', 'Gateway', placeholder='192.168.1.1') }}
|
||||
{{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
|
||||
</div>
|
||||
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst waere der Host nach der
|
||||
Aenderung nicht mehr erreichbar. Das wird vor dem Schreiben geprueft.', 'info') }}
|
||||
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst wäre der Host nach der
|
||||
Änderung nicht mehr erreichbar. Das wird vor dem Schreiben geprüft.', 'info') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--danger" type="submit"
|
||||
data-confirm="Netzwerkeinstellungen jetzt uebernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestaetigung wird nach {{ revert_seconds }} Sekunden zurueckgerollt."
|
||||
data-confirm-title="Netzwerk aendern" data-confirm-ok="Uebernehmen">
|
||||
Uebernehmen
|
||||
data-confirm="Netzwerkeinstellungen jetzt übernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestätigung wird nach {{ revert_seconds }} Sekunden zurückgerollt."
|
||||
data-confirm-title="Netzwerk ändern" data-confirm-ok="Übernehmen">
|
||||
Übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
{% block content %}
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfuegbar -- die Konfiguration kann hier erzeugt und angesehen,
|
||||
{{ alert('Systemhelfer nicht verfügbar -- die Konfiguration kann hier erzeugt und angesehen,
|
||||
aber nicht angewandt werden.', 'info') }}
|
||||
{% endif %}
|
||||
|
||||
{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben.
|
||||
Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der
|
||||
Vorgaenger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
|
||||
blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
|
||||
{{ alert('Die Site-Datei wird bei jeder Übernahme vollständig neu geschrieben.
|
||||
Alles, was ein Administrator sonst per Hand hineinschreiben würde, ist hier Einstellung. Der
|
||||
Vorgänger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
|
||||
blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
|
||||
'Warum die Datei immer neu geschrieben wird') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
@@ -32,12 +32,12 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
<div class="form-grid">
|
||||
{{ field('web_domain', 'Domain', value=config.domain,
|
||||
placeholder='tesm.firma.de', disabled=bool(license_hint),
|
||||
hint_text='Leer = Zugriff ueber IP-Adresse.') }}
|
||||
{{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email',
|
||||
hint_text='Leer = Zugriff über IP-Adresse.') }}
|
||||
{{ field('web_acme_email', 'E-Mail für Let\'s Encrypt', value=acme_email, type='email',
|
||||
disabled=bool(license_hint)) }}
|
||||
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
|
||||
disabled=bool(license_hint), extra_class='field--full') }}
|
||||
{{ field('web_key_path', 'Privater Schluessel (Pfad)', value=config.key_path,
|
||||
{{ field('web_key_path', 'Privater Schlüssel (Pfad)', value=config.key_path,
|
||||
disabled=bool(license_hint), extra_class='field--full') }}
|
||||
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
|
||||
disabled=bool(license_hint), extra_class='field--full') }}
|
||||
@@ -47,7 +47,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
checked=config.https_enabled, disabled=bool(license_hint)) }}
|
||||
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
|
||||
checked=config.redirect_http, disabled=bool(license_hint),
|
||||
hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst koennte Let\'s
|
||||
hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst könnte Let\'s
|
||||
Encrypt nie erneuern.') }}
|
||||
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
|
||||
checked=config.hsts_enabled, disabled=bool(license_hint),
|
||||
@@ -67,7 +67,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
|
||||
<div class="card__sub">Vorschau der Datei, die geschrieben wuerde</div>
|
||||
<div class="card__sub">Vorschau der Datei, die geschrieben würde</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
@@ -89,8 +89,8 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
{% if certificate.present and not certificate.get('error') %}
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
|
||||
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issuer }}</dd>
|
||||
<dt>Gueltig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
|
||||
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issür }}</dd>
|
||||
<dt>Gültig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
|
||||
<dt>Seriennummer</dt><dd class="mono fs-xs break-all">{{ certificate.serial }}</dd>
|
||||
</dl>
|
||||
{% elif certificate.get('error') %}
|
||||
@@ -107,7 +107,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
<button class="btn btn--sm" type="submit"
|
||||
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
|
||||
data-busy-label="Fordere an ..."
|
||||
data-confirm="Zertifikat fuer {{ config.domain }} per Let's Encrypt anfordern? Die Domain muss oeffentlich auf diesen Host zeigen."
|
||||
data-confirm="Zertifikat für {{ config.domain }} per Let's Encrypt anfordern? Die Domain muss öffentlich auf diesen Host zeigen."
|
||||
data-confirm-title="Zertifikat anfordern" data-confirm-ok="Anfordern"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('lock', size=14) }} Let's-Encrypt-Zertifikat anfordern
|
||||
@@ -117,15 +117,15 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
<p class="fs-xs faint">certbot ist auf diesem Host nicht installiert.</p>
|
||||
{% elif not config.domain %}
|
||||
<p class="fs-xs faint">
|
||||
Fuer Let's Encrypt wird oben eine Domain gebraucht, die oeffentlich auf
|
||||
Für Let's Encrypt wird oben eine Domain gebraucht, die öffentlich auf
|
||||
diesen Host zeigt.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
<p class="muted fs-sm">
|
||||
Kein oeffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
||||
zeigen dafuer eine Warnung, die Verbindung ist aber verschluesselt --
|
||||
Kein öffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
||||
zeigen dafür eine Warnung, die Verbindung ist aber verschlüsselt --
|
||||
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
|
||||
</p>
|
||||
{% if local_names %}
|
||||
@@ -133,7 +133,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
Wird ausgestellt auf:
|
||||
<span class="mono">{{ (config.domain or local_names[0]) }}</span>
|
||||
{% if local_names|length > 1 %}
|
||||
(zusaetzlich {{ local_names[1:] | join(', ') }})
|
||||
(zusätzlich {{ local_names[1:] | join(', ') }})
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -156,7 +156,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Uebernehmen</div>
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Übernehmen</div>
|
||||
<div class="card__sub">Schreiben, <span class="mono">nginx -t</span>, dann neu laden</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,11 +165,11 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary btn--block" type="submit"
|
||||
{% if license_hint or not sysops_available %}disabled{% endif %}
|
||||
data-busy-label="Uebernehme ..."
|
||||
data-confirm="Konfiguration schreiben, pruefen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
||||
data-confirm-title="nginx uebernehmen" data-confirm-ok="Uebernehmen"
|
||||
data-busy-label="Übernehme ..."
|
||||
data-confirm="Konfiguration schreiben, prüfen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
||||
data-confirm-title="nginx übernehmen" data-confirm-ok="Übernehmen"
|
||||
data-confirm-style="primary">
|
||||
Konfiguration uebernehmen
|
||||
Konfiguration übernehmen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{# ==========================================================================
|
||||
Grundgeruest fuer Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor,
|
||||
Grundgerüst für Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor,
|
||||
Kundenportal, Self-Service.
|
||||
|
||||
Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar
|
||||
serverseitig** gerendert, nicht ueber die JSON-Bruecke. Der Vorgaenger hatte
|
||||
serverseitig** gerendert, nicht über die JSON-Brücke. Der Vorgänger hatte
|
||||
dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript
|
||||
nutzbar bleiben muessen (und weil Tests hier direkt auf Text pruefen).
|
||||
nutzbar bleiben müssen (und weil Tests hier direkt auf Text prüfen).
|
||||
========================================================================== #}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
@@ -23,11 +23,11 @@
|
||||
<body data-csrf="{{ csrf_token() }}">
|
||||
<div class="auth-page">
|
||||
{% if auth_tagline %}
|
||||
{# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund --
|
||||
uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt
|
||||
zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und
|
||||
beim Umschalten von Hand waere die versteckte Variante die falsche.
|
||||
``aria-hidden``, weil der Name schon in der Ueberschrift steht. #}
|
||||
{# Wortmarke mit Funktionszeile als tatsächlicher Seitenhintergrund --
|
||||
übernommen vom Vorgänger. Bewusst inline und mit Themen-Tokens statt
|
||||
zweier Dateien für hell und dunkel: bei der Einstellung "System" und
|
||||
beim Umschalten von Hand wäre die versteckte Variante die falsche.
|
||||
``aria-hidden``, weil der Name schon in der Überschrift steht. #}
|
||||
<div class="auth-backdrop" aria-hidden="true">
|
||||
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(150,15)">
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<p class="auth-footnote">{{ app_name }} {{ app_version }} · © {{ current_year }} WiS GmbH</p>
|
||||
<p class="auth-footnote">{{ app_name }} {{ app_version }} · © {{ current_year }} {{ copyright_notice }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
{% from "tesm_core/macros.html" import empty_state, alert, pill %}
|
||||
|
||||
{% block page_title %}Papierkorb{% endblock %}
|
||||
{% block page_sub %}{{ total }} Eintraege · Aufbewahrung {{ retention_days }} Tage{% endblock %}
|
||||
{% block page_sub %}{{ total }} Einträge · Aufbewahrung {{ retention_days }} Tage{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine
|
||||
gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.',
|
||||
{{ alert('Ansehen ist immer möglich. Wiederherstellen und endgültiges Löschen erfordern eine
|
||||
gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Seite entfernt.',
|
||||
'info') }}
|
||||
|
||||
{% for section in sections %}
|
||||
@@ -23,8 +23,8 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="kind" value="{{ section.key }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Alle {{ section.entries | length }} Eintraege unter {{ section.label }} endgueltig loeschen? Das laesst sich nicht rueckgaengig machen."
|
||||
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Endgueltig loeschen">
|
||||
data-confirm="Alle {{ section.entries | length }} Einträge unter {{ section.label }} endgültig löschen? Das lässt sich nicht rückgängig machen."
|
||||
data-confirm-title="Endgültig löschen" data-confirm-ok="Endgültig löschen">
|
||||
Bereich leeren
|
||||
</button>
|
||||
</form>
|
||||
@@ -33,7 +33,7 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
||||
{% if section.entries %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Eintrag</th><th>Detail</th><th>Geloescht</th><th class="actions">Aktion</th></tr></thead>
|
||||
<thead><tr><th>Eintrag</th><th>Detail</th><th>Gelöscht</th><th class="actions">Aktion</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in section.entries %}
|
||||
<tr>
|
||||
@@ -53,9 +53,9 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
||||
action="{{ url_for('trash.purge', kind=section.key, ident=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="{{ entry.label }} endgueltig loeschen?"
|
||||
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Loeschen">
|
||||
Endgueltig
|
||||
data-confirm="{{ entry.label }} endgültig löschen?"
|
||||
data-confirm-title="Endgültig löschen" data-confirm-ok="Löschen">
|
||||
Endgültig
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<span class="table-toolbar__spacer"></span>
|
||||
{% if directory_enabled and current_user.has('users.create') %}
|
||||
<button class="btn btn--sm" type="button" data-dialog-open="user-directory">
|
||||
{{ icon('users', size=15) }} Aus Verzeichnis uebernehmen
|
||||
{{ icon('users', size=15) }} Aus Verzeichnis übernehmen
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.is_admin %}
|
||||
@@ -90,9 +90,9 @@
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Konto {{ user.username }} wirklich loeschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgueltig entfernt.{% else %} Es landet im Papierkorb.{% endif %}"
|
||||
data-confirm-title="Konto loeschen" data-confirm-ok="Loeschen"
|
||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
||||
data-confirm="Konto {{ user.username }} wirklich löschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgültig entfernt.{% else %} Es landet im Papierkorb.{% endif %}"
|
||||
data-confirm-title="Konto löschen" data-confirm-ok="Löschen"
|
||||
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -106,8 +106,8 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
||||
unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
||||
{{ alert('Rechte werden ausschliesslich über Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
||||
unabhängig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
||||
Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -137,7 +137,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% if current_user.is_admin %}
|
||||
{{ checkbox('is_admin', 'Administrator (darf alles, unabhaengig von Gruppen)') }}
|
||||
{{ checkbox('is_admin', 'Administrator (darf alles, unabhängig von Gruppen)') }}
|
||||
{% endif %}
|
||||
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
|
||||
</div>
|
||||
@@ -149,7 +149,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
{% endcall %}
|
||||
|
||||
{% if directory_enabled %}
|
||||
{% call modal('user-directory', 'Aus Verzeichnis uebernehmen',
|
||||
{% call modal('user-directory', 'Aus Verzeichnis übernehmen',
|
||||
'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %}
|
||||
<form method="post" action="{{ url_for('admin.directory_adopt') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -169,7 +169,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
</div>
|
||||
</fieldset>
|
||||
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
|
||||
uebernommen und ueberschrieben.', 'info') }}
|
||||
übernommen und überschrieben.', 'info') }}
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
@@ -198,7 +198,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
{{ field('email', 'E-Mail', value=user.email, type='email') }}
|
||||
{{ field('new_password', 'Neues Passwort setzen (optional)', type='password',
|
||||
autocomplete='new-password',
|
||||
hint_text='Setzt das Passwort zurueck, erzwingt einen Wechsel und beendet alle Sitzungen des Kontos.') }}
|
||||
hint_text='Setzt das Passwort zurück, erzwingt einen Wechsel und beendet alle Sitzungen des Kontos.') }}
|
||||
{% endif %}
|
||||
<fieldset class="fieldset">
|
||||
<legend>Gruppen</legend>
|
||||
|
||||
@@ -126,6 +126,37 @@ def format_date(value: Any) -> str:
|
||||
return format_datetime(value, with_time=False)
|
||||
|
||||
|
||||
def format_millis(value: Any) -> str:
|
||||
"""Millisekunden lesbar: ms, dann Sekunden, dann Minuten:Sekunden.
|
||||
|
||||
Vierzehn Stellen Millisekunden liest niemand. "315895 ms" sagt nichts,
|
||||
"5:15" sofort etwas. Die Grenzen liegen dort, wo die naechstgroessere
|
||||
Einheit anfaengt, und die Sekunden bekommen zwei Nachkommastellen -- bei
|
||||
einem Neustart ist der Unterschied zwischen 15,2 s und 15,97 s durchaus
|
||||
von Interesse.
|
||||
"""
|
||||
try:
|
||||
millis = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return "-"
|
||||
if millis < 0:
|
||||
return "-"
|
||||
# Unter 10 ms mit einer Nachkommastelle: Antwortzeiten liegen oft bei
|
||||
# 0,3 ms, und "0 ms" waere schlicht falsch.
|
||||
if millis < 10:
|
||||
return f"{millis:.1f} ms".replace(".", ",")
|
||||
if millis < 1000:
|
||||
return f"{int(round(millis))} ms"
|
||||
# Erst runden, dann die Einheit waehlen: 59999 ms sind gerundet 60,00 s und
|
||||
# gehoeren damit in die Minutenform, nicht in eine Sekundenangabe, die wie
|
||||
# eine Minute aussieht.
|
||||
hundertstel = int(round(millis / 10))
|
||||
if hundertstel < 60 * 100:
|
||||
return f"{hundertstel / 100:.2f} s".replace(".", ",")
|
||||
minuten, rest = divmod(hundertstel, 60 * 100)
|
||||
return f"{minuten}:{rest / 100:05.2f}".replace(".", ",")
|
||||
|
||||
|
||||
def format_duration(seconds: Any) -> str:
|
||||
try:
|
||||
total = int(seconds)
|
||||
@@ -256,6 +287,7 @@ def init_app(app: Flask) -> None:
|
||||
dt=format_datetime,
|
||||
date=format_date,
|
||||
duration=format_duration,
|
||||
millis=format_millis,
|
||||
number=format_number,
|
||||
yes_no=yes_no,
|
||||
from_json=from_json,
|
||||
|
||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing-server"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["tesm-licensing==2.0.0", "cryptography>=41"]
|
||||
dependencies = ["tesm-licensing==2.0.2", "cryptography>=41"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["cryptography>=42"]
|
||||
|
||||
@@ -62,7 +62,7 @@ from .protocol import (
|
||||
verify_master_response,
|
||||
)
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.0.2"
|
||||
|
||||
__all__ = [
|
||||
"ACTIONS",
|
||||
|
||||
Reference in New Issue
Block a user