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:
+330
-96
@@ -203,6 +203,48 @@ def _ensure_schema():
|
||||
if "is_default" not in existing_cols:
|
||||
conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0")
|
||||
|
||||
# Migration: Vor-/Nachname für Benutzer nachrüsten.
|
||||
user_cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
if "first_name" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT")
|
||||
if "last_name" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT")
|
||||
|
||||
# Zugangsdaten (Credentials): eigenständige, wiederverwendbare
|
||||
# SSH-Logins für Switche, statt Username/Passwort direkt am Switch.
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
switch_cols = {row["name"] for row in conn.execute("PRAGMA table_info(switches)").fetchall()}
|
||||
if "credential_id" not in switch_cols:
|
||||
conn.execute("ALTER TABLE switches ADD COLUMN credential_id INTEGER")
|
||||
|
||||
# Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten
|
||||
# (ältere DB-Version) in eigene Credentials-Datensätze überführen.
|
||||
if "username" in switch_cols and "password" in switch_cols:
|
||||
legacy_switches = conn.execute(
|
||||
"SELECT hostname, username, password FROM switches WHERE credential_id IS NULL"
|
||||
).fetchall()
|
||||
for sw in legacy_switches:
|
||||
base_name = f"{sw['hostname']} (migriert)"
|
||||
final_name, suffix = base_name, 1
|
||||
while conn.execute("SELECT 1 FROM credentials WHERE name=?", (final_name,)).fetchone():
|
||||
suffix += 1
|
||||
final_name = f"{base_name} {suffix}"
|
||||
cur = conn.execute(
|
||||
"INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)",
|
||||
(final_name, sw["username"], sw["password"]),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE switches SET credential_id=? WHERE hostname=?",
|
||||
(cur.lastrowid, sw["hostname"]),
|
||||
)
|
||||
|
||||
# Standardgruppe 'Benutzer' sicherstellen. Die View-Rechte werden nur bei
|
||||
# der *erstmaligen* Erzeugung gesetzt — spätere Anpassungen durch einen
|
||||
# Admin (z.B. ein Recht wieder entziehen) bleiben so über Neustarts hinweg
|
||||
@@ -437,12 +479,12 @@ def get_device_status(devices):
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""
|
||||
Dashboard als Kachel-Ansicht. Ohne Login: nur aktive (nicht deaktivierte)
|
||||
Geräte, nur Online/Offline/Gesamt-Statistik, keine Interaktion.
|
||||
Eingeloggt: alle Geräte (inkl. deaktivierte), volle Statistik-Kacheln,
|
||||
Klick-Details und manueller PoE-Neustart. In beiden Fällen: erst alle
|
||||
Nicht-Online-Geräte (alphabetisch), danach alle Online-Geräte
|
||||
(alphabetisch).
|
||||
Dashboard als Kachel-Ansicht, in drei Abschnitten: Offline, Online,
|
||||
Deaktiviert (jeweils alphabetisch) — jeweils nur gerendert, wenn nicht
|
||||
leer. Ohne Login: keine deaktivierten Geräte, nur Online/Offline/Gesamt-
|
||||
Statistik, keine Interaktion. Eingeloggt: alle Geräte, volle
|
||||
Statistik-Kacheln, Klick-Details, manueller PoE-Neustart bzw. bei
|
||||
deaktivierten Geräten eine Aktivieren-Option.
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
all_devices = conn.execute(
|
||||
@@ -452,26 +494,34 @@ def index():
|
||||
|
||||
interval = int(get_setting("interval", 5))
|
||||
status, last_seen, last_checked, last_run_at = get_device_status(all_devices)
|
||||
|
||||
is_authenticated = current_user.is_authenticated
|
||||
visible_devices = all_devices if is_authenticated else [d for d in all_devices if d["is_active"]]
|
||||
|
||||
def sort_key(d):
|
||||
is_online = bool(d["is_active"]) and status.get(d["mac"]) == "online"
|
||||
return (1 if is_online else 0, d["name"].lower())
|
||||
def by_name(devs):
|
||||
return sorted(devs, key=lambda d: d["name"].lower())
|
||||
|
||||
devices = sorted(visible_devices, key=sort_key)
|
||||
offline_devices = by_name(
|
||||
d for d in all_devices if d["is_active"] and status.get(d["mac"]) != "online"
|
||||
)
|
||||
online_devices = by_name(
|
||||
d for d in all_devices if d["is_active"] and status.get(d["mac"]) == "online"
|
||||
)
|
||||
disabled_devices = by_name(d for d in all_devices if not d["is_active"]) if is_authenticated else []
|
||||
|
||||
online = sum(1 for d in visible_devices if d["is_active"] and status.get(d["mac"]) == "online")
|
||||
offline = sum(1 for d in visible_devices if d["is_active"] and status.get(d["mac"]) != "online")
|
||||
stats = {"online": online, "offline": offline, "total": len(visible_devices)}
|
||||
visible_devices = offline_devices + online_devices + disabled_devices
|
||||
devices = offline_devices + online_devices # für Kompatibilität/Zähler
|
||||
|
||||
stats = {"online": len(online_devices), "offline": len(offline_devices), "total": len(devices)}
|
||||
if is_authenticated:
|
||||
stats["disabled"] = sum(1 for d in all_devices if not d["is_active"])
|
||||
stats["total"] = len(all_devices)
|
||||
stats["disabled"] = len(disabled_devices)
|
||||
|
||||
last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None
|
||||
|
||||
return render_template(
|
||||
"index.html", devices=devices, status=status, last_seen=last_seen,
|
||||
"index.html",
|
||||
offline_devices=offline_devices, online_devices=online_devices, disabled_devices=disabled_devices,
|
||||
device_count=len(visible_devices),
|
||||
status=status, last_seen=last_seen,
|
||||
last_checked=last_checked, interval=interval, stats=stats,
|
||||
last_run_epoch_ms=last_run_epoch_ms,
|
||||
)
|
||||
@@ -670,6 +720,34 @@ def toggle_device(mac):
|
||||
# Switches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_credential_choice(conn):
|
||||
"""
|
||||
Liest die Zugangsdaten-Auswahl aus einem Switch-Formular: entweder eine
|
||||
bestehende credential_id, oder (choice == "new") legt direkt aus dem
|
||||
Switch-Formular heraus neue Zugangsdaten an — damit man beim Anlegen
|
||||
eines Switches nicht zuerst zu "Zugangsdaten" wechseln muss.
|
||||
Gibt (credential_id, error_message) zurück; error_message ist None bei Erfolg.
|
||||
"""
|
||||
choice = request.form.get("credential_choice", "")
|
||||
if choice == "new":
|
||||
name = request.form.get("new_credential_name", "").strip()
|
||||
username = request.form.get("new_credential_username", "").strip()
|
||||
password = request.form.get("new_credential_password", "")
|
||||
if not (name and username and password):
|
||||
return None, "Für neue Zugangsdaten müssen Name, Username und Passwort ausgefüllt sein!"
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)",
|
||||
(name, username, encrypt_password(password)),
|
||||
)
|
||||
return cur.lastrowid, None
|
||||
except sqlite3.IntegrityError:
|
||||
return None, "Es existieren bereits Zugangsdaten mit diesem Namen!"
|
||||
elif choice.isdigit():
|
||||
return int(choice), None
|
||||
return None, "Bitte Zugangsdaten auswählen oder neue anlegen."
|
||||
|
||||
|
||||
@app.route("/switches", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def switches():
|
||||
@@ -685,17 +763,19 @@ def switches():
|
||||
return redirect(url_for("switches"))
|
||||
hostname = request.form["hostname"]
|
||||
ip = request.form["ip"]
|
||||
username = request.form["username"]
|
||||
password = encrypt_password(request.form["password"])
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO switches (hostname, ip, username, password) VALUES (?, ?, ?, ?)",
|
||||
(hostname, ip, username, password),
|
||||
)
|
||||
conn.commit()
|
||||
flash(f"Switch {hostname} hinzugefügt.", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Hostname existiert bereits oder Eingabefehler!", "danger")
|
||||
credential_id, cred_error = _resolve_credential_choice(conn)
|
||||
if cred_error:
|
||||
flash(cred_error, "danger")
|
||||
else:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)",
|
||||
(hostname, ip, credential_id),
|
||||
)
|
||||
conn.commit()
|
||||
flash(f"Switch {hostname} hinzugefügt.", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Hostname existiert bereits oder Eingabefehler!", "danger")
|
||||
|
||||
if request.method == "POST" and "edit_switch" in request.form:
|
||||
if not current_user.has_permission("switches.edit"):
|
||||
@@ -704,33 +784,35 @@ def switches():
|
||||
old_hostname = request.form["old_hostname"]
|
||||
hostname = request.form["hostname"]
|
||||
ip = request.form["ip"]
|
||||
username = request.form["username"]
|
||||
new_password = request.form.get("password")
|
||||
credential_id, cred_error = _resolve_credential_choice(conn)
|
||||
if cred_error:
|
||||
flash(cred_error, "danger")
|
||||
else:
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE switches SET hostname=?, ip=?, credential_id=? WHERE hostname=?",
|
||||
(hostname, ip, credential_id, old_hostname),
|
||||
)
|
||||
if hostname != old_hostname:
|
||||
conn.execute(
|
||||
"UPDATE devices SET switch_hostname=? WHERE switch_hostname=?",
|
||||
(hostname, old_hostname),
|
||||
)
|
||||
conn.commit()
|
||||
flash(f"Switch {hostname} aktualisiert.", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Hostname existiert bereits oder Eingabefehler!", "danger")
|
||||
|
||||
try:
|
||||
if new_password:
|
||||
conn.execute(
|
||||
"UPDATE switches SET hostname=?, ip=?, username=?, password=? WHERE hostname=?",
|
||||
(hostname, ip, username, encrypt_password(new_password), old_hostname),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE switches SET hostname=?, ip=?, username=? WHERE hostname=?",
|
||||
(hostname, ip, username, old_hostname),
|
||||
)
|
||||
if hostname != old_hostname:
|
||||
conn.execute(
|
||||
"UPDATE devices SET switch_hostname=? WHERE switch_hostname=?",
|
||||
(hostname, old_hostname),
|
||||
)
|
||||
conn.commit()
|
||||
flash(f"Switch {hostname} aktualisiert.", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Hostname existiert bereits oder Eingabefehler!", "danger")
|
||||
|
||||
switch_rows = conn.execute("SELECT hostname, ip, username FROM switches ORDER BY hostname ASC").fetchall()
|
||||
switch_rows = conn.execute("""
|
||||
SELECT switches.hostname, switches.ip, switches.credential_id,
|
||||
credentials.name AS credential_name, credentials.username AS credential_username
|
||||
FROM switches
|
||||
LEFT JOIN credentials ON credentials.id = switches.credential_id
|
||||
ORDER BY switches.hostname ASC
|
||||
""").fetchall()
|
||||
all_credentials = conn.execute("SELECT id, name, username FROM credentials ORDER BY name ASC").fetchall()
|
||||
conn.close()
|
||||
return render_template("switches.html", switches=switch_rows)
|
||||
return render_template("switches.html", switches=switch_rows, all_credentials=all_credentials)
|
||||
|
||||
|
||||
@app.route("/switches/delete/<hostname>", methods=["POST"])
|
||||
@@ -755,6 +837,94 @@ def delete_switch(hostname):
|
||||
return redirect(url_for("switches"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zugangsdaten (Credentials) — wiederverwendbare SSH-Logins für Switche,
|
||||
# damit nicht jeder Switch sein eigenes Login braucht. Gleiche Berechtigungen
|
||||
# wie Switch-Verwaltung (switches.*), da inhaltlich untrennbar davon.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route("/credentials", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def credentials():
|
||||
if not current_user.can_manage_switches:
|
||||
flash("Keine Berechtigung für die Zugangsdatenverwaltung.", "danger")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
conn = get_db_connection()
|
||||
|
||||
if request.method == "POST" and "add_credential" in request.form:
|
||||
if not current_user.has_permission("switches.create"):
|
||||
flash("Keine Berechtigung, Zugangsdaten anzulegen.", "danger")
|
||||
return redirect(url_for("credentials"))
|
||||
name = request.form.get("name", "").strip()
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
if name and username and password:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)",
|
||||
(name, username, encrypt_password(password)),
|
||||
)
|
||||
conn.commit()
|
||||
flash(f"Zugangsdaten '{name}' angelegt.", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger")
|
||||
else:
|
||||
flash("Name, Username und Passwort müssen ausgefüllt sein!", "danger")
|
||||
|
||||
elif request.method == "POST" and "edit_credential" in request.form:
|
||||
if not current_user.has_permission("switches.edit"):
|
||||
flash("Keine Berechtigung, Zugangsdaten zu bearbeiten.", "danger")
|
||||
return redirect(url_for("credentials"))
|
||||
cred_id = request.form.get("credential_id")
|
||||
name = request.form.get("name", "").strip()
|
||||
username = request.form.get("username", "").strip()
|
||||
new_password = request.form.get("password", "")
|
||||
if name and username:
|
||||
try:
|
||||
if new_password:
|
||||
conn.execute(
|
||||
"UPDATE credentials SET name=?, username=?, password=? WHERE id=?",
|
||||
(name, username, encrypt_password(new_password), cred_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE credentials SET name=?, username=? WHERE id=?",
|
||||
(name, username, cred_id),
|
||||
)
|
||||
conn.commit()
|
||||
flash(f"Zugangsdaten '{name}' aktualisiert.", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger")
|
||||
else:
|
||||
flash("Name und Username müssen ausgefüllt sein!", "danger")
|
||||
|
||||
elif request.method == "POST" and "delete_credential" in request.form:
|
||||
if not current_user.has_permission("switches.delete"):
|
||||
flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger")
|
||||
return redirect(url_for("credentials"))
|
||||
cred_id = request.form.get("delete_credential")
|
||||
used_by = conn.execute("SELECT hostname FROM switches WHERE credential_id=?", (cred_id,)).fetchall()
|
||||
if used_by:
|
||||
names = ", ".join(s["hostname"] for s in used_by)
|
||||
flash(f"Diese Zugangsdaten werden noch von folgenden Switchen verwendet: {names}", "danger")
|
||||
else:
|
||||
conn.execute("DELETE FROM credentials WHERE id=?", (cred_id,))
|
||||
conn.commit()
|
||||
flash("Zugangsdaten gelöscht.", "success")
|
||||
|
||||
credential_rows = conn.execute("""
|
||||
SELECT credentials.id, credentials.name, credentials.username,
|
||||
COUNT(switches.hostname) AS switch_count
|
||||
FROM credentials
|
||||
LEFT JOIN switches ON switches.credential_id = credentials.id
|
||||
GROUP BY credentials.id
|
||||
ORDER BY credentials.name ASC
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return render_template("credentials.html", credentials=credential_rows)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen.
|
||||
#
|
||||
@@ -1063,25 +1233,27 @@ def users():
|
||||
if "add_user" in request.form:
|
||||
username = request.form["username"].strip()
|
||||
password = request.form["password"].strip()
|
||||
is_admin = int(request.form.get("is_admin", 0))
|
||||
first_name = request.form.get("first_name", "").strip() or None
|
||||
last_name = request.form.get("last_name", "").strip() or None
|
||||
# Die Gruppen-Auswahl entscheidet auch über die Rolle: Auswahl
|
||||
# "admin" macht den Benutzer zum Admin, jede andere Auswahl ist
|
||||
# eine normale Gruppe (oder keine).
|
||||
group_choice = request.form.get("group_id") or ""
|
||||
is_admin = 1 if group_choice == "admin" else 0
|
||||
|
||||
if username and password:
|
||||
pw_hash = bcrypt.generate_password_hash(password).decode("utf-8")
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
|
||||
(username, pw_hash, is_admin),
|
||||
"INSERT INTO users (username, password, is_admin, first_name, last_name) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(username, pw_hash, is_admin, first_name, last_name),
|
||||
)
|
||||
# Neue, nicht-admin Benutzer landen automatisch in der
|
||||
# Standardgruppe 'Benutzer' (alle Ansichtsrechte).
|
||||
if not is_admin:
|
||||
default_group = conn.execute(
|
||||
"SELECT id FROM groups WHERE is_default=1 LIMIT 1"
|
||||
).fetchone()
|
||||
if default_group:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
|
||||
(cur.lastrowid, default_group["id"]),
|
||||
)
|
||||
if not is_admin and group_choice:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
|
||||
(cur.lastrowid, group_choice),
|
||||
)
|
||||
conn.commit()
|
||||
flash(f"Benutzer '{username}' erfolgreich angelegt!", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
@@ -1089,29 +1261,56 @@ def users():
|
||||
else:
|
||||
flash("Username und Passwort dürfen nicht leer sein!", "danger")
|
||||
|
||||
elif "change_role" in request.form:
|
||||
elif "edit_user" in request.form:
|
||||
# Nur Stammdaten + optional Passwort — Gruppe/Rolle wird
|
||||
# ausschließlich über "Gruppe zuweisen" geändert (s.u.).
|
||||
user_id = request.form["user_id"]
|
||||
username = request.form.get("username", "").strip()
|
||||
is_admin = int(request.form.get("is_admin", 0))
|
||||
first_name = request.form.get("first_name", "").strip() or None
|
||||
last_name = request.form.get("last_name", "").strip() or None
|
||||
new_password = request.form.get("new_password", "").strip()
|
||||
if username:
|
||||
conn.execute(
|
||||
"UPDATE users SET username=?, is_admin=? WHERE id=?", (username, is_admin, user_id)
|
||||
)
|
||||
if new_password:
|
||||
pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
|
||||
conn.execute(
|
||||
"UPDATE users SET username=?, first_name=?, last_name=?, password=? WHERE id=?",
|
||||
(username, first_name, last_name, pw_hash, user_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE users SET username=?, first_name=?, last_name=? WHERE id=?",
|
||||
(username, first_name, last_name, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
flash("Rolle und Username geändert!", "success")
|
||||
flash("Benutzer aktualisiert!", "success")
|
||||
else:
|
||||
flash("Username darf nicht leer sein!", "danger")
|
||||
|
||||
elif "change_password" in request.form:
|
||||
elif "assign_group" in request.form:
|
||||
user_id = request.form["user_id"]
|
||||
new_password = request.form.get("new_password", "").strip()
|
||||
if new_password:
|
||||
pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
|
||||
conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, user_id))
|
||||
conn.commit()
|
||||
flash("Passwort erfolgreich geändert!", "success")
|
||||
choice = request.form.get("group_id") or ""
|
||||
|
||||
# Mindestens ein Admin muss bestehen bleiben.
|
||||
target = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if target and target["is_admin"] and choice != "admin":
|
||||
admin_count = conn.execute("SELECT COUNT(*) AS n FROM users WHERE is_admin=1").fetchone()["n"]
|
||||
if admin_count <= 1:
|
||||
flash("Es muss mindestens ein Admin bestehen bleiben.", "danger")
|
||||
conn.close()
|
||||
return redirect(url_for("users"))
|
||||
|
||||
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
|
||||
if choice == "admin":
|
||||
conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (user_id,))
|
||||
else:
|
||||
flash("Passwort darf nicht leer sein!", "danger")
|
||||
conn.execute("UPDATE users SET is_admin=0 WHERE id=?", (user_id,))
|
||||
if choice:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
|
||||
(user_id, choice),
|
||||
)
|
||||
conn.commit()
|
||||
flash("Gruppe zugewiesen!", "success")
|
||||
|
||||
elif "delete_user" in request.form:
|
||||
user_id = request.form["delete_user"]
|
||||
@@ -1121,16 +1320,18 @@ def users():
|
||||
flash("Benutzer gelöscht!", "success")
|
||||
|
||||
users_list = conn.execute("""
|
||||
SELECT users.id, users.username, users.is_admin,
|
||||
GROUP_CONCAT(groups.name, ', ') AS group_names
|
||||
SELECT users.id, users.username, users.is_admin, users.first_name, users.last_name,
|
||||
GROUP_CONCAT(groups.name, ', ') AS group_names,
|
||||
(SELECT group_id FROM user_groups WHERE user_groups.user_id = users.id LIMIT 1) AS group_id
|
||||
FROM users
|
||||
LEFT JOIN user_groups ON user_groups.user_id = users.id
|
||||
LEFT JOIN groups ON groups.id = user_groups.group_id
|
||||
GROUP BY users.id
|
||||
ORDER BY users.username ASC
|
||||
""").fetchall()
|
||||
all_groups = conn.execute("SELECT id, name, is_default FROM groups ORDER BY is_default DESC, name ASC").fetchall()
|
||||
conn.close()
|
||||
return render_template("users.html", users=users_list)
|
||||
return render_template("users.html", users=users_list, all_groups=all_groups)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1163,31 +1364,62 @@ def groups():
|
||||
elif "save_group" in request.form:
|
||||
group_id = request.form.get("group_id")
|
||||
name = request.form.get("name", "").strip()
|
||||
selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS)
|
||||
selected_members = {int(x) for x in request.form.getlist("members") if x.isdigit()}
|
||||
|
||||
if not name:
|
||||
flash("Gruppenname darf nicht leer sein!", "danger")
|
||||
conn.close()
|
||||
return redirect(url_for("groups"))
|
||||
|
||||
# Die Gruppenkarte (Name + Rechte) und das Mitglieder-Modal sind
|
||||
# zwei getrennte Formulare, damit das Speichern des einen nicht
|
||||
# versehentlich den Stand des anderen zurücksetzt. Die verstecken
|
||||
# *_submitted-Felder markieren, welcher Teil tatsächlich abgeschickt
|
||||
# wurde (leere Checkbox-Listen wären sonst nicht von "nichts
|
||||
# ausgewählt" zu unterscheiden).
|
||||
try:
|
||||
conn.execute("UPDATE groups SET name=? WHERE id=?", (name, group_id))
|
||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
||||
[(group_id, p) for p in selected_permissions],
|
||||
)
|
||||
conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)",
|
||||
[(uid, group_id) for uid in selected_members],
|
||||
)
|
||||
|
||||
if "permissions_submitted" in request.form:
|
||||
selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS)
|
||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
||||
[(group_id, p) for p in selected_permissions],
|
||||
)
|
||||
|
||||
if "members_submitted" in request.form:
|
||||
selected_members = {int(x) for x in request.form.getlist("members") if x.isdigit()}
|
||||
conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)",
|
||||
[(uid, group_id) for uid in selected_members],
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
flash(f"Gruppe '{name}' aktualisiert.", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger")
|
||||
|
||||
elif "assign_admins" in request.form:
|
||||
selected_admins = {int(x) for x in request.form.getlist("members") if x.isdigit()}
|
||||
if not selected_admins:
|
||||
flash("Es muss mindestens ein Admin bestehen bleiben.", "danger")
|
||||
else:
|
||||
conn.execute("UPDATE users SET is_admin=0")
|
||||
conn.executemany(
|
||||
"UPDATE users SET is_admin=1 WHERE id=?",
|
||||
[(uid,) for uid in selected_admins],
|
||||
)
|
||||
# Admins brauchen keine Gruppenrechte mehr (sie dürfen ohnehin
|
||||
# alles) — Mitgliedschaften aufräumen, damit sie nicht doppelt
|
||||
# in Admin- und z.B. Standardgruppe auftauchen.
|
||||
conn.executemany(
|
||||
"DELETE FROM user_groups WHERE user_id=?",
|
||||
[(uid,) for uid in selected_admins],
|
||||
)
|
||||
conn.commit()
|
||||
flash("Admin-Zuweisung aktualisiert.", "success")
|
||||
|
||||
elif "delete_group" in request.form:
|
||||
group_id = request.form.get("delete_group")
|
||||
target = conn.execute("SELECT name, is_default FROM groups WHERE id=?", (group_id,)).fetchone()
|
||||
@@ -1236,6 +1468,7 @@ def groups():
|
||||
"permissions": set(ALL_PERMISSION_KEYS),
|
||||
"member_names": [u["username"] for u in admin_rows],
|
||||
}
|
||||
all_users_all = conn.execute("SELECT id, username, is_admin FROM users ORDER BY username ASC").fetchall()
|
||||
|
||||
conn.close()
|
||||
return render_template(
|
||||
@@ -1243,6 +1476,7 @@ def groups():
|
||||
groups=groups_data,
|
||||
admin_virtual_group=admin_virtual_group,
|
||||
all_users=all_users,
|
||||
all_users_all=all_users_all,
|
||||
permission_catalog=PERMISSIONS,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,13 +9,25 @@ DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
|
||||
# Switches (Aruba-Switche mit SSH-Zugangsdaten; Passwort wird verschlüsselt gespeichert)
|
||||
# Zugangsdaten (wiederverwendbare SSH-Logins, mehrere Switche können sich
|
||||
# dieselben teilen; Passwort wird verschlüsselt gespeichert)
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
|
||||
# Switches (Aruba-Switche) — referenzieren ihre Zugangsdaten statt sie
|
||||
# direkt zu speichern
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS switches (
|
||||
hostname TEXT PRIMARY KEY,
|
||||
ip TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
credential_id INTEGER,
|
||||
FOREIGN KEY (credential_id) REFERENCES credentials(id)
|
||||
);
|
||||
""")
|
||||
|
||||
@@ -38,7 +50,9 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
is_admin INTEGER DEFAULT 0
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
first_name TEXT,
|
||||
last_name TEXT
|
||||
);
|
||||
""")
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ def generate_ips_list():
|
||||
|
||||
switches = {
|
||||
row["hostname"]: row
|
||||
for row in conn.execute("SELECT hostname, ip, username, password FROM switches")
|
||||
for row in conn.execute("""
|
||||
SELECT switches.hostname, switches.ip,
|
||||
credentials.username AS username, credentials.password AS password
|
||||
FROM switches
|
||||
LEFT JOIN credentials ON credentials.id = switches.credential_id
|
||||
""")
|
||||
}
|
||||
|
||||
devices = conn.execute("""
|
||||
@@ -26,12 +31,12 @@ def generate_ips_list():
|
||||
|
||||
for dev in devices:
|
||||
switch = switches.get(dev["switch_hostname"])
|
||||
if switch:
|
||||
if switch and switch["password"]:
|
||||
switch_ip = switch["ip"]
|
||||
switch_user = switch["username"]
|
||||
switch_pass = decrypt_password(switch["password"])
|
||||
else:
|
||||
switch_ip = ""
|
||||
switch_ip = switch["ip"] if switch else ""
|
||||
switch_user = ""
|
||||
switch_pass = ""
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ button { font-family: inherit; }
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
display: inline-flex;
|
||||
width: 36px; height: 36px;
|
||||
align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -273,6 +273,14 @@ button { font-family: inherit; }
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Desktop: dauerhaftes Einklappen der Sidebar (nicht die mobile
|
||||
Off-Canvas-Logik unten, die bleibt für schmale Screens bestehen). */
|
||||
@media (min-width: 901px) {
|
||||
.sidebar.collapsed { transform: translateX(-100%); }
|
||||
.main.sidebar-collapsed { margin-left: 0; }
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -442,11 +450,31 @@ button { font-family: inherit; }
|
||||
Device grid (dashboard)
|
||||
========================================================================== */
|
||||
|
||||
/* Bootstrap-artiges Raster: feste Spaltenzahl je Breakpoint statt frei
|
||||
fließendem auto-fill — dadurch maximal 6 Kacheln nebeneinander auf
|
||||
breiten Screens, dafür beliebig viele Zeilen untereinander. */
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
@media (min-width: 480px) { .device-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (min-width: 720px) { .device-grid { grid-template-columns: repeat(4, 1fr); } }
|
||||
@media (min-width: 960px) { .device-grid { grid-template-columns: repeat(5, 1fr); } }
|
||||
@media (min-width: 1200px) { .device-grid { grid-template-columns: repeat(6, 1fr); } }
|
||||
|
||||
.dash-section { margin-bottom: 26px; }
|
||||
.dash-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
position: relative;
|
||||
@@ -582,7 +610,8 @@ table.data-table {
|
||||
|
||||
.mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--text-dim); }
|
||||
.cell-name { font-weight: 600; }
|
||||
.row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.row-actions { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; }
|
||||
.row-actions form { display: flex; }
|
||||
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
@@ -590,6 +619,13 @@ table.data-table {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.group-detail-row td {
|
||||
padding: 20px 24px 22px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.group-detail-row:hover { background: transparent; }
|
||||
#chev-admin, [id^="chev-"] { transition: transform 0.15s ease; }
|
||||
|
||||
/* ==========================================================================
|
||||
Forms
|
||||
========================================================================== */
|
||||
|
||||
@@ -39,9 +39,14 @@
|
||||
function initSidebar() {
|
||||
const sidebar = document.querySelector(".sidebar");
|
||||
const backdrop = document.querySelector(".sidebar-backdrop");
|
||||
const main = document.querySelector(".main");
|
||||
const toggles = document.querySelectorAll("[data-sidebar-toggle]");
|
||||
if (!sidebar) return;
|
||||
|
||||
const DESKTOP_BREAKPOINT = 900;
|
||||
const COLLAPSE_KEY = "poe-sidebar-collapsed";
|
||||
|
||||
// Mobile: temporäres Überlagern per Hamburger + Backdrop.
|
||||
function open() {
|
||||
sidebar.classList.add("open");
|
||||
backdrop && backdrop.classList.add("open");
|
||||
@@ -50,8 +55,22 @@
|
||||
sidebar.classList.remove("open");
|
||||
backdrop && backdrop.classList.remove("open");
|
||||
}
|
||||
|
||||
// Desktop: dauerhaftes Ein-/Ausklappen, über Neuladen hinweg gemerkt.
|
||||
function setCollapsed(collapsed) {
|
||||
sidebar.classList.toggle("collapsed", collapsed);
|
||||
main && main.classList.toggle("sidebar-collapsed", collapsed);
|
||||
localStorage.setItem(COLLAPSE_KEY, collapsed ? "1" : "0");
|
||||
}
|
||||
|
||||
if (localStorage.getItem(COLLAPSE_KEY) === "1") setCollapsed(true);
|
||||
|
||||
toggles.forEach((btn) => btn.addEventListener("click", () => {
|
||||
sidebar.classList.contains("open") ? close() : open();
|
||||
if (window.innerWidth <= DESKTOP_BREAKPOINT) {
|
||||
sidebar.classList.contains("open") ? close() : open();
|
||||
} else {
|
||||
setCollapsed(!sidebar.classList.contains("collapsed"));
|
||||
}
|
||||
}));
|
||||
backdrop && backdrop.addEventListener("click", close);
|
||||
document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close));
|
||||
|
||||
@@ -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"/>',
|
||||
"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"/>',
|
||||
"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"/>',
|
||||
"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"/>',
|
||||
@@ -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>
|
||||
Switches
|
||||
</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 %}
|
||||
{% if current_user.is_admin %}
|
||||
<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,123 +12,179 @@
|
||||
{% block content %}
|
||||
|
||||
<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
|
||||
Devices und Switches gezielt an normale Benutzer vergeben, ohne sie zu Admins zu machen.
|
||||
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich.
|
||||
Über Gruppen lassen sich einzelne Verwaltungsrechte für Devices und Switches gezielt vergeben.
|
||||
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken,
|
||||
um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.
|
||||
</p>
|
||||
|
||||
{# Virtuelle Admin-"Gruppe": rein informativ, kein Formular — Admin-Status
|
||||
wird ausschließlich über den is_admin-Schalter auf der Users-Seite gesetzt. #}
|
||||
<div class="card card-pad" style="margin-bottom:16px; opacity:0.85;">
|
||||
<div class="section-head">
|
||||
<div class="flex gap-2" style="align-items:center;">
|
||||
<h3 style="font-size:16px;">Admin</h3>
|
||||
<span class="pill admin">Systemrolle</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-faint" style="font-size:12px; margin:-8px 0 14px;">
|
||||
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.
|
||||
</p>
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
|
||||
{% for cat_key, cat in permission_catalog.items() %}
|
||||
<div>
|
||||
<div class="permission-group-title">{{ cat['label'] }}</div>
|
||||
<div class="check-list">
|
||||
{% for key, label in cat['items'].items() %}
|
||||
<label class="check-row" style="cursor:default; color:var(--text-faint);">
|
||||
<input type="checkbox" checked disabled>
|
||||
{{ label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% 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 class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Gruppe</th><th>Mitglieder</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
<!-- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft
|
||||
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>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="group-detail-row hidden" id="detail-admin">
|
||||
<td colspan="3">
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
|
||||
{% for cat_key, cat in permission_catalog.items() %}
|
||||
<div>
|
||||
<div class="permission-group-title">{{ cat['label'] }}</div>
|
||||
<div class="check-list">
|
||||
{% for key, label in cat['items'].items() %}
|
||||
<label class="check-row" style="cursor:default; color:var(--text-faint);">
|
||||
<input type="checkbox" checked disabled>
|
||||
{{ label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
||||
{% for g in groups %}
|
||||
<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">
|
||||
<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="name" value="{{ g.name }}">
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
|
||||
{% for cat_key, cat in permission_catalog.items() %}
|
||||
<div>
|
||||
<div class="permission-group-title">{{ cat['label'] }}</div>
|
||||
<div class="check-list">
|
||||
{% for key, label in cat['items'].items() %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="permissions" value="{{ key }}" {% if key in g.permissions %}checked{% endif %}>
|
||||
{{ label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
|
||||
{% if groups %}
|
||||
<div style="display:flex; flex-direction:column; gap:16px;">
|
||||
{% for g in groups %}
|
||||
<div class="card card-pad">
|
||||
<!-- Modal: Admin-Mitglieder verwalten -->
|
||||
<div class="modal-overlay" id="adminMembersModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
|
||||
<div class="section-head">
|
||||
<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>
|
||||
<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>
|
||||
{% 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() %}
|
||||
<div>
|
||||
<div class="permission-group-title">{{ cat['label'] }}</div>
|
||||
<div class="check-list">
|
||||
{% for key, label in cat['items'].items() %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="permissions" value="{{ key }}" {% if key in g.permissions %}checked{% endif %}>
|
||||
{{ label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div>
|
||||
<div class="permission-group-title">Mitglieder</div>
|
||||
<div class="check-list" style="max-height:180px; overflow-y:auto; padding-right:4px;">
|
||||
{% for u in all_users %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['id'] in g.members %}checked{% endif %}>
|
||||
{{ u['username'] }}
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Keine Nicht-Admin-Benutzer vorhanden.</p>
|
||||
{% endfor %}
|
||||
</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 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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
||||
Noch keine Gruppen angelegt.
|
||||
|
||||
<!-- 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 %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['id'] in g.members %}checked{% endif %}>
|
||||
{{ u['username'] }}
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Keine Nicht-Admin-Benutzer vorhanden.</p>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Modal: Neue Gruppe -->
|
||||
<div class="modal-overlay" id="addGroupModal">
|
||||
@@ -143,7 +199,7 @@
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<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 class="modal-footer">
|
||||
@@ -155,3 +211,15 @@
|
||||
</div>
|
||||
|
||||
{% 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" %}
|
||||
{% set active_page = "index" %}
|
||||
{% 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 %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,35 +33,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if devices %}
|
||||
<div class="device-grid">
|
||||
{% for d in devices %}
|
||||
{% 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 %}"
|
||||
data-status="{{ 'offline' if d['is_active'] == 0 else st }}"
|
||||
data-mac="{{ d['mac'] }}"
|
||||
data-name="{{ d['name'] }}"
|
||||
data-ip="{{ d['rpi_ip'] }}"
|
||||
data-switch="{{ d['switch_hostname'] or '-' }}"
|
||||
data-port="{{ d['port'] or '-' }}"
|
||||
data-active="{{ d['is_active'] }}"
|
||||
data-checked="{{ last_checked.get(d['mac']) or '-' }}"
|
||||
{% if last_seen.get(d['mac']) %}title="{{ last_seen[d['mac']] }}"{% endif %}>
|
||||
<div class="dc-top">
|
||||
<div class="dc-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2.5"/><path d="M8 2v3M16 2v3M8 19v3M16 19v3M2 8h3M2 16h3M19 8h3M19 16h3"/></svg>
|
||||
</div>
|
||||
{% if d['is_active'] == 0 %}
|
||||
<span class="pill disabled">Aus</span>
|
||||
{% else %}
|
||||
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
|
||||
{% endif %}
|
||||
{% macro device_tile(d) %}
|
||||
{% 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 %}"
|
||||
data-status="{{ 'offline' if d['is_active'] == 0 else st }}"
|
||||
data-mac="{{ d['mac'] }}"
|
||||
data-name="{{ d['name'] }}"
|
||||
data-ip="{{ d['rpi_ip'] }}"
|
||||
data-switch="{{ d['switch_hostname'] or '-' }}"
|
||||
data-port="{{ d['port'] or '-' }}"
|
||||
data-active="{{ d['is_active'] }}"
|
||||
data-checked="{{ last_checked.get(d['mac']) or '-' }}"
|
||||
{% if last_seen.get(d['mac']) %}title="{{ last_seen[d['mac']] }}"{% endif %}>
|
||||
<div class="dc-top">
|
||||
<div class="dc-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2.5"/><path d="M8 2v3M16 2v3M8 19v3M16 19v3M2 8h3M2 16h3M19 8h3M19 16h3"/></svg>
|
||||
</div>
|
||||
<div class="dc-name">{{ d['name'] }}</div>
|
||||
<div class="dc-ip">{{ d['rpi_ip'] }}</div>
|
||||
{% if d['is_active'] == 0 %}
|
||||
<span class="pill disabled">Aus</span>
|
||||
{% else %}
|
||||
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="dc-name">{{ d['name'] }}</div>
|
||||
<div class="dc-ip">{{ d['rpi_ip'] }}</div>
|
||||
</div>
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
||||
{% if current_user.is_authenticated %}
|
||||
@@ -95,8 +128,14 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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') %}
|
||||
<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>
|
||||
Neustarten
|
||||
</button>
|
||||
@@ -153,11 +192,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
setInterval(updateTimer, 1000);
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedCard = null, selectedMac = null, selectedName = null;
|
||||
const restartButton = document.getElementById("restartButton");
|
||||
const activateButton = document.getElementById("activateButton");
|
||||
|
||||
document.querySelectorAll(".device-card").forEach(card => {
|
||||
const mac = card.dataset.mac;
|
||||
@@ -171,11 +234,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
}
|
||||
card.addEventListener("click", function () {
|
||||
if (this.dataset.active == "0") return;
|
||||
if (sessionStorage.getItem("restart_" + this.dataset.mac)) return;
|
||||
selectedCard = this;
|
||||
selectedMac = this.dataset.mac;
|
||||
selectedName = this.dataset.name;
|
||||
const isActive = this.dataset.active != "0";
|
||||
document.getElementById("deviceModalTitle").innerText = this.dataset.name;
|
||||
document.getElementById("deviceIp").innerText = this.dataset.ip || "-";
|
||||
document.getElementById("deviceSwitch").innerText = this.dataset.switch || "-";
|
||||
@@ -183,11 +246,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("deviceChecked").innerText = this.dataset.checked || "-";
|
||||
const pill = this.querySelector(".pill");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
const restartButton = document.getElementById("restartButton");
|
||||
if (restartButton) {
|
||||
restartButton.addEventListener("click", function () {
|
||||
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||
@@ -219,6 +283,26 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
.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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
{% 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>
|
||||
Neuer Switch
|
||||
</button>
|
||||
@@ -29,18 +29,24 @@
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="switchesTable">
|
||||
<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>
|
||||
<tbody>
|
||||
{% for s in switches %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ s['hostname'] }}</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>
|
||||
<div class="row-actions">
|
||||
{% 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>
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -58,7 +64,7 @@
|
||||
{% if can_edit %}
|
||||
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
|
||||
<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="old_hostname" value="{{ s['hostname'] }}">
|
||||
<div class="modal-header">
|
||||
@@ -73,15 +79,29 @@
|
||||
<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>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="username" value="{{ s['username'] }}" required>
|
||||
<div class="field">
|
||||
<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 class="field"><label>Neues Passwort</label>
|
||||
<input type="password" id="password_edit_{{ loop.index }}" name="password" placeholder="Nur bei Änderung ausfüllen">
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm_edit_{{ loop.index }}" name="password_confirm" placeholder="Bestätigen">
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
<div class="new-credential-fields hidden">
|
||||
<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 class="field"><label>Passwort bestätigen</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -122,15 +142,32 @@
|
||||
<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>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="username" required placeholder="z.B. admin">
|
||||
<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="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 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>
|
||||
<input type="text" name="new_credential_username" placeholder="z.B. admin">
|
||||
</div>
|
||||
<div class="field"><label>Passwort</label>
|
||||
<input type="password" id="password_add" name="new_credential_password">
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm_add" name="new_credential_password_confirm">
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -171,6 +208,7 @@
|
||||
<div class="modal-footer">
|
||||
<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.
|
||||
Bei bestehenden Zugangsdaten ist das Passwort hier nicht bekannt (verschlüsselt gespeichert) — bitte manuell eingeben.
|
||||
</p>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal onclick="closeTerminal()">Schließen</button>
|
||||
</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-addon-fit.js') }}"></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)
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -221,13 +276,25 @@ function setTermStatus(text, cls) {
|
||||
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) {
|
||||
const host = (form.querySelector("input[name='ip']") || {}).value?.trim();
|
||||
const username = (form.querySelector("input[name='username']") || {}).value?.trim();
|
||||
activePasswordInput = form.querySelector("input[name='password']");
|
||||
const { username, passwordInput } = getCredentialInfo(form);
|
||||
activePasswordInput = passwordInput;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -267,7 +334,7 @@ const termPasteBtn = document.getElementById("termPastePassword");
|
||||
if (termPasteBtn) {
|
||||
termPasteBtn.addEventListener("click", () => {
|
||||
if (!activePasswordInput || !activePasswordInput.value) {
|
||||
showToast("Kein Passwort im Formular eingetragen.", "danger");
|
||||
showToast("Kein Passwort bekannt — bitte manuell im Terminal eingeben.", "danger");
|
||||
return;
|
||||
}
|
||||
if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
|
||||
@@ -297,9 +364,11 @@ function validateIP(input) {
|
||||
return ok;
|
||||
}
|
||||
|
||||
function validatePassword(id, isEdit) {
|
||||
const pass = document.getElementById(isEdit ? `password_edit_${id}` : "password_add");
|
||||
const confirm = document.getElementById(isEdit ? `password_confirm_edit_${id}` : "password_confirm_add");
|
||||
function validateNewCredentialPassword(form) {
|
||||
const select = form.querySelector(".credential-select");
|
||||
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.value !== confirm.value) { confirm.classList.add("is-invalid"); return false; }
|
||||
confirm.classList.remove("is-invalid");
|
||||
@@ -310,15 +379,15 @@ function validateSwitchForm(form, id) {
|
||||
const ipInput = form.querySelector("input[name='ip']");
|
||||
let valid = true;
|
||||
if (ipInput) valid = validateIP(ipInput) && valid;
|
||||
valid = validatePassword(id, id !== "add") && valid;
|
||||
valid = validateNewCredentialPassword(form) && valid;
|
||||
return valid;
|
||||
}
|
||||
|
||||
document.addEventListener("input", (e) => {
|
||||
if (e.target.name === "ip") validateIP(e.target);
|
||||
if (e.target.id.startsWith("password_confirm")) {
|
||||
const id = e.target.id.replace("password_confirm_", "");
|
||||
const pass = document.getElementById("password_" + id);
|
||||
if (e.target.name === "new_credential_password_confirm") {
|
||||
const form = e.target.closest("form");
|
||||
const pass = form.querySelector("input[name='new_credential_password']");
|
||||
if (pass) e.target.classList.toggle("is-invalid", pass.value !== e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,17 +14,31 @@
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<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>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<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">{{ u['group_names'] or '—' }}</td>
|
||||
<td class="text-dim">
|
||||
{% 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>
|
||||
<div class="row-actions">
|
||||
<button class="btn btn-sm btn-secondary" onclick="openRoleModal({{ u['id'] }}, '{{ u['username'] }}', {{ u['is_admin'] }})">Rolle</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="openPasswordModal({{ u['id'] }}, '{{ u['username'] }}')">Passwort</button>
|
||||
<button class="icon-btn" title="Bearbeiten"
|
||||
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?">
|
||||
<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>
|
||||
@@ -50,10 +64,16 @@
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<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>Passwort</label><input type="password" name="password" required></div>
|
||||
<div class="field"><label>Rolle</label>
|
||||
<select name="is_admin"><option value="0">User</option><option value="1">Admin</option></select>
|
||||
<div class="field">
|
||||
<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 class="modal-footer">
|
||||
@@ -64,45 +84,55 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Rolle ändern -->
|
||||
<div class="modal-overlay" id="roleModal">
|
||||
<!-- Modal: Benutzer bearbeiten (Stammdaten + optional neues Passwort;
|
||||
Gruppe/Rolle wird ausschließlich über "Gruppe zuweisen" geändert) -->
|
||||
<div class="modal-overlay" id="editModal">
|
||||
<div class="modal">
|
||||
<form method="post" id="roleForm">
|
||||
<input type="hidden" name="user_id" id="role_user_id">
|
||||
<form method="post" id="editForm">
|
||||
<input type="hidden" name="user_id" id="edit_user_id">
|
||||
<div class="modal-header">
|
||||
<h3>Rolle ändern</h3>
|
||||
<h3>Benutzer bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Username</label><input type="text" name="username" id="role_username" required></div>
|
||||
<div class="field"><label>Rolle</label>
|
||||
<select name="is_admin" id="role_is_admin"><option value="0">User</option><option value="1">Admin</option></select>
|
||||
<div class="field"><label>Vorname</label><input type="text" name="first_name" id="edit_first_name"></div>
|
||||
<div class="field"><label>Name</label><input type="text" name="last_name" id="edit_last_name"></div>
|
||||
<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 class="modal-footer">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Passwort ändern -->
|
||||
<div class="modal-overlay" id="passwordModal">
|
||||
<div class="modal">
|
||||
<form method="post" id="passwordForm">
|
||||
<input type="hidden" name="user_id" id="password_user_id">
|
||||
<!-- Modal: Gruppe zuweisen (inkl. Admin als Auswahl) -->
|
||||
<div class="modal-overlay" id="groupModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post" id="groupForm">
|
||||
<input type="hidden" name="user_id" id="group_user_id">
|
||||
<div class="modal-header">
|
||||
<h3>Passwort ändern</h3>
|
||||
<h3>Gruppe zuweisen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Username</label><input type="text" name="username" id="password_username" readonly></div>
|
||||
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" required></div>
|
||||
<div class="field">
|
||||
<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 class="modal-footer">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -112,17 +142,18 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function openRoleModal(userId, username, isAdmin) {
|
||||
document.getElementById("role_user_id").value = userId;
|
||||
document.getElementById("role_username").value = username;
|
||||
document.getElementById("role_is_admin").value = isAdmin;
|
||||
PoeUI.openModal("roleModal");
|
||||
function openEditModal(userId, username, firstName, lastName) {
|
||||
document.getElementById("edit_user_id").value = userId;
|
||||
document.getElementById("edit_username").value = username;
|
||||
document.getElementById("edit_first_name").value = firstName;
|
||||
document.getElementById("edit_last_name").value = lastName;
|
||||
document.querySelector("#editForm input[name='new_password']").value = "";
|
||||
PoeUI.openModal("editModal");
|
||||
}
|
||||
function openPasswordModal(userId, username) {
|
||||
document.getElementById("password_user_id").value = userId;
|
||||
document.getElementById("password_username").value = username;
|
||||
document.querySelector("#passwordForm input[name='new_password']").value = "";
|
||||
PoeUI.openModal("passwordModal");
|
||||
function openGroupModal(userId, groupChoice) {
|
||||
document.getElementById("group_user_id").value = userId;
|
||||
document.getElementById("group_select").value = groupChoice || "";
|
||||
PoeUI.openModal("groupModal");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user