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 <noreply@anthropic.com>
This commit is contained in:
@@ -10,20 +10,33 @@ Linux-VM). Entwickelt wird unter WSL2 (Ubuntu) unter Windows — siehe
|
|||||||
|
|
||||||
Die App ermöglicht:
|
Die App ermöglicht:
|
||||||
|
|
||||||
- **Öffentliches Kurz-Dashboard ohne Login** (Hostname, IP, Status, letzte
|
- **Öffentliches Kachel-Dashboard ohne Login** — zeigt nur aktive (nicht
|
||||||
Prüfung) — für den schnellen Blick auf dem Flur-Monitor; volle Verwaltung
|
deaktivierte) Geräte, nur Online/Offline/Gesamt-Statistik; volle Verwaltung,
|
||||||
und PoE-Neustart bleiben hinter dem Login
|
Aktivieren/Deaktivieren und PoE-Neustart bleiben hinter dem Login. Eingeloggt
|
||||||
- Verwaltung von Devices und Switches (inkl. verschlüsselter SSH-Zugangsdaten)
|
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
|
- **Browser-SSH-Terminal beim Anlegen/Bearbeiten eines Switches**, um den
|
||||||
Host-Key zu bestätigen und die Zugangsdaten direkt zu prüfen, bevor der
|
Host-Key zu bestätigen und die Zugangsdaten direkt zu prüfen, bevor der
|
||||||
Switch gespeichert wird — funktioniert auf jedem Betriebssystem, die
|
Switch gespeichert wird — funktioniert auf jedem Betriebssystem, die
|
||||||
SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut
|
SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut
|
||||||
- Manueller PoE-Neustart einzelner Geräte per Klick
|
- Manueller PoE-Neustart einzelner Geräte per Klick; bei deaktivierten
|
||||||
- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst)
|
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)
|
- Live-Log-Ansicht mit farblicher Hervorhebung (online/offline)
|
||||||
- **Rechtesystem mit Gruppen**: Admins dürfen immer alles; normalen
|
- **Rechtesystem mit Gruppen**: Admins dürfen immer alles (als Gruppe „Admin“
|
||||||
Benutzern lassen sich über Gruppen einzelne Verwaltungsrechte gezielt
|
überall mit aufgeführt); normalen Benutzern lassen sich über Gruppen
|
||||||
zuweisen (siehe [Rechtesystem](#rechtesystem-gruppen--berechtigungen))
|
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?
|
## Was ist neu gegenüber dem Original?
|
||||||
|
|
||||||
@@ -61,39 +74,67 @@ Die App ermöglicht:
|
|||||||
|
|
||||||
## Rechtesystem (Gruppen & Berechtigungen)
|
## 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
|
Settingsverwaltung — das bleibt exklusiv Admins vorbehalten. Zusätzlich gibt
|
||||||
es jetzt **Gruppen**: eine Gruppe bündelt einzelne Verwaltungsrechte, die
|
es **Gruppen**: eine Gruppe bündelt einzelne Verwaltungsrechte, die dann
|
||||||
dann normalen Benutzern zugewiesen werden können, ohne sie zu Admins zu
|
normalen Benutzern zugewiesen werden können, ohne sie zu Admins zu machen.
|
||||||
machen. Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren
|
Ein Benutzer kann nur einer Gruppe/Rolle gleichzeitig zugeordnet sein
|
||||||
sich (Vereinigung, nicht Schnittmenge).
|
(ü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:
|
Verfügbare Rechte:
|
||||||
|
|
||||||
| Bereich | Recht | Bedeutung |
|
| Bereich | Recht | Bedeutung |
|
||||||
|----------|---------------------|---------------------------------------------|
|
|----------|---------------------|-----------------------------------------------|
|
||||||
|
| Devices | `devices.view` | Devices-Seite ansehen |
|
||||||
| Devices | `devices.toggle` | Geräte aktivieren/deaktivieren |
|
| Devices | `devices.toggle` | Geräte aktivieren/deaktivieren |
|
||||||
| Devices | `devices.create` | Geräte anlegen |
|
| Devices | `devices.create` | Geräte anlegen |
|
||||||
| Devices | `devices.edit` | Geräte bearbeiten (inkl. Switch-Zuordnung) |
|
| Devices | `devices.edit` | Geräte bearbeiten (inkl. Switch-Zuordnung) |
|
||||||
| Devices | `devices.delete` | Geräte löschen |
|
| Devices | `devices.delete` | Geräte löschen |
|
||||||
| Devices | `devices.restart` | PoE-Neustart über das Dashboard |
|
| Devices | `devices.restart` | PoE-Neustart/Aktivieren über das Dashboard |
|
||||||
| Switches | `switches.create` | Switche anlegen |
|
| Switches | `switches.view` | Switches- und Zugangsdaten-Seite ansehen |
|
||||||
| Switches | `switches.edit` | Switche bearbeiten |
|
| Switches | `switches.create` | Switche und Zugangsdaten anlegen |
|
||||||
| Switches | `switches.delete` | Switche löschen |
|
| 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):
|
Sowohl das Anzeigen der Devices-/Switches-Seiten als auch jede einzelne
|
||||||
Name vergeben, Rechte per Checkbox zuweisen, Mitglieder auswählen. Die
|
Aktion (Buttons, Toggle-Switches, Formulare) ist an das jeweilige Recht
|
||||||
Devices-Seite bleibt für alle eingeloggten Benutzer les- und durchsuchbar;
|
gekoppelt — im Frontend ausgeblendet **und** im Backend serverseitig
|
||||||
nur die tatsächlichen Aktionen (Buttons, Toggle-Switches, Formulare) sind an
|
durchgesetzt, unabhängig vom Frontend.
|
||||||
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.
|
|
||||||
|
|
||||||
Datenmodell: `groups`, `group_permissions` (Gruppe → Recht), `user_groups`
|
Datenmodell: `groups` (inkl. `is_default`-Flag), `group_permissions`
|
||||||
(Benutzer → Gruppe). Bestehende Datenbanken werden beim App-Start automatisch
|
(Gruppe → Recht), `user_groups` (Benutzer → Gruppe). Bestehende Datenbanken
|
||||||
um diese Tabellen ergänzt (`_ensure_schema()` in `app.py`), ein manuelles
|
werden beim App-Start automatisch migriert (`_ensure_schema()` in `app.py`,
|
||||||
Migrations-Skript ist nicht nötig.
|
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
|
## Entwicklung unter WSL
|
||||||
|
|
||||||
|
|||||||
+296
-62
@@ -203,6 +203,48 @@ def _ensure_schema():
|
|||||||
if "is_default" not in existing_cols:
|
if "is_default" not in existing_cols:
|
||||||
conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0")
|
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
|
# Standardgruppe 'Benutzer' sicherstellen. Die View-Rechte werden nur bei
|
||||||
# der *erstmaligen* Erzeugung gesetzt — spätere Anpassungen durch einen
|
# der *erstmaligen* Erzeugung gesetzt — spätere Anpassungen durch einen
|
||||||
# Admin (z.B. ein Recht wieder entziehen) bleiben so über Neustarts hinweg
|
# Admin (z.B. ein Recht wieder entziehen) bleiben so über Neustarts hinweg
|
||||||
@@ -437,12 +479,12 @@ def get_device_status(devices):
|
|||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
"""
|
"""
|
||||||
Dashboard als Kachel-Ansicht. Ohne Login: nur aktive (nicht deaktivierte)
|
Dashboard als Kachel-Ansicht, in drei Abschnitten: Offline, Online,
|
||||||
Geräte, nur Online/Offline/Gesamt-Statistik, keine Interaktion.
|
Deaktiviert (jeweils alphabetisch) — jeweils nur gerendert, wenn nicht
|
||||||
Eingeloggt: alle Geräte (inkl. deaktivierte), volle Statistik-Kacheln,
|
leer. Ohne Login: keine deaktivierten Geräte, nur Online/Offline/Gesamt-
|
||||||
Klick-Details und manueller PoE-Neustart. In beiden Fällen: erst alle
|
Statistik, keine Interaktion. Eingeloggt: alle Geräte, volle
|
||||||
Nicht-Online-Geräte (alphabetisch), danach alle Online-Geräte
|
Statistik-Kacheln, Klick-Details, manueller PoE-Neustart bzw. bei
|
||||||
(alphabetisch).
|
deaktivierten Geräten eine Aktivieren-Option.
|
||||||
"""
|
"""
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
all_devices = conn.execute(
|
all_devices = conn.execute(
|
||||||
@@ -452,26 +494,34 @@ def index():
|
|||||||
|
|
||||||
interval = int(get_setting("interval", 5))
|
interval = int(get_setting("interval", 5))
|
||||||
status, last_seen, last_checked, last_run_at = get_device_status(all_devices)
|
status, last_seen, last_checked, last_run_at = get_device_status(all_devices)
|
||||||
|
|
||||||
is_authenticated = current_user.is_authenticated
|
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):
|
def by_name(devs):
|
||||||
is_online = bool(d["is_active"]) and status.get(d["mac"]) == "online"
|
return sorted(devs, key=lambda d: d["name"].lower())
|
||||||
return (1 if is_online else 0, 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")
|
visible_devices = offline_devices + online_devices + disabled_devices
|
||||||
offline = sum(1 for d in visible_devices if d["is_active"] and status.get(d["mac"]) != "online")
|
devices = offline_devices + online_devices # für Kompatibilität/Zähler
|
||||||
stats = {"online": online, "offline": offline, "total": len(visible_devices)}
|
|
||||||
|
stats = {"online": len(online_devices), "offline": len(offline_devices), "total": len(devices)}
|
||||||
if is_authenticated:
|
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
|
last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None
|
||||||
|
|
||||||
return render_template(
|
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_checked=last_checked, interval=interval, stats=stats,
|
||||||
last_run_epoch_ms=last_run_epoch_ms,
|
last_run_epoch_ms=last_run_epoch_ms,
|
||||||
)
|
)
|
||||||
@@ -670,6 +720,34 @@ def toggle_device(mac):
|
|||||||
# Switches
|
# 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"])
|
@app.route("/switches", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def switches():
|
def switches():
|
||||||
@@ -685,12 +763,14 @@ def switches():
|
|||||||
return redirect(url_for("switches"))
|
return redirect(url_for("switches"))
|
||||||
hostname = request.form["hostname"]
|
hostname = request.form["hostname"]
|
||||||
ip = request.form["ip"]
|
ip = request.form["ip"]
|
||||||
username = request.form["username"]
|
credential_id, cred_error = _resolve_credential_choice(conn)
|
||||||
password = encrypt_password(request.form["password"])
|
if cred_error:
|
||||||
|
flash(cred_error, "danger")
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO switches (hostname, ip, username, password) VALUES (?, ?, ?, ?)",
|
"INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)",
|
||||||
(hostname, ip, username, password),
|
(hostname, ip, credential_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
flash(f"Switch {hostname} hinzugefügt.", "success")
|
flash(f"Switch {hostname} hinzugefügt.", "success")
|
||||||
@@ -704,19 +784,14 @@ def switches():
|
|||||||
old_hostname = request.form["old_hostname"]
|
old_hostname = request.form["old_hostname"]
|
||||||
hostname = request.form["hostname"]
|
hostname = request.form["hostname"]
|
||||||
ip = request.form["ip"]
|
ip = request.form["ip"]
|
||||||
username = request.form["username"]
|
credential_id, cred_error = _resolve_credential_choice(conn)
|
||||||
new_password = request.form.get("password")
|
if cred_error:
|
||||||
|
flash(cred_error, "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:
|
else:
|
||||||
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE switches SET hostname=?, ip=?, username=? WHERE hostname=?",
|
"UPDATE switches SET hostname=?, ip=?, credential_id=? WHERE hostname=?",
|
||||||
(hostname, ip, username, old_hostname),
|
(hostname, ip, credential_id, old_hostname),
|
||||||
)
|
)
|
||||||
if hostname != old_hostname:
|
if hostname != old_hostname:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -728,9 +803,16 @@ def switches():
|
|||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
flash("Hostname existiert bereits oder Eingabefehler!", "danger")
|
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()
|
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/<hostname>", methods=["POST"])
|
@app.route("/switches/delete/<hostname>", methods=["POST"])
|
||||||
@@ -755,6 +837,94 @@ def delete_switch(hostname):
|
|||||||
return redirect(url_for("switches"))
|
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.
|
# Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen.
|
||||||
#
|
#
|
||||||
@@ -1063,24 +1233,26 @@ def users():
|
|||||||
if "add_user" in request.form:
|
if "add_user" in request.form:
|
||||||
username = request.form["username"].strip()
|
username = request.form["username"].strip()
|
||||||
password = request.form["password"].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:
|
if username and password:
|
||||||
pw_hash = bcrypt.generate_password_hash(password).decode("utf-8")
|
pw_hash = bcrypt.generate_password_hash(password).decode("utf-8")
|
||||||
try:
|
try:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
|
"INSERT INTO users (username, password, is_admin, first_name, last_name) "
|
||||||
(username, pw_hash, is_admin),
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(username, pw_hash, is_admin, first_name, last_name),
|
||||||
)
|
)
|
||||||
# Neue, nicht-admin Benutzer landen automatisch in der
|
if not is_admin and group_choice:
|
||||||
# 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(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
|
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
|
||||||
(cur.lastrowid, default_group["id"]),
|
(cur.lastrowid, group_choice),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
flash(f"Benutzer '{username}' erfolgreich angelegt!", "success")
|
flash(f"Benutzer '{username}' erfolgreich angelegt!", "success")
|
||||||
@@ -1089,29 +1261,56 @@ def users():
|
|||||||
else:
|
else:
|
||||||
flash("Username und Passwort dürfen nicht leer sein!", "danger")
|
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"]
|
user_id = request.form["user_id"]
|
||||||
username = request.form.get("username", "").strip()
|
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:
|
if username:
|
||||||
|
if new_password:
|
||||||
|
pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET username=?, is_admin=? WHERE id=?", (username, is_admin, user_id)
|
"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()
|
conn.commit()
|
||||||
flash("Rolle und Username geändert!", "success")
|
flash("Benutzer aktualisiert!", "success")
|
||||||
else:
|
else:
|
||||||
flash("Username darf nicht leer sein!", "danger")
|
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"]
|
user_id = request.form["user_id"]
|
||||||
new_password = request.form.get("new_password", "").strip()
|
choice = request.form.get("group_id") or ""
|
||||||
if new_password:
|
|
||||||
pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
|
# Mindestens ein Admin muss bestehen bleiben.
|
||||||
conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, user_id))
|
target = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
conn.commit()
|
if target and target["is_admin"] and choice != "admin":
|
||||||
flash("Passwort erfolgreich geändert!", "success")
|
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:
|
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:
|
elif "delete_user" in request.form:
|
||||||
user_id = request.form["delete_user"]
|
user_id = request.form["delete_user"]
|
||||||
@@ -1121,16 +1320,18 @@ def users():
|
|||||||
flash("Benutzer gelöscht!", "success")
|
flash("Benutzer gelöscht!", "success")
|
||||||
|
|
||||||
users_list = conn.execute("""
|
users_list = conn.execute("""
|
||||||
SELECT users.id, users.username, users.is_admin,
|
SELECT users.id, users.username, users.is_admin, users.first_name, users.last_name,
|
||||||
GROUP_CONCAT(groups.name, ', ') AS group_names
|
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
|
FROM users
|
||||||
LEFT JOIN user_groups ON user_groups.user_id = users.id
|
LEFT JOIN user_groups ON user_groups.user_id = users.id
|
||||||
LEFT JOIN groups ON groups.id = user_groups.group_id
|
LEFT JOIN groups ON groups.id = user_groups.group_id
|
||||||
GROUP BY users.id
|
GROUP BY users.id
|
||||||
ORDER BY users.username ASC
|
ORDER BY users.username ASC
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
|
all_groups = conn.execute("SELECT id, name, is_default FROM groups ORDER BY is_default DESC, name ASC").fetchall()
|
||||||
conn.close()
|
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:
|
elif "save_group" in request.form:
|
||||||
group_id = request.form.get("group_id")
|
group_id = request.form.get("group_id")
|
||||||
name = request.form.get("name", "").strip()
|
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:
|
if not name:
|
||||||
flash("Gruppenname darf nicht leer sein!", "danger")
|
flash("Gruppenname darf nicht leer sein!", "danger")
|
||||||
conn.close()
|
conn.close()
|
||||||
return redirect(url_for("groups"))
|
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:
|
try:
|
||||||
conn.execute("UPDATE groups SET name=? WHERE id=?", (name, group_id))
|
conn.execute("UPDATE groups SET name=? WHERE id=?", (name, group_id))
|
||||||
|
|
||||||
|
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.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
"INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
||||||
[(group_id, p) for p in selected_permissions],
|
[(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.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)",
|
"INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)",
|
||||||
[(uid, group_id) for uid in selected_members],
|
[(uid, group_id) for uid in selected_members],
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
flash(f"Gruppe '{name}' aktualisiert.", "success")
|
flash(f"Gruppe '{name}' aktualisiert.", "success")
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger")
|
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:
|
elif "delete_group" in request.form:
|
||||||
group_id = request.form.get("delete_group")
|
group_id = request.form.get("delete_group")
|
||||||
target = conn.execute("SELECT name, is_default FROM groups WHERE id=?", (group_id,)).fetchone()
|
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),
|
"permissions": set(ALL_PERMISSION_KEYS),
|
||||||
"member_names": [u["username"] for u in admin_rows],
|
"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()
|
conn.close()
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -1243,6 +1476,7 @@ def groups():
|
|||||||
groups=groups_data,
|
groups=groups_data,
|
||||||
admin_virtual_group=admin_virtual_group,
|
admin_virtual_group=admin_virtual_group,
|
||||||
all_users=all_users,
|
all_users=all_users,
|
||||||
|
all_users_all=all_users_all,
|
||||||
permission_catalog=PERMISSIONS,
|
permission_catalog=PERMISSIONS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,25 @@ DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
|
|||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
c = conn.cursor()
|
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("""
|
c.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS switches (
|
CREATE TABLE IF NOT EXISTS switches (
|
||||||
hostname TEXT PRIMARY KEY,
|
hostname TEXT PRIMARY KEY,
|
||||||
ip TEXT NOT NULL,
|
ip TEXT NOT NULL,
|
||||||
username TEXT NOT NULL,
|
credential_id INTEGER,
|
||||||
password TEXT NOT NULL
|
FOREIGN KEY (credential_id) REFERENCES credentials(id)
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -38,7 +50,9 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password TEXT NOT NULL,
|
password TEXT NOT NULL,
|
||||||
is_admin INTEGER DEFAULT 0
|
is_admin INTEGER DEFAULT 0,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ def generate_ips_list():
|
|||||||
|
|
||||||
switches = {
|
switches = {
|
||||||
row["hostname"]: row
|
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("""
|
devices = conn.execute("""
|
||||||
@@ -26,12 +31,12 @@ def generate_ips_list():
|
|||||||
|
|
||||||
for dev in devices:
|
for dev in devices:
|
||||||
switch = switches.get(dev["switch_hostname"])
|
switch = switches.get(dev["switch_hostname"])
|
||||||
if switch:
|
if switch and switch["password"]:
|
||||||
switch_ip = switch["ip"]
|
switch_ip = switch["ip"]
|
||||||
switch_user = switch["username"]
|
switch_user = switch["username"]
|
||||||
switch_pass = decrypt_password(switch["password"])
|
switch_pass = decrypt_password(switch["password"])
|
||||||
else:
|
else:
|
||||||
switch_ip = ""
|
switch_ip = switch["ip"] if switch else ""
|
||||||
switch_user = ""
|
switch_user = ""
|
||||||
switch_pass = ""
|
switch_pass = ""
|
||||||
|
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hamburger {
|
.hamburger {
|
||||||
display: none;
|
display: inline-flex;
|
||||||
width: 36px; height: 36px;
|
width: 36px; height: 36px;
|
||||||
align-items: center; justify-content: center;
|
align-items: center; justify-content: center;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -273,6 +273,14 @@ button { font-family: inherit; }
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
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 {
|
.content {
|
||||||
@@ -442,11 +450,31 @@ button { font-family: inherit; }
|
|||||||
Device grid (dashboard)
|
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 {
|
.device-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 14px;
|
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 {
|
.device-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -582,7 +610,8 @@ table.data-table {
|
|||||||
|
|
||||||
.mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--text-dim); }
|
.mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--text-dim); }
|
||||||
.cell-name { font-weight: 600; }
|
.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 {
|
.empty-row td {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -590,6 +619,13 @@ table.data-table {
|
|||||||
color: var(--text-faint);
|
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
|
Forms
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|||||||
@@ -39,9 +39,14 @@
|
|||||||
function initSidebar() {
|
function initSidebar() {
|
||||||
const sidebar = document.querySelector(".sidebar");
|
const sidebar = document.querySelector(".sidebar");
|
||||||
const backdrop = document.querySelector(".sidebar-backdrop");
|
const backdrop = document.querySelector(".sidebar-backdrop");
|
||||||
|
const main = document.querySelector(".main");
|
||||||
const toggles = document.querySelectorAll("[data-sidebar-toggle]");
|
const toggles = document.querySelectorAll("[data-sidebar-toggle]");
|
||||||
if (!sidebar) return;
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
const DESKTOP_BREAKPOINT = 900;
|
||||||
|
const COLLAPSE_KEY = "poe-sidebar-collapsed";
|
||||||
|
|
||||||
|
// Mobile: temporäres Überlagern per Hamburger + Backdrop.
|
||||||
function open() {
|
function open() {
|
||||||
sidebar.classList.add("open");
|
sidebar.classList.add("open");
|
||||||
backdrop && backdrop.classList.add("open");
|
backdrop && backdrop.classList.add("open");
|
||||||
@@ -50,8 +55,22 @@
|
|||||||
sidebar.classList.remove("open");
|
sidebar.classList.remove("open");
|
||||||
backdrop && backdrop.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", () => {
|
toggles.forEach((btn) => btn.addEventListener("click", () => {
|
||||||
|
if (window.innerWidth <= DESKTOP_BREAKPOINT) {
|
||||||
sidebar.classList.contains("open") ? close() : open();
|
sidebar.classList.contains("open") ? close() : open();
|
||||||
|
} else {
|
||||||
|
setCollapsed(!sidebar.classList.contains("collapsed"));
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
backdrop && backdrop.addEventListener("click", close);
|
backdrop && backdrop.addEventListener("click", close);
|
||||||
document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close));
|
document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close));
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"share": '<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/>',
|
"share": '<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/>',
|
||||||
"users": '<circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/>',
|
"users": '<circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/>',
|
||||||
"groups": '<rect x="3" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="3" y="13" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/>',
|
"groups": '<rect x="3" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="3" y="13" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/>',
|
||||||
|
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
|
||||||
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
|
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
|
||||||
"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"/>',
|
"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"/>',
|
"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"/>',
|
||||||
@@ -59,6 +60,10 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['share']|safe }}</svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['share']|safe }}</svg>
|
||||||
Switches
|
Switches
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('credentials') }}" class="nav-item {% if active_page == 'credentials' %}active{% endif %}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['key']|safe }}</svg>
|
||||||
|
Zugangsdaten
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<a href="{{ url_for('users') }}" class="nav-item {% if active_page == 'users' %}active{% endif %}">
|
<a href="{{ url_for('users') }}" class="nav-item {% if active_page == 'users' %}active{% endif %}">
|
||||||
|
|||||||
@@ -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 %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
|
||||||
|
{% block topbar_right %}
|
||||||
|
{% if can_create %}
|
||||||
|
<button type="button" class="btn btn-primary" data-open-modal="addCredentialModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Neue Zugangsdaten
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px; max-width:720px;">
|
||||||
|
Zugangsdaten können mehreren Switchen gleichzeitig zugeordnet werden. Beim Anlegen eines
|
||||||
|
Switches lassen sich bestehende Zugangsdaten auswählen oder direkt neue hinterlegen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Name</th><th>Username</th><th>Verwendet von</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in credentials %}
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name">{{ c['name'] }}</td>
|
||||||
|
<td class="mono">{{ c['username'] }}</td>
|
||||||
|
<td class="text-dim">{{ c['switch_count'] }} Switch{{ 'e' if c['switch_count'] != 1 else '' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
{% if can_edit %}
|
||||||
|
<button class="icon-btn" title="Bearbeiten"
|
||||||
|
onclick="openEditCredentialModal({{ c['id'] }}, '{{ c['name'] }}', '{{ c['username'] }}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_delete %}
|
||||||
|
<form method="post" data-confirm="Zugangsdaten „{{ c['name'] }}“ wirklich löschen?">
|
||||||
|
<input type="hidden" name="delete_credential" value="{{ c['id'] }}">
|
||||||
|
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr class="empty-row"><td colspan="4">Noch keine Zugangsdaten vorhanden.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_create %}
|
||||||
|
<!-- Modal: Neue Zugangsdaten -->
|
||||||
|
<div class="modal-overlay" id="addCredentialModal">
|
||||||
|
<div class="modal" style="max-width:380px;">
|
||||||
|
<form method="post" onsubmit="return validateCredentialForm(this, 'add');">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Neue Zugangsdaten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="add_credential" value="1">
|
||||||
|
<div class="field"><label>Name</label>
|
||||||
|
<input type="text" name="name" required placeholder="z.B. Standard-Switch-Login">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Username</label>
|
||||||
|
<input type="text" name="username" required placeholder="z.B. admin">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Passwort</label>
|
||||||
|
<input type="password" id="password_add" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Passwort bestätigen</label>
|
||||||
|
<input type="password" id="password_confirm_add" name="password_confirm" required>
|
||||||
|
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<!-- Modal: Zugangsdaten bearbeiten -->
|
||||||
|
<div class="modal-overlay" id="editCredentialModal">
|
||||||
|
<div class="modal" style="max-width:380px;">
|
||||||
|
<form method="post" onsubmit="return validateCredentialForm(this, 'edit');">
|
||||||
|
<input type="hidden" name="edit_credential" value="1">
|
||||||
|
<input type="hidden" name="credential_id" id="edit_cred_id">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Zugangsdaten bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field"><label>Name</label>
|
||||||
|
<input type="text" name="name" id="edit_cred_name" required>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Username</label>
|
||||||
|
<input type="text" name="username" id="edit_cred_username" required>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Neues Passwort</label>
|
||||||
|
<input type="password" id="password_edit" name="password" placeholder="Nur bei Änderung ausfüllen">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Passwort bestätigen</label>
|
||||||
|
<input type="password" id="password_confirm_edit" name="password_confirm" placeholder="Bestätigen">
|
||||||
|
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function openEditCredentialModal(id, name, username) {
|
||||||
|
document.getElementById("edit_cred_id").value = id;
|
||||||
|
document.getElementById("edit_cred_name").value = name;
|
||||||
|
document.getElementById("edit_cred_username").value = username;
|
||||||
|
document.getElementById("password_edit").value = "";
|
||||||
|
document.getElementById("password_confirm_edit").value = "";
|
||||||
|
PoeUI.openModal("editCredentialModal");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCredentialForm(form, id) {
|
||||||
|
const pass = document.getElementById("password_" + id);
|
||||||
|
const confirm = document.getElementById("password_confirm_" + id);
|
||||||
|
if (!pass || !confirm) return true;
|
||||||
|
if (pass.value || confirm.value || id === "add") {
|
||||||
|
if (pass.value !== confirm.value) {
|
||||||
|
confirm.classList.add("is-invalid");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
confirm.classList.remove("is-invalid");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -12,23 +12,34 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px; max-width:720px;">
|
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px; max-width:720px;">
|
||||||
Admins dürfen immer alles. Über Gruppen lassen sich einzelne Verwaltungsrechte für
|
Über Gruppen lassen sich einzelne Verwaltungsrechte für Devices und Switches gezielt vergeben.
|
||||||
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. Auf „Rechte“ klicken,
|
||||||
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich.
|
um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{# Virtuelle Admin-"Gruppe": rein informativ, kein Formular — Admin-Status
|
<div class="table-wrap">
|
||||||
wird ausschließlich über den is_admin-Schalter auf der Users-Seite gesetzt. #}
|
<div style="overflow-x:auto;">
|
||||||
<div class="card card-pad" style="margin-bottom:16px; opacity:0.85;">
|
<table class="data-table">
|
||||||
<div class="section-head">
|
<thead><tr><th>Gruppe</th><th>Mitglieder</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||||
<div class="flex gap-2" style="align-items:center;">
|
<tbody>
|
||||||
<h3 style="font-size:16px;">Admin</h3>
|
<!-- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft
|
||||||
<span class="pill admin">Systemrolle</span>
|
wird direkt über is_admin gesteuert. -->
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name">Admin <span class="pill admin">Systemrolle</span></td>
|
||||||
|
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="icon-btn" title="Rechte anzeigen" onclick="toggleDetail('detail-admin')">
|
||||||
|
<svg id="chev-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
<p class="text-faint" style="font-size:12px; margin:-8px 0 14px;">
|
</tr>
|
||||||
Admins dürfen immer alles — Rolle wird über <a href="{{ url_for('users') }}" style="color:var(--accent-strong); font-weight:600;">Users</a> vergeben, nicht hier.
|
<tr class="group-detail-row hidden" id="detail-admin">
|
||||||
</p>
|
<td colspan="3">
|
||||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
|
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
|
||||||
{% for cat_key, cat in permission_catalog.items() %}
|
{% for cat_key, cat in permission_catalog.items() %}
|
||||||
<div>
|
<div>
|
||||||
@@ -43,43 +54,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div>
|
|
||||||
<div class="permission-group-title">Mitglieder</div>
|
|
||||||
<p class="text-dim" style="font-size:13px;">
|
|
||||||
{% if admin_virtual_group.member_names %}{{ admin_virtual_group.member_names|join(', ') }}{% else %}Keine Admins vorhanden.{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
{% if groups %}
|
|
||||||
<div style="display:flex; flex-direction:column; gap:16px;">
|
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
<div class="card card-pad">
|
<tr>
|
||||||
|
<td class="cell-name">
|
||||||
|
{{ g.name }}
|
||||||
|
{% if g.is_default %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-dim">{{ g.member_names|length }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="icon-btn" title="Rechte anzeigen/bearbeiten" onclick="toggleDetail('detail-{{ g.id }}')">
|
||||||
|
<svg id="chev-{{ g.id }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||||
|
</button>
|
||||||
|
{% if not g.is_default %}
|
||||||
|
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte.">
|
||||||
|
<input type="hidden" name="delete_group" value="{{ g.id }}">
|
||||||
|
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
|
||||||
|
<td colspan="3">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="save_group" value="1">
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
<input type="hidden" name="permissions_submitted" value="1">
|
||||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||||
|
<input type="hidden" name="name" value="{{ g.name }}">
|
||||||
<div class="section-head">
|
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
|
||||||
<div class="field" style="margin-bottom:0; max-width:280px; flex:1;">
|
|
||||||
<label>Gruppenname</label>
|
|
||||||
<div class="flex gap-2" style="align-items:center;">
|
|
||||||
<input type="text" name="name" value="{{ g.name }}" required>
|
|
||||||
{% if g.is_default %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<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>
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% if g.is_default %}
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:-8px 0 8px;">
|
|
||||||
Standardgruppe — jeder neu angelegte Benutzer wird ihr automatisch zugeordnet. Kann nicht gelöscht werden.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px; margin-top:8px;">
|
|
||||||
{% for cat_key, cat in permission_catalog.items() %}
|
{% for cat_key, cat in permission_catalog.items() %}
|
||||||
<div>
|
<div>
|
||||||
<div class="permission-group-title">{{ cat['label'] }}</div>
|
<div class="permission-group-title">{{ cat['label'] }}</div>
|
||||||
@@ -93,10 +106,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="flex" style="justify-content:flex-end; margin-top:16px;">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
<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>
|
||||||
|
Rechte speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr class="empty-row"><td colspan="3">Noch keine weiteren Gruppen angelegt.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Modal: Admin-Mitglieder verwalten -->
|
||||||
<div class="permission-group-title">Mitglieder</div>
|
<div class="modal-overlay" id="adminMembersModal">
|
||||||
<div class="check-list" style="max-height:180px; overflow-y:auto; padding-right:4px;">
|
<div class="modal" style="max-width:380px;">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="assign_admins" value="1">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Admin-Mitglieder</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:0 0 12px;">Mindestens ein Admin muss bestehen bleiben.</p>
|
||||||
|
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
|
||||||
|
{% for u in all_users_all %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['is_admin'] %}checked{% endif %}>
|
||||||
|
{{ u['username'] }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modals: Mitglieder pro Gruppe verwalten -->
|
||||||
|
{% for g in groups %}
|
||||||
|
<div class="modal-overlay" id="membersModal{{ loop.index }}">
|
||||||
|
<div class="modal" style="max-width:380px;">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
<input type="hidden" name="members_submitted" value="1">
|
||||||
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||||
|
<input type="hidden" name="name" value="{{ g.name }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Mitglieder — {{ g.name }}</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
|
||||||
{% for u in all_users %}
|
{% for u in all_users %}
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['id'] in g.members %}checked{% endif %}>
|
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['id'] in g.members %}checked{% endif %}>
|
||||||
@@ -107,28 +177,14 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:16px; padding-top:14px; border-top:1px solid var(--border-soft);">
|
|
||||||
<span class="text-faint" style="font-size:12px;">
|
|
||||||
{% if g.member_names %}Mitglieder: {{ g.member_names|join(', ') }}{% else %}Keine Mitglieder{% endif %}
|
|
||||||
</span>
|
|
||||||
{% if not g.is_default %}
|
|
||||||
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte.">
|
|
||||||
<input type="hidden" name="delete_group" value="{{ g.id }}">
|
|
||||||
<button type="submit" class="btn btn-sm btn-danger">Gruppe löschen</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
|
||||||
Noch keine Gruppen angelegt.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Modal: Neue Gruppe -->
|
<!-- Modal: Neue Gruppe -->
|
||||||
<div class="modal-overlay" id="addGroupModal">
|
<div class="modal-overlay" id="addGroupModal">
|
||||||
@@ -143,7 +199,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
||||||
<div class="field-hint">Rechte und Mitglieder werden danach auf der Gruppenkarte eingestellt.</div>
|
<div class="field-hint">Rechte und Mitglieder werden danach über die Gruppentabelle eingestellt.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -155,3 +211,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function toggleDetail(id) {
|
||||||
|
const row = document.getElementById(id);
|
||||||
|
if (!row) return;
|
||||||
|
row.classList.toggle("hidden");
|
||||||
|
const chev = document.getElementById(id.replace("detail-", "chev-"));
|
||||||
|
if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "index" %}
|
{% set active_page = "index" %}
|
||||||
{% block page_title %}Dashboard{% endblock %}
|
{% block page_title %}Dashboard{% endblock %}
|
||||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte{{ " im Bestand" if current_user.is_authenticated else "" }}</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ device_count }} Geräte{{ " im Bestand" if current_user.is_authenticated else "" }}</div>{% endblock %}
|
||||||
{% block topbar_right %}
|
{% block topbar_right %}
|
||||||
|
<div class="search-input" style="min-width:180px;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="text" id="tileSearch" placeholder="Gerät suchen…">
|
||||||
|
</div>
|
||||||
<span class="timer-pill"><span class="dot"></span><span id="dashboard-timer">Nächste Prüfung in --s</span></span>
|
<span class="timer-pill"><span class="dot"></span><span id="dashboard-timer">Nächste Prüfung in --s</span></span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -29,9 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if devices %}
|
{% macro device_tile(d) %}
|
||||||
<div class="device-grid">
|
|
||||||
{% for d in devices %}
|
|
||||||
{% set st = status.get(d['mac'], 'unbekannt') %}
|
{% set st = status.get(d['mac'], 'unbekannt') %}
|
||||||
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %} {% if not current_user.is_authenticated %}is-readonly{% endif %}"
|
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %} {% if not current_user.is_authenticated %}is-readonly{% endif %}"
|
||||||
data-status="{{ 'offline' if d['is_active'] == 0 else st }}"
|
data-status="{{ 'offline' if d['is_active'] == 0 else st }}"
|
||||||
@@ -56,8 +58,39 @@
|
|||||||
<div class="dc-name">{{ d['name'] }}</div>
|
<div class="dc-name">{{ d['name'] }}</div>
|
||||||
<div class="dc-ip">{{ d['rpi_ip'] }}</div>
|
<div class="dc-ip">{{ d['rpi_ip'] }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% if device_count %}
|
||||||
|
|
||||||
|
{% if offline_devices %}
|
||||||
|
<div class="dash-section">
|
||||||
|
<div class="dash-section-title">Offline <span class="pill offline">{{ offline_devices|length }}</span></div>
|
||||||
|
<div class="device-grid">
|
||||||
|
{% for d in offline_devices %}{{ device_tile(d) }}{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if online_devices %}
|
||||||
|
<div class="dash-section">
|
||||||
|
<div class="dash-section-title">Online <span class="pill online">{{ online_devices|length }}</span></div>
|
||||||
|
<div class="device-grid">
|
||||||
|
{% for d in online_devices %}{{ device_tile(d) }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if disabled_devices %}
|
||||||
|
<div class="dash-section">
|
||||||
|
<div class="dash-section-title">Deaktiviert <span class="pill disabled">{{ disabled_devices|length }}</span></div>
|
||||||
|
<div class="device-grid">
|
||||||
|
{% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p id="noSearchResults" class="text-faint hidden" style="text-align:center; padding:30px 0;">Kein Gerät gefunden.</p>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
@@ -95,8 +128,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
||||||
|
{% if current_user.has_permission('devices.toggle') %}
|
||||||
|
<button type="button" class="btn btn-success" id="activateButton" style="display:none;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L20 7"/></svg>
|
||||||
|
Aktivieren
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% if current_user.has_permission('devices.restart') %}
|
{% if current_user.has_permission('devices.restart') %}
|
||||||
<button type="button" class="btn btn-primary" id="restartButton">
|
<button type="button" class="btn btn-primary" id="restartButton" style="display:none;">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||||
Neustarten
|
Neustarten
|
||||||
</button>
|
</button>
|
||||||
@@ -153,11 +192,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
setInterval(updateTimer, 1000);
|
setInterval(updateTimer, 1000);
|
||||||
updateTimer();
|
updateTimer();
|
||||||
|
|
||||||
|
// Suchfilter über alle Abschnitte hinweg; Abschnitt wird komplett
|
||||||
|
// ausgeblendet, wenn keine seiner Kacheln mehr passt.
|
||||||
|
const searchInput = document.getElementById("tileSearch");
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener("input", () => {
|
||||||
|
const q = searchInput.value.trim().toLowerCase();
|
||||||
|
let anyVisible = false;
|
||||||
|
document.querySelectorAll(".dash-section").forEach(section => {
|
||||||
|
let sectionHasMatch = false;
|
||||||
|
section.querySelectorAll(".device-card").forEach(card => {
|
||||||
|
const match = card.dataset.name.toLowerCase().includes(q);
|
||||||
|
card.classList.toggle("hidden", !match);
|
||||||
|
if (match) sectionHasMatch = true;
|
||||||
|
});
|
||||||
|
section.classList.toggle("hidden", !sectionHasMatch);
|
||||||
|
if (sectionHasMatch) anyVisible = true;
|
||||||
|
});
|
||||||
|
const noResults = document.getElementById("noSearchResults");
|
||||||
|
if (noResults) noResults.classList.toggle("hidden", anyVisible || q === "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedCard = null, selectedMac = null, selectedName = null;
|
let selectedCard = null, selectedMac = null, selectedName = null;
|
||||||
|
const restartButton = document.getElementById("restartButton");
|
||||||
|
const activateButton = document.getElementById("activateButton");
|
||||||
|
|
||||||
document.querySelectorAll(".device-card").forEach(card => {
|
document.querySelectorAll(".device-card").forEach(card => {
|
||||||
const mac = card.dataset.mac;
|
const mac = card.dataset.mac;
|
||||||
@@ -171,11 +234,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
card.addEventListener("click", function () {
|
card.addEventListener("click", function () {
|
||||||
if (this.dataset.active == "0") return;
|
|
||||||
if (sessionStorage.getItem("restart_" + this.dataset.mac)) return;
|
if (sessionStorage.getItem("restart_" + this.dataset.mac)) return;
|
||||||
selectedCard = this;
|
selectedCard = this;
|
||||||
selectedMac = this.dataset.mac;
|
selectedMac = this.dataset.mac;
|
||||||
selectedName = this.dataset.name;
|
selectedName = this.dataset.name;
|
||||||
|
const isActive = this.dataset.active != "0";
|
||||||
document.getElementById("deviceModalTitle").innerText = this.dataset.name;
|
document.getElementById("deviceModalTitle").innerText = this.dataset.name;
|
||||||
document.getElementById("deviceIp").innerText = this.dataset.ip || "-";
|
document.getElementById("deviceIp").innerText = this.dataset.ip || "-";
|
||||||
document.getElementById("deviceSwitch").innerText = this.dataset.switch || "-";
|
document.getElementById("deviceSwitch").innerText = this.dataset.switch || "-";
|
||||||
@@ -183,11 +246,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
document.getElementById("deviceChecked").innerText = this.dataset.checked || "-";
|
document.getElementById("deviceChecked").innerText = this.dataset.checked || "-";
|
||||||
const pill = this.querySelector(".pill");
|
const pill = this.querySelector(".pill");
|
||||||
document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-";
|
document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-";
|
||||||
|
if (restartButton) restartButton.style.display = isActive ? "" : "none";
|
||||||
|
if (activateButton) activateButton.style.display = isActive ? "none" : "";
|
||||||
PoeUI.openModal("deviceModal");
|
PoeUI.openModal("deviceModal");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const restartButton = document.getElementById("restartButton");
|
|
||||||
if (restartButton) {
|
if (restartButton) {
|
||||||
restartButton.addEventListener("click", function () {
|
restartButton.addEventListener("click", function () {
|
||||||
PoeUI.closeModal(document.getElementById("deviceModal"));
|
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||||
@@ -219,6 +283,26 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
.catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); });
|
.catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activateButton) {
|
||||||
|
activateButton.addEventListener("click", function () {
|
||||||
|
const button = this;
|
||||||
|
button.disabled = true;
|
||||||
|
fetch("/devices/toggle/" + encodeURIComponent(selectedMac), { method: "POST" })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
button.disabled = false;
|
||||||
|
if (!data.success) {
|
||||||
|
showToast(data.msg || "Aktivieren fehlgeschlagen.", "danger");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||||
|
showToast(data.msg, "success");
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
})
|
||||||
|
.catch(() => { button.disabled = false; showToast("Fehler beim Aktivieren.", "danger"); });
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
||||||
{% block topbar_right %}
|
{% block topbar_right %}
|
||||||
{% if can_create %}
|
{% if can_create %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addSwitchModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addSwitchModal" onclick="resetCredentialChoice('add')">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
Neuer Switch
|
Neuer Switch
|
||||||
</button>
|
</button>
|
||||||
@@ -29,18 +29,24 @@
|
|||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="data-table" id="switchesTable">
|
<table class="data-table" id="switchesTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Hostname</th><th>IP-Adresse</th><th>Username</th><th style="width:1%;">Aktionen</th></tr>
|
<tr><th>Hostname</th><th>IP-Adresse</th><th>Zugangsdaten</th><th style="width:1%;">Aktionen</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for s in switches %}
|
{% for s in switches %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="cell-name">{{ s['hostname'] }}</td>
|
<td class="cell-name">{{ s['hostname'] }}</td>
|
||||||
<td class="mono">{{ s['ip'] }}</td>
|
<td class="mono">{{ s['ip'] }}</td>
|
||||||
<td>{{ s['username'] }}</td>
|
<td>
|
||||||
|
{% if s['credential_name'] %}
|
||||||
|
{{ s['credential_name'] }} <span class="text-faint mono" style="font-size:11.5px;">({{ s['credential_username'] }})</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-faint">— keine —</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button class="icon-btn" title="Bearbeiten" data-open-modal="editSwitchModal{{ loop.index }}">
|
<button class="icon-btn" title="Bearbeiten" data-open-modal="editSwitchModal{{ loop.index }}" onclick="resetCredentialChoice('edit{{ loop.index }}')">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -58,7 +64,7 @@
|
|||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
|
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<form method="post" onsubmit="return validateSwitchForm(this, '{{ loop.index }}');">
|
<form method="post" onsubmit="return validateSwitchForm(this, 'edit{{ loop.index }}');">
|
||||||
<input type="hidden" name="edit_switch" value="1">
|
<input type="hidden" name="edit_switch" value="1">
|
||||||
<input type="hidden" name="old_hostname" value="{{ s['hostname'] }}">
|
<input type="hidden" name="old_hostname" value="{{ s['hostname'] }}">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -73,17 +79,31 @@
|
|||||||
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
||||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Username</label>
|
<div class="field">
|
||||||
<input type="text" name="username" value="{{ s['username'] }}" required>
|
<label>Zugangsdaten</label>
|
||||||
|
<select name="credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
|
||||||
|
{% for c in all_credentials %}
|
||||||
|
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}" {% if c['id'] == s['credential_id'] %}selected{% endif %}>{{ c['name'] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="new">+ Neue Zugangsdaten anlegen</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Neues Passwort</label>
|
<div class="new-credential-fields hidden">
|
||||||
<input type="password" id="password_edit_{{ loop.index }}" name="password" placeholder="Nur bei Änderung ausfüllen">
|
<div class="field"><label>Name der Zugangsdaten</label>
|
||||||
|
<input type="text" name="new_credential_name" placeholder="z.B. Lager-Switche">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Username</label>
|
||||||
|
<input type="text" name="new_credential_username" placeholder="z.B. admin">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Passwort</label>
|
||||||
|
<input type="password" id="password_edit{{ loop.index }}" name="new_credential_password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Passwort bestätigen</label>
|
<div class="field"><label>Passwort bestätigen</label>
|
||||||
<input type="password" id="password_confirm_edit_{{ loop.index }}" name="password_confirm" placeholder="Bestätigen">
|
<input type="password" id="password_confirm_edit{{ loop.index }}" name="new_credential_password_confirm">
|
||||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||||
@@ -122,17 +142,34 @@
|
|||||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
|
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
|
||||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Zugangsdaten</label>
|
||||||
|
<select name="credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
|
||||||
|
{% if not all_credentials %}<option value="new" selected>+ Neue Zugangsdaten anlegen</option>
|
||||||
|
{% else %}
|
||||||
|
{% for c in all_credentials %}
|
||||||
|
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}">{{ c['name'] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="new">+ Neue Zugangsdaten anlegen</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="new-credential-fields {% if all_credentials %}hidden{% endif %}">
|
||||||
|
<div class="field"><label>Name der Zugangsdaten</label>
|
||||||
|
<input type="text" name="new_credential_name" placeholder="z.B. Lager-Switche">
|
||||||
|
</div>
|
||||||
<div class="field"><label>Username</label>
|
<div class="field"><label>Username</label>
|
||||||
<input type="text" name="username" required placeholder="z.B. admin">
|
<input type="text" name="new_credential_username" placeholder="z.B. admin">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Passwort</label>
|
<div class="field"><label>Passwort</label>
|
||||||
<input type="password" id="password_add" name="password" required>
|
<input type="password" id="password_add" name="new_credential_password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Passwort bestätigen</label>
|
<div class="field"><label>Passwort bestätigen</label>
|
||||||
<input type="password" id="password_confirm_add" name="password_confirm" required>
|
<input type="password" id="password_confirm_add" name="new_credential_password_confirm">
|
||||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||||
@@ -171,6 +208,7 @@
|
|||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<p class="text-faint" style="font-size:11.5px; margin-right:auto;">
|
<p class="text-faint" style="font-size:11.5px; margin-right:auto;">
|
||||||
Erste Verbindung? Bestätige den Host-Key mit „yes“, gib danach das Passwort ein — direkt hier im Terminal.
|
Erste Verbindung? Bestätige den Host-Key mit „yes“, gib danach das Passwort ein — direkt hier im Terminal.
|
||||||
|
Bei bestehenden Zugangsdaten ist das Passwort hier nicht bekannt (verschlüsselt gespeichert) — bitte manuell eingeben.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" class="btn btn-secondary" data-close-modal onclick="closeTerminal()">Schließen</button>
|
<button type="button" class="btn btn-secondary" data-close-modal onclick="closeTerminal()">Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,6 +222,23 @@
|
|||||||
<script src="{{ url_for('static', filename='js/vendor/xterm.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/vendor/xterm.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/vendor/xterm-addon-fit.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/vendor/xterm-addon-fit.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Zugangsdaten-Auswahl: "+ Neue Zugangsdaten anlegen" blendet die Felder ein
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
function toggleNewCredentialFields(select) {
|
||||||
|
const fields = select.closest(".modal-body").querySelector(".new-credential-fields");
|
||||||
|
if (fields) fields.classList.toggle("hidden", select.value !== "new");
|
||||||
|
}
|
||||||
|
function resetCredentialChoice(id) {
|
||||||
|
// Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend
|
||||||
|
// zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach
|
||||||
|
// vorherigem Umschalten auf "neu" ohne zu speichern).
|
||||||
|
setTimeout(() => {
|
||||||
|
const select = document.querySelector(`#${id === 'add' ? 'addSwitchModal' : 'editSwitchModal' + id.replace('edit','')} .credential-select`);
|
||||||
|
if (select) toggleNewCredentialFields(select);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal)
|
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -221,13 +276,25 @@ function setTermStatus(text, cls) {
|
|||||||
el.innerText = text;
|
el.innerText = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCredentialInfo(form) {
|
||||||
|
const select = form.querySelector(".credential-select");
|
||||||
|
if (!select || select.value === "new") {
|
||||||
|
return {
|
||||||
|
username: (form.querySelector("input[name='new_credential_username']") || {}).value?.trim(),
|
||||||
|
passwordInput: form.querySelector("input[name='new_credential_password']"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const option = select.selectedOptions[0];
|
||||||
|
return { username: option ? option.dataset.username : null, passwordInput: null };
|
||||||
|
}
|
||||||
|
|
||||||
function openTerminal(form) {
|
function openTerminal(form) {
|
||||||
const host = (form.querySelector("input[name='ip']") || {}).value?.trim();
|
const host = (form.querySelector("input[name='ip']") || {}).value?.trim();
|
||||||
const username = (form.querySelector("input[name='username']") || {}).value?.trim();
|
const { username, passwordInput } = getCredentialInfo(form);
|
||||||
activePasswordInput = form.querySelector("input[name='password']");
|
activePasswordInput = passwordInput;
|
||||||
|
|
||||||
if (!host || !username) {
|
if (!host || !username) {
|
||||||
showToast("Bitte IP-Adresse und Username ausfüllen, bevor du die Verbindung testest.", "danger");
|
showToast("Bitte IP-Adresse ausfüllen und Zugangsdaten auswählen/anlegen, bevor du die Verbindung testest.", "danger");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +334,7 @@ const termPasteBtn = document.getElementById("termPastePassword");
|
|||||||
if (termPasteBtn) {
|
if (termPasteBtn) {
|
||||||
termPasteBtn.addEventListener("click", () => {
|
termPasteBtn.addEventListener("click", () => {
|
||||||
if (!activePasswordInput || !activePasswordInput.value) {
|
if (!activePasswordInput || !activePasswordInput.value) {
|
||||||
showToast("Kein Passwort im Formular eingetragen.", "danger");
|
showToast("Kein Passwort bekannt — bitte manuell im Terminal eingeben.", "danger");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
|
if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
|
||||||
@@ -297,9 +364,11 @@ function validateIP(input) {
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePassword(id, isEdit) {
|
function validateNewCredentialPassword(form) {
|
||||||
const pass = document.getElementById(isEdit ? `password_edit_${id}` : "password_add");
|
const select = form.querySelector(".credential-select");
|
||||||
const confirm = document.getElementById(isEdit ? `password_confirm_edit_${id}` : "password_confirm_add");
|
if (!select || select.value !== "new") return true;
|
||||||
|
const pass = form.querySelector("input[name='new_credential_password']");
|
||||||
|
const confirm = form.querySelector("input[name='new_credential_password_confirm']");
|
||||||
if (!pass || !confirm) return true;
|
if (!pass || !confirm) return true;
|
||||||
if (pass.value !== confirm.value) { confirm.classList.add("is-invalid"); return false; }
|
if (pass.value !== confirm.value) { confirm.classList.add("is-invalid"); return false; }
|
||||||
confirm.classList.remove("is-invalid");
|
confirm.classList.remove("is-invalid");
|
||||||
@@ -310,15 +379,15 @@ function validateSwitchForm(form, id) {
|
|||||||
const ipInput = form.querySelector("input[name='ip']");
|
const ipInput = form.querySelector("input[name='ip']");
|
||||||
let valid = true;
|
let valid = true;
|
||||||
if (ipInput) valid = validateIP(ipInput) && valid;
|
if (ipInput) valid = validateIP(ipInput) && valid;
|
||||||
valid = validatePassword(id, id !== "add") && valid;
|
valid = validateNewCredentialPassword(form) && valid;
|
||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("input", (e) => {
|
document.addEventListener("input", (e) => {
|
||||||
if (e.target.name === "ip") validateIP(e.target);
|
if (e.target.name === "ip") validateIP(e.target);
|
||||||
if (e.target.id.startsWith("password_confirm")) {
|
if (e.target.name === "new_credential_password_confirm") {
|
||||||
const id = e.target.id.replace("password_confirm_", "");
|
const form = e.target.closest("form");
|
||||||
const pass = document.getElementById("password_" + id);
|
const pass = form.querySelector("input[name='new_credential_password']");
|
||||||
if (pass) e.target.classList.toggle("is-invalid", pass.value !== e.target.value);
|
if (pass) e.target.classList.toggle("is-invalid", pass.value !== e.target.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,17 +14,31 @@
|
|||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead><tr><th>Username</th><th>Rolle</th><th>Gruppen</th><th style="width:1%;">Aktionen</th></tr></thead>
|
<thead><tr><th>Username</th><th>Name</th><th>Gruppe</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="cell-name">{{ u['username'] }}</td>
|
<td class="cell-name">{{ u['username'] }}</td>
|
||||||
<td><span class="pill {{ 'admin' if u['is_admin'] else 'user' }}">{{ "Admin" if u['is_admin'] else "User" }}</span></td>
|
<td class="text-dim">
|
||||||
<td class="text-dim">{{ u['group_names'] or '—' }}</td>
|
{% if u['first_name'] or u['last_name'] %}{{ [u['first_name'], u['last_name']]|select|join(' ') }}{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if u['is_admin'] %}
|
||||||
|
<span class="pill admin">Admin</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-dim">{{ u['group_names'] or '—' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="btn btn-sm btn-secondary" onclick="openRoleModal({{ u['id'] }}, '{{ u['username'] }}', {{ u['is_admin'] }})">Rolle</button>
|
<button class="icon-btn" title="Bearbeiten"
|
||||||
<button class="btn btn-sm btn-secondary" onclick="openPasswordModal({{ u['id'] }}, '{{ u['username'] }}')">Passwort</button>
|
onclick="openEditModal({{ u['id'] }}, '{{ u['username'] }}', '{{ u['first_name'] or '' }}', '{{ u['last_name'] or '' }}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="Gruppe zuweisen"
|
||||||
|
onclick="openGroupModal({{ u['id'] }}, '{{ 'admin' if u['is_admin'] else (u['group_id'] or '') }}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/></svg>
|
||||||
|
</button>
|
||||||
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich löschen?">
|
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich löschen?">
|
||||||
<button type="submit" name="delete_user" value="{{ u['id'] }}" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
<button type="submit" name="delete_user" value="{{ u['id'] }}" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||||
@@ -50,10 +64,16 @@
|
|||||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<div class="field"><label>Vorname</label><input type="text" name="first_name"></div>
|
||||||
|
<div class="field"><label>Name</label><input type="text" name="last_name"></div>
|
||||||
<div class="field"><label>Username</label><input type="text" name="username" required></div>
|
<div class="field"><label>Username</label><input type="text" name="username" required></div>
|
||||||
<div class="field"><label>Passwort</label><input type="password" name="password" required></div>
|
<div class="field"><label>Passwort</label><input type="password" name="password" required></div>
|
||||||
<div class="field"><label>Rolle</label>
|
<div class="field">
|
||||||
<select name="is_admin"><option value="0">User</option><option value="1">Admin</option></select>
|
<label>Gruppe</label>
|
||||||
|
<select name="group_id">
|
||||||
|
{% for g in all_groups %}<option value="{{ g['id'] }}" {% if g['is_default'] %}selected{% endif %}>{{ g['name'] }}</option>{% endfor %}
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -64,45 +84,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal: Rolle ändern -->
|
<!-- Modal: Benutzer bearbeiten (Stammdaten + optional neues Passwort;
|
||||||
<div class="modal-overlay" id="roleModal">
|
Gruppe/Rolle wird ausschließlich über "Gruppe zuweisen" geändert) -->
|
||||||
|
<div class="modal-overlay" id="editModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<form method="post" id="roleForm">
|
<form method="post" id="editForm">
|
||||||
<input type="hidden" name="user_id" id="role_user_id">
|
<input type="hidden" name="user_id" id="edit_user_id">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Rolle ändern</h3>
|
<h3>Benutzer bearbeiten</h3>
|
||||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="field"><label>Username</label><input type="text" name="username" id="role_username" required></div>
|
<div class="field"><label>Vorname</label><input type="text" name="first_name" id="edit_first_name"></div>
|
||||||
<div class="field"><label>Rolle</label>
|
<div class="field"><label>Name</label><input type="text" name="last_name" id="edit_last_name"></div>
|
||||||
<select name="is_admin" id="role_is_admin"><option value="0">User</option><option value="1">Admin</option></select>
|
<div class="field"><label>Username</label><input type="text" name="username" id="edit_username" required></div>
|
||||||
|
<div class="field"><label>Neues Passwort</label>
|
||||||
|
<input type="password" name="new_password" placeholder="Nur bei Änderung ausfüllen">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
<button type="submit" name="change_role" value="1" class="btn btn-primary">Speichern</button>
|
<button type="submit" name="edit_user" value="1" class="btn btn-primary">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal: Passwort ändern -->
|
<!-- Modal: Gruppe zuweisen (inkl. Admin als Auswahl) -->
|
||||||
<div class="modal-overlay" id="passwordModal">
|
<div class="modal-overlay" id="groupModal">
|
||||||
<div class="modal">
|
<div class="modal" style="max-width:380px;">
|
||||||
<form method="post" id="passwordForm">
|
<form method="post" id="groupForm">
|
||||||
<input type="hidden" name="user_id" id="password_user_id">
|
<input type="hidden" name="user_id" id="group_user_id">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Passwort ändern</h3>
|
<h3>Gruppe zuweisen</h3>
|
||||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="field"><label>Username</label><input type="text" name="username" id="password_username" readonly></div>
|
<div class="field">
|
||||||
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" required></div>
|
<label>Gruppe</label>
|
||||||
|
<select name="group_id" id="group_select">
|
||||||
|
<option value="">Keine Gruppe</option>
|
||||||
|
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
<div class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
<button type="submit" name="change_password" value="1" class="btn btn-primary">Ändern</button>
|
<button type="submit" name="assign_group" value="1" class="btn btn-primary">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,17 +142,18 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
function openRoleModal(userId, username, isAdmin) {
|
function openEditModal(userId, username, firstName, lastName) {
|
||||||
document.getElementById("role_user_id").value = userId;
|
document.getElementById("edit_user_id").value = userId;
|
||||||
document.getElementById("role_username").value = username;
|
document.getElementById("edit_username").value = username;
|
||||||
document.getElementById("role_is_admin").value = isAdmin;
|
document.getElementById("edit_first_name").value = firstName;
|
||||||
PoeUI.openModal("roleModal");
|
document.getElementById("edit_last_name").value = lastName;
|
||||||
|
document.querySelector("#editForm input[name='new_password']").value = "";
|
||||||
|
PoeUI.openModal("editModal");
|
||||||
}
|
}
|
||||||
function openPasswordModal(userId, username) {
|
function openGroupModal(userId, groupChoice) {
|
||||||
document.getElementById("password_user_id").value = userId;
|
document.getElementById("group_user_id").value = userId;
|
||||||
document.getElementById("password_username").value = username;
|
document.getElementById("group_select").value = groupChoice || "";
|
||||||
document.querySelector("#passwordForm input[name='new_password']").value = "";
|
PoeUI.openModal("groupModal");
|
||||||
PoeUI.openModal("passwordModal");
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user