Compare commits
3
Commits
2c9ec8a100
..
v1.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efb0d2aa01 | ||
|
|
d461f8ca42 | ||
|
|
1b7fe81507 |
@@ -29,6 +29,11 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
|
||||||
|
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
|
||||||
|
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
|
||||||
|
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
|
||||||
|
client_max_body_size 16m;
|
||||||
proxy_pass http://127.0.0.1:5000;
|
proxy_pass http://127.0.0.1:5000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ sudo chmod 755 /var/log/tesm
|
|||||||
step "Deploying application to /srv/tesm"
|
step "Deploying application to /srv/tesm"
|
||||||
sudo mkdir -p /srv/tesm
|
sudo mkdir -p /srv/tesm
|
||||||
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||||
|
--exclude 'known_hosts' \
|
||||||
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
||||||
print_status "Application files copied"
|
print_status "Application files copied"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.1.0
|
1.1.3
|
||||||
|
|||||||
+142
-9
@@ -164,6 +164,14 @@ DEVICE_MAINTENANCE_CATEGORY = "linux"
|
|||||||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
# Fileshare-Uploads: bislang gab es keine Grenze (nginx' eigenes Limit vor
|
||||||
|
# app.py griff mangels client_max_body_size faktisch bei 1MB, siehe
|
||||||
|
# etc/nginx/sites-available/tesm bzw. _NGINX_PROXY_LOCATIONS -- beides jetzt
|
||||||
|
# passend auf 15/16MB angehoben). Ohne dieses Flask-seitige Limit würde ein
|
||||||
|
# zu großer Upload erst ganz am Ende, nach vollständigem Empfang, an
|
||||||
|
# secure_filename()/os.path-Prüfungen scheitern -- mit MAX_CONTENT_LENGTH
|
||||||
|
# bricht Werkzeug den Request sofort ab (413), siehe Fehlerbehandlung unten.
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_app_log_handler = logging.FileHandler(TESM_APP_LOG_PATH, encoding="utf-8")
|
_app_log_handler = logging.FileHandler(TESM_APP_LOG_PATH, encoding="utf-8")
|
||||||
@@ -175,6 +183,18 @@ except OSError:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(413)
|
||||||
|
def _handle_request_too_large(_e):
|
||||||
|
"""Greift z.B. beim Fileshare-Upload (siehe MAX_CONTENT_LENGTH oben) --
|
||||||
|
ohne diesen Handler würde Werkzeug eine nackte 413-Fehlerseite ohne
|
||||||
|
App-Look ausliefern. request.referrer statt einer festen Route, damit
|
||||||
|
das auch für andere, spätere Uploads (nicht nur Fileshare) die richtige
|
||||||
|
Seite trifft."""
|
||||||
|
max_mb = (app.config.get("MAX_CONTENT_LENGTH") or 0) // (1024 * 1024)
|
||||||
|
flash(f"Die Datei ist zu groß (Limit: {max_mb} MB).", "danger")
|
||||||
|
return redirect(request.referrer or url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
def _load_or_create_secret() -> str:
|
def _load_or_create_secret() -> str:
|
||||||
env_secret = os.environ.get("TESM_SECRET_KEY")
|
env_secret = os.environ.get("TESM_SECRET_KEY")
|
||||||
if env_secret:
|
if env_secret:
|
||||||
@@ -1833,6 +1853,35 @@ FILESHARE_MOUNT_ROOT = os.environ.get("TESM_FILESHARE_MOUNT_ROOT", "/mnt/tesm-sh
|
|||||||
FILESHARE_MAX_AGE_SECONDS = 12 * 3600
|
FILESHARE_MAX_AGE_SECONDS = 12 * 3600
|
||||||
FILESHARE_SWEEP_INTERVAL_SECONDS = 1800
|
FILESHARE_SWEEP_INTERVAL_SECONDS = 1800
|
||||||
|
|
||||||
|
# Inline-Vorschau (/fileshare/view, siehe unten): pro Endung, welche Art von
|
||||||
|
# Vorschau der Client bauen soll (steuert nur die UI/JS-Verzweigung) und mit
|
||||||
|
# welchem Content-Type die Datei ausgeliefert wird. Bewusst eine feste
|
||||||
|
# Positivliste -- alles andere bekommt gar keinen Vorschau-Button und die
|
||||||
|
# View-Route liefert für unbekannte Endungen 415 statt "irgendwas" mit vom
|
||||||
|
# Dateinamen geratenem Content-Type auszuliefern. .doc (altes Word-Binär-
|
||||||
|
# format) ist bewusst NICHT dabei -- mammoth.js kann nur .docx (OOXML)
|
||||||
|
# zuverlässig konvertieren; SheetJS dagegen liest sowohl alte .xls- als auch
|
||||||
|
# .xlsx-Dateien ordentlich, deshalb dort beide.
|
||||||
|
_FILESHARE_PREVIEW_KINDS = {
|
||||||
|
".pdf": "pdf",
|
||||||
|
".jpg": "image", ".jpeg": "image", ".png": "image", ".gif": "image",
|
||||||
|
".webp": "image", ".bmp": "image", ".svg": "image",
|
||||||
|
".txt": "text", ".csv": "text", ".log": "text", ".md": "text", ".json": "text",
|
||||||
|
".docx": "docx",
|
||||||
|
".xlsx": "xlsx", ".xls": "xlsx",
|
||||||
|
}
|
||||||
|
_FILESHARE_PREVIEW_MIME = {
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
|
||||||
|
".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml",
|
||||||
|
".txt": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8",
|
||||||
|
".log": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
|
||||||
|
".json": "text/plain; charset=utf-8",
|
||||||
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".xls": "application/vnd.ms-excel",
|
||||||
|
}
|
||||||
|
|
||||||
_active_fileshare_mounts = {}
|
_active_fileshare_mounts = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -2099,11 +2148,13 @@ def _fileshare_list_dir(abs_path):
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
is_dir = entry.is_dir(follow_symlinks=False)
|
is_dir = entry.is_dir(follow_symlinks=False)
|
||||||
|
ext = os.path.splitext(entry.name)[1].lower()
|
||||||
entries.append({
|
entries.append({
|
||||||
"name": entry.name,
|
"name": entry.name,
|
||||||
"is_dir": is_dir,
|
"is_dir": is_dir,
|
||||||
"size_str": "" if is_dir else _format_log_size(st.st_size),
|
"size_str": "" if is_dir else _format_log_size(st.st_size),
|
||||||
"mtime_str": datetime.fromtimestamp(st.st_mtime).strftime("%d.%m.%Y %H:%M"),
|
"mtime_str": datetime.fromtimestamp(st.st_mtime).strftime("%d.%m.%Y %H:%M"),
|
||||||
|
"preview_kind": None if is_dir else _FILESHARE_PREVIEW_KINDS.get(ext),
|
||||||
})
|
})
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
@@ -2111,6 +2162,29 @@ def _fileshare_list_dir(abs_path):
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _fileshare_tree_ancestors(label, rel_path):
|
||||||
|
"""Für die Baumansicht: liefert für JEDE Ebene von der Freigabe-Wurzel
|
||||||
|
bis zum aktuellen Pfad die dortigen Unterordner (nur Ordner, keine
|
||||||
|
Dateien) -- damit der Baum serverseitig schon bis zur aktuellen
|
||||||
|
Position aufgeklappt gerendert werden kann. Alles darüber hinaus
|
||||||
|
(Geschwister-Ordner, die der Nutzer selbst aufklappt) lädt der Client
|
||||||
|
bei Bedarf über /fileshare/subfolders nach. Schlüssel ist der jeweilige
|
||||||
|
Teilpfad ("" für die Freigabe-Wurzel selbst)."""
|
||||||
|
segments = [p for p in rel_path.split("/") if p]
|
||||||
|
expanded = {}
|
||||||
|
acc = []
|
||||||
|
for depth in range(len(segments) + 1):
|
||||||
|
current_rel = "/".join(acc)
|
||||||
|
abs_path = _fileshare_resolve_path(label, current_rel)
|
||||||
|
if not abs_path or not os.path.isdir(abs_path):
|
||||||
|
break
|
||||||
|
folders = [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]
|
||||||
|
expanded[current_rel] = folders
|
||||||
|
if depth < len(segments):
|
||||||
|
acc.append(segments[depth])
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
@app.route("/fileshare")
|
@app.route("/fileshare")
|
||||||
@login_required
|
@login_required
|
||||||
def fileshare():
|
def fileshare():
|
||||||
@@ -2150,9 +2224,50 @@ def fileshare():
|
|||||||
entries=_fileshare_list_dir(abs_path) if abs_path else [],
|
entries=_fileshare_list_dir(abs_path) if abs_path else [],
|
||||||
can_create=current_user.has_permission("fileshare.create"),
|
can_create=current_user.has_permission("fileshare.create"),
|
||||||
can_edit=current_user.has_permission("fileshare.edit"),
|
can_edit=current_user.has_permission("fileshare.edit"),
|
||||||
|
tree_expanded=_fileshare_tree_ancestors(selected_share, rel_path) if abs_path else {"": []},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/fileshare/subfolders")
|
||||||
|
@login_required
|
||||||
|
def fileshare_subfolders():
|
||||||
|
"""Lazy-Nachladen EINER Baumebene (nur Unterordner) für die Baum-
|
||||||
|
Navigation der Fileshare-Seite -- die Wurzel-bis-aktuell-Kette liefert
|
||||||
|
die Hauptroute bereits serverseitig mit (siehe tree_expanded), alles
|
||||||
|
andere (vom Nutzer aufgeklappte Geschwisterordner) holt der Client
|
||||||
|
gezielt über diese Route nach, ohne die Freigabe komplett zu durchlaufen."""
|
||||||
|
if not current_user.has_permission("fileshare.view"):
|
||||||
|
return jsonify({"error": "Keine Berechtigung."}), 403
|
||||||
|
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
|
||||||
|
if not abs_path or not os.path.isdir(abs_path):
|
||||||
|
return jsonify({"error": "Ungültiger Pfad."}), 404
|
||||||
|
return jsonify({"folders": [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/fileshare/view")
|
||||||
|
@login_required
|
||||||
|
def fileshare_view():
|
||||||
|
"""Inline-Vorschau (Content-Disposition NICHT 'attachment', anders als
|
||||||
|
/fileshare/download) für eine feste Positivliste von Dateitypen (siehe
|
||||||
|
_FILESHARE_PREVIEW_MIME) -- alles andere liefert bewusst 415 statt mit
|
||||||
|
geratenem Content-Type etwas potenziell Falsches inline auszuliefern.
|
||||||
|
nosniff + eine restriktive CSP zusätzlich als Tiefenverteidigung, falls
|
||||||
|
diese URL direkt (statt über das Vorschau-Modal) aufgerufen wird."""
|
||||||
|
if not current_user.has_permission("fileshare.view"):
|
||||||
|
return "Keine Berechtigung.", 403
|
||||||
|
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
|
||||||
|
if not abs_path or not os.path.isfile(abs_path):
|
||||||
|
return "Datei nicht gefunden.", 404
|
||||||
|
ext = os.path.splitext(abs_path)[1].lower()
|
||||||
|
mimetype = _FILESHARE_PREVIEW_MIME.get(ext)
|
||||||
|
if not mimetype:
|
||||||
|
return "Vorschau für diesen Dateityp nicht verfügbar.", 415
|
||||||
|
resp = send_file(abs_path, as_attachment=False, mimetype=mimetype, conditional=True)
|
||||||
|
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
resp.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@app.route("/fileshare/download")
|
@app.route("/fileshare/download")
|
||||||
@login_required
|
@login_required
|
||||||
def fileshare_download():
|
def fileshare_download():
|
||||||
@@ -2174,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:
|
||||||
filename = secure_filename(file.filename)
|
uploaded, rejected = [], []
|
||||||
dest = os.path.join(abs_dir, filename) if filename else None
|
for file in files:
|
||||||
if not filename or os.path.dirname(os.path.realpath(dest)) != os.path.realpath(abs_dir):
|
filename = secure_filename(file.filename)
|
||||||
flash("Ungültiger Dateiname.", "danger")
|
dest = os.path.join(abs_dir, filename) if filename else None
|
||||||
else:
|
if not filename or os.path.dirname(os.path.realpath(dest)) != os.path.realpath(abs_dir):
|
||||||
|
rejected.append(file.filename)
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
@@ -3143,6 +3271,11 @@ _NGINX_PROXY_LOCATIONS = """ location /ws/ {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
|
||||||
|
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
|
||||||
|
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
|
||||||
|
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
|
||||||
|
client_max_body_size 16m;
|
||||||
proxy_pass http://127.0.0.1:5000;
|
proxy_pass http://127.0.0.1:5000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -1292,6 +1292,92 @@ select {
|
|||||||
}
|
}
|
||||||
.xterm-container .xterm { height: 100%; }
|
.xterm-container .xterm { height: 100%; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Fileshare (Baum-Navigation + Vorschau)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* align-items:stretch (statt flex-start) + die main-Seite selbst als
|
||||||
|
Flex-Spalte mit table-wrap{flex:1}, damit beide Kacheln (Baum links,
|
||||||
|
Tabelle rechts) immer gleich hoch sind, unabhängig davon welche Seite
|
||||||
|
gerade mehr Inhalt hat. */
|
||||||
|
.fileshare-layout { display: flex; align-items: stretch; gap: 16px; }
|
||||||
|
.fileshare-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.fileshare-main .table-wrap { flex: 1; }
|
||||||
|
|
||||||
|
.fileshare-tree {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.fileshare-tree-title {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-weight: 650;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-root, .tree-children { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.tree-children { padding-left: 16px; }
|
||||||
|
|
||||||
|
.tree-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.tree-row:hover { background: var(--bg-card-hover); }
|
||||||
|
.tree-row.active { background: var(--bg-card-hover); color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
|
.tree-toggle {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: none; background: transparent; color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.tree-toggle:hover { color: var(--text); }
|
||||||
|
.tree-toggle:disabled { visibility: hidden; }
|
||||||
|
|
||||||
|
.tree-label {
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vorschau: von mammoth.js/SheetJS erzeugter bzw. selbst gebauter Inhalt */
|
||||||
|
.docx-preview { font-size: 14px; line-height: 1.65; }
|
||||||
|
.docx-preview table { border-collapse: collapse; margin: 10px 0; }
|
||||||
|
.docx-preview table td, .docx-preview table th { border: 1px solid var(--border); padding: 6px 10px; }
|
||||||
|
.docx-preview img { max-width: 100%; }
|
||||||
|
.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
|
Utilities
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -1336,6 +1422,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) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
@@ -5,106 +5,132 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
{% macro render_tree_node(share, path, name, expanded_map, active_path) %}
|
||||||
|
<li class="tree-node" data-share="{{ share }}" data-path="{{ path }}">
|
||||||
|
<div class="tree-row{{ ' active' if path == active_path else '' }}">
|
||||||
|
{% if path in expanded_map %}
|
||||||
|
<button type="button" class="tree-toggle" aria-expanded="true">▼</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="tree-toggle" aria-expanded="false">▶</button>
|
||||||
|
{% endif %}
|
||||||
|
<span class="tree-label">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px; height:14px; margin-right:5px; vertical-align:-2px; color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||||
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="tree-children{{ '' if path in expanded_map else ' hidden' }}"{% if path in expanded_map %} data-loaded="1"{% endif %}>
|
||||||
|
{% if path in expanded_map %}
|
||||||
|
{% for child in expanded_map[path] %}
|
||||||
|
{{ render_tree_node(share, (path ~ '/' ~ child) if path else child, child, expanded_map, active_path) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Dateifreigaben</h2>
|
<h2 style="font-size:16px;">Dateifreigaben</h2>
|
||||||
<div class="hint">Freigaben je nach AD-Gruppenmitgliedschaft für diese Sitzung gemountet — wird beim Abmelden wieder ausgehängt.</div>
|
<div class="hint">Freigaben je nach AD-Gruppenmitgliedschaft für diese Sitzung gemountet — wird beim Abmelden wieder ausgehängt.</div>
|
||||||
</div>
|
</div>
|
||||||
{% if shares|length > 1 %}
|
</div>
|
||||||
<div class="flex gap-2" style="align-items:center;">
|
|
||||||
<label for="shareSelect" class="text-faint" style="font-size:12.5px; font-weight:600;">Freigabe:</label>
|
<div class="fileshare-layout">
|
||||||
<select id="shareSelect" onchange="window.location.href='{{ url_for('fileshare') }}?share=' + encodeURIComponent(this.value);">
|
<div class="fileshare-tree card">
|
||||||
|
<div class="fileshare-tree-title">Freigaben</div>
|
||||||
|
<ul class="tree-root" id="fileshareTree">
|
||||||
{% for s in shares %}
|
{% for s in shares %}
|
||||||
<option value="{{ s }}" {% if s == selected_share %}selected{% endif %}>{{ s }}</option>
|
{{ render_tree_node(s, '', s, tree_expanded if s == selected_share else {}, rel_path if s == selected_share else None) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2" style="align-items:center; margin-bottom:14px; flex-wrap:wrap;">
|
<div class="fileshare-main">
|
||||||
<a href="{{ url_for('fileshare', share=selected_share) }}" class="mono" style="font-size:13px; font-weight:600;">{{ selected_share }}</a>
|
<div class="table-wrap">
|
||||||
{% for b in breadcrumbs %}
|
<div class="table-toolbar">
|
||||||
<span class="text-faint">/</span>
|
<div class="search-input">
|
||||||
<a href="{{ url_for('fileshare', share=selected_share, path=b.path) }}" class="mono" style="font-size:13px;">{{ b.name }}</a>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
{% endfor %}
|
<input type="text" id="fileSearch" placeholder="Datei/Ordner suchen..." oninput="filterTable('fileSearch', 'fileTable')">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if can_create %}
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-open-modal="mkdirModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><path d="M12 11v4M10 13h4"/></svg>
|
||||||
|
Neuer Ordner
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-open-modal="uploadModal">
|
||||||
|
<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="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||||
|
Hochladen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap" style="margin-bottom:16px;">
|
{% if entries %}
|
||||||
<div class="table-toolbar">
|
<div style="overflow-x:auto;">
|
||||||
<div class="search-input">
|
<table class="data-table" id="fileTable" data-sortable>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
<thead><tr>
|
||||||
<input type="text" id="fileSearch" placeholder="Datei/Ordner suchen..." oninput="filterTable('fileSearch', 'fileTable')">
|
<th data-sort-key="name">Name</th>
|
||||||
</div>
|
<th data-sort-key="size">Größe</th>
|
||||||
<div class="flex gap-2">
|
<th data-sort-key="mtime">Geändert</th>
|
||||||
{% if can_create %}
|
<th style="width:1%;">Aktionen</th>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-open-modal="mkdirModal">
|
</tr></thead>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><path d="M12 11v4M10 13h4"/></svg>
|
<tbody>
|
||||||
Neuer Ordner
|
{% for e in entries %}
|
||||||
</button>
|
<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 }}">
|
||||||
<button type="button" class="btn btn-primary btn-sm" data-open-modal="uploadModal">
|
<td class="cell-name">
|
||||||
<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="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
{% if e.is_dir %}
|
||||||
Hochladen
|
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
</button>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:15px; height:15px; margin-right:6px; vertical-align:-2px; color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||||
|
{{ e.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:15px; height:15px; margin-right:6px; vertical-align:-2px; color:var(--text-faint);"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
|
{{ e.name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-faint">{{ e.size_str }}</td>
|
||||||
|
<td class="text-faint">{{ e.mtime_str }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
{% if e.preview_kind %}
|
||||||
|
<button type="button" class="icon-btn" title="Vorschau"
|
||||||
|
data-preview-kind="{{ e.preview_kind }}"
|
||||||
|
data-preview-name="{{ e.name }}"
|
||||||
|
data-preview-url="{{ url_for('fileshare_view', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
{% if not e.is_dir %}
|
||||||
|
<a class="icon-btn" title="Herunterladen" href="{{ url_for('fileshare_download', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="icon-btn" title="Umbenennen" onclick="openRenameModal('{{ e.name|e }}')">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<form method="post" action="{{ url_for('fileshare_delete') }}" data-confirm="„{{ e.name }}“ wirklich endgültig löschen?">
|
||||||
|
<input type="hidden" name="share" value="{{ selected_share }}">
|
||||||
|
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||||
|
<input type="hidden" name="name" value="{{ e.name }}">
|
||||||
|
<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>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="padding:40px 16px; text-align:center; color:var(--text-faint);">Dieser Ordner ist leer.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if entries %}
|
|
||||||
<div style="overflow-x:auto;">
|
|
||||||
<table class="data-table" id="fileTable" data-sortable>
|
|
||||||
<thead><tr>
|
|
||||||
<th data-sort-key="name">Name</th>
|
|
||||||
<th data-sort-key="size">Größe</th>
|
|
||||||
<th data-sort-key="mtime">Geändert</th>
|
|
||||||
<th style="width:1%;">Aktionen</th>
|
|
||||||
</tr></thead>
|
|
||||||
<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 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) }}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:15px; height:15px; margin-right:6px; vertical-align:-2px; color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
|
||||||
{{ e.name }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:15px; height:15px; margin-right:6px; vertical-align:-2px; color:var(--text-faint);"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
|
||||||
{{ e.name }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-faint">{{ e.size_str }}</td>
|
|
||||||
<td class="text-faint">{{ e.mtime_str }}</td>
|
|
||||||
<td>
|
|
||||||
<div class="row-actions">
|
|
||||||
{% if not e.is_dir %}
|
|
||||||
<a class="icon-btn" title="Herunterladen" href="{{ url_for('fileshare_download', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
|
||||||
<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>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if can_edit %}
|
|
||||||
<button type="button" class="icon-btn" title="Umbenennen" onclick="openRenameModal('{{ e.name|e }}')">
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
<form method="post" action="{{ url_for('fileshare_delete') }}" data-confirm="„{{ e.name }}“ wirklich endgültig löschen?">
|
|
||||||
<input type="hidden" name="share" value="{{ selected_share }}">
|
|
||||||
<input type="hidden" name="path" value="{{ rel_path }}">
|
|
||||||
<input type="hidden" name="name" value="{{ e.name }}">
|
|
||||||
<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>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div style="padding:40px 16px; text-align:center; color:var(--text-faint);">Dieser Ordner ist leer.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can_create %}
|
{% if can_create %}
|
||||||
@@ -114,13 +140,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 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>
|
||||||
@@ -182,10 +210,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="previewModal">
|
||||||
|
<div class="modal" style="max-width:900px; width:90vw;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="previewTitle">Vorschau</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="previewBody" style="max-height:75vh; overflow:auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/vendor/mammoth.browser.min.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/vendor/xlsx.full.min.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
|
||||||
|
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
|
||||||
|
|
||||||
function openRenameModal(name) {
|
function openRenameModal(name) {
|
||||||
document.getElementById("renameOldName").value = name;
|
document.getElementById("renameOldName").value = name;
|
||||||
document.getElementById("renameNewName").value = name;
|
document.getElementById("renameNewName").value = name;
|
||||||
@@ -199,5 +242,276 @@ function filterTable(inputId, tableId) {
|
|||||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 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) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "tree-node";
|
||||||
|
li.dataset.share = share;
|
||||||
|
li.dataset.path = path;
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "tree-row";
|
||||||
|
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "tree-toggle";
|
||||||
|
toggle.textContent = "▶";
|
||||||
|
toggle.setAttribute("aria-expanded", "false");
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "tree-label";
|
||||||
|
label.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:5px;vertical-align:-2px;color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>';
|
||||||
|
label.append(document.createTextNode(name));
|
||||||
|
|
||||||
|
row.appendChild(toggle);
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const childUl = document.createElement("ul");
|
||||||
|
childUl.className = "tree-children hidden";
|
||||||
|
|
||||||
|
li.appendChild(row);
|
||||||
|
li.appendChild(childUl);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTreeNode(btn) {
|
||||||
|
const li = btn.closest(".tree-node");
|
||||||
|
const childUl = li.querySelector(":scope > .tree-children");
|
||||||
|
if (childUl.classList.contains("hidden")) {
|
||||||
|
if (childUl.dataset.loaded === "1") {
|
||||||
|
childUl.classList.remove("hidden");
|
||||||
|
btn.textContent = "▼";
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
} else {
|
||||||
|
loadTreeChildren(li, childUl, btn);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
childUl.classList.add("hidden");
|
||||||
|
btn.textContent = "▶";
|
||||||
|
btn.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTreeChildren(li, childUl, btn) {
|
||||||
|
const share = li.dataset.share;
|
||||||
|
const path = li.dataset.path;
|
||||||
|
const prevLabel = btn.textContent;
|
||||||
|
btn.textContent = "…";
|
||||||
|
fetch(FILESHARE_SUBFOLDERS_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
childUl.innerHTML = "";
|
||||||
|
(data.folders || []).forEach(name => {
|
||||||
|
const childPath = path ? path + "/" + name : name;
|
||||||
|
childUl.appendChild(buildTreeNode(share, childPath, name));
|
||||||
|
});
|
||||||
|
childUl.dataset.loaded = "1";
|
||||||
|
childUl.classList.remove("hidden");
|
||||||
|
btn.textContent = "▼";
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
})
|
||||||
|
.catch(() => { btn.textContent = prevLabel; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTree(share, path) {
|
||||||
|
window.location.href = FILESHARE_BASE_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("fileshareTree").addEventListener("click", function (e) {
|
||||||
|
const toggle = e.target.closest(".tree-toggle");
|
||||||
|
if (toggle) { toggleTreeNode(toggle); return; }
|
||||||
|
const label = e.target.closest(".tree-label");
|
||||||
|
if (label) {
|
||||||
|
const li = label.closest(".tree-node");
|
||||||
|
navigateTree(li.dataset.share, li.dataset.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------- Datei-Vorschau ---------------- */
|
||||||
|
|
||||||
|
const fileTableEl = document.getElementById("fileTable");
|
||||||
|
if (fileTableEl) {
|
||||||
|
fileTableEl.addEventListener("click", function (e) {
|
||||||
|
const btn = e.target.closest("[data-preview-kind]");
|
||||||
|
if (!btn) return;
|
||||||
|
openPreview(btn.dataset.previewKind, btn.dataset.previewName, btn.dataset.previewUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(kind, name, url) {
|
||||||
|
document.getElementById("previewTitle").textContent = name;
|
||||||
|
const body = document.getElementById("previewBody");
|
||||||
|
body.innerHTML = "";
|
||||||
|
PoeUI.openModal("previewModal");
|
||||||
|
|
||||||
|
if (kind === "pdf") {
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
iframe.src = url;
|
||||||
|
iframe.style.width = "100%";
|
||||||
|
iframe.style.height = "70vh";
|
||||||
|
iframe.style.border = "0";
|
||||||
|
body.appendChild(iframe);
|
||||||
|
} else if (kind === "image") {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = url;
|
||||||
|
img.alt = name;
|
||||||
|
img.style.maxWidth = "100%";
|
||||||
|
img.style.display = "block";
|
||||||
|
img.style.margin = "0 auto";
|
||||||
|
body.appendChild(img);
|
||||||
|
} else if (kind === "text") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
// Nur die ersten 512KB anfordern -- bei sehr großen Textdateien
|
||||||
|
// (Logs etc.) reicht das für eine Vorschau, ohne alles auf einmal
|
||||||
|
// laden zu müssen. Server unterstützt Range ueber send_file(conditional=True).
|
||||||
|
fetch(url, { headers: { "Range": "bytes=0-524287" } })
|
||||||
|
.then(r => {
|
||||||
|
// r.status ist bei einem Range-Request praktisch immer 206,
|
||||||
|
// auch wenn die Datei kleiner als die angefragten 512KB ist
|
||||||
|
// (der Server liefert dann trotzdem "206" mit der kompletten
|
||||||
|
// Datei) -- ob wirklich abgeschnitten wurde, steht nur im
|
||||||
|
// Content-Range-Header ("bytes 0-524287/<Gesamtgroesse>").
|
||||||
|
const contentRange = r.headers.get("Content-Range") || "";
|
||||||
|
const m = contentRange.match(/\/(\d+)$/);
|
||||||
|
const truncated = !!m && parseInt(m[1], 10) > 524288;
|
||||||
|
return r.text().then(text => ({ text: text, truncated: truncated }));
|
||||||
|
})
|
||||||
|
.then(({ text, truncated }) => {
|
||||||
|
body.innerHTML = "";
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.style.whiteSpace = "pre-wrap";
|
||||||
|
pre.style.wordBreak = "break-word";
|
||||||
|
pre.style.fontSize = "12.5px";
|
||||||
|
pre.textContent = text;
|
||||||
|
body.appendChild(pre);
|
||||||
|
if (truncated) {
|
||||||
|
const hint = document.createElement("div");
|
||||||
|
hint.className = "text-faint";
|
||||||
|
hint.style.marginTop = "10px";
|
||||||
|
hint.style.fontSize = "12px";
|
||||||
|
hint.textContent = "Nur die ersten 512 KB angezeigt — bitte herunterladen für die komplette Datei.";
|
||||||
|
body.appendChild(hint);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
|
||||||
|
} else if (kind === "docx") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
fetch(url).then(r => r.arrayBuffer())
|
||||||
|
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
|
||||||
|
.then(result => {
|
||||||
|
body.innerHTML = "";
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "docx-preview";
|
||||||
|
// mammoth erzeugt kontrolliertes HTML aus Words eigenem,
|
||||||
|
// begrenztem Formatierungsmodell (Absätze/Tabellen/Listen/
|
||||||
|
// Bilder) -- kein beliebiges, aus der Datei uebernommenes
|
||||||
|
// Skript kann darin stecken, anders als bei generischem
|
||||||
|
// "fremdes HTML direkt einbetten".
|
||||||
|
wrap.innerHTML = result.value;
|
||||||
|
body.appendChild(wrap);
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden (Format evtl. nicht unterstützt)."; });
|
||||||
|
} else if (kind === "xlsx") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
fetch(url).then(r => r.arrayBuffer())
|
||||||
|
.then(buf => {
|
||||||
|
const wb = XLSX.read(buf, { type: "array" });
|
||||||
|
body.innerHTML = "";
|
||||||
|
wb.SheetNames.forEach(function (sheetName, idx) {
|
||||||
|
const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], { header: 1, defval: "" });
|
||||||
|
const h4 = document.createElement("div");
|
||||||
|
h4.className = "xlsx-preview-sheet-title";
|
||||||
|
h4.textContent = sheetName;
|
||||||
|
body.appendChild(h4);
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.style.overflowX = "auto";
|
||||||
|
const table = document.createElement("table");
|
||||||
|
table.className = "data-table";
|
||||||
|
// Zellenwerte bewusst per textContent statt ueber die
|
||||||
|
// eingebaute HTML-Ausgabe von SheetJS gesetzt -- so ist
|
||||||
|
// die Vorschau unabhaengig von deren Escaping-Verhalten
|
||||||
|
// garantiert sicher gegen Inhalte in den Zellen.
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
row.forEach(function (cell) {
|
||||||
|
const td = document.createElement("td");
|
||||||
|
td.textContent = (cell === null || cell === undefined) ? "" : String(cell);
|
||||||
|
tr.appendChild(td);
|
||||||
|
});
|
||||||
|
table.appendChild(tr);
|
||||||
|
});
|
||||||
|
wrap.appendChild(table);
|
||||||
|
body.appendChild(wrap);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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