Compare commits

..
2 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 af5dfe30b4 Fileshare: Mehrfachauswahl zum Herunterladen (ZIP) und Loeschen (v1.1.4)
- Checkbox-Spalte in der Dateitabelle (inkl. "alle auswaehlen" im Tabellen-
  kopf) -- sobald mindestens ein Eintrag markiert ist, erscheint eine
  Aktionsleiste mit "Herunterladen (ZIP)" und (mit fileshare.edit)
  "Loeschen".
- Herunterladen buendelt die Auswahl serverseitig zu EINEM ZIP statt
  mehrerer einzelner Downloads (vermeidet Browser-Blockaden bei vielen
  gleichzeitigen Downloads) -- neue Route /fileshare/download-multi,
  Ordner werden dabei rekursiv mit aufgenommen (relativer Pfad als
  Archivpfad).
- Loeschen mehrerer Elemente auf einmal ueber eine neue Route
  /fileshare/delete-multi (ein Bestaetigungsdialog fuer die ganze Auswahl),
  mit Sammel-Erfolgsmeldung analog zum Mehrfach-Upload.
- Die Pfadaufloesung+Validierung fuer ein einzelnes Kind-Element wurde aus
  dem bestehenden Einzel-Loeschen in _fileshare_resolve_child() extrahiert
  und von Einzel-Loeschen, Mehrfach-Loeschen UND Mehrfach-Download
  gemeinsam genutzt (kein duplizierter Sicherheitscode).

Live auf POETEST verifiziert: Mehrfachauswahl inkl. "alle auswaehlen",
ZIP-Download zweier Dateien (Inhalt geprueft), ZIP-Download eines Ordners
(rekursiv, korrekter Archivpfad), Mehrfach-Loeschen mit Sammelmeldung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:10:21 +02:00
alientimandClaude Sonnet 5 efb0d2aa01 Gruppen-Bearbeiten als Modal, Mobile-Fix, Mehrfach-Upload (v1.1.3)
- Gruppen bearbeiten (Name + Rechte) laeuft jetzt ueber ein Modal statt der
  bisherigen Inline-Ausklapp-Zeile -- gleiche Optik wie "Neue Gruppe". Der
  Gruppenname war dabei bisher ein verstecktes, nie wirklich editierbares
  Feld; jetzt ein normales Texteingabefeld (Backend unterstuetzte das
  Umbenennen inkl. Systemgruppen-Schutz bereits vollstaendig, es fehlte nur
  die Eingabemoeglichkeit im Formular). Admin-Rechte-Ansicht und die
  "Freischalten"-Ausnahme fuer die Systemgruppe "Benutzer" ziehen ins
  jeweilige Modal mit um.
- Fileshare-Baum + Tabelle nebeneinander sprengte auf Tablet-/Handybreite
  die Seite -- stapelt jetzt (Baum oben, Tabelle darunter) ab der
  bestehenden 900px-Sidebar-Umschaltgrenze.
- Mehrfach-Upload: Datei-Eingabefeld erlaubt jetzt echte Mehrfachauswahl
  (mehrere Dateien in einem Dialog) UND mehrmaliges Hinzufuegen
  nacheinander (per DataTransfer angesammelt, bevor "Hochladen" gedrueckt
  wird) -- funktioniert nativ auch auf Mobilgeraeten, da kein Custom-
  Upload-Mechanismus noetig ist. Backend verarbeitet jetzt eine Liste
  statt einer einzelnen Datei (request.files.getlist), mit Sammel-
  Erfolgsmeldung und pro Datei separater Namensvalidierung.

