Files
tesm/srv/poe_manager/templates/groups.html
T
alientimandClaude Sonnet 5 4b7403f476 Vollständig granulares, an der Navbar gespiegeltes Rechtesystem
Kompletter Umbau des Permission-Systems weg von groben is_admin-Gates hin zu
einem zweistufigen, an die Sidebar-Struktur gespiegelten Rechtebaum:

  Geräte (devices_group.view)
   ├─ Clients      Lesen/Schreiben/Ändern/Löschen (+ PoE-Neustart)
   ├─ Switche      Lesen/Schreiben/Ändern/Löschen
   └─ Zugangsdaten Lesen/Schreiben/Ändern/Löschen (jetzt eigene Rechte,
                    vorher an switches.* gekoppelt)
  Logs (logs_group.view)
   ├─ Live         Lesen
   └─ Änderungen   Lesen
  Einstellungen (settings_group.view)
   ├─ Benutzer            Lesen/Schreiben/Ändern/Löschen
   ├─ Gruppen             Lesen/Schreiben/Ändern/Löschen
   ├─ Systemeinstellungen Lesen/Ändern
   └─ Im-/Export          Lesen (Export)/Ändern (Import)

- User.has_permission() ist jetzt hierarchisch: das "Bereich anzeigen"-Recht
  einer Top-Level-Gruppe wirkt als Kill-Switch für alle Kind-Rechte
  darunter, auch wenn ein Kind-Recht einzeln noch gesetzt ist. Mit
  Testgruppe verifiziert (devices.view ohne devices_group.view -> /devices
  liefert 302, "Geräte" verschwindet komplett aus der Sidebar; nach
  Zurücksetzen sofort wieder 200).
- devices.toggle entfällt, ist jetzt Teil von devices.edit (Ändern).
- Neue eigenständige credentials.*-Rechte statt Kopplung an switches.*.
- Benutzer- und Gruppenverwaltung sind jetzt ebenfalls granular/delegierbar
  (users.*/groups.*) statt fest is_admin-exklusiv — dafür neue,
  fest einprogrammierte Eskalationsschranken: Admin-Konten anlegen/ändern/
  löschen sowie Admin-Zuweisung bleiben unabhängig von delegierten Rechten
  echten Admins vorbehalten (mit Testgruppe verifiziert: Anlegen als Admin,
  Bearbeiten/Löschen bestehender Admin-Konten und Zuweisen zu "admin"
  wurden alle korrekt blockiert, normale Benutzerverwaltung funktioniert).
- "Admin" (virtuell) und "Benutzer" (Standardgruppe, neues is_system-Flag)
  sind jetzt echte Systemgruppen: weder umbenennbar noch in ihren Rechten
  änderbar, auch nicht durch Admins über die UI — Mitgliedschaft bleibt frei
  verwaltbar. Mit direktem POST verifiziert: Umbenennen/Löschen/Rechte-Reset
  von "Benutzer" werden blockiert, Mitgliederverwaltung funktioniert weiter.
- groups.html zeigt den Baum jetzt als 3 Zeilen (Geräte/Logs/Einstellungen)
  mit eingerückten Unterpunkten statt einer flachen Liste von Kategorien mit
  wiederholtem Bereichsnamen im Label.
- Migration in _ensure_schema() (Altrechte übertragen, neue Bereichs-Rechte
  für bestehende Gruppen nachtragen) läuft jetzt über einen Einmal-Guard in
  der settings-Tabelle — lief anfangs bei jedem Neustart erneut und hat
  damit den Kill-Switch-Mechanismus untergraben (ein deaktiviertes
  Bereichs-Recht wäre bei jedem Neustart automatisch wieder gesetzt worden,
  solange irgendein Kind-Recht noch aktiv war); im Test entdeckt und behoben.
- create_db.py synchronisiert (is_system-Spalte, neuer Rechtesatz für
  Frischinstallationen).
- README: Rechtesystem-Abschnitt komplett neu beschrieben (Baum, Kill-Switch,
  Systemgruppen, Eskalationsschutz).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 18:28:42 +02:00

259 lines
12 KiB
HTML

{% extends "base.html" %}
{% set active_page = "groups" %}
{% block page_title %}Gruppen{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
{% block content %}
{# Rendert den zweistufigen Rechte-Baum: eine Zeile pro Top-Level-Bereich
(Geräte/Logs/Einstellungen) mit eigenem "Bereich anzeigen"-Kästchen,
darunter eingerückt die Unterpunkte mit ihren jeweiligen Einzelrechten.
readonly=true zeigt nur den aktuellen Stand (Admin/Systemgruppen bzw.
fehlendes groups.edit), sonst ist der Baum Teil eines <form>. #}
{% macro permission_tree(checked_keys, readonly) %}
{% for group_key, group in permission_catalog.items() %}
<div class="permission-area">
<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 %}
{% endmacro %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Gruppen</h2>
<div class="hint">
Ü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,
um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.
„Admin“ und „Benutzer“ sind feste Systemgruppen und nicht veränderbar.
</div>
</div>
{% if current_user.has_permission('groups.create') %}
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Gruppe
</button>
{% endif %}
</div>
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table" data-sortable>
<thead><tr>
<th data-sort-key="name">Gruppe</th>
<th data-sort-key="members">Mitglieder</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<!-- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft
wird direkt über is_admin gesteuert. Bleibt beim Sortieren immer
oben (eigenes <tbody data-sort-pinned>). -->
<tbody data-sort-pinned>
<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">
{{ permission_tree(admin_virtual_group.permissions, true) }}
<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>
</tbody>
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
<!-- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen <tbody>, damit
die Sortierung beide Zeilen gemeinsam verschiebt. -->
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
<tr>
<td class="cell-name">
{{ g.name }}
{% if g.is_system %}<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' if can_edit_this else '' }}" 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 current_user.has_permission('groups.delete') 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.">
<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">
{% if can_edit_this %}
<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 }}">
{{ permission_tree(g.permissions, false) }}
<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>
{% else %}
{{ permission_tree(g.permissions, true) }}
{% if g.is_system %}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
{% endif %}
{% endif %}
</td>
</tr>
</tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="3">Noch keine weiteren Gruppen angelegt.</td></tr>
</tbody>
{% endfor %}
</table>
</div>
</div>
<!-- Modal: Admin-Mitglieder verwalten -->
<div class="modal-overlay" id="adminMembersModal">
<div class="modal" style="max-width:380px;">
<form method="post">
<input type="hidden" name="assign_admins" value="1">
<div class="modal-header">
<h3>Admin-Mitglieder</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-faint" style="font-size:11.5px; margin:0 0 12px;">Mindestens ein Admin muss bestehen bleiben.</p>
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
{% for u in all_users_all %}
<label class="check-row">
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['is_admin'] %}checked{% endif %}>
{{ u['username'] }}
</label>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- Modals: Mitglieder pro Gruppe verwalten -->
{% for g in groups %}
<div class="modal-overlay" id="membersModal{{ loop.index }}">
<div class="modal" style="max-width:380px;">
<form method="post">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="members_submitted" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
<div class="modal-header">
<h3>Mitglieder — {{ g.name }}</h3>
<button type="button" class="modal-close" data-close-modal>&times;</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>
{% endfor %}
<!-- Modal: Neue Gruppe -->
<div class="modal-overlay" id="addGroupModal">
<div class="modal" style="max-width:380px;">
<form method="post">
<div class="modal-header">
<h3>Neue Gruppe</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<input type="hidden" name="add_group" value="1">
<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 über die Gruppentabelle eingestellt.</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>
{% 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 %}