diff --git a/README.md b/README.md index 7d1c640..caba770 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index cd8e5a7..095d765 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -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": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"}, - {"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"}, - {"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": "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": "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")) # --------------------------------------------------------------------------- diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index 427c961..6e4b93c 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -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); } diff --git a/srv/poe_manager/templates/account.html b/srv/poe_manager/templates/account.html new file mode 100644 index 0000000..767ae4d --- /dev/null +++ b/srv/poe_manager/templates/account.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block page_title %}Mein Konto{% endblock %} +{% block page_sub %}
{% endblock %} + +{% block content %} + +{% if current_user.is_admin %} +