Live auf POETEST verifiziert: Umbenennen+Rechte-Speichern ueber das neue
Modal, Admin-Modal (readonly), Mobile-Layout (390px, Baum stapelt korrekt),
echte Mehrfachauswahl (2 Dateien in einem Dialog, beide korrekt
hochgeladen und einzeln in der Sammelmeldung genannt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:17:32 +02:00
5 changed files with 425 additions and 87 deletions
+1 -1
View File
@@ -1 +1 @@
1.1.2
1.1.4
+133 -13
View File
@@ -19,7 +19,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes, serialization
from cryptography import x509
from datetime import datetime, timedelta
import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback
import base64, csv, io, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
import paramiko
import yaml
import ssl
@@ -2289,18 +2289,31 @@ def fileshare_upload():
return redirect(url_for("fileshare", share=share, path=rel_path))
abs_dir = _fileshare_resolve_path(share, rel_path)
file = request.files.get("file")
if not abs_dir or not os.path.isdir(abs_dir) or not file or not file.filename:
# request.files.getlist() statt .get(): das Upload-Feld erlaubt jetzt
# Mehrfachauswahl (name="file" multiple) -- ein einzelner Dateiauswahl-
# Dialog liefert dann mehrere Files unter demselben Feldnamen, klassisch
# eine Datei liefert genauso eine Liste mit einem Element.
files = [f for f in request.files.getlist("file") if f and f.filename]
if not abs_dir or not os.path.isdir(abs_dir) or not files:
flash("Ungültiges Ziel oder keine Datei ausgewählt.", "danger")
else:
filename = secure_filename(file.filename)
dest = os.path.join(abs_dir, filename) if filename else None
if not filename or os.path.dirname(os.path.realpath(dest)) != os.path.realpath(abs_dir):
flash("Ungültiger Dateiname.", "danger")
else:
uploaded, rejected = [], []
for file in files:
filename = secure_filename(file.filename)
dest = os.path.join(abs_dir, filename) if filename else None
if not filename or os.path.dirname(os.path.realpath(dest)) != os.path.realpath(abs_dir):
rejected.append(file.filename)
continue
file.save(dest)
log_action("fileshare.upload", share, f"{rel_path}/{filename}".strip("/"))
flash(f"{filename}“ hochgeladen.", "success")
uploaded.append(filename)
if uploaded:
log_action("fileshare.upload", share, f"{rel_path}/".strip("/") + f" ({len(uploaded)} Datei(en): {', '.join(uploaded)})")
if len(uploaded) == 1:
flash(f"{uploaded[0]}“ hochgeladen.", "success")
else:
flash(f"{len(uploaded)} Dateien hochgeladen: {', '.join(uploaded)}.", "success")
if rejected:
flash(f"Ungültiger Dateiname, übersprungen: {', '.join(rejected)}.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
@@ -2327,6 +2340,21 @@ def fileshare_mkdir():
return redirect(url_for("fileshare", share=share, path=rel_path))
def _fileshare_resolve_child(share, rel_path, name):
"""Löst EINEN Kind-Eintrag (Datei oder Ordner) von rel_path auf und
stellt zusätzlich sicher, dass er auch tatsächlich direkt DARIN liegt
(per os.sep-Präfix-Vergleich des bereits Path-Traversal-geprüften
_fileshare_resolve_path) -- von delete/delete-multi/download-multi
gemeinsam genutzt. None bei jedem ungültigen Fall, wirft nie."""
if not name:
return None
parent_abs = _fileshare_resolve_path(share, rel_path)
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/"))
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
return None
return target_abs
@app.route("/fileshare/delete", methods=["POST"])
@login_required
def fileshare_delete():
@@ -2337,9 +2365,8 @@ def fileshare_delete():
return redirect(url_for("fileshare", share=share, path=rel_path))
name = request.form.get("name", "")
parent_abs = _fileshare_resolve_path(share, rel_path)
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/")) if name else None
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
target_abs = _fileshare_resolve_child(share, rel_path, name)
if not target_abs:
flash("Ungültiges Ziel.", "danger")
else:
try:
@@ -2354,6 +2381,99 @@ def fileshare_delete():
return redirect(url_for("fileshare", share=share, path=rel_path))
@app.route("/fileshare/delete-multi", methods=["POST"])
@login_required
def fileshare_delete_multi():
"""Wie /fileshare/delete, nur für eine per Checkbox ausgewählte Menge
an Dateien/Ordnern auf einmal (Mehrfachauswahl in der Tabelle)."""
share = request.form.get("share", "")
rel_path = request.form.get("path", "")
if not current_user.has_permission("fileshare.edit"):
flash("Keine Berechtigung, Dateien/Ordner zu löschen.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
names = [n for n in request.form.getlist("names") if n]
if not names:
flash("Keine Elemente ausgewählt.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
deleted, failed = [], []
for name in names:
target_abs = _fileshare_resolve_child(share, rel_path, name)
if not target_abs:
failed.append(name)
continue
try:
if os.path.isdir(target_abs):
shutil.rmtree(target_abs)
else:
os.remove(target_abs)
deleted.append(name)
except OSError:
failed.append(name)
if deleted:
log_action("fileshare.delete", share, f"{rel_path}/".strip("/") + f" ({len(deleted)} Element(e): {', '.join(deleted)})")
flash(f"{len(deleted)} Element(e) gelöscht: {', '.join(deleted)}.", "success")
if failed:
flash(f"Löschen fehlgeschlagen für: {', '.join(failed)}.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
@app.route("/fileshare/download-multi", methods=["POST"])
@login_required
def fileshare_download_multi():
"""Baut eine Auswahl aus mehreren Dateien/Ordnern zu EINEM ZIP zusammen
-- vermeidet, dass der Browser bei vielen einzelnen Downloads auf
einmal blockiert/nachfragt, und ist die auch anderswo (Drive, Nextcloud
etc.) übliche Erwartung bei Mehrfachauswahl. Ordner werden rekursiv mit
aufgenommen (relativer Pfad innerhalb des Ordners als Archivpfad)."""
share = request.form.get("share", "")
rel_path = request.form.get("path", "")
if not current_user.has_permission("fileshare.view"):
return "Keine Berechtigung.", 403
names = [n for n in request.form.getlist("names") if n]
if not names:
flash("Keine Elemente ausgewählt.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
targets = []
skipped = []
for name in names:
target_abs = _fileshare_resolve_child(share, rel_path, name)
if not target_abs or not os.path.exists(target_abs):
skipped.append(name)
continue
targets.append((name, target_abs))
if not targets:
flash("Keines der ausgewählten Elemente konnte gefunden werden.", "danger")
return redirect(url_for("fileshare", share=share, path=rel_path))
if skipped:
flash(f"Übersprungen (nicht gefunden): {', '.join(skipped)}.", "danger")
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for name, target_abs in targets:
if os.path.isdir(target_abs):
for root, _dirs, files in os.walk(target_abs):
for fname in files:
full = os.path.join(root, fname)
arcname = os.path.join(name, os.path.relpath(full, target_abs))
zf.write(full, arcname)
else:
zf.write(target_abs, name)
buffer.seek(0)
if len(targets) == 1:
base_name, _ext = os.path.splitext(targets[0][0])
zip_name = secure_filename(base_name if not os.path.isdir(targets[0][1]) else targets[0][0]) or "Download"
else:
zip_name = secure_filename(f"{share}-Auswahl") or "Download"
log_action("fileshare.download", share, f"{len(targets)} Element(e) als ZIP: {', '.join(n for n, _ in targets)}")
return send_file(buffer, as_attachment=True, download_name=f"{zip_name}.zip", mimetype="application/zip")
@app.route("/fileshare/rename", methods=["POST"])
@login_required
def fileshare_rename():
+24
View File
@@ -1360,6 +1360,24 @@ select {
.xlsx-preview-sheet-title { margin: 20px 0 8px; font-size: 13px; font-weight: 650; }
.xlsx-preview-sheet-title:first-child { margin-top: 0; }
/* Angesammelte Dateien im Upload-Modal (Mehrfachauswahl, siehe fileshare.html) */
.upload-file-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.upload-file-row {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 5px 10px;
background: var(--bg-elevated);
border: 1px solid var(--border-soft);
border-radius: 7px;
font-size: 12.5px;
}
.upload-file-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload-file-remove {
flex-shrink: 0;
border: none; background: transparent; color: var(--text-faint);
font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px;
}
.upload-file-remove:hover { color: var(--danger); }
/* ==========================================================================
Utilities
========================================================================== */
@@ -1404,6 +1422,12 @@ select {
Buttons/Suchfeld ineinanderzuschieben. */
.modal-footer { flex-wrap: wrap; }
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
/* Fileshare-Baum + Tabelle nebeneinander sprengt auf Tablet-/Handy-
Breite die Seite (Baum-Spalte ist fest 260px breit) -- Baum stapelt
stattdessen oben, Tabelle darunter in voller Breite. */
.fileshare-layout { flex-direction: column; }
.fileshare-tree { flex: 1 1 auto; max-width: 100%; max-height: 240px; }
}
@media (max-width: 640px) {
+169 -4
View File
@@ -66,10 +66,25 @@
</div>
</div>
<div id="bulkActionsBar" class="flex gap-2 hidden" data-share="{{ selected_share }}" data-path="{{ rel_path }}" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
<span id="bulkSelectedCount" class="text-faint" style="font-size:12.5px; font-weight:600;"></span>
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkDownload()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Herunterladen (ZIP)
</button>
{% if can_edit %}
<button type="button" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);" onclick="bulkDelete()">
<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>
Löschen
</button>
{% endif %}
</div>
{% if entries %}
<div style="overflow-x:auto;">
<table class="data-table" id="fileTable" data-sortable>
<thead><tr>
<th style="width:1%;"><input type="checkbox" id="selectAllFiles"></th>
<th data-sort-key="name">Name</th>
<th data-sort-key="size">Größe</th>
<th data-sort-key="mtime">Geändert</th>
@@ -78,6 +93,7 @@
<tbody>
{% for e in entries %}
<tr data-sort-name="{{ e.name|lower }}" data-sort-size="{{ 0 if e.is_dir else e.size_str }}" data-sort-mtime="{{ e.mtime_str }}">
<td><input type="checkbox" class="row-select" value="{{ e.name }}"></td>
<td class="cell-name">
{% if e.is_dir %}
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
@@ -140,14 +156,15 @@
<input type="hidden" name="share" value="{{ selected_share }}">
<input type="hidden" name="path" value="{{ rel_path }}">
<div class="modal-header">
<h3>Datei hochladen</h3>
<h3>Datei(en) hochladen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Datei</label>
<input type="file" name="file" required>
<div class="field-hint">Maximal 15&nbsp;MB pro Datei.</div>
<label>Datei(en)</label>
<input type="file" name="file" id="uploadFileInput" multiple required>
<div id="uploadFileList" class="upload-file-list"></div>
<div class="field-hint">Insgesamt maximal 15&nbsp;MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).</div>
</div>
<div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
</div>
@@ -227,6 +244,8 @@
<script>
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
const FILESHARE_DOWNLOAD_MULTI_URL = "{{ url_for('fileshare_download_multi') }}";
const FILESHARE_DELETE_MULTI_URL = "{{ url_for('fileshare_delete_multi') }}";
function openRenameModal(name) {
document.getElementById("renameOldName").value = name;
@@ -242,6 +261,152 @@ function filterTable(inputId, tableId) {
});
}
/* ---------------- Mehrfachauswahl (Herunterladen als ZIP / Löschen) ---------------- */
function getSelectedFileNames() {
return Array.from(document.querySelectorAll("#fileTable .row-select:checked")).map(function (cb) { return cb.value; });
}
function updateBulkBar() {
const names = getSelectedFileNames();
const bar = document.getElementById("bulkActionsBar");
const countEl = document.getElementById("bulkSelectedCount");
if (!bar) return;
if (names.length > 0) {
bar.classList.remove("hidden");
countEl.textContent = names.length + " ausgewählt";
} else {
bar.classList.add("hidden");
}
const selectAll = document.getElementById("selectAllFiles");
const allBoxes = document.querySelectorAll("#fileTable .row-select");
if (selectAll && allBoxes.length) {
selectAll.checked = names.length === allBoxes.length;
selectAll.indeterminate = names.length > 0 && names.length < allBoxes.length;
}
}
function submitBulkForm(actionUrl, names) {
// data-share/data-path statt Jinja-Werte direkt in einen JS-String-
// Literal zu setzen -- Ordnernamen kommen von der echten Freigabe und
// koennten Anfuehrungszeichen o.ae. enthalten, ueber HTML-Attribute
// (von Jinja automatisch escaped) ist das unproblematisch.
const bar = document.getElementById("bulkActionsBar");
const form = document.createElement("form");
form.method = "post";
form.action = actionUrl;
form.style.display = "none";
[["share", bar.dataset.share], ["path", bar.dataset.path]].forEach(function (pair) {
const input = document.createElement("input");
input.type = "hidden";
input.name = pair[0];
input.value = pair[1];
form.appendChild(input);
});
names.forEach(function (name) {
const input = document.createElement("input");
input.type = "hidden";
input.name = "names";
input.value = name;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function bulkDownload() {
const names = getSelectedFileNames();
if (!names.length) return;
submitBulkForm(FILESHARE_DOWNLOAD_MULTI_URL, names);
}
function bulkDelete() {
const names = getSelectedFileNames();
if (!names.length) return;
window.confirmAction(
names.length + " ausgewählte Elemente wirklich endgültig löschen?",
function () { submitBulkForm(FILESHARE_DELETE_MULTI_URL, names); },
"Auswahl löschen?"
);
}
document.addEventListener("DOMContentLoaded", function () {
const selectAll = document.getElementById("selectAllFiles");
if (selectAll) {
selectAll.addEventListener("change", function () {
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) { cb.checked = selectAll.checked; });
updateBulkBar();
});
}
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) {
cb.addEventListener("change", updateBulkBar);
});
});
/* ---------------- Mehrfach-Upload (Multiauswahl + mehrmals nacheinander) ---------------- */
/* Ein <input type=file multiple> ERSETZT bei jeder erneuten Dateiauswahl
die vorherige -- fuer "mehrmals nacheinander hinzufuegen" wird deshalb
selbst eine "angesammelte" Auswahl per DataTransfer gepflegt und nach
jeder Aenderung zurueck auf das Input-Feld geschrieben, sodass das
normale <form>-Submit (kein fetch() noetig) am Ende alle gesammelten
Dateien mitschickt. DataTransfer-Zuweisung an .files wird von allen
gaengigen Mobil-Browsern (Android Chrome, iOS Safari) mitgetragen; falls
nicht, faellt es einfach auf das native Verhalten (letzte Auswahl zaehlt)
zurueck, ohne den Upload an sich zu verhindern. */
(function () {
const input = document.getElementById("uploadFileInput");
const listEl = document.getElementById("uploadFileList");
if (!input || !listEl) return;
let staged = null;
try { staged = new DataTransfer(); } catch (e) { staged = null; }
function render() {
listEl.innerHTML = "";
if (!staged) return;
Array.from(staged.files).forEach(function (file, idx) {
const row = document.createElement("div");
row.className = "upload-file-row";
const name = document.createElement("span");
name.textContent = file.name;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "upload-file-remove";
removeBtn.setAttribute("aria-label", "Entfernen");
removeBtn.textContent = "×";
removeBtn.addEventListener("click", function () {
const dt = new DataTransfer();
Array.from(staged.files).forEach(function (f, i) {
if (i !== idx) dt.items.add(f);
});
staged = dt;
input.files = staged.files;
render();
});
row.appendChild(name);
row.appendChild(removeBtn);
listEl.appendChild(row);
});
}
input.addEventListener("change", function () {
if (!staged) return; // kein DataTransfer-Support -- natives Verhalten greift
Array.from(input.files).forEach(function (file) { staged.items.add(file); });
input.files = staged.files;
render();
});
// Beim (Wieder-)Oeffnen des Modals eine frische Sammlung starten, statt
// Dateien aus einem vorherigen, bereits abgeschickten Upload-Vorgang
// versehentlich mitzuschleppen.
document.querySelectorAll('[data-open-modal="uploadModal"]').forEach(function (btn) {
btn.addEventListener("click", function () {
try { staged = new DataTransfer(); } catch (e) { staged = null; }
input.value = "";
render();
});
});
})();
/* ---------------- Baum-Navigation (Freigaben links) ---------------- */
function buildTreeNode(share, path, name) {
+98 -69
View File
@@ -104,8 +104,8 @@
<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 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>
@@ -113,16 +113,11 @@
</div>
</td>
</tr>
<tr class="group-detail-row hidden" id="detail-admin">
<td colspan="3">
{{ 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>
</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">
@@ -132,8 +127,12 @@
<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 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>
@@ -149,57 +148,6 @@
</div>
</td>
</tr>
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<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, true) }}
<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>
{% elif can_unlock_system %}
<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;">
<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>
{% else %}
{{ 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 %}
{% endif %}
</td>
</tr>
</tbody>
{% else %}
<tbody data-sort-pinned>
@@ -210,6 +158,19 @@
</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">
@@ -238,6 +199,8 @@
</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">
@@ -268,6 +231,80 @@
</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">
@@ -317,14 +354,6 @@ function unlockSystemGroup(id) {
);
}
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)";
}
function applyPermissionGating() {
document.querySelectorAll(".permission-group-col").forEach(function (area) {
const toggle = area.querySelector(".permission-area-toggle-cb");