Live-Log-Fix, Sidebar-Untermenüs für Geräte/Einstellungen, eigenes Konto

- Live-Log-Bug behoben: _latest_log_file() sortierte nach Datei-ctime statt
  nach Dateiname — auf manchen Dateisystemen (u.a. unter WSL2 auf einem
  gemounteten Windows-Laufwerk) unzuverlässig und lieferte nicht immer das
  tatsächlich neueste Logfile. Sortiert jetzt wie der Rest des Codes über
  den chronologisch sortierbaren Dateinamen (rpi-YYYYMMDDHHMMSS.log).
- Navbar umstrukturiert in aufklappbare Gruppen mit Unterpunkten:
  - "Geräte": Clients (bisherige Devices-Seite), Switche, Zugangsdaten
  - "Einstellungen": Benutzer, Gruppen, Systemeinstellungen (Prüfintervall),
    Im-/Export
  - "Logs": Live, Änderungen (vormals Live-Log/Änderungslog)
  Sichtbarkeit gilt jetzt auch pro Unterpunkt: eine Gruppe erscheint nur,
  wenn mindestens ein Unterpunkt für den Benutzer sichtbar ist, und zeigt
  dann auch nur die sichtbaren Unterpunkte.
- Im-/Export als eigene Unterseite mit Export/Import nebeneinander
  (.settings-grid).
- Neue Seite "Mein Konto" (/account, erreichbar über ein Zahnrad-Symbol
  neben dem eigenen Namen in der Sidebar) ersetzt das bisherige Profil-Modal:
  Profil/Passwort/Profilbild als volle Seite, dazu für Admins die
  Navbar-Reihenfolge (aus den Systemeinstellungen hierher verschoben).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 14:44:02 +02:00
