Fix: Benutzer-Löschen im Browser kaputt (JS), Gruppen-Rechtetabelle überarbeitet

Bugfix (von dir gemeldet, live auf Port 5000 gefunden):
- initConfirmables() in app.js rief beim bestätigten Absenden
  form.requestSubmit() OHNE den ursprünglichen Submitter auf. Laut Spec
  wird dann KEIN Submit-Button-Name/Value mitgeschickt. users.html trug
  die Benutzer-ID als name/value des Löschen-Buttons selbst (statt eines
  verstecken Inputs wie überall sonst) — dadurch kam "delete_user" nie
  im Request an, der Klick tat scheinbar nichts. Fix: e.submitter beim
  ersten Klick merken und an requestSubmit(submitter) weiterreichen
  (behebt es grundsätzlich für alle Formulare dieser Art); zusätzlich
  users.html auf das robustere hidden-input-Muster umgestellt wie
  credentials/devices/groups. Mit echtem Playwright-Browserklick
  (nicht nur curl) verifiziert.

Gruppen-Rechtetabelle, mehrere Korrekturrunden nach deinem Mockup:
- Bereichsname + Kill-Switch-Kästchen jetzt eng nebeneinander in der
  Tabellen-Kopfzeile (nicht mehr an den Rand gestellt), R/W/E/D-Spalten
  in derselben Zeile statt einer zweiten.
- Nur die von einem Bereich tatsächlich genutzten Spalten werden gezeigt
  (GROUP_ROW_TYPES) — Logs zeigt nur R, Einstellungen nur R/W/E, nur
  Geräte zeigt zusätzlich D.
- Bugfix: D-Spalte bei Geräte verschwand durch zu schmale Flex-Breite der
  Spalte im Overflow, obwohl serverseitig korrekt gerendert — behoben
  durch flex:0 0 auto statt fester Breite.
- Checkbox-Zentrierung robust per display:block+margin:auto statt sich
  auf native Checkbox-Ränder zu verlassen (waren leicht asymmetrisch).
- Unterpunkte deutlich weiter eingerückt (34px) für klare Hierarchie.
- Sichtbares Ausgrauen (nicht nur natives disabled) der Unterpunkte,
  solange der Bereich-Kill-Switch aus ist (JS setzt .permission-locked).
- Legende jetzt UNTER der Rechtetabelle statt darüber.
- Rechte lassen sich jetzt direkt beim Anlegen einer neuen Gruppe setzen
  (Modal zeigt dieselbe Rechtetabelle, add_group-Route verarbeitet
  mitgesendete permissions[] im selben Request) statt sie erst danach
  über die Gruppentabelle nachzupflegen.

