From fa6bb2935fa62c49bcb9e58a7875322154ac31ee Mon Sep 17 00:00:00 2001 From: alientim Date: Mon, 10 Aug 2026 13:30:47 +0200 Subject: [PATCH] Zugangsdaten-Entitaet, Gruppen-Tabelle mit Aufklappansicht, Vor-/Nachname, Dashboard-Sektionen - Zugangsdaten (SSH-Logins) als eigenstaendige, wiederverwendbare Entitaet statt direkt am Switch; inline Neuanlage beim Switch-Erstellen moeglich; automatische Migration bestehender Switch-Logins - Gruppen-Seite als Tabelle mit Aufklapp-Zeile fuer Rechte (Akkordeon), Admin als feste Systemzeile (Mitgliederverwaltung ueber is_admin), Standardgruppe 'Benutzer' mit allen Ansichtsrechten (devices.view, switches.view), automatische Zuordnung neuer/verwaister Benutzer - Users-Seite: Icon-Buttons statt Text, Bearbeiten+Passwortaenderung in einem Modal zusammengefuehrt, Gruppe/Admin-Zuweisung ueber eigenen Zuweisen-Button (wie Switch-Zuordnung bei Devices), Vor-/Nachname - Dashboard: einheitliche Kachelansicht mit/ohne Login, drei sortierte Abschnitte (Offline/Online/Deaktiviert), Kachel-Suchfilter, Bootstrap- artiges Grid (max. 6 Spalten), Aktivieren-Option im Popup fuer deaktivierte Geraete, Countdown serverseitig korrekt geseedet - Sidebar dauerhaft einklappbar (Desktop, persistent via localStorage) - Devices-Tabelle: Aktions-Buttons nebeneinander statt untereinander Co-Authored-By: Claude Sonnet 5 --- README.md | 113 ++++-- srv/poe_manager/app.py | 426 ++++++++++++++++----- srv/poe_manager/create_db.py | 22 +- srv/poe_manager/generate_ips.py | 11 +- srv/poe_manager/static/css/style.css | 42 +- srv/poe_manager/static/js/app.js | 21 +- srv/poe_manager/templates/base.html | 5 + srv/poe_manager/templates/credentials.html | 157 ++++++++ srv/poe_manager/templates/groups.html | 278 +++++++++----- srv/poe_manager/templates/index.html | 144 +++++-- srv/poe_manager/templates/switches.html | 133 +++++-- srv/poe_manager/templates/users.html | 101 +++-- 12 files changed, 1108 insertions(+), 345 deletions(-) create mode 100644 srv/poe_manager/templates/credentials.html diff --git a/README.md b/README.md index c42db8f..f657ff3 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,33 @@ Linux-VM). Entwickelt wird unter WSL2 (Ubuntu) unter Windows — siehe Die App ermöglicht: -- **Öffentliches Kurz-Dashboard ohne Login** (Hostname, IP, Status, letzte - Prüfung) — für den schnellen Blick auf dem Flur-Monitor; volle Verwaltung - und PoE-Neustart bleiben hinter dem Login -- Verwaltung von Devices und Switches (inkl. verschlüsselter SSH-Zugangsdaten) +- **Öffentliches Kachel-Dashboard ohne Login** — zeigt nur aktive (nicht + deaktivierte) Geräte, nur Online/Offline/Gesamt-Statistik; volle Verwaltung, + Aktivieren/Deaktivieren und PoE-Neustart bleiben hinter dem Login. Eingeloggt + sind zusätzlich deaktivierte Geräte sichtbar (eigene Kachel-Sektion). Alle + Kacheln sind durchsuchbar und in drei Abschnitten sortiert: Offline, Online, + Deaktiviert — jeweils alphabetisch +- Verwaltung von Devices und Switches +- **Zugangsdaten als eigene, wiederverwendbare Entität**: SSH-Logins werden + einmal angelegt und können mehreren Switchen zugeordnet werden; beim + Anlegen eines Switches lassen sich bestehende Zugangsdaten auswählen oder + direkt neue hinterlegen, ohne vorher zu „Zugangsdaten“ wechseln zu müssen - **Browser-SSH-Terminal beim Anlegen/Bearbeiten eines Switches**, um den Host-Key zu bestätigen und die Zugangsdaten direkt zu prüfen, bevor der Switch gespeichert wird — funktioniert auf jedem Betriebssystem, die SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut -- Manueller PoE-Neustart einzelner Geräte per Klick -- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) +- Manueller PoE-Neustart einzelner Geräte per Klick; bei deaktivierten + Geräten bietet das Detail-Popup stattdessen direkt eine Aktivieren-Option +- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) — + der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten + Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet - Live-Log-Ansicht mit farblicher Hervorhebung (online/offline) -- **Rechtesystem mit Gruppen**: Admins dürfen immer alles; normalen - Benutzern lassen sich über Gruppen einzelne Verwaltungsrechte gezielt - zuweisen (siehe [Rechtesystem](#rechtesystem-gruppen--berechtigungen)) +- **Rechtesystem mit Gruppen**: Admins dürfen immer alles (als Gruppe „Admin“ + überall mit aufgeführt); normalen Benutzern lassen sich über Gruppen + einzelne Verwaltungsrechte gezielt zuweisen, inkl. reiner Ansichtsrechte + (siehe [Rechtesystem](#rechtesystem-gruppen--berechtigungen)) +- Einklappbare Sidebar (Desktop: dauerhaft, über Neuladen hinweg gemerkt; + mobil: temporäres Überlagern) ## Was ist neu gegenüber dem Original? @@ -61,39 +74,67 @@ Die App ermöglicht: ## Rechtesystem (Gruppen & Berechtigungen) -Admins (`is_admin=1`) dürfen wie bisher alles, inklusive Benutzer-/Gruppen-/ +Admins dürfen wie bisher alles, inklusive Benutzer-/Gruppen-/ Settingsverwaltung — das bleibt exklusiv Admins vorbehalten. Zusätzlich gibt -es jetzt **Gruppen**: eine Gruppe bündelt einzelne Verwaltungsrechte, die -dann normalen Benutzern zugewiesen werden können, ohne sie zu Admins zu -machen. Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren -sich (Vereinigung, nicht Schnittmenge). +es **Gruppen**: eine Gruppe bündelt einzelne Verwaltungsrechte, die dann +normalen Benutzern zugewiesen werden können, ohne sie zu Admins zu machen. +Ein Benutzer kann nur einer Gruppe/Rolle gleichzeitig zugeordnet sein +(über den Button „Gruppe zuweisen“, analog zur Switch-Zuordnung bei +Devices) — technisch sind mehrere Gruppen pro Benutzer möglich (Rechte +würden sich addieren), die UI bildet aber bewusst nur eine 1:1-Zuordnung ab. + +Auf der **Gruppen**-Seite (nur für Admins) werden zur Übersicht immer auch +die beiden Systemrollen mit aufgeführt: +- **Admin** — eine feste, nicht editierbare Zeile mit allen Rechten; + Mitgliedschaft wird direkt hier verwaltet (Button „Mitglieder verwalten“), + intern über den `is_admin`-Schalter der Benutzer. Mindestens ein Admin + muss immer bestehen bleiben (serverseitig erzwungen). +- **Benutzer** — die **Standardgruppe**, mit der alle Ansichtsrechte + (`devices.view`, `switches.view`) vorbelegt sind. Jeder neu angelegte, + nicht-admin Benutzer wird ihr automatisch zugeordnet; sie kann nicht + gelöscht werden. + +Jede Gruppe lässt sich über „Rechte anzeigen/bearbeiten“ aufklappen (wie ein +Akkordeon) und zeigt dort die volle Checkbox-Liste; Mitglieder werden über +einen eigenen Button/Modal verwaltet (nur die Anzahl steht in der Tabelle). Verfügbare Rechte: -| Bereich | Recht | Bedeutung | -|----------|---------------------|---------------------------------------------| -| Devices | `devices.toggle` | Geräte aktivieren/deaktivieren | -| Devices | `devices.create` | Geräte anlegen | -| Devices | `devices.edit` | Geräte bearbeiten (inkl. Switch-Zuordnung) | -| Devices | `devices.delete` | Geräte löschen | -| Devices | `devices.restart` | PoE-Neustart über das Dashboard | -| Switches | `switches.create` | Switche anlegen | -| Switches | `switches.edit` | Switche bearbeiten | -| Switches | `switches.delete` | Switche löschen | +| Bereich | Recht | Bedeutung | +|----------|---------------------|-----------------------------------------------| +| Devices | `devices.view` | Devices-Seite ansehen | +| Devices | `devices.toggle` | Geräte aktivieren/deaktivieren | +| Devices | `devices.create` | Geräte anlegen | +| Devices | `devices.edit` | Geräte bearbeiten (inkl. Switch-Zuordnung) | +| Devices | `devices.delete` | Geräte löschen | +| Devices | `devices.restart` | PoE-Neustart/Aktivieren über das Dashboard | +| Switches | `switches.view` | Switches- und Zugangsdaten-Seite ansehen | +| Switches | `switches.create` | Switche und Zugangsdaten anlegen | +| Switches | `switches.edit` | Switche und Zugangsdaten bearbeiten | +| Switches | `switches.delete` | Switche und Zugangsdaten löschen | -Verwaltet werden Gruppen unter **Gruppen** (nur für Admins sichtbar/nutzbar): -Name vergeben, Rechte per Checkbox zuweisen, Mitglieder auswählen. Die -Devices-Seite bleibt für alle eingeloggten Benutzer les- und durchsuchbar; -nur die tatsächlichen Aktionen (Buttons, Toggle-Switches, Formulare) sind an -das jeweilige Recht gekoppelt — sowohl im Frontend (ausgeblendet) als auch -im Backend (serverseitig durchgesetzt, unabhängig vom Frontend). Die -Switches-Seite ist komplett unsichtbar/gesperrt für Benutzer ohne mindestens -ein `switches.*`-Recht. +Sowohl das Anzeigen der Devices-/Switches-Seiten als auch jede einzelne +Aktion (Buttons, Toggle-Switches, Formulare) ist an das jeweilige Recht +gekoppelt — im Frontend ausgeblendet **und** im Backend serverseitig +durchgesetzt, unabhängig vom Frontend. -Datenmodell: `groups`, `group_permissions` (Gruppe → Recht), `user_groups` -(Benutzer → Gruppe). Bestehende Datenbanken werden beim App-Start automatisch -um diese Tabellen ergänzt (`_ensure_schema()` in `app.py`), ein manuelles -Migrations-Skript ist nicht nötig. +Datenmodell: `groups` (inkl. `is_default`-Flag), `group_permissions` +(Gruppe → Recht), `user_groups` (Benutzer → Gruppe). Bestehende Datenbanken +werden beim App-Start automatisch migriert (`_ensure_schema()` in `app.py`, +inkl. Nachrüsten der Standardgruppe und Zuordnung bestehender Benutzer ohne +Gruppe) — kein manuelles Migrations-Skript nötig. + +## Zugangsdaten (wiederverwendbare SSH-Logins) + +Switch-Zugangsdaten (Username + verschlüsseltes Passwort) sind eine eigene +Entität (`credentials`-Tabelle) statt direkt am Switch zu hängen — mehrere +Switche mit demselben Login teilen sich einen Eintrag. Beim Anlegen/Bearbeiten +eines Switches lässt sich per Dropdown ein bestehender Eintrag auswählen oder +über „+ Neue Zugangsdaten anlegen“ direkt im selben Formular ein neuer +anlegen, ohne vorher zur eigenen **Zugangsdaten**-Seite wechseln zu müssen. +Löschen ist nur möglich, wenn kein Switch mehr auf den Eintrag verweist. +Bestehende Datenbanken aus einer älteren Version (Username/Passwort direkt +am Switch) werden beim ersten Start automatisch migriert. ## Entwicklung unter WSL diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index d3dafe2..5c226b0 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -203,6 +203,48 @@ def _ensure_schema(): if "is_default" not in existing_cols: conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0") + # Migration: Vor-/Nachname für Benutzer nachrüsten. + user_cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)").fetchall()} + if "first_name" not in user_cols: + conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT") + if "last_name" not in user_cols: + conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT") + + # Zugangsdaten (Credentials): eigenständige, wiederverwendbare + # SSH-Logins für Switche, statt Username/Passwort direkt am Switch. + conn.execute(""" + CREATE TABLE IF NOT EXISTS credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL + ) + """) + switch_cols = {row["name"] for row in conn.execute("PRAGMA table_info(switches)").fetchall()} + if "credential_id" not in switch_cols: + conn.execute("ALTER TABLE switches ADD COLUMN credential_id INTEGER") + + # Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten + # (ältere DB-Version) in eigene Credentials-Datensätze überführen. + if "username" in switch_cols and "password" in switch_cols: + legacy_switches = conn.execute( + "SELECT hostname, username, password FROM switches WHERE credential_id IS NULL" + ).fetchall() + for sw in legacy_switches: + base_name = f"{sw['hostname']} (migriert)" + final_name, suffix = base_name, 1 + while conn.execute("SELECT 1 FROM credentials WHERE name=?", (final_name,)).fetchone(): + suffix += 1 + final_name = f"{base_name} {suffix}" + cur = conn.execute( + "INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)", + (final_name, sw["username"], sw["password"]), + ) + conn.execute( + "UPDATE switches SET credential_id=? WHERE hostname=?", + (cur.lastrowid, sw["hostname"]), + ) + # Standardgruppe 'Benutzer' sicherstellen. Die View-Rechte werden nur bei # der *erstmaligen* Erzeugung gesetzt — spätere Anpassungen durch einen # Admin (z.B. ein Recht wieder entziehen) bleiben so über Neustarts hinweg @@ -437,12 +479,12 @@ def get_device_status(devices): @app.route("/") def index(): """ - Dashboard als Kachel-Ansicht. Ohne Login: nur aktive (nicht deaktivierte) - Geräte, nur Online/Offline/Gesamt-Statistik, keine Interaktion. - Eingeloggt: alle Geräte (inkl. deaktivierte), volle Statistik-Kacheln, - Klick-Details und manueller PoE-Neustart. In beiden Fällen: erst alle - Nicht-Online-Geräte (alphabetisch), danach alle Online-Geräte - (alphabetisch). + Dashboard als Kachel-Ansicht, in drei Abschnitten: Offline, Online, + Deaktiviert (jeweils alphabetisch) — jeweils nur gerendert, wenn nicht + leer. Ohne Login: keine deaktivierten Geräte, nur Online/Offline/Gesamt- + Statistik, keine Interaktion. Eingeloggt: alle Geräte, volle + Statistik-Kacheln, Klick-Details, manueller PoE-Neustart bzw. bei + deaktivierten Geräten eine Aktivieren-Option. """ conn = get_db_connection() all_devices = conn.execute( @@ -452,26 +494,34 @@ def index(): interval = int(get_setting("interval", 5)) status, last_seen, last_checked, last_run_at = get_device_status(all_devices) - is_authenticated = current_user.is_authenticated - visible_devices = all_devices if is_authenticated else [d for d in all_devices if d["is_active"]] - def sort_key(d): - is_online = bool(d["is_active"]) and status.get(d["mac"]) == "online" - return (1 if is_online else 0, d["name"].lower()) + def by_name(devs): + return sorted(devs, key=lambda d: d["name"].lower()) - devices = sorted(visible_devices, key=sort_key) + offline_devices = by_name( + d for d in all_devices if d["is_active"] and status.get(d["mac"]) != "online" + ) + online_devices = by_name( + d for d in all_devices if d["is_active"] and status.get(d["mac"]) == "online" + ) + disabled_devices = by_name(d for d in all_devices if not d["is_active"]) if is_authenticated else [] - online = sum(1 for d in visible_devices if d["is_active"] and status.get(d["mac"]) == "online") - offline = sum(1 for d in visible_devices if d["is_active"] and status.get(d["mac"]) != "online") - stats = {"online": online, "offline": offline, "total": len(visible_devices)} + visible_devices = offline_devices + online_devices + disabled_devices + devices = offline_devices + online_devices # für Kompatibilität/Zähler + + stats = {"online": len(online_devices), "offline": len(offline_devices), "total": len(devices)} if is_authenticated: - stats["disabled"] = sum(1 for d in all_devices if not d["is_active"]) + stats["total"] = len(all_devices) + stats["disabled"] = len(disabled_devices) last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None return render_template( - "index.html", devices=devices, status=status, last_seen=last_seen, + "index.html", + offline_devices=offline_devices, online_devices=online_devices, disabled_devices=disabled_devices, + device_count=len(visible_devices), + status=status, last_seen=last_seen, last_checked=last_checked, interval=interval, stats=stats, last_run_epoch_ms=last_run_epoch_ms, ) @@ -670,6 +720,34 @@ def toggle_device(mac): # Switches # --------------------------------------------------------------------------- +def _resolve_credential_choice(conn): + """ + Liest die Zugangsdaten-Auswahl aus einem Switch-Formular: entweder eine + bestehende credential_id, oder (choice == "new") legt direkt aus dem + Switch-Formular heraus neue Zugangsdaten an — damit man beim Anlegen + eines Switches nicht zuerst zu "Zugangsdaten" wechseln muss. + Gibt (credential_id, error_message) zurück; error_message ist None bei Erfolg. + """ + choice = request.form.get("credential_choice", "") + if choice == "new": + name = request.form.get("new_credential_name", "").strip() + username = request.form.get("new_credential_username", "").strip() + password = request.form.get("new_credential_password", "") + if not (name and username and password): + return None, "Für neue Zugangsdaten müssen Name, Username und Passwort ausgefüllt sein!" + try: + cur = conn.execute( + "INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)", + (name, username, encrypt_password(password)), + ) + return cur.lastrowid, None + except sqlite3.IntegrityError: + return None, "Es existieren bereits Zugangsdaten mit diesem Namen!" + elif choice.isdigit(): + return int(choice), None + return None, "Bitte Zugangsdaten auswählen oder neue anlegen." + + @app.route("/switches", methods=["GET", "POST"]) @login_required def switches(): @@ -685,17 +763,19 @@ def switches(): return redirect(url_for("switches")) hostname = request.form["hostname"] ip = request.form["ip"] - username = request.form["username"] - password = encrypt_password(request.form["password"]) - try: - conn.execute( - "INSERT INTO switches (hostname, ip, username, password) VALUES (?, ?, ?, ?)", - (hostname, ip, username, password), - ) - conn.commit() - flash(f"Switch {hostname} hinzugefügt.", "success") - except sqlite3.IntegrityError: - flash("Hostname existiert bereits oder Eingabefehler!", "danger") + credential_id, cred_error = _resolve_credential_choice(conn) + if cred_error: + flash(cred_error, "danger") + else: + try: + conn.execute( + "INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)", + (hostname, ip, credential_id), + ) + conn.commit() + flash(f"Switch {hostname} hinzugefügt.", "success") + except sqlite3.IntegrityError: + flash("Hostname existiert bereits oder Eingabefehler!", "danger") if request.method == "POST" and "edit_switch" in request.form: if not current_user.has_permission("switches.edit"): @@ -704,33 +784,35 @@ def switches(): old_hostname = request.form["old_hostname"] hostname = request.form["hostname"] ip = request.form["ip"] - username = request.form["username"] - new_password = request.form.get("password") + credential_id, cred_error = _resolve_credential_choice(conn) + if cred_error: + flash(cred_error, "danger") + else: + try: + conn.execute( + "UPDATE switches SET hostname=?, ip=?, credential_id=? WHERE hostname=?", + (hostname, ip, credential_id, old_hostname), + ) + if hostname != old_hostname: + conn.execute( + "UPDATE devices SET switch_hostname=? WHERE switch_hostname=?", + (hostname, old_hostname), + ) + conn.commit() + flash(f"Switch {hostname} aktualisiert.", "success") + except sqlite3.IntegrityError: + flash("Hostname existiert bereits oder Eingabefehler!", "danger") - try: - if new_password: - conn.execute( - "UPDATE switches SET hostname=?, ip=?, username=?, password=? WHERE hostname=?", - (hostname, ip, username, encrypt_password(new_password), old_hostname), - ) - else: - conn.execute( - "UPDATE switches SET hostname=?, ip=?, username=? WHERE hostname=?", - (hostname, ip, username, old_hostname), - ) - if hostname != old_hostname: - conn.execute( - "UPDATE devices SET switch_hostname=? WHERE switch_hostname=?", - (hostname, old_hostname), - ) - conn.commit() - flash(f"Switch {hostname} aktualisiert.", "success") - except sqlite3.IntegrityError: - flash("Hostname existiert bereits oder Eingabefehler!", "danger") - - switch_rows = conn.execute("SELECT hostname, ip, username FROM switches ORDER BY hostname ASC").fetchall() + switch_rows = conn.execute(""" + SELECT switches.hostname, switches.ip, switches.credential_id, + credentials.name AS credential_name, credentials.username AS credential_username + FROM switches + LEFT JOIN credentials ON credentials.id = switches.credential_id + ORDER BY switches.hostname ASC + """).fetchall() + all_credentials = conn.execute("SELECT id, name, username FROM credentials ORDER BY name ASC").fetchall() conn.close() - return render_template("switches.html", switches=switch_rows) + return render_template("switches.html", switches=switch_rows, all_credentials=all_credentials) @app.route("/switches/delete/", methods=["POST"]) @@ -755,6 +837,94 @@ def delete_switch(hostname): return redirect(url_for("switches")) +# --------------------------------------------------------------------------- +# Zugangsdaten (Credentials) — wiederverwendbare SSH-Logins für Switche, +# damit nicht jeder Switch sein eigenes Login braucht. Gleiche Berechtigungen +# wie Switch-Verwaltung (switches.*), da inhaltlich untrennbar davon. +# --------------------------------------------------------------------------- + +@app.route("/credentials", methods=["GET", "POST"]) +@login_required +def credentials(): + if not current_user.can_manage_switches: + flash("Keine Berechtigung für die Zugangsdatenverwaltung.", "danger") + return redirect(url_for("index")) + + conn = get_db_connection() + + if request.method == "POST" and "add_credential" in request.form: + if not current_user.has_permission("switches.create"): + flash("Keine Berechtigung, Zugangsdaten anzulegen.", "danger") + return redirect(url_for("credentials")) + name = request.form.get("name", "").strip() + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if name and username and password: + try: + conn.execute( + "INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)", + (name, username, encrypt_password(password)), + ) + conn.commit() + flash(f"Zugangsdaten '{name}' angelegt.", "success") + except sqlite3.IntegrityError: + flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger") + else: + flash("Name, Username und Passwort müssen ausgefüllt sein!", "danger") + + elif request.method == "POST" and "edit_credential" in request.form: + if not current_user.has_permission("switches.edit"): + flash("Keine Berechtigung, Zugangsdaten zu bearbeiten.", "danger") + return redirect(url_for("credentials")) + cred_id = request.form.get("credential_id") + name = request.form.get("name", "").strip() + username = request.form.get("username", "").strip() + new_password = request.form.get("password", "") + if name and username: + try: + if new_password: + conn.execute( + "UPDATE credentials SET name=?, username=?, password=? WHERE id=?", + (name, username, encrypt_password(new_password), cred_id), + ) + else: + conn.execute( + "UPDATE credentials SET name=?, username=? WHERE id=?", + (name, username, cred_id), + ) + conn.commit() + flash(f"Zugangsdaten '{name}' aktualisiert.", "success") + except sqlite3.IntegrityError: + flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger") + else: + flash("Name und Username müssen ausgefüllt sein!", "danger") + + elif request.method == "POST" and "delete_credential" in request.form: + if not current_user.has_permission("switches.delete"): + flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger") + return redirect(url_for("credentials")) + cred_id = request.form.get("delete_credential") + used_by = conn.execute("SELECT hostname FROM switches WHERE credential_id=?", (cred_id,)).fetchall() + if used_by: + names = ", ".join(s["hostname"] for s in used_by) + flash(f"Diese Zugangsdaten werden noch von folgenden Switchen verwendet: {names}", "danger") + else: + conn.execute("DELETE FROM credentials WHERE id=?", (cred_id,)) + conn.commit() + flash("Zugangsdaten gelöscht.", "success") + + credential_rows = conn.execute(""" + SELECT credentials.id, credentials.name, credentials.username, + COUNT(switches.hostname) AS switch_count + FROM credentials + LEFT JOIN switches ON switches.credential_id = credentials.id + GROUP BY credentials.id + ORDER BY credentials.name ASC + """).fetchall() + conn.close() + return render_template("credentials.html", credentials=credential_rows) + + # --------------------------------------------------------------------------- # Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen. # @@ -1063,25 +1233,27 @@ def users(): if "add_user" in request.form: username = request.form["username"].strip() password = request.form["password"].strip() - is_admin = int(request.form.get("is_admin", 0)) + first_name = request.form.get("first_name", "").strip() or None + last_name = request.form.get("last_name", "").strip() or None + # Die Gruppen-Auswahl entscheidet auch über die Rolle: Auswahl + # "admin" macht den Benutzer zum Admin, jede andere Auswahl ist + # eine normale Gruppe (oder keine). + group_choice = request.form.get("group_id") or "" + is_admin = 1 if group_choice == "admin" else 0 + if username and password: pw_hash = bcrypt.generate_password_hash(password).decode("utf-8") try: cur = conn.execute( - "INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)", - (username, pw_hash, is_admin), + "INSERT INTO users (username, password, is_admin, first_name, last_name) " + "VALUES (?, ?, ?, ?, ?)", + (username, pw_hash, is_admin, first_name, last_name), ) - # Neue, nicht-admin Benutzer landen automatisch in der - # Standardgruppe 'Benutzer' (alle Ansichtsrechte). - if not is_admin: - default_group = conn.execute( - "SELECT id FROM groups WHERE is_default=1 LIMIT 1" - ).fetchone() - if default_group: - conn.execute( - "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", - (cur.lastrowid, default_group["id"]), - ) + if not is_admin and group_choice: + conn.execute( + "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", + (cur.lastrowid, group_choice), + ) conn.commit() flash(f"Benutzer '{username}' erfolgreich angelegt!", "success") except sqlite3.IntegrityError: @@ -1089,29 +1261,56 @@ def users(): else: flash("Username und Passwort dürfen nicht leer sein!", "danger") - elif "change_role" in request.form: + elif "edit_user" in request.form: + # Nur Stammdaten + optional Passwort — Gruppe/Rolle wird + # ausschließlich über "Gruppe zuweisen" geändert (s.u.). user_id = request.form["user_id"] username = request.form.get("username", "").strip() - is_admin = int(request.form.get("is_admin", 0)) + first_name = request.form.get("first_name", "").strip() or None + last_name = request.form.get("last_name", "").strip() or None + new_password = request.form.get("new_password", "").strip() if username: - conn.execute( - "UPDATE users SET username=?, is_admin=? WHERE id=?", (username, is_admin, user_id) - ) + if new_password: + pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8") + conn.execute( + "UPDATE users SET username=?, first_name=?, last_name=?, password=? WHERE id=?", + (username, first_name, last_name, pw_hash, user_id), + ) + else: + conn.execute( + "UPDATE users SET username=?, first_name=?, last_name=? WHERE id=?", + (username, first_name, last_name, user_id), + ) conn.commit() - flash("Rolle und Username geändert!", "success") + flash("Benutzer aktualisiert!", "success") else: flash("Username darf nicht leer sein!", "danger") - elif "change_password" in request.form: + elif "assign_group" in request.form: user_id = request.form["user_id"] - new_password = request.form.get("new_password", "").strip() - if new_password: - pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8") - conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, user_id)) - conn.commit() - flash("Passwort erfolgreich geändert!", "success") + choice = request.form.get("group_id") or "" + + # Mindestens ein Admin muss bestehen bleiben. + target = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone() + if target and target["is_admin"] and choice != "admin": + admin_count = conn.execute("SELECT COUNT(*) AS n FROM users WHERE is_admin=1").fetchone()["n"] + if admin_count <= 1: + flash("Es muss mindestens ein Admin bestehen bleiben.", "danger") + conn.close() + return redirect(url_for("users")) + + conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) + if choice == "admin": + conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (user_id,)) else: - flash("Passwort darf nicht leer sein!", "danger") + conn.execute("UPDATE users SET is_admin=0 WHERE id=?", (user_id,)) + if choice: + conn.execute( + "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", + (user_id, choice), + ) + conn.commit() + flash("Gruppe zugewiesen!", "success") elif "delete_user" in request.form: user_id = request.form["delete_user"] @@ -1121,16 +1320,18 @@ def users(): flash("Benutzer gelöscht!", "success") users_list = conn.execute(""" - SELECT users.id, users.username, users.is_admin, - GROUP_CONCAT(groups.name, ', ') AS group_names + SELECT users.id, users.username, users.is_admin, users.first_name, users.last_name, + GROUP_CONCAT(groups.name, ', ') AS group_names, + (SELECT group_id FROM user_groups WHERE user_groups.user_id = users.id LIMIT 1) AS group_id FROM users LEFT JOIN user_groups ON user_groups.user_id = users.id LEFT JOIN groups ON groups.id = user_groups.group_id GROUP BY users.id ORDER BY users.username ASC """).fetchall() + all_groups = conn.execute("SELECT id, name, is_default FROM groups ORDER BY is_default DESC, name ASC").fetchall() conn.close() - return render_template("users.html", users=users_list) + return render_template("users.html", users=users_list, all_groups=all_groups) # --------------------------------------------------------------------------- @@ -1163,31 +1364,62 @@ def groups(): elif "save_group" in request.form: group_id = request.form.get("group_id") name = request.form.get("name", "").strip() - selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS) - selected_members = {int(x) for x in request.form.getlist("members") if x.isdigit()} if not name: flash("Gruppenname darf nicht leer sein!", "danger") conn.close() return redirect(url_for("groups")) + # Die Gruppenkarte (Name + Rechte) und das Mitglieder-Modal sind + # zwei getrennte Formulare, damit das Speichern des einen nicht + # versehentlich den Stand des anderen zurücksetzt. Die verstecken + # *_submitted-Felder markieren, welcher Teil tatsächlich abgeschickt + # wurde (leere Checkbox-Listen wären sonst nicht von "nichts + # ausgewählt" zu unterscheiden). try: conn.execute("UPDATE groups SET name=? WHERE id=?", (name, group_id)) - conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) - conn.executemany( - "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", - [(group_id, p) for p in selected_permissions], - ) - conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) - conn.executemany( - "INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)", - [(uid, group_id) for uid in selected_members], - ) + + if "permissions_submitted" in request.form: + selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS) + conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) + conn.executemany( + "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", + [(group_id, p) for p in selected_permissions], + ) + + if "members_submitted" in request.form: + selected_members = {int(x) for x in request.form.getlist("members") if x.isdigit()} + conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) + conn.executemany( + "INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)", + [(uid, group_id) for uid in selected_members], + ) + conn.commit() flash(f"Gruppe '{name}' aktualisiert.", "success") except sqlite3.IntegrityError: flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger") + elif "assign_admins" in request.form: + selected_admins = {int(x) for x in request.form.getlist("members") if x.isdigit()} + if not selected_admins: + flash("Es muss mindestens ein Admin bestehen bleiben.", "danger") + else: + conn.execute("UPDATE users SET is_admin=0") + conn.executemany( + "UPDATE users SET is_admin=1 WHERE id=?", + [(uid,) for uid in selected_admins], + ) + # Admins brauchen keine Gruppenrechte mehr (sie dürfen ohnehin + # alles) — Mitgliedschaften aufräumen, damit sie nicht doppelt + # in Admin- und z.B. Standardgruppe auftauchen. + conn.executemany( + "DELETE FROM user_groups WHERE user_id=?", + [(uid,) for uid in selected_admins], + ) + conn.commit() + flash("Admin-Zuweisung aktualisiert.", "success") + elif "delete_group" in request.form: group_id = request.form.get("delete_group") target = conn.execute("SELECT name, is_default FROM groups WHERE id=?", (group_id,)).fetchone() @@ -1236,6 +1468,7 @@ def groups(): "permissions": set(ALL_PERMISSION_KEYS), "member_names": [u["username"] for u in admin_rows], } + all_users_all = conn.execute("SELECT id, username, is_admin FROM users ORDER BY username ASC").fetchall() conn.close() return render_template( @@ -1243,6 +1476,7 @@ def groups(): groups=groups_data, admin_virtual_group=admin_virtual_group, all_users=all_users, + all_users_all=all_users_all, permission_catalog=PERMISSIONS, ) diff --git a/srv/poe_manager/create_db.py b/srv/poe_manager/create_db.py index 69236d9..b848fe7 100644 --- a/srv/poe_manager/create_db.py +++ b/srv/poe_manager/create_db.py @@ -9,13 +9,25 @@ DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) conn = sqlite3.connect(DB_PATH) c = conn.cursor() -# Switches (Aruba-Switche mit SSH-Zugangsdaten; Passwort wird verschlüsselt gespeichert) +# Zugangsdaten (wiederverwendbare SSH-Logins, mehrere Switche können sich +# dieselben teilen; Passwort wird verschlüsselt gespeichert) +c.execute(""" +CREATE TABLE IF NOT EXISTS credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL +); +""") + +# Switches (Aruba-Switche) — referenzieren ihre Zugangsdaten statt sie +# direkt zu speichern c.execute(""" CREATE TABLE IF NOT EXISTS switches ( hostname TEXT PRIMARY KEY, ip TEXT NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL + credential_id INTEGER, + FOREIGN KEY (credential_id) REFERENCES credentials(id) ); """) @@ -38,7 +50,9 @@ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, - is_admin INTEGER DEFAULT 0 + is_admin INTEGER DEFAULT 0, + first_name TEXT, + last_name TEXT ); """) diff --git a/srv/poe_manager/generate_ips.py b/srv/poe_manager/generate_ips.py index 2e729d4..d666386 100644 --- a/srv/poe_manager/generate_ips.py +++ b/srv/poe_manager/generate_ips.py @@ -14,7 +14,12 @@ def generate_ips_list(): switches = { row["hostname"]: row - for row in conn.execute("SELECT hostname, ip, username, password FROM switches") + for row in conn.execute(""" + SELECT switches.hostname, switches.ip, + credentials.username AS username, credentials.password AS password + FROM switches + LEFT JOIN credentials ON credentials.id = switches.credential_id + """) } devices = conn.execute(""" @@ -26,12 +31,12 @@ def generate_ips_list(): for dev in devices: switch = switches.get(dev["switch_hostname"]) - if switch: + if switch and switch["password"]: switch_ip = switch["ip"] switch_user = switch["username"] switch_pass = decrypt_password(switch["password"]) else: - switch_ip = "" + switch_ip = switch["ip"] if switch else "" switch_user = "" switch_pass = "" diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index 7c57997..e175f6f 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -265,7 +265,7 @@ button { font-family: inherit; } } .hamburger { - display: none; + display: inline-flex; width: 36px; height: 36px; align-items: center; justify-content: center; border-radius: var(--radius-sm); @@ -273,6 +273,14 @@ button { font-family: inherit; } background: var(--bg-card); color: var(--text); cursor: pointer; + flex-shrink: 0; +} + +/* Desktop: dauerhaftes Einklappen der Sidebar (nicht die mobile + Off-Canvas-Logik unten, die bleibt für schmale Screens bestehen). */ +@media (min-width: 901px) { + .sidebar.collapsed { transform: translateX(-100%); } + .main.sidebar-collapsed { margin-left: 0; } } .content { @@ -442,11 +450,31 @@ button { font-family: inherit; } Device grid (dashboard) ========================================================================== */ +/* Bootstrap-artiges Raster: feste Spaltenzahl je Breakpoint statt frei + fließendem auto-fill — dadurch maximal 6 Kacheln nebeneinander auf + breiten Screens, dafür beliebig viele Zeilen untereinander. */ .device-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-template-columns: repeat(2, 1fr); gap: 14px; } +@media (min-width: 480px) { .device-grid { grid-template-columns: repeat(3, 1fr); } } +@media (min-width: 720px) { .device-grid { grid-template-columns: repeat(4, 1fr); } } +@media (min-width: 960px) { .device-grid { grid-template-columns: repeat(5, 1fr); } } +@media (min-width: 1200px) { .device-grid { grid-template-columns: repeat(6, 1fr); } } + +.dash-section { margin-bottom: 26px; } +.dash-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 650; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 12px; +} .device-card { position: relative; @@ -582,7 +610,8 @@ table.data-table { .mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--text-dim); } .cell-name { font-weight: 600; } -.row-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.row-actions { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; } +.row-actions form { display: flex; } .empty-row td { text-align: center; @@ -590,6 +619,13 @@ table.data-table { color: var(--text-faint); } +.group-detail-row td { + padding: 20px 24px 22px; + background: var(--bg-elevated); +} +.group-detail-row:hover { background: transparent; } +#chev-admin, [id^="chev-"] { transition: transform 0.15s ease; } + /* ========================================================================== Forms ========================================================================== */ diff --git a/srv/poe_manager/static/js/app.js b/srv/poe_manager/static/js/app.js index 045f68c..6eb7bce 100644 --- a/srv/poe_manager/static/js/app.js +++ b/srv/poe_manager/static/js/app.js @@ -39,9 +39,14 @@ function initSidebar() { const sidebar = document.querySelector(".sidebar"); const backdrop = document.querySelector(".sidebar-backdrop"); + const main = document.querySelector(".main"); const toggles = document.querySelectorAll("[data-sidebar-toggle]"); if (!sidebar) return; + const DESKTOP_BREAKPOINT = 900; + const COLLAPSE_KEY = "poe-sidebar-collapsed"; + + // Mobile: temporäres Überlagern per Hamburger + Backdrop. function open() { sidebar.classList.add("open"); backdrop && backdrop.classList.add("open"); @@ -50,8 +55,22 @@ sidebar.classList.remove("open"); backdrop && backdrop.classList.remove("open"); } + + // Desktop: dauerhaftes Ein-/Ausklappen, über Neuladen hinweg gemerkt. + function setCollapsed(collapsed) { + sidebar.classList.toggle("collapsed", collapsed); + main && main.classList.toggle("sidebar-collapsed", collapsed); + localStorage.setItem(COLLAPSE_KEY, collapsed ? "1" : "0"); + } + + if (localStorage.getItem(COLLAPSE_KEY) === "1") setCollapsed(true); + toggles.forEach((btn) => btn.addEventListener("click", () => { - sidebar.classList.contains("open") ? close() : open(); + if (window.innerWidth <= DESKTOP_BREAKPOINT) { + sidebar.classList.contains("open") ? close() : open(); + } else { + setCollapsed(!sidebar.classList.contains("collapsed")); + } })); backdrop && backdrop.addEventListener("click", close); document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close)); diff --git a/srv/poe_manager/templates/base.html b/srv/poe_manager/templates/base.html index 6b61941..476682a 100644 --- a/srv/poe_manager/templates/base.html +++ b/srv/poe_manager/templates/base.html @@ -25,6 +25,7 @@ "share": '', "users": '', "groups": '', + "key": '', "terminal": '', "sliders": '', "logout": '', @@ -59,6 +60,10 @@ {{ icons['share']|safe }} Switches + + {{ icons['key']|safe }} + Zugangsdaten + {% endif %} {% if current_user.is_admin %} diff --git a/srv/poe_manager/templates/credentials.html b/srv/poe_manager/templates/credentials.html new file mode 100644 index 0000000..85c2dc9 --- /dev/null +++ b/srv/poe_manager/templates/credentials.html @@ -0,0 +1,157 @@ +{% extends "base.html" %} +{% set active_page = "credentials" %} +{% set can_create = current_user.has_permission('switches.create') %} +{% set can_edit = current_user.has_permission('switches.edit') %} +{% set can_delete = current_user.has_permission('switches.delete') %} +{% block page_title %}Zugangsdaten{% endblock %} +{% block page_sub %}
{{ credentials|length }} Zugangsdaten
{% endblock %} +{% block topbar_right %} +{% if can_create %} + +{% endif %} +{% endblock %} + +{% block content %} + +

+ Zugangsdaten können mehreren Switchen gleichzeitig zugeordnet werden. Beim Anlegen eines + Switches lassen sich bestehende Zugangsdaten auswählen oder direkt neue hinterlegen. +

+ +
+
+ + + + {% for c in credentials %} + + + + + + + {% else %} + + {% endfor %} + +
NameUsernameVerwendet vonAktionen
{{ c['name'] }}{{ c['username'] }}{{ c['switch_count'] }} Switch{{ 'e' if c['switch_count'] != 1 else '' }} +
+ {% if can_edit %} + + {% endif %} + {% if can_delete %} +
+ + +
+ {% endif %} +
+
Noch keine Zugangsdaten vorhanden.
+
+
+ +{% if can_create %} + + +{% endif %} + +{% if can_edit %} + + +{% endif %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/srv/poe_manager/templates/groups.html b/srv/poe_manager/templates/groups.html index b59db28..9498490 100644 --- a/srv/poe_manager/templates/groups.html +++ b/srv/poe_manager/templates/groups.html @@ -12,123 +12,179 @@ {% block content %}

- Admins dürfen immer alles. Über Gruppen lassen sich einzelne Verwaltungsrechte für - Devices und Switches gezielt an normale Benutzer vergeben, ohne sie zu Admins zu machen. - Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. + Über Gruppen lassen sich einzelne Verwaltungsrechte für Devices und Switches gezielt vergeben. + Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken, + um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.

-{# Virtuelle Admin-"Gruppe": rein informativ, kein Formular — Admin-Status - wird ausschließlich über den is_admin-Schalter auf der Users-Seite gesetzt. #} -
-
-
-

Admin

- Systemrolle -
-
-

- Admins dürfen immer alles — Rolle wird über Users vergeben, nicht hier. -

-
- {% for cat_key, cat in permission_catalog.items() %} -
-
{{ cat['label'] }}
-
- {% for key, label in cat['items'].items() %} - - {% endfor %} -
-
- {% endfor %} -
-
Mitglieder
-

- {% if admin_virtual_group.member_names %}{{ admin_virtual_group.member_names|join(', ') }}{% else %}Keine Admins vorhanden.{% endif %} -

-
+
+
+ + + + + + + + + + + + + + {% for g in groups %} + + + + + + + + + {% else %} + + {% endfor %} + +
GruppeMitgliederAktionen
Admin Systemrolle{{ admin_virtual_group.member_names|length }} +
+ + +
+
+ {{ g.name }} + {% if g.is_default %}Standard{% endif %} + {{ g.member_names|length }} +
+ + + {% if not g.is_default %} +
+ + +
+ {% endif %} +
+
Noch keine weiteren Gruppen angelegt.
-{% if groups %} -
- {% for g in groups %} -
+ +