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:
2026-08-10 13:30:47 +02:00
co-authored by Claude Sonnet 5
parent 08d8d1f738
commit fa6bb2935f
12 changed files with 1108 additions and 345 deletions
+330 -96
View File
@@ -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,
)