co-authored by Claude Sonnet 5
parent a7a23b7c12
commit 37dac64abc
10 changed files with 275 additions and 194 deletions
+35 -24
View File
@@ -44,16 +44,16 @@ Die App ermöglicht:
zuletzt geändert hat. PoE-Neustarts werden bewusst **nicht** geloggt
(siehe [Änderungslog](#änderungslog))
- **Verschlüsselter Import/Export**: Geräte, Switche und Zugangsdaten lassen
sich unter Einstellungen als ein mit selbstgewählter Passphrase
verschlüsseltes Bundle exportieren und auf einer neuen Umgebung wieder
importieren (siehe [Import/Export](#importexport))
- **Eigenes Profil**: jeder Benutzer kann über einen Klick auf seinen Namen
in der Sidebar Vor-/Nachname, eigenes Passwort und ein Profilbild pflegen
(siehe [Eigenes Profil](#eigenes-profil--profilbild))
- **Anpassbare Navigation**: Admins können die Reihenfolge der Sidebar-
Menüpunkte unter Einstellungen per Auf/Ab-Buttons festlegen; jeder Benutzer
sieht davon ohnehin nur, wofür er berechtigt ist (siehe
[Navigation anpassen](#navigation-anpassen))
sich unter Einstellungen → Im-/Export" als ein mit selbstgewählter
Passphrase verschlüsseltes Bundle exportieren und auf einer neuen Umgebung
wieder importieren (siehe [Import/Export](#importexport))
- **Eigenes Konto**: das Zahnrad-Symbol neben dem eigenen Namen unten in der
Sidebar führt zu „Mein Konto“ — Vor-/Nachname, eigenes Passwort und
Profilbild pflegen (siehe [Eigenes Konto](#eigenes-konto--profilbild))
- **Anpassbare Navigation**: Admins legen dort zusätzlich die Reihenfolge der
Sidebar-Hauptpunkte per Auf-/Ab-Buttons fest; jeder Benutzer sieht davon
ohnehin nur, wofür er berechtigt ist — auch pro Unterpunkt innerhalb einer
Gruppe (siehe [Navigation anpassen](#navigation-anpassen))
## Was ist neu gegenüber dem Original?
@@ -185,24 +185,35 @@ angelegt. Switche referenzieren ihre Zugangsdaten dabei über den Namen (nicht
die interne ID), damit der Import auch bei abweichender ID-Vergabe auf dem
Zielsystem korrekt verknüpft.
## Eigenes Profil & Profilbild
## Eigenes Konto & Profilbild
Ein Klick auf den eigenen Namen unten in der Sidebar öffnet „Mein Profil“:
Vor-/Nachname ändern, ein Profilbild hochladen (PNG/JPG/GIF/WEBP) und das
eigene Passwort ändern (erfordert Eingabe des aktuellen Passworts). Das ist
bewusst getrennt vom Admin-Bereich „Benutzer“, der andere Konten verwaltet.
Ist ein Profilbild hinterlegt, erscheint es statt der Initialen sowohl in
der Sidebar als auch im Änderungslog neben dem Benutzernamen.
Das Zahnrad-Symbol neben dem eigenen Namen unten in der Sidebar führt zu
**„Mein Konto“** (`/account`): Vor-/Nachname ändern, ein Profilbild
hochladen (PNG/JPG/GIF/WEBP) und das eigene Passwort ändern (erfordert
Eingabe des aktuellen Passworts). Das ist bewusst getrennt vom Admin-Bereich
„Benutzer“, der andere Konten verwaltet. Ist ein Profilbild hinterlegt,
erscheint es statt der Initialen sowohl in der Sidebar als auch im
Änderungslog neben dem Benutzernamen. Admins sehen auf derselben Seite
zusätzlich die Navbar-Reihenfolge (siehe unten).
## Navigation anpassen
Die Reihenfolge der Sidebar-Menüpunkte ist unter **Einstellungen** per
Auf-/Ab-Buttons konfigurierbar (gespeichert als JSON-Liste unter
`settings.nav_order`) und gilt global für alle Benutzer. Unabhängig von der
Reihenfolge sieht jeder Benutzer ohnehin nur die Punkte, für die er auch
berechtigt ist — ein Benutzer ohne jede Gruppe sieht z.B. nur „Dashboard“.
„Logs“ ist dabei eine aufklappbare Gruppe mit den Unterpunkten „Live-Log
und „Änderungslog“.
Die Sidebar ist in drei aufklappbare Gruppen strukturiert:
- **Geräte** — Clients (die eigentlichen Devices), Switche, Zugangsdaten
- **Einstellungen** — Benutzer, Gruppen, Systemeinstellungen (Prüfintervall),
Im-/Export
- **Logs** — Live, Änderungen (Änderungslog)
Die Reihenfolge dieser drei Gruppen (und von „Dashboard“) ist für Admins
unter **Mein Konto** (Zahnrad-Symbol in der Sidebar) per Auf-/Ab-Buttons
konfigurierbar (gespeichert als JSON-Liste unter `settings.nav_order`) und
gilt global für alle Benutzer. Unabhängig von der Reihenfolge sieht jeder
Benutzer ohnehin nur, wofür er berechtigt ist — sowohl auf Gruppenebene als
auch pro Unterpunkt: hat ein Benutzer z.B. nur `devices.view`, erscheint die
Gruppe „Geräte“ nur mit dem Unterpunkt „Clients“ (ohne Switche/
Zugangsdaten); eine Gruppe verschwindet komplett, wenn kein Unterpunkt
sichtbar ist.
## Entwicklung unter WSL
+60 -22
View File
@@ -150,36 +150,43 @@ DEFAULT_GROUP_NAME = "Benutzer"
DEFAULT_GROUP_PERMISSIONS = ["devices.view", "switches.view"]
# ---------------------------------------------------------------------------
# Navbar — Reihenfolge ist admin-konfigurierbar (siehe settings(), gespeichert
# als JSON-Liste von Keys unter settings.nav_order); standardmäßig wird pro
# Benutzer nur angezeigt, wofür er auch tatsächlich eine Berechtigung hat.
# "logs" ist eine Gruppe mit Untermenü (Live-Log + Änderungslog).
# Navbar — die Reihenfolge der TOP-LEVEL-Punkte ist admin-konfigurierbar
# (siehe /account, gespeichert als JSON-Liste von Keys unter
# settings.nav_order); standardmäßig wird pro Benutzer nur angezeigt, wofür
# er auch tatsächlich eine Berechtigung hat — das gilt auch pro Unterpunkt
# innerhalb einer Gruppe: eine Gruppe erscheint nur, wenn mindestens ein
# Unterpunkt sichtbar ist, und zeigt dann auch nur die sichtbaren Unterpunkte.
# ---------------------------------------------------------------------------
NAV_ITEMS = [
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
{"key": "devices", "label": "Geräte", "icon": "cpu", "endpoint": "devices"},
{"key": "devices_group", "label": "Geräte", "icon": "cpu", "children": [
{"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"},
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
]},
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
{"key": "logs", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live-Log", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_activity", "label": "Änderungslog", "icon": "history", "endpoint": "activity_log"},
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
{"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
]},
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"},
]},
{"key": "settings", "label": "Einstellungen", "icon": "sliders", "endpoint": "settings"},
]
DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS]
NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS}
def _nav_item_visible(key, user):
def _nav_key_visible(key, user):
if key == "index":
return True
if key == "devices":
return user.can_view_devices
if key in ("switches", "credentials"):
return user.can_manage_switches
if key in ("users", "groups", "logs", "settings"):
if key in ("users", "groups", "settings_system", "settings_importexport", "logs_live", "logs_activity"):
return user.is_admin
return False
@@ -194,7 +201,16 @@ def inject_nav():
stored_order = []
ordered_keys = [k for k in stored_order if k in NAV_ITEMS_BY_KEY]
ordered_keys += [k for k in DEFAULT_NAV_ORDER if k not in ordered_keys]
nav_items_ordered = [NAV_ITEMS_BY_KEY[k] for k in ordered_keys if _nav_item_visible(k, current_user)]
nav_items_ordered = []
for k in ordered_keys:
item = NAV_ITEMS_BY_KEY[k]
if item.get("children"):
visible_children = [c for c in item["children"] if _nav_key_visible(c["key"], current_user)]
if visible_children:
nav_items_ordered.append({**item, "children": visible_children})
elif _nav_key_visible(k, current_user):
nav_items_ordered.append(item)
return {"nav_items_ordered": nav_items_ordered}
@@ -507,12 +523,19 @@ def logout():
# ---------------------------------------------------------------------------
# Eigenes Profil — jeder eingeloggte Benutzer darf Vor-/Nachname, eigenes
# Passwort und Profilbild selbst ändern (Klick auf den eigenen Namen in der
# Sidebar). Bewusst getrennt vom Admin-"Users"-Bereich, der andere Benutzer
# verwaltet.
# Eigenes Profil / Konto — jeder eingeloggte Benutzer darf Vor-/Nachname,
# eigenes Passwort und Profilbild selbst ändern (Zahnrad-Symbol neben dem
# eigenen Namen unten in der Sidebar). Bewusst getrennt vom Admin-"Users"-
# Bereich, der andere Benutzer verwaltet. Admins konfigurieren hier
# zusätzlich die Navbar-Reihenfolge (siehe save_nav_order()).
# ---------------------------------------------------------------------------
@app.route("/account")
@login_required
def account():
return render_template("account.html")
@app.route("/profile", methods=["POST"])
@login_required
def profile():
@@ -576,10 +599,16 @@ def profile():
# ---------------------------------------------------------------------------
def _latest_log_file():
"""Neuestes Logfile ermitteln. Bewusst über den Dateinamen sortiert
(rpi-YYYYMMDDHHMMSS.log ist lexikografisch = chronologisch sortierbar),
NICHT über Dateisystem-Metadaten wie ctime/mtime — die sind je nach
Dateisystem (z.B. unter WSL2 auf einem gemounteten Windows-Laufwerk)
unzuverlässig und haben in der Praxis nicht immer das tatsächlich
zuletzt geschriebene Logfile geliefert."""
log_files = glob.glob(LOG_GLOB)
if not log_files:
return None
return max(log_files, key=os.path.getctime)
return max(log_files)
def get_last_seen(dev_name: str):
@@ -758,6 +787,15 @@ def settings():
return render_template("settings.html", interval=interval)
@app.route("/settings/import-export")
@login_required
def settings_import_export():
if not current_user.is_admin:
flash("Nur Admins dürfen Daten importieren/exportieren!", "danger")
return redirect(url_for("index"))
return render_template("settings_import_export.html")
@app.route("/settings/nav-order", methods=["POST"])
@login_required
def save_nav_order():
@@ -770,7 +808,7 @@ def save_nav_order():
set_setting("nav_order", json.dumps(order))
log_action("settings.update", "Navbar-Reihenfolge")
flash("Navbar-Reihenfolge gespeichert.", "success")
return redirect(url_for("settings"))
return redirect(url_for("account"))
# ---------------------------------------------------------------------------
@@ -791,7 +829,7 @@ def export_data():
passphrase = request.form.get("export_passphrase", "")
if not passphrase:
flash("Bitte eine Passphrase für den Export angeben.", "danger")
return redirect(url_for("settings"))
return redirect(url_for("settings_import_export"))
conn = get_db_connection()
credentials_rows = conn.execute("SELECT name, username, password FROM credentials").fetchall()
@@ -850,7 +888,7 @@ def import_data():
upload = request.files.get("import_file")
if not passphrase or not upload or not upload.filename:
flash("Bitte Passphrase und Export-Datei angeben.", "danger")
return redirect(url_for("settings"))
return redirect(url_for("settings_import_export"))
try:
envelope = json.loads(upload.read().decode("utf-8"))
@@ -859,7 +897,7 @@ def import_data():
payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8"))
except Exception:
flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger")
return redirect(url_for("settings"))
return redirect(url_for("settings_import_export"))
conn = get_db_connection()
n_cred = n_switch = n_dev = 0
@@ -898,7 +936,7 @@ def import_data():
log_action("data.import", "Devices/Switches/Zugangsdaten", f"{n_dev} Geräte, {n_switch} Switche, {n_cred} Zugangsdaten")
flash(f"Import abgeschlossen: {n_cred} Zugangsdaten, {n_switch} Switche, {n_dev} Geräte.", "success")
return redirect(url_for("settings"))
return redirect(url_for("settings_import_export"))
# ---------------------------------------------------------------------------
+11 -1
View File
@@ -200,6 +200,14 @@ button { font-family: inherit; }
.nav-group.expanded .nav-group-children { display: flex; }
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
/* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite) */
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 24px;
align-items: start;
}
/* Navbar-Reihenfolge (Settings) */
.nav-order-list {
list-style: none;
@@ -247,7 +255,9 @@ button { font-family: inherit; }
flex-shrink: 0;
}
.user-meta { overflow: hidden; }
.user-meta { flex: 1; min-width: 0; overflow: hidden; }
.user-chip .icon-btn { flex-shrink: 0; width: 30px; height: 30px; }
.user-chip .icon-btn svg { width: 15px; height: 15px; }
.user-meta .u-name { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-meta .u-role { font-size: 11px; color: var(--text-faint); }
+99
View File
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block page_title %}Mein Konto{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ current_user.username }}</div>{% endblock %}
{% block content %}
{% if current_user.is_admin %}
<div class="card card-pad" style="margin-bottom:24px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
<div class="hint">Bestimmt, in welcher Reihenfolge die Menüpunkte in der Sidebar erscheinen. Jeder Benutzer sieht davon nur, wofür er auch berechtigt ist.</div>
</div>
</div>
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
<ul class="nav-order-list" id="navOrderList">
{% for item in nav_items_ordered %}
<li data-key="{{ item.key }}">
<span>{{ item.label }}</span>
<div class="nav-order-actions">
<button type="button" class="icon-btn" title="Nach oben" onclick="moveNavItem(this,-1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</button>
<button type="button" class="icon-btn" title="Nach unten" onclick="moveNavItem(this,1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</button>
</div>
<input type="hidden" name="nav_order" value="{{ item.key }}">
</li>
{% endfor %}
</ul>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Reihenfolge speichern
</button>
</form>
</div>
{% endif %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Profil</h2>
<div class="hint">Name und Profilbild ändern sich in der Sidebar und im Änderungslog.</div>
</div>
</div>
<div style="display:flex; align-items:center; gap:14px; margin-bottom:20px;">
{% if current_user.avatar_url %}
<img src="{{ current_user.avatar_url }}" alt="" style="width:56px; height:56px; border-radius:50%; object-fit:cover;">
{% else %}
<div class="user-avatar" style="width:56px; height:56px; font-size:18px;">{{ current_user.username[:2]|upper }}</div>
{% endif %}
<form method="post" action="{{ url_for('profile') }}" enctype="multipart/form-data" id="avatarForm" style="flex:1;">
<input type="file" name="avatar" id="avatarInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none;" onchange="document.getElementById('avatarForm').requestSubmit();">
<button type="button" class="btn btn-secondary" style="width:100%;" onclick="document.getElementById('avatarInput').click();">Profilbild ändern</button>
<input type="hidden" name="upload_avatar" value="1">
</form>
</div>
<form method="post" action="{{ url_for('profile') }}" id="profileForm">
<div class="field"><label>Vorname</label><input type="text" name="first_name" value="{{ current_user.first_name or '' }}"></div>
<div class="field"><label>Name</label><input type="text" name="last_name" value="{{ current_user.last_name or '' }}"></div>
<div class="field"><label>Username</label><input type="text" value="{{ current_user.username }}" disabled></div>
<button type="submit" name="update_profile" value="1" class="btn btn-primary btn-block">Speichern</button>
</form>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Passwort ändern</h2>
<div class="hint">Erfordert Eingabe des aktuellen Passworts.</div>
</div>
</div>
<form method="post" action="{{ url_for('profile') }}" id="passwordForm">
<div class="field"><label>Aktuelles Passwort</label><input type="password" name="current_password" autocomplete="current-password"></div>
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" autocomplete="new-password"></div>
<div class="field"><label>Neues Passwort bestätigen</label><input type="password" name="confirm_password" autocomplete="new-password"></div>
<button type="submit" name="change_password" value="1" class="btn btn-secondary btn-block">Passwort ändern</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function moveNavItem(btn, dir) {
const li = btn.closest("li");
const target = dir === -1 ? li.previousElementSibling : li.nextElementSibling;
if (!target) return;
if (dir === -1) li.parentNode.insertBefore(li, target);
else li.parentNode.insertBefore(target, li);
}
</script>
{% endblock %}
+1 -1
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Änderungslog{% endblock %}
{% block page_title %}Änderungen{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
{% block content %}
+7 -51
View File
@@ -21,6 +21,8 @@
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
} %}
<div class="app-shell">
@@ -65,7 +67,7 @@
</nav>
<div class="sidebar-footer">
<button type="button" class="user-chip" data-open-modal="profileModal" onclick="resetProfileForms();" style="cursor:pointer; text-align:left; background:none; border:none; width:100%;">
<div class="user-chip">
{% if current_user.avatar_url %}
<img class="user-avatar" src="{{ current_user.avatar_url }}" alt="" style="object-fit:cover;">
{% else %}
@@ -75,7 +77,10 @@
<div class="u-name">{{ current_user.display_name }}</div>
<div class="u-role">{{ "Administrator" if current_user.is_admin else current_user.group_names or "Benutzer" }}</div>
</div>
</button>
<a href="{{ url_for('account') }}" class="icon-btn" title="Einstellungen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['gear']|safe }}</svg>
</a>
</div>
<div class="footer-actions">
<button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln">
<span data-theme-icon></span>
@@ -86,49 +91,6 @@
</div>
</div>
</aside>
<!-- Modal: Mein Profil (Klick auf eigenen Namen in der Sidebar) -->
<div class="modal-overlay" id="profileModal">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h3>Mein Profil</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div style="display:flex; align-items:center; gap:14px; margin-bottom:20px;">
{% if current_user.avatar_url %}
<img src="{{ current_user.avatar_url }}" alt="" style="width:56px; height:56px; border-radius:50%; object-fit:cover;">
{% else %}
<div class="user-avatar" style="width:56px; height:56px; font-size:18px;">{{ current_user.username[:2]|upper }}</div>
{% endif %}
<form method="post" action="{{ url_for('profile') }}" enctype="multipart/form-data" id="avatarForm" style="flex:1;">
<input type="file" name="avatar" id="avatarInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none;" onchange="document.getElementById('avatarForm').requestSubmit();">
<button type="button" class="btn btn-secondary" style="width:100%;" onclick="document.getElementById('avatarInput').click();">Profilbild ändern</button>
<input type="hidden" name="upload_avatar" value="1">
</form>
</div>
<form method="post" action="{{ url_for('profile') }}" id="profileForm">
<div class="field"><label>Vorname</label><input type="text" name="first_name" value="{{ current_user.first_name or '' }}"></div>
<div class="field"><label>Name</label><input type="text" name="last_name" value="{{ current_user.last_name or '' }}"></div>
<div class="field"><label>Username</label><input type="text" value="{{ current_user.username }}" disabled></div>
<button type="submit" name="update_profile" value="1" class="btn btn-primary btn-block">Speichern</button>
</form>
<div style="border-top:1px solid var(--border); margin:20px 0;"></div>
<form method="post" action="{{ url_for('profile') }}" id="passwordForm">
<h4 style="font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:var(--text-dim); margin-bottom:12px;">Passwort ändern</h4>
<div class="field"><label>Aktuelles Passwort</label><input type="password" name="current_password" autocomplete="current-password"></div>
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" autocomplete="new-password"></div>
<div class="field"><label>Neues Passwort bestätigen</label><input type="password" name="confirm_password" autocomplete="new-password"></div>
<button type="submit" name="change_password" value="1" class="btn btn-secondary btn-block">Passwort ändern</button>
</form>
</div>
</div>
</div>
{% endif %}
<div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}">
@@ -171,12 +133,6 @@
{% endwith %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
function resetProfileForms() {
const pwForm = document.getElementById("passwordForm");
if (pwForm) pwForm.reset();
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+1 -1
View File
@@ -6,7 +6,7 @@
{% set can_delete = current_user.has_permission('devices.delete') %}
{% set show_actions_col = can_edit or can_delete %}
{% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
{% block page_title %}Geräte{% endblock %}
{% block page_title %}Clients{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
{% block topbar_right %}
{% if can_create %}
+1 -1
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Live-Log{% endblock %}
{% block page_title %}Live{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ log_name or "kein Logfile" }}</div>{% endblock %}
{% block topbar_right %}
<span class="timer-pill"><span class="dot"></span>Update in <span id="timer">--</span>s</span>
+2 -89
View File
@@ -1,41 +1,10 @@
{% extends "base.html" %}
{% set active_page = "settings" %}
{% block page_title %}Einstellungen{% endblock %}
{% set active_page = "settings_system" %}
{% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}<div class="topbar-sub">Prüfintervall für das Monitoring</div>{% endblock %}
{% block content %}
<div class="card card-pad" style="max-width:420px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
<div class="hint">Bestimmt, in welcher Reihenfolge die Menüpunkte in der Sidebar erscheinen. Jeder Benutzer sieht davon nur, wofür er auch berechtigt ist.</div>
</div>
</div>
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
<ul class="nav-order-list" id="navOrderList">
{% for item in nav_items_ordered %}
<li data-key="{{ item.key }}">
<span>{{ item.label }}</span>
<div class="nav-order-actions">
<button type="button" class="icon-btn" title="Nach oben" onclick="moveNavItem(this,-1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</button>
<button type="button" class="icon-btn" title="Nach unten" onclick="moveNavItem(this,1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</button>
</div>
<input type="hidden" name="nav_order" value="{{ item.key }}">
</li>
{% endfor %}
</ul>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Reihenfolge speichern
</button>
</form>
</div>
<div class="card card-pad" style="max-width:420px; margin-top:24px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Prüfintervall</h2>
@@ -54,60 +23,4 @@
</button>
</form>
</div>
<div class="card card-pad" style="max-width:420px; margin-top:24px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Export</h2>
<div class="hint">Geräte, Switche und Zugangsdaten als verschlüsseltes Bundle sichern — z.B. für einen Umzug auf eine neue Umgebung.</div>
</div>
</div>
<form method="post" action="{{ url_for('export_data') }}">
<div class="field">
<label for="export_passphrase">Passphrase</label>
<input type="password" name="export_passphrase" id="export_passphrase" required>
<div class="field-hint">Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.</div>
</div>
<button type="submit" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Export herunterladen
</button>
</form>
</div>
<div class="card card-pad" style="max-width:420px; margin-top:24px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Import</h2>
<div class="hint">Ein zuvor exportiertes Bundle einlesen. Bestehende Einträge mit gleichem Namen/Hostname/MAC werden aktualisiert, neue werden angelegt.</div>
</div>
</div>
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data"
data-confirm="Import wirklich starten? Bestehende Einträge mit gleichem Namen/Hostname/MAC werden überschrieben.">
<div class="field">
<label for="import_file">Export-Datei</label>
<input type="file" name="import_file" id="import_file" accept=".json" required>
</div>
<div class="field">
<label for="import_passphrase">Passphrase</label>
<input type="password" name="import_passphrase" id="import_passphrase" required>
</div>
<button type="submit" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 9V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4"/><path d="M7 14l5-5 5 5"/><path d="M12 9v12"/></svg>
Import starten
</button>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
function moveNavItem(btn, dir) {
const li = btn.closest("li");
const target = dir === -1 ? li.previousElementSibling : li.nextElementSibling;
if (!target) return;
if (dir === -1) li.parentNode.insertBefore(li, target);
else li.parentNode.insertBefore(target, li);
}
</script>
{% endblock %}
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% set active_page = "settings_importexport" %}
{% block page_title %}Im-/Export{% endblock %}
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Export</h2>
<div class="hint">Geräte, Switche und Zugangsdaten als verschlüsseltes Bundle sichern — z.B. für einen Umzug auf eine neue Umgebung.</div>
</div>
</div>
<form method="post" action="{{ url_for('export_data') }}">
<div class="field">
<label for="export_passphrase">Passphrase</label>
<input type="password" name="export_passphrase" id="export_passphrase" required>
<div class="field-hint">Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.</div>
</div>
<button type="submit" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Export herunterladen
</button>
</form>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Import</h2>
<div class="hint">Ein zuvor exportiertes Bundle einlesen. Bestehende Einträge mit gleichem Namen/Hostname/MAC werden aktualisiert, neue werden angelegt.</div>
</div>
</div>
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data"
data-confirm="Import wirklich starten? Bestehende Einträge mit gleichem Namen/Hostname/MAC werden überschrieben.">
<div class="field">
<label for="import_file">Export-Datei</label>
<input type="file" name="import_file" id="import_file" accept=".json" required>
</div>
<div class="field">
<label for="import_passphrase">Passphrase</label>
<input type="password" name="import_passphrase" id="import_passphrase" required>
</div>
<button type="submit" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 9V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4"/><path d="M7 14l5-5 5 5"/><path d="M12 9v12"/></svg>
Import starten
</button>
</form>
</div>
</div>
{% endblock %}