Files
tesm/srv/tesm/templates/groups.html
T
alientimandClaude Sonnet 5 0f0fa99e53 Feld-/Funktionserklaerungen als Hover-Tooltip + Klick-Modal (v1.1.10)
Statisch sichtbare Erklaerungstexte (.hint/.field-hint) app-weit auf ein
einheitliches "i"-Icon umgestellt: Hover zeigt den Text neben dem
Mauszeiger, Klick/Tap oeffnet ein wiederverwendetes Modal mit dem vollen
Text. Zentral in app.js (initHintIcons, ensureHintTooltip/-Modal) via
Event-Delegation, kein Aufwand pro Seite. Hover ist bewusst per
matchMedia("(hover: hover) and (pointer: fine)") gated -- auf Touch-
Geraeten faellt automatisch nur Tap-zu-Modal an, da echte
mouseenter/mousemove-Events dort ohnehin nicht zuverlaessig auftreten.

Nicht jede .hint/.field-hint wurde umgestellt -- nach folgenden Kriterien
entschieden:
- Live-Status-Anzeigen (aktuelle Log-Pfade, Netzwerkstatus, Auditlog-
  Schwellenwerte, aktive Reservierungs-/Lease-Zaehler, certbot-Timer-
  Status usw.) bleiben sichtbar, da sie aktuellen Zustand zeigen, keine
  generische Erklaerung.
- Kurze, einzeilige Abschnitts-Untertitel (z.B. "Server, Bind-Konto und
  Suchparameter fuer die Anbindung an AD/LDAP.") bleiben als Orientierung
  direkt unter der Ueberschrift sichtbar.
- Bereits JS-live-aktualisierte Status-Felder (id-tragend, z.B.
  "Fragt live... ab." -> "N Gruppen geladen.") bleiben unangetastet.
- Laengere, mehrsaetzige Abschnitts-Erklaerungen (z.B. AD-Gruppenzu-
  ordnungen, Fileshare-Gruppenzuordnung, Subnetze, DHCP-Options, Wartung)
  sowie so gut wie alle Feld-Erklaerungen wandern ins Icon.

Nebenbei: Karte "Fileshare-Gruppen" in LDAP-Einstellungen zu "Fileshare-
Gruppenzuordnung" umbenannt (passt jetzt zur bereits bestehenden
log_action-Zielbezeichnung in app.py).

Neue gemeinsame Macro-Datei templates/_hint_icon.html, importiert von
15 Templates. Verifiziert: alle 18 betroffenen Seiten laden fehlerfrei
(200, keine JS-Fehler), Icon-Anzahl je Seite geprueft, Hover-Tooltip und
Klick-Modal auf Desktop, Tap-Modal auf simuliertem Mobilgeraet (390x844,
Touch), sowie stichprobenartig bestaetigt dass die bewusst belassenen
Hinweise unveraendert sichtbar bleiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:07:33 +02:00

395 lines
18 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 %}
{% import "_hint_icon.html" as hi %}
{% 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 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>
{% 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 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, compact=false) %}
<div class="permission-groups-row{{ ' permission-groups-row--compact' if compact }}">
{% for group_key, group in permission_catalog.items() %}
{{ 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 — bei Im-/Export: Import ausführen) ·
<strong>D</strong> = Dashboard (PoE-Neustart über das Dashboard-Popup, nur bei Clients) ·
<strong>X</strong> = Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)
</div>
{% endmacro %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Gruppen {{ hi.hint_icon("Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich. „Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.", "Gruppen") }}</h2>
</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 class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="groupSearch" placeholder="Gruppen durchsuchen…" oninput="filterGroupsTable()">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="groupsTable" 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>
<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" data-open-modal="adminGroupModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></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>
</tbody>
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<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="{{ 'Bearbeiten' if (can_edit_this or can_unlock_system) else 'Anzeigen' }}" data-open-modal="editGroupModal{{ loop.index }}">
{% if can_edit_this or can_unlock_system %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{% endif %}
</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>
</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>
<div class="modal-overlay" id="adminGroupModal">
<div class="modal" style="max-width:1000px;">
<div class="modal-header">
<h3>Admin — Rechte</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
{{ permission_tree(admin_virtual_group.permissions, true, 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>
</div>
</div>
</div>
<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>
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<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>
<div class="modal-overlay" id="editGroupModal{{ loop.index }}">
<div class="modal" style="max-width:1000px;">
{% 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 }}">
<div class="modal-header">
<h3>Gruppe bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<input type="text" name="name" value="{{ g.name }}" required>
</div>
{{ permission_tree(g.permissions, false, true) }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">
<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>
Speichern
</button>
</div>
</form>
{% elif can_unlock_system %}
<div class="modal-header">
<h3>Gruppe „{{ g.name }}“</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Name</label><input type="text" value="{{ g.name }}" disabled></div>
<div id="readonly-{{ g.id }}">
{{ permission_tree(g.permissions, true, true) }}
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px; flex-wrap:wrap;">
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
</div>
</div>
<form method="post" class="hidden" id="unlock-{{ g.id }}">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="unlock_system_group" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
{{ permission_tree(g.permissions, false, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
Rechte hier können den Erst-Login neuer Konten einschränken.
</p>
<div class="flex" style="justify-content:flex-end;">
<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>
</div>
{% else %}
<div class="modal-header">
<h3>Gruppe „{{ g.name }}“</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
{{ permission_tree(g.permissions, true, 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 %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="addGroupModal">
<div class="modal" style="max-width:1000px;">
<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 {{ hi.hint_icon("Mitglieder werden danach über die Gruppentabelle zugeordnet.", "Name") }}</label>
<input type="text" name="name" required placeholder="z.B. Facility-Team">
</div>
{{ permission_tree([], false) }}
</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 filterGroupsTable() {
const q = document.getElementById("groupSearch").value.trim().toLowerCase();
document.querySelectorAll("#groupsTable tbody").forEach(tbody => {
if (tbody.hasAttribute("data-sort-pinned")) return;
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
function unlockSystemGroup(id) {
window.confirmAction(
"Rechte der Standardgruppe „Benutzer“ wirklich bearbeiten? Diese Gruppe ist der Login-Fallback für neue Benutzer (auch neue AD/LDAP-Konten) — zu restriktive Rechte können deren Erst-Login einschränken.",
() => {
document.getElementById("readonly-" + id).classList.add("hidden");
document.getElementById("unlock-" + id).classList.remove("hidden");
},
"Standardgruppe freischalten?"
);
}
function applyPermissionGating() {
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;
});
if (tbody) tbody.classList.toggle("permission-locked", !toggle.checked);
};
toggle.addEventListener("change", sync);
sync();
});
}
function applyRowViewPrerequisite() {
document.querySelectorAll(".permission-table tbody tr").forEach(function (tr) {
const boxes = Array.from(tr.querySelectorAll(".permission-child-cb"));
if (boxes.length < 2) return;
const viewBox = boxes[0];
const restBoxes = boxes.slice(1);
restBoxes.forEach(function (cb) {
cb.addEventListener("change", function () {
if (cb.checked && !viewBox.checked && !viewBox.disabled) {
viewBox.checked = true;
}
});
});
viewBox.addEventListener("change", function () {
if (!viewBox.checked) {
restBoxes.forEach(function (cb) { cb.checked = false; });
}
});
});
}
document.addEventListener("DOMContentLoaded", applyPermissionGating);
document.addEventListener("DOMContentLoaded", applyRowViewPrerequisite);
</script>
{% endblock %}