Compare commits

..
21 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 682e9b4420 Lizenzserver-Deployment: install-license.sh/update-license.sh + systemd/nginx-Vorlagen
Fork von install.sh/update.sh/tesm.service/nginx-Site (TESM selbst),
beschraenkt auf /srv/tesm-license -- deployt ausschliesslich den in Phase 2
gebauten Master-Lizenzserver, laesst eine evtl. auf demselben Host
vorhandene TESM-Installation komplett unberuehrt (eigener Pfad-/Env-Var-
Namespace, siehe app.py). Gleiches Backup+Health-Check+Rueckroll-
Sicherheitsnetz wie bei TESMs eigenem Installer.

Teil von Phase 3 (Infrastruktur), siehe Plan toasty-twirling-hickey.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:15:02 +02:00
alientimandClaude Sonnet 5 90e07aa310 Master-Lizenzserver Phase 2: neue Schwester-Anwendung srv/tesm-license (v1.0.0)
Fork von TESM (srv/tesm), auf Kunden-/Lizenzverwaltung reduziert statt
Geraete-/PoE-Management. Wiederverwendet unveraendert: Login/Session/
Benutzer- und Gruppenverwaltung, LDAP/AD, NGINX- und Systemeinstellungen,
Live-Log/Verlauf/Auditlog, Im-/Export-Grundgeruest, das komplette
PERMISSIONS/NAV_ITEMS/inject_nav()-Rechtesystem sowie das in Phase 1
gebaute Lizenzsystem selbst sowohl fuer die MASTER-eigene Bootstrap-Lizenz
als auch fuer das exakt gleiche licensing.py (byte-identisch zu TESM --
Signieren/Verifizieren muss zwischen beiden Seiten kompatibel bleiben).

Entfernt: Clients/Switche/Zugangsdaten/DHCP/Fileshare/Wartung/Papierkorb/
manueller PoE-Neustart/SSH-Terminal inkl. aller zugehoerigen Tabellen,
Routen, Permissions, Nav-Eintraege und Abhaengigkeiten (paramiko/Flask-Sock/
simple-websocket/PyNaCl/pyasn1 aus requirements.txt). Benutzer-/Gruppen-
Loeschung von Soft- auf Hard-Delete umgestellt (kein Papierkorb mehr).
Eigener Pfad-/Env-Var-Namespace (TESM_LICENSE_* statt TESM_*, /var/log/
tesm-license statt /var/log/tesm usw.), damit Master und TESM testweise
sogar auf demselben Host nebeneinander laufen koennen, ohne sich Log-/
Config-Pfade streitig zu machen.

Neu -- der eigentliche Lizenzserver:
- license_customers/licenses-Tabellen, Master-Signaturschluessel
  (master_signing_key.json, einmalig erzeugt, NIE automatisch rotiert --
  jede Kundenlizenz traegt den zum Ausstellungszeitpunkt aktuellen
  master_pubkey fest eingebettet).
- Dashboard ('/') als Kunden-/Lizenzuebersicht (Typ, Module, Ablauf,
  Status, letzter Heartbeat), eigene Kunden-Verwaltungsseite.
- Lizenz-Ausstellung (Typ/Module/Laufzeit -> signierte Datei via
  licensing.issue_license), Detailseite, Download, Widerruf.
- /api/activate, /api/deactivate, /api/heartbeat (unauthentifiziert per
  Design -- die Signatur der Anfrage IST der Berechtigungsnachweis) sowie
  eine manuelle Offline-Code-Seite, die dieselben drei Verarbeitungs-
  funktionen (_process_activate/_process_deactivate/_process_heartbeat)
  nutzt wie die Online-API -- ein Protokoll, zwei Transportwege.
- E-Mail-Versand ausgestellter Lizenzen per Microsoft Graph
  (Client-Credentials-Flow, reine Standardbibliothek/urllib, keine neue
  Abhaengigkeit) inkl. Einrichtungsanleitung und Verbindungstest.
- create_master_license.py: lokales Bootstrap-/Erneuerungs-Skript fuer
  die eigene Lizenz des Masters (kein externer Super-Master noetig).

Verifiziert auf dem Testsystem (192.168.82.51): App laeuft parallel zu der
dort laufenden echten TESM-Instanz (Port 5001 vs. 80/5000, eigene
Log-/DB-Pfade, TESM unangetastet). Kompletter ECHTER End-to-End-Rundlauf
per Playwright durchgespielt -- kein simulierter Gegenpart: Kunde anlegen
-> Custom-Lizenz (dhcp+fileshare) ausstellen -> herunterladen -> auf der
echten TESM-Instanz hochladen -> ECHTE Online-Aktivierung (Master
verzeichnet Fingerprint, TESM zeigt 'Aktiviert') -> ECHTE Online-
Deaktivierung (TESM zeigt 'lizenzlos, Export bleibt moeglich', Master
zeigt Status 'Deaktiviert'). Lokale Syntax-/Templatepruefung (py_compile,
pyflakes, jinja2-Parse aller Templates) sauber.

