Compare commits

...
22 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 8577cd24cd Export-Kategorien: kuerzere Namen, Auditlog/Benutzer-Hinweise als Icon (weiterhin v1.1.11)
Sichtbare Checkbox-Namen im Export-Formular verkuerzt (nur die Kachel-
Ansicht betroffen, Funktionalitaet/Schluessel unveraendert):
Custom-Gruppen -> Gruppen, LDAP/AD-Einstellungen -> LDAP/AD,
DHCP-Einstellungen -> DHCP, NGINX-Einstellungen -> NGINX,
Benutzer (lokal) -> Benutzer.

Neue Hinweis-Icons (Klammerzusaetze wandern konsistent zu den bereits
bestehenden bei Zugangsdaten/NGINX ins "i"-Icon statt in den Namen):
- Auditlog: Hinweis, dass nur die aktuell in der DB stehenden Eintraege
  exportiert werden (bereits archivierte Tage liegen als eigene Dateien
  vor).
- Benutzer: Hinweis, dass AD/LDAP-Benutzer NICHT gesichert werden (nur
  lokale Konten sind Teil des Exports -- AD-Konten legen sich beim
  naechsten Login ohnehin automatisch neu an).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:35:49 +02:00
alientimandClaude Sonnet 5 877903bd77 NGINX-Export inkl. Zertifikat/Schluessel, Kategorie-Hinweise als Icon (weiterhin v1.1.11)
- NGINX-Export nimmt jetzt zusaetzlich das aktuell hinterlegte TLS-
  Zertifikat und den privaten Schluessel selbst mit (Base64, falls
  vorhanden) -- der gesamte Export ist ohnehin passphrasenverschluesselt,
  genau wie andere Geheimnisse (Zugangsdaten-Passwoerter, LDAP-Bind-
  Passwort) bereits im Klartext darin landen. Bewusst weiterhin
  AUSGESCHLOSSEN bleibt jeglicher Let's-Encrypt-/certbot-Zustand
  (ssl_source, ACME-Konto, Renewal-Timer) -- an genau diesen Host/diese
  Domain-Registrierung gebunden, kann nicht mitziehen. Ein Import setzt
  ssl_source deshalb immer auf "upload", auch wenn urspruenglich per Let's
  Encrypt ausgestellt.
- Die erklaerenden Klammerzusaetze bei "Zugangsdaten" (know_hosts-Hinweis)
  und "NGINX-Einstellungen" (Zertifikat/Schluessel-Hinweis) sind jetzt ein
  "i"-Hinweis-Icon statt Fliesstext im Label, passend zum bestehenden
  Hover-Tooltip-/Klick-Modal-Muster (siehe v1.1.10).

Verifiziert per isoliertem Test mit einem echten (selbstsignierten)
Test-Zertifikat: Export->Import-Roundtrip liefert bitidentische Cert-/
Key-Dateien mit korrekten Berechtigungen (0600 Schluessel, 0644
Zertifikat) auf einem eigenen Zielpfad, sowie live per Browser gegen
POETEST (Hover zeigt korrekten Tooltip-Text, 2 Icons im Export-Formular).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:33:31 +02:00
alientimandClaude Sonnet 5 2b05d012ee Im-/Export: fehlende Felder ergaenzt, NGINX neu, known_hosts (v1.1.11)
Vollstaendiger Audit aller Export-/Import-Kategorien gegen das tatsaechliche
DB-Schema (7 parallele Pruef-Agents je Kategorie) ergab mehrere Luecken --
alle behoben:

- Auditlog-Export (v1.1.10-Nacharbeit): kein Deckel mehr bei 500 Zeilen,
  exportiert wird das komplette aktuell in der DB stehende Auditlog
  (bereits archivierte Tage liegen ohnehin als eigene Dateien vor).
- Clients: ssh_port und Zugangsdaten-Zuordnung (als portabler
  credential_name statt roher ID) fehlten komplett im Export.
- Switche: ssh_port fehlte komplett im Export.
- Zugangsdaten: category (z.B. "Linux-Client" fuer Wartung) fehlte.
- Custom-Gruppen: is_default fehlte (war beim Import zusaetzlich hart auf 0
  gesetzt statt aus der Importdatei uebernommen).
