Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb8b929c3c | ||
|
|
af5dfe30b4 | ||
|
|
efb0d2aa01 |
+1
-1
@@ -1 +1 @@
|
|||||||
1.1.2
|
1.1.5
|
||||||
|
|||||||
+130
-10
@@ -19,7 +19,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|||||||
from cryptography.hazmat.primitives import hashes, serialization
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
from datetime import datetime, timedelta
|
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 paramiko
|
||||||
import yaml
|
import yaml
|
||||||
import ssl
|
import ssl
|
||||||
@@ -2289,18 +2289,31 @@ def fileshare_upload():
|
|||||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||||
|
|
||||||
abs_dir = _fileshare_resolve_path(share, rel_path)
|
abs_dir = _fileshare_resolve_path(share, rel_path)
|
||||||
file = request.files.get("file")
|
# request.files.getlist() statt .get(): das Upload-Feld erlaubt jetzt
|
||||||
if not abs_dir or not os.path.isdir(abs_dir) or not file or not file.filename:
|
# 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")
|
flash("Ungültiges Ziel oder keine Datei ausgewählt.", "danger")
|
||||||
else:
|
else:
|
||||||
|
uploaded, rejected = [], []
|
||||||
|
for file in files:
|
||||||
filename = secure_filename(file.filename)
|
filename = secure_filename(file.filename)
|
||||||
dest = os.path.join(abs_dir, filename) if filename else None
|
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):
|
if not filename or os.path.dirname(os.path.realpath(dest)) != os.path.realpath(abs_dir):
|
||||||
flash("Ungültiger Dateiname.", "danger")
|
rejected.append(file.filename)
|
||||||
else:
|
continue
|
||||||
file.save(dest)
|
file.save(dest)
|
||||||
log_action("fileshare.upload", share, f"{rel_path}/{filename}".strip("/"))
|
uploaded.append(filename)
|
||||||
flash(f"„{filename}“ hochgeladen.", "success")
|
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))
|
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))
|
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"])
|
@app.route("/fileshare/delete", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def fileshare_delete():
|
def fileshare_delete():
|
||||||
@@ -2337,9 +2365,8 @@ def fileshare_delete():
|
|||||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||||
|
|
||||||
name = request.form.get("name", "")
|
name = request.form.get("name", "")
|
||||||
parent_abs = _fileshare_resolve_path(share, rel_path)
|
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||||||
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/")) if name else None
|
if not target_abs:
|
||||||
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
|
|
||||||
flash("Ungültiges Ziel.", "danger")
|
flash("Ungültiges Ziel.", "danger")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -2354,6 +2381,99 @@ def fileshare_delete():
|
|||||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
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"])
|
@app.route("/fileshare/rename", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def fileshare_rename():
|
def fileshare_rename():
|
||||||
|
|||||||
@@ -516,6 +516,11 @@ button { font-family: inherit; }
|
|||||||
.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); }
|
.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); }
|
||||||
.pill.action-pill::before { display: none; }
|
.pill.action-pill::before { display: none; }
|
||||||
.pill.action-pill svg { width: 13px; height: 13px; }
|
.pill.action-pill svg { width: 13px; height: 13px; }
|
||||||
|
/* Auditlog: Hinzufuegen gruen, Loeschen rot, alles andere (Bearbeiten/
|
||||||
|
Aktivieren/Zuweisen/...) orange -- siehe activity_log.html */
|
||||||
|
.pill.action-pill--create { background: var(--success-dim); color: var(--success); }
|
||||||
|
.pill.action-pill--delete { background: var(--danger-dim); color: var(--danger); }
|
||||||
|
.pill.action-pill--edit { background: var(--accent-dim); color: var(--accent-strong); }
|
||||||
|
|
||||||
.avatar-sm {
|
.avatar-sm {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1360,6 +1365,24 @@ select {
|
|||||||
.xlsx-preview-sheet-title { margin: 20px 0 8px; font-size: 13px; font-weight: 650; }
|
.xlsx-preview-sheet-title { margin: 20px 0 8px; font-size: 13px; font-weight: 650; }
|
||||||
.xlsx-preview-sheet-title:first-child { margin-top: 0; }
|
.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
|
Utilities
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -1404,6 +1427,12 @@ select {
|
|||||||
Buttons/Suchfeld ineinanderzuschieben. */
|
Buttons/Suchfeld ineinanderzuschieben. */
|
||||||
.modal-footer { flex-wrap: wrap; }
|
.modal-footer { flex-wrap: wrap; }
|
||||||
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
"profile.update": "Profil aktualisiert",
|
"profile.update": "Profil aktualisiert",
|
||||||
"profile.password": "Passwort geändert",
|
"profile.password": "Passwort geändert",
|
||||||
"check.run_now": "Prüfung manuell gestartet",
|
"check.run_now": "Prüfung manuell gestartet",
|
||||||
|
"fileshare.upload": "Datei(en) hochgeladen",
|
||||||
|
"fileshare.mkdir": "Ordner angelegt",
|
||||||
|
"fileshare.delete": "Datei/Ordner gelöscht",
|
||||||
|
"fileshare.rename": "Datei/Ordner umbenannt",
|
||||||
|
"fileshare.download": "Als ZIP heruntergeladen",
|
||||||
} %}
|
} %}
|
||||||
{% set action_icons = {
|
{% set action_icons = {
|
||||||
"delete": '<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"/>',
|
"delete": '<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"/>',
|
||||||
@@ -37,6 +42,12 @@
|
|||||||
"activate": '<path d="M20 6L9 17l-5-5"/>',
|
"activate": '<path d="M20 6L9 17l-5-5"/>',
|
||||||
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
||||||
} %}
|
} %}
|
||||||
|
{% set action_kind_class = {
|
||||||
|
"create": "action-pill--create",
|
||||||
|
"upload": "action-pill--create",
|
||||||
|
"mkdir": "action-pill--create",
|
||||||
|
"delete": "action-pill--delete",
|
||||||
|
} %}
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
@@ -56,7 +67,7 @@
|
|||||||
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
|
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
|
||||||
<td class="cell-name">{{ e['username'] }}</td>
|
<td class="cell-name">{{ e['username'] }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="pill action-pill">
|
<span class="pill action-pill {{ action_kind_class.get(kind, 'action-pill--edit') }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
|
||||||
{{ action_labels.get(e['action'], e['action']) }}
|
{{ action_labels.get(e['action'], e['action']) }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -66,10 +66,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% if entries %}
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="data-table" id="fileTable" data-sortable>
|
<table class="data-table" id="fileTable" data-sortable>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
|
<th style="width:1%;"><input type="checkbox" id="selectAllFiles"></th>
|
||||||
<th data-sort-key="name">Name</th>
|
<th data-sort-key="name">Name</th>
|
||||||
<th data-sort-key="size">Größe</th>
|
<th data-sort-key="size">Größe</th>
|
||||||
<th data-sort-key="mtime">Geändert</th>
|
<th data-sort-key="mtime">Geändert</th>
|
||||||
@@ -78,6 +93,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for e in entries %}
|
{% 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 }}">
|
<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">
|
<td class="cell-name">
|
||||||
{% if e.is_dir %}
|
{% if e.is_dir %}
|
||||||
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
<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="share" value="{{ selected_share }}">
|
||||||
<input type="hidden" name="path" value="{{ rel_path }}">
|
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Datei hochladen</h3>
|
<h3>Datei(en) hochladen</h3>
|
||||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Datei</label>
|
<label>Datei(en)</label>
|
||||||
<input type="file" name="file" required>
|
<input type="file" name="file" id="uploadFileInput" multiple required>
|
||||||
<div class="field-hint">Maximal 15 MB pro Datei.</div>
|
<div id="uploadFileList" class="upload-file-list"></div>
|
||||||
|
<div class="field-hint">Insgesamt maximal 15 MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).</div>
|
||||||
</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 class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,6 +244,8 @@
|
|||||||
<script>
|
<script>
|
||||||
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
|
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
|
||||||
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
|
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) {
|
function openRenameModal(name) {
|
||||||
document.getElementById("renameOldName").value = 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) ---------------- */
|
/* ---------------- Baum-Navigation (Freigaben links) ---------------- */
|
||||||
|
|
||||||
function buildTreeNode(share, path, name) {
|
function buildTreeNode(share, path, name) {
|
||||||
|
|||||||
@@ -104,8 +104,8 @@
|
|||||||
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="icon-btn" title="Rechte anzeigen" onclick="toggleDetail('detail-admin')">
|
<button class="icon-btn" title="Rechte anzeigen" data-open-modal="adminGroupModal">
|
||||||
<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>
|
<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>
|
||||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
<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>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
|
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
{% 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 }}">
|
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="cell-name">
|
<td class="cell-name">
|
||||||
@@ -132,8 +127,12 @@
|
|||||||
<td class="text-dim">{{ g.member_names|length }}</td>
|
<td class="text-dim">{{ g.member_names|length }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
|
<button class="icon-btn" title="{{ 'Bearbeiten' if (can_edit_this or can_unlock_system) else 'Anzeigen' }}" data-open-modal="editGroupModal{{ loop.index }}">
|
||||||
<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>
|
{% 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>
|
||||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
<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>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tbody data-sort-pinned>
|
<tbody data-sort-pinned>
|
||||||
@@ -210,6 +158,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>×</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-overlay" id="adminMembersModal">
|
||||||
<div class="modal" style="max-width:380px;">
|
<div class="modal" style="max-width:380px;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -238,6 +199,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for g in groups %}
|
{% 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-overlay" id="membersModal{{ loop.index }}">
|
||||||
<div class="modal" style="max-width:380px;">
|
<div class="modal" style="max-width:380px;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -268,6 +231,80 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>×</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>×</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>×</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 %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="modal-overlay" id="addGroupModal">
|
<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() {
|
function applyPermissionGating() {
|
||||||
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
||||||
const toggle = area.querySelector(".permission-area-toggle-cb");
|
const toggle = area.querySelector(".permission-area-toggle-cb");
|
||||||
|
|||||||
Reference in New Issue
Block a user