Rechtesystem: Löschen überall in Ändern falten, Gruppen-Tabelle transponieren

- "Ändern" deckt jetzt ausnahmslos überall Löschen mit ab (auch bei
  Clients) — es gibt kein eigenes *.delete-Recht mehr. Betroffen:
  devices/switches/credentials/users/groups.
- "D" in der Rechtetabelle bedeutet ausschließlich Dashboard-Neustart
  (devices.restart, PoE-Neustart über das Dashboard-Popup) und existiert
  nur bei Clients. Automatische Neustarts bei Ausfall (poe.sh) bleiben
  davon komplett unabhängig.
- Gruppen-Rechtetabelle transponiert: Kategorien jetzt als Zeilen,
  Rechte R(ead)/W(rite)/E(dit)/D(ashboard-Neustart) nebeneinander als
  Spalten statt untereinander. Deckt sich besser mit dem Legenden-Format
  und braucht weniger vertikalen Platz.
- Neue, einmalig laufende Migration (_migrated_permission_delete_fold_v2)
  überführt bestehende *.delete-Grants verlustfrei in *.edit, bevor die
  veralteten Rechte-Zeilen entfernt werden.
- Routen (delete_device/delete_switch/delete_credential/delete_user/
  delete_group) und die zugehörigen can_delete-Template-Variablen prüfen
  jetzt einheitlich *.edit statt *.delete.
- README-Rechtesystem-Abschnitt und Legende an das neue Tabellen-Layout
  angepasst.
- Live getestet: Kill-Switch, alle 5 Delete-Foldings (mit/ohne Recht),
  devices.restart-Gating unabhängig von devices.edit, Eskalationsschutz
  (Admin-Löschschutz, Systemgruppen-Schutz) — alles bestätigt korrekt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 19:49:28 +02:00
