Files
tesm/srv/poe_manager/templates/groups.html
T
alientimandClaude Sonnet 5 813b17d002 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>
2026-08-10 19:49:28 +02:00

326 lines
15 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 einen Bereich (Geräte/Logs/Einstellungen) als kompakte Tabelle:
Zeilen = Bereich selbst (nur die R-Spalte aktiv, das ist der Kill-Switch)
+ je eine Zeile pro Unterpunkt (Clients, Switche, ...). Spalten = die
kanonischen Rechte R/W/E/D nebeneinander (permission_row_types).
"Ä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) %}
{% for group_key, group in permission_catalog.items() %}
{{ permission_table(group, checked_keys, readonly) }}
{% 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. 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>
{% 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.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.">
<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)";
}
// 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>
{% endblock %}