- LDAP-Gruppenzuordnungen: app_group_id wurde als ROHE lokale Datenbank-ID
  exportiert -- auf einem frisch installierten Zielsystem (der eigentliche
  Zweck dieser Funktion, siehe Seiten-Untertitel "Umzug auf eine neue
  Umgebung") haben Gruppen dort andere IDs, die Zuordnung waere kaputt oder
  auf die falsche Gruppe gezeigt. Jetzt wird der Gruppenname exportiert
  (bzw. "Admin" als Sonderfall) und beim Import auf dem Zielsystem wieder zur
  dortigen ID aufgeloest -- unbekannte Zielgruppen werden übersprungen statt
  eine kaputte Referenz anzulegen.
- ldap_fileshare_mappings (AD-Gruppe -> Freigabe) fehlte im Export/Import
  komplett -- alle Fileshare-Zuordnungen gingen bei jedem Umzug verloren.
- ldap_required_login_group (Login-Beschraenkung auf eine AD-Gruppe) fehlte
  in LDAP_SETTING_KEYS und wurde nie exportiert.
- NGINX-Einstellungen (Domain, Ports, SSL/HSTS-Schalter) hatten bisher gar
  keine Export-/Import-Kategorie. Bewusst AUSGESCHLOSSEN bleiben ssl_source,
  das TLS-Zertifikat und der private Schluessel sowie jeglicher
  Let's-Encrypt-/certbot-Zustand -- das sind host-gebundene reale Dateien
  bzw. eine ACME-Registrierung fuer genau diesen Host, kein portabler
  Einstellungswert.
- known_hosts: sobald Zugangsdaten exportiert werden, wird zusaetzlich der
  Inhalt von known_hosts mitexportiert (jeder per SSH bereits bestaetigte
  Host-Key gehoert inhaltlich zu den Zugangsdaten) und beim Import ergaenzend
  (nicht ueberschreibend) mit Zeilen-Deduplizierung in die eigene
  known_hosts-Datei eingespielt.

avatar_filename (Benutzer) bleibt bewusst vom Export ausgeschlossen -- reiner
Dateiname ohne die zugehoerige Bilddatei waere auf dem Zielsystem nur ein
kaputter Verweis, analog zur bewussten Nichtaufnahme von TLS-Zertifikat/
Schluessel bei NGINX.

Verifiziert per isoliertem Roundtrip-Test gegen eine Kopie der echten
Datenbank (nie die Produktivdaten selbst): Export -> Loeschen -> Re-Import
fuer jede geaenderte Kategorie plus known_hosts-Merge (inkl.
Deduplizierungs-Probe durch zweifachen Import derselben Datei), sowie ein
echter Browser-Exportlauf gegen POETEST zur Bestaetigung der UI/Label-Texte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:27:31 +02:00
alientimandClaude Sonnet 5 0f0fa99e53 Feld-/Funktionserklaerungen als Hover-Tooltip + Klick-Modal (v1.1.10)
Statisch sichtbare Erklaerungstexte (.hint/.field-hint) app-weit auf ein
einheitliches "i"-Icon umgestellt: Hover zeigt den Text neben dem
Mauszeiger, Klick/Tap oeffnet ein wiederverwendetes Modal mit dem vollen
Text. Zentral in app.js (initHintIcons, ensureHintTooltip/-Modal) via
Event-Delegation, kein Aufwand pro Seite. Hover ist bewusst per
matchMedia("(hover: hover) and (pointer: fine)") gated -- auf Touch-
Geraeten faellt automatisch nur Tap-zu-Modal an, da echte
mouseenter/mousemove-Events dort ohnehin nicht zuverlaessig auftreten.

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:07:33 +02:00
alientimandClaude Sonnet 5 9b4cf8c2e1 Auditlog: Statistik-Kacheln zeigen geladen/gesamt (weiterhin v1.1.9)
Alle vier Kacheln (Alle, Hinzufuegen, Aenderungen, Loeschungen) zeigen
jetzt "geladen / gesamt" (z.B. "300 / 466"), solange noch nicht alles
geladen ist -- Gesamtzahlen je Kategorie kommen aus einer einzigen
aggregierten SQL-Abfrage (SUM/CASE ueber die bestehenden LIKE-Muster von
category_of), nicht aus einem vollstaendigen Tabellen-Scan. Sobald alles
geladen ist, wird wieder nur die reine Zahl angezeigt (client- wie
serverseitig identische Formatierungslogik).

Dieser Fix wird per Nutzervorgabe unter der GLEICHEN Releasnummer v1.1.9
ausgeliefert (Tag wird bewusst weitergezogen statt neu vergeben, da
v1.1.9 zum Zeitpunkt dieses Commits zwar schon getaggt, aber die
Kachel-Anzeige noch nicht ausgereift war).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:38:30 +02:00
alientimandClaude Sonnet 5 1c0082d3d4 Auditlog: Nachladen aelterer Eintraege in 300er-Schritten (v1.1.9)
Die Auditlog-Seite zeigte bisher unveraenderlich nur die neuesten 500
Zeilen an -- alles Aeltere war komplett unsichtbar (auch wenn es noch
nicht archiviert war, siehe v1.1.6). Jetzt:

- Seitengroesse von 500 auf 300 reduziert (Erstladung UND jeder Nachlade-
  Schritt).
- Neuer Button "Mehr laden (300)" (orange) laedt per AJAX genau den
  naechsten 300er-Block aelterer Eintraege nach (Cursor-Pagination ueber
  die id-Spalte) und haengt ihn ans bestehende tbody an, ohne die Seite
  neu zu laden.
- Neuer Button "Alle laden" ruft denselben Nachlade-Schritt wiederholt
  auf, bis wirklich alles geladen ist (mit Fortschrittsanzeige im
  Button-Text), statt in einer Anfrage die komplette (potenziell sehr
  grosse) Tabelle auf einmal zu rendern.
- Suche/Sortierung/Kategorie-Filter sowie die vier Statistik-Kacheln
  arbeiten transparent ueber ALLE aktuell geladenen Zeilen (nicht nur die
  urspruenglichen 300) -- Zeilenliste fuer die Filterung wird jetzt bei
  jedem Aufruf frisch aus dem DOM gelesen statt einmalig zwischengespeichert.
- Zeilen-Rendering (Aktions-Label/-Icon/-Farbe) in ein gemeinsames Makro
  (_audit_log_macros.html) ausgelagert, das sowohl die Erstladung als auch
  das AJAX-Fragment (_audit_log_rows.html) nutzen -- verhindert Abweichungen
  zwischen beiden Darstellungen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:31:48 +02:00
alientimandClaude Sonnet 5 fc4b165d23 Auditlog: Ziel-Spalte wieder dynamisch statt erzwungen einzeilig (v1.1.8)
Das white-space:nowrap aus v1.1.7 sollte nur den Sonderfall des neuen
Auditlog-Export-Eintrags fixen, hat aber als Nebenwirkung die gesamte
Ziel-Spalte in die Breite gezogen: der bereits laenger bestehenden Aktion
data.export gehoert ein bis zu 135 Zeichen langes Ziel (Liste der
exportierten Kategorien), und table-layout:auto bemisst eine Spaltenbreite
anhand des LAENGSTEN unzeilenumbrochenen Inhalts ueber alle (auch gerade
nicht gefilterte) Zeilen hinweg -- nicht nur der sichtbaren.

Der eigentliche Ausloeser (zu langer Wert im Ziel-Feld) ist mit v1.1.7
bereits behoben (Ziel = kurzer Ressourcenname wie "Auditlog", Dateiliste
in Details) und macht das erzwungene nowrap ueberfluessig. Zeilenumbruch
bei den seltenen laengeren Zielen (wie data.export) ist unproblematisch,
genau wie es die Details-Spalte schon immer handhabt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:22:01 +02:00
alientimandClaude Sonnet 5 e81735d5ec Auditlog-Export: Ziel/Details korrekt getrennt (v1.1.7)
Beim Export archivierter Auditlog-Tage (log_action("auditlog.export_delete", ...))
stand faelschlich die komplette Dateiliste im Feld "Ziel" (dadurch wurde die
Spalte in der Auditlog-Tabelle wieder unschoen breit), statt wie bei allen
anderen Aktionen ueblich einen kurzen Ressourcennamen im Ziel und die
ausfuehrliche Beschreibung in Details zu haben. Jetzt: Ziel = "Auditlog",
Details = Anzahl + exakte Dateinamen. Gleiche Korrektur fuer den
automatischen Archivierungslauf selbst (auditlog.archive). "Ziel"-Spalte
zusaetzlich mit white-space:nowrap gegen Umbrueche bei laengeren Werten
abgesichert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:19:07 +02:00
alientimandClaude Sonnet 5 2eaefc9e1f Auditlog-Archivierung, Verlauf-RAW mit Zeilennummern, UI-Politur (v1.1.6)
- Auditlog waechst nur bis zu einem Schwellenwert (20.000 Zeilen): danach
  werden die aeltesten VOLLSTAENDIGEN Kalendertage (der laufende Tag wird
  nie mitten am Tag angefasst) tageweise in je eine Datei
  audit-YYYY-MM-DD.log unter /var/log/tesm/audit-archive archiviert und
  aus der DB geloescht, bis wieder 15.000 Zeilen unterschritten sind.
  Laeuft als taeglich pruefender Hintergrund-Thread (gate: TESM_WEB_PROCESS,
  wie beim bestehenden Fileshare-Sweep), zusaetzlich einmalig sofort beim
  Dienststart.
- Archivierte Auditlog-Tage sind unter Verlauf einsehbar (RAW-Ansicht) und
  koennen dort als ein ZIP exportiert werden, was sie danach vom Server
  loescht -- bewusst nur manuell ausgeloest statt automatisch, mit eigenem
  Recht logs_history.edit (neue "E"-Spalte bei Verlauf in der
  Gruppen-Rechtematrix, ergibt sich automatisch aus GROUP_ROW_TYPES).
  Aufbewahrung der Archivdateien selbst ist unbegrenzt; bei knappem
  Speicherplatz (< 1 GiB frei im Log-Verzeichnis) erscheint ein Warnhinweis
  auf der Verlauf-Seite.
- Verlauf -> "Komplettes Log (RAW)": Inhalt jetzt mit Zeilennummern (reine
  CSS-Counter-Loesung, kein Extra-Markup pro Zeile) -- Live-Log-RAW und
  nginx-RAW-Konfiguration bleiben bewusst unveraendert (nur an dieser
  Stelle angefragt).
- Auditlog-Tabelle: Avatar-Spalte entfernt, Spaltenbreiten (Zeitpunkt/
  Benutzer/Aktion/Ziel) schrumpfen jetzt auf den Inhalt statt zu strecken,
  "Ziel" nimmt nur noch so viel Platz wie noetig; Aktion-Pills brechen
  generell nicht mehr um (white-space: nowrap).
- "Änderungen" endgueltig zu "Auditlog" umbenannt (Recht-Label, Kommentare);
  die "Änderungen"-Filterkachel selbst (Bearbeiten-Kategorie) behaelt
  bewusst ihren Namen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:06:33 +02:00
alientimandClaude Sonnet 5 cb8b929c3c Auditlog: Hinzufuegen gruen, Loeschen rot, Bearbeiten orange (v1.1.5)
- Neue Pill-Farbvarianten (.action-pill--create/--delete/--edit) je nach
  Aktions-Art -- "create"/"upload"/"mkdir" gruen, "delete" rot, alles
  andere (bearbeiten/aktivieren/zuweisen/...) orange als Sammelkategorie.
- Fehlende Klartext-Label fuer die neuen fileshare.*-Aktionen ergaenzt
  (upload/mkdir/delete/rename/download) -- vorher stand dort noch der
  rohe Aktionsschluessel wie "fileshare.delete" statt eines lesbaren
  Textes wie bei allen anderen Aktionen.

Live auf POETEST verifiziert: alle drei Farben korrekt (gruen bei
"Datei(en) hochgeladen"/"Gruppe angelegt", rot bei "Datei/Ordner
gelöscht"/"Gruppe gelöscht", orange bei "Als ZIP heruntergeladen"/
"Gruppe bearbeitet").

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:17:32 +02:00
alientimandClaude Sonnet 5 d461f8ca42 Fileshare: redundanten Breadcrumb entfernt, Baum/Tabelle gleich hoch (v1.1.2)
- Der Breadcrumb-Link-Zeile ueber der Dateitabelle ("LDAP Share / Test")
  war seit der Baum-Navigation redundant -- der Baum zeigt die aktuelle
  Position ja schon (aufgeklappt + hervorgehoben). Entfernt.
- .fileshare-layout auf align-items:stretch umgestellt (statt flex-start)
  und .fileshare-main als Flex-Spalte mit table-wrap{flex:1}, damit die
  Baum- und die Tabellen-Kachel immer exakt gleich hoch sind, unabhaengig
  vom jeweiligen Inhalt. Das inline margin-bottom auf .table-wrap musste
  dafuer weg (sonst blieb trotzdem eine 16px-Luecke zwischen den unteren
  Kanten).

Live auf POETEST verifiziert: beide Kacheln exakt gleiche Hoehe (166.5px
in beiden Fällen), Breadcrumb-Div nicht mehr im DOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:51:57 +02:00
alientimandClaude Sonnet 5 1b7fe81507 Fileshare: Baum-Navigation, Datei-Vorschau, 15MB-Uploads (v1.1.1)
- Freigaben-Auswahl von einem Dropdown auf eine Baum-Navigation (links)
  umgestellt: alle gemounteten Freigaben als Wurzelknoten, Ordner beliebig
  aufklappbar. Der Pfad von der Wurzel bis zur aktuellen Position wird
  serverseitig schon aufgeklappt gerendert (_fileshare_tree_ancestors),
  alles andere laedt der Client per Klick lazy nach (neue Route
  /fileshare/subfolders) -- kein Durchlaufen der kompletten Freigabe auf
  einmal noetig. Dateien stehen bewusst nicht im Baum, nur in der
  bestehenden Tabelle rechts.
- Inline-Vorschau fuer PDF (iframe), Bilder, TXT/CSV/LOG/MD/JSON (per Range-
  Request in Haeppchen bis 512KB, mit Hinweis bei Abschneiden -- Erkennung
  ueber den Content-Range-Header, nicht nur den 206-Status, der bei JEDEM
  Range-Request auftritt) sowie Word/Excel rein clientseitig per mammoth.js
  bzw. SheetJS (beide lokal vendored, kein Cloud-Viewer -- interne Dokumente
  verlassen das Netz nicht). Excel-Zellen werden bewusst per textContent
  statt ueber SheetJS' eigene HTML-Ausgabe gesetzt, um unabhaengig von
  deren Escaping-Verhalten sicher zu sein. Neue Route /fileshare/view mit
  fester Endungs-Positivliste, nosniff + restriktiver CSP.
- Uploads bis 15MB erlaubt (MAX_CONTENT_LENGTH in app.py) -- dafuer musste
  auch nginx' client_max_body_size (Standard 1MB) auf 16MB angehoben werden,
  sowohl im Vorlagen-Code (_NGINX_PROXY_LOCATIONS/_render_nginx_config,
  fuer die naechste Anwendung ueber Einstellungen->NGINX) als auch in der
  Fresh-Install-Vorlage. Neuer 413-Errorhandler zeigt eine Flash-Meldung
  statt Werkzeugs nackter Fehlerseite.
- install.sh: known_hosts (liegt unter /srv/tesm) von rsync --delete
  ausgenommen -- fehlte bisher in der Exclude-Liste (anders als sqlite.db/
  fernet.key/secret.key) und waere bei jedem Update verlorengegangen.

Live auf POETEST getestet: Baum-Vorbefuellung, Lazy-Load von
Geschwisterordnern, alle 5 Vorschautypen (PDF-Route direkt verifiziert,
Bild/Text/Word/Excel zusaetzlich visuell), Abschneide-Erkennung sowohl bei
kleiner als auch bei >512KB-Datei.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:40:33 +02:00
alientimandClaude Sonnet 5 2c9ec8a100 Fileshare: Hostname-Aufloesung fuer UNC-Pfade + Zuordnungen bearbeitbar
- Freigabe-Hostnamen (statt nur IP) werden jetzt unterstuetzt: mount.cifs
  bekommt die tatsaechliche Ziel-IP gezielt per "ip="-Option mitgegeben,
  aufgeloest ueber _resolve_unc_host_ip(). Deckt zwei reale Faelle ab, die
  beim Live-Test mit einem echten Server (Kurzname "s2025") auftraten:
  1. Ein bloer Kurzname ohne DNS-Suffixsuche loest auf diesem Server gar
     nicht auf -- Fallback probiert zusaetzlich den aus der LDAP-Server-
     Einstellung abgeleiteten AD-Domaenensuffix (a la ".ad.eertmoed.net").
  2. IPv4 wird bevorzugt aufgeloest, IPv6 nur als Fallback genutzt, falls
     fuer den Hostnamen keine IPv4-Adresse existiert.
  Der Hostname bleibt dabei unveraendert in der UNC sichtbar/gespeichert.
- AD-Gruppenzuordnungen (App-Rechte) und Fileshare-Gruppen sind jetzt per
  Bearbeiten-Button (Stift-Icon) direkt aenderbar statt nur loeschen+neu
  anlegen zu koennen -- neue edit_ldap_group_mapping/edit_fileshare_mapping
  POST-Routen (UPDATE per id), Bearbeiten-Modals vorbefuellt inkl.
  AD-Gruppen-Dropdown (gleiches Lade-Muster wie beim Anlegen).
- UX: "Erforderliche AD-Gruppe fuer Login" ist jetzt ein Dropdown mit
  "Gruppen laden" (identisches Muster wie die anderen AD-Gruppenfelder)
  statt eines Freitextfelds, in das der volle DN von Hand einzutragen war.
- _normalize_share_unc()-Hilfsfunktion aus dem Add-Handler herausgezogen,
  jetzt auch vom Edit-Handler genutzt (kein duplizierter Code).

Live auf POETEST verifiziert: Hostname-Mount ("s2025") erfolgreich nach
Fix, Bearbeiten-Modals korrekt vorbefuellt und persistiert, Dropdown laedt
55 AD-Gruppen und behaelt die aktuelle Auswahl beim Neuladen bei.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 16:56:40 +02:00
alientimandClaude Sonnet 5 09b222615a Fileshare-Feature: LDAP-gruppengesteuerte Netzlaufwerke im Browser (v1.1.0)
- Neue ldap_fileshare_mappings-Tabelle (AD-Gruppe -> Freigabe-UNC+Label),
  verwaltet in Einstellungen -> LDAP wie die bestehende App-Gruppenzuordnung
  (inkl. AD-Gruppen-Dropdown per "Gruppen laden", UNC-Backslash/Slash-
  Normalisierung).
- Login-Gate: optionale "Erforderliche AD-Gruppe fuer Login" (Dropdown,
  gleiches Lade-Muster) -- ohne Mitgliedschaft scheitert der Login wie bei
  falschen Zugangsdaten, ohne Hinweis auf den eigentlichen Grund.
- Sessionabhaengiges Mounten: beim LDAP-Login werden alle per AD-Gruppe
  zugeordneten Freigaben mit den eigenen (nie gespeicherten) Zugangsdaten
  per mount.cifs unter /mnt/tesm-shares/<session-token>/ gemountet, beim
  Logout wieder ausgehaengt. Passwort läuft ausschliesslich ueber die
  PASSWD-Umgebungsvariable von mount.cifs. Aufraeum-Sweep (30 Min-Takt,
  12h-Schwelle) und Cleanup-on-Startup als Sicherheitsnetz.
- Zwei unabhaengige Gates: TESM-Rechtesystem (fileshare.view/.create/.edit,
  neue PERMISSIONS-Gruppe fileshare_group) UND AD-Gruppenmitgliedschaft
  muessen beide zutreffen, sonst wird nicht gemountet und der Menuepunkt
  "Dateifreigaben" bleibt verborgen.
- Neuer Dateibrowser (/fileshare, .../download, .../upload, .../mkdir,
  .../delete, .../rename) mit Breadcrumbs, Upload/Download, Anlegen/
  Umbenennen/Loeschen -- Pfadaufloesung ausschliesslich ueber
  os.path.realpath-Vergleich gegen die eigene Mount-Root (Path-Traversal-
  Schutz), zusaetzlich secure_filename() fuer Datei-/Ordnernamen.
- KRITISCHER Bugfix waehrend Live-Test gefunden: generate_ips.py importiert
  app.py als Hilfsmodul (u.a. aus poe.sh/tesm-check.service heraus, alle
  paar Sekunden) -- das fuehrte Modul-Level-Code erneut aus und hat aktive
  Fileshare-Mounts anderer Sitzungen sofort wieder ausgehaengt. Neues
  TESM_WEB_PROCESS-Env-Flag (nur in tesm.service gesetzt) schuetzt
  Cleanup-on-Startup und den Sweep-Thread davor, bei einem reinen
  Modul-Import erneut zu laufen.
- install.sh: cifs-utils ergaenzt (liefert mount.cifs).
- SCHEMA_VERSION 1 -> 2, VERSION 1.0.10 -> 1.1.0.

Live auf Testbox (192.168.82.51) und POETEST (192.168.80.202) getestet:
Login-Gate (erlauben/verbieten/reset), Rechte-Gate (Mount+Menuepunkt nur
mit fileshare.view), vollstaendiger Datei-Workflow (Upload/Download/
Umbenennen/Anlegen/Loeschen) gegen eine echte SMB-Freigabe, Path-Traversal
gegen alle 5 Routen (serverseitig verifiziert, kein Escape), sauberes
Unmount bei /logout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 16:34:28 +02:00
alientimandClaude Sonnet 5 0074e48e09 Responsive-Fixes per echtem Playwright/DevTools-Test verifiziert, zwei Bugs behoben
Auf Nachfrage tatsaechlich mit Browser-DevTools (Playwright/Chromium,
lokal installiert) statt nur per CSS-Analyse geprueft -- echte
Screenshots + automatisierte Overflow-Pruefung auf iPhone-16-Pro- (393x852),
iPad- (820x1180 Hoch-/1180x820 Querformat) und Desktop-Viewport (1440x900)
gegen die echte Live-Instanz auf Testsystem-Update-Restart.

Dabei zwei echte Bugs gefunden und behoben:

1. Topbar-Ueberlappung (ALLE Seiten, jeder eingeloggte Nutzer, jedes
   iPhone/kleine Handy): .topbar-left (Seitentitel) und .topbar-right
   (Pruef-Countdown- + DHCP-Pille) sind beide flex:1 ohne Zeilenumbruch --
   auf 393px Breite quetscht das den Titel auf eine Breite, die kleiner
   ist als sein eigener Inhalt braucht, wodurch der Titeltext sichtbar
   ueber seine Spalte hinauslief und mit der Pille kollidierte (per
   getBoundingClientRect nachgewiesen: 24.5px Ueberlauf). Fix: ab 560px
   Breite bricht die Topbar um, Titel und Pillen bekommen je eine eigene
   Zeile.

2. HTTP-/HTTPS-Port-Felder auf der NGINX-Seite stapelten trotz
   entsprechender Media-Query-Regel nicht auf Handy-Breite -- Ursache:
   inline style="flex:1" auf den .field-Divs hat Vorrang vor jeder externen
   @media-Regel, ganz gleich wie spezifisch. Fix: inline style entfernt,
   neue Klasse .field--half stattdessen -- die Mobile-Stapel-Regel greift
   jetzt wie vorgesehen.

Zusaetzlich EINE eigene Aenderung aus v1.0.9 wieder zurueckgenommen, weil
sie sich beim Testen als unnoetig und riskant herausstellte:
- .table-wrap: overflow-x:auto (aus v1.0.9) wieder auf overflow:hidden
  zurueckgesetzt. Grund: JEDES Template mit .table-wrap hat bereits einen
  eigenen <div style="overflow-x:auto;"> exakt um die <table> -- die
  zusaetzliche Scroll-Faehigkeit der Karte selbst war komplett redundant
  und erzeugte einen verschachtelten doppelten Scroll-Container um
  denselben Inhalt. Beim Testen der aufklappbaren Rechte-Tabelle
  (Gruppen-Seite) fuehrte das zu Inhalten, die bei negativem x dauerhaft
  unsichtbar wurden. table.data-table { min-width: 560px; } bleibt
  bestehen (harmlos, der jeweils vorhandene innere Wrapper scrollt
  weiterhin korrekt).

Eine dritte vermutete Ursache (aufgeklappte Rechte-Tabelle "verschwindet"
beim Anklicken des Chevrons) stellte sich NICHT als echter App-Bug heraus,
sondern als Artefakt von Playwrights eigenem automatischem
"scroll-into-view-vor-Klick"-Verhalten (der Chevron sitzt in der letzten
Tabellenspalte) -- per direktem JS-Aufruf von toggleDetail() ohne
Playwright-Klick liess sich das nicht reproduzieren, die Seite verhaelt
sich für echte Nutzer korrekt.

Alle 8 getesteten Seiten x 4 Viewports (32 Kombinationen) zeigen nach den
Fixes keinerlei horizontalen Overflow mehr (dokumentseitenweite
scrollWidth == clientWidth in jedem Fall).

VERSION 1.0.9 -> 1.0.10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 14:29:48 +02:00
alientimandClaude Sonnet 5 a3f5f028f2 Responsives CSS ausgebaut: Handy (iPhone) und Tablet (iPad) sauber nutzbar
Bestehendes eigenes Design-System beibehalten (kein Bootstrap-Framework
eingebunden), stattdessen die vorhandenen Media Queries/Breakpoints
(Sidebar kollabiert bereits ab 900px) um die fehlenden Faelle ergaenzt:

- .table-wrap: overflow:hidden -> overflow-x:auto. Tabellen mit vielen
  Spalten (Geraete, Switche, Papierkorb, Benutzer, ...) haben auf
  schmalen Screens bisher entweder Spalten lautlos abgeschnitten oder
  die ganze Seite waagerecht aufgerissen -- jetzt scrollt NUR die
  Tabelle selbst (gleiches Prinzip wie Bootstraps .table-responsive).
  table.data-table bekommt zusaetzlich eine min-width als sinnvolle
  Scroll-Schwelle.
- .settings-grid: minmax(340px, 1fr) -> minmax(min(340px, 100%), 1fr) --
  ein nackter Pixel-Mindestwert erzwingt auf einem 375px-iPhone sonst
  waagerechten Overflow der ganzen Seite, sobald die Spalte breiter als
  der verfuegbare Platz ist. Neue Modifier-Klasse .settings-grid--wide
  (420px-Variante) ersetzt zwei betroffene inline
  style="grid-template-columns:minmax(420px,...)"-Overrides (LDAP-,
  NGINX-Seite), die denselben 100%-Fallback nicht hatten.
- .permission-group-col: eigenes overflow-x:auto zusaetzlich zum
  bestehenden flex-wrap der Elternzeile -- falls eine einzelne
  Rechte-Spalte (z.B. Geraete mit R/W/E/D) auch nach dem Umbruch auf
  eine eigene Zeile noch zu breit ist.
- Ab 900px (bestehender Sidebar-Breakpoint) zusaetzlich: .flex (generische
  Button-/Toolbar-Zeilen), .section-head, .term-toolbar/.log-toolbar und
  .detail-row brechen um statt seitlich ueberzulaufen; .modal-footer
  ebenso; .search-input in Tabellen-Toolbars schrumpft flexibel.
- Ab 640px: nebeneinander angeordnete Formularfelder (z.B. HTTP-/HTTPS-
  Port bei NGINX) stapeln sich auf volle Breite statt zwei sehr schmale
  Eingaben zu erzwingen; Modal-Innenabstand etwas schmaler.
- Ab 560px: Buttons in Kopfzeilen (section-head) nehmen die volle Breite
  ein statt als schmaler Block rechts zu kleben.

Live auf Testsystem-Update-Restart verifiziert: alle Kernseiten
(Dashboard, Clients, Switche, Gruppen, LDAP, NGINX, Live-Log, Wartung)
laden weiterhin fehlerfrei (200) nach dem CSS-/Template-Austausch.

WICHTIGER HINWEIS: In dieser Umgebung steht kein Browser-Screenshot-
Werkzeug zur Verfuegung, die Aenderungen wurden also nicht pixelgenau auf
echten iPhone-16-Pro-/iPad-Bildschirmgroessen gegengeprueft, sondern rein
ueber CSS-Analyse und Seiten-Ladetests. Ein Blick auf einem echten Geraet
(oder per Browser-Devtools-Emulation) wird empfohlen.

VERSION 1.0.8 -> 1.0.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:34:35 +02:00
alientimandClaude Sonnet 5 5bca29f3da NGINX-Seite: Sichtbarkeit/Kontrolle der automatischen Let's-Encrypt-Verlaengerung
certbot richtet die automatische Verlaengerung (certbot.timer, zweimal
taeglich) zwar schon beim Paket-Install selbst ein -- das war fuer den
Admin in der Weboberflaeche bisher aber unsichtbar, reine Behauptung im
Code-Kommentar. Jetzt sichtbar/nachpruefbar:

- Neue Zeile "Automatische Verlaengerung" im Zertifikat-Status (nur bei
  Let's-Encrypt-Zertifikaten): Timer aktiv/inaktiv + naechster geplanter
  Lauf (_certbot_timer_status(), liest systemctl is-active/list-timers).
- "Verlaengerung testen (Dry-Run)" -- certbot renew --dry-run, aendert
  nichts, zeigt nur ob die Verlaengerung im Ernstfall funktionieren wuerde.
- "Jetzt manuell verlaengern" -- certbot renew --force-renewal, fuer
  Testzwecke oder eine sofortige Schluessel-Rotation (mit Warnhinweis auf
  Let's Encrypts Rate-Limits).
- Hinweistext beim Anfordern zeigt den Timer-Status schon VOR der ersten
  Anforderung.

VERSION 1.0.7 -> 1.0.8 (ab hier wieder normale fortlaufende
Versionierung statt erneutem Release derselben Versionsnummer).

Live auf Testsystem-Update-Restart verifiziert: Seite laedt weiterhin
fehlerfrei (_certbot_timer_status crasht nicht ohne aktives
Let's-Encrypt-Zertifikat), certbot.timer selbst bestaetigt aktiv
(naechster Lauf laut systemctl list-timers korrekt angezeigt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:24:15 +02:00
alientimandClaude Sonnet 5 4de5828573 NGINX-Seite ausgebaut: Domain/Ports, RAW-Config, Let's Encrypt mit Auto-Verlaengerung; Flask nur noch lokal erreichbar
Umbenennung: Einstellungen -> SSL/TLS heisst jetzt Einstellungen -> NGINX
(Route /settings/ssl -> /settings/nginx, Recht settings_ssl.* ->
settings_nginx.*, Icon "server" statt "shield") -- die Seite verwaltet
den kompletten Reverse-Proxy, nicht mehr nur SSL.

Neu:
- Domain (server_name) und HTTP-/HTTPS-Port sind jetzt einstellbar, nicht
  mehr hart "_" bzw. 80/443. Aendert man den HTTP-Port weg von 80, wird
  automatisch zusaetzlich ein minimaler Nur-ACME-Block auf Port 80
  mitgeschrieben, da Let's Encrypt (HTTP-01) protokollbedingt immer Port 80
  braucht, unabhaengig vom konfigurierten HTTP-Port.
- "RAW-Konfiguration"-Button zeigt die komplette aktuelle nginx-Config im
  selben Popup-Stil wie das Live-Log (Wiederverwendung von
  window.openRawLogModal, kein neuer Code dafuer noetig).
- Let's Encrypt: Zertifikat per certbot anfordern (certonly --webroot,
  bewusst NICHT das certbot-nginx-Plugin -- das wuerde die App-eigene,
  komplett selbstgeschriebene Config direkt editieren und bei der
  naechsten Aenderung ueber diese Seite wieder verlieren). Automatische
  Verlaengerung laeuft ueber certbots eigenen systemd-Timer/Cron
  (Standard-Paketverhalten), ein Deploy-Hook
  (/etc/letsencrypt/renewal-hooks/deploy/tesm-reload-nginx.sh, wird wie
  die logrotate-Config bei jedem App-Start neu geschrieben) laedt nginx
  nach jeder Verlaengerung neu. Fehlgeschlagene Anforderung (kein
  oeffentlicher DNS-Eintrag o.ae.) wird sauber abgefangen und als
  Fehlermeldung angezeigt, ohne etwas zu veraendern -- live mit einer
  ungueltigen Test-Domain verifiziert.
- Zertifikatsquelle (Upload vs. Let's Encrypt) wird als eigenes Setting
  (ssl_source) verfolgt, das Anwenden/Anzeigen/Entfernen beruecksichtigt
  beide Quellen jeweils passend (Entfernen einer LE-verwalteten
  Installation laeuft ueber "certbot delete", nicht ueber rohes
  Datei-Loeschen, damit certbots eigene Renewal-Konfiguration nicht
  verwaist).
- certbot zu install.sh's Paketliste hinzugefuegt.

Flask/gunicorn bindet jetzt an 127.0.0.1 statt 0.0.0.0 (tesm.service) --
direkter Zugriff auf Port 5000 aus dem Netz ist damit nicht mehr moeglich,
nur noch ueber den nginx-Reverse-Proxy (der ohnehin nur ueber 127.0.0.1
zugreift). Beantwortet die Nachfrage, ob Flask unerreichbar gemacht werden
kann.

Live auf Testsystem-Update-Restart verifiziert: Domain/Port-Aenderung
(HTTP-Port 8080 inkl. automatischem Zusatz-Block auf Port 80),
RAW-Config-Anzeige, Zertifikat-Upload, Let's-Encrypt-Fehlschlag sauber
abgefangen, Rueckbau auf Standardwerte (_, 80, 443, kein SSL) ueber
denselben Anwenden/Bestaetigen-Sicherheitsnetz-Pfad wie zuvor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:15:32 +02:00
alientimandClaude Sonnet 5 f27dde466b SSL/TLS-Verwaltung (Upload+Aktivieren), install.sh-Idempotenz, poe_web/rpi-check-Cleanup entfernt
SSL/TLS (neue Seite Einstellungen -> SSL/TLS, eigenes Recht settings_ssl.view/.edit):
- Zertifikat+privater Schluessel per Upload hinterlegbar (PEM), Validierung
  VOR dem Speichern: gueltiges Zertifikat, noch nicht abgelaufen, Schluessel
  passt zum Zertifikat (Abgleich ueber die oeffentlichen Schluessel als
  DER-Bytes -- funktioniert algorithmus-unabhaengig fuer RSA/EC/...).
  Speicherort /etc/tesm/ssl (bewusst ausserhalb von /srv/tesm, damit
  rsync --delete beim Update das Zertifikat nicht mitloescht), Key mit
  chmod 600.
- HTTPS aktivieren schreibt/validiert (nginx -t) eine neue nginx-Konfiguration
  und laedt erst danach neu; Port 80 leitet dann automatisch auf https weiter.
  HSTS optional zuschaltbar, setzt aktives SSL voraus.
- Gleiches Sicherheitsnetz wie bei den Netzwerkeinstellungen: nach dem
  Anwenden 45s Zeitfenster zum Bestaetigen, sonst automatischer Rueckroll
  der nginx-Konfiguration UND der ssl_enabled/hsts_enabled-Einstellungen.
  Beide Pfade (Timeout-Rollback und Bestaetigen-verhindert-Rollback) live
  auf Testsystem-Update-Restart verifiziert, inklusive eines ECHTEN,
  ungeplant ausgeloesten Rollbacks waehrend des Testens.
- Noch KEINE Let's-Encrypt-Automatisierung (siehe Kommentar im Code) --
  alle bekannten Instanzen laufen intern ohne oeffentliche Domain, HTTP-01
  wuerde dort nicht funktionieren; kann bei Bedarf separat ergaenzt werden.

install.sh: Update-Idempotenz ("nur anfassen, was sich geaendert hat"):
- install_if_changed()-Helfer: Custom-Skript/systemd-Units/MOTD-Banner
  werden nur noch bei tatsaechlich geaendertem Inhalt neu geschrieben,
  daemon-reload nur wenn sich mindestens eine Unit geaendert hat.
- nginx-Konfiguration wird nur noch bei einer Frischinstallation aus dem
  Repo-Template geschrieben -- eine bereits vorhandene (egal ob unveraendert
  oder von der App selbst um SSL erweitert) wird nie wieder ueberschrieben.
  Voraussetzung dafuer, dass die neue SSL-Funktion ein Update ueberlebt.

update.sh: komplette Sonderbehandlung fuer die zwei TESM vorausgehenden
Generationen (rpi-check.service, poe_web.service/poe_manager) entfernt --
auf allen aktuellen Installationen laengst migriert. Deaktivierte
Unit-Dateien dieser Generationen wurden auf Testsystem-Update-Restart
zusaetzlich manuell endgueltig geloescht (waren dort seit einer Migration
im September 2025 nur deaktiviert, nicht entfernt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:56:55 +02:00
alientimandClaude Sonnet 5 8253ab3f0e Verlauf: aktuelle live.log als Eintrag, Auswahl per Zeitraum statt Dateiname; RAW-Darstellung
- _live_log_history_files() liefert jetzt zusätzlich generation 0 = die
  aktuelle, noch laufende live.log selbst (nicht nur die rotierten
  Kopien .1/.2/...) -- _resolve_log_history_file akzeptiert 'live.log'
  als gültige Auswahl.
- Jeder Eintrag bekommt ein range_label ('von' - 'bis'): 'bis' ist das
  eigene mtime (Zeitpunkt der Rotation) bzw. 'laufend' bei der aktuellen
  Datei, 'von' ist das mtime der naechstaelteren Generation -- rename()
  bei einer Rotation aendert mtime nicht, jede Generation behaelt also
  dauerhaft ihren eigenen Entstehungszeitpunkt. Live verifiziert: die
  Zeitraeume reihen sich lueckenlos aneinander (Ende von Generation N ==
  Anfang von Generation N-1).
- Dropdown und Toolbar zeigen jetzt den Zeitraum statt des rohen
  Dateinamens.
- logs_history.html: Farbcodierung (colorizeLine) entfernt, log-box durch
  ein <pre class="raw-log-content"> ersetzt -- reine RAW-Darstellung
  ohne Client-seitiges Span-pro-Zeile-Aufbauen, auf Wunsch zur weiteren
  Reduktion der Ladezeit im Verlauf (Live-Ansicht selbst bleibt farbig).
- VERSION 1.0.6 -> 1.0.7.

Live getestet auf Testsystem-Update-Restart: 4 Eintraege (aktuell + 3
rotierte Generationen), Zeitraeume lueckenlos verkettet, RAW-Endpoint für
'file=live.log' funktioniert, Path-Traversal-Versuch weiterhin mit 404
abgewiesen.

Zusaetzlich untersucht (kein Code-Fix, siehe Konversation): der Verdacht
eines install.sh-Bugs, der tesm-check.service nach einem Update gestoppt
liesse. journalctl zeigt auf beiden Boxen durchgehend 'active (running)'
ueber den fraglichen Zeitraum -- keine Bestaetigung eines Defekts in der
Dienstverwaltung gefunden. Wahrscheinlichste Erklaerung: ein manuell zu
Testzwecken erzwungener logrotate-Lauf (logrotate -f) direkt nach dem
Update hat live.log geleert, wodurch die Live-Log-Seite und die
Naechste-Pruefung-Anzeige fuer die Dauer eines Pruefintervalls leer
wirkten, bis der naechste Zyklus neuen Inhalt schrieb.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:28:15 +02:00
30 changed files with 4115 additions and 427 deletions
+5
View File
@@ -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;
+15 -1
View File
@@ -6,6 +6,16 @@ After=network.target
Type=simple Type=simple
User=root User=root
WorkingDirectory=/srv/tesm WorkingDirectory=/srv/tesm
# Markiert genau DIESEN Prozess als den echten Web-App-Dienst -- app.py
# wird nämlich NICHT nur hierüber gestartet, sondern auch von
# generate_ips.py (via "from app import ...") als reines Hilfsmodul
# importiert, z.B. aus poe.sh/tesm-check.service heraus, alle paar
# Sekunden. Ein einfacher Import führt JEDEN Modul-Level-Code in app.py
# erneut aus -- ohne dieses Flag würde jeder generate_ips.py-Aufruf
# _fileshare_cleanup_all_on_startup() erneut auslösen und damit gerade
# aktive Fileshare-Mounts anderer, echter Sitzungen sofort wieder
# aushängen. Siehe die Prüfung auf TESM_WEB_PROCESS in app.py.
Environment=TESM_WEB_PROCESS=1
# Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()- # Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()-
# Entwicklungsserver -- siehe requirements.txt für die ausführliche # Entwicklungsserver -- siehe requirements.txt für die ausführliche
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class # Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
@@ -14,8 +24,12 @@ WorkingDirectory=/srv/tesm
# nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal- # nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal-
# WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung, # WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung,
# da nur ein Prozess läuft) killen. # da nur ein Prozess läuft) killen.
# "--bind 127.0.0.1:5000" statt 0.0.0.0: Flask/Gunicorn soll ausschließlich
# über den nginx-Reverse-Proxy (siehe Einstellungen -> NGINX) erreichbar
# sein, nicht zusätzlich direkt und unverschlüsselt über Port 5000 aus dem
# Netz -- nginx selbst greift ohnehin nur über 127.0.0.1 zu.
ExecStart=/srv/tesm/venv/bin/gunicorn --workers 1 --worker-class gthread --threads 16 \ ExecStart=/srv/tesm/venv/bin/gunicorn --workers 1 --worker-class gthread --threads 16 \
--timeout 0 --bind 0.0.0.0:5000 app:app --timeout 0 --bind 127.0.0.1:5000 app:app
Restart=always Restart=always
RestartSec=5 RestartSec=5
+35 -7
View File
@@ -44,6 +44,21 @@ NC='\033[0m'
print_status() { echo -e "${GREEN}${NC} ${1} completed."; } print_status() { echo -e "${GREEN}${NC} ${1} completed."; }
step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/tesm-install.log; } step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/tesm-install.log; }
# Kopiert nur, wenn der Inhalt sich tatsächlich unterscheidet (oder das
# Ziel noch fehlt) -- bei einem Update sollen ausschließlich Dateien
# angefasst werden, die sich zwischen der laufenden und der neuen Version
# wirklich geändert haben (unverändert lässt "cp" Zeitstempel/Inode aber
# trotzdem hochzählen, was z.B. Monitoring/Backups unnötig triggert).
# Gibt 0 zurück, wenn tatsächlich kopiert wurde, 1 wenn übersprungen.
install_if_changed() {
local src="$1" dst="$2" mode="${3:-644}"
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
return 1
fi
sudo install -m "$mode" "$src" "$dst"
return 0
}
# "clear" schlägt ohne TERM/Pseudo-Terminal fehl (z.B. bei Automatisierung # "clear" schlägt ohne TERM/Pseudo-Terminal fehl (z.B. bei Automatisierung
# per SSH exec_command ohne pty, genau wie update.sh dieses Skript aufruft) # per SSH exec_command ohne pty, genau wie update.sh dieses Skript aufruft)
# -- mit "set -e" bricht das sonst die GESAMTE Installation sofort und ohne # -- mit "set -e" bricht das sonst die GESAMTE Installation sofort und ohne
@@ -99,7 +114,7 @@ fi
# ---- Pakete ---- # ---- Pakete ----
step "Installing system packages" step "Installing system packages"
sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update" sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update"
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed" sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate certbot cifs-utils >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
# ---- Log-Verzeichnis ---- # ---- Log-Verzeichnis ----
# NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als # NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als
@@ -119,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"
@@ -150,10 +166,13 @@ fi
# ---- Custom-Skripte + systemd ---- # ---- Custom-Skripte + systemd ----
step "Copying custom scripts and systemd units" step "Copying custom scripts and systemd units"
sudo mkdir -p /usr/local/bin/custom sudo mkdir -p /usr/local/bin/custom
sudo cp "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh UNITS_CHANGED=0
sudo chmod +x /usr/local/bin/custom/poe.sh install_if_changed "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh 755 || true
sudo cp "$REPO_DIR"/etc/systemd/system/*.service /etc/systemd/system/ for f in "$REPO_DIR"/etc/systemd/system/*.service "$REPO_DIR"/etc/systemd/system/*.timer; do
sudo cp "$REPO_DIR"/etc/systemd/system/*.timer /etc/systemd/system/ if install_if_changed "$f" "/etc/systemd/system/$(basename "$f")" 644; then
UNITS_CHANGED=1
fi
done
print_status "Scripts and units copied" print_status "Scripts and units copied"
# ---- SSH-Login-Banner (MOTD) ---- # ---- SSH-Login-Banner (MOTD) ----
@@ -162,8 +181,7 @@ print_status "Scripts and units copied"
# Die Standard-Skripte bleiben erhalten, werden aber deaktiviert (chmod -x, # Die Standard-Skripte bleiben erhalten, werden aber deaktiviert (chmod -x,
# nicht gelöscht), damit ein Rückbau jederzeit per chmod +x möglich ist. # nicht gelöscht), damit ein Rückbau jederzeit per chmod +x möglich ist.
step "Setting up TESM SSH login banner" step "Setting up TESM SSH login banner"
sudo cp "$REPO_DIR/etc/update-motd.d/50-tesm" /etc/update-motd.d/50-tesm install_if_changed "$REPO_DIR/etc/update-motd.d/50-tesm" /etc/update-motd.d/50-tesm 755 || true
sudo chmod 755 /etc/update-motd.d/50-tesm
sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \ sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \
/etc/update-motd.d/50-motd-news /etc/update-motd.d/60-unminimize \ /etc/update-motd.d/50-motd-news /etc/update-motd.d/60-unminimize \
/etc/update-motd.d/91-release-upgrade /etc/update-motd.d/92-unattended-upgrades \ /etc/update-motd.d/91-release-upgrade /etc/update-motd.d/92-unattended-upgrades \
@@ -171,8 +189,16 @@ sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \
print_status "SSH login banner configured" print_status "SSH login banner configured"
# ---- nginx ---- # ---- nginx ----
# Nur bei einer Frischinstallation aus dem Repo-Template geschrieben --
# eine bereits vorhandene Konfiguration (egal ob unverändert oder von der
# App selbst z.B. um SSL/HSTS erweitert, siehe Systemeinstellungen -> SSL)
# wird NIE wieder überschrieben. Symlink/Default-Site-Entfernung und der
# Validierungs-Reload laufen dagegen immer -- rein idempotent, ändern an
# einer bestehenden Konfiguration selbst nichts.
step "Configuring nginx reverse proxy" step "Configuring nginx reverse proxy"
if [ ! -f /etc/nginx/sites-available/tesm ]; then
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm
fi
sudo ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm sudo ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm
sudo rm -f /etc/nginx/sites-enabled/default sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx
@@ -180,7 +206,9 @@ print_status "nginx configured"
# ---- Dienste aktivieren ---- # ---- Dienste aktivieren ----
step "Enabling services" step "Enabling services"
if [ "$UNITS_CHANGED" -eq 1 ]; then
sudo systemctl daemon-reload sudo systemctl daemon-reload
fi
sudo systemctl enable --now tesm.service sudo systemctl enable --now tesm.service
sudo systemctl enable --now tesm-check.service tesm-check-restart.timer sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
print_status "Services enabled and started" print_status "Services enabled and started"
+1 -1
View File
@@ -1 +1 @@
1 2
+1 -1
View File
@@ -1 +1 @@
1.0.6 1.1.11
+2023 -71
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -176,6 +176,17 @@ CREATE TABLE IF NOT EXISTS ldap_group_mappings (
); );
""") """)
c.execute("""
CREATE TABLE IF NOT EXISTS ldap_fileshare_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL,
ad_group_name TEXT NOT NULL,
share_label TEXT NOT NULL,
share_unc TEXT NOT NULL,
UNIQUE(ad_group_dn, share_unc)
);
""")
c.execute(""" c.execute("""
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+255 -2
View File
@@ -197,13 +197,23 @@ button { font-family: inherit; }
.nav-group.expanded .nav-group-children { display: flex; } .nav-group.expanded .nav-group-children { display: flex; }
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; } .nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
/* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite) */ /* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite). minmax(min(...,
100%), 1fr) statt eines nackten Pixelwerts -- eine Spalten-Mindestbreite
von z.B. 340px würde auf einem 375px-iPhone (abzüglich .content-Padding)
sonst waagerechten Overflow der ganzen Seite erzwingen, weil "auto-fit"
erst ab einer Breite größer als der Mindestwert umbricht. */
.settings-grid { .settings-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr));
gap: 24px; gap: 24px;
align-items: start; align-items: start;
} }
/* Modifier für Seiten, die etwas breitere Karten brauchen (z.B. NGINX,
LDAP) -- ersetzt frühere inline style="grid-template-columns:..."
Overrides, die denselben 100%-Fallback nicht hatten. */
.settings-grid--wide {
grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
}
/* Navbar-Reihenfolge (Settings) */ /* Navbar-Reihenfolge (Settings) */
.nav-order-list { .nav-order-list {
@@ -436,6 +446,18 @@ button { font-family: inherit; }
.card-pad { padding: 20px 22px; } .card-pad { padding: 20px 22px; }
.notice-banner {
padding: 12px 16px;
border-radius: var(--radius);
font-size: 12.5px;
line-height: 1.5;
}
.notice-banner--warning {
background: var(--warning-dim);
color: var(--warning);
border: 1px solid var(--warning);
}
.section-head { .section-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -488,6 +510,7 @@ button { font-family: inherit; }
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 12px;
font-weight: 650; font-weight: 650;
white-space: nowrap;
} }
.pill svg { width: 12px; height: 12px; } .pill svg { width: 12px; height: 12px; }
.pill::before { .pill::before {
@@ -506,6 +529,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;
@@ -683,8 +711,19 @@ button { font-family: inherit; }
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-radius: var(--radius); border-radius: var(--radius);
/* Zurück auf overflow:hidden (nur für die abgerundeten Ecken) --
JEDES Template, das .table-wrap verwendet, hat bereits einen eigenen
<div style="overflow-x:auto;"> exakt um die <table> herum (nicht um
die ganze Karte). overflow-x:auto zusätzlich HIER hätte einen zweiten,
verschachtelten Scroll-Container um denselben Inhalt erzeugt -- live
auf einem iPhone-Viewport nachgewiesen: bei der aufklappbaren
Rechte-Tabelle (Gruppen-Seite) landete der Inhalt dadurch dauerhaft
unsichtbar bei negativem x. Die Tabellen scrollen also weiterhin
horizontal, nur über den bereits vorhandenen inneren Wrapper, nicht
zusätzlich über die Karte selbst. */
overflow: hidden; overflow: hidden;
} }
.table-wrap table.data-table { min-width: 560px; }
.table-toolbar { .table-toolbar {
display: flex; display: flex;
@@ -792,6 +831,12 @@ table.data-table {
========================================================================== */ ========================================================================== */
.field { margin-bottom: 15px; } .field { margin-bottom: 15px; }
/* Zwei .field nebeneinander in einer .flex-Zeile (z.B. HTTP-/HTTPS-Port
bei NGINX) -- als Klasse statt inline style="flex:1", damit die
Mobile-Stapel-Regel (siehe @media max-width:640px weiter unten) sie
per externem Stylesheet überschreiben kann; ein inline style gewinnt
sonst grundsätzlich gegen jede @media-Regel, ganz gleich wie spezifisch. */
.field--half { flex: 1; min-width: 0; }
.field label { .field label {
display: block; display: block;
font-size: 12.5px; font-size: 12.5px;
@@ -923,6 +968,48 @@ select {
flex-direction: column; flex-direction: column;
animation: modal-in 0.16s ease; animation: modal-in 0.16s ease;
} }
/* ---- Hinweis-Icons (Hover-Tooltip + Klick-Modal), siehe _hint_icon.html
und initHintIcons() in app.js ---- */
.hint-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
flex: 0 0 auto;
border-radius: 50%;
border: 1.5px solid var(--text-faint);
background: transparent;
color: var(--text-faint);
font-size: 10px;
font-weight: 700;
font-style: italic;
font-family: Georgia, "Times New Roman", serif;
line-height: 1;
cursor: help;
vertical-align: middle;
margin-left: 6px;
padding: 0;
transition: border-color 0.15s, color 0.15s;
}
.hint-icon:hover, .hint-icon:focus-visible { border-color: var(--accent); color: var(--accent); }
#hint-tooltip {
position: fixed;
z-index: 10000;
max-width: 280px;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
background: var(--bg-elevated);
color: var(--text);
border: 1px solid var(--border);
box-shadow: var(--shadow);
pointer-events: none;
display: none;
}
@keyframes modal-in { @keyframes modal-in {
from { opacity: 0; transform: translateY(8px) scale(0.98); } from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0) scale(1); }
@@ -1119,6 +1206,31 @@ select {
} }
[data-theme="light"] .raw-log-content { background: #0e1116; } [data-theme="light"] .raw-log-content { background: #0e1116; }
/* Zeilennummern im RAW-Log-Popup (aktuell nur "Verlauf") -- Nummer kommt
rein aus CSS-Countern (kein Extra-Markup pro Zeile mit fest eingebrannter
Zahl), damit Filtern/Kopieren des reinen Logtexts unverändert bleibt. */
.raw-log-content.with-line-numbers {
counter-reset: raw-line;
white-space: normal;
}
.raw-log-content.with-line-numbers .raw-log-line {
display: flex;
white-space: pre-wrap;
word-break: break-word;
}
.raw-log-content.with-line-numbers .raw-log-line::before {
counter-increment: raw-line;
content: counter(raw-line);
flex: 0 0 auto;
min-width: 3.5em;
margin-right: 14px;
padding-right: 10px;
border-right: 1px solid var(--border);
text-align: right;
color: var(--text-faint);
user-select: none;
}
/* ========================================================================== /* ==========================================================================
Permission checklists (Gruppen) Permission checklists (Gruppen)
========================================================================== */ ========================================================================== */
@@ -1156,8 +1268,14 @@ select {
Logs mit nur R). Feste/zu schmale Breiten haben die D-Spalte bei Logs mit nur R). Feste/zu schmale Breiten haben die D-Spalte bei
Geräte zuvor lautlos in den Overflow geschoben. */ Geräte zuvor lautlos in den Overflow geschoben. */
flex: 0 0 auto; flex: 0 0 auto;
max-width: 100%;
padding-right: 28px; padding-right: 28px;
border-right: 1px solid var(--border-soft); border-right: 1px solid var(--border-soft);
/* Fällt eine einzelne Spalte (z.B. Geräte mit vielen Rechten-Spalten)
trotz .permission-groups-row's flex-wrap auf einem schmalen Screen
für sich genommen noch zu breit aus, scrollt nur diese eine Spalte
waagerecht statt die Seite aufzureißen. */
overflow-x: auto;
} }
.permission-group-col:last-child { border-right: none; padding-right: 0; } .permission-group-col:last-child { border-right: none; padding-right: 0; }
.permission-group-header-cell { padding-left: 0 !important; } .permission-group-header-cell { padding-left: 0 !important; }
@@ -1259,6 +1377,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
========================================================================== */ ========================================================================== */
@@ -1286,14 +1490,63 @@ select {
.content { padding: 18px 16px 40px; } .content { padding: 18px 16px 40px; }
.topbar { padding: 10px 16px; } .topbar { padding: 10px 16px; }
.topbar-logo { opacity: 0.5; } .topbar-logo { opacity: 0.5; }
/* Ab hier iPad-Breite (900px ist bereits der bestehende Sidebar-
Umschaltpunkt) und schmaler: generische Button-/Toolbar-Zeilen
(.flex) sollen umbrechen statt seitlich überzulaufen -- betrifft vor
allem Kopfzeilen mit mehreren Aktions-Buttons (z.B. NGINX-, Logs-,
Verlauf-Seite) und nebeneinander angeordnete Formularfelder. */
.flex { flex-wrap: wrap; }
.section-head { flex-wrap: wrap; }
.term-toolbar, .log-toolbar { flex-wrap: wrap; row-gap: 8px; }
.detail-row { flex-wrap: wrap; }
.detail-row .v { text-align: left; }
/* Modal-Fußzeilen (Speichern/Abbrechen) und Tabellen-Kopfzeilen
(Suche + Aktion) sollen bei wenig Platz ebenfalls umbrechen statt
Buttons/Suchfeld ineinanderzuschieben. */
.modal-footer { flex-wrap: wrap; }
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
/* Fileshare-Baum + Tabelle nebeneinander sprengt auf Tablet-/Handy-
Breite die Seite (Baum-Spalte ist fest 260px breit) -- Baum stapelt
stattdessen oben, Tabelle darunter in voller Breite. */
.fileshare-layout { flex-direction: column; }
.fileshare-tree { flex: 1 1 auto; max-width: 100%; max-height: 240px; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.topbar-logo { display: none; } .topbar-logo { display: none; }
/* Formulare mit zwei nebeneinander angeordneten Feldern (class="flex
gap-2" um zwei .field-Divs, z.B. HTTP-/HTTPS-Port bei NGINX) stapeln
auf Handy-Breite volle Breite pro Feld statt zwei sehr schmale
Eingaben nebeneinander zu erzwingen. */
form .flex > .field { flex-basis: 100%; }
.modal-header, .modal-body, .modal-footer { padding-left: 16px; padding-right: 16px; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } .device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.topbar-title { font-size: 16px; } .topbar-title { font-size: 16px; }
.stat-row { grid-template-columns: repeat(2, 1fr); } .stat-row { grid-template-columns: repeat(2, 1fr); }
/* Button-Leisten in Kopfzeilen (z.B. section-head/card-Kopf mit
"RAW anzeigen" o.ä.) nehmen auf sehr schmalen Screens die volle
Breite ein statt als schmaler Block rechts zu kleben. */
.section-head > .btn,
.section-head > .btn-sm { flex: 1 1 100%; }
/* Auf einem iPhone quetschen die beiden rechten Topbar-Pillen (Prüf-
Countdown + DHCP) .topbar-left (flex:1 1 0%, min-width:0) so weit
zusammen, dass der Seitentitel über seine eigene Spaltenbreite
hinausläuft und sichtbar mit der Pille kollidiert (per Playwright-
Screenshot auf 393px Breite nachgewiesen: .topbar-title ragte 24.5px
über .topbar-lefts berechnete Breite hinaus). Titel und Pillen
bekommen ab hier je eine eigene Zeile statt sich einen Platz zu
teilen, der für beide zusammen nicht reicht. */
.topbar { flex-wrap: wrap; row-gap: 6px; }
.topbar-left { flex: 1 1 100%; }
.topbar-right { flex: 1 1 100%; justify-content: flex-start; }
} }
+120 -2
View File
@@ -245,12 +245,30 @@
}); });
} }
window.openRawLogModal = function (url, title) { function renderRawLogLines(container, text) {
// Eine Zeile = ein <div>, Nummer kommt per CSS-Counter (::before) --
// so bleibt der eigentliche Zeileninhalt reiner textContent (kein XSS-
// Risiko) und die Nummerierung muss nirgends von Hand mitgezählt werden.
const lines = text.split("\n");
if (lines.length && lines[lines.length - 1] === "") lines.pop(); // trailing \n erzeugt sonst eine Phantomzeile
container.textContent = "";
const frag = document.createDocumentFragment();
lines.forEach((line) => {
const row = document.createElement("div");
row.className = "raw-log-line";
row.textContent = line;
frag.appendChild(row);
});
container.appendChild(frag);
}
window.openRawLogModal = function (url, title, withLineNumbers) {
if (!url) return; if (!url) return;
ensureRawLogModal(); ensureRawLogModal();
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)"; document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
const content = document.getElementById("raw-log-modal-content"); const content = document.getElementById("raw-log-modal-content");
const meta = document.getElementById("raw-log-modal-meta"); const meta = document.getElementById("raw-log-modal-meta");
content.classList.toggle("with-line-numbers", !!withLineNumbers);
content.textContent = "Lade …"; content.textContent = "Lade …";
meta.textContent = ""; meta.textContent = "";
openModal("raw-log-modal"); openModal("raw-log-modal");
@@ -260,10 +278,109 @@
if (logName) meta.textContent = logName; if (logName) meta.textContent = logName;
return r.text(); return r.text();
}) })
.then((text) => { content.textContent = text; }) .then((text) => {
if (withLineNumbers) renderRawLogLines(content, text);
else content.textContent = text;
})
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; }); .catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
}; };
/* ---------------- Hinweis-Icons (Hover-Tooltip + Klick-Modal) ---------------- */
/* Zentraler Mechanismus für ALLE "i"-Icons (siehe _hint_icon.html) app-weit
-- ein einziges delegiertes Set von Listenern statt pro Icon eigener
Handler, damit auch nachträglich per JS eingefügte Icons (z.B. in
dynamisch nachgeladenen Tabellenzeilen) ohne weiteres Zutun funktionieren.
Hover zeigt den Text als Tooltip neben dem Mauszeiger -- aber NUR auf
Geräten, die tatsächlich sinnvoll hovern können (matchMedia-Check bei
JEDEM mouseenter neu ausgewertet, nicht einmalig beim Laden gecacht,
damit z.B. ein Convertible/Tablet beim Umschalten Maus/Touch korrekt
reagiert). Auf reinen Touch-Geräten bleibt so nur Tap -> Modal übrig,
da dort ohnehin kein echtes mouseenter/mousemove-Hover stattfindet. */
function ensureHintTooltip() {
if (document.getElementById("hint-tooltip")) return;
const el = document.createElement("div");
el.id = "hint-tooltip";
document.body.appendChild(el);
}
function ensureHintModal() {
if (document.getElementById("hint-modal")) return;
const html = `
<div class="modal-overlay" id="hint-modal">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h3 id="hint-modal-title">Hinweis</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p id="hint-modal-body" class="text-dim" style="margin:0; line-height:1.6;"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#hint-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("hint-modal").addEventListener("click", (e) => {
if (e.target.id === "hint-modal") closeModal(e.target);
});
}
function canHover() {
return window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
function initHintIcons() {
if (!document.querySelector(".hint-icon")) return;
ensureHintTooltip();
const tooltip = document.getElementById("hint-tooltip");
function positionTooltip(e) {
const offset = 14;
const rect = tooltip.getBoundingClientRect();
let x = e.clientX + offset;
let y = e.clientY + offset;
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offset;
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offset;
tooltip.style.left = Math.max(8, x) + "px";
tooltip.style.top = Math.max(8, y) + "px";
}
// mouseenter/mouseleave bubbeln nicht -- Delegation via Capture-Phase
// auf document funktioniert dafür trotzdem zuverlässig.
document.addEventListener("mouseenter", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon || !canHover()) return;
tooltip.textContent = icon.dataset.hint || "";
tooltip.style.display = "block";
positionTooltip(e);
}, true);
document.addEventListener("mouseleave", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon) return;
tooltip.style.display = "none";
}, true);
document.addEventListener("mousemove", function (e) {
if (tooltip.style.display === "block") positionTooltip(e);
});
document.addEventListener("click", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon) return;
e.preventDefault();
tooltip.style.display = "none";
ensureHintModal();
document.getElementById("hint-modal-title").textContent = icon.dataset.hintTitle || "Hinweis";
document.getElementById("hint-modal-body").textContent = icon.dataset.hint || "";
openModal("hint-modal");
});
}
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */ /* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten /* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/ Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
@@ -607,6 +724,7 @@
initLeaseCountdowns(); initLeaseCountdowns();
initUnsavedChangesGuard(); initUnsavedChangesGuard();
initSortableTables(); initSortableTables();
initHintIcons();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
}); });
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+67
View File
@@ -0,0 +1,67 @@
{#
Gemeinsame Auditlog-Zeilendarstellung -- von activity_log.html (Erstladung)
UND _audit_log_rows.html (AJAX-Nachladen über "Weitere 500 laden")
importiert, damit beide garantiert dieselbe Darstellung erzeugen und
action_labels/action_icons/category_of nicht an zwei Stellen gepflegt
werden müssen.
#}
{% set action_labels = {
"settings.update": "Einstellung geändert",
"device.create": "Gerät angelegt",
"device.edit": "Gerät bearbeitet",
"device.delete": "Gerät gelöscht",
"device.activate": "Gerät aktiviert",
"device.deactivate": "Gerät deaktiviert",
"switch.create": "Switch angelegt",
"switch.edit": "Switch bearbeitet",
"switch.delete": "Switch gelöscht",
"credential.create": "Zugangsdaten angelegt",
"credential.edit": "Zugangsdaten bearbeitet",
"credential.delete": "Zugangsdaten gelöscht",
"user.create": "Benutzer angelegt",
"user.edit": "Benutzer bearbeitet",
"user.delete": "Benutzer gelöscht",
"user.assign_group": "Gruppe zugewiesen",
"group.create": "Gruppe angelegt",
"group.edit": "Gruppe bearbeitet",
"group.delete": "Gruppe gelöscht",
"group.assign_admins": "Admin-Zuweisung geändert",
"profile.update": "Profil aktualisiert",
"profile.password": "Passwort geändert",
"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",
"auditlog.archive": "Auditlog archiviert",
"auditlog.export_delete": "Auditlog-Archiv exportiert",
} %}
{% 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"/>',
"create": '<path d="M12 5v14M5 12h14"/>',
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
"activate": '<path d="M20 6L9 17l-5-5"/>',
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
} %}
{% set create_kinds = ("create", "upload", "mkdir") %}
{% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %}
{% macro audit_row(e) %}
{% set kind = e['action'].split('.')[-1] %}
{% set cat = category_of(kind)|trim %}
<tr data-category="{{ cat }}" data-sort-ts="{{ e['ts'] }}" data-sort-user="{{ e['username']|lower }}"
data-sort-action="{{ action_labels.get(e['action'], e['action'])|lower }}" data-sort-target="{{ (e['target'] or '')|lower }}">
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
<td class="cell-name">{{ e['username'] }}</td>
<td>
<span class="pill action-pill action-pill--{{ cat }}">
<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']) }}
</span>
</td>
<td>{{ e['target'] or '—' }}</td>
<td class="text-dim">{{ e['details'] or '—' }}</td>
</tr>
{% endmacro %}
+5
View File
@@ -0,0 +1,5 @@
{# Reines Zeilenfragment fürs AJAX-Nachladen (activity_log_more()) -- kein
umschließendes <table>/<tbody>, wird per insertAdjacentHTML direkt an das
bestehende tbody von #auditTable angehängt. #}
{% import "_audit_log_macros.html" as m %}
{% for e in entries %}{{ m.audit_row(e) }}{% endfor %}
+9
View File
@@ -0,0 +1,9 @@
{#
Gemeinsames "i"-Hinweis-Icon: Hover zeigt den Text als Tooltip neben dem
Mauszeiger, Klick/Tap öffnet dasselbe (wiederverwendete) Modal mit dem
vollen Text -- Mechanik lebt zentral in app.js (initHintIcons), hier wird
nur das Icon mit seinen Daten-Attributen erzeugt. title = Kontext für die
Modal-Überschrift (z.B. das zugehörige Feld-Label), text = der eigentliche
Erklärungstext.
#}
{% macro hint_icon(text, title='Hinweis') %}<button type="button" class="hint-icon" data-hint="{{ text }}" data-hint-title="{{ title }}" aria-label="Hinweis: {{ title }}">i</button>{% endmacro %}
+208 -59
View File
@@ -1,75 +1,224 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "logs" %} {% set active_page = "logs" %}
{% block page_title %}Änderungen{% endblock %} {% block page_title %}Auditlog{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %} {% block page_sub %}
<div class="topbar-sub" id="auditEntryCount">
{% if has_more %}{{ entries|length }} von {{ total_count }} Einträgen geladen{% else %}{{ entries|length }} Eintrag{{ 'e' if entries|length != 1 else '' }}{% endif %}
</div>
{% endblock %}
{% block content %} {% block content %}
{% import "_audit_log_macros.html" as m %}
{% set action_labels = { {% set ns = namespace(create=0, delete=0, edit=0) %}
"settings.update": "Einstellung geändert", {% for e in entries %}
"device.create": "Gerät angelegt", {% set cat = m.category_of(e['action'].split('.')[-1])|trim %}
"device.edit": "Gerät bearbeitet", {% if cat == "create" %}{% set ns.create = ns.create + 1 %}
"device.delete": "Gerät gelöscht", {% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %}
"device.activate": "Gerät aktiviert", {% else %}{% set ns.edit = ns.edit + 1 %}{% endif %}
"device.deactivate": "Gerät deaktiviert", {% endfor %}
"switch.create": "Switch angelegt",
"switch.edit": "Switch bearbeitet", {% macro fmt_count(loaded, total) %}{% if loaded < total %}{{ loaded }} / {{ total }}{% else %}{{ loaded }}{% endif %}{% endmacro %}
"switch.delete": "Switch gelöscht",
"credential.create": "Zugangsdaten angelegt", <div class="stat-row" style="margin-bottom:18px;">
"credential.edit": "Zugangsdaten bearbeitet", <div class="stat-card" data-category-filter="">
"credential.delete": "Zugangsdaten gelöscht", <div class="stat-label">Alle</div>
"user.create": "Benutzer angelegt", <div class="stat-value" id="statAll">{{ fmt_count(entries|length, total_count) }}</div>
"user.edit": "Benutzer bearbeitet", </div>
"user.delete": "Benutzer gelöscht", <div class="stat-card" data-category-filter="create">
"user.assign_group": "Gruppe zugewiesen", <div class="stat-label">Hinzufügen</div>
"group.create": "Gruppe angelegt", <div class="stat-value" id="statCreate" style="color:var(--success);">{{ fmt_count(ns.create, total_create) }}</div>
"group.edit": "Gruppe bearbeitet", </div>
"group.delete": "Gruppe gelöscht", <div class="stat-card" data-category-filter="edit">
"group.assign_admins": "Admin-Zuweisung geändert", <div class="stat-label">Änderungen</div>
"profile.update": "Profil aktualisiert", <div class="stat-value" id="statEdit" style="color:var(--accent-strong);">{{ fmt_count(ns.edit, total_edit) }}</div>
"profile.password": "Passwort geändert", </div>
"check.run_now": "Prüfung manuell gestartet", <div class="stat-card" data-category-filter="delete">
} %} <div class="stat-label">Löschungen</div>
{% set action_icons = { <div class="stat-value" id="statDelete" style="color:var(--danger);">{{ fmt_count(ns.delete, total_delete) }}</div>
"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"/>', </div>
"create": '<path d="M12 5v14M5 12h14"/>', </div>
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
"activate": '<path d="M20 6L9 17l-5-5"/>',
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
} %}
<div class="table-wrap"> <div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="auditSearch" placeholder="Benutzer, Aktion, Ziel oder Details durchsuchen...">
</div>
</div>
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table" id="auditTable" data-sortable>
<thead><tr><th style="width:1%;"></th><th>Zeitpunkt</th><th>Benutzer</th><th>Aktion</th><th>Ziel</th><th>Details</th></tr></thead> <thead><tr>
<th data-sort-key="ts" style="width:1%; white-space:nowrap;">Zeitpunkt</th>
<th data-sort-key="user" style="width:1%; white-space:nowrap;">Benutzer</th>
<th data-sort-key="action" style="width:1%; white-space:nowrap;">Aktion</th>
<th data-sort-key="target" style="width:1%; white-space:nowrap;">Ziel</th>
<th>Details</th>
</tr></thead>
<tbody> <tbody>
{% for e in entries %} {% for e in entries %}{{ m.audit_row(e) }}{% else %}
{% set kind = e['action'].split('.')[-1] %} <tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
<tr>
<td>
{% if avatars.get(e['username']) %}
<img class="avatar-sm" src="{{ url_for('static', filename='uploads/avatars/' + avatars[e['username']]) }}" alt="">
{% else %}
<span class="avatar-sm avatar-placeholder">{{ e['username'][:1]|upper }}</span>
{% endif %}
</td>
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
<td class="cell-name">{{ e['username'] }}</td>
<td>
<span class="pill action-pill">
<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']) }}
</span>
</td>
<td>{{ e['target'] or '—' }}</td>
<td class="text-dim">{{ e['details'] or '—' }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">Noch keine Änderungen protokolliert.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<p id="auditNoResults" class="text-faint hidden" style="padding:16px; text-align:center; font-size:12.5px;">Keine Einträge für diese Auswahl.</p>
<div id="auditLoadMoreWrap" class="{{ 'hidden' if not has_more }}" style="padding:16px; text-align:center; display:flex; gap:8px; justify-content:center;">
<button type="button" id="auditLoadMoreBtn" class="btn btn-primary" data-oldest-id="{{ oldest_loaded_id or '' }}">
Mehr laden (300)
</button>
<button type="button" id="auditLoadAllBtn" class="btn btn-secondary">
Alle laden
</button>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const searchInput = document.getElementById("auditSearch");
const noResults = document.getElementById("auditNoResults");
let activeCategory = null;
function applyFilters() {
const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr"));
let anyVisible = false;
rows.forEach(function (row) {
if (row.classList.contains("empty-row")) return;
const categoryMatches = !activeCategory || row.dataset.category === activeCategory;
const textMatches = !q || row.innerText.toLowerCase().includes(q);
const match = categoryMatches && textMatches;
row.style.display = match ? "" : "none";
if (match) anyVisible = true;
});
if (noResults) noResults.classList.toggle("hidden", anyVisible);
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
const isTotal = card.dataset.categoryFilter === "";
const active = activeCategory === null ? isTotal : card.dataset.categoryFilter === activeCategory;
card.classList.toggle("active", active);
});
}
if (searchInput) searchInput.addEventListener("input", applyFilters);
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
card.addEventListener("click", function () {
const key = this.dataset.categoryFilter;
activeCategory = (!key || activeCategory === key) ? null : key;
applyFilters();
});
});
/* ---- Nachladen älterer Einträge (Mehr laden / Alle laden) ---- */
const totalCount = {{ total_count }};
const totalCreate = {{ total_create }};
const totalEdit = {{ total_edit }};
const totalDelete = {{ total_delete }};
const entryCountLabel = document.getElementById("auditEntryCount");
function fmtCount(loaded, total) {
return loaded < total ? loaded + " / " + total : String(loaded);
}
function recomputeStats() {
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr[data-category]"));
const counts = { create: 0, edit: 0, delete: 0 };
rows.forEach(function (r) { counts[r.dataset.category] = (counts[r.dataset.category] || 0) + 1; });
document.getElementById("statAll").textContent = fmtCount(rows.length, totalCount);
document.getElementById("statCreate").textContent = fmtCount(counts.create, totalCreate);
document.getElementById("statEdit").textContent = fmtCount(counts.edit, totalEdit);
document.getElementById("statDelete").textContent = fmtCount(counts.delete, totalDelete);
if (entryCountLabel) {
entryCountLabel.textContent = rows.length < totalCount
? rows.length + " von " + totalCount + " Einträgen geladen"
: rows.length + " Eintrag" + (rows.length === 1 ? "" : "e");
}
return rows.length;
}
const loadMoreBtn = document.getElementById("auditLoadMoreBtn");
const loadAllBtn = document.getElementById("auditLoadAllBtn");
const loadMoreWrap = document.getElementById("auditLoadMoreWrap");
const tbody = document.querySelector("#auditTable tbody");
const MORE_URL = "{{ url_for('activity_log_more') }}";
// Laedt genau einen weiteren 300er-Block nach und haengt ihn an -- von
// beiden Buttons genutzt: "Mehr laden" ruft das einmal auf, "Alle laden"
// ruft es wiederholt auf, bis der Server "keine weiteren mehr" meldet.
function loadMoreOnce() {
const oldestId = loadMoreBtn.dataset.oldestId;
if (!oldestId) return Promise.resolve({ hasMore: false });
return fetch(MORE_URL + "?before_id=" + encodeURIComponent(oldestId))
.then(function (r) {
const hasMore = r.headers.get("X-Has-More") === "1";
const newOldestId = r.headers.get("X-Oldest-Id");
return r.text().then(function (html) {
return { html: html, hasMore: hasMore, newOldestId: newOldestId };
});
})
.then(function (result) {
const emptyRow = tbody.querySelector(".empty-row");
if (emptyRow) emptyRow.remove();
tbody.insertAdjacentHTML("beforeend", result.html);
if (result.newOldestId) loadMoreBtn.dataset.oldestId = result.newOldestId;
return result;
});
}
if (loadMoreBtn) {
loadMoreBtn.addEventListener("click", function () {
loadMoreBtn.disabled = true;
if (loadAllBtn) loadAllBtn.disabled = true;
const originalText = loadMoreBtn.textContent;
loadMoreBtn.textContent = "Lädt …";
loadMoreOnce()
.then(function (result) {
if (!result.hasMore) loadMoreWrap.classList.add("hidden");
recomputeStats();
applyFilters();
})
.catch(function () { /* Button unten wird trotzdem wieder aktiviert */ })
.then(function () {
loadMoreBtn.disabled = false;
if (loadAllBtn) loadAllBtn.disabled = false;
loadMoreBtn.textContent = originalText;
});
});
}
if (loadAllBtn) {
loadAllBtn.addEventListener("click", function () {
loadMoreBtn.disabled = true;
loadAllBtn.disabled = true;
const originalText = loadAllBtn.textContent;
function step() {
loadAllBtn.textContent = "Lädt … (" + recomputeStats() + " von " + totalCount + ")";
return loadMoreOnce().then(function (result) {
return result.hasMore ? step() : null;
});
}
step()
.then(function () {
loadMoreWrap.classList.add("hidden");
})
.catch(function () { /* teilweise geladene Eintraege bleiben stehen */ })
.then(function () {
recomputeStats();
applyFilters();
loadMoreBtn.disabled = false;
loadAllBtn.disabled = false;
loadAllBtn.textContent = originalText;
});
});
}
applyFilters();
recomputeStats();
});
</script>
{% endblock %}
+3
View File
@@ -20,6 +20,9 @@
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>', "terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>', "history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>', "clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
"shield": '<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>',
"server": '<rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01M6 17.5h.01"/>',
"folder": '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>', "sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>', "logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>', "gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
+2 -2
View File
@@ -8,6 +8,7 @@
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
@@ -95,13 +96,12 @@
<div class="field"><label>Username</label> <div class="field"><label>Username</label>
<input type="text" name="username" required placeholder="z.B. admin"> <input type="text" name="username" required placeholder="z.B. admin">
</div> </div>
<div class="field"><label>Kategorie</label> <div class="field"><label>Kategorie {{ hi.hint_icon("Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.", "Kategorie") }}</label>
<select name="category"> <select name="category">
{% for key, label in categories %} {% for key, label in categories %}
<option value="{{ key }}">{{ label }}</option> <option value="{{ key }}">{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="hint">Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.</div>
</div> </div>
<div class="field"><label>Passwort</label> <div class="field"><label>Passwort</label>
<input type="password" id="password_add" name="password" required> <input type="password" id="password_add" name="password" required>
+5 -8
View File
@@ -13,6 +13,7 @@
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
@@ -125,9 +126,8 @@
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF"> <input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div> <div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
</div> </div>
<div class="field"><label>Switchport</label> <div class="field"><label>Switchport {{ hi.hint_icon("Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.", "Switchport") }}</label>
<input type="text" name="port" placeholder="z.B. 3"> <input type="text" name="port" placeholder="z.B. 3">
<div class="field-hint">Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.</div>
</div> </div>
<div class="field"><label>Switch (optional)</label> <div class="field"><label>Switch (optional)</label>
<select name="switch_hostname"> <select name="switch_hostname">
@@ -136,9 +136,8 @@
</select> </select>
</div> </div>
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div> <div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
<div class="field"><label>SSH-Port</label> <div class="field"><label>SSH-Port {{ hi.hint_icon("TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.", "SSH-Port") }}</label>
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)"> <input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Zugangsdaten</label> <label>Zugangsdaten</label>
@@ -201,14 +200,12 @@
<input type="text" name="mac" id="edit_mac" required> <input type="text" name="mac" id="edit_mac" required>
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div> <div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
</div> </div>
<div class="field"><label>Switchport</label> <div class="field"><label>Switchport {{ hi.hint_icon("Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.", "Switchport") }}</label>
<input type="text" name="port" id="edit_port"> <input type="text" name="port" id="edit_port">
<div class="field-hint">Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.</div>
</div> </div>
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div> <div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
<div class="field"><label>SSH-Port</label> <div class="field"><label>SSH-Port {{ hi.hint_icon("TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.", "SSH-Port") }}</label>
<input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)"> <input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Zugangsdaten</label> <label>Zugangsdaten</label>
+617
View File
@@ -0,0 +1,617 @@
{% extends "base.html" %}
{% set active_page = "fileshare" %}
{% block page_title %}Dateifreigaben{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
{% 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>
<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>
</div>
<div class="fileshare-layout">
<div class="fileshare-tree card">
<div class="fileshare-tree-title">Freigaben</div>
<ul class="tree-root" id="fileshareTree">
{% for s in shares %}
{{ render_tree_node(s, '', s, tree_expanded if s == selected_share else {}, rel_path if s == selected_share else None) }}
{% endfor %}
</ul>
</div>
<div class="fileshare-main">
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="fileSearch" placeholder="Datei/Ordner suchen..." oninput="filterTable('fileSearch', 'fileTable')">
</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 id="bulkActionsBar" class="flex gap-2 hidden" data-share="{{ selected_share }}" data-path="{{ rel_path }}" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
<span id="bulkSelectedCount" class="text-faint" style="font-size:12.5px; font-weight:600;"></span>
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkDownload()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Herunterladen (ZIP)
</button>
{% if can_edit %}
<button type="button" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);" onclick="bulkDelete()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
Löschen
</button>
{% endif %}
</div>
{% if entries %}
<div style="overflow-x:auto;">
<table class="data-table" id="fileTable" data-sortable>
<thead><tr>
<th style="width:1%;"><input type="checkbox" id="selectAllFiles"></th>
<th data-sort-key="name">Name</th>
<th data-sort-key="size">Größe</th>
<th data-sort-key="mtime">Geändert</th>
<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><input type="checkbox" class="row-select" value="{{ e.name }}"></td>
<td class="cell-name">
{% if e.is_dir %}
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
<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 %}
</div>
</div>
</div>
{% if can_create %}
<div class="modal-overlay" id="uploadModal">
<div class="modal">
<form method="post" action="{{ url_for('fileshare_upload') }}" enctype="multipart/form-data">
<input type="hidden" name="share" value="{{ selected_share }}">
<input type="hidden" name="path" value="{{ rel_path }}">
<div class="modal-header">
<h3>Datei(en) hochladen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Datei(en) {{ hi.hint_icon("Insgesamt maximal 15 MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).", "Datei(en)") }}</label>
<input type="file" name="file" id="uploadFileInput" multiple required>
<div id="uploadFileList" class="upload-file-list"></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>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Hochladen</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="mkdirModal">
<div class="modal">
<form method="post" action="{{ url_for('fileshare_mkdir') }}">
<input type="hidden" name="share" value="{{ selected_share }}">
<input type="hidden" name="path" value="{{ rel_path }}">
<div class="modal-header">
<h3>Neuer Ordner</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Ordnername</label>
<input type="text" name="name" required autofocus>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if can_edit %}
<div class="modal-overlay" id="renameModal">
<div class="modal">
<form method="post" action="{{ url_for('fileshare_rename') }}">
<input type="hidden" name="share" value="{{ selected_share }}">
<input type="hidden" name="path" value="{{ rel_path }}">
<input type="hidden" name="old_name" id="renameOldName">
<div class="modal-header">
<h3>Umbenennen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Neuer Name</label>
<input type="text" name="new_name" id="renameNewName" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Umbenennen</button>
</div>
</form>
</div>
</div>
{% 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>&times;</button>
</div>
<div class="modal-body" id="previewBody" style="max-height:75vh; overflow:auto;"></div>
</div>
</div>
{% endblock %}
{% 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>
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
const FILESHARE_DOWNLOAD_MULTI_URL = "{{ url_for('fileshare_download_multi') }}";
const FILESHARE_DELETE_MULTI_URL = "{{ url_for('fileshare_delete_multi') }}";
function openRenameModal(name) {
document.getElementById("renameOldName").value = name;
document.getElementById("renameNewName").value = name;
PoeUI.openModal("renameModal");
}
function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
if (row.classList.contains("empty-row")) return;
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
/* ---------------- Mehrfachauswahl (Herunterladen als ZIP / Löschen) ---------------- */
function getSelectedFileNames() {
return Array.from(document.querySelectorAll("#fileTable .row-select:checked")).map(function (cb) { return cb.value; });
}
function updateBulkBar() {
const names = getSelectedFileNames();
const bar = document.getElementById("bulkActionsBar");
const countEl = document.getElementById("bulkSelectedCount");
if (!bar) return;
if (names.length > 0) {
bar.classList.remove("hidden");
countEl.textContent = names.length + " ausgewählt";
} else {
bar.classList.add("hidden");
}
const selectAll = document.getElementById("selectAllFiles");
const allBoxes = document.querySelectorAll("#fileTable .row-select");
if (selectAll && allBoxes.length) {
selectAll.checked = names.length === allBoxes.length;
selectAll.indeterminate = names.length > 0 && names.length < allBoxes.length;
}
}
function submitBulkForm(actionUrl, names) {
// data-share/data-path statt Jinja-Werte direkt in einen JS-String-
// Literal zu setzen -- Ordnernamen kommen von der echten Freigabe und
// koennten Anfuehrungszeichen o.ae. enthalten, ueber HTML-Attribute
// (von Jinja automatisch escaped) ist das unproblematisch.
const bar = document.getElementById("bulkActionsBar");
const form = document.createElement("form");
form.method = "post";
form.action = actionUrl;
form.style.display = "none";
[["share", bar.dataset.share], ["path", bar.dataset.path]].forEach(function (pair) {
const input = document.createElement("input");
input.type = "hidden";
input.name = pair[0];
input.value = pair[1];
form.appendChild(input);
});
names.forEach(function (name) {
const input = document.createElement("input");
input.type = "hidden";
input.name = "names";
input.value = name;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function bulkDownload() {
const names = getSelectedFileNames();
if (!names.length) return;
submitBulkForm(FILESHARE_DOWNLOAD_MULTI_URL, names);
}
function bulkDelete() {
const names = getSelectedFileNames();
if (!names.length) return;
window.confirmAction(
names.length + " ausgewählte Elemente wirklich endgültig löschen?",
function () { submitBulkForm(FILESHARE_DELETE_MULTI_URL, names); },
"Auswahl löschen?"
);
}
document.addEventListener("DOMContentLoaded", function () {
const selectAll = document.getElementById("selectAllFiles");
if (selectAll) {
selectAll.addEventListener("change", function () {
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) { cb.checked = selectAll.checked; });
updateBulkBar();
});
}
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) {
cb.addEventListener("change", updateBulkBar);
});
});
/* ---------------- Mehrfach-Upload (Multiauswahl + mehrmals nacheinander) ---------------- */
/* Ein <input type=file multiple> ERSETZT bei jeder erneuten Dateiauswahl
die vorherige -- fuer "mehrmals nacheinander hinzufuegen" wird deshalb
selbst eine "angesammelte" Auswahl per DataTransfer gepflegt und nach
jeder Aenderung zurueck auf das Input-Feld geschrieben, sodass das
normale <form>-Submit (kein fetch() noetig) am Ende alle gesammelten
Dateien mitschickt. DataTransfer-Zuweisung an .files wird von allen
gaengigen Mobil-Browsern (Android Chrome, iOS Safari) mitgetragen; falls
nicht, faellt es einfach auf das native Verhalten (letzte Auswahl zaehlt)
zurueck, ohne den Upload an sich zu verhindern. */
(function () {
const input = document.getElementById("uploadFileInput");
const listEl = document.getElementById("uploadFileList");
if (!input || !listEl) return;
let staged = null;
try { staged = new DataTransfer(); } catch (e) { staged = null; }
function render() {
listEl.innerHTML = "";
if (!staged) return;
Array.from(staged.files).forEach(function (file, idx) {
const row = document.createElement("div");
row.className = "upload-file-row";
const name = document.createElement("span");
name.textContent = file.name;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "upload-file-remove";
removeBtn.setAttribute("aria-label", "Entfernen");
removeBtn.textContent = "×";
removeBtn.addEventListener("click", function () {
const dt = new DataTransfer();
Array.from(staged.files).forEach(function (f, i) {
if (i !== idx) dt.items.add(f);
});
staged = dt;
input.files = staged.files;
render();
});
row.appendChild(name);
row.appendChild(removeBtn);
listEl.appendChild(row);
});
}
input.addEventListener("change", function () {
if (!staged) return; // kein DataTransfer-Support -- natives Verhalten greift
Array.from(input.files).forEach(function (file) { staged.items.add(file); });
input.files = staged.files;
render();
});
// Beim (Wieder-)Oeffnen des Modals eine frische Sammlung starten, statt
// Dateien aus einem vorherigen, bereits abgeschickten Upload-Vorgang
// versehentlich mitzuschleppen.
document.querySelectorAll('[data-open-modal="uploadModal"]').forEach(function (btn) {
btn.addEventListener("click", function () {
try { staged = new DataTransfer(); } catch (e) { staged = null; }
input.value = "";
render();
});
});
})();
/* ---------------- Baum-Navigation (Freigaben links) ---------------- */
function buildTreeNode(share, path, name) {
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>
{% endblock %}
+101 -76
View File
@@ -4,6 +4,7 @@
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
{% macro permission_table(group, group_key, checked_keys, readonly) %} {% macro permission_table(group, group_key, checked_keys, readonly) %}
{% set row_types = group_row_types[group_key] %} {% set row_types = group_row_types[group_key] %}
@@ -69,11 +70,7 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Gruppen</h2> <h2 style="font-size:16px;">Gruppen {{ hi.hint_icon("Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich. „Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.", "Gruppen") }}</h2>
<div class="hint">
Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich.
„Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.
</div>
</div> </div>
{% if current_user.has_permission('groups.create') %} {% if current_user.has_permission('groups.create') %}
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal"> <button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
@@ -104,8 +101,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 +110,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 +124,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 +145,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 +155,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>&times;</button>
</div>
<div class="modal-body">
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
</div>
</div>
</div>
<div class="modal-overlay" id="adminMembersModal"> <div class="modal-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 +196,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 +228,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>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<input type="text" name="name" value="{{ g.name }}" required>
</div>
{{ permission_tree(g.permissions, false, true) }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</div>
</form>
{% elif can_unlock_system %}
<div class="modal-header">
<h3>Gruppe „{{ g.name }}“</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Name</label><input type="text" value="{{ g.name }}" disabled></div>
<div id="readonly-{{ g.id }}">
{{ permission_tree(g.permissions, true, true) }}
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px; flex-wrap:wrap;">
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
</div>
</div>
<form method="post" class="hidden" id="unlock-{{ g.id }}">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="unlock_system_group" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
{{ permission_tree(g.permissions, false, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
Rechte hier können den Erst-Login neuer Konten einschränken.
</p>
<div class="flex" style="justify-content:flex-end;">
<button type="submit" class="btn btn-primary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Rechte speichern
</button>
</div>
</form>
</div>
{% else %}
<div class="modal-header">
<h3>Gruppe „{{ g.name }}“</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
{{ permission_tree(g.permissions, true, true) }}
{% if g.is_system %}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %} {% endfor %}
<div class="modal-overlay" id="addGroupModal"> <div class="modal-overlay" id="addGroupModal">
@@ -280,9 +314,8 @@
<div class="modal-body"> <div class="modal-body">
<input type="hidden" name="add_group" value="1"> <input type="hidden" name="add_group" value="1">
<div class="field"> <div class="field">
<label>Name</label> <label>Name {{ hi.hint_icon("Mitglieder werden danach über die Gruppentabelle zugeordnet.", "Name") }}</label>
<input type="text" name="name" required placeholder="z.B. Facility-Team"> <input type="text" name="name" required placeholder="z.B. Facility-Team">
<div class="field-hint">Mitglieder werden danach über die Gruppentabelle zugeordnet.</div>
</div> </div>
{{ permission_tree([], false) }} {{ permission_tree([], false) }}
</div> </div>
@@ -317,14 +350,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");
+61 -33
View File
@@ -1,14 +1,16 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "logs" %} {% set active_page = "logs" %}
{% block page_title %}Verlauf{% endblock %} {% block page_title %}Verlauf{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ selected_name or "kein Archiv-Log" }}</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Log-Verlauf</h2> <h2 style="font-size:16px;">Log-Verlauf</h2>
<div class="hint">Ältere, von logrotate rotierte Kopien des Live-Logs — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar.</div> <div class="hint">Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.</div>
<div class="hint">Zusätzlich weiter unten: bereits archivierte Auditlog-Tage (das Auditlog wird ab {{ "{:,}".format(audit_threshold).replace(",", ".") }} Einträgen automatisch tageweise archiviert, bis {{ "{:,}".format(audit_target).replace(",", ".") }} Einträge unterschritten sind).</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{{ url_for('logs') }}" class="btn btn-secondary"> <a href="{{ url_for('logs') }}" class="btn btn-secondary">
@@ -16,7 +18,7 @@
Zurück zur Live-Ansicht Zurück zur Live-Ansicht
</a> </a>
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_name or '' }}')" onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}', true)"
{% if not selected_name %}disabled{% endif %}> {% if not selected_name %}disabled{% endif %}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
Komplettes Log (RAW) Komplettes Log (RAW)
@@ -26,18 +28,16 @@
{% if not files %} {% if not files %}
<p class="text-faint" style="font-size:12.5px;"> <p class="text-faint" style="font-size:12.5px;">
Noch keine rotierten Live-Logs vorhanden — sobald logrotate das aktuelle Kein Live-Log gefunden.
<a href="{{ url_for('logs') }}" style="color:var(--accent-strong); font-weight:600;">Live-Log</a>
zum ersten Mal rotiert (siehe Rotations-Intervall unter Systemeinstellungen), erscheinen ältere Stände hier.
</p> </p>
{% else %} {% else %}
<div class="flex gap-2" style="align-items:center; margin-bottom:14px;"> <div class="flex gap-2" style="align-items:center; margin-bottom:14px;">
<label for="history-file-select" class="text-faint" style="font-size:12.5px; font-weight:600;">Archiv-Stand:</label> <label for="history-file-select" class="text-faint" style="font-size:12.5px; font-weight:600;">Zeitraum:</label>
<select id="history-file-select" onchange="window.location.href=this.value;" class="mono" style="max-width:420px;"> <select id="history-file-select" onchange="window.location.href=this.value;" class="mono" style="max-width:460px;">
{% for f in files %} {% for f in files %}
<option value="{{ url_for('logs_history', file=f.filename) }}" {% if f.filename == selected_name %}selected{% endif %}> <option value="{{ url_for('logs_history', file=f.filename) }}" {% if f.filename == selected_name %}selected{% endif %}>
{{ f.filename }} — rotiert am {{ f.mtime_str }} ({{ f.size_str }}) {% if f.generation == 0 %}Aktuell — {{ f.range_label }}{% else %}{{ f.range_label }}{% endif %} ({{ f.size_str }})
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
@@ -46,39 +46,67 @@
<div class="log-shell"> <div class="log-shell">
<div class="log-toolbar"> <div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div> <div class="log-dots"><span></span><span></span><span></span></div>
<span class="text-faint mono" style="font-size:11.5px;">{{ selected_name }}</span> <span class="text-faint mono" style="font-size:11.5px;">{{ selected_file.range_label if selected_file else "" }}</span>
<span class="text-faint mono" style="font-size:11.5px;">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen{% endif %}</span> <span class="text-faint mono" style="font-size:11.5px;">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen — RAW{% endif %}</span>
</div> </div>
<div id="log-box">{{ log_content or "" }}</div> <pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
</div>
{% endif %}
<div class="section-head" style="margin-top:28px;">
<div>
<h2 style="font-size:16px;">Archivierte Auditlog-Tage {{ hi.hint_icon("Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.", "Archivierte Auditlog-Tage") }}</h2>
</div>
{% if current_user.can_manage_log_history and audit_files %}
<form method="post" action="{{ url_for('logs_history_audit_export') }}"
data-confirm="Alle {{ audit_files|length }} archivierten Auditlog-Datei(en) als ZIP herunterladen und danach vom Server löschen?"
data-confirm-title="Export &amp; Löschen">
<button type="submit" class="btn btn-secondary">
<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>
Alle exportieren &amp; löschen (ZIP)
</button>
</form>
{% endif %}
</div>
{% if disk_warning %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ disk_warning }}</div>
{% endif %}
{% if not audit_files %}
<p class="text-faint" style="font-size:12.5px;">Noch keine archivierten Auditlog-Tage vorhanden.</p>
{% else %}
<div class="table-wrap">
<table class="data-table">
<thead><tr>
<th style="width:1%; white-space:nowrap;">Tag</th>
<th style="width:1%; white-space:nowrap;">Größe</th>
<th></th>
</tr></thead>
<tbody>
{% for f in audit_files %}
<tr>
<td class="mono">{{ f.day }}</td>
<td class="text-dim mono" style="font-size:12.5px;">{{ (f.size / 1024)|round(1) }} KB</td>
<td>
<button type="button" class="btn btn-secondary btn-sm"
onclick="openRawLogModal('{{ url_for('logs_history_audit_raw', file=f.filename) }}', 'Auditlog-Archiv — {{ f.day }}', true)">
Ansehen (RAW)
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
function colorizeLine(line) {
let cls = "";
if (line.includes(" ist erreichbar!")) cls = "online";
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
else if (line.startsWith("----")) cls = "sep";
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
const span = document.createElement("span");
span.className = "log-line" + (cls ? " " + cls : "");
span.textContent = line;
return span;
}
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const box = document.getElementById("log-box"); const box = document.getElementById("log-box");
if (!box) return; if (box) box.scrollTop = box.scrollHeight;
const text = box.textContent;
box.innerHTML = "";
const lines = text.split("\n");
lines.forEach((line, i) => {
box.appendChild(colorizeLine(line));
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
});
box.scrollTop = box.scrollHeight;
}); });
</script> </script>
{% endblock %} {% endblock %}
+5 -7
View File
@@ -4,15 +4,11 @@
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Wartung (SSH)</h2> <h2 style="font-size:16px;">Wartung (SSH) {{ hi.hint_icon("Bulk-Update (apt update & upgrade -y) und Neustart per SSH direkt auf dem Gerät — unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.", "Wartung (SSH)") }}</h2>
<div class="hint">
Bulk-Update (<code>apt update &amp; upgrade -y</code>) und Neustart per SSH direkt auf dem Gerät —
unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten
SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.
</div>
</div> </div>
</div> </div>
@@ -87,7 +83,9 @@
{% if can_run %} {% if can_run %}
<div class="section-head" style="margin-top:16px;"> <div class="section-head" style="margin-top:16px;">
<div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.</div> <div style="display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--text-faint);">
Bulk-Aktionen {{ hi.hint_icon("Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.", "Bulk-Aktionen") }}
</div>
<div style="display:flex; gap:8px;"> <div style="display:flex; gap:8px;">
<button type="button" class="btn btn-secondary" onclick="bulkReboot()"> <button type="button" class="btn btn-secondary" onclick="bulkReboot()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
+7 -12
View File
@@ -4,6 +4,7 @@
{% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid"> <div class="settings-grid">
<div class="card card-pad"> <div class="card card-pad">
@@ -27,13 +28,12 @@
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;"> <hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post"> <form method="post">
<div class="field"> <div class="field">
<label for="timezone">Zeitzone</label> <label for="timezone">Zeitzone {{ hi.hint_icon("Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.", "Zeitzone") }}</label>
<select name="timezone" id="timezone" required> <select name="timezone" id="timezone" required>
{% for tz in timezones %} {% for tz in timezones %}
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option> <option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="field-hint">Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" class="btn btn-primary btn-block">
<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> <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>
@@ -43,9 +43,8 @@
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;"> <hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post"> <form method="post">
<div class="field"> <div class="field">
<label for="interval">Prüfintervall (Minuten)</label> <label for="interval">Prüfintervall (Minuten) {{ hi.hint_icon("Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.", "Prüfintervall (Minuten)") }}</label>
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required> <input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
<div class="field-hint">Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" class="btn btn-primary btn-block">
<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> <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>
@@ -118,13 +117,12 @@
{% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %} {% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %}
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt."> <form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
<input type="hidden" name="apply_network" value="1"> <input type="hidden" name="apply_network" value="1">
<div class="field"><label>Interface</label> <div class="field"><label>Interface {{ hi.hint_icon("Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.", "Interface") }}</label>
<select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)"> <select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)">
{% for iface in net_interfaces %} {% for iface in net_interfaces %}
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option> <option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="field-hint">Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.</div>
</div> </div>
<div class="field"><label>Modus</label> <div class="field"><label>Modus</label>
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')"> <select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
@@ -143,9 +141,8 @@
<input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1"> <input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
</div> </div>
</div> </div>
<div class="field"><label>DNS-Server</label> <div class="field"><label>DNS-Server {{ hi.hint_icon("Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.", "DNS-Server") }}</label>
<input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8"> <input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" class="btn btn-primary btn-block">
<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> <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>
@@ -184,9 +181,8 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen)</label> <label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen) {{ hi.hint_icon("Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).", "Aufbewahrung (Anzahl Rotationen)") }}</label>
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required> <input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
<div class="field-hint">Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" class="btn btn-primary btn-block">
<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> <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>
@@ -221,9 +217,8 @@
{% if current_user.has_permission('settings_system.edit') %} {% if current_user.has_permission('settings_system.edit') %}
<form method="post"> <form method="post">
<div class="field"> <div class="field">
<label for="trash_retention_days">Aufbewahrungsdauer (Tage)</label> <label for="trash_retention_days">Aufbewahrungsdauer (Tage) {{ hi.hint_icon("Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).", "Aufbewahrungsdauer (Tage)") }}</label>
<input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required> <input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required>
<div class="field-hint">Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).</div>
</div> </div>
<button type="submit" name="save_trash_retention" value="1" class="btn btn-primary btn-block"> <button type="submit" name="save_trash_retention" value="1" class="btn btn-primary btn-block">
<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> <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>
+15 -32
View File
@@ -5,6 +5,7 @@
{% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid"> <div class="settings-grid">
<div class="card card-pad"> <div class="card card-pad">
@@ -81,9 +82,8 @@
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label> <div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}"> <input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
</div> </div>
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label> <div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei) {{ hi.hint_icon("Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.", "Ausgabepfad (Kea-Konfigurationsdatei)") }}</label>
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono"> <input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
<div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" class="btn btn-primary btn-block">
<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> <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>
@@ -98,11 +98,7 @@
<div class="card card-pad" style="grid-column:1 / -1;"> <div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Subnetze</h2> <h2 style="font-size:16px;">Subnetze {{ hi.hint_icon("Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range, optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.", "Subnetze") }}</h2>
<div class="hint">
Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range,
optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.
</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal"> <button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
@@ -175,11 +171,7 @@
<div class="card card-pad" style="grid-column:1 / -1;"> <div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">DHCP-Options</h2> <h2 style="font-size:16px;">DHCP-Options {{ hi.hint_icon('Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt, eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.', "DHCP-Options") }}</h2>
<div class="hint">
Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt,
eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.
</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal"> <button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
@@ -217,7 +209,9 @@
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)"> <input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button> <button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
</form> </form>
<div class="field-hint">Leeren Wert speichern, um eine Standard-Option wieder auszublenden.</div> <div class="text-faint" style="font-size:11.5px; display:flex; align-items:center; gap:6px;">
Standard-Option zurücksetzen {{ hi.hint_icon("Leeren Wert speichern, um eine Standard-Option wieder auszublenden.", "Standard-Option zurücksetzen") }}
</div>
{% else %} {% else %}
{{ option_values.get(d.id, {}).get('', '—') }} {{ option_values.get(d.id, {}).get('', '—') }}
{% endif %} {% endif %}
@@ -272,11 +266,7 @@
<div class="card card-pad" style="grid-column:1 / -1;"> <div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Aktive Leases</h2> <h2 style="font-size:16px;">Aktive Leases {{ hi.hint_icon("Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung, die sich einfach eine freie IP aus dem Pool genommen haben.", "Aktive Leases") }}</h2>
<div class="hint">
Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung,
die sich einfach eine freie IP aus dem Pool genommen haben.
</div>
</div> </div>
</div> </div>
@@ -497,9 +487,8 @@
<div class="field"><label>Code</label> <div class="field"><label>Code</label>
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225"> <input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
</div> </div>
<div class="field"><label>Name</label> <div class="field"><label>Name {{ hi.hint_icon("Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.", "Name") }}</label>
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*"> <input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
<div class="field-hint">Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.</div>
</div> </div>
<div class="field"><label>Datentyp</label> <div class="field"><label>Datentyp</label>
<select name="type"> <select name="type">
@@ -539,13 +528,11 @@
<div class="field"><label>MAC-Adresse</label> <div class="field"><label>MAC-Adresse</label>
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF"> <input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
</div> </div>
<div class="field"><label>IP-Adresse</label> <div class="field"><label>IP-Adresse {{ hi.hint_icon("Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.", "IP-Adresse") }}</label>
<input type="text" name="ip" required placeholder="z.B. 192.168.1.50"> <input type="text" name="ip" required placeholder="z.B. 192.168.1.50">
<div class="field-hint">Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.</div>
</div> </div>
<div class="field"><label>Name</label> <div class="field"><label>Name {{ hi.hint_icon("Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.", "Name") }}</label>
<input type="text" name="name" required placeholder="z.B. Empfangsdrucker"> <input type="text" name="name" required placeholder="z.B. Empfangsdrucker">
<div class="field-hint">Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -575,16 +562,14 @@
<div class="field"><label>Range Start</label> <div class="field"><label>Range Start</label>
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100"> <input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
</div> </div>
<div class="field"><label>Range Ende</label> <div class="field"><label>Range Ende {{ hi.hint_icon("Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.", "Range Ende") }}</label>
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200"> <input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
</div> </div>
<div class="field"><label>Gateway (optional)</label> <div class="field"><label>Gateway (optional)</label>
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces"> <input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
</div> </div>
<div class="field"><label>DNS-Server (optional)</label> <div class="field"><label>DNS-Server (optional) {{ hi.hint_icon("Kommagetrennt, falls mehrere.", "DNS-Server (optional)") }}</label>
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8"> <input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -617,16 +602,14 @@
<div class="field"><label>Range Start</label> <div class="field"><label>Range Start</label>
<input type="text" name="range_start" value="{{ s.range_start }}" required> <input type="text" name="range_start" value="{{ s.range_start }}" required>
</div> </div>
<div class="field"><label>Range Ende</label> <div class="field"><label>Range Ende {{ hi.hint_icon("Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.", "Range Ende") }}</label>
<input type="text" name="range_end" value="{{ s.range_end }}" required> <input type="text" name="range_end" value="{{ s.range_end }}" required>
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
</div> </div>
<div class="field"><label>Gateway (optional)</label> <div class="field"><label>Gateway (optional)</label>
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces"> <input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
</div> </div>
<div class="field"><label>DNS-Server (optional)</label> <div class="field"><label>DNS-Server (optional) {{ hi.hint_icon("Kommagetrennt, falls mehrere.", "DNS-Server (optional)") }}</label>
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8"> <input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
+5 -13
View File
@@ -4,6 +4,7 @@
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid"> <div class="settings-grid">
<div class="card card-pad"> <div class="card card-pad">
@@ -26,7 +27,7 @@
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben."> data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
<input type="hidden" name="import_token" value="{{ import_preview.token }}"> <input type="hidden" name="import_token" value="{{ import_preview.token }}">
<div class="field"> <div class="field">
<label>Was importieren?</label> <label>Was importieren? {{ hi.hint_icon("AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet, nicht über diese App, und beim nächsten Login automatisch neu angelegt.", "Was importieren?") }}</label>
<div class="check-list"> <div class="check-list">
{% for s in import_preview.sections %} {% for s in import_preview.sections %}
<label class="check-row"> <label class="check-row">
@@ -38,10 +39,6 @@
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
<div class="field-hint">
AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet,
nicht über diese App, und beim nächsten Login automatisch neu angelegt.
</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" class="btn btn-primary btn-block">
<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> <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>
@@ -77,7 +74,7 @@
{% if current_user.has_permission('settings_importexport.export') %} {% if current_user.has_permission('settings_importexport.export') %}
<form method="post" action="{{ url_for('export_data') }}"> <form method="post" action="{{ url_for('export_data') }}">
<div class="field"> <div class="field">
<label>Was exportieren?</label> <label>Was exportieren? {{ hi.hint_icon("AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet (nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.", "Was exportieren?") }}</label>
<div class="check-list"> <div class="check-list">
{% for key, label in export_sections %} {% for key, label in export_sections %}
<label class="check-row"> <label class="check-row">
@@ -85,20 +82,15 @@
{% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %} {% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}> {% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
{{ label }} {{ label }}
{% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %}
{% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %} {% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
<div class="field-hint">
AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet
(nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird
lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.
</div>
</div> </div>
<div class="field"> <div class="field">
<label for="export_passphrase">Passphrase</label> <label for="export_passphrase">Passphrase {{ hi.hint_icon("Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.", "Passphrase") }}</label>
<input type="password" name="export_passphrase" id="export_passphrase" required> <input type="password" name="export_passphrase" id="export_passphrase" required>
<div class="field-hint">Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.</div>
</div> </div>
<button type="submit" class="btn btn-secondary btn-block"> <button type="submit" class="btn btn-secondary btn-block">
<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> <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>
+244 -25
View File
@@ -5,7 +5,8 @@
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
{% block content %} {% block content %}
<div class="settings-grid" style="grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));"> {% import "_hint_icon.html" as hi %}
<div class="settings-grid settings-grid--wide">
<div class="card card-pad"> <div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
@@ -23,18 +24,16 @@
<span>LDAP-Anmeldung aktivieren</span> <span>LDAP-Anmeldung aktivieren</span>
</label> </label>
</div> </div>
<div class="field"><label>Server</label> <div class="field"><label>Server {{ hi.hint_icon("DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.", "Server") }}</label>
<input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local"> <input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local">
<div class="field-hint">DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.</div>
</div> </div>
<div class="field"> <div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;"> <label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ldap_use_ssl" id="ldap_use_ssl" {% if ldap.use_ssl %}checked{% endif %} <input type="checkbox" name="ldap_use_ssl" id="ldap_use_ssl" {% if ldap.use_ssl %}checked{% endif %}
onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;"> onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;">
<span class="track"></span> <span class="track"></span>
<span>LDAPS/TLS verwenden</span> <span>LDAPS/TLS verwenden {{ hi.hint_icon("Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.", "LDAPS/TLS verwenden") }}</span>
</label> </label>
<div class="field-hint">Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.</div>
</div> </div>
<div class="field"><label>Port</label> <div class="field"><label>Port</label>
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}"> <input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
@@ -43,13 +42,11 @@
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;"> <label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}> <input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}>
<span class="track"></span> <span class="track"></span>
<span>Zertifikatsprüfung überspringen (nur LDAPS)</span> <span>Zertifikatsprüfung überspringen (nur LDAPS) {{ hi.hint_icon("Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.", "Zertifikatsprüfung überspringen") }}</span>
</label> </label>
<div class="field-hint">Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.</div>
</div> </div>
<div class="field"><label>Bind-Konto (Service-Account)</label> <div class="field"><label>Bind-Konto (Service-Account) {{ hi.hint_icon("Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.", "Bind-Konto (Service-Account)") }}</label>
<input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local"> <input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local">
<div class="field-hint">Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.</div>
</div> </div>
<div class="field"><label>Bind-Passwort</label> <div class="field"><label>Bind-Passwort</label>
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}"> <input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
@@ -57,18 +54,28 @@
<div class="field"><label>Base-DN</label> <div class="field"><label>Base-DN</label>
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln"> <input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
</div> </div>
<div class="field"><label>Attribut für Benutzername</label> <div class="field"><label>Attribut für Benutzername {{ hi.hint_icon("Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.", "Attribut für Benutzername") }}</label>
<input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName"> <input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName">
<div class="field-hint">Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.</div>
</div> </div>
<div class="field"><label>Standardgruppe für neue AD-Benutzer</label> <div class="field"><label>Standardgruppe für neue AD-Benutzer {{ hi.hint_icon("Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.", "Standardgruppe für neue AD-Benutzer") }}</label>
<select name="ldap_default_group"> <select name="ldap_default_group">
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option> <option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
{% for g in ldap_groups %} {% for g in ldap_groups %}
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option> <option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div> </div>
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
<div class="flex gap-2">
<select name="ldap_required_login_group" id="requiredGroupSelect" style="flex:1;">
<option value="">Keine (jeder gültige AD-Benutzer darf sich anmelden)</option>
{% if ldap.required_login_group %}
<option value="{{ ldap.required_login_group }}" selected>{{ ldap.required_login_group }}</option>
{% endif %}
</select>
<button type="button" class="btn btn-secondary btn-sm" id="requiredGroupLoadBtn">Gruppen laden</button>
</div>
<div class="field-hint" id="requiredGroupLoadStatus">Ist hier eine Gruppe ausgewählt, scheitert die Anmeldung für alle Benutzer, die ihr NICHT angehören (rekursiv, auch über verschachtelte Gruppen) — wie bei falschen Zugangsdaten, ohne Hinweis auf den eigentlichen Grund.</div>
</div> </div>
<div class="flex gap-2" style="flex-wrap:wrap;"> <div class="flex gap-2" style="flex-wrap:wrap;">
<button type="submit" name="save_ldap" value="1" class="btn btn-primary"> <button type="submit" name="save_ldap" value="1" class="btn btn-primary">
@@ -96,12 +103,7 @@
<div class="card card-pad"> <div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">AD-Gruppenzuordnungen</h2> <h2 style="font-size:16px;">AD-Gruppenzuordnungen {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.", "AD-Gruppenzuordnungen") }}</h2>
<div class="hint">
Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten
AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere
Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.
</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal"> <button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
@@ -123,12 +125,18 @@
<td>{{ m.app_group_name or '—' }}</td> <td>{{ m.app_group_name or '—' }}</td>
<td> <td>
{% if can_edit %} {% if can_edit %}
<div class="row-actions">
<button type="button" class="icon-btn" title="Bearbeiten"
onclick="openEditLdapMappingModal('{{ m.id }}','{{ m.ad_group_dn|e }}','{{ m.ad_group_name|e }}','{{ m.app_group_id }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
</button>
<form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?"> <form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?">
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}"> <input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen"> <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> <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> </button>
</form> </form>
</div>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -142,6 +150,57 @@
{% endif %} {% endif %}
</div> </div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Fileshare-Gruppenzuordnung {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“ hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.", "Fileshare-Gruppenzuordnung") }}</h2>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Zuordnung hinzufügen
</button>
{% endif %}
</div>
{% if fileshare_mappings %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>AD-Gruppe</th><th>Freigabe</th><th>Pfad</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for m in fileshare_mappings %}
<tr>
<td>{{ m.ad_group_name }}<div class="text-faint mono" style="font-size:11px;">{{ m.ad_group_dn }}</div></td>
<td>{{ m.share_label }}</td>
<td class="mono" style="font-size:12px;">{{ m.share_unc }}</td>
<td>
{% if can_edit %}
<div class="row-actions">
<button type="button" class="icon-btn" title="Bearbeiten"
onclick="openEditFileshareMappingModal('{{ m.id }}','{{ m.ad_group_dn|e }}','{{ m.ad_group_name|e }}','{{ m.share_label|e }}','{{ m.share_unc|e }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
</button>
<form method="post" data-confirm="Fileshare-Zuordnung „{{ m.ad_group_name }} → {{ m.share_label }}“ löschen?">
<input type="hidden" name="delete_fileshare_mapping" value="{{ m.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch keine Fileshare-Zuordnung angelegt — für niemanden wird eine Freigabe gemountet.</p>
{% endif %}
</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
@@ -183,15 +242,125 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="addFileshareMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_fileshare_mapping" value="1">
<div class="modal-header">
<h3>Fileshare-Zuordnung hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>AD-Gruppe</label>
<div class="flex gap-2">
<select name="fs_ad_group_dn" id="fsMappingAdGroup" required style="flex:1;">
<option value="">— zuerst laden —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="fsMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName">
<div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div>
<div class="field"><label>Bezeichnung {{ hi.hint_icon("Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.", "Bezeichnung") }}</label>
<input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required>
</div>
<div class="field"><label>Freigabe-Pfad (UNC) {{ hi.hint_icon("Beide Schreibweisen funktionieren — \\\\server\\freigabe wird automatisch in das von Linux benötigte //server/freigabe umgewandelt.", "Freigabe-Pfad (UNC)") }}</label>
<input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="editLdapMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="edit_ldap_group_mapping" id="editLdapMappingId" value="">
<div class="modal-header">
<h3>AD-Gruppenzuordnung bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>AD-Gruppe</label>
<div class="flex gap-2">
<select name="ad_group_dn" id="editLdapMappingAdGroup" required style="flex:1;"></select>
<button type="button" class="btn btn-secondary btn-sm" id="editLdapMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="ad_group_name" id="editLdapMappingAdGroupName">
<div class="field-hint" id="editLdapMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div>
<div class="field"><label>App-Rechtegruppe</label>
<select name="app_group_id" id="editLdapMappingAppGroup" required>
<option value="">— auswählen —</option>
<option value="admin">Admin (alle Rechte)</option>
{% for g in ldap_groups %}
<option value="{{ g['id'] }}">{{ g['name'] }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="editFileshareMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="edit_fileshare_mapping" id="editFsMappingId" value="">
<div class="modal-header">
<h3>Fileshare-Zuordnung bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>AD-Gruppe</label>
<div class="flex gap-2">
<select name="fs_ad_group_dn" id="editFsMappingAdGroup" required style="flex:1;"></select>
<button type="button" class="btn btn-secondary btn-sm" id="editFsMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName">
<div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div>
<div class="field"><label>Bezeichnung {{ hi.hint_icon("Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.", "Bezeichnung") }}</label>
<input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required>
</div>
<div class="field"><label>Freigabe-Pfad (UNC) {{ hi.hint_icon("Beide Schreibweisen funktionieren — \\\\server\\freigabe wird automatisch in das von Linux benötigte //server/freigabe umgewandelt.", "Freigabe-Pfad (UNC)") }}</label>
<input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<script> <script>
(function () { (function () {
var btn = document.getElementById('ldapMappingLoadGroupsBtn'); function wireAdGroupLoader(btnId, selectId, nameFieldId, statusId, includeEmptyOption) {
var select = document.getElementById('ldapMappingAdGroup'); var btn = document.getElementById(btnId);
var nameField = document.getElementById('ldapMappingAdGroupName'); var select = document.getElementById(selectId);
var status = document.getElementById('ldapMappingLoadStatus'); var nameField = nameFieldId ? document.getElementById(nameFieldId) : null;
var status = document.getElementById(statusId);
if (!btn) return; if (!btn) return;
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
// Aktuellen Wert (z.B. die vorher gespeicherte, noch nicht per
// Klartext-Namen aufgeloeste Gruppe) merken -- bleibt nach dem
// Neuaufbau der Optionsliste ausgewaehlt, falls sie unter den
// geladenen Gruppen auftaucht.
var currentValue = select.value;
status.textContent = 'Lade Gruppen …'; status.textContent = 'Lade Gruppen …';
fetch("{{ url_for('settings_ldap_ad_groups') }}") fetch("{{ url_for('settings_ldap_ad_groups') }}")
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@@ -201,8 +370,14 @@
return; return;
} }
select.innerHTML = ''; select.innerHTML = '';
if (includeEmptyOption) {
var emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = 'Keine (jeder gültige AD-Benutzer darf sich anmelden)';
select.appendChild(emptyOpt);
}
if (!groups.length) { if (!groups.length) {
select.innerHTML = '<option value="">Keine Gruppen gefunden</option>'; if (!includeEmptyOption) select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.'; status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
return; return;
} }
@@ -211,19 +386,63 @@
opt.value = g.dn; opt.value = g.dn;
opt.textContent = g.name; opt.textContent = g.name;
opt.dataset.name = g.name; opt.dataset.name = g.name;
if (g.dn === currentValue) opt.selected = true;
select.appendChild(opt); select.appendChild(opt);
}); });
nameField.value = select.options[select.selectedIndex].dataset.name || ''; if (nameField) nameField.value = (select.options[select.selectedIndex] && select.options[select.selectedIndex].dataset.name) || '';
status.textContent = groups.length + ' Gruppe(n) geladen.'; status.textContent = groups.length + ' Gruppe(n) geladen.';
}) })
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; }); .catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
}); });
select.addEventListener('change', function () { select.addEventListener('change', function () {
if (!nameField) return;
var opt = select.options[select.selectedIndex]; var opt = select.options[select.selectedIndex];
nameField.value = (opt && opt.dataset.name) || ''; nameField.value = (opt && opt.dataset.name) || '';
}); });
}
wireAdGroupLoader('ldapMappingLoadGroupsBtn', 'ldapMappingAdGroup', 'ldapMappingAdGroupName', 'ldapMappingLoadStatus', false);
wireAdGroupLoader('fsMappingLoadGroupsBtn', 'fsMappingAdGroup', 'fsMappingAdGroupName', 'fsMappingLoadStatus', false);
wireAdGroupLoader('requiredGroupLoadBtn', 'requiredGroupSelect', null, 'requiredGroupLoadStatus', true);
wireAdGroupLoader('editLdapMappingLoadGroupsBtn', 'editLdapMappingAdGroup', 'editLdapMappingAdGroupName', 'editLdapMappingLoadStatus', false);
wireAdGroupLoader('editFsMappingLoadGroupsBtn', 'editFsMappingAdGroup', 'editFsMappingAdGroupName', 'editFsMappingLoadStatus', false);
})(); })();
// Bearbeiten-Modals vorbefuellen -- die AD-Gruppe steht dabei zunaechst nur
// als DN+Name aus der Datenbank zur Verfuegung (ohne erneute LDAP-Abfrage);
// "Gruppen laden" ersetzt die Auswahlliste bei Bedarf durch die vollstaendige,
// aktuelle AD-Gruppenliste und behaelt den bisherigen Wert dabei bei (siehe
// wireAdGroupLoader oben).
function seedMappingSelect(selectId, dn, name) {
var select = document.getElementById(selectId);
select.innerHTML = '';
var opt = document.createElement('option');
opt.value = dn;
opt.textContent = name || dn;
opt.dataset.name = name || dn;
opt.selected = true;
select.appendChild(opt);
}
function openEditLdapMappingModal(id, dn, name, appGroupId) {
document.getElementById('editLdapMappingId').value = id;
seedMappingSelect('editLdapMappingAdGroup', dn, name);
document.getElementById('editLdapMappingAdGroupName').value = name;
document.getElementById('editLdapMappingAppGroup').value = appGroupId;
document.getElementById('editLdapMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
PoeUI.openModal('editLdapMappingModal');
}
function openEditFileshareMappingModal(id, dn, name, label, unc) {
document.getElementById('editFsMappingId').value = id;
seedMappingSelect('editFsMappingAdGroup', dn, name);
document.getElementById('editFsMappingAdGroupName').value = name;
document.getElementById('editFsMappingLabel').value = label;
document.getElementById('editFsMappingUnc').value = unc;
document.getElementById('editFsMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
PoeUI.openModal('editFileshareMappingModal');
}
</script> </script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+200
View File
@@ -0,0 +1,200 @@
{% extends "base.html" %}
{% set active_page = "settings_nginx" %}
{% set can_edit = current_user.has_permission('settings_nginx.edit') %}
{% block page_title %}NGINX{% endblock %}
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid settings-grid--wide">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Reverse-Proxy</h2>
<div class="hint">Domain (server_name), Ports und HTTPS/HSTS für den nginx-Reverse-Proxy vor der App.</div>
</div>
<button type="button" class="btn btn-secondary btn-sm"
onclick="openRawLogModal('{{ url_for('settings_nginx_raw') }}', 'Aktuelle nginx-Konfiguration (RAW)')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
RAW-Konfiguration
</button>
</div>
{% if pending_nginx_token %}
<div class="card-pad" style="background:var(--warning-dim); border-radius:var(--radius-sm); margin-bottom:16px;">
<p style="margin:0 0 12px; font-size:13px;">
Neue nginx-Konfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung —
bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ nginx_revert_seconds }}s ohne Bestätigung).
</p>
<form method="post">
<input type="hidden" name="confirm_nginx" value="{{ pending_nginx_token }}">
<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>
Verbindung funktioniert — bestätigen
</button>
</form>
</div>
{% elif can_edit %}
<form method="post"
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
<div class="field">
<label for="server_name">Domain (server_name) {{ hi.hint_icon("\"_\" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.", "Domain (server_name)") }}</label>
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
</div>
<div class="flex gap-2">
<div class="field field--half">
<label for="http_port">HTTP-Port</label>
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
</div>
<div class="field field--half">
<label for="https_port">HTTPS-Port {{ hi.hint_icon("Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) — weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.", "HTTP-Port / HTTPS-Port") }}</label>
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
</div>
</div>
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
<span class="track"></span>
<span>SSL/HTTPS aktivieren</span>
</label>
{% if not cert_info %}<div class="field-hint">Erst nach Hochladen oder Anfordern eines Zertifikats verfügbar.</div>{% endif %}
</div>
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
<span class="track"></span>
<span>HSTS (Strict-Transport-Security) {{ hi.hint_icon("Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.", "HSTS") }}</span>
</label>
</div>
<button type="submit" name="apply_nginx" value="1" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
Anwenden
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „NGINX ändern“.</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Zertifikat</h2>
<div class="hint">Hochladen (PEM) oder automatisch per Let's Encrypt anfordern — mit automatischer Verlängerung über certbots eigenen Timer.</div>
</div>
</div>
{% if cert_info %}
<div class="detail-list" style="margin-bottom:18px;">
<div class="detail-row"><span class="k">Quelle</span><span class="v">{{ "Let's Encrypt" if cert_info.source == "letsencrypt" else "Hochgeladen" }}</span></div>
<div class="detail-row"><span class="k">Subject</span><span class="v" style="text-align:right; word-break:break-all;">{{ cert_info.subject }}</span></div>
<div class="detail-row"><span class="k">Aussteller</span><span class="v" style="text-align:right; word-break:break-all;">{{ cert_info.issuer }}</span></div>
<div class="detail-row">
<span class="k">Gültig bis</span>
<span class="v">
{{ cert_info.not_after.strftime('%d.%m.%Y') }}
{% if cert_info.days_left < 0 %}
<span class="pill offline" style="margin-left:6px;">abgelaufen</span>
{% elif cert_info.days_left <= 30 %}
<span class="pill unknown" style="margin-left:6px;">noch {{ cert_info.days_left }} Tage</span>
{% else %}
<span class="pill online" style="margin-left:6px;">noch {{ cert_info.days_left }} Tage</span>
{% endif %}
</span>
</div>
{% if cert_info.source == "letsencrypt" %}
<div class="detail-row">
<span class="k">Automatische Verlängerung</span>
<span class="v">
{% if certbot_timer and certbot_timer.active %}
<span class="pill online">aktiv</span>
{% if certbot_timer.next_run %}<span class="text-faint" style="font-size:11px; display:block; margin-top:2px;">nächster Lauf: {{ certbot_timer.next_run }}</span>{% endif %}
{% elif certbot_timer %}
<span class="pill offline">inaktiv</span>
{% else %}
<span class="pill unknown">unbekannt</span>
{% endif %}
</span>
</div>
{% endif %}
</div>
{% if cert_info.source == "letsencrypt" and can_edit %}
<div class="flex gap-2" style="margin-bottom:18px;">
<form method="post" style="flex:1;">
<button type="submit" name="test_renewal" value="1" class="btn btn-secondary btn-block btn-sm">Verlängerung testen (Dry-Run)</button>
</form>
<form method="post" style="flex:1;" data-confirm="Zertifikat jetzt sofort erneuern (zählt gegen Let's Encrypts Rate-Limits)?">
<button type="submit" name="force_renew" value="1" class="btn btn-secondary btn-block btn-sm">Jetzt manuell verlängern</button>
</form>
</div>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Noch kein Zertifikat hinterlegt.</p>
{% endif %}
{% if can_edit %}
<div class="field">
<label style="font-weight:600; font-size:13px;">Let's Encrypt anfordern</label>
<div class="field-hint" style="margin-bottom:8px;">Setzt voraus, dass die Domain öffentlich auf diesen Host auflöst und Port 80 aus dem Internet erreichbar ist (HTTP-01-Validierung) — für rein interne Instanzen ohne öffentliche Domain nicht nutzbar, dann stattdessen hochladen. Verlängerung läuft danach automatisch über certbots eigenen Timer{% if certbot_timer %} ({{ "aktiv" if certbot_timer.active else "inaktiv!" }}){% endif %}, zweimal täglich.</div>
</div>
<form method="post" style="margin-bottom:18px;">
<div class="field">
<label for="le_domain">Domain</label>
<input type="text" name="le_domain" id="le_domain" placeholder="z.B. tesm.example.com" value="{{ server_name if server_name != '_' else '' }}">
</div>
<div class="field">
<label for="le_email">E-Mail (optional, für Ablauf-Erinnerungen)</label>
<input type="email" name="le_email" id="le_email" placeholder="admin@example.com">
</div>
<button type="submit" name="request_letsencrypt" value="1" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>
Zertifikat anfordern
</button>
</form>
<div class="field"><label style="font-weight:600; font-size:13px;">Manuell hochladen</label></div>
<form method="post" enctype="multipart/form-data" style="margin-bottom:10px;">
<div class="field">
<label for="cert_file">Zertifikat (PEM, .crt/.pem)</label>
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
</div>
<div class="field">
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt) {{ hi.hint_icon("Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.", "Privater Schlüssel") }}</label>
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
</div>
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block">
<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>
Zertifikat + Schlüssel hochladen
</button>
</form>
{% if cert_info and not ssl_enabled %}
<form method="post" data-confirm="Hinterlegtes Zertifikat wirklich entfernen?" data-no-unsaved-guard>
<button type="submit" name="remove_cert" value="1" class="btn btn-danger btn-block">Zertifikat entfernen</button>
</form>
{% elif cert_info %}
<p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.</p>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const sslCheck = document.getElementById("ssl_enabled_check");
const hstsCheck = document.getElementById("hsts_enabled_check");
if (sslCheck && hstsCheck) {
sslCheck.addEventListener("change", () => {
hstsCheck.disabled = !sslCheck.checked;
if (!sslCheck.checked) hstsCheck.checked = false;
});
}
});
</script>
{% endblock %}
+3 -4
View File
@@ -10,6 +10,7 @@
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
@@ -102,9 +103,8 @@
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100"> <input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div> <div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div> </div>
<div class="field"><label>SSH-Port</label> <div class="field"><label>SSH-Port {{ hi.hint_icon("Leer lassen, wenn der Switch den Standard-Port 22 verwendet.", "SSH-Port") }}</label>
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)"> <input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Zugangsdaten</label> <label>Zugangsdaten</label>
@@ -162,9 +162,8 @@
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100"> <input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div> <div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div> </div>
<div class="field"><label>SSH-Port</label> <div class="field"><label>SSH-Port {{ hi.hint_icon("Leer lassen, wenn der Switch den Standard-Port 22 verwendet.", "SSH-Port") }}</label>
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)"> <input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Zugangsdaten</label> <label>Zugangsdaten</label>
+2 -2
View File
@@ -4,6 +4,7 @@
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
@@ -205,13 +206,12 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="field"> <div class="field">
<label>Gruppe</label> <label>Gruppe {{ hi.hint_icon("Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.", "Gruppe") }}</label>
<select name="group_id" id="group_select"> <select name="group_id" id="group_select">
<option value="">Keine Gruppe</option> <option value="">Keine Gruppe</option>
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %} {% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %} {% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
</select> </select>
<div class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
+9 -31
View File
@@ -9,16 +9,15 @@
# automatischem Backup + Migrations-Sicherheitsnetz samt Rückroll- # automatischem Backup + Migrations-Sicherheitsnetz samt Rückroll-
# Möglichkeit -- siehe install.sh für Details). # Möglichkeit -- siehe install.sh für Details).
# #
# Dieses Skript selbst ist nur noch für das Ablösen ÄLTERER, TESM # Dieses Skript ist reiner Bootstrapper: Download + Entpacken + Aufruf von
# vorausgehender Installationsstufen auf diesem Host zuständig: # install.sh. Die frühere Sonderbehandlung zweier TESM vorausgehender
# 1. Das ursprüngliche, reine Bash-Setup aus dem separaten Repo # Installationsstufen (rpi-check.service aus dem ursprünglichen reinen
# https://gitea.int.eertmoed.net/WiS/Aruba-PoE (rpi-check.service + # Bash-Setup, poe_web.service/poe_manager als Flask-Vorgänger von TESM)
# statische ips.list, keine Datenbank/Web-UI). # wurde entfernt -- diese Stufen sind auf allen aktuellen Installationen
# 2. Die spätere Flask-App "PoE Manager" unter /srv/poe_manager # längst migriert, die dazugehörigen Unit-Dateien wurden dort manuell
# (poe_web.service, Vorgänger von TESM in diesem Repo vor der # entfernt. Ein heute noch unmigriertes System wäre ohnehin so alt, dass
# Umbenennung). # ein manueller Blick vor dem Update angebracht ist statt automatischer
# Beide werden NICHT gelöscht, sondern nur gestoppt/deaktiviert und # stillschweigender Bereinigung.
# (falls vorhanden) beiseite verschoben.
# #
# Auszuführen als root auf dem Zielsystem: # Auszuführen als root auf dem Zielsystem:
# sudo ./update.sh # sudo ./update.sh
@@ -38,7 +37,6 @@ GITEA_TOKEN="${TESM_GITEA_TOKEN:-}"
PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz" PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz"
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}" DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
WORK_DIR="/tmp/tesm-update-$(date +%s)" WORK_DIR="/tmp/tesm-update-$(date +%s)"
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}" PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
@@ -61,10 +59,6 @@ echo "install.sh daraus -- das erkennt selbst, ob es sich um eine frische"
echo "Installation oder ein Update einer bestehenden handelt, und sichert" echo "Installation oder ein Update einer bestehenden handelt, und sichert"
echo "die Datenbank bei einer Schema-Änderung automatisch vorsorglich ab." echo "die Datenbank bei einer Schema-Änderung automatisch vorsorglich ab."
echo echo
echo "Ältere, TESM vorausgehende Installationsstufen (PoE Manager unter"
echo "/srv/poe_manager, das ursprüngliche reine Bash-Setup) werden dabei"
echo "erkannt, gestoppt/deaktiviert und beiseite verschoben (nicht gelöscht)."
echo
read -r -p "Fortfahren? [y/N] " confirm read -r -p "Fortfahren? [y/N] " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
@@ -86,22 +80,6 @@ mkdir -p "$WORK_DIR/pkg"
tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1 tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1
echo -e "${GREEN}${NC} Paket entpackt." echo -e "${GREEN}${NC} Paket entpackt."
echo -e "${RED}${NC} Stoppe/deaktiviere ältere Installationsstufen (falls vorhanden)..."
# "|| true" macht nichts kaputt, falls einzelne Units gar nicht vorhanden
# sind. tesm.service selbst wird NICHT hier, sondern von install.sh
# gestoppt -- das entscheidet dort auch gleich, ob ein Backup nötig ist.
systemctl stop poe_web.service 2>/dev/null || true
systemctl disable poe_web.service 2>/dev/null || true
systemctl stop rpi-check.service rpi-check-restart.service rpi-check-restart.timer 2>/dev/null || true
systemctl disable rpi-check.service rpi-check-restart.service rpi-check-restart.timer 2>/dev/null || true
if [ -d /srv/poe_manager ]; then
dest="/srv/poe_manager-backup-${TIMESTAMP}"
echo -e "${RED}${NC} Sichere /srv/poe_manager nach ${dest}..."
mv /srv/poe_manager "$dest"
echo -e "${GREEN}${NC} Gesichert (nicht gelöscht)."
fi
echo -e "${GREEN}${NC} Ältere Installationsstufen bereinigt."
echo -e "${RED}${NC} Starte install.sh aus dem Paket..." echo -e "${RED}${NC} Starte install.sh aus dem Paket..."
bash "$WORK_DIR/pkg/install.sh" bash "$WORK_DIR/pkg/install.sh"