co-authored by Claude Sonnet 5
parent 4b7403f476
commit 813b17d002
8 changed files with 291 additions and 136 deletions
+39 -28
View File
@@ -109,34 +109,43 @@ Ein Benutzer kann mehreren Gruppen angehören, die Rechte addieren sich
### Zweistufiger Rechtebaum ### Zweistufiger Rechtebaum
Der Baum hat genau zwei Ebenen, exakt gespiegelt an den drei Sidebar-Gruppen: Der Baum hat genau zwei Ebenen, exakt gespiegelt an den drei Sidebar-Gruppen.
Pro Bereich gibt es eine kompakte Tabelle: Zeilen = der Bereich selbst
„(gesamt)“ + je ein Unterpunkt, Spalten = die Rechte **R**(ead/Lesen),
**W**(rite/Schreiben, Anlegen), **E**(dit/Ändern) nebeneinander. **„Ändern“
deckt Löschen überall mit ab** — es gibt bewusst kein eigenes
Löschen-Recht. Eine vierte Spalte **D** existiert nur bei Clients und
bedeutet dort **nicht** Löschen, sondern *PoE-Neustart über das
Dashboard-Popup auslösen* (automatische Neustarts bei Ausfall über
`poe.sh` sind davon komplett unabhängig und laufen immer). Für Zeilen/Spalten
ohne passendes Recht (z.B. „W“ beim Bereich selbst, oder „D“ bei
Switche/Zugangsdaten/Benutzer/Gruppen) wird eine ausgegraute, nicht
anklickbare Checkbox angezeigt statt die Zelle wegzulassen:
``` ```
Geräte (devices_group.view — Bereich an/aus) Geräte (gesamt) R
├─ Clients Lesen · Schreiben (Anlegen) · Ändern (inkl. Akt./Deakt.) · Löschen · PoE-Neustart ├─ Clients R · W · E · D (D = PoE-Neustart über Dashboard)
├─ Switche Lesen · Schreiben · Ändern · Löschen ├─ Switche R · W · E
└─ Zugangsdaten Lesen · Schreiben · Ändern · Löschen └─ Zugangsdaten R · W · E
Logs (logs_group.view — Bereich an/aus) Logs (gesamt) R
├─ Live Lesen ├─ Live R
└─ Änderungen Lesen └─ Änderungen R
Einstellungen (settings_group.view — Bereich an/aus) Einstellungen (gesamt) R
├─ Benutzer Lesen · Schreiben · Ändern · Löschen ├─ Benutzer R · W · E
├─ Gruppen Lesen · Schreiben · Ändern · Löschen ├─ Gruppen R · W · E
├─ Systemeinstellungen Lesen · Ändern ├─ Systemeinstellungen R · E
└─ Im-/Export Lesen (Export) · Ändern (Import) └─ Im-/Export R (Export) · E (Import)
``` ```
Das jeweilige „Bereich an/aus“-Recht (`devices_group.view` / Das jeweilige „Bereich an/aus“-Recht (`devices_group.view` /
`logs_group.view` / `settings_group.view`) wirkt als **Kill-Switch**: ist es `logs_group.view` / `settings_group.view`, die „(gesamt)“-Zeile) wirkt als
für eine Gruppe nicht gesetzt, greift kein einziges Recht darunter mehr — **Kill-Switch**: ist es für eine Gruppe nicht gesetzt, greift kein einziges
selbst wenn z.B. `devices.view` einzeln noch angehakt ist. So lässt sich ein Recht darunter mehr — selbst wenn z.B. `devices.view` einzeln noch angehakt
ganzer Bereich mit einem Klick sperren, ohne jedes Unterrecht einzeln ist. So lässt sich ein ganzer Bereich mit einem Klick sperren, ohne jedes
zurücknehmen zu müssen (`User.has_permission()` in `app.py`). Unterrecht einzeln zurücknehmen zu müssen (`User.has_permission()` in
`app.py`). Auf der Gruppen-Seite sind die Unterpunkt-Zeilen einer Tabelle per
Auf der **Gruppen**-Seite wird der Baum als Akkordeon pro Gruppe angezeigt: JavaScript so lange gesperrt (ausgegraut, nicht anklickbar), bis die
eine Zeile pro Top-Level-Bereich mit eigenem Kästchen, darunter eingerückt „(gesamt)“-Zeile angehakt ist.
die Unterpunkte mit ihren Einzelrechten — ohne den Bereichsnamen in jedem
Unterpunkt zu wiederholen.
### Systemgruppen ### Systemgruppen
@@ -150,8 +159,8 @@ beiden frei verwaltbar:
angelegte Nicht-Admin automatisch zugeordnet wird. Fester Rechtesatz: angelegte Nicht-Admin automatisch zugeordnet wird. Fester Rechtesatz:
Geräte-Bereich + Clients/Switche lesen, Logs-Bereich + Live-Log lesen. Geräte-Bereich + Clients/Switche lesen, Logs-Bereich + Live-Log lesen.
Alle anderen Gruppen sind vom jeweiligen Rechteinhaber (`groups.edit` bzw. Alle anderen Gruppen sind vom jeweiligen Rechteinhaber (`groups.edit`, das
`groups.create`/`groups.delete`) frei konfigurierbar. auch Anlegen und Löschen von Gruppen umfasst) frei konfigurierbar.
### Eskalationsschutz ### Eskalationsschutz
@@ -174,10 +183,12 @@ Formulare) ist an das jeweilige Recht gekoppelt — im Frontend ausgeblendet
Datenmodell: `groups` (inkl. `is_default`- und `is_system`-Flag), Datenmodell: `groups` (inkl. `is_default`- und `is_system`-Flag),
`group_permissions` (Gruppe → Recht), `user_groups` (Benutzer → Gruppe). `group_permissions` (Gruppe → Recht), `user_groups` (Benutzer → Gruppe).
Bestehende Datenbanken werden beim App-Start automatisch migriert Bestehende Datenbanken werden beim App-Start automatisch migriert
(`_ensure_schema()` in `app.py`, u.a. `devices.toggle``devices.edit`, (`_ensure_schema()` in `app.py`): `devices.toggle``devices.edit`,
`switches.*` → gespiegelte `credentials.*`, nachträgliches Setzen der neuen `switches.*` → gespiegelte `credentials.*`, nachträgliches Setzen der neuen
Bereichs-Rechte für bereits vergebene Unterrechte) — läuft **nur einmalig** Bereichs-Rechte für bereits vergebene Unterrechte, sowie (spätere Revision)
über einen Guard in der `settings`-Tabelle, damit ein bewusst deaktiviertes sämtliche `*.delete`-Rechte → `*.edit` (Löschen wurde nachträglich überall in
Ändern gefaltet). Jede dieser Migrationen läuft **nur einmalig** über einen
eigenen Guard in der `settings`-Tabelle, damit z.B. ein bewusst deaktiviertes
Bereichs-Recht nicht bei jedem Neustart automatisch wieder gesetzt wird. Bereichs-Recht nicht bei jedem Neustart automatisch wieder gesetzt wird.
## Zugangsdaten (wiederverwendbare SSH-Logins) ## Zugangsdaten (wiederverwendbare SSH-Logins)
+117 -56
View File
@@ -125,10 +125,20 @@ login_manager.init_app(app)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Zweistufig verschachtelt, gespiegelt an der Navbar-Struktur: jeder # Zweistufig verschachtelt, gespiegelt an der Navbar-Struktur: jeder
# Top-Level-Bereich (Geräte/Logs/Einstellungen) hat ein eigenes "Bereich # Top-Level-Bereich (Geräte/Logs/Einstellungen) hat ein eigenes "Bereich
# anzeigen"-Recht (view_key) und darunter mehrere Unterpunkte mit jeweils # anzeigen"-Recht (view_key) und darunter mehrere Unterpunkte (Spalten in der
# eigenen Lesen/Schreiben/Ändern/Löschen-Rechten. Der Bereichs-Name wird # Gruppen-Tabelle). Jeder Unterpunkt liefert "rows": eine Teilmenge der vier
# dadurch nicht in jedem Unterpunkt wiederholt (siehe groups.html: eine # kanonischen Rechte-Zeilen (view/create/edit/delete = L/S/Ä/D, siehe
# Zeile pro Top-Level-Bereich, Unterpunkte darunter eingerückt). # PERMISSION_ROW_TYPES) — fehlt eine Zeile für einen Unterpunkt (z.B.
# "delete" bei Systemeinstellungen), wird die Zelle in der Tabelle grau und
# nicht setzbar dargestellt statt einfach zu fehlen, damit alle Spalten
# eines Bereichs bündig nebeneinander in einer kompakten Tabelle stehen.
PERMISSION_ROW_TYPES = [
("view", "R", "Lesen (Read)"),
("create", "W", "Schreiben/Anlegen (Write)"),
("edit", "E", "Ändern inkl. Löschen (Edit)"),
("extra", "D", "Dashboard-Neustart (nur bei Clients — kein Löschen-Recht)"),
]
PERMISSIONS = { PERMISSIONS = {
"devices_group": { "devices_group": {
"label": "Geräte", "label": "Geräte",
@@ -136,30 +146,33 @@ PERMISSIONS = {
"children": { "children": {
"devices": { "devices": {
"label": "Clients", "label": "Clients",
"items": { # "Ändern" deckt Löschen überall mit ab, auch hier — es gibt
"devices.view": "Lesen", # nirgends ein eigenes Löschen-Recht. Die vierte Spalte (D)
"devices.create": "Schreiben (Anlegen)", # ist bei Clients KEIN Löschen, sondern das Recht, einen
"devices.edit": "Ändern (inkl. Aktivieren/Deaktivieren)", # PoE-Neustart über das Dashboard-Popup auszulösen
"devices.delete": "Löschen", # (automatische Neustarts bei Ausfall bleiben davon unberührt).
"devices.restart": "PoE-Neustart über das Dashboard", "rows": {
"view": "devices.view",
"create": "devices.create",
"edit": "devices.edit",
"extra": "devices.restart",
}, },
}, },
"switches": { "switches": {
"label": "Switche", "label": "Switche",
"items": { # Kein eigenes Löschen-Recht — "Ändern" deckt das mit ab.
"switches.view": "Lesen", "rows": {
"switches.create": "Schreiben (Anlegen)", "view": "switches.view",
"switches.edit": "Ändern", "create": "switches.create",
"switches.delete": "Löschen", "edit": "switches.edit",
}, },
}, },
"credentials": { "credentials": {
"label": "Zugangsdaten", "label": "Zugangsdaten",
"items": { "rows": {
"credentials.view": "Lesen", "view": "credentials.view",
"credentials.create": "Schreiben (Anlegen)", "create": "credentials.create",
"credentials.edit": "Ändern", "edit": "credentials.edit",
"credentials.delete": "Löschen",
}, },
}, },
}, },
@@ -168,14 +181,8 @@ PERMISSIONS = {
"label": "Logs", "label": "Logs",
"view_key": "logs_group.view", "view_key": "logs_group.view",
"children": { "children": {
"logs_live": { "logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
"label": "Live", "logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}},
"items": {"logs_live.view": "Lesen"},
},
"logs_activity": {
"label": "Änderungen",
"items": {"logs_activity.view": "Lesen"},
},
}, },
}, },
"settings_group": { "settings_group": {
@@ -184,40 +191,55 @@ PERMISSIONS = {
"children": { "children": {
"users": { "users": {
"label": "Benutzer", "label": "Benutzer",
"items": { "rows": {
"users.view": "Lesen", "view": "users.view",
"users.create": "Schreiben (Anlegen)", "create": "users.create",
"users.edit": "Ändern", "edit": "users.edit",
"users.delete": "Löschen",
}, },
}, },
"groups": { "groups": {
"label": "Gruppen", "label": "Gruppen",
"items": { "rows": {
"groups.view": "Lesen", "view": "groups.view",
"groups.create": "Schreiben (Anlegen)", "create": "groups.create",
"groups.edit": "Ändern", "edit": "groups.edit",
"groups.delete": "Löschen",
}, },
}, },
"settings_system": { "settings_system": {
"label": "Systemeinstellungen", "label": "Systemeinstellungen",
"items": { "rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
"settings_system.view": "Lesen",
"settings_system.edit": "Ändern",
},
}, },
"settings_importexport": { "settings_importexport": {
"label": "Im-/Export", "label": "Im-/Export",
"items": { # L = Export (nur lesend), Ä = Import (verändert Bestand).
"settings_importexport.view": "Lesen (Export)", "rows": {"view": "settings_importexport.view", "edit": "settings_importexport.edit"},
"settings_importexport.edit": "Ändern (Import)",
},
}, },
}, },
}, },
} }
# Menschenlesbare Einzel-Labels je Recht (Tooltips auf den Checkboxen in der
# Tabelle) — aus PERMISSIONS abgeleitet, damit es nur eine Quelle gibt.
PERMISSION_LABELS = {
"devices_group.view": "Geräte-Bereich anzeigen",
"logs_group.view": "Logs-Bereich anzeigen",
"settings_group.view": "Einstellungen-Bereich anzeigen",
"devices.view": "Clients lesen", "devices.create": "Clients anlegen",
"devices.edit": "Clients ändern (inkl. Aktivieren/Deaktivieren, Löschen)",
"devices.restart": "PoE-Neustart über das Dashboard ausführen (automatische Neustarts bei Ausfall bleiben unberührt)",
"switches.view": "Switche lesen", "switches.create": "Switche anlegen",
"switches.edit": "Switche ändern (inkl. Löschen)",
"credentials.view": "Zugangsdaten lesen", "credentials.create": "Zugangsdaten anlegen",
"credentials.edit": "Zugangsdaten ändern (inkl. Löschen)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen",
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
"users.edit": "Benutzer ändern (inkl. Löschen)",
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
"groups.edit": "Gruppen ändern (inkl. Löschen)",
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
"settings_importexport.view": "Daten exportieren", "settings_importexport.edit": "Daten importieren",
}
# Aus der verschachtelten Struktur abgeleitet: alle vergebbaren Keys, sowie # Aus der verschachtelten Struktur abgeleitet: alle vergebbaren Keys, sowie
# eine Zuordnung jedes Kind-Rechts zum "Bereich anzeigen"-Recht seines # eine Zuordnung jedes Kind-Rechts zum "Bereich anzeigen"-Recht seines
# Top-Level-Bereichs. Ist das übergeordnete Recht einer Gruppe nicht # Top-Level-Bereichs. Ist das übergeordnete Recht einer Gruppe nicht
@@ -230,7 +252,7 @@ PERMISSION_PARENT_GROUP = {}
for _group in PERMISSIONS.values(): for _group in PERMISSIONS.values():
ALL_PERMISSION_KEYS.append(_group["view_key"]) ALL_PERMISSION_KEYS.append(_group["view_key"])
for _child in _group["children"].values(): for _child in _group["children"].values():
for _key in _child["items"]: for _key in _child["rows"].values():
ALL_PERMISSION_KEYS.append(_key) ALL_PERMISSION_KEYS.append(_key)
PERMISSION_PARENT_GROUP[_key] = _group["view_key"] PERMISSION_PARENT_GROUP[_key] = _group["view_key"]
@@ -352,13 +374,13 @@ class User(UserMixin):
@property @property
def can_manage_switches(self): def can_manage_switches(self):
return self.has_any_permission( return self.has_any_permission(
["switches.view", "switches.create", "switches.edit", "switches.delete"] ["switches.view", "switches.create", "switches.edit"]
) )
@property @property
def can_manage_credentials(self): def can_manage_credentials(self):
return self.has_any_permission( return self.has_any_permission(
["credentials.view", "credentials.create", "credentials.edit", "credentials.delete"] ["credentials.view", "credentials.create", "credentials.edit"]
) )
@property @property
@@ -372,13 +394,13 @@ class User(UserMixin):
@property @property
def can_manage_users(self): def can_manage_users(self):
return self.has_any_permission( return self.has_any_permission(
["users.view", "users.create", "users.edit", "users.delete"] ["users.view", "users.create", "users.edit"]
) )
@property @property
def can_manage_groups(self): def can_manage_groups(self):
return self.has_any_permission( return self.has_any_permission(
["groups.view", "groups.create", "groups.edit", "groups.delete"] ["groups.view", "groups.create", "groups.edit"]
) )
@property @property
@@ -595,6 +617,38 @@ def _ensure_schema():
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key,) "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key,)
) )
# Migration v2: "Löschen" als eigenes Recht wieder abgeschafft — "Ändern"
# deckt das Löschen jetzt überall mit ab (auch bei Clients, keine
# Ausnahme mehr). Gruppen, die bisher nur *.delete ohne *.edit hatten,
# würden sonst durch das Update ihr Löschen-Recht stillschweigend
# verlieren — deshalb erst *.edit nachtragen, dann die veralteten
# *.delete-Zeilen entfernen. Ebenfalls einmalig per Guard, aus demselben
# Grund wie bei v1 (kein Zurückschreiben bei jedem Neustart nötig, da
# *.delete als Recht komplett entfällt und nie wieder vorkommen kann).
_migration_key_v2 = "_migrated_permission_delete_fold_v2"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_v2,)).fetchone():
_delete_to_edit = {
"devices.delete": "devices.edit",
"switches.delete": "switches.edit",
"credentials.delete": "credentials.edit",
"users.delete": "users.edit",
"groups.delete": "groups.edit",
}
for delete_perm, edit_perm in _delete_to_edit.items():
rows = conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (delete_perm,)
).fetchall()
for row in rows:
conn.execute(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
(row["group_id"], edit_perm),
)
conn.execute("DELETE FROM group_permissions WHERE permission=?", (delete_perm,))
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,)
)
# Standardgruppe 'Benutzer' — SYSTEMGRUPPE: Name und Rechte sind fix und # Standardgruppe 'Benutzer' — SYSTEMGRUPPE: Name und Rechte sind fix und
# werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht # werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht
# nur bei Erstanlage). Das ist unbedenklich, weil is_system die Gruppe in # nur bei Erstanlage). Das ist unbedenklich, weil is_system die Gruppe in
@@ -1366,7 +1420,8 @@ def devices():
flash("Fehler beim Aktualisieren des Geräts!", "danger") flash("Fehler beim Aktualisieren des Geräts!", "danger")
elif "delete_device" in request.form: elif "delete_device" in request.form:
if not current_user.has_permission("devices.delete"): # "Ändern" deckt Löschen mit ab (kein eigenes devices.delete-Recht).
if not current_user.has_permission("devices.edit"):
flash("Keine Berechtigung, Geräte zu löschen.", "danger") flash("Keine Berechtigung, Geräte zu löschen.", "danger")
return redirect(url_for("devices")) return redirect(url_for("devices"))
@@ -1528,7 +1583,8 @@ def switches():
@app.route("/switches/delete/<hostname>", methods=["POST"]) @app.route("/switches/delete/<hostname>", methods=["POST"])
@login_required @login_required
def delete_switch(hostname): def delete_switch(hostname):
if not current_user.has_permission("switches.delete"): # "Ändern" deckt Löschen mit ab (kein eigenes switches.delete-Recht).
if not current_user.has_permission("switches.edit"):
flash("Keine Berechtigung, Switche zu löschen.", "danger") flash("Keine Berechtigung, Switche zu löschen.", "danger")
return redirect(url_for("switches")) return redirect(url_for("switches"))
@@ -1613,7 +1669,8 @@ def credentials():
flash("Name und Username müssen ausgefüllt sein!", "danger") flash("Name und Username müssen ausgefüllt sein!", "danger")
elif request.method == "POST" and "delete_credential" in request.form: elif request.method == "POST" and "delete_credential" in request.form:
if not current_user.has_permission("credentials.delete"): # "Ändern" deckt Löschen mit ab (kein eigenes credentials.delete-Recht).
if not current_user.has_permission("credentials.edit"):
flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger") flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger")
return redirect(url_for("credentials")) return redirect(url_for("credentials"))
cred_id = request.form.get("delete_credential") cred_id = request.form.get("delete_credential")
@@ -2098,7 +2155,8 @@ def users():
flash("Gruppe zugewiesen!", "success") flash("Gruppe zugewiesen!", "success")
elif "delete_user" in request.form: elif "delete_user" in request.form:
if not current_user.has_permission("users.delete"): # "Ändern" deckt Löschen mit ab (kein eigenes users.delete-Recht).
if not current_user.has_permission("users.edit"):
flash("Keine Berechtigung, Benutzer zu löschen.", "danger") flash("Keine Berechtigung, Benutzer zu löschen.", "danger")
return redirect(url_for("users")) return redirect(url_for("users"))
@@ -2254,7 +2312,8 @@ def groups():
flash("Admin-Zuweisung aktualisiert.", "success") flash("Admin-Zuweisung aktualisiert.", "success")
elif "delete_group" in request.form: elif "delete_group" in request.form:
if not current_user.has_permission("groups.delete"): # "Ändern" deckt Löschen mit ab (kein eigenes groups.delete-Recht).
if not current_user.has_permission("groups.edit"):
flash("Keine Berechtigung, Gruppen zu löschen.", "danger") flash("Keine Berechtigung, Gruppen zu löschen.", "danger")
return redirect(url_for("groups")) return redirect(url_for("groups"))
group_id = request.form.get("delete_group") group_id = request.form.get("delete_group")
@@ -2318,6 +2377,8 @@ def groups():
all_users=all_users, all_users=all_users,
all_users_all=all_users_all, all_users_all=all_users_all,
permission_catalog=PERMISSIONS, permission_catalog=PERMISSIONS,
permission_row_types=PERMISSION_ROW_TYPES,
permission_labels=PERMISSION_LABELS,
) )
+32 -16
View File
@@ -1043,26 +1043,42 @@ select {
margin-bottom: 8px; margin-bottom: 8px;
} }
/* Zweistufiger Rechte-Baum (Gruppen-Detailansicht): eine Zeile pro /* Rechte-Tabelle (Gruppen-Detailansicht): eine kompakte Tabelle pro
Top-Level-Bereich (Geräte/Logs/Einstellungen) mit eigenem "Bereich Top-Level-Bereich (Geräte/Logs/Einstellungen). Erste Zeile = der Bereich
anzeigen"-Kästchen, darunter eingerückt die Unterpunkte. */ selbst "(gesamt)" (nur die R-Spalte aktiv, das ist der Kill-Switch),
.permission-area { margin-bottom: 22px; } weitere Zeilen = je ein Unterpunkt (Clients, Switche, ...). Spalten =
R/W/E/D nebeneinander (siehe Legende im Seitenkopf). */
.permission-area { margin-bottom: 20px; }
.permission-area:last-child { margin-bottom: 0; } .permission-area:last-child { margin-bottom: 0; }
.permission-area-toggle { .permission-table {
font-size: 14.5px; border-collapse: collapse;
padding-bottom: 8px; font-size: 13px;
margin-bottom: 14px; white-space: nowrap;
}
.permission-table th, .permission-table td {
padding: 7px 14px;
text-align: center;
border-bottom: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft);
} }
.permission-area-toggle strong { font-weight: 700; } .permission-table th {
.permission-area-children { font-size: 11.5px;
display: grid; font-weight: 650;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); color: var(--text-faint);
gap: 20px; text-transform: uppercase;
padding-left: 22px; letter-spacing: 0.03em;
border-left: 2px solid var(--border-soft); font-family: var(--font-mono);
margin-left: 5px;
} }
.permission-table th:first-child { text-transform: none; font-family: inherit; letter-spacing: normal; }
.permission-table td:first-child, .permission-table th:first-child { text-align: left; padding-right: 20px; }
.permission-table tbody tr:last-child td { border-bottom: none; }
.permission-row-label { color: var(--text-dim); }
.permission-table input[type="checkbox"] {
width: 16px; height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.permission-table input[type="checkbox"]:disabled { cursor: not-allowed; }
.permission-cb-na { opacity: 0.25; accent-color: var(--text-faint) !important; }
.check-list { display: flex; flex-direction: column; gap: 9px; } .check-list { display: flex; flex-direction: column; gap: 9px; }
.check-row { .check-row {
+1 -1
View File
@@ -2,7 +2,7 @@
{% set active_page = "credentials" %} {% set active_page = "credentials" %}
{% set can_create = current_user.has_permission('credentials.create') %} {% set can_create = current_user.has_permission('credentials.create') %}
{% set can_edit = current_user.has_permission('credentials.edit') %} {% set can_edit = current_user.has_permission('credentials.edit') %}
{% set can_delete = current_user.has_permission('credentials.delete') %} {% set can_delete = current_user.has_permission('credentials.edit') %}
{% block page_title %}Zugangsdaten{% endblock %} {% block page_title %}Zugangsdaten{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
+1 -1
View File
@@ -3,7 +3,7 @@
{% set can_toggle = current_user.has_permission('devices.edit') %} {% set can_toggle = current_user.has_permission('devices.edit') %}
{% set can_create = current_user.has_permission('devices.create') %} {% set can_create = current_user.has_permission('devices.create') %}
{% set can_edit = current_user.has_permission('devices.edit') %} {% set can_edit = current_user.has_permission('devices.edit') %}
{% set can_delete = current_user.has_permission('devices.delete') %} {% set can_delete = current_user.has_permission('devices.edit') %}
{% set show_actions_col = can_edit or can_delete %} {% set show_actions_col = can_edit or can_delete %}
{% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %} {% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
{% block page_title %}Clients{% endblock %} {% block page_title %}Clients{% endblock %}
+99 -32
View File
@@ -5,38 +5,77 @@
{% block content %} {% block content %}
{# Rendert den zweistufigen Rechte-Baum: eine Zeile pro Top-Level-Bereich {# Rendert einen Bereich (Geräte/Logs/Einstellungen) als kompakte Tabelle:
(Geräte/Logs/Einstellungen) mit eigenem "Bereich anzeigen"-Kästchen, Zeilen = Bereich selbst (nur die R-Spalte aktiv, das ist der Kill-Switch)
darunter eingerückt die Unterpunkte mit ihren jeweiligen Einzelrechten. + je eine Zeile pro Unterpunkt (Clients, Switche, ...). Spalten = die
readonly=true zeigt nur den aktuellen Stand (Admin/Systemgruppen bzw. kanonischen Rechte R/W/E/D nebeneinander (permission_row_types).
fehlendes groups.edit), sonst ist der Baum Teil eines <form>. #} "Ändern" (E) deckt Löschen überall mit ab — es gibt nirgends ein eigenes
Löschen-Recht. "D" existiert nur bei Clients und bedeutet dort NICHT
Löschen, sondern PoE-Neustart über das Dashboard-Popup. Zellen ohne
passendes Recht für die jeweilige Zeile (z.B. "W" bei "Geräte" selbst,
oder "D" bei Switche/Zugangsdaten) werden als ausgegraute, nicht
anklickbare Checkbox dargestellt statt zu fehlen — dadurch bleiben alle
Spalten bündig untereinander. readonly=true zeigt nur den aktuellen Stand
(Admin/Systemgruppen bzw. fehlendes groups.edit). Die JS-Funktion
applyPermissionGating() sperrt Kind-Rechte, solange das Bereich-Lesen
(Kill-Switch) nicht gesetzt ist. #}
{% macro permission_table(group, checked_keys, readonly) %}
<div class="permission-area">
<div style="overflow-x:auto;">
<table class="permission-table">
<thead>
<tr>
<th>{{ group['label'] }}</th>
{% for row_key, row_letter, row_label in permission_row_types %}
<th title="{{ row_label }}">{{ row_letter }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td class="permission-row-label">{{ group['label'] }} (gesamt)</td>
{% for row_key, row_letter, row_label in permission_row_types %}
<td>
{% if row_key == 'view' %}
<input type="checkbox" name="permissions" value="{{ group['view_key'] }}"
title="{{ permission_labels.get(group['view_key'], group['label']) }}"
{% if group['view_key'] in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}
class="permission-area-toggle-cb">
{% else %}
<input type="checkbox" disabled class="permission-cb-na" tabindex="-1">
{% endif %}
</td>
{% endfor %}
</tr>
{% for child_key, child in group['children'].items() %}
<tr>
<td class="permission-row-label">{{ child['label'] }}</td>
{% for row_key, row_letter, row_label in permission_row_types %}
{% set perm_key = child['rows'].get(row_key) %}
<td>
{% if perm_key %}
<input type="checkbox" name="permissions" value="{{ perm_key }}"
title="{{ permission_labels.get(perm_key, perm_key) }}"
{% if perm_key in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}
class="permission-child-cb">
{% else %}
<input type="checkbox" disabled class="permission-cb-na" tabindex="-1">
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endmacro %}
{% macro permission_tree(checked_keys, readonly) %} {% macro permission_tree(checked_keys, readonly) %}
{% for group_key, group in permission_catalog.items() %} {% for group_key, group in permission_catalog.items() %}
<div class="permission-area"> {{ permission_table(group, checked_keys, readonly) }}
<label class="check-row permission-area-toggle" {% if readonly %}style="cursor:default; color:var(--text-faint);"{% endif %}>
<input type="checkbox" name="permissions" value="{{ group['view_key'] }}"
{% if group['view_key'] in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}>
<strong>{{ group['label'] }}</strong>
</label>
<div class="permission-area-children">
{% for child_key, child in group['children'].items() %}
<div>
<div class="permission-group-title">{{ child['label'] }}</div>
<div class="check-list">
{% for key, label in child['items'].items() %}
<label class="check-row" {% if readonly %}style="cursor:default; color:var(--text-faint);"{% endif %}>
<input type="checkbox" name="permissions" value="{{ key }}"
{% if key in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}>
{{ label }}
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %} {% endfor %}
{% endmacro %} {% endmacro %}
@@ -47,7 +86,12 @@
Über Gruppen lassen sich Rechte für jeden Bereich der App granular vergeben. Über Gruppen lassen sich Rechte für jeden Bereich der App granular vergeben.
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken, Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken,
um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern. um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.
„Admin“ und „Benutzer“ sind feste Systemgruppen und nicht veränderbar. „Admin“ und „Benutzer“ sind feste Systemgruppen und nicht veränderbar. Die Zeile „... (gesamt)“
muss gelesen werden dürfen, bevor die Zeilen darunter greifen — ausgegraute Kästchen gelten für
die jeweilige Zeile nicht.
<strong>R</strong> = Lesen (Read) · <strong>W</strong> = Schreiben/Anlegen (Write) ·
<strong>E</strong> = Ändern inkl. Löschen (Edit) ·
<strong>D</strong> = Dashboard-Neustart (nur bei Clients; automatische Neustarts bei Ausfall bleiben davon unberührt)
</div> </div>
</div> </div>
{% if current_user.has_permission('groups.create') %} {% if current_user.has_permission('groups.create') %}
@@ -112,7 +156,7 @@
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}"> <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> <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> </button>
{% if current_user.has_permission('groups.delete') and not g.is_default and not g.is_system %} {% if current_user.has_permission('groups.edit') and not g.is_default and not g.is_system %}
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte."> <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 }}"> <input type="hidden" name="delete_group" value="{{ g.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen"> <button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
@@ -254,5 +298,28 @@ function toggleDetail(id) {
const chev = document.getElementById(id.replace("detail-", "chev-")); const chev = document.getElementById(id.replace("detail-", "chev-"));
if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)"; if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)";
} }
// Kind-Rechte einer Kategorie sind erst vergebbar, wenn das übergeordnete
// "Bereich anzeigen"-Recht (Kill-Switch, erste Spalte/L-Zeile) gesetzt ist —
// spiegelt serverseitig User.has_permission() (PERMISSION_PARENT_GROUP).
// Bearbeitbare Tabellen bekommen einen Live-Listener, Nur-Lese-Tabellen
// (Admin/Systemgruppen) bleiben unangetastet, deren Checkboxen sind ohnehin
// alle disabled.
function applyPermissionGating() {
document.querySelectorAll(".permission-area").forEach(function (area) {
const toggle = area.querySelector(".permission-area-toggle-cb");
if (!toggle || toggle.disabled) return;
const children = area.querySelectorAll(".permission-child-cb");
const sync = function () {
children.forEach(function (cb) {
cb.disabled = !toggle.checked;
if (!toggle.checked) cb.checked = false;
});
};
toggle.addEventListener("change", sync);
sync();
});
}
document.addEventListener("DOMContentLoaded", applyPermissionGating);
</script> </script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -5,7 +5,7 @@
{% endblock %} {% endblock %}
{% set can_create = current_user.has_permission('switches.create') %} {% set can_create = current_user.has_permission('switches.create') %}
{% set can_edit = current_user.has_permission('switches.edit') %} {% set can_edit = current_user.has_permission('switches.edit') %}
{% set can_delete = current_user.has_permission('switches.delete') %} {% set can_delete = current_user.has_permission('switches.edit') %}
{% block page_title %}Switche{% endblock %} {% block page_title %}Switche{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
+1 -1
View File
@@ -54,7 +54,7 @@
<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> <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> </button>
{% endif %} {% endif %}
{% if current_user.has_permission('users.delete') and may_touch_target %} {% if current_user.has_permission('users.edit') and may_touch_target %}
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich löschen?"> <form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich löschen?">
<button type="submit" name="delete_user" value="{{ u['id'] }}" class="icon-btn" style="color:var(--danger);" title="Löschen"> <button type="submit" name="delete_user" value="{{ u['id'] }}" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>