Alles inkl. Erstellen-mit-Rechten und Bearbeiten-Regression live per
Playwright verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 09:52:01 +02:00
co-authored by Claude Sonnet 5
parent 8e4a874943
commit 7d7239e1af
6 changed files with 172 additions and 91 deletions
+24 -2
View File
@@ -268,6 +268,18 @@ for _group in PERMISSIONS.values():
ALL_PERMISSION_KEYS.append(_key)
PERMISSION_PARENT_GROUP[_key] = _group["view_key"]
# Welche Spalten (R/W/E/D) für einen Bereich überhaupt vorkommen — z.B. hat
# nur "Geräte" (wegen Clients' Dashboard-Neustart) eine D-Spalte, Logs/
# Einstellungen brauchen sie gar nicht erst anzuzeigen. Auf der Gruppen-
# Seite bekommt so jeder Bereich nur die Spalten, die er tatsächlich nutzt,
# statt überall gleich viele (teils komplett ungenutzte) Spalten zu zeigen.
GROUP_ROW_TYPES = {}
for _group_key, _group in PERMISSIONS.items():
_used_row_keys = {"view"}
for _child in _group["children"].values():
_used_row_keys.update(_child["rows"].keys())
GROUP_ROW_TYPES[_group_key] = [rt for rt in PERMISSION_ROW_TYPES if rt[0] in _used_row_keys]
DEFAULT_GROUP_NAME = "Benutzer"
DEFAULT_GROUP_PERMISSIONS = [
"devices_group.view", "devices.view", "switches.view",
@@ -2738,9 +2750,18 @@ def groups():
name = request.form.get("name", "").strip()
if name:
try:
conn.execute("INSERT INTO groups (name) VALUES (?)", (name,))
cur = conn.execute("INSERT INTO groups (name) VALUES (?)", (name,))
# Rechte direkt beim Anlegen setzbar (dieselbe Tabelle wie
# beim Bearbeiten, hier ohne eigenes "_submitted"-Flag, da
# eine neue Gruppe ohnehin noch keine Rechte hat).
selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS)
if selected_permissions:
conn.executemany(
"INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
[(cur.lastrowid, p) for p in selected_permissions],
)
conn.commit()
log_action("group.create", name)
log_action("group.create", name, f"{len(selected_permissions)} Rechte" if selected_permissions else None)
flash(f"Gruppe '{name}' angelegt.", "success")
except sqlite3.IntegrityError:
flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger")
@@ -2906,6 +2927,7 @@ def groups():
all_users_all=all_users_all,
permission_catalog=PERMISSIONS,
permission_row_types=PERMISSION_ROW_TYPES,
group_row_types=GROUP_ROW_TYPES,
permission_labels=PERMISSION_LABELS,
)
+55 -11
View File
@@ -1057,23 +1057,55 @@ select {
margin-bottom: 8px;
}
/* Rechte-Tabelle (Gruppen-Detailansicht): eine kompakte Tabelle pro
Top-Level-Bereich (Geräte/Logs/Einstellungen). Erste Zeile = der Bereich
selbst "(gesamt)" (nur die R-Spalte aktiv, das ist der Kill-Switch),
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; }
/* Rechte-Übersicht (Gruppen-Detailansicht): die Top-Level-Bereiche
(Geräte/Logs/Einstellungen) stehen als kompakte, schmale Spalten
nebeneinander statt untereinander. Kopfzeile der Tabelle = Bereichsname
+ Kill-Switch-Kästchen (eng beieinander, linksbündig) in der ERSTEN
Zelle, gefolgt von den Buchstaben-Spalten (R/W/E/D) — Name/Kästchen und
Buchstaben stehen also in derselben Zeile. Tabellenkörper = Unterpunkte
als eingerückte Zeilen, Spalten je Bereich nur die dort tatsächlich
genutzten Rechte. Legende steht direkt unter der Zeile (.permission-legend). */
.permission-legend {
font-size: 12px;
color: var(--text-faint);
margin-top: 14px;
}
.permission-groups-row {
display: flex;
flex-wrap: wrap;
gap: 28px;
}
.permission-group-col {
/* Nicht auf eine feste Breite zwingen — Bereiche mit mehr Spalten (z.B.
Geräte mit R/W/E/D) brauchen mehr Platz als welche mit weniger (z.B.
Logs mit nur R). Feste/zu schmale Breiten haben die D-Spalte bei
Geräte zuvor lautlos in den Overflow geschoben. */
flex: 0 0 auto;
padding-right: 28px;
border-right: 1px solid var(--border-soft);
}
.permission-group-col:last-child { border-right: none; padding-right: 0; }
.permission-group-header-cell { padding-left: 0 !important; }
.permission-group-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
cursor: pointer;
}
.permission-group-name { font-size: 14px; font-weight: 650; color: var(--text); text-transform: none; letter-spacing: normal; font-family: inherit; }
.permission-table {
border-collapse: collapse;
font-size: 13px;
white-space: nowrap;
}
.permission-table th, .permission-table td {
padding: 7px 14px;
text-align: center;
padding: 5px 10px;
border-bottom: 1px solid var(--border-soft);
}
.permission-table th:not(:first-child), .permission-table td:not(:first-child) {
text-align: center;
}
.permission-table thead th { padding-bottom: 10px; border-bottom: 1px solid var(--border-soft); }
.permission-table th {
font-size: 11.5px;
font-weight: 650;
@@ -1083,14 +1115,26 @@ select {
font-family: var(--font-mono);
}
.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 td:first-child, .permission-table th:first-child { text-align: left; padding-right: 14px; padding-left: 0; }
.permission-table tbody tr:last-child td { border-bottom: none; }
.permission-row-label { color: var(--text-dim); }
.permission-row-label { color: var(--text-dim); padding-left: 34px !important; }
/* Sichtbares Ausgrauen, solange der Kill-Switch des Bereichs aus ist —
zusätzlich zum disabled-Attribut, das der Browser nur dezent abblendet
(siehe applyPermissionGating() im Script-Block). */
.permission-table tbody.permission-locked { opacity: 0.35; }
.permission-table input[type="checkbox"] {
width: 16px; height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
/* Explizit zentrieren (statt sich auf text-align + native Checkbox-Ränder
zu verlassen, die je nach Browser/OS leicht asymmetrisch sind) — nur für
die Rechte-Checkboxen im Tabellenkörper, nicht für das Bereich-Kästchen
in der Kopfzeile (das steht bewusst inline neben dem Namen). */
.permission-table tbody input[type="checkbox"] {
display: block;
margin: 0 auto;
}
.permission-table input[type="checkbox"]:disabled { cursor: not-allowed; }
.permission-cb-na { opacity: 0.25; accent-color: var(--text-faint) !important; }
+8 -1
View File
@@ -160,9 +160,16 @@
form.addEventListener("submit", function (e) {
if (form.dataset.confirmed === "1") return;
e.preventDefault();
// Welcher Button hat den Submit ausgelöst? Muss beim erneuten
// requestSubmit() explizit mitgegeben werden — sonst geht bei
// Formularen, die ihre ID über name/value des Submit-Buttons
// transportieren (statt über ein <input type="hidden">), dieses
// Feld beim Neu-Absenden stillschweigend verloren (requestSubmit()
// ohne Argument zählt als "kein Button aktiviert").
const submitter = e.submitter;
window.confirmAction(form.getAttribute("data-confirm"), () => {
form.dataset.confirmed = "1";
form.requestSubmit ? form.requestSubmit() : form.submit();
form.requestSubmit ? form.requestSubmit(submitter) : form.submit();
}, form.getAttribute("data-confirm-title"));
});
});
+52 -43
View File
@@ -5,53 +5,50 @@
{% 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).
{# Rendert einen Bereich (Geräte/Logs/Einstellungen) als kompakte, schmale
Spalte: eigene Kopfzeile mit Bereichsname + einem Kästchen (der
Kill-Switch, "Bereich sichtbar"), darunter eine kleine Tabelle mit den
Unterpunkten (Clients, Switche, ...) als Zeilen und nur den für diesen
Bereich tatsächlich genutzten Rechten (group_row_types[group_key]) als
Spalten — Logs/Einstellungen zeigen z.B. nur R/W/E, nur Geräte zeigt
zusätzlich D (PoE-Neustart über Dashboard, ausschließlich bei Clients).
"Ä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
Löschen-Recht. Zellen ohne passendes Recht für die jeweilige Zeile (z.B.
"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">
(Kill-Switch) nicht gesetzt ist. Mehrere Bereiche stehen per Flexbox
nebeneinander (siehe .permission-groups-row), damit die ganze
Rechteübersicht einer Gruppe in einer kompakten Zeile Platz hat. #}
{% macro permission_table(group, group_key, checked_keys, readonly) %}
{% set row_types = group_row_types[group_key] %}
<div class="permission-group-col">
<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 class="permission-group-header-cell">
<label class="permission-group-toggle">
<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">
<span class="permission-group-name">{{ group['label'] }}</span>
</label>
</th>
{% for row_key, row_letter, row_label in 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 %}
{% for row_key, row_letter, row_label in row_types %}
{% set perm_key = child['rows'].get(row_key) %}
<td>
{% if perm_key %}
@@ -74,9 +71,16 @@
{% endmacro %}
{% macro permission_tree(checked_keys, readonly) %}
<div class="permission-groups-row">
{% for group_key, group in permission_catalog.items() %}
{{ permission_table(group, checked_keys, readonly) }}
{{ permission_table(group, group_key, checked_keys, readonly) }}
{% endfor %}
</div>
<div class="permission-legend">
<strong>R</strong> = Read (Lesen) · <strong>W</strong> = Write (Anlegen) ·
<strong>E</strong> = Edit (Ändern, inkl. Löschen) ·
<strong>D</strong> = Dashboard (PoE-Neustart über das Dashboard-Popup, nur bei Clients)
</div>
{% endmacro %}
<div class="section-head">
@@ -86,12 +90,9 @@
Ü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)
„Admin“ und „Benutzer“ sind feste Systemgruppen und nicht veränderbar. Das Kästchen neben dem
Bereichsnamen (Geräte/Logs/Einstellungen) muss gesetzt sein, bevor die Rechte darunter greifen —
automatische Neustarts bei Ausfall bleiben von „D“ unberührt. Legende direkt bei den Rechten.
</div>
</div>
{% if current_user.has_permission('groups.create') %}
@@ -263,9 +264,11 @@
</div>
{% endfor %}
<!-- Modal: Neue Gruppe -->
<!-- Modal: Neue Gruppe — Rechte direkt beim Anlegen setzbar, Mitglieder
werden danach über die Gruppentabelle zugeordnet (die Gruppe muss
dafür erst existieren). -->
<div class="modal-overlay" id="addGroupModal">
<div class="modal" style="max-width:380px;">
<div class="modal" style="max-width:900px;">
<form method="post">
<div class="modal-header">
<h3>Neue Gruppe</h3>
@@ -276,8 +279,9 @@
<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 class="field-hint">Mitglieder werden danach über die Gruppentabelle zugeordnet.</div>
</div>
{{ permission_tree([], false) }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
@@ -306,15 +310,20 @@ function toggleDetail(id) {
// (Admin/Systemgruppen) bleiben unangetastet, deren Checkboxen sind ohnehin
// alle disabled.
function applyPermissionGating() {
document.querySelectorAll(".permission-area").forEach(function (area) {
document.querySelectorAll(".permission-group-col").forEach(function (area) {
const toggle = area.querySelector(".permission-area-toggle-cb");
if (!toggle || toggle.disabled) return;
const tbody = area.querySelector("tbody");
const children = area.querySelectorAll(".permission-child-cb");
const sync = function () {
children.forEach(function (cb) {
cb.disabled = !toggle.checked;
if (!toggle.checked) cb.checked = false;
});
// Zusätzlich zum disabled-Attribut (das der Browser nur dezent
// abblendet) sichtbar ausgrauen, wie gefordert — sonst fällt
// "Bereich gesperrt" auf den ersten Blick kaum auf.
if (tbody) tbody.classList.toggle("permission-locked", !toggle.checked);
};
toggle.addEventListener("change", sync);
sync();
+2 -1
View File
@@ -56,7 +56,8 @@
{% endif %}
{% if current_user.has_permission('users.edit') and may_touch_target %}
<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">
<input type="hidden" name="delete_user" value="{{ u['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>