Bewusst NICHT nach main gemergt/getaggt/ausgerollt -- folgt zusammen mit
Phase 3 (Infrastruktur-Umzug + POETEST-Enterprise-Lizenz), siehe Plan
toasty-twirling-hickey.md (Phase 2 von 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 02:07:30 +02:00
alientimandClaude Sonnet 5 c9ac0bf69b Lizenz-/Modulsystem Phase 1: TESM-seitiges Gating, Aktivierung, Topbar (v1.2.0)
Baut auf licensing.py (Phase 0) auf und schaltet TESM erstmals auf ein
lizenziertes Modulmodell um. Standard-Funktionen (Dashboard, Clients,
Switche, Zugangsdaten, Benutzer, Gruppen, Auditlog, Live-Log, Verlauf,
Import) bleiben immer frei nutzbar.

- license_active()/module_licensed()-Gate-Funktionen + In-Memory-
  Lizenzcache (_load_license, _write_license_file, _delete_license_file),
  Jinja-Globals fuer Templates.
- Vollstaendige Module (DHCP inkl. Kea-Log, Fileshare, Wartung) werden ohne
  passendes Modul in der Lizenz komplett aus der Navigation ausgeblendet
  (_nav_key_visible) UND serverseitig auf Routenebene abgewiesen
  (_require_module) -- ein direkter URL-Aufruf hilft nicht.
- 'Sichtbar, aber ohne Lizenz inaktiv': LDAP- und NGINX-Einstellungen,
  Papierkorb-Bearbeiten, Export sowie das Pruefintervall-Feld (aus dem
  gemeinsamen Systemeinstellungen-Gate herausgeloest, Hostname/Zeitzone/
  Netzwerk/Logs bleiben frei) zeigen ohne Lizenz einen Hinweis statt der
  Aktion.
- Export bleibt als bewusste Ausnahme auch im lizenzlosen Zustand nutzbar,
  wenn dieser durch eine selbst ausgeloeste Deaktivierung (Systemwechsel)
  entstanden ist (license_export_grace).
- Automatischer PoE-Neustart bei Ausfall wird in poe.sh (nicht in Python)
  gegatet -- einmal pro Schleifendurchlauf gelesen, an check_device()
  durchgereicht; der manuelle Neustart-Button (devices.restart) bleibt
  komplett unangetastet.
- Topbar-Lizenzanzeige (Prioritaet: fehlt > abgelaufen > laeuft in <=30
  Tagen ab > Heartbeat seit >=14 Tagen ausgeblieben (rein informativ) >
  alles ok), eigene CSS-Blink-Varianten fuer Rot/Orange (die vorhandene
  pulse-dot-Animation war fest auf Gruen verdrahtet).
- Neue Settings-Unterseite 'Lizenz': Status, Lizenzdatei-Upload, Online-
  Aktivierung/-Deaktivierung mit automatischem Fallback auf einen manuell
  auszutauschenden Code (ein Protokoll, zwei Transportwege, siehe
  licensing.py), Anbieter-Kontaktkarte.
- 6-stuendiger Heartbeat-Hintergrundthread (_license_heartbeat_loop, exakt
  nach dem Vorbild von _audit_archive_loop).

Isoliert auf dem Testsystem (192.168.82.51) verifiziert: alle fuenf
Topbar-/Sichtbarkeitszustaende (keine/gueltige/bald ablaufende/abgelaufene/
heartbeat-stale Lizenz) per Playwright durchgespielt, kompletter Online-
Fallback-auf-Offline-Code-Austausch fuer Aktivierung UND Deaktivierung
gegen einen simulierten Master erfolgreich durchlaufen (inkl. Export-
Grace-Ausnahme nach Deaktivierung).

Bewusst NICHT nach main gemergt/getaggt/ausgerollt: ohne den in Phase 2
noch zu bauenden Master-Lizenzserver kann POETEST noch keine echte Lizenz
beziehen -- ein Rollout jetzt wuerde dort DHCP/Fileshare/Wartung/LDAP/
NGINX/Papierkorb/Export/automatischen Neustart sofort abschalten. Bleibt
auf dev, bis Phase 2 (Master) + Phase 3 (POETEST-Enterprise-Lizenz) so
weit sind, dass beides zusammen ausgerollt werden kann.

Teil des Lizenz-/Modulsystems, siehe Plan toasty-twirling-hickey.md
(Phase 1 von 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:45:35 +02:00
alientimandClaude Sonnet 5 b70ebefb17 Lizenzsystem Phase 0: Ed25519-Kryptographie, Aktivierungs-/Heartbeat-Protokoll (licensing.py)
Neues gemeinsames Modul fuer Client (TESM) und kuenftigen Master-Lizenzserver:
- Zwei-Ebenen-Ed25519-Schluesselhierarchie (Master-Signaturschluessel +
  Pro-Lizenz-Schluesselpaar), kanonische JSON-Signierung
- issue_license/verify_license_file/license_status (inkl. 30-Tage-Gnadenfrist,
  30-Tage-Vorwarnung, Modul-Set)
- Ein Protokoll fuer Online- (API) und Offline-Aktivierung (Code-Copy-Paste):
  build_client_request/verify_client_request,
  build_master_response/verify_master_response, encode_code/decode_code
- system_fingerprint (Systembindung), heartbeat_stale_warning (14 Tage,
  rein informativ)

Isoliert getestet (Signatur-Rundlauf, Manipulationserkennung, Ablauf-/
Gnadenfrist-Datumslogik, Online- und Offline-Aktivierung/Deaktivierung inkl.
Fingerprint-Bindungspruefung und Reaktivierung nach Deaktivierung,
Eingabevalidierung) -- 41/41 Checks erfolgreich.

Teil von Phase 0 des Lizenz-/Modulsystems, siehe Plan
toasty-twirling-hickey.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:42:32 +02:00
alientimandClaude Sonnet 5 d90c27b8f8 LDAP-Export: Sperrstatus + individuelle Gruppenzuweisung von AD-Nutzern (weiterhin v1.1.11)
_export_users()/_import_users() schliessen AD/LDAP-Konten bewusst komplett
aus (sie werden ueber Active Directory verwaltet). Das hatte aber eine
Luecke: weder eine manuelle Sperrung/Entsperrung noch eine manuell (per
"Gruppe zuweisen") vergebene individuelle Gruppenmitgliedschaft eines
AD-Benutzers wurden bisher irgendwo gesichert -- bei einem Umzug auf ein
neues System (der eigentliche Zweck dieser Seite) waere ein zuvor
gesperrtes AD-Konto dort wieder entsperrt gewesen, und individuell
vergebene Rechte waeren beim naechsten Login des Nutzers verloren
gegangen.

Neu in der "ldap"-Kategorie: ad_user_states exportiert fuer jeden bereits
bekannten AD-Benutzer Sperrstatus + aktuelle Gruppenmitgliedschaft (inkl.
Admin als Sentinel, analog den bestehenden AD-Gruppenzuordnungen). Beim
Import wird ein bereits vorhandenes AD-Konto entsprechend aktualisiert;
existiert es auf dem Zielsystem noch nicht (Nutzer hat sich dort noch nie
eingeloggt), wird ein Platzhalter-Konto mit auth_source='ldap' und
korrektem Sperrstatus VORAB angelegt -- der Login-Pfad prueft is_locked
bereits vor der eigentlichen AD-Authentifizierung, ein vorab gesperrtes
Konto bleibt also gesperrt. Gruppenzuweisungen sind rein additiv
(insert or ignore), die naechste automatische AD-Gruppenzuordnung beim
Login nimmt nichts davon wieder weg.

Ausserdem:
- Die pauschalen "Was exportieren?"/"Was importieren?"-Hinweise entfernt
  (waren durch die praeziseren Kategorie-Hinweise bei Benutzer/LDAP/
  Zugangsdaten/NGINX/Auditlog laengst ueberholt und teils sachlich
  veraltet).
- Kategorie-Hinweise jetzt auch in der Import-Vorschau sichtbar (vorher
  nur beim Export).
- "Nur Admin"-Kennzeichnung aus beiden Kategorie-Listen entfernt (rein
  optisch, die serverseitige Rechtepruefung bleibt unveraendert).

Verifiziert per isoliertem Roundtrip-Test: gesperrter AD-Nutzer bleibt
nach Reset+Import gesperrt, manuell zugewiesene Gruppe eines AD-Nutzers
uebersteht Loeschen+Import, und ein komplett neuer (nie eingeloggter)
AD-Nutzer wird korrekt mit Sperrstatus/Gruppen/Admin-Flag vorangelegt --
inkl. Bestaetigung, dass der echte Login-Sperr-Check dieses vorab
angelegte Konto tatsaechlich blockiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:46:10 +02:00
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
80 changed files with 16257 additions and 386 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;
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
location / {
client_max_body_size 4m;
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static/ {
alias /srv/tesm-license/static/;
expires 7d;
}
}
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=TESM Lizenzserver (Master)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/srv/tesm-license
# Analog zu TESM_WEB_PROCESS bei TESM selbst -- markiert diesen Prozess als
# den echten Web-Dienst (startet den Lizenz-Heartbeat- und den Auditlog-
# Archivierungs-Thread genau einmal). Anders als bei TESM importiert hier
# kein Hilfsskript app.py periodisch als Modul (kein Geräte-Polling auf
# dem Lizenzserver) -- das Flag bleibt trotzdem gesetzt, für den Fall
# künftiger Hilfsskripte und zur Konsistenz mit dem TESM-Muster.
Environment=TESM_LICENSE_WEB_PROCESS=1
# Kein WebSocket-Terminal wie bei TESM -- gthread/--timeout 0 daher nicht
# nötig, ein paar Threads reichen für die parallele Aktivierungs-/
# Heartbeat-API mehrerer Kunden.
# "--bind 127.0.0.1:5000" wie bei TESM: nur über den nginx-Reverse-Proxy
# von außen erreichbar.
ExecStart=/srv/tesm-license/venv/bin/gunicorn --workers 1 --threads 8 \
--bind 127.0.0.1:5000 app:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+10
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
+155
View File
@@ -0,0 +1,155 @@
#!/bin/bash
# ============================================================================
# TESM-Lizenzserver — Installer/Updater
# Fork von install.sh (TESM selbst), auf /srv/tesm-license statt /srv/tesm
# beschränkt -- deployt NUR den Master-Lizenzserver-Teil dieses Repos
# (srv/tesm-license/), lässt srv/tesm/ komplett unangetastet. Für dasselbe
# Sicherheitsnetz (Backup + Health-Check + automatischer Rückroll) wie bei
# TESM, siehe dortigen Kommentarkopf für die ausführliche Begründung --
# hier nur die Kurzfassung je Schritt.
#
# Auszuführen als root, nachdem dieses Repo/Release-Paket z.B. nach
# /root/tesm entpackt wurde:
# sudo ./install-license.sh
# ============================================================================
set -e
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
print_status() { echo -e "${GREEN}${NC} ${1} completed."; }
step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/tesm-license-install.log; }
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 2>/dev/null || true
# ---- Bestehende Installation erkennen ----
EXISTING_INSTALL=0
[ -f /srv/tesm-license/sqlite.db ] && EXISTING_INSTALL=1
NEEDS_MIGRATION_GUARD=0
BACKUP_DIR=""
if [ "$EXISTING_INSTALL" -eq 1 ]; then
OLD_SCHEMA_VERSION=""
[ -f /srv/tesm-license/SCHEMA_VERSION ] && OLD_SCHEMA_VERSION="$(tr -d '[:space:]' < /srv/tesm-license/SCHEMA_VERSION)"
NEW_SCHEMA_VERSION=""
[ -f "$REPO_DIR/srv/tesm-license/SCHEMA_VERSION" ] && NEW_SCHEMA_VERSION="$(tr -d '[:space:]' < "$REPO_DIR/srv/tesm-license/SCHEMA_VERSION")"
step "Stopping tesm-license.service for update"
systemctl stop tesm-license.service 2>/dev/null || true
print_status "Service stopped"
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then
echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} In-Place-Update."
else
echo -e "${YELLOW}Bestehende Installation erkannt, Schema-Version geändert oder unbekannt:${NC} Update mit automatischer Migration."
fi
NEEDS_MIGRATION_GUARD=1
BACKUP_DIR="/srv/tesm-license-backup-pre-update-$(date +%Y%m%d-%H%M%S)"
step "Backing up current installation to $BACKUP_DIR before update"
cp -a /srv/tesm-license "$BACKUP_DIR"
print_status "Backup created"
fi
# ---- Pakete ----
step "Installing system packages"
sudo apt-get update >>/var/log/tesm-license-install.log 2>&1 && print_status "apt update"
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 rsync logrotate certbot >>/var/log/tesm-license-install.log 2>&1 && print_status "Packages installed"
# ---- Log-Verzeichnis ----
sudo mkdir -p /var/log/tesm-license
sudo chmod 755 /var/log/tesm-license
# ---- App-Verzeichnis ----
step "Deploying application to /srv/tesm-license"
sudo mkdir -p /srv/tesm-license
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
--exclude 'license.json' --exclude 'master_signing_key.json' \
"$REPO_DIR/srv/tesm-license/" /srv/tesm-license/ >>/var/log/tesm-license-install.log 2>&1
print_status "Application files copied"
# ---- Python venv ----
step "Setting up Python virtual environment"
cd /srv/tesm-license
sudo python3 -m venv venv
sudo ./venv/bin/python3 -m pip install --upgrade pip >>/var/log/tesm-license-install.log 2>&1
sudo ./venv/bin/python3 -m pip install -r requirements.txt >>/var/log/tesm-license-install.log 2>&1
print_status "Virtual environment ready"
# ---- Datenbank ----
if [ ! -f /srv/tesm-license/sqlite.db ]; then
step "Initializing database"
sudo ./venv/bin/python3 create_db.py >>/var/log/tesm-license-install.log 2>&1
print_status "Database initialized"
echo -e "${RED}${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen:"
echo " sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_admin.py"
echo -e "${RED}${NC} Und die eigene Bootstrap-Lizenz erzeugen:"
echo " sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_master_license.py"
fi
# ---- systemd ----
step "Copying systemd unit"
UNITS_CHANGED=0
install_if_changed "$REPO_DIR/etc/systemd/system/tesm-license.service" /etc/systemd/system/tesm-license.service 644 && UNITS_CHANGED=1
print_status "Unit copied"
# ---- nginx ----
step "Configuring nginx reverse proxy"
if [ ! -f /etc/nginx/sites-available/tesm-license ]; then
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm-license" /etc/nginx/sites-available/tesm-license
fi
sudo ln -sf /etc/nginx/sites-available/tesm-license /etc/nginx/sites-enabled/tesm-license
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t >>/var/log/tesm-license-install.log 2>&1 && sudo systemctl reload nginx
print_status "nginx configured"
# ---- Dienst aktivieren ----
step "Enabling service"
if [ "$UNITS_CHANGED" -eq 1 ]; then
sudo systemctl daemon-reload
fi
sudo systemctl enable --now tesm-license.service
print_status "Service enabled and started"
# ---- Sicherheitsnetz prüfen ----
if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
step "Verifying migrated installation"
HEALTHY=0
for i in $(seq 1 15); do
sleep 2
if curl -fsS -o /dev/null http://127.0.0.1:5000/login 2>/dev/null; then
HEALTHY=1
break
fi
done
if [ "$HEALTHY" -eq 1 ]; then
print_status "Migration verified healthy"
rm -rf "$BACKUP_DIR"
echo -e "${GREEN}${NC} Update erfolgreich -- Backup wieder entfernt."
else
echo -e "${RED}✖ Update fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}"
sudo systemctl stop tesm-license.service 2>/dev/null || true
FAILED_DIR="/srv/tesm-license-failed-update-$(date +%Y%m%d-%H%M%S)"
sudo mv /srv/tesm-license "$FAILED_DIR"
sudo cp -a "$BACKUP_DIR" /srv/tesm-license
sudo systemctl start tesm-license.service
echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update.${NC}"
echo "Backup bleibt erhalten unter: ${BACKUP_DIR}"
echo "Die fehlgeschlagene Installation liegt zur Fehlersuche unter: ${FAILED_DIR}"
echo "Bitte /var/log/tesm-license/app.log dort prüfen, bevor erneut aktualisiert wird."
fi
fi
echo -e "${GREEN}${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)."
+2 -1
View File
@@ -114,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 certbot >>/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
@@ -134,6 +134,7 @@ sudo chmod 755 /var/log/tesm
step "Deploying application to /srv/tesm" step "Deploying application to /srv/tesm"
sudo mkdir -p /srv/tesm sudo mkdir -p /srv/tesm
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \ sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
--exclude 'known_hosts' \
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1 "$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
print_status "Application files copied" print_status "Application files copied"
+1
View File
@@ -0,0 +1 @@
1
+1
View File
@@ -0,0 +1 @@
1.0.0
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Legt einen Admin-Benutzer im TESM-Lizenzserver an."""
import os
import sqlite3
from getpass import getpass
from flask_bcrypt import Bcrypt
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.environ.get("TESM_LICENSE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
bcrypt = Bcrypt()
def main():
username = input("Admin-Benutzername: ").strip()
password = getpass("Passwort: ")
password_confirm = getpass("Passwort bestätigen: ")
if password != password_confirm:
print("Passwörter stimmen nicht überein!")
return
pw_hash = bcrypt.generate_password_hash(password).decode("utf-8")
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
try:
cur.execute(
"INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
(username, pw_hash, 1),
)
conn.commit()
print(f"Admin-Benutzer '{username}' erfolgreich angelegt.")
except sqlite3.IntegrityError:
print("Benutzername existiert bereits!")
finally:
conn.close()
if __name__ == "__main__":
main()
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Initialisiert die SQLite-Datenbank für den TESM-Lizenzserver (Master).
WICHTIG: Wer hier eine neue Tabelle oder Spalte hinzufügt, muss auch die
Datei SCHEMA_VERSION (daneben, im selben Verzeichnis) um 1 erhöhen.
update.sh vergleicht diese Zahl zwischen der aktuell installierten und der
neuen Version, um zu entscheiden, ob ein Update in-place laufen darf
(bestehende Datenbank bleibt erhalten) oder eine frische Installation
nötig ist (Datenbank wird beiseitegesichert). Ein vergessenes Hochzählen
würde dazu führen, dass ein Host mit altem Schema faelschlich per
In-Place-Update aktualisiert wird, statt frisch installiert zu werden --
_ensure_schema() faengt die meisten Faelle zwar zur Laufzeit ab, ist aber
kein Ersatz fuer eine korrekte Versionsnummer hier.
Gegenüber TESM entfernt: credentials/switches/devices/dhcp_*/
ldap_fileshare_mappings (kein Geräte-/DHCP-/Fileshare-Management auf dem
Lizenzserver). Neu: license_customers/licenses (Kern des Lizenzservers,
siehe licensing.py für das zugehörige Kryptographie-/Protokollformat)."""
import os
import sqlite3
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.environ.get("TESM_LICENSE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
first_name TEXT,
last_name TEXT,
avatar_filename TEXT,
auth_source TEXT NOT NULL DEFAULT 'local',
email TEXT,
is_locked INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
is_default INTEGER DEFAULT 0,
is_system INTEGER DEFAULT 0,
deleted_at TEXT
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS group_permissions (
group_id INTEGER NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (group_id, permission),
FOREIGN KEY (group_id) REFERENCES groups(id)
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (group_id) REFERENCES groups(id)
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS service_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
purpose TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT UNIQUE NOT NULL,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER NOT NULL,
FOREIGN KEY (app_group_id) REFERENCES groups(id)
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
details TEXT
);
""")
# --- Kern des Lizenzservers ---------------------------------------------
c.execute("""
CREATE TABLE IF NOT EXISTS license_customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
contact_email TEXT,
contact_phone TEXT,
notes TEXT,
created_at TEXT NOT NULL
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id TEXT UNIQUE NOT NULL,
customer_id INTEGER NOT NULL,
type TEXT NOT NULL,
modules TEXT NOT NULL DEFAULT '[]',
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
license_pubkey TEXT NOT NULL,
license_file_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'issued',
fingerprint TEXT,
activated_at TEXT,
deactivated_at TEXT,
last_heartbeat_at TEXT,
revoked_at TEXT,
role TEXT NOT NULL DEFAULT 'customer',
created_by TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
);
""")
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5"))
cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",))
if cur.rowcount > 0:
default_group_id = cur.lastrowid
c.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
[
(default_group_id, "logs_group.view"),
(default_group_id, "logs_live.view"),
],
)
conn.commit()
conn.close()
print(f"Datenbank '{DB_PATH}' wurde initialisiert inklusive Settings.")
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Erzeugt/erneuert die Bootstrap-Lizenz DIESES Lizenzservers selbst.
Läuft rein lokal (kein Netzwerk, kein externer Super-Master) -- der Master
prüft seine eigene Lizenz über denselben license_active()-Mechanismus wie
Kundeninstanzen (siehe app.py), nur ohne Heartbeat-Ziel: dieses Skript
setzt den Aktivierungsstatus direkt in der Datenbank, da es keine höhere
Instanz gibt, die eine Aktivierung bestätigen könnte.
Nutzt denselben Master-Signaturschlüssel, den der Server auch zum
Ausstellen von Kundenlizenzen verwendet (wird beim ersten Aufruf
automatisch erzeugt, falls noch nicht vorhanden) -- WICHTIG: dieser
Schlüssel wird beim erneuten Aufruf (Verlängerung) wiederverwendet, nie
neu erzeugt, siehe _load_or_create_master_signing_key() weiter unten und
die identische Funktion in app.py: ein Schlüsselwechsel würde die
Offline-Verifikation künftiger Aktivierungs-/Heartbeat-Antworten für ALLE
bereits an Kunden ausgegebenen Lizenzen brechen.
Aufruf:
python3 create_master_license.py [--days 730] [--name "Firma XY"]
Erneuerung (z.B. kurz vor Ablauf): einfach erneut aufrufen -- überschreibt
die bestehende Bootstrap-Lizenz, der Signaturschlüssel bleibt unverändert.
"""
import argparse
import json
import os
import sqlite3
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import licensing
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MASTER_SIGNING_KEY_PATH = os.environ.get("TESM_LICENSE_MASTER_KEY", os.path.join(BASE_DIR, "master_signing_key.json"))
LICENSE_PATH = os.environ.get("TESM_LICENSE_PATH", os.path.join(BASE_DIR, "license.json"))
DB_PATH = os.environ.get("TESM_LICENSE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
def _load_or_create_master_signing_key():
try:
with open(MASTER_SIGNING_KEY_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
return data["private"], data["public"], False
except (OSError, ValueError, KeyError):
priv, pub = licensing.generate_keypair()
with open(MASTER_SIGNING_KEY_PATH, "w", encoding="utf-8") as f:
json.dump({"private": priv, "public": pub}, f)
try:
os.chmod(MASTER_SIGNING_KEY_PATH, 0o600)
except OSError:
pass
return priv, pub, True
def main():
parser = argparse.ArgumentParser(description="Erzeugt/erneuert die Bootstrap-Lizenz dieses Lizenzservers.")
parser.add_argument("--days", type=int, default=730, help="Gültigkeitsdauer in Tagen (Standard: 730 = 2 Jahre)")
parser.add_argument("--name", default="Lizenzserver (Eigenbetrieb)", help="Anzeigename in der Lizenzdatei")
args = parser.parse_args()
master_priv, master_pub, created = _load_or_create_master_signing_key()
if created:
print(f"Neuer Master-Signaturschlüssel erzeugt: {MASTER_SIGNING_KEY_PATH}")
else:
print(f"Bestehenden Master-Signaturschlüssel wiederverwendet: {MASTER_SIGNING_KEY_PATH}")
license_file, _pub = licensing.issue_license(
customer={"name": args.name, "contact_email": ""},
license_type="enterprise",
modules=[],
valid_days=args.days,
master_private_key_b64=master_priv,
master_public_key_b64=master_pub,
master_endpoint="",
vendor={"name": "", "phone": "", "email": "", "address": "", "logo_base64": ""},
)
with open(LICENSE_PATH, "w", encoding="utf-8") as f:
json.dump(license_file, f, indent=2)
try:
os.chmod(LICENSE_PATH, 0o600)
except OSError:
pass
print(f"Bootstrap-Lizenz geschrieben: {LICENSE_PATH} (gültig {args.days} Tage, bis {license_file['expires_at']})")
if os.path.exists(DB_PATH):
conn = sqlite3.connect(DB_PATH)
conn.execute(
"INSERT INTO settings (key, value) VALUES ('license_activation_status', 'active') "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value"
)
conn.commit()
conn.close()
print("Aktivierungsstatus in der Datenbank auf 'active' gesetzt.")
else:
print(
f"WARNUNG: Datenbank {DB_PATH} nicht gefunden -- bitte zuerst create_db.py ausführen, "
"dann dieses Skript erneut aufrufen, damit der Aktivierungsstatus gesetzt werden kann."
)
print("Fertig -- Dienst neu starten (oder warten, bis der Prozess die Lizenz beim nächsten Zugriff neu lädt).")
if __name__ == "__main__":
main()
+281
View File
@@ -0,0 +1,281 @@
"""
TESM Lizenzsystem — gemeinsame Kryptographie- und Protokoll-Bausteine für
Kundeninstanz (Client) UND Lizenzserver (Master). Beide Seiten importieren
exakt dieselbe Datei (auf dem Master 1:1 mitkopiert) — Signieren und
Verifizieren müssen bitidentisch funktionieren, jede Abweichung würde
Lizenzen der jeweils anderen Seite unlesbar machen.
Vollständiges Design: siehe C:\\Users\\tim\\.claude\\plans\\toasty-twirling-hickey.md
(Phase 0). Kurzfassung der Schlüsselhierarchie:
- Master-Signaturschlüssel (Ed25519): signiert Lizenzdateien selbst UND
jede Aktivierungs-/Deaktivierungs-/Heartbeat-Antwort. Privater Teil bleibt
ausschließlich auf dem Master, öffentlicher Teil steckt in jeder
ausgestellten Lizenzdatei (ermöglicht rein OFFLINE verifizierbare
Master-Antworten beim Kunden).
- Pro-Lizenz-Schlüsselpaar (Ed25519, einmalig je Lizenz erzeugt): der
private Teil reist in der Lizenzdatei zum Kunden und signiert dessen
Aktivierungs-/Deaktivierungs-/Heartbeat-*Anfragen*; der öffentliche Teil
bleibt beim Master in dessen Datenbank (ermöglicht dem Master, jede
Anfrage einer bestimmten Lizenz zweifelsfrei zuzuordnen, ohne die
ursprüngliche Aktivierung "live" gesehen haben zu müssen).
Ed25519 statt RSA: kleine Schlüssel/Signaturen (wichtig, da Aktivierungs-
Anfragen/-Antworten für den Offline-Fall als von Hand kopierbarer Code
dargestellt werden), fest vorgegebene, sichere Parameter (keine
Padding-/Hash-Wahl wie bei RSA-PSS nötig).
"""
import base64
import datetime
import hashlib
import json
import socket
import time
import uuid
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
ALL_MODULES = ("dhcp", "fileshare", "maintenance")
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
# ============================================================== Schlüssel ==
def generate_keypair():
"""Erzeugt ein neues Ed25519-Schlüsselpaar. Rückgabe: (private_b64, public_b64)."""
priv = Ed25519PrivateKey.generate()
return _encode_private_key(priv), _encode_public_key(priv.public_key())
def _encode_private_key(priv: Ed25519PrivateKey) -> str:
return base64.urlsafe_b64encode(priv.private_bytes_raw()).decode("ascii")
def _encode_public_key(pub: Ed25519PublicKey) -> str:
return base64.urlsafe_b64encode(pub.public_bytes_raw()).decode("ascii")
def _decode_private_key(s: str) -> Ed25519PrivateKey:
return Ed25519PrivateKey.from_private_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
def _decode_public_key(s: str) -> Ed25519PublicKey:
return Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
# ===================================================== Signieren/Prüfen ==
def _canonical_bytes(payload: dict) -> bytes:
"""Deterministische JSON-Kodierung (sortierte Keys, kompakte Trenner) --
Voraussetzung dafür, dass Signieren und Verifizieren exakt dieselben
Bytes sehen, unabhängig von der Dict-Einfügereihenfolge."""
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def sign_payload(payload: dict, private_key_b64: str) -> str:
priv = _decode_private_key(private_key_b64)
return base64.urlsafe_b64encode(priv.sign(_canonical_bytes(payload))).decode("ascii")
def verify_payload(payload: dict, signature_b64: str, public_key_b64: str) -> bool:
"""Gibt bewusst nur True/False zurück (nie eine Exception nach außen) --
ein Aufrufer soll "ungültig" nie mit einem Absturz verwechseln können."""
try:
pub = _decode_public_key(public_key_b64)
pub.verify(base64.urlsafe_b64decode(signature_b64.encode("ascii")), _canonical_bytes(payload))
return True
except (InvalidSignature, ValueError, TypeError, KeyError):
return False
# ============================================================ Zeitformat ==
def _iso(ts: float) -> str:
return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_iso(s: str) -> float:
return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()
# ================================================================ Lizenz ==
def issue_license(*, customer, license_type, modules, valid_days,
master_private_key_b64, master_public_key_b64,
master_endpoint, vendor, license_id=None, now=None):
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
frischem Pro-Lizenz-Schlüsselpaar.
Rückgabe: (license_file, license_pubkey) -- license_file ist die
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
bestimmt (Verifikation künftiger Aktivierungs-/Heartbeat-Anfragen
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
unknown = set(modules) - set(ALL_MODULES)
if unknown:
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
license_priv, license_pub = generate_keypair()
now = now if now is not None else time.time()
payload = {
"license_id": license_id or str(uuid.uuid4()),
"customer": customer,
"type": license_type,
"modules": sorted(modules),
"issued_at": _iso(now),
"expires_at": _iso(now + valid_days * 86400),
"license_pubkey": license_pub,
"master_pubkey": master_public_key_b64,
"master_endpoint": master_endpoint,
"vendor": vendor,
}
signature = sign_payload(payload, master_private_key_b64)
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
return license_file, license_pub
def verify_license_file(license_file: dict) -> bool:
"""Prüft die Master-Signatur über die Lizenz-Nutzdaten. license_privkey
und signature selbst sind nicht Teil der signierten Nutzlast (die
Signatur wurde ja gerade über den Rest gebildet, siehe issue_license)."""
payload = {k: v for k, v in license_file.items() if k not in ("license_privkey", "signature")}
return verify_payload(payload, license_file.get("signature", ""), license_file.get("master_pubkey", ""))
def license_status(license_file: dict, now=None) -> dict:
"""Berechnet den aktuellen Anzeige-/Gate-Status einer (bereits als
signaturgültig geprüften!) Lizenz -- ruft NICHT selbst verify_license_file
auf, das bleibt bewusst Sache des Aufrufers, damit hier niemand versehentlich
den Status einer manipulierten Datei berechnet, ohne die Prüfung
überhaupt gemacht zu haben."""
now = now if now is not None else time.time()
expires_at = _parse_iso(license_file["expires_at"])
days_left = (expires_at - now) / 86400
expired = days_left < 0
days_since_expiry = -days_left if expired else 0.0
grace_active = expired and days_since_expiry <= GRACE_PERIOD_DAYS
modules_active = (not expired) or grace_active
return {
"expired": expired,
"days_left": days_left,
"days_since_expiry": days_since_expiry,
"expiring_soon": (not expired) and days_left <= EXPIRY_WARNING_DAYS,
"grace_active": grace_active,
"modules_active": modules_active,
"modules": set(license_file.get("modules", [])) if modules_active else set(),
"type": license_file.get("type"),
"customer": license_file.get("customer"),
"expires_at": license_file.get("expires_at"),
}
def heartbeat_stale_warning(last_heartbeat_ts, now=None) -> bool:
"""True, wenn der letzte erfolgreiche Heartbeat HEARTBEAT_WARNING_DAYS
oder länger zurückliegt. Rein informativ (siehe Moduldocstring) -- ein
fehlender/alter Heartbeat schaltet nie ein Modul ab, nur das
eingebettete Ablaufdatum selbst zählt dafür (siehe license_status)."""
if last_heartbeat_ts is None:
return False # noch nie verbunden gewesen ist der normale Ausgangszustand, kein Ausfall
now = now if now is not None else time.time()
return (now - last_heartbeat_ts) / 86400 >= HEARTBEAT_WARNING_DAYS
# ========================================== Fingerprint (System-Bindung) ==
def system_fingerprint():
"""Stabiler Identifikator dieses Hosts: /etc/machine-id (von systemd bei
der Erstinstallation einmalig erzeugt, übersteht Reboots und normale
Updates) kombiniert mit dem Hostnamen, gehasht damit die rohe
machine-id nie im Klartext übertragen/gespeichert wird. Unter
Windows/ohne /etc/machine-id fällt dies auf den Hostnamen allein
zurück (nur für lokale Tests relevant, Produktivsysteme sind Linux)."""
machine_id = ""
try:
with open("/etc/machine-id", "r", encoding="utf-8") as f:
machine_id = f.read().strip()
except OSError:
pass
raw = f"{machine_id}:{socket.gethostname()}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
# ============================ Aktivierung / Deaktivierung / Heartbeat =====
# Ein Protokoll, zwei Transportwege: online automatisch per HTTPS-API,
# offline exakt dasselbe Anfrage/Antwort-Paar als von Hand kopierbarer Code
# (encode_code/decode_code) -- der Master ist dadurch in BEIDEN Fällen die
# einzige Quelle der Wahrheit dafür, an welches System eine Lizenz gerade
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
# verhindert".
def build_client_request(action, license_file, nonce=None) -> dict:
"""Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt."""
if action not in ("activate", "deactivate", "heartbeat"):
raise ValueError(f"Unbekannte Aktion: {action!r}")
payload = {
"license_id": license_file["license_id"],
"fingerprint": system_fingerprint(),
"action": action,
"nonce": nonce or uuid.uuid4().hex,
"timestamp": _iso(time.time()),
}
signature = sign_payload(payload, license_file["license_privkey"])
return {**payload, "signature": signature}
def verify_client_request(request: dict, license_public_key_b64: str) -> bool:
"""Vom MASTER aufgerufen: prüft eine Client-Anfrage gegen den bei
Ausstellung dieser Lizenz in der Master-DB gespeicherten Public-Key."""
payload = {k: v for k, v in request.items() if k != "signature"}
return verify_payload(payload, request.get("signature", ""), license_public_key_b64)
def build_master_response(action, license_id, fingerprint, master_private_key_b64,
status="ok", license_update=None, now=None) -> dict:
"""Vom MASTER aufgerufen: erzeugt eine signierte Antwort/Bestätigung
(z.B. "activated", "deactivated", oder das Ergebnis eines Heartbeats).
license_update: optional eine komplette, neu signierte Lizenzdatei
(z.B. nach nachträglicher Modul-Änderung durch den Admin) -- der Client
übernimmt sie nur nach eigener, erfolgreicher Signaturprüfung."""
payload = {
"license_id": license_id,
"fingerprint": fingerprint,
"action": action,
"status": status,
"timestamp": _iso(now if now is not None else time.time()),
}
if license_update is not None:
payload["license_update"] = license_update
signature = sign_payload(payload, master_private_key_b64)
return {**payload, "signature": signature}
def verify_master_response(response: dict, master_public_key_b64: str) -> bool:
"""Vom CLIENT aufgerufen: prüft eine Master-Antwort komplett OFFLINE
gegen den in der eigenen Lizenzdatei eingebetteten master_pubkey."""
payload = {k: v for k, v in response.items() if k != "signature"}
return verify_payload(payload, response.get("signature", ""), master_public_key_b64)
# ==================================================== Codes für Offline ==
def encode_code(data: dict) -> str:
"""Kodiert ein Anfrage-/Antwort-dict als kompakten, per Copy-Paste
übertragbaren Code (Base64 einer kanonischen JSON-Darstellung, keine
Zeilenumbrüche/Sonderzeichen, damit Kopieren aus/in ein Textfeld nichts
kaputt macht)."""
return base64.urlsafe_b64encode(_canonical_bytes(data)).decode("ascii")
def decode_code(code: str) -> dict:
return json.loads(base64.urlsafe_b64decode(code.strip().encode("ascii")).decode("utf-8"))
+37
View File
@@ -0,0 +1,37 @@
bcrypt==5.0.0
blinker==1.9.0
cffi==2.0.0
click==8.3.0
cryptography==46.0.1
Flask==3.1.2
Flask-Bcrypt==1.0.1
Flask-Login==0.6.3
gunicorn==23.0.0
itsdangerous==2.2.0
Jinja2==3.1.6
ldap3==2.9.1
MarkupSafe==3.0.2
pycparser==2.23
PyYAML==6.0.2
Werkzeug==3.1.3
# Gegenueber TESM entfernt: Flask-Sock/simple-websocket (Browser-SSH-
# Terminal fuer Switche gibt es hier nicht mehr), paramiko/PyNaCl/pyasn1
# (nur fuer SSH zu Switchen/Geraeten gebraucht -- der Lizenzserver macht
# kein Geraete-Management). cffi/pycparser bleiben: cryptography selbst
# haengt (ausserhalb PyPy) von cffi ab, das wiederum pycparser braucht --
# nicht optional, auch ohne paramiko.
#
# PyYAML wird für das netplan-Backend der Host-Netzwerkeinstellungen
# gebraucht (Systemeinstellungen → Netzwerkeinstellungen) — schreibt/liest
# die eigene Override-Datei unter /etc/netplan/.
#
# ldap3 (reines Python, keine System-Bibliothek wie libldap nötig) für die
# optionale Active-Directory/LDAP-Anmeldung (Systemeinstellungen → LDAP) —
# Search+Bind gegen einen AD-Domain-Controller oder generischen LDAP-Server.
#
# gunicorn: produktiver WSGI-Server für tesm-license.service (siehe dort)
# statt Flasks eigenem app.run()-Entwicklungsserver ("WARNING: This is a
# development server..."). Ein Worker-Prozess mit mehreren Threads
# (--worker-class gthread) reicht hier aus, da der Lizenzserver — anders
# als TESM — keine langlebigen WebSocket-Verbindungen mehr offen hält.
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="62" height="62" rx="14" fill="#10151B"></rect>
<g transform="translate(5,20.19) scale(0.16875)">
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 842 B

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="62" height="62" rx="14" fill="#EDF1F2" stroke="#C1CBCE" stroke-width="2"></rect>
<g transform="translate(5,20.19) scale(0.16875)">
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 875 B

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(150,15)">
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
</g>
<text x="310" y="178" text-anchor="middle" font-family="ui-sans-serif, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-weight="600" font-size="13" letter-spacing="1.5" fill="#E2A63C">ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 320 140" xmlns="http://www.w3.org/2000/svg">
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
</svg>

After

Width:  |  Height:  |  Size: 699 B

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(150,15)">
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
</g>
<text x="310" y="178" text-anchor="middle" font-family="ui-sans-serif, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-weight="600" font-size="13" letter-spacing="1.5" fill="#B5750E">ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 320 140" xmlns="http://www.w3.org/2000/svg">
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
</svg>

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+633
View File
@@ -0,0 +1,633 @@
/* ==========================================================================
PoE Manager — shared UI behaviour (sidebar, theme, modals, toasts)
========================================================================== */
(function () {
"use strict";
/* ---------------- Theme ---------------- */
const THEME_KEY = "poe-theme";
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
document.querySelectorAll("[data-theme-icon]").forEach((el) => {
el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun;
});
// Favicon + Sidebar-Logo folgen demselben Theme wie der Rest der App
// (auch bei manuellem Umschalten, nicht nur bei System-Präferenz) --
// "dunkel"/"hell" bezeichnen hier die für den jeweiligen Modus gedachte
// Logo-Variante (helle Farben für Dark Mode, dunkle für Light Mode).
const suffix = theme === "light" ? "light" : "dark";
const favicon = document.getElementById("app-favicon");
if (favicon) favicon.href = "/static/images/icon-" + suffix + ".svg";
const sidebarLogo = document.getElementById("sidebar-logo");
if (sidebarLogo) sidebarLogo.src = "/static/images/logo-" + suffix + ".svg";
const loginBgLogo = document.getElementById("login-bg-logo");
if (loginBgLogo) loginBgLogo.src = "/static/images/logo-" + suffix + "-subline.svg";
}
function initTheme() {
const saved = localStorage.getItem(THEME_KEY) ||
(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
applyTheme(saved);
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
const next = current === "light" ? "dark" : "light";
localStorage.setItem(THEME_KEY, next);
applyTheme(next);
}
const ICONS = {
sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>',
moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z"/></svg>',
};
/* ---------------- Sidebar (mobile) ---------------- */
function initSidebar() {
const sidebar = document.querySelector(".sidebar");
const backdrop = document.querySelector(".sidebar-backdrop");
const main = document.querySelector(".main");
const toggles = document.querySelectorAll("[data-sidebar-toggle]");
if (!sidebar) return;
const DESKTOP_BREAKPOINT = 900;
const COLLAPSE_KEY = "poe-sidebar-collapsed";
// Mobile: temporäres Überlagern per Hamburger + Backdrop.
function open() {
sidebar.classList.add("open");
backdrop && backdrop.classList.add("open");
}
function close() {
sidebar.classList.remove("open");
backdrop && backdrop.classList.remove("open");
}
// Desktop: dauerhaftes Ein-/Ausklappen, über Neuladen hinweg gemerkt.
function setCollapsed(collapsed) {
sidebar.classList.toggle("collapsed", collapsed);
main && main.classList.toggle("sidebar-collapsed", collapsed);
localStorage.setItem(COLLAPSE_KEY, collapsed ? "1" : "0");
}
if (localStorage.getItem(COLLAPSE_KEY) === "1") setCollapsed(true);
toggles.forEach((btn) => btn.addEventListener("click", () => {
if (window.innerWidth <= DESKTOP_BREAKPOINT) {
sidebar.classList.contains("open") ? close() : open();
} else {
setCollapsed(!sidebar.classList.contains("collapsed"));
}
}));
backdrop && backdrop.addEventListener("click", close);
document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close));
}
/* ---------------- Modals ---------------- */
function openModal(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.add("open");
document.body.style.overflow = "hidden";
}
function closeModal(el) {
const overlay = el.closest ? el.closest(".modal-overlay") : el;
if (!overlay) return;
overlay.classList.remove("open");
document.body.style.overflow = "";
}
function initModals() {
document.querySelectorAll("[data-open-modal]").forEach((trigger) => {
trigger.addEventListener("click", () => openModal(trigger.getAttribute("data-open-modal")));
});
document.querySelectorAll("[data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeModal(overlay);
});
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
document.querySelectorAll(".modal-overlay.open").forEach((o) => closeModal(o));
}
});
}
/* ---------------- Confirm dialog (replaces window.confirm) ---------------- */
function ensureConfirmModal() {
if (document.getElementById("confirm-modal")) return;
const html = `
<div class="modal-overlay" id="confirm-modal">
<div class="modal" style="max-width:380px;">
<div class="modal-header">
<h3 id="confirm-title">Bist du sicher?</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p id="confirm-message" class="text-dim"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="button" class="btn btn-danger" id="confirm-ok" style="background:var(--danger);color:#fff;">Bestätigen</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#confirm-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("confirm-modal").addEventListener("click", (e) => {
if (e.target.id === "confirm-modal") closeModal(e.target);
});
}
window.confirmAction = function (message, onConfirm, title) {
ensureConfirmModal();
document.getElementById("confirm-title").innerText = title || "Bist du sicher?";
document.getElementById("confirm-message").innerText = message;
const okBtn = document.getElementById("confirm-ok");
const freshBtn = okBtn.cloneNode(true);
okBtn.parentNode.replaceChild(freshBtn, okBtn);
freshBtn.addEventListener("click", () => {
closeModal(freshBtn);
onConfirm();
});
openModal("confirm-modal");
};
/* ---------------- Ja/Nein-Dialog für ungespeicherte Änderungen ---------------- */
/* Eigener Dialog statt confirmAction (dort ist "Abbrechen" = beim Bleiben,
hier gibt es bewusst nur die zwei angefragten Optionen: speichern oder
verwerfen — beide verlassen die Seite, nur "Abbrechen"/Escape/Backdrop
bricht das Verlassen selbst ab und lässt die Seite unverändert offen. */
function ensureSaveDiscardModal() {
if (document.getElementById("save-discard-modal")) return;
const html = `
<div class="modal-overlay" id="save-discard-modal">
<div class="modal" style="max-width:420px;">
<div class="modal-header">
<h3>Ungespeicherte Änderungen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-dim" id="save-discard-message"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="save-discard-no">Nein, verwerfen</button>
<button type="button" class="btn btn-primary" id="save-discard-yes">Ja, speichern</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#save-discard-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("save-discard-modal").addEventListener("click", (e) => {
if (e.target.id === "save-discard-modal") closeModal(e.target);
});
}
window.confirmSaveDiscard = function (message, onSave, onDiscard) {
ensureSaveDiscardModal();
document.getElementById("save-discard-message").innerText = message;
const yesBtn = document.getElementById("save-discard-yes");
const noBtn = document.getElementById("save-discard-no");
const freshYes = yesBtn.cloneNode(true);
yesBtn.parentNode.replaceChild(freshYes, yesBtn);
const freshNo = noBtn.cloneNode(true);
noBtn.parentNode.replaceChild(freshNo, noBtn);
freshYes.addEventListener("click", () => { closeModal(freshYes); onSave(); });
freshNo.addEventListener("click", () => { closeModal(freshNo); onDiscard(); });
openModal("save-discard-modal");
};
/* ---------------- RAW-Log-Popup (Live-Log & Verlauf) ---------------- */
/* Gleiches Modal-Grundgerüst wie der Ja/Nein-Dialog für ungespeicherte
Änderungen (modal-overlay/modal-header/modal-body/modal-footer), nur
breiter/höher und mit lazy nachgeladenem Inhalt -- der komplette
Log-Inhalt wird bewusst erst beim tatsächlichen Öffnen per fetch
nachgeladen, nicht schon beim Seitenaufbau, damit genau das Problem
(unnötig große Ladezeiten) nicht durch das Popup selbst zurückkommt. */
function ensureRawLogModal() {
if (document.getElementById("raw-log-modal")) return;
const html = `
<div class="modal-overlay" id="raw-log-modal">
<div class="modal" style="max-width:min(1100px, 92vw); height:85vh;">
<div class="modal-header">
<h3 id="raw-log-modal-title">Komplettes Log (RAW)</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body" style="padding:0; display:flex; flex-direction:column; flex:1; min-height:0;">
<pre id="raw-log-modal-content" class="raw-log-content"></pre>
</div>
<div class="modal-footer">
<span class="text-faint mono" id="raw-log-modal-meta" style="margin-right:auto; font-size:11.5px;"></span>
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#raw-log-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("raw-log-modal").addEventListener("click", (e) => {
if (e.target.id === "raw-log-modal") closeModal(e.target);
});
}
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;
ensureRawLogModal();
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
const content = document.getElementById("raw-log-modal-content");
const meta = document.getElementById("raw-log-modal-meta");
content.classList.toggle("with-line-numbers", !!withLineNumbers);
content.textContent = "Lade …";
meta.textContent = "";
openModal("raw-log-modal");
fetch(url)
.then((r) => {
const logName = r.headers.get("X-Log-Name");
if (logName) meta.textContent = logName;
return r.text();
})
.then((text) => {
if (withLineNumbers) renderRawLogLines(content, text);
else content.textContent = text;
})
.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 ---------------- */
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
Toggle-Buttons) seit dem Laden der Seite verändert wurde, und fragt vor
dem Verlassen (Klick auf einen Link, z.B. in der Navigation) per Ja/Nein,
ob zuerst gespeichert werden soll — statt Änderungen stillschweigend zu
verwerfen. "Ja" sendet das Formular ganz normal ab (echter POST, kein
AJAX-Nachbau nötig) und merkt sich das eigentliche Ziel in
sessionStorage, um nach dem Speichern automatisch dorthin
weiterzuleiten, statt dass der Link ein zweites Mal angeklickt werden
muss. Für Browser-eigene Navigation (Tab schließen, Reload, Adresszeile)
gibt es zusätzlich beforeunload — dort erlaubt der Browser aus
Sicherheitsgründen aber nur eine generische Warnung, kein eigenes
Ja/Nein/Speichern-Dialogfeld. */
const PENDING_NAV_KEY = "poe-pending-nav-after-save";
function serializeForm(form) {
return new URLSearchParams(new FormData(form)).toString();
}
function initUnsavedChangesGuard() {
const pendingNav = sessionStorage.getItem(PENDING_NAV_KEY);
if (pendingNav) {
sessionStorage.removeItem(PENDING_NAV_KEY);
window.location.href = pendingNav;
return;
}
const trackable = [];
document.querySelectorAll("form[method='post' i]").forEach((form) => {
if (form.hasAttribute("data-no-unsaved-guard")) return;
const fields = form.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea");
if (!fields.length) return; // reine Aktions-Formulare (Löschen/Toggle) ohne Eingabefeld
trackable.push({ form, initial: serializeForm(form) });
});
if (!trackable.length) return;
// Verhindert die native Browser-Warnung (beforeunload), wenn die Seite
// ohnehin schon bewusst verlassen wird — entweder weil eines der
// beobachteten Formulare ganz normal abgeschickt wurde (eigener
// "Speichern"/"Anlegen"-Button auf der Seite) oder weil unser eigener
// Ja/Nein-Dialog die Navigation ausgelöst hat. Ohne das würde beim
// normalen Speichern zusätzlich zum eigentlichen Erfolg immer noch die
// generische Browser-Meldung aufpoppen — die soll ausschließlich dann
// erscheinen, wenn tatsächlich UNGEFRAGT navigiert wird (Tab schließen,
// Reload, Adresszeile), nicht bei einem bewussten Speichern.
let navigatingAway = false;
trackable.forEach(({ form }) => {
form.addEventListener("submit", () => { navigatingAway = true; });
});
function dirtyForm() {
return trackable.find(({ form, initial }) => serializeForm(form) !== initial) || null;
}
document.addEventListener("click", (e) => {
const link = e.target.closest("a[href]");
if (!link || link.target === "_blank" || link.href.startsWith("javascript:") || link.hasAttribute("data-no-unsaved-guard")) return;
const dirty = dirtyForm();
if (!dirty) return;
e.preventDefault();
const href = link.href;
window.confirmSaveDiscard(
"Es gibt ungespeicherte Änderungen auf dieser Seite. Vor dem Verlassen speichern?",
() => {
navigatingAway = true;
sessionStorage.setItem(PENDING_NAV_KEY, href);
dirty.form.requestSubmit ? dirty.form.requestSubmit() : dirty.form.submit();
},
() => {
navigatingAway = true;
window.location.href = href;
}
);
});
window.addEventListener("beforeunload", (e) => {
if (!navigatingAway && dirtyForm()) {
e.preventDefault();
e.returnValue = "";
}
});
}
/* Intercept forms/buttons marked with data-confirm="message" */
function initConfirmables() {
document.querySelectorAll("form[data-confirm]").forEach((form) => {
form.addEventListener("submit", function (e) {
if (form.dataset.confirmed === "1") return;
e.preventDefault();
// Welcher Button hat den Submit ausgelöst? Muss beim erneuten
// requestSubmit() explizit mitgegeben werden — sonst geht bei
// Formularen, die ihre ID über name/value des Submit-Buttons
// transportieren (statt über ein <input type="hidden">), dieses
// Feld beim Neu-Absenden stillschweigend verloren (requestSubmit()
// ohne Argument zählt als "kein Button aktiviert").
const submitter = e.submitter;
window.confirmAction(form.getAttribute("data-confirm"), () => {
form.dataset.confirmed = "1";
form.requestSubmit ? form.requestSubmit(submitter) : form.submit();
}, form.getAttribute("data-confirm-title"));
});
});
}
/* ---------------- Toasts ---------------- */
function ensureToastStack() {
let stack = document.querySelector(".toast-stack");
if (!stack) {
stack = document.createElement("div");
stack.className = "toast-stack";
document.body.appendChild(stack);
}
return stack;
}
window.showToast = function (message, type) {
const stack = ensureToastStack();
const toast = document.createElement("div");
toast.className = "toast " + (type || "info");
toast.innerHTML = `<span>${message}</span><span class="toast-close">&times;</span>`;
stack.appendChild(toast);
const remove = () => {
toast.classList.add("hide");
setTimeout(() => toast.remove(), 180);
};
toast.querySelector(".toast-close").addEventListener("click", remove);
setTimeout(remove, 5000);
};
function initFlashedMessages() {
const data = document.getElementById("flashed-data");
if (!data) return;
try {
const messages = JSON.parse(data.textContent);
messages.forEach(([category, msg]) => {
const type = category === "danger" ? "danger" : category === "success" ? "success" : "info";
window.showToast(msg, type);
});
} catch (e) { /* noop */ }
}
/* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen — der manuell
aufgeklappte Zustand bleibt über Seitenwechsel hinweg erhalten
(localStorage), zusätzlich zur automatischen Aufklappung der Gruppe
der aktuell aktiven Seite (server-seitig via "expanded"-Klasse). */
const NAV_EXPANDED_KEY = "poe-nav-expanded";
function getExpandedNavGroups() {
try {
return new Set(JSON.parse(localStorage.getItem(NAV_EXPANDED_KEY) || "[]"));
} catch (e) {
return new Set();
}
}
function saveExpandedNavGroups(set) {
localStorage.setItem(NAV_EXPANDED_KEY, JSON.stringify(Array.from(set)));
}
function initNavGroups() {
const expanded = getExpandedNavGroups();
document.querySelectorAll("[data-nav-group]").forEach((group) => {
const key = group.dataset.navGroupKey;
if (key && expanded.has(key)) group.classList.add("expanded");
});
document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const group = btn.closest("[data-nav-group]");
const isExpanded = group.classList.toggle("expanded");
const key = group.dataset.navGroupKey;
if (!key) return;
const set = getExpandedNavGroups();
if (isExpanded) set.add(key); else set.delete(key);
saveExpandedNavGroups(set);
});
});
}
/* ---------------- Sortierbare Tabellen ---------------- */
/*
* Klick auf <th data-sort-key="..."> sortiert die Tabelle. Zwei Modi:
* - Normale Tabellen: sortiert <tr>-Zeilen innerhalb des einzigen <tbody>
* anhand von data-sort-<key> auf der jeweiligen <tr>.
* - Akkordeon-Tabellen (mehrere <tbody>, z.B. Gruppen mit Detail-Zeile):
* sortiert ganze <tbody>-Blöcke anhand von data-sort-<key> auf dem
* jeweiligen <tbody>, damit Haupt- und Detail-Zeile zusammenbleiben.
* <tbody data-sort-pinned> (z.B. die virtuelle "Admin"-Zeile) bleibt
* dabei immer an ihrer Position.
*/
function compareSortValues(a, b, asc) {
a = (a === null || a === undefined) ? "" : String(a);
b = (b === null || b === undefined) ? "" : String(b);
const na = parseFloat(a), nb = parseFloat(b);
const bothNumeric = a !== "" && b !== "" && !isNaN(na) && !isNaN(nb);
const cmp = bothNumeric ? (na - nb) : a.toLowerCase().localeCompare(b.toLowerCase(), "de");
return asc ? cmp : -cmp;
}
function sortTable(table, th) {
const key = th.dataset.sortKey;
const asc = th.dataset.sortDir !== "asc";
table.querySelectorAll("thead th[data-sort-key]").forEach((h) => {
delete h.dataset.sortDir;
h.classList.remove("sort-asc", "sort-desc");
});
th.dataset.sortDir = asc ? "asc" : "desc";
th.classList.add(asc ? "sort-asc" : "sort-desc");
if (table.tBodies.length > 1) {
const movable = Array.prototype.filter.call(table.tBodies, (tb) => !tb.hasAttribute("data-sort-pinned"));
movable.sort((a, b) => compareSortValues(
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
));
movable.forEach((tb) => table.appendChild(tb));
} else {
const tbody = table.tBodies[0];
const rows = Array.prototype.filter.call(tbody.rows, (r) => !r.classList.contains("empty-row"));
rows.sort((a, b) => compareSortValues(
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
));
rows.forEach((r) => tbody.appendChild(r));
}
}
function initSortableTables() {
document.querySelectorAll("table[data-sortable] thead th[data-sort-key]").forEach((th) => {
th.classList.add("sortable-col");
th.addEventListener("click", () => sortTable(th.closest("table"), th));
});
}
/* ---------------- Init ---------------- */
document.addEventListener("DOMContentLoaded", function () {
initTheme();
initSidebar();
initModals();
initConfirmables();
initFlashedMessages();
initNavGroups();
initUnsavedChangesGuard();
initSortableTables();
initHintIcons();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
});
window.PoeUI = { openModal, closeModal };
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
File diff suppressed because one or more lines are too long
@@ -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 %}
@@ -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 %}
@@ -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 %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block page_title %}Mein Konto{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ current_user.username }}</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Profil</h2>
<div class="hint">Name und Profilbild ändern sich in der Sidebar und im Änderungslog.</div>
</div>
</div>
<div style="display:flex; align-items:center; gap:14px; margin-bottom:20px;">
{% if current_user.avatar_url %}
<img src="{{ current_user.avatar_url }}" alt="" style="width:56px; height:56px; border-radius:50%; object-fit:cover;">
{% else %}
<div class="user-avatar" style="width:56px; height:56px; font-size:18px;">{{ current_user.username[:2]|upper }}</div>
{% endif %}
<form method="post" action="{{ url_for('profile') }}" enctype="multipart/form-data" id="avatarForm" style="flex:1;">
<input type="file" name="avatar" id="avatarInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none;" onchange="document.getElementById('avatarForm').requestSubmit();">
<button type="button" class="btn btn-secondary" style="width:100%;" onclick="document.getElementById('avatarInput').click();">Profilbild ändern</button>
<input type="hidden" name="upload_avatar" value="1">
</form>
</div>
<form method="post" action="{{ url_for('profile') }}" id="profileForm">
<div class="field"><label>Vorname</label><input type="text" name="first_name" value="{{ current_user.first_name or '' }}"></div>
<div class="field"><label>Name</label><input type="text" name="last_name" value="{{ current_user.last_name or '' }}"></div>
<div class="field"><label>Username</label><input type="text" value="{{ current_user.username }}" disabled></div>
<button type="submit" name="update_profile" value="1" class="btn btn-primary btn-block">Speichern</button>
</form>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Passwort ändern</h2>
<div class="hint">
{% if current_user.is_ldap_user %}Wird über Active Directory verwaltet.{% else %}Erfordert Eingabe des aktuellen Passworts.{% endif %}
</div>
</div>
</div>
{% if current_user.is_ldap_user %}
<p class="text-faint" style="font-size:12.5px;">
Dieses Konto meldet sich über Active Directory/LDAP an — das Passwort wird dort verwaltet
und kann in dieser App nicht geändert werden. Bitte das Domänen-Passwort wie gewohnt ändern.
</p>
{% else %}
<form method="post" action="{{ url_for('profile') }}" id="passwordForm">
<div class="field"><label>Aktuelles Passwort</label><input type="password" name="current_password" autocomplete="current-password"></div>
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" autocomplete="new-password"></div>
<div class="field"><label>Neues Passwort bestätigen</label><input type="password" name="confirm_password" autocomplete="new-password"></div>
<button type="submit" name="change_password" value="1" class="btn btn-secondary btn-block">Passwort ändern</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,224 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Auditlog{% 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 %}
{% import "_audit_log_macros.html" as m %}
{% set ns = namespace(create=0, delete=0, edit=0) %}
{% for e in entries %}
{% set cat = m.category_of(e['action'].split('.')[-1])|trim %}
{% if cat == "create" %}{% set ns.create = ns.create + 1 %}
{% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %}
{% else %}{% set ns.edit = ns.edit + 1 %}{% endif %}
{% endfor %}
{% macro fmt_count(loaded, total) %}{% if loaded < total %}{{ loaded }} / {{ total }}{% else %}{{ loaded }}{% endif %}{% endmacro %}
<div class="stat-row" style="margin-bottom:18px;">
<div class="stat-card" data-category-filter="">
<div class="stat-label">Alle</div>
<div class="stat-value" id="statAll">{{ fmt_count(entries|length, total_count) }}</div>
</div>
<div class="stat-card" data-category-filter="create">
<div class="stat-label">Hinzufügen</div>
<div class="stat-value" id="statCreate" style="color:var(--success);">{{ fmt_count(ns.create, total_create) }}</div>
</div>
<div class="stat-card" data-category-filter="edit">
<div class="stat-label">Änderungen</div>
<div class="stat-value" id="statEdit" style="color:var(--accent-strong);">{{ fmt_count(ns.edit, total_edit) }}</div>
</div>
<div class="stat-card" data-category-filter="delete">
<div class="stat-label">Löschungen</div>
<div class="stat-value" id="statDelete" style="color:var(--danger);">{{ fmt_count(ns.delete, total_delete) }}</div>
</div>
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" 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;">
<table class="data-table" id="auditTable" data-sortable>
<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>
{% for e in entries %}{{ m.audit_row(e) }}{% else %}
<tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
{% endfor %}
</tbody>
</table>
</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>
{% 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 %}
+150
View File
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or "TESM-Lizenzserver" }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
{% block extra_head %}{% endblock %}
</head>
<body>
{% set icons = {
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
"cpu": '<rect x="6" y="6" width="12" height="12" rx="1.5"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>',
"share": '<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/>',
"users": '<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"/>',
"groups": '<rect x="3" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="3" y="13" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/>',
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
"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"/>',
"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"/>',
"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"/>',
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
"network": '<rect x="9" y="2" width="6" height="6" rx="1.2"/><rect x="2" y="16" width="6" height="6" rx="1.2"/><rect x="16" y="16" width="6" height="6" rx="1.2"/><path d="M12 8v4M12 12H5v4M12 12h7v4"/>',
"wrench": '<path d="M14.7 6.3a4 4 0 11-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 015.4-5.4z"/>',
"trash": '<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 6h16z"/><path d="M10 11v6M14 11v6"/>',
} %}
<div class="app-shell">
{% if current_user.is_authenticated %}
<div class="sidebar-backdrop" data-sidebar-toggle></div>
<aside class="sidebar">
<div class="sidebar-brand">
<img id="sidebar-logo" src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM-Lizenzserver" style="width:100%; height:auto; display:block;">
</div>
<nav class="sidebar-nav">
{% for item in nav_items_ordered %}
{% if item.children %}
{% set child_active = item.children|selectattr('endpoint', 'equalto', request.endpoint)|list %}
<div class="nav-group {% if child_active %}expanded active-group{% endif %}" data-nav-group data-nav-group-key="{{ item.key }}">
<button type="button" class="nav-group-toggle" data-nav-group-toggle>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
{{ item.label }}
<svg class="nav-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</button>
<div class="nav-group-children">
{% for child in item.children %}
<a href="{{ url_for(child.endpoint) }}" class="nav-item {% if request.endpoint == child.endpoint %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[child.icon]|safe }}</svg>
{{ child.label }}
</a>
{% endfor %}
</div>
</div>
{% else %}
<a href="{{ url_for(item.endpoint) }}" class="nav-item {% if active_page == item.key %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
{{ item.label }}
</a>
{% endif %}
{% endfor %}
</nav>
<div class="sidebar-footer">
<div class="user-chip">
{% if current_user.avatar_url %}
<img class="user-avatar" src="{{ current_user.avatar_url }}" alt="" style="object-fit:cover;">
{% else %}
<div class="user-avatar">{{ current_user.username[:2]|upper }}</div>
{% endif %}
<div class="user-meta">
<div class="u-name">{{ current_user.display_name }}</div>
<div class="u-role">{{ "Administrator" if current_user.is_admin else current_user.group_names or "Benutzer" }}</div>
</div>
<a href="{{ url_for('account') }}" class="icon-btn" title="Einstellungen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['gear']|safe }}</svg>
</a>
</div>
<div class="footer-actions">
<button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln">
<span data-theme-icon></span>
</button>
<a href="{{ url_for('logout') }}" class="icon-btn" title="Abmelden" style="flex:1; display:flex;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin:auto;">{{ icons['logout']|safe }}</svg>
</a>
</div>
<div class="sidebar-copyright" style="padding:10px 20px 4px; font-size:10.5px; color:var(--text-faint); text-align:center;">
© {{ current_year }} TESM-Lizenzserver v{{ tesm_version }}
</div>
</div>
</aside>
{% endif %}
<div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}">
<div class="topbar">
<div class="topbar-left">
{% if current_user.is_authenticated %}
<button type="button" class="hamburger" data-sidebar-toggle aria-label="Menü">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
{% endif %}
<div>
<div class="topbar-title">{% block page_title %}Dashboard{% endblock %}</div>
{% block page_sub %}{% endblock %}
</div>
</div>
<div class="topbar-logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
</div>
<div class="topbar-right">
{% if license_topbar %}
<a href="{{ url_for('settings_license') }}" class="timer-pill" title="{{ license_topbar.text }}" style="text-decoration:none;">
<span class="dot {% if license_topbar.blink %}dot--blink-{{ license_topbar.level }}{% endif %}"
style="{% if not license_topbar.blink %}animation:none; box-shadow:none;{% endif %} background:{{ 'var(--danger)' if license_topbar.level == 'danger' else 'var(--warning)' }};"></span>{{ license_topbar.text }}
</a>
{% endif %}
{% if not current_user.is_authenticated %}
<a href="{{ url_for('login') }}" 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="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
Login
</a>
{% endif %}
</div>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
<script type="application/json" id="flashed-data">{{ messages|tojson }}</script>
{% endwith %}
<script src="{{ asset_url('js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+161
View File
@@ -0,0 +1,161 @@
{% extends "base.html" %}
{% set active_page = "customers" %}
{% block page_title %}Kunden{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ customers|length }} Kunde{{ 'n' if customers|length != 1 else '' }}</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Kunden</h2>
</div>
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addCustomerModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neuer Kunde
</button>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="customerSearch" placeholder="Kunden durchsuchen…" oninput="filterCustomersTable()">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="customersTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Kunde</th>
<th data-sort-key="email">Kontakt</th>
<th data-sort-key="licenses">Lizenzen</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
{% for c in customers %}
<tbody data-sort-name="{{ c.name|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-licenses="{{ c.license_count }}">
<tr>
<td class="cell-name">{{ c.name }}</td>
<td class="text-dim">{{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %}</td>
<td class="text-dim">{{ c.license_count }}</td>
<td>
<div class="row-actions">
{% if can_edit %}
<button class="icon-btn" title="Bearbeiten" data-open-modal="editCustomerModal{{ loop.index }}">
<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>
{% if c.license_count == 0 %}
<form method="post" data-confirm="Kunde „{{ c.name }}“ wirklich löschen?">
<input type="hidden" name="delete_customer" value="{{ c.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
{% endif %}
{% if current_user.has_permission('licenses.create') %}
<a class="icon-btn" title="Lizenz ausstellen" href="{{ url_for('license_issue') }}?customer_id={{ c.id }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9m-4 0H3m2 0a2 2 0 100-4 2 2 0 000 4zm4 4h.01"/></svg>
</a>
{% endif %}
</div>
</td>
</tr>
</tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="4">Noch keine Kunden angelegt.</td></tr>
</tbody>
{% endfor %}
</table>
</div>
</div>
{% for c in customers %}
<div class="modal-overlay" id="editCustomerModal{{ loop.index }}">
<div class="modal" style="max-width:480px;">
<form method="post">
<input type="hidden" name="edit_customer" value="{{ c.id }}">
<div class="modal-header">
<h3>Kunde 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="{{ c.name }}" required {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>E-Mail</label>
<input type="email" name="contact_email" value="{{ c.contact_email or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>Telefon</label>
<input type="text" name="contact_phone" value="{{ c.contact_phone or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>Notizen</label>
<textarea name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{{ c.notes or '' }}</textarea>
</div>
</div>
{% if can_edit %}
<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>
{% endif %}
</form>
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="addCustomerModal">
<div class="modal" style="max-width:480px;">
<form method="post">
<input type="hidden" name="new_customer" value="1">
<div class="modal-header">
<h3>Neuer Kunde</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" required placeholder="z.B. Musterfirma GmbH">
</div>
<div class="field">
<label>E-Mail</label>
<input type="email" name="contact_email">
</div>
<div class="field">
<label>Telefon</label>
<input type="text" name="contact_phone">
</div>
<div class="field">
<label>Notizen</label>
<textarea name="notes" rows="3"></textarea>
</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>
{% endblock %}
{% block scripts %}
<script>
function filterCustomersTable() {
const q = document.getElementById("customerSearch").value.trim().toLowerCase();
document.querySelectorAll("#customersTable tbody").forEach(tbody => {
if (tbody.hasAttribute("data-sort-pinned")) return;
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
</script>
{% endblock %}
+393
View File
@@ -0,0 +1,393 @@
{% extends "base.html" %}
{% set active_page = "groups" %}
{% block page_title %}Gruppen{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
{% macro permission_table(group, group_key, checked_keys, readonly) %}
{% set row_types = group_row_types[group_key] %}
<div class="permission-group-col">
<div style="overflow-x:auto;">
<table class="permission-table">
<thead>
<tr>
<th class="permission-group-header-cell">
<label class="permission-group-toggle">
<input type="checkbox" name="permissions" value="{{ group['view_key'] }}"
title="{{ permission_labels.get(group['view_key'], group['label']) }}"
{% if group['view_key'] in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}
class="permission-area-toggle-cb">
<span class="permission-group-name">{{ group['label'] }}</span>
</label>
</th>
{% for row_key, row_letter, row_label in row_types %}
<th title="{{ row_label }}">{{ row_letter }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for child_key, child in group['children'].items() %}
<tr>
<td class="permission-row-label">{{ child['label'] }}</td>
{% for row_key, row_letter, row_label in row_types %}
{% set perm_key = child['rows'].get(row_key) %}
<td>
{% if perm_key %}
<input type="checkbox" name="permissions" value="{{ perm_key }}"
title="{{ permission_labels.get(perm_key, perm_key) }}"
{% if perm_key in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}
class="permission-child-cb">
{% else %}
<input type="checkbox" disabled class="permission-cb-na" tabindex="-1">
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endmacro %}
{% macro permission_tree(checked_keys, readonly, compact=false) %}
<div class="permission-groups-row{{ ' permission-groups-row--compact' if compact }}">
{% for group_key, group in permission_catalog.items() %}
{{ permission_table(group, group_key, checked_keys, readonly) }}
{% endfor %}
</div>
<div class="permission-legend">
<strong>R</strong> = Read (Lesen) · <strong>W</strong> = Write (Anlegen) ·
<strong>E</strong> = Edit (Ändern, inkl. Löschen — bei Im-/Export: Import ausführen) ·
<strong>X</strong> = Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)
</div>
{% endmacro %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Gruppen {{ hi.hint_icon("Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich. „Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.", "Gruppen") }}</h2>
</div>
{% if current_user.has_permission('groups.create') %}
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Gruppe
</button>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="groupSearch" placeholder="Gruppen durchsuchen…" oninput="filterGroupsTable()">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="groupsTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Gruppe</th>
<th data-sort-key="members">Mitglieder</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody data-sort-pinned>
<tr>
<td class="cell-name">Admin <span class="pill admin">Systemrolle</span></td>
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="Rechte anzeigen" data-open-modal="adminGroupModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
</button>
</div>
</td>
</tr>
</tbody>
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
<tr>
<td class="cell-name">
{{ g.name }}
{% if g.is_system %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
</td>
<td class="text-dim">{{ g.member_names|length }}</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="{{ 'Bearbeiten' if (can_edit_this or can_unlock_system) else 'Anzeigen' }}" data-open-modal="editGroupModal{{ loop.index }}">
{% if can_edit_this or can_unlock_system %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{% endif %}
</button>
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
</button>
{% if current_user.has_permission('groups.edit') and not g.is_default and not g.is_system %}
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich endgültig löschen? Mitglieder verlieren die zugehörigen Rechte, das kann nicht rückgängig gemacht werden.">
<input type="hidden" name="delete_group" value="{{ g.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
</tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="3">Noch keine weiteren Gruppen angelegt.</td></tr>
</tbody>
{% endfor %}
</table>
</div>
</div>
<div class="modal-overlay" id="adminGroupModal">
<div class="modal" style="max-width:1000px;">
<div class="modal-header">
<h3>Admin — Rechte</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
</div>
</div>
</div>
<div class="modal-overlay" id="adminMembersModal">
<div class="modal" style="max-width:380px;">
<form method="post">
<input type="hidden" name="assign_admins" value="1">
<div class="modal-header">
<h3>Admin-Mitglieder</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-faint" style="font-size:11.5px; margin:0 0 12px;">Mindestens ein Admin muss bestehen bleiben.</p>
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
{% for u in all_users_all %}
<label class="check-row">
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['is_admin'] %}checked{% endif %}>
{{ u['username'] }}
</label>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<div class="modal-overlay" id="membersModal{{ loop.index }}">
<div class="modal" style="max-width:380px;">
<form method="post">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="members_submitted" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
<div class="modal-header">
<h3>Mitglieder — {{ g.name }}</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
{% for u in all_users %}
<label class="check-row">
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['id'] in g.members %}checked{% endif %}>
{{ u['username'] }}
</label>
{% else %}
<p class="text-faint" style="font-size:12px;">Keine Nicht-Admin-Benutzer vorhanden.</p>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="editGroupModal{{ loop.index }}">
<div class="modal" style="max-width:1000px;">
{% if can_edit_this %}
<form method="post">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<div class="modal-header">
<h3>Gruppe bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<input type="text" name="name" value="{{ g.name }}" required>
</div>
{{ permission_tree(g.permissions, false, true) }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</div>
</form>
{% elif can_unlock_system %}
<div class="modal-header">
<h3>Gruppe „{{ g.name }}“</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Name</label><input type="text" value="{{ g.name }}" disabled></div>
<div id="readonly-{{ g.id }}">
{{ permission_tree(g.permissions, true, true) }}
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px; flex-wrap:wrap;">
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
</div>
</div>
<form method="post" class="hidden" id="unlock-{{ g.id }}">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="unlock_system_group" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
{{ permission_tree(g.permissions, false, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
Rechte hier können den Erst-Login neuer Konten einschränken.
</p>
<div class="flex" style="justify-content:flex-end;">
<button type="submit" class="btn btn-primary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Rechte speichern
</button>
</div>
</form>
</div>
{% else %}
<div class="modal-header">
<h3>Gruppe „{{ g.name }}“</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
{{ permission_tree(g.permissions, true, true) }}
{% if g.is_system %}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="addGroupModal">
<div class="modal" style="max-width:1000px;">
<form method="post">
<div class="modal-header">
<h3>Neue Gruppe</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<input type="hidden" name="add_group" value="1">
<div class="field">
<label>Name {{ hi.hint_icon("Mitglieder werden danach über die Gruppentabelle zugeordnet.", "Name") }}</label>
<input type="text" name="name" required placeholder="z.B. Facility-Team">
</div>
{{ permission_tree([], false) }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function filterGroupsTable() {
const q = document.getElementById("groupSearch").value.trim().toLowerCase();
document.querySelectorAll("#groupsTable tbody").forEach(tbody => {
if (tbody.hasAttribute("data-sort-pinned")) return;
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
function unlockSystemGroup(id) {
window.confirmAction(
"Rechte der Standardgruppe „Benutzer“ wirklich bearbeiten? Diese Gruppe ist der Login-Fallback für neue Benutzer (auch neue AD/LDAP-Konten) — zu restriktive Rechte können deren Erst-Login einschränken.",
() => {
document.getElementById("readonly-" + id).classList.add("hidden");
document.getElementById("unlock-" + id).classList.remove("hidden");
},
"Standardgruppe freischalten?"
);
}
function applyPermissionGating() {
document.querySelectorAll(".permission-group-col").forEach(function (area) {
const toggle = area.querySelector(".permission-area-toggle-cb");
if (!toggle || toggle.disabled) return;
const tbody = area.querySelector("tbody");
const children = area.querySelectorAll(".permission-child-cb");
const sync = function () {
children.forEach(function (cb) {
cb.disabled = !toggle.checked;
if (!toggle.checked) cb.checked = false;
});
if (tbody) tbody.classList.toggle("permission-locked", !toggle.checked);
};
toggle.addEventListener("change", sync);
sync();
});
}
function applyRowViewPrerequisite() {
document.querySelectorAll(".permission-table tbody tr").forEach(function (tr) {
const boxes = Array.from(tr.querySelectorAll(".permission-child-cb"));
if (boxes.length < 2) return;
const viewBox = boxes[0];
const restBoxes = boxes.slice(1);
restBoxes.forEach(function (cb) {
cb.addEventListener("change", function () {
if (cb.checked && !viewBox.checked && !viewBox.disabled) {
viewBox.checked = true;
}
});
});
viewBox.addEventListener("change", function () {
if (!viewBox.checked) {
restBoxes.forEach(function (cb) { cb.checked = false; });
}
});
});
}
document.addEventListener("DOMContentLoaded", applyPermissionGating);
document.addEventListener("DOMContentLoaded", applyRowViewPrerequisite);
</script>
{% endblock %}
+101
View File
@@ -0,0 +1,101 @@
{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Dashboard{% endblock %}
{% block page_sub %}<div class="topbar-sub">Kunden-/Lizenzübersicht</div>{% endblock %}
{% block content %}
{% if licenses is none %}
<div class="card card-pad" style="max-width:560px; margin:40px auto; text-align:center;">
<p class="text-faint" style="font-size:13px;">
{% if not current_user.is_authenticated %}Bitte anmelden.{% else %}Keine Berechtigung, Lizenzen anzusehen.{% endif %}
</p>
</div>
{% else %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Lizenzen</h2>
</div>
{% if can_create %}
<a href="{{ url_for('license_issue') }}" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Lizenz ausstellen
</a>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="licenseSearch" placeholder="Lizenzen durchsuchen…" oninput="filterLicensesTable()">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="licensesTable" data-sortable>
<thead><tr>
<th data-sort-key="customer">Kunde</th>
<th data-sort-key="type">Typ</th>
<th>Module</th>
<th data-sort-key="expires">Ablauf</th>
<th data-sort-key="status">Status</th>
<th data-sort-key="heartbeat">Letzter Heartbeat</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
{% for l in licenses %}
<tbody data-sort-customer="{{ l.customer_name|lower }}" data-sort-type="{{ l.type }}"
data-sort-expires="{{ l.expires_at }}" data-sort-status="{{ l.status }}"
data-sort-heartbeat="{{ l.last_heartbeat_at or '' }}">
<tr>
<td class="cell-name">{{ l.customer_name }}</td>
<td>{{ type_labels.get(l.type, l.type) }}</td>
<td class="text-dim">{{ l.modules_list|join(', ') if l.modules_list else '—' }}</td>
<td class="text-dim">
{{ l.expires_at[:10] }}
{% if l.status == 'active' and l.days_left is not none %}
{% if l.is_expired %}<span style="color:var(--danger);">(abgelaufen)</span>
{% elif l.days_left <= 30 %}<span style="color:var(--warning);">({{ l.days_left }} Tage)</span>
{% endif %}
{% endif %}
</td>
<td>
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(l.status, 'disabled') }}">
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }}
</span>
</td>
<td class="text-dim">{{ l.last_heartbeat_at or '—' }}</td>
<td>
<div class="row-actions">
<a class="icon-btn" title="Details" href="{{ url_for('license_detail', license_id=l.license_id) }}">
<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>
</a>
</div>
</td>
</tr>
</tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="7">Noch keine Lizenzen ausgestellt.</td></tr>
</tbody>
{% endfor %}
</table>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function filterLicensesTable() {
const el = document.getElementById("licenseSearch");
if (!el) return;
const q = el.value.trim().toLowerCase();
document.querySelectorAll("#licensesTable tbody").forEach(tbody => {
if (tbody.hasAttribute("data-sort-pinned")) return;
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
</script>
{% endblock %}
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Lizenz{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ customer.name if customer else '?' }}</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Status</h2>
</div>
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(license.status, 'disabled') }}">
{{ {"issued": "Ausgestellt (nicht aktiviert)", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(license.status, license.status) }}
</span>
</div>
<div class="field"><label>Kunde</label><input type="text" value="{{ customer.name if customer else '?' }}" disabled></div>
<div class="field"><label>Lizenz-ID</label><input type="text" class="mono" value="{{ license.license_id }}" disabled></div>
<div class="field"><label>Typ</label><input type="text" value="{{ type_labels.get(license.type, license.type) }}" disabled></div>
<div class="field"><label>Module</label><input type="text" value="{{ license.modules_list|join(', ') if license.modules_list else 'keine' }}" disabled></div>
<div class="field"><label>Ausgestellt am</label><input type="text" value="{{ license.issued_at }}" disabled></div>
<div class="field">
<label>Läuft ab am</label>
<input type="text" value="{{ license.expires_at }}{% if license.days_left is not none %} ({{ license.days_left }} Tage {{ 'verbleibend' if not license.is_expired else 'überschritten' }}){% endif %}" disabled>
</div>
{% if license.fingerprint %}
<div class="field"><label>System-Fingerabdruck</label><input type="text" class="mono" value="{{ license.fingerprint }}" disabled></div>
<div class="field"><label>Aktiviert am</label><input type="text" value="{{ license.activated_at or '' }}" disabled></div>
{% endif %}
{% if license.last_heartbeat_at %}
<div class="field"><label>Letzter Heartbeat</label><input type="text" value="{{ license.last_heartbeat_at }}" disabled></div>
{% endif %}
{% if license.deactivated_at %}
<div class="field"><label>Deaktiviert am</label><input type="text" value="{{ license.deactivated_at }}" disabled></div>
{% endif %}
{% if license.revoked_at %}
<div class="field"><label>Widerrufen am</label><input type="text" value="{{ license.revoked_at }}" disabled></div>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Aktionen</h2></div>
</div>
<a href="{{ url_for('license_download', license_id=license.license_id) }}" class="btn btn-secondary btn-block" style="margin-bottom:10px;">
<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>
Lizenzdatei herunterladen
</a>
{% if can_edit and customer and customer.contact_email %}
<form method="post" action="{{ url_for('license_send_email', license_id=license.license_id) }}" style="margin-bottom:10px;">
<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="M4 4h16v16H4z"/><path d="M22 6l-10 7L2 6"/></svg>
Per E-Mail an {{ customer.contact_email }} senden
</button>
</form>
{% endif %}
{% if can_edit and license.status not in ('revoked',) %}
<form method="post" action="{{ url_for('license_revoke', license_id=license.license_id) }}"
data-confirm="Lizenz wirklich widerrufen? Der Kunde verliert den Zugriff spätestens beim nächsten Heartbeat.">
<button type="submit" class="btn btn-danger btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>
Lizenz widerrufen
</button>
</form>
{% endif %}
{% if current_user.has_permission('licenses.edit') %}
<a href="{{ url_for('license_manual_code') }}" class="btn btn-secondary btn-block" style="margin-top:10px;">Offline-Code verarbeiten</a>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Lizenz ausstellen{% endblock %}
{% block page_sub %}<div class="topbar-sub">Neue signierte Lizenz für einen Kunden erzeugen</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
{% if not master_licensed %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Dieser Lizenzserver hat selbst keine gültige Lizenz — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
Ausstellen ist erst danach wieder möglich.
</div>
{% endif %}
<form method="post">
<div class="field">
<label for="customer_id">Kunde</label>
<select name="customer_id" id="customer_id" onchange="toggleNewCustomerFields()" required>
<option value="__new__" {% if not preselect_customer_id %}selected{% endif %}>+ Neuer Kunde…</option>
{% for c in customers %}
<option value="{{ c.id }}" {% if preselect_customer_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div id="newCustomerFields" class="field-group">
<div class="field">
<label for="new_customer_name">Name (neuer Kunde)</label>
<input type="text" name="new_customer_name" id="new_customer_name" placeholder="z.B. Musterfirma GmbH">
</div>
<div class="field">
<label for="new_customer_email">E-Mail (neuer Kunde)</label>
<input type="email" name="new_customer_email" id="new_customer_email">
</div>
</div>
<div class="field">
<label for="license_type">Lizenztyp {{ hi.hint_icon("Trial: zeitlich begrenzt, voller Zugriff. Standard: alle lizenzpflichtigen Basis-Funktionen, keine Zusatzmodule. Custom: Standard + einzeln wählbare Module. Enterprise: alles.", "Lizenztyp") }}</label>
<select name="license_type" id="license_type" onchange="toggleModuleFields()" required>
{% for t in license_types %}
<option value="{{ t }}" {% if t == 'standard' %}selected{% endif %}>{{ type_labels.get(t, t) }}</option>
{% endfor %}
</select>
</div>
<div id="moduleFields" class="field hidden">
<label>Module</label>
<div class="check-list">
{% for m in all_modules %}
<label class="check-row">
<input type="checkbox" name="modules" value="{{ m }}">
{{ module_labels.get(m, m) }}
</label>
{% endfor %}
</div>
</div>
<div class="field">
<label for="valid_days">Gültigkeitsdauer (Tage) {{ hi.hint_icon("365 = 1 Jahr, 730 = 2 Jahre. Frei wählbar.", "Gültigkeitsdauer") }}</label>
<input type="number" name="valid_days" id="valid_days" value="365" min="1" required>
<div class="field-hint">
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=30">30 Tage (Trial)</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=365">1 Jahr</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=730">2 Jahre</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block" {% if not master_licensed %}disabled{% endif %}>
<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>
Lizenz ausstellen
</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleNewCustomerFields() {
const isNew = document.getElementById("customer_id").value === "__new__";
document.getElementById("newCustomerFields").classList.toggle("hidden", !isNew);
}
function toggleModuleFields() {
const type = document.getElementById("license_type").value;
document.getElementById("moduleFields").classList.toggle("hidden", type !== "custom");
}
document.addEventListener("DOMContentLoaded", function () {
toggleNewCustomerFields();
toggleModuleFields();
});
</script>
{% endblock %}
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Offline-Code verarbeiten{% endblock %}
{% block page_sub %}<div class="topbar-sub">Aktivierung/Deaktivierung/Heartbeat für Kunden ohne Netzwerkzugriff auf diesen Server</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Code vom Kunden eintragen</h2>
<div class="hint">
Der Kunde liest diesen Code auf seiner "Lizenz"-Seite ab und teilt ihn mit (Telefon/E-Mail). Nach der
Verarbeitung hier den Antwortcode zurück an den Kunden geben — der trägt ihn dort ein.
</div>
</div>
</div>
<form method="post">
<div class="field">
<label for="client_code">Code vom Kunden</label>
<textarea class="mono" name="client_code" id="client_code" rows="6" style="width:100%; resize:vertical;" required></textarea>
</div>
<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>
Verarbeiten
</button>
</form>
</div>
{% if result_code %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Antwortcode für den Kunden</h2>
<div class="hint">{{ processed_action|capitalize }} erfolgreich verarbeitet — diesen Code an den Kunden zurückgeben.</div>
</div>
</div>
<div class="field">
<textarea class="mono" rows="6" readonly onclick="this.select()" style="width:100%; resize:vertical;">{{ result_code }}</textarea>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login · TESM-Lizenzserver</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
</head>
<body>
<div class="login-page">
<div class="login-bg">
{# logo-dark-subline.svg traegt TESMs Geraete-Tagline ("Ueberwachen, Booten, PXE...") fest eingebrannt --
fuer den Lizenzserver stattdessen das reine (taglinefreie) Wortmarken-SVG verwenden. #}
<img src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM-Lizenzserver" id="login-bg-logo">
</div>
<div class="login-card">
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="field" style="margin-bottom:18px;">
{% for message in messages %}
<div class="toast danger" style="position:static; animation:none;">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" data-no-unsaved-guard>
<div class="field">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="field">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button>
</form>
<div style="margin-top:16px; font-size:10.5px; color:var(--text-faint); text-align:center;">TESM-Lizenzserver v{{ tesm_version }}</div>
</div>
</div>
<script src="{{ asset_url('js/app.js') }}"></script>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Live{% endblock %}
{% block page_sub %}<div class="topbar-sub" data-log-name>{{ log_name or "kein Logfile" }}</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Live-Log</h2>
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline). Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.</div>
</div>
<div class="flex gap-2">
{% if current_user.can_view_log_history %}
<a href="{{ url_for('logs_history') }}" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
Verlauf
</a>
{% endif %}
<button type="button" class="btn btn-secondary" onclick="openRawLogModal('{{ url_for('get_log_raw') }}', 'Komplettes Live-Log (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>
Komplettes Log (RAW)
</button>
<button id="refresh-btn" 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 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
Aktualisieren
</button>
</div>
</div>
<div class="log-shell">
<div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div>
<span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span>
<span class="text-faint mono" style="font-size:11.5px;" id="log-line-info">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen{% endif %}</span>
</div>
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
</div>
{% endblock %}
{% block scripts %}
<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", () => {
const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000;
function renderLog(text, logName, totalLines) {
const box = document.getElementById("log-box");
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;
if (logName) {
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
}
const info = document.getElementById("log-line-info");
if (info && totalLines) {
info.textContent = "letzte {{ tail_lines }} von " + totalLines + " Zeilen";
}
}
function fetchLog() {
fetch("{{ url_for('get_log') }}")
.then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name"), r.headers.get("X-Total-Lines"))))
.catch(err => console.error(err));
}
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
document.addEventListener("poe:check-triggered", fetchLog);
fetchLog();
if (intervalMilliseconds) setInterval(fetchLog, intervalMilliseconds);
});
</script>
{% endblock %}
@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Verlauf{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Log-Verlauf</h2>
<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 class="flex gap-2">
<a href="{{ url_for('logs') }}" 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="M19 12H5M12 19l-7-7 7-7"/></svg>
Zurück zur Live-Ansicht
</a>
<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_file.range_label if selected_file else '' }}', true)"
{% 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>
Komplettes Log (RAW)
</button>
</div>
</div>
{% if not files %}
<p class="text-faint" style="font-size:12.5px;">
Kein Live-Log gefunden.
</p>
{% else %}
<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;">Zeitraum:</label>
<select id="history-file-select" onchange="window.location.href=this.value;" class="mono" style="max-width:460px;">
{% for f in files %}
<option value="{{ url_for('logs_history', file=f.filename) }}" {% if f.filename == selected_name %}selected{% endif %}>
{% if f.generation == 0 %}Aktuell — {{ f.range_label }}{% else %}{{ f.range_label }}{% endif %} ({{ f.size_str }})
</option>
{% endfor %}
</select>
</div>
<div class="log-shell">
<div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div>
<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 — RAW{% endif %}</span>
</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>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const box = document.getElementById("log-box");
if (box) box.scrollTop = box.scrollHeight;
});
</script>
{% endblock %}
+372
View File
@@ -0,0 +1,372 @@
{% extends "base.html" %}
{% set active_page = "settings_system" %}
{% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}<div class="topbar-sub">Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Host</h2>
<div class="hint">Name und Zeitzone dieses Hosts.</div>
</div>
</div>
{% set can_edit_host = current_user.has_permission('settings_system.edit') %}
{% if can_edit_host %}
<form method="post">
<div class="field">
<label for="hostname">Hostname</label>
<input type="text" name="hostname" id="hostname" value="{{ current_hostname or '' }}" pattern="[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?" required>
</div>
<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>
Hostname setzen
</button>
</form>
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post">
<div class="field">
<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>
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</div>
<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>
Zeitzone setzen
</button>
</form>
{% else %}
<div class="field">
<label>Hostname</label>
<input type="text" value="{{ current_hostname or '' }}" disabled>
</div>
<div class="field">
<label>Zeitzone</label>
<input type="text" value="{{ current_timezone or '' }}" disabled>
</div>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Netzwerkeinstellungen</h2>
<div class="hint">IP/DNS/DHCP-Umschaltung dieses Hosts.</div>
</div>
</div>
<div class="flex gap-2" style="align-items:center; margin-bottom:12px; flex-wrap:wrap;">
{% if net_backend == 'unknown' %}
<span class="pill unknown">Kein unterstütztes Backend erkannt</span>
{% else %}
<span class="pill online">{{ {'networkmanager': 'NetworkManager', 'dhcpcd': 'dhcpcd', 'netplan': 'netplan'}[net_backend] }}</span>
{% endif %}
</div>
{% for state in active_net_states %}
<div class="flex gap-2" style="align-items:center; margin-bottom:8px; flex-wrap:wrap;">
<span class="mono text-faint" style="font-size:12px;">
{{ state.interface }} — {{ state.ip }}/{{ state.prefix }}{% if state.gateway %}, Gateway {{ state.gateway }}{% endif %}
</span>
<span class="pill {{ 'user' if state.mode == 'static' else ('online' if state.mode == 'dhcp' else 'unknown') }}">
{{ {'static': 'Statisch', 'dhcp': 'DHCP', 'unknown': 'Modus unbekannt'}[state.mode] }}
</span>
{% if state.dns %}
<span class="text-faint" style="font-size:12px;">DNS: <span class="mono">{{ state.dns|join(', ') }}</span></span>
{% endif %}
</div>
{% else %}
<div class="text-faint" style="font-size:12px; margin-bottom:8px;">Kein aktives Interface mit IPv4-Adresse gefunden.</div>
{% endfor %}
<div style="margin-bottom:16px;"></div>
{% if pending_network_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 Netzwerkkonfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung —
bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ net_revert_seconds }}s ohne Bestätigung).
</p>
<form method="post">
<input type="hidden" name="confirm_network" value="{{ pending_network_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 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.">
<input type="hidden" name="apply_network" value="1">
<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)">
{% for iface in net_interfaces %}
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Modus</label>
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
<option value="dhcp" {% if net_state.mode != 'static' %}selected{% endif %}>DHCP (automatisch)</option>
<option value="static" {% if net_state.mode == 'static' %}selected{% endif %}>Statisch</option>
</select>
</div>
<div id="netStaticFields" class="{{ 'hidden' if net_state.mode != 'static' }}">
<div class="field"><label>IP-Adresse</label>
<input type="text" name="net_ip" id="netIpInput" value="{{ net_state.ip if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.50">
</div>
<div class="field"><label>Prefix (CIDR-Bits)</label>
<input type="number" name="net_prefix" id="netPrefixInput" min="1" max="32" value="{{ net_state.prefix if net_state.mode == 'static' else '' }}" placeholder="z.B. 24">
</div>
<div class="field"><label>Gateway</label>
<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 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">
</div>
<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>
Netzwerkkonfiguration anwenden
</button>
<p class="text-faint" style="font-size:11px; margin-top:10px;">
⚠ Kann die Erreichbarkeit dieses Hosts unterbrechen. Ohne Bestätigung wird automatisch nach {{ net_revert_seconds }}s zurückgerollt.
</p>
</form>
{% elif net_backend == 'unknown' %}
<p class="text-faint" style="font-size:12.5px;">
Weder NetworkManager, dhcpcd noch netplan/systemd-networkd aktiv erkannt — Netzwerkänderungen über diese Seite sind deaktiviert.
Bitte die Netzwerkkonfiguration dieses Hosts manuell vornehmen.
</p>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Logs</h2>
<div class="hint">Rotation & Aufbewahrung von Live-, Änderungs- und App-Log.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<input type="hidden" name="save_log_rotation" value="1">
<div class="field">
<label for="log_rotation_interval">Rotations-Intervall</label>
<select name="log_rotation_interval" id="log_rotation_interval">
<option value="daily" {% if log_rotation_interval == "daily" %}selected{% endif %}>Täglich</option>
<option value="weekly" {% if log_rotation_interval == "weekly" %}selected{% endif %}>Wöchentlich</option>
<option value="monthly" {% if log_rotation_interval == "monthly" %}selected{% endif %}>Monatlich</option>
</select>
</div>
<div class="field">
<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>
</div>
<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>
Speichern
</button>
</form>
{% else %}
<div class="field">
<label>Rotations-Intervall</label>
<input type="text" value="{{ log_rotation_interval }}" disabled>
</div>
<div class="field">
<label>Aufbewahrung (Anzahl Rotationen)</label>
<input type="text" value="{{ log_rotation_keep }}" disabled>
</div>
{% endif %}
<div class="field-hint mono" style="margin-top:10px; font-size:11px; line-height:1.6;">
Live: {{ log_paths.live }}<br>
Änderungen: {{ log_paths.changes }}<br>
App: {{ log_paths.app }}
</div>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Anbieter &amp; Lizenzserver</h2>
<div class="hint">Wird in jede ausgestellte Kundenlizenz eingebettet (Kontakt + wo der Client aktivieren/heartbeaten soll).</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="save_vendor_settings" value="1">
<div class="field">
<label for="master_endpoint">Lizenzserver-Endpunkt (URL) {{ hi.hint_icon("Die von Kundeninstanzen aus erreichbare Basis-URL dieses Servers, z.B. https://lizenz.example.com. Wird in jede ausgestellte Lizenz eingebettet.", "Lizenzserver-Endpunkt") }}</label>
<input type="text" name="master_endpoint" id="master_endpoint" value="{{ master_endpoint }}" placeholder="https://lizenz.example.com">
</div>
<div class="field">
<label for="vendor_name">Firma</label>
<input type="text" name="vendor_name" id="vendor_name" value="{{ vendor.name }}">
</div>
<div class="field">
<label for="vendor_phone">Telefon</label>
<input type="text" name="vendor_phone" id="vendor_phone" value="{{ vendor.phone }}">
</div>
<div class="field">
<label for="vendor_email">E-Mail</label>
<input type="email" name="vendor_email" id="vendor_email" value="{{ vendor.email }}">
</div>
<div class="field">
<label for="vendor_address">Anschrift</label>
<textarea name="vendor_address" id="vendor_address" rows="2">{{ vendor.address }}</textarea>
</div>
<div class="field">
<label for="vendor_logo">Logo (optional, max. 200 KB)</label>
{% if vendor.logo_base64 %}
<div style="margin-bottom:8px;"><img src="{{ vendor.logo_base64 }}" alt="Logo" style="max-height:48px; max-width:200px;"></div>
{% endif %}
<input type="file" name="vendor_logo" id="vendor_logo" accept=".png,.jpg,.jpeg,.svg">
</div>
<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>
Speichern
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">E-Mail-Versand (Microsoft Graph)</h2>
<div class="hint">Für den direkten Versand ausgestellter Lizenzen an Kunden per E-Mail (optional -- Download funktioniert immer, auch ohne dies einzurichten).</div>
</div>
</div>
<details style="margin-bottom:14px;">
<summary style="cursor:pointer; font-size:12.5px; color:var(--text-dim);">Einrichtungsanleitung (Azure AD App-Registrierung)</summary>
<ol style="font-size:12px; color:var(--text-faint); margin:10px 0 0; padding-left:18px; line-height:1.7;">
<li>Im <a href="https://portal.azure.com" target="_blank" rel="noopener">Azure-Portal</a> unter „Azure Active Directory → App-Registrierungen“ eine neue App anlegen.</li>
<li>Unter „API-Berechtigungen“ die Anwendungsberechtigung (nicht delegiert!) <code>Mail.Send</code> für Microsoft Graph hinzufügen und per „Administratorzustimmung erteilen“ bestätigen.</li>
<li>Unter „Zertifikate &amp; Geheimnisse“ einen neuen Client-Secret-Wert erzeugen und sofort kopieren (wird nur einmal angezeigt).</li>
<li>Tenant-ID und Client-ID stehen auf der Übersichtsseite der App-Registrierung.</li>
<li>Als Absender-Postfach eine echte, lizenzierte Mailbox im selben Tenant angeben (z.B. lizenz@firma.de).</li>
<li>Optional, aber empfohlen: per Exchange-Online-„Application Access Policy“ die App-Berechtigung auf genau dieses Postfach einschränken, statt tenant-weit jede Mailbox versenden zu lassen.</li>
<li>Da dies ein reiner App-zu-App-Login ohne Benutzeranmeldung ist (Client-Credentials-Flow), spielt eine ggf. für Benutzer aktivierte MFA-Pflicht hier keine Rolle.</li>
</ol>
</details>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<input type="hidden" name="save_graph_settings" value="1">
<div class="field">
<label for="graph_tenant_id">Tenant-ID</label>
<input type="text" name="graph_tenant_id" id="graph_tenant_id" value="{{ graph.tenant_id }}">
</div>
<div class="field">
<label for="graph_client_id">Client-ID</label>
<input type="text" name="graph_client_id" id="graph_client_id" value="{{ graph.client_id }}">
</div>
<div class="field">
<label for="graph_client_secret">Client-Secret</label>
<input type="password" name="graph_client_secret" id="graph_client_secret" value="{{ graph.client_secret }}" autocomplete="off">
</div>
<div class="field">
<label for="graph_sender_mailbox">Absender-Postfach</label>
<input type="email" name="graph_sender_mailbox" id="graph_sender_mailbox" value="{{ graph.sender_mailbox }}" placeholder="lizenz@firma.de">
</div>
<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>
Speichern
</button>
</form>
<form method="post" action="{{ url_for('settings_email_test') }}" style="margin-top:10px;">
<button type="submit" class="btn btn-secondary btn-block">Verbindung testen</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</p>
{% endif %}
</div>
{% if current_user.is_admin %}
{% macro nav_order_buttons() %}
<div class="nav-order-actions">
<button type="button" class="icon-btn" title="Nach oben" onclick="moveNavItem(this,-1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</button>
<button type="button" class="icon-btn" title="Nach unten" onclick="moveNavItem(this,1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</button>
</div>
{% endmacro %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
<div class="hint">Reihenfolge der Sidebar-Menüpunkte inkl. Unterpunkte — gilt für alle Benutzer.</div>
</div>
</div>
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
<ul class="nav-order-list" id="navOrderList">
{% for item in full_nav_items %}
<li data-key="{{ item.key }}">
<div class="nav-order-row">
<span>{{ item.label }}</span>
{{ nav_order_buttons() }}
</div>
<input type="hidden" name="nav_order" value="{{ item.key }}">
{% if item.children %}
<ul class="nav-order-sublist">
{% for child in item.children %}
<li data-key="{{ child.key }}">
<div class="nav-order-row">
<span>{{ child.label }}</span>
{{ nav_order_buttons() }}
</div>
<input type="hidden" name="nav_child_order_{{ item.key }}" value="{{ child.key }}">
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
<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>
Reihenfolge speichern
</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function moveNavItem(btn, dir) {
const li = btn.closest("li");
const target = dir === -1 ? li.previousElementSibling : li.nextElementSibling;
if (!target) return;
if (dir === -1) li.parentNode.insertBefore(li, target);
else li.parentNode.insertBefore(target, li);
}
function loadNetworkState(iface) {
fetch("{{ url_for('settings_network_state') }}?interface=" + encodeURIComponent(iface))
.then(r => r.json())
.then(state => {
if (state.error) return;
const isStatic = state.mode === "static";
document.getElementById("netModeSelect").value = isStatic ? "static" : "dhcp";
document.getElementById("netStaticFields").classList.toggle("hidden", !isStatic);
document.getElementById("netIpInput").value = isStatic ? (state.ip || "") : "";
document.getElementById("netPrefixInput").value = isStatic ? (state.prefix || "") : "";
document.getElementById("netGatewayInput").value = isStatic ? (state.gateway || "") : "";
document.getElementById("netDnsInput").value = (state.dns || []).join(", ");
})
.catch(() => {});
}
</script>
{% endblock %}
@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% set active_page = "settings_importexport" %}
{% block page_title %}Im-/Export{% endblock %}
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Import</h2>
<div class="hint">
{% if import_preview %}
Datei erfolgreich gelesen — wähle aus, welche Kategorien tatsächlich eingespielt werden sollen.
{% else %}
Exportiertes Bundle einlesen — nach dem Entschlüsseln wählst du aus, was übernommen wird.
{% endif %}
</div>
</div>
</div>
{% if not current_user.has_permission('settings_importexport.edit') %}
<p class="text-faint" style="font-size:12.5px;">Für den Import fehlt das Recht „Im-/Export ändern“.</p>
{% elif import_preview %}
<form method="post" action="{{ url_for('import_apply') }}"
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 }}">
<div class="field">
<label>Was importieren?</label>
<div class="check-list">
{% for s in import_preview.sections %}
<label class="check-row">
<input type="checkbox" name="import_sections" value="{{ s.key }}"
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
{{ s.label }} <span class="text-faint">({{ s.count }})</span>
{% if export_section_hints and s.key in export_section_hints %}{{ hi.hint_icon(export_section_hints[s.key], s.label) }}{% endif %}
</label>
{% endfor %}
</div>
</div>
<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>
Ausgewählte Kategorien importieren
</button>
</form>
<a href="{{ url_for('settings_import_export') }}" class="text-faint" style="font-size:12px; display:inline-block; margin-top:10px;">Abbrechen / andere Datei wählen</a>
{% else %}
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data">
<div class="field">
<label for="import_file">Export-Datei</label>
<input type="file" name="import_file" id="import_file" accept=".json" required>
</div>
<div class="field">
<label for="import_passphrase">Passphrase</label>
<input type="password" name="import_passphrase" id="import_passphrase" required>
</div>
<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 9V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4"/><path d="M7 14l5-5 5 5"/><path d="M12 9v12"/></svg>
Datei lesen &amp; Vorschau anzeigen
</button>
</form>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Export</h2>
<div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div>
</div>
</div>
{% if current_user.has_permission('settings_importexport.export') and license_export_allowed() %}
<form method="post" action="{{ url_for('export_data') }}">
<div class="field">
<label>Was exportieren?</label>
<div class="check-list">
{% for key, label in export_sections %}
<label class="check-row">
<input type="checkbox" name="export_sections" value="{{ key }}"
{% 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 %}>
{{ label }}
{% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %}
</label>
{% endfor %}
</div>
</div>
<div class="field">
<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>
</div>
<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>
Export herunterladen
</button>
</form>
{% elif current_user.has_permission('settings_importexport.export') %}
<p class="text-faint" style="font-size:12.5px;">Für den Export ist keine gültige Lizenz vorhanden — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.</p>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,324 @@
{% extends "base.html" %}
{% set active_page = "settings_ldap" %}
{% set can_edit = current_user.has_permission('settings_ldap.edit') and license_active() %}
{% block page_title %}LDAP / Active Directory{% endblock %}
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</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;">Verbindung</h2>
<div class="hint">Server, Bind-Konto und Suchparameter für die Anbindung an AD/LDAP.</div>
</div>
</div>
{% if can_edit %}
<form method="post">
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ldap_enabled" {% if ldap.enabled %}checked{% endif %}>
<span class="track"></span>
<span>LDAP-Anmeldung aktivieren</span>
</label>
</div>
<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">
</div>
<div class="field">
<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 %}
onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;">
<span class="track"></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>
</div>
<div class="field"><label>Port</label>
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
</div>
<div class="field">
<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 %}>
<span class="track"></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>
</div>
<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">
</div>
<div class="field"><label>Bind-Passwort</label>
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
</div>
<div class="field"><label>Base-DN</label>
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
</div>
<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">
</div>
<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">
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
{% for g in ldap_groups %}
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
{% endfor %}
</select>
</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 class="flex gap-2" style="flex-wrap:wrap;">
<button type="submit" name="save_ldap" value="1" 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>
<button type="submit" name="test_ldap" value="1" class="btn btn-secondary" formnovalidate>
Verbindung testen
</button>
</div>
</form>
{% if ldap.bind_dn %}
<form method="post" data-confirm="LDAP-Bind-Konto wirklich löschen? Die LDAP-Anmeldung wird dabei deaktiviert." style="margin-top:10px;">
<input type="hidden" name="clear_ldap_bind" value="1">
<button type="submit" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);">
Bind-Konto löschen
</button>
</form>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">
{% if current_user.has_permission('settings_ldap.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.
{% endif %}
</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<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>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
<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 mappings %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>AD-Gruppe</th><th>App-Rechtegruppe</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for m in mappings %}
<tr>
<td>{{ m.ad_group_name }}<div class="text-faint mono" style="font-size:11px;">{{ m.ad_group_dn }}</div></td>
<td>{{ m.app_group_name or '—' }}</td>
<td>
{% 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?">
<input type="hidden" name="delete_ldap_group_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 Zuordnung angelegt — alle neuen AD-Benutzer erhalten nur die Standardgruppe.</p>
{% endif %}
</div>
</div>
{% if can_edit %}
<div class="modal-overlay" id="addLdapMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_ldap_group_mapping" value="1">
<div class="modal-header">
<h3>AD-Gruppenzuordnung 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="ad_group_dn" id="ldapMappingAdGroup" required style="flex:1;">
<option value="">— zuerst laden —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="ldapMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="ad_group_name" id="ldapMappingAdGroupName">
<div class="field-hint" id="ldapMappingLoadStatus">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" 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="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>
<script>
(function () {
function wireAdGroupLoader(btnId, selectId, nameFieldId, statusId, includeEmptyOption) {
var btn = document.getElementById(btnId);
var select = document.getElementById(selectId);
var nameField = nameFieldId ? document.getElementById(nameFieldId) : null;
var status = document.getElementById(statusId);
if (!btn) return;
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 …';
fetch("{{ url_for('settings_ldap_ad_groups') }}")
.then(function (r) { return r.json(); })
.then(function (groups) {
if (!Array.isArray(groups)) {
status.textContent = groups.error || 'Fehler beim Laden.';
return;
}
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 (!includeEmptyOption) select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
return;
}
groups.forEach(function (g) {
var opt = document.createElement('option');
opt.value = g.dn;
opt.textContent = g.name;
opt.dataset.name = g.name;
if (g.dn === currentValue) opt.selected = true;
select.appendChild(opt);
});
if (nameField) nameField.value = (select.options[select.selectedIndex] && select.options[select.selectedIndex].dataset.name) || '';
status.textContent = groups.length + ' Gruppe(n) geladen.';
})
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
});
select.addEventListener('change', function () {
if (!nameField) return;
var opt = select.options[select.selectedIndex];
nameField.value = (opt && opt.dataset.name) || '';
});
}
wireAdGroupLoader('ldapMappingLoadGroupsBtn', 'ldapMappingAdGroup', 'ldapMappingAdGroupName', 'ldapMappingLoadStatus', false);
wireAdGroupLoader('requiredGroupLoadBtn', 'requiredGroupSelect', null, 'requiredGroupLoadStatus', true);
wireAdGroupLoader('editLdapMappingLoadGroupsBtn', 'editLdapMappingAdGroup', 'editLdapMappingAdGroupName', 'editLdapMappingLoadStatus', 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');
}
</script>
{% endif %}
{% endblock %}
@@ -0,0 +1,179 @@
{% extends "base.html" %}
{% set active_page = "settings_license" %}
{% set can_edit = current_user.has_permission('settings_system.edit') %}
{% block page_title %}Lizenz{% endblock %}
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Status</h2>
<div class="hint">Aktuell installierte Lizenz und ihr Zustand.</div>
</div>
</div>
{% if license_error %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ license_error }}</div>
{% endif %}
{% if not license_file %}
<p class="text-faint" style="font-size:12.5px;">Keine Lizenzdatei vorhanden — bitte unten eine Lizenzdatei hochladen.</p>
{% else %}
<div class="field">
<label>Kunde</label>
<input type="text" value="{{ license_file.customer.name or '' }}" disabled>
</div>
<div class="field">
<label>Lizenztyp</label>
<input type="text" value="{{ {'trial': 'Trial', 'standard': 'Standard', 'custom': 'Custom', 'enterprise': 'Enterprise'}.get(license_file.type, license_file.type) }}" disabled>
</div>
<div class="field">
<label>Module {{ hi.hint_icon("Zusätzlich zu den immer verfügbaren Standard-Funktionen freigeschaltete Vollmodule.", "Module") }}</label>
<input type="text" value="{{ (license_file.modules or [])|join(', ') if license_file.modules else 'keine' }}" disabled>
</div>
<div class="field">
<label>Ausgestellt am</label>
<input type="text" value="{{ license_file.issued_at or '' }}" disabled>
</div>
<div class="field">
<label>Läuft ab am</label>
<input type="text" value="{{ license_file.expires_at or '' }}" disabled>
</div>
<div class="field">
<label>Aktivierungsstatus</label>
<input type="text" value="{{ 'Aktiviert' if license_is_activated else 'Nicht aktiviert' }}" disabled>
</div>
<div class="field">
<label>Letzter Heartbeat</label>
<input type="text" value="{{ license_last_heartbeat_at or 'noch nie' }}" disabled>
</div>
{% if license_last_error %}
<div class="field">
<label>Letzter Fehler</label>
<input type="text" value="{{ license_last_error }}" disabled>
</div>
{% endif %}
<div class="field">
<label>System-Fingerabdruck {{ hi.hint_icon("Eindeutiger Identifikator dieses Systems, an den die Lizenz bei der Aktivierung gebunden wird.", "System-Fingerabdruck") }}</label>
<input type="text" class="mono" value="{{ license_fingerprint }}" disabled>
</div>
{% endif %}
</div>
{% if license_file and license_valid %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Aktivierung</h2>
<div class="hint">
Ein Protokoll, zwei Wege: automatisch online, oder — falls kein Netzwerkzugriff auf den
Lizenzserver besteht — per Code manuell mit dem Lizenzserver-Administrator ausgetauscht.
</div>
</div>
</div>
{% if not can_edit %}
<p class="text-faint" style="font-size:12.5px;">Für Aktivierung/Deaktivierung fehlt das Recht „Systemeinstellungen ändern“.</p>
{% elif pending_action %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:10px;">
Offene {{ 'Aktivierungs' if pending_action.action == 'activate' else 'Deaktivierungs' }}-Anfrage — folgenden Code
beim Lizenzserver-Administrator eingeben:
</p>
<div class="field">
<textarea class="mono" rows="4" readonly onclick="this.select()" style="width:100%; resize:vertical;">{{ pending_code }}</textarea>
</div>
<form method="post" action="{{ url_for('license_activate_confirm' if pending_action.action == 'activate' else 'license_deactivate_confirm') }}">
<div class="field">
<label for="confirmation_code">Bestätigungscode vom Lizenzserver</label>
<textarea class="mono" name="confirmation_code" id="confirmation_code" rows="4" style="width:100%; resize:vertical;" required></textarea>
</div>
<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>
Code bestätigen
</button>
</form>
<form method="post" action="{{ url_for('license_activate' if pending_action.action == 'activate' else 'license_deactivate') }}" style="margin-top:10px;">
<button type="submit" class="btn btn-secondary btn-block">Erneut online versuchen</button>
</form>
{% elif license_is_activated %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:10px;">
Lizenz ist aktiviert. Bei einem Systemwechsel zuerst hier deaktivieren — danach kann der Kunde sich beim
Anbieter selbst eine neue Lizenz für das neue System ausstellen lassen.
</p>
<form method="post" action="{{ url_for('license_deactivate') }}"
data-confirm="Lizenz wirklich deaktivieren? Alle lizenzpflichtigen Module/Funktionen werden danach sofort inaktiv (Export bleibt bis zur nächsten Aktivierung möglich).">
<button type="submit" class="btn btn-danger btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>
Lizenz deaktivieren (Systemwechsel)
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('license_activate') }}">
<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>
Jetzt aktivieren
</button>
</form>
{% endif %}
</div>
{% endif %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Lizenzdatei hochladen</h2>
<div class="hint">Vom Anbieter erhaltene Lizenzdatei einspielen — muss danach noch aktiviert werden.</div>
</div>
</div>
{% if can_edit %}
<form method="post" action="{{ url_for('license_upload') }}" enctype="multipart/form-data">
<div class="field">
<label for="license_file">Lizenzdatei</label>
<input type="file" name="license_file" id="license_file" accept=".json,.lic" required>
</div>
<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="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
Hochladen
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Upload fehlt das Recht „Systemeinstellungen ändern“.</p>
{% endif %}
</div>
{% if license_file and license_file.vendor %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Anbieter</h2>
<div class="hint">Kontakt für Rückfragen zu dieser Lizenz.</div>
</div>
</div>
<div class="field">
<label>Firma</label>
<input type="text" value="{{ license_file.vendor.name or '' }}" disabled>
</div>
<div class="field">
<label>Telefon</label>
<input type="text" value="{{ license_file.vendor.phone or '' }}" disabled>
</div>
<div class="field">
<label>E-Mail</label>
<input type="text" value="{{ license_file.vendor.email or '' }}" disabled>
</div>
<div class="field">
<label>Anschrift</label>
<input type="text" value="{{ license_file.vendor.address or '' }}" disabled>
</div>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,212 @@
{% extends "base.html" %}
{% set active_page = "settings_nginx" %}
{% set can_edit = current_user.has_permission('settings_nginx.edit') and license_active() %}
{% 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;">
{% if current_user.has_permission('settings_nginx.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Änderungen fehlt das Recht „NGINX ändern“.
{% endif %}
</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;">
{% if current_user.has_permission('settings_nginx.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.
{% endif %}
</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 %}
+315
View File
@@ -0,0 +1,315 @@
{% extends "base.html" %}
{% set active_page = "users" %}
{% block page_title %}Benutzer{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Benutzer</h2>
<div class="hint">Die Gruppe bestimmt die Rechte eines Benutzers.</div>
</div>
{% if current_user.has_permission('users.create') %}
<div class="flex gap-2">
{% if ldap_enabled %}
<button type="button" class="btn btn-secondary" data-open-modal="ldapAddModal" onclick="resetLdapSearch();">
<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>
Aus Active Directory hinzufügen
</button>
{% endif %}
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neuer Benutzer
</button>
</div>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="userSearch" placeholder="Benutzer durchsuchen…" oninput="filterTable('userSearch','usersTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="usersTable" data-sortable>
<thead><tr>
<th data-sort-key="username">Username</th>
<th data-sort-key="firstname">Vorname</th>
<th data-sort-key="lastname">Nachname</th>
<th data-sort-key="group">Gruppe</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody>
{% for u in users %}
{% set group_label = 'Admin' if u['is_admin'] else (u['group_names'] or '') %}
{% set is_ldap = u['auth_source'] == 'ldap' %}
<tr data-sort-username="{{ u['username']|lower }}" data-sort-firstname="{{ (u['first_name'] or '')|lower }}" data-sort-lastname="{{ (u['last_name'] or '')|lower }}" data-sort-group="{{ group_label|lower }}">
<td class="cell-name">
{{ u['username'] }}
{% if is_ldap %}<span class="pill user" style="font-size:10px; padding:2px 7px;" title="Konto stammt aus Active Directory/LDAP, Passwort wird dort verwaltet">AD</span>{% endif %}
{% if u['is_locked'] %}<span class="pill" style="font-size:10px; padding:2px 7px; background:var(--danger-dim); color:var(--danger);" title="Login für dieses Konto ist gesperrt">Gesperrt</span>{% endif %}
{% if u['email'] %}<div class="text-faint" style="font-size:11px;">{{ u['email'] }}</div>{% endif %}
</td>
<td class="text-dim">{{ u['first_name'] or '—' }}</td>
<td class="text-dim">{{ u['last_name'] or '—' }}</td>
<td>
{% if u['is_admin'] %}
<span class="pill admin">Admin</span>
{% else %}
<span class="text-dim">{{ u['group_names'] or '—' }}</span>
{% endif %}
</td>
{% set may_touch_target = current_user.is_admin or not u['is_admin'] %}
<td>
<div class="row-actions">
{% if current_user.has_permission('users.edit') and may_touch_target and not is_ldap %}
<button class="icon-btn" title="Bearbeiten"
onclick="openEditModal({{ u['id'] }}, '{{ u['username'] }}', '{{ u['first_name'] or '' }}', '{{ u['last_name'] or '' }}', '{{ u['email'] or '' }}')">
<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>
{% endif %}
{% if current_user.has_permission('users.edit') and may_touch_target %}
<button class="icon-btn" title="Gruppe zuweisen"
onclick="openGroupModal({{ u['id'] }}, '{{ 'admin' if u['is_admin'] else (u['group_id'] or '') }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/></svg>
</button>
{% endif %}
{% if current_user.has_permission('users.edit') and may_touch_target and u['id'] != current_user.id %}
<form method="post" data-confirm="„{{ u['username'] }}“ wirklich {{ 'entsperren' if u['is_locked'] else 'sperren' }}?">
<input type="hidden" name="toggle_lock" value="{{ u['id'] }}">
<button type="submit" class="icon-btn" title="{{ 'Entsperren' if u['is_locked'] else 'Sperren' }}">
{% if u['is_locked'] %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 019.9-1"/></svg>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
{% endif %}
</button>
</form>
{% endif %}
{% if current_user.has_permission('users.edit') and may_touch_target %}
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<input type="hidden" name="delete_user" value="{{ u['id'] }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="5">Noch keine Benutzer vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="modal-overlay" id="userModal">
<div class="modal" style="max-width:1000px;">
<form method="post" id="userForm">
<div class="modal-header">
<h3>Neuen Benutzer anlegen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Vorname</label><input type="text" name="first_name"></div>
<div class="field"><label>Name</label><input type="text" name="last_name"></div>
<div class="field"><label>Username</label><input type="text" name="username" required></div>
<div class="field"><label>E-Mail</label><input type="email" name="email" placeholder="optional — ermöglicht Login per E-Mail"></div>
<div class="field"><label>Passwort</label><input type="password" name="password" required></div>
<div class="field">
<label>Gruppe</label>
<select name="group_id">
{% for g in all_groups %}<option value="{{ g['id'] }}" {% if g['is_default'] %}selected{% endif %}>{{ g['name'] }}</option>{% endfor %}
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" name="add_user" value="1" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="editModal">
<div class="modal">
<form method="post" id="editForm">
<input type="hidden" name="user_id" id="edit_user_id">
<div class="modal-header">
<h3>Benutzer bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Vorname</label><input type="text" name="first_name" id="edit_first_name"></div>
<div class="field"><label>Name</label><input type="text" name="last_name" id="edit_last_name"></div>
<div class="field"><label>Username</label><input type="text" name="username" id="edit_username" required></div>
<div class="field"><label>E-Mail</label><input type="email" name="email" id="edit_email" placeholder="optional — ermöglicht Login per E-Mail"></div>
<div class="field"><label>Neues Passwort</label>
<input type="password" name="new_password" placeholder="Nur bei Änderung ausfüllen">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" name="edit_user" value="1" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% if ldap_enabled %}
<div class="modal-overlay" id="ldapAddModal">
<div class="modal">
<form method="post" id="ldapAddForm">
<input type="hidden" name="ldap_username" id="ldap_add_username">
<div class="modal-header">
<h3>Aus Active Directory hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Suche</label>
<input type="text" id="ldapSearchInput" placeholder="Name, Benutzername oder UPN eingeben …" autocomplete="off">
<div class="field-hint" id="ldapSearchStatus">Mindestens 2 Zeichen eingeben.</div>
</div>
<div id="ldapSearchResults" style="max-height:240px; overflow-y:auto; display:flex; flex-direction:column; gap:4px;"></div>
<div class="field" id="ldapAddGroupField" style="display:none;">
<label>Gruppe für <span id="ldapAddSelectedName"></span></label>
<select name="group_id" id="ldapAddGroupSelect">
{% for g in all_groups %}<option value="{{ g['id'] }}" {% if g['is_default'] %}selected{% endif %}>{{ g['name'] }}</option>{% endfor %}
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" name="ldap_add_user" value="1" class="btn btn-primary" id="ldapAddSubmit" disabled>Hinzufügen</button>
</div>
</form>
</div>
</div>
{% endif %}
<div class="modal-overlay" id="groupModal">
<div class="modal" style="max-width:380px;">
<form method="post" id="groupForm">
<input type="hidden" name="user_id" id="group_user_id">
<div class="modal-header">
<h3>Gruppe zuweisen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Gruppe {{ hi.hint_icon("Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.", "Gruppe") }}</label>
<select name="group_id" id="group_select">
<option value="">Keine Gruppe</option>
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" name="assign_group" value="1" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
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";
});
}
function openEditModal(userId, username, firstName, lastName, email) {
document.getElementById("edit_user_id").value = userId;
document.getElementById("edit_username").value = username;
document.getElementById("edit_first_name").value = firstName;
document.getElementById("edit_last_name").value = lastName;
document.getElementById("edit_email").value = email || "";
document.querySelector("#editForm input[name='new_password']").value = "";
PoeUI.openModal("editModal");
}
function openGroupModal(userId, groupChoice) {
document.getElementById("group_user_id").value = userId;
document.getElementById("group_select").value = groupChoice || "";
PoeUI.openModal("groupModal");
}
{% if ldap_enabled %}
function resetLdapSearch() {
document.getElementById("ldapSearchInput").value = "";
document.getElementById("ldapSearchResults").innerHTML = "";
document.getElementById("ldapSearchStatus").textContent = "Mindestens 2 Zeichen eingeben.";
document.getElementById("ldapAddGroupField").style.display = "none";
document.getElementById("ldap_add_username").value = "";
document.getElementById("ldapAddSubmit").disabled = true;
}
(function () {
var input = document.getElementById("ldapSearchInput");
var results = document.getElementById("ldapSearchResults");
var status = document.getElementById("ldapSearchStatus");
if (!input) return;
var debounceTimer = null;
input.addEventListener("input", function () {
var q = input.value.trim();
clearTimeout(debounceTimer);
if (q.length < 2) {
results.innerHTML = "";
status.textContent = "Mindestens 2 Zeichen eingeben.";
return;
}
status.textContent = "Suche …";
debounceTimer = setTimeout(function () {
fetch("{{ url_for('users_ldap_search') }}?q=" + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (data) {
if (!Array.isArray(data)) {
status.textContent = data.error || "Fehler bei der Suche.";
return;
}
results.innerHTML = "";
if (!data.length) {
status.textContent = "Keine Treffer (oder bereits lokal bekannt).";
return;
}
status.textContent = data.length + " Treffer:";
data.forEach(function (u) {
var full = [u.first_name, u.last_name].filter(Boolean).join(" ");
var row = document.createElement("button");
row.type = "button";
row.className = "btn btn-secondary btn-sm";
row.style.textAlign = "left";
row.style.justifyContent = "flex-start";
row.textContent = u.username + (full ? " — " + full : "") + (u.email ? " (" + u.email + ")" : "");
row.addEventListener("click", function () {
document.getElementById("ldap_add_username").value = u.username;
document.getElementById("ldapAddSelectedName").textContent = u.username;
document.getElementById("ldapAddGroupField").style.display = "";
document.getElementById("ldapAddSubmit").disabled = false;
});
results.appendChild(row);
});
})
.catch(function () { status.textContent = "Fehler bei der Suche."; });
}, 300);
});
})();
{% endif %}
</script>
{% endblock %}
+1 -1
View File
@@ -1 +1 @@
1 2
+1 -1
View File
@@ -1 +1 @@
1.0.10 1.2.0
+1950 -70
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,
+281
View File
@@ -0,0 +1,281 @@
"""
TESM Lizenzsystem — gemeinsame Kryptographie- und Protokoll-Bausteine für
Kundeninstanz (Client) UND Lizenzserver (Master). Beide Seiten importieren
exakt dieselbe Datei (auf dem Master 1:1 mitkopiert) — Signieren und
Verifizieren müssen bitidentisch funktionieren, jede Abweichung würde
Lizenzen der jeweils anderen Seite unlesbar machen.
Vollständiges Design: siehe C:\\Users\\tim\\.claude\\plans\\toasty-twirling-hickey.md
(Phase 0). Kurzfassung der Schlüsselhierarchie:
- Master-Signaturschlüssel (Ed25519): signiert Lizenzdateien selbst UND
jede Aktivierungs-/Deaktivierungs-/Heartbeat-Antwort. Privater Teil bleibt
ausschließlich auf dem Master, öffentlicher Teil steckt in jeder
ausgestellten Lizenzdatei (ermöglicht rein OFFLINE verifizierbare
Master-Antworten beim Kunden).
- Pro-Lizenz-Schlüsselpaar (Ed25519, einmalig je Lizenz erzeugt): der
private Teil reist in der Lizenzdatei zum Kunden und signiert dessen
Aktivierungs-/Deaktivierungs-/Heartbeat-*Anfragen*; der öffentliche Teil
bleibt beim Master in dessen Datenbank (ermöglicht dem Master, jede
Anfrage einer bestimmten Lizenz zweifelsfrei zuzuordnen, ohne die
ursprüngliche Aktivierung "live" gesehen haben zu müssen).
Ed25519 statt RSA: kleine Schlüssel/Signaturen (wichtig, da Aktivierungs-
Anfragen/-Antworten für den Offline-Fall als von Hand kopierbarer Code
dargestellt werden), fest vorgegebene, sichere Parameter (keine
Padding-/Hash-Wahl wie bei RSA-PSS nötig).
"""
import base64
import datetime
import hashlib
import json
import socket
import time
import uuid
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
ALL_MODULES = ("dhcp", "fileshare", "maintenance")
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
# ============================================================== Schlüssel ==
def generate_keypair():
"""Erzeugt ein neues Ed25519-Schlüsselpaar. Rückgabe: (private_b64, public_b64)."""
priv = Ed25519PrivateKey.generate()
return _encode_private_key(priv), _encode_public_key(priv.public_key())
def _encode_private_key(priv: Ed25519PrivateKey) -> str:
return base64.urlsafe_b64encode(priv.private_bytes_raw()).decode("ascii")
def _encode_public_key(pub: Ed25519PublicKey) -> str:
return base64.urlsafe_b64encode(pub.public_bytes_raw()).decode("ascii")
def _decode_private_key(s: str) -> Ed25519PrivateKey:
return Ed25519PrivateKey.from_private_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
def _decode_public_key(s: str) -> Ed25519PublicKey:
return Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
# ===================================================== Signieren/Prüfen ==
def _canonical_bytes(payload: dict) -> bytes:
"""Deterministische JSON-Kodierung (sortierte Keys, kompakte Trenner) --
Voraussetzung dafür, dass Signieren und Verifizieren exakt dieselben
Bytes sehen, unabhängig von der Dict-Einfügereihenfolge."""
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def sign_payload(payload: dict, private_key_b64: str) -> str:
priv = _decode_private_key(private_key_b64)
return base64.urlsafe_b64encode(priv.sign(_canonical_bytes(payload))).decode("ascii")
def verify_payload(payload: dict, signature_b64: str, public_key_b64: str) -> bool:
"""Gibt bewusst nur True/False zurück (nie eine Exception nach außen) --
ein Aufrufer soll "ungültig" nie mit einem Absturz verwechseln können."""
try:
pub = _decode_public_key(public_key_b64)
pub.verify(base64.urlsafe_b64decode(signature_b64.encode("ascii")), _canonical_bytes(payload))
return True
except (InvalidSignature, ValueError, TypeError, KeyError):
return False
# ============================================================ Zeitformat ==
def _iso(ts: float) -> str:
return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_iso(s: str) -> float:
return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()
# ================================================================ Lizenz ==
def issue_license(*, customer, license_type, modules, valid_days,
master_private_key_b64, master_public_key_b64,
master_endpoint, vendor, license_id=None, now=None):
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
frischem Pro-Lizenz-Schlüsselpaar.
Rückgabe: (license_file, license_pubkey) -- license_file ist die
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
bestimmt (Verifikation künftiger Aktivierungs-/Heartbeat-Anfragen
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
unknown = set(modules) - set(ALL_MODULES)
if unknown:
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
license_priv, license_pub = generate_keypair()
now = now if now is not None else time.time()
payload = {
"license_id": license_id or str(uuid.uuid4()),
"customer": customer,
"type": license_type,
"modules": sorted(modules),
"issued_at": _iso(now),
"expires_at": _iso(now + valid_days * 86400),
"license_pubkey": license_pub,
"master_pubkey": master_public_key_b64,
"master_endpoint": master_endpoint,
"vendor": vendor,
}
signature = sign_payload(payload, master_private_key_b64)
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
return license_file, license_pub
def verify_license_file(license_file: dict) -> bool:
"""Prüft die Master-Signatur über die Lizenz-Nutzdaten. license_privkey
und signature selbst sind nicht Teil der signierten Nutzlast (die
Signatur wurde ja gerade über den Rest gebildet, siehe issue_license)."""
payload = {k: v for k, v in license_file.items() if k not in ("license_privkey", "signature")}
return verify_payload(payload, license_file.get("signature", ""), license_file.get("master_pubkey", ""))
def license_status(license_file: dict, now=None) -> dict:
"""Berechnet den aktuellen Anzeige-/Gate-Status einer (bereits als
signaturgültig geprüften!) Lizenz -- ruft NICHT selbst verify_license_file
auf, das bleibt bewusst Sache des Aufrufers, damit hier niemand versehentlich
den Status einer manipulierten Datei berechnet, ohne die Prüfung
überhaupt gemacht zu haben."""
now = now if now is not None else time.time()
expires_at = _parse_iso(license_file["expires_at"])
days_left = (expires_at - now) / 86400
expired = days_left < 0
days_since_expiry = -days_left if expired else 0.0
grace_active = expired and days_since_expiry <= GRACE_PERIOD_DAYS
modules_active = (not expired) or grace_active
return {
"expired": expired,
"days_left": days_left,
"days_since_expiry": days_since_expiry,
"expiring_soon": (not expired) and days_left <= EXPIRY_WARNING_DAYS,
"grace_active": grace_active,
"modules_active": modules_active,
"modules": set(license_file.get("modules", [])) if modules_active else set(),
"type": license_file.get("type"),
"customer": license_file.get("customer"),
"expires_at": license_file.get("expires_at"),
}
def heartbeat_stale_warning(last_heartbeat_ts, now=None) -> bool:
"""True, wenn der letzte erfolgreiche Heartbeat HEARTBEAT_WARNING_DAYS
oder länger zurückliegt. Rein informativ (siehe Moduldocstring) -- ein
fehlender/alter Heartbeat schaltet nie ein Modul ab, nur das
eingebettete Ablaufdatum selbst zählt dafür (siehe license_status)."""
if last_heartbeat_ts is None:
return False # noch nie verbunden gewesen ist der normale Ausgangszustand, kein Ausfall
now = now if now is not None else time.time()
return (now - last_heartbeat_ts) / 86400 >= HEARTBEAT_WARNING_DAYS
# ========================================== Fingerprint (System-Bindung) ==
def system_fingerprint():
"""Stabiler Identifikator dieses Hosts: /etc/machine-id (von systemd bei
der Erstinstallation einmalig erzeugt, übersteht Reboots und normale
Updates) kombiniert mit dem Hostnamen, gehasht damit die rohe
machine-id nie im Klartext übertragen/gespeichert wird. Unter
Windows/ohne /etc/machine-id fällt dies auf den Hostnamen allein
zurück (nur für lokale Tests relevant, Produktivsysteme sind Linux)."""
machine_id = ""
try:
with open("/etc/machine-id", "r", encoding="utf-8") as f:
machine_id = f.read().strip()
except OSError:
pass
raw = f"{machine_id}:{socket.gethostname()}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
# ============================ Aktivierung / Deaktivierung / Heartbeat =====
# Ein Protokoll, zwei Transportwege: online automatisch per HTTPS-API,
# offline exakt dasselbe Anfrage/Antwort-Paar als von Hand kopierbarer Code
# (encode_code/decode_code) -- der Master ist dadurch in BEIDEN Fällen die
# einzige Quelle der Wahrheit dafür, an welches System eine Lizenz gerade
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
# verhindert".
def build_client_request(action, license_file, nonce=None) -> dict:
"""Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt."""
if action not in ("activate", "deactivate", "heartbeat"):
raise ValueError(f"Unbekannte Aktion: {action!r}")
payload = {
"license_id": license_file["license_id"],
"fingerprint": system_fingerprint(),
"action": action,
"nonce": nonce or uuid.uuid4().hex,
"timestamp": _iso(time.time()),
}
signature = sign_payload(payload, license_file["license_privkey"])
return {**payload, "signature": signature}
def verify_client_request(request: dict, license_public_key_b64: str) -> bool:
"""Vom MASTER aufgerufen: prüft eine Client-Anfrage gegen den bei
Ausstellung dieser Lizenz in der Master-DB gespeicherten Public-Key."""
payload = {k: v for k, v in request.items() if k != "signature"}
return verify_payload(payload, request.get("signature", ""), license_public_key_b64)
def build_master_response(action, license_id, fingerprint, master_private_key_b64,
status="ok", license_update=None, now=None) -> dict:
"""Vom MASTER aufgerufen: erzeugt eine signierte Antwort/Bestätigung
(z.B. "activated", "deactivated", oder das Ergebnis eines Heartbeats).
license_update: optional eine komplette, neu signierte Lizenzdatei
(z.B. nach nachträglicher Modul-Änderung durch den Admin) -- der Client
übernimmt sie nur nach eigener, erfolgreicher Signaturprüfung."""
payload = {
"license_id": license_id,
"fingerprint": fingerprint,
"action": action,
"status": status,
"timestamp": _iso(now if now is not None else time.time()),
}
if license_update is not None:
payload["license_update"] = license_update
signature = sign_payload(payload, master_private_key_b64)
return {**payload, "signature": signature}
def verify_master_response(response: dict, master_public_key_b64: str) -> bool:
"""Vom CLIENT aufgerufen: prüft eine Master-Antwort komplett OFFLINE
gegen den in der eigenen Lizenzdatei eingebetteten master_pubkey."""
payload = {k: v for k, v in response.items() if k != "signature"}
return verify_payload(payload, response.get("signature", ""), master_public_key_b64)
# ==================================================== Codes für Offline ==
def encode_code(data: dict) -> str:
"""Kodiert ein Anfrage-/Antwort-dict als kompakten, per Copy-Paste
übertragbaren Code (Base64 einer kanonischen JSON-Darstellung, keine
Zeilenumbrüche/Sonderzeichen, damit Kopieren aus/in ein Textfeld nichts
kaputt macht)."""
return base64.urlsafe_b64encode(_canonical_bytes(data)).decode("ascii")
def decode_code(code: str) -> dict:
return json.loads(base64.urlsafe_b64decode(code.strip().encode("ascii")).decode("utf-8"))
+193
View File
@@ -446,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;
@@ -498,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 {
@@ -516,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;
@@ -556,6 +574,22 @@ button { font-family: inherit; }
70% { box-shadow: 0 0 0 6px rgba(47,208,122,0); } 70% { box-shadow: 0 0 0 6px rgba(47,208,122,0); }
100% { box-shadow: 0 0 0 0 rgba(47,208,122,0); } 100% { box-shadow: 0 0 0 0 rgba(47,208,122,0); }
} }
/* Lizenz-Topbar-Pill: pulse-dot ist fest auf Grün (Erfolg) verdrahtet --
eigene Varianten für die rot/orange blinkenden Lizenz-Zustände, sonst
würde die Halo-Animation weiter grün pulsieren, egal welche
Hintergrundfarbe der Punkt selbst per Inline-Style bekommt. */
.timer-pill .dot.dot--blink-danger { animation: pulse-dot-danger 1.4s infinite; }
.timer-pill .dot.dot--blink-warning { animation: pulse-dot-warning 1.4s infinite; }
@keyframes pulse-dot-danger {
0% { box-shadow: 0 0 0 0 rgba(240,71,92,0.5); }
70% { box-shadow: 0 0 0 6px rgba(240,71,92,0); }
100% { box-shadow: 0 0 0 0 rgba(240,71,92,0); }
}
@keyframes pulse-dot-warning {
0% { box-shadow: 0 0 0 0 rgba(245,166,35,0.5); }
70% { box-shadow: 0 0 0 6px rgba(245,166,35,0); }
100% { box-shadow: 0 0 0 0 rgba(245,166,35,0); }
}
.timer-pill-refresh { .timer-pill-refresh {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
@@ -950,6 +984,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); }
@@ -1146,6 +1222,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)
========================================================================== */ ========================================================================== */
@@ -1292,6 +1393,92 @@ select {
} }
.xterm-container .xterm { height: 100%; } .xterm-container .xterm { height: 100%; }
/* ==========================================================================
Fileshare (Baum-Navigation + Vorschau)
========================================================================== */
/* align-items:stretch (statt flex-start) + die main-Seite selbst als
Flex-Spalte mit table-wrap{flex:1}, damit beide Kacheln (Baum links,
Tabelle rechts) immer gleich hoch sind, unabhängig davon welche Seite
gerade mehr Inhalt hat. */
.fileshare-layout { display: flex; align-items: stretch; gap: 16px; }
.fileshare-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.fileshare-main .table-wrap { flex: 1; }
.fileshare-tree {
flex: 0 0 260px;
max-width: 260px;
padding: 14px;
overflow-y: auto;
}
.fileshare-tree-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
font-weight: 650;
margin-bottom: 8px;
}
.tree-root, .tree-children { list-style: none; margin: 0; padding: 0; }
.tree-children { padding-left: 16px; }
.tree-row {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 6px;
border-radius: 7px;
}
.tree-row:hover { background: var(--bg-card-hover); }
.tree-row.active { background: var(--bg-card-hover); color: var(--accent); font-weight: 600; }
.tree-toggle {
width: 18px; height: 18px;
flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
border: none; background: transparent; color: var(--text-faint);
font-size: 10px;
cursor: pointer;
padding: 0;
}
.tree-toggle:hover { color: var(--text); }
.tree-toggle:disabled { visibility: hidden; }
.tree-label {
font-size: 13px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Vorschau: von mammoth.js/SheetJS erzeugter bzw. selbst gebauter Inhalt */
.docx-preview { font-size: 14px; line-height: 1.65; }
.docx-preview table { border-collapse: collapse; margin: 10px 0; }
.docx-preview table td, .docx-preview table th { border: 1px solid var(--border); padding: 6px 10px; }
.docx-preview img { max-width: 100%; }
.xlsx-preview-sheet-title { margin: 20px 0 8px; font-size: 13px; font-weight: 650; }
.xlsx-preview-sheet-title:first-child { margin-top: 0; }
/* Angesammelte Dateien im Upload-Modal (Mehrfachauswahl, siehe fileshare.html) */
.upload-file-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.upload-file-row {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 5px 10px;
background: var(--bg-elevated);
border: 1px solid var(--border-soft);
border-radius: 7px;
font-size: 12.5px;
}
.upload-file-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload-file-remove {
flex-shrink: 0;
border: none; background: transparent; color: var(--text-faint);
font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px;
}
.upload-file-remove:hover { color: var(--danger); }
/* ========================================================================== /* ==========================================================================
Utilities Utilities
========================================================================== */ ========================================================================== */
@@ -1336,6 +1523,12 @@ select {
Buttons/Suchfeld ineinanderzuschieben. */ Buttons/Suchfeld ineinanderzuschieben. */
.modal-footer { flex-wrap: wrap; } .modal-footer { flex-wrap: wrap; }
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; } .table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
/* Fileshare-Baum + Tabelle nebeneinander sprengt auf Tablet-/Handy-
Breite die Seite (Baum-Spalte ist fest 260px breit) -- Baum stapelt
stattdessen oben, Tabelle darunter in voller Breite. */
.fileshare-layout { flex-direction: column; }
.fileshare-tree { flex: 1 1 auto; max-width: 100%; max-height: 240px; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
+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 %}
+209 -60
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>
</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>
{% 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 %}
+7
View File
@@ -22,6 +22,7 @@
"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"/>', "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"/>', "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"/>',
@@ -128,6 +129,12 @@
</button> </button>
{% endif %} {% endif %}
</span> </span>
{% if license_topbar %}
<a href="{{ url_for('settings_license') }}" class="timer-pill" title="{{ license_topbar.text }}" style="text-decoration:none;">
<span class="dot {% if license_topbar.blink %}dot--blink-{{ license_topbar.level }}{% endif %}"
style="{% if not license_topbar.blink %}animation:none; box-shadow:none;{% endif %} background:{{ 'var(--danger)' if license_topbar.level == 'danger' else 'var(--warning)' }};"></span>{{ license_topbar.text }}
</a>
{% endif %}
{% if dhcp_topbar_active is not none %} {% if dhcp_topbar_active is not none %}
<span class="timer-pill" title="DHCP-Dienst: {{ 'aktiv' if dhcp_topbar_active else 'inaktiv' }}"> <span class="timer-pill" title="DHCP-Dienst: {{ 'aktiv' if dhcp_topbar_active else 'inaktiv' }}">
<span class="dot" style="animation:none; background:{{ 'var(--success)' if dhcp_topbar_active else 'var(--danger)' }}; box-shadow:none;"></span>DHCP <span class="dot" style="animation:none; background:{{ 'var(--success)' if dhcp_topbar_active else 'var(--danger)' }}; box-shadow:none;"></span>DHCP
+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");
+51 -1
View File
@@ -4,11 +4,13 @@
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein 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">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">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_file.range_label if selected_file else '' }}')" 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)
@@ -50,6 +52,54 @@
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre> <pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
</div> </div>
{% endif %} {% 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>
{% endif %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
+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>
+12 -5
View File
@@ -17,6 +17,13 @@
</div> </div>
</div> </div>
{% if not license_active() %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Wiederherstellen und endgültiges Löschen sind nur mit gültiger Lizenz verfügbar — siehe
<a href="{{ url_for('settings_license') }}">Lizenz</a>. Ansehen ist weiterhin uneingeschränkt möglich.
</div>
{% endif %}
<div style="display:flex; flex-direction:column; gap:16px;"> <div style="display:flex; flex-direction:column; gap:16px;">
<div class="card card-pad"> <div class="card card-pad">
@@ -44,7 +51,7 @@
<td class="mono">{{ d['ip'] }}</td> <td class="mono">{{ d['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td>
<td> <td>
{% if current_user.has_permission('papierkorb.edit') %} {% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('restore_device', mac=d['mac']) }}"> <form method="post" action="{{ url_for('restore_device', mac=d['mac']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button> <button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -92,7 +99,7 @@
<td class="mono">{{ s['ip'] }}</td> <td class="mono">{{ s['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td>
<td> <td>
{% if current_user.has_permission('papierkorb.edit') %} {% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('restore_switch', hostname=s['hostname']) }}"> <form method="post" action="{{ url_for('restore_switch', hostname=s['hostname']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button> <button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -140,7 +147,7 @@
<td class="mono">{{ c['username'] }}</td> <td class="mono">{{ c['username'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td>
<td> <td>
{% if current_user.has_permission('papierkorb.edit') %} {% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('restore_credential', cred_id=c['id']) }}"> <form method="post" action="{{ url_for('restore_credential', cred_id=c['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button> <button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -189,7 +196,7 @@
<td class="text-dim">{{ full_name or '—' }}</td> <td class="text-dim">{{ full_name or '—' }}</td>
<td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td>
<td> <td>
{% if current_user.has_permission('papierkorb.edit') %} {% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('restore_user', user_id=u['id']) }}"> <form method="post" action="{{ url_for('restore_user', user_id=u['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button> <button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -235,7 +242,7 @@
<td class="cell-name">{{ g['name'] }}</td> <td class="cell-name">{{ g['name'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td>
<td> <td>
{% if current_user.has_permission('papierkorb.edit') %} {% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('restore_group', group_id=g['id']) }}"> <form method="post" action="{{ url_for('restore_group', group_id=g['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button> <button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
+29 -24
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">
@@ -13,7 +14,8 @@
<div class="hint">Name und Zeitzone dieses Hosts.</div> <div class="hint">Name und Zeitzone dieses Hosts.</div>
</div> </div>
</div> </div>
{% if current_user.has_permission('settings_system.edit') %} {% set can_edit_host = current_user.has_permission('settings_system.edit') %}
{% if can_edit_host %}
<form method="post"> <form method="post">
<div class="field"> <div class="field">
<label for="hostname">Hostname</label> <label for="hostname">Hostname</label>
@@ -27,31 +29,18 @@
<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>
Zeitzone setzen Zeitzone setzen
</button> </button>
</form> </form>
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post">
<div class="field">
<label for="interval">Prüfintervall (Minuten)</label>
<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>
<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>
Speichern & Service neustarten
</button>
</form>
{% else %} {% else %}
<div class="field"> <div class="field">
<label>Hostname</label> <label>Hostname</label>
@@ -61,10 +50,30 @@
<label>Zeitzone</label> <label>Zeitzone</label>
<input type="text" value="{{ current_timezone or '' }}" disabled> <input type="text" value="{{ current_timezone or '' }}" disabled>
</div> </div>
{% endif %}
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
{% if can_edit_host and license_active() %}
<form method="post">
<div class="field">
<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>
</div>
<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>
Speichern & Service neustarten
</button>
</form>
{% else %}
<div class="field"> <div class="field">
<label>Prüfintervall (Minuten)</label> <label>Prüfintervall (Minuten)</label>
<input type="number" value="{{ interval }}" disabled> <input type="number" value="{{ interval }}" disabled>
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div> <div class="field-hint">
{% if not can_edit_host %}
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
{% else %}
Nur mit gültiger Lizenz änderbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% endif %}
</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -118,13 +127,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 +151,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 +191,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 +227,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">
+7 -14
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">
@@ -34,14 +35,10 @@
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %} {% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}> {% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
{{ s.label }} <span class="text-faint">({{ s.count }})</span> {{ s.label }} <span class="text-faint">({{ s.count }})</span>
{% if s.admin_only %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %} {% if export_section_hints and s.key in export_section_hints %}{{ hi.hint_icon(export_section_hints[s.key], s.label) }}{% endif %}
</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>
@@ -74,7 +71,7 @@
<div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div> <div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div>
</div> </div>
</div> </div>
{% if current_user.has_permission('settings_importexport.export') %} {% if current_user.has_permission('settings_importexport.export') and license_export_allowed() %}
<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?</label>
@@ -85,26 +82,22 @@
{% 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 key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %} {% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% 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>
Export herunterladen Export herunterladen
</button> </button>
</form> </form>
{% elif current_user.has_permission('settings_importexport.export') %}
<p class="text-faint" style="font-size:12.5px;">Für den Export ist keine gültige Lizenz vorhanden — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.</p>
{% else %} {% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p> <p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
{% endif %} {% endif %}
+286 -61
View File
@@ -1,10 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "settings_ldap" %} {% set active_page = "settings_ldap" %}
{% set can_edit = current_user.has_permission('settings_ldap.edit') %} {% set can_edit = current_user.has_permission('settings_ldap.edit') and license_active() %}
{% block page_title %}LDAP / Active Directory{% endblock %} {% block page_title %}LDAP / Active Directory{% endblock %}
{% 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 %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid settings-grid--wide"> <div class="settings-grid settings-grid--wide">
<div class="card card-pad"> <div class="card card-pad">
@@ -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">
@@ -89,19 +96,20 @@
</form> </form>
{% endif %} {% endif %}
{% else %} {% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.</p> <p class="text-faint" style="font-size:12.5px;">
{% if current_user.has_permission('settings_ldap.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.
{% endif %}
</p>
{% endif %} {% endif %}
</div> </div>
<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 +131,18 @@
<td>{{ m.app_group_name or '—' }}</td> <td>{{ m.app_group_name or '—' }}</td>
<td> <td>
{% if can_edit %} {% if can_edit %}
<form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?"> <div class="row-actions">
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}"> <button type="button" class="icon-btn" title="Bearbeiten"
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen"> 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="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="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
</button> </button>
</form> <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 }}">
<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 %} {% endif %}
</td> </td>
</tr> </tr>
@@ -142,6 +156,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,47 +248,207 @@
</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;
if (!btn) return; var status = document.getElementById(statusId);
if (!btn) return;
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
status.textContent = 'Lade Gruppen …'; // Aktuellen Wert (z.B. die vorher gespeicherte, noch nicht per
fetch("{{ url_for('settings_ldap_ad_groups') }}") // Klartext-Namen aufgeloeste Gruppe) merken -- bleibt nach dem
.then(function (r) { return r.json(); }) // Neuaufbau der Optionsliste ausgewaehlt, falls sie unter den
.then(function (groups) { // geladenen Gruppen auftaucht.
if (!Array.isArray(groups)) { var currentValue = select.value;
status.textContent = groups.error || 'Fehler beim Laden.'; status.textContent = 'Lade Gruppen …';
return; fetch("{{ url_for('settings_ldap_ad_groups') }}")
} .then(function (r) { return r.json(); })
select.innerHTML = ''; .then(function (groups) {
if (!groups.length) { if (!Array.isArray(groups)) {
select.innerHTML = '<option value="">Keine Gruppen gefunden</option>'; status.textContent = groups.error || 'Fehler beim Laden.';
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.'; return;
return; }
} select.innerHTML = '';
groups.forEach(function (g) { if (includeEmptyOption) {
var opt = document.createElement('option'); var emptyOpt = document.createElement('option');
opt.value = g.dn; emptyOpt.value = '';
opt.textContent = g.name; emptyOpt.textContent = 'Keine (jeder gültige AD-Benutzer darf sich anmelden)';
opt.dataset.name = g.name; select.appendChild(emptyOpt);
select.appendChild(opt); }
}); if (!groups.length) {
nameField.value = select.options[select.selectedIndex].dataset.name || ''; if (!includeEmptyOption) select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
status.textContent = groups.length + ' Gruppe(n) geladen.'; status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
}) return;
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; }); }
}); groups.forEach(function (g) {
var opt = document.createElement('option');
opt.value = g.dn;
opt.textContent = g.name;
opt.dataset.name = g.name;
if (g.dn === currentValue) opt.selected = true;
select.appendChild(opt);
});
if (nameField) nameField.value = (select.options[select.selectedIndex] && select.options[select.selectedIndex].dataset.name) || '';
status.textContent = groups.length + ' Gruppe(n) geladen.';
})
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
});
select.addEventListener('change', function () { select.addEventListener('change', function () {
var opt = select.options[select.selectedIndex]; if (!nameField) return;
nameField.value = (opt && opt.dataset.name) || ''; var opt = select.options[select.selectedIndex];
}); 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 %}
+179
View File
@@ -0,0 +1,179 @@
{% extends "base.html" %}
{% set active_page = "settings_license" %}
{% set can_edit = current_user.has_permission('settings_system.edit') %}
{% block page_title %}Lizenz{% endblock %}
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Status</h2>
<div class="hint">Aktuell installierte Lizenz und ihr Zustand.</div>
</div>
</div>
{% if license_error %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ license_error }}</div>
{% endif %}
{% if not license_file %}
<p class="text-faint" style="font-size:12.5px;">Keine Lizenzdatei vorhanden — bitte unten eine Lizenzdatei hochladen.</p>
{% else %}
<div class="field">
<label>Kunde</label>
<input type="text" value="{{ license_file.customer.name or '' }}" disabled>
</div>
<div class="field">
<label>Lizenztyp</label>
<input type="text" value="{{ {'trial': 'Trial', 'standard': 'Standard', 'custom': 'Custom', 'enterprise': 'Enterprise'}.get(license_file.type, license_file.type) }}" disabled>
</div>
<div class="field">
<label>Module {{ hi.hint_icon("Zusätzlich zu den immer verfügbaren Standard-Funktionen freigeschaltete Vollmodule.", "Module") }}</label>
<input type="text" value="{{ (license_file.modules or [])|join(', ') if license_file.modules else 'keine' }}" disabled>
</div>
<div class="field">
<label>Ausgestellt am</label>
<input type="text" value="{{ license_file.issued_at or '' }}" disabled>
</div>
<div class="field">
<label>Läuft ab am</label>
<input type="text" value="{{ license_file.expires_at or '' }}" disabled>
</div>
<div class="field">
<label>Aktivierungsstatus</label>
<input type="text" value="{{ 'Aktiviert' if license_is_activated else 'Nicht aktiviert' }}" disabled>
</div>
<div class="field">
<label>Letzter Heartbeat</label>
<input type="text" value="{{ license_last_heartbeat_at or 'noch nie' }}" disabled>
</div>
{% if license_last_error %}
<div class="field">
<label>Letzter Fehler</label>
<input type="text" value="{{ license_last_error }}" disabled>
</div>
{% endif %}
<div class="field">
<label>System-Fingerabdruck {{ hi.hint_icon("Eindeutiger Identifikator dieses Systems, an den die Lizenz bei der Aktivierung gebunden wird.", "System-Fingerabdruck") }}</label>
<input type="text" class="mono" value="{{ license_fingerprint }}" disabled>
</div>
{% endif %}
</div>
{% if license_file and license_valid %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Aktivierung</h2>
<div class="hint">
Ein Protokoll, zwei Wege: automatisch online, oder — falls kein Netzwerkzugriff auf den
Lizenzserver besteht — per Code manuell mit dem Lizenzserver-Administrator ausgetauscht.
</div>
</div>
</div>
{% if not can_edit %}
<p class="text-faint" style="font-size:12.5px;">Für Aktivierung/Deaktivierung fehlt das Recht „Systemeinstellungen ändern“.</p>
{% elif pending_action %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:10px;">
Offene {{ 'Aktivierungs' if pending_action.action == 'activate' else 'Deaktivierungs' }}-Anfrage — folgenden Code
beim Lizenzserver-Administrator eingeben:
</p>
<div class="field">
<textarea class="mono" rows="4" readonly onclick="this.select()" style="width:100%; resize:vertical;">{{ pending_code }}</textarea>
</div>
<form method="post" action="{{ url_for('license_activate_confirm' if pending_action.action == 'activate' else 'license_deactivate_confirm') }}">
<div class="field">
<label for="confirmation_code">Bestätigungscode vom Lizenzserver</label>
<textarea class="mono" name="confirmation_code" id="confirmation_code" rows="4" style="width:100%; resize:vertical;" required></textarea>
</div>
<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>
Code bestätigen
</button>
</form>
<form method="post" action="{{ url_for('license_activate' if pending_action.action == 'activate' else 'license_deactivate') }}" style="margin-top:10px;">
<button type="submit" class="btn btn-secondary btn-block">Erneut online versuchen</button>
</form>
{% elif license_is_activated %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:10px;">
Lizenz ist aktiviert. Bei einem Systemwechsel zuerst hier deaktivieren — danach kann der Kunde sich beim
Anbieter selbst eine neue Lizenz für das neue System ausstellen lassen.
</p>
<form method="post" action="{{ url_for('license_deactivate') }}"
data-confirm="Lizenz wirklich deaktivieren? Alle lizenzpflichtigen Module/Funktionen werden danach sofort inaktiv (Export bleibt bis zur nächsten Aktivierung möglich).">
<button type="submit" class="btn btn-danger btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>
Lizenz deaktivieren (Systemwechsel)
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('license_activate') }}">
<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>
Jetzt aktivieren
</button>
</form>
{% endif %}
</div>
{% endif %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Lizenzdatei hochladen</h2>
<div class="hint">Vom Anbieter erhaltene Lizenzdatei einspielen — muss danach noch aktiviert werden.</div>
</div>
</div>
{% if can_edit %}
<form method="post" action="{{ url_for('license_upload') }}" enctype="multipart/form-data">
<div class="field">
<label for="license_file">Lizenzdatei</label>
<input type="file" name="license_file" id="license_file" accept=".json,.lic" required>
</div>
<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="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
Hochladen
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Upload fehlt das Recht „Systemeinstellungen ändern“.</p>
{% endif %}
</div>
{% if license_file and license_file.vendor %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Anbieter</h2>
<div class="hint">Kontakt für Rückfragen zu dieser Lizenz.</div>
</div>
</div>
<div class="field">
<label>Firma</label>
<input type="text" value="{{ license_file.vendor.name or '' }}" disabled>
</div>
<div class="field">
<label>Telefon</label>
<input type="text" value="{{ license_file.vendor.phone or '' }}" disabled>
</div>
<div class="field">
<label>E-Mail</label>
<input type="text" value="{{ license_file.vendor.email or '' }}" disabled>
</div>
<div class="field">
<label>Anschrift</label>
<input type="text" value="{{ license_file.vendor.address or '' }}" disabled>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+20 -14
View File
@@ -1,10 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "settings_nginx" %} {% set active_page = "settings_nginx" %}
{% set can_edit = current_user.has_permission('settings_nginx.edit') %} {% set can_edit = current_user.has_permission('settings_nginx.edit') and license_active() %}
{% block page_title %}NGINX{% endblock %} {% block page_title %}NGINX{% endblock %}
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid settings-grid--wide"> <div class="settings-grid settings-grid--wide">
<div class="card card-pad"> <div class="card card-pad">
@@ -38,9 +39,8 @@
<form method="post" <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."> 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"> <div class="field">
<label for="server_name">Domain (server_name)</label> <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"> <input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
<div class="field-hint">"_" 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.</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<div class="field field--half"> <div class="field field--half">
@@ -48,14 +48,10 @@
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required> <input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
</div> </div>
<div class="field field--half"> <div class="field field--half">
<label for="https_port">HTTPS-Port</label> <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> <input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
</div> </div>
</div> </div>
<div class="field-hint" style="margin-top:-8px; margin-bottom:14px;">
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.
</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="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}> <input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
@@ -68,9 +64,8 @@
<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="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}> <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 class="track"></span>
<span>HSTS (Strict-Transport-Security)</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> </label>
<div class="field-hint">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.</div>
</div> </div>
<button type="submit" name="apply_nginx" value="1" class="btn btn-primary btn-block"> <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> <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>
@@ -78,7 +73,13 @@
</button> </button>
</form> </form>
{% else %} {% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „NGINX ändern“.</p> <p class="text-faint" style="font-size:12.5px;">
{% if current_user.has_permission('settings_nginx.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Änderungen fehlt das Recht „NGINX ändern“.
{% endif %}
</p>
{% endif %} {% endif %}
</div> </div>
@@ -165,9 +166,8 @@
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required> <input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
</div> </div>
<div class="field"> <div class="field">
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt)</label> <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> <input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
<div class="field-hint">Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.</div>
</div> </div>
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block"> <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> <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>
@@ -183,7 +183,13 @@
<p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p> <p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p>
{% endif %} {% endif %}
{% else %} {% else %}
<p class="text-faint" style="font-size:12.5px;">Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.</p> <p class="text-faint" style="font-size:12.5px;">
{% if current_user.has_permission('settings_nginx.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.
{% endif %}
</p>
{% endif %} {% endif %}
</div> </div>
+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">
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# ============================================================================
# TESM-Lizenzserver — Update/Reinstall (Bootstrapper)
# Fork von update.sh (TESM selbst) -- lädt dasselbe gepackte Gitea-Release
# herunter (das Repo enthält beide Apps: srv/tesm/ UND srv/tesm-license/)
# und übergibt an install-license.sh aus dem Paket, statt install.sh.
#
# Auszuführen als root auf dem Zielsystem:
# sudo ./update-license.sh
# Optional übersteuerbar (z.B. für eine bestimmte Version statt "latest"):
# TESM_RELEASE_TAG=v1.0.0 sudo -E ./update-license.sh
# ============================================================================
set -e
GITEA_BASE="${TESM_GITEA_BASE:-https://gitea.int.eertmoed.net}"
REPO_OWNER="${TESM_REPO_OWNER:-alientim}"
REPO_NAME="${TESM_REPO_NAME:-tesm}"
RELEASE_TAG="${TESM_RELEASE_TAG:-latest}"
GITEA_USER="${TESM_GITEA_USER:-}"
GITEA_TOKEN="${TESM_GITEA_TOKEN:-}"
PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz"
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
WORK_DIR="/tmp/tesm-license-update-$(date +%s)"
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
if [ "$(id -u)" -ne 0 ]; then
echo "Bitte als root ausführen (sudo ./update-license.sh)." >&2
exit 1
fi
echo -e "${YELLOW}============================================================================${NC}"
echo -e "${YELLOW} TESM-Lizenzserver Update/Reinstall — Release \"${RELEASE_TAG}\"${NC}"
echo -e "${YELLOW}============================================================================${NC}"
echo
echo "Lädt das Release-Paket herunter (enthält TESM und den Lizenzserver"
echo "zusammen) und übergibt Installation/Update an install-license.sh"
echo "daraus -- deployt ausschließlich srv/tesm-license/, lässt eine"
echo "eventuell auf demselben Host vorhandene TESM-Installation unberührt."
echo
read -r -p "Fortfahren? [y/N] " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "Abgebrochen — nichts wurde verändert."
exit 0
fi
echo -e "${RED}${NC} Lade Paket von ${DOWNLOAD_URL}..."
mkdir -p "$WORK_DIR"
CURL_AUTH=()
if [ -n "$GITEA_USER" ] && [ -n "$GITEA_TOKEN" ]; then
CURL_AUTH=(-u "${GITEA_USER}:${GITEA_TOKEN}")
fi
curl -fsSL "${CURL_AUTH[@]}" -o "$PACKAGE_FILE" "$DOWNLOAD_URL"
echo -e "${GREEN}${NC} Paket heruntergeladen ($(du -h "$PACKAGE_FILE" | cut -f1))."
echo -e "${RED}${NC} Entpacke Paket..."
mkdir -p "$WORK_DIR/pkg"
tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1
echo -e "${GREEN}${NC} Paket entpackt."
echo -e "${RED}${NC} Starte install-license.sh aus dem Paket..."
bash "$WORK_DIR/pkg/install-license.sh"
echo
echo -e "${GREEN}============================================================================${NC}"
echo -e "${GREEN} TESM-Lizenzserver Update/Reinstall (Release \"${RELEASE_TAG}\") abgeschlossen.${NC}"
echo -e "${GREEN}============================================================================${NC}"
+22 -1
View File
@@ -80,6 +80,7 @@ EOF
function check_device() { function check_device() {
local rpi_ip=$1 dev_name=$2 switch_ip=$3 switch_ssh_port=$4 local rpi_ip=$1 dev_name=$2 switch_ip=$3 switch_ssh_port=$4
local switch_hostname=$5 switch_port=$6 switch_user=$7 switch_pass=$8 local switch_hostname=$5 switch_port=$6 switch_user=$7 switch_pass=$8
local license_active=$9
if ping -c 1 -W 2 "$rpi_ip" &> /dev/null; then if ping -c 1 -W 2 "$rpi_ip" &> /dev/null; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist erreichbar!" >> "$LOGFILE" echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist erreichbar!" >> "$LOGFILE"
@@ -92,6 +93,15 @@ function check_device() {
return return
fi fi
# Automatischer PoE-Neustart bei Ausfall ist ein lizenzpflichtiges
# Feature (siehe Lizenzsystem) -- der manuelle Neustart-Button im
# Dashboard (manual_restart() weiter unten) ruft disable_poe/enable_poe
# dagegen IMMER direkt auf und bleibt davon bewusst unberührt.
if [ "$license_active" != "1" ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: automatischer PoE-Neustart übersprungen -- keine gültige Lizenz." >> "$LOGFILE"
return
fi
if ! disable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass" "$switch_ssh_port"; then if ! disable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass" "$switch_ssh_port"; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: Switch $switch_hostname nicht erreichbar oder Kommando abgelehnt — PoE-Deaktivierung fehlgeschlagen, kein Neustart durchgeführt." >> "$LOGFILE" echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: Switch $switch_hostname nicht erreichbar oder Kommando abgelehnt — PoE-Deaktivierung fehlgeschlagen, kein Neustart durchgeführt." >> "$LOGFILE"
return return
@@ -146,8 +156,19 @@ fi
while true; do while true; do
echo "--------------------------------------------------------------------" >> "$LOGFILE" echo "--------------------------------------------------------------------" >> "$LOGFILE"
# Einmal pro Schleifendurchlauf gelesen (wie SLEEP oben beim Skriptstart),
# nicht pro Gerät -- reicht als Shell-Variable an check_device() durch.
LICENSE_ACTIVE=$(python3 - <<'END'
import sys
sys.path.insert(0, "/srv/tesm")
from app import license_active
print("1" if license_active() else "0")
END
)
LICENSE_ACTIVE=${LICENSE_ACTIVE:-0}
while IFS='|' read -r rpi_ip dev_name switch_ip switch_ssh_port switch_hostname switch_port switch_user switch_pass mac; do while IFS='|' read -r rpi_ip dev_name switch_ip switch_ssh_port switch_hostname switch_port switch_user switch_pass mac; do
check_device "$rpi_ip" "$dev_name" "$switch_ip" "$switch_ssh_port" "$switch_hostname" "$switch_port" "$switch_user" "$switch_pass" & check_device "$rpi_ip" "$dev_name" "$switch_ip" "$switch_ssh_port" "$switch_hostname" "$switch_port" "$switch_user" "$switch_pass" "$LICENSE_ACTIVE" &
done < <(python3 /srv/tesm/generate_ips.py) done < <(python3 /srv/tesm/generate_ips.py)
wait wait