Rebrand to TESM: infra rename, LDAP/AD login, selective import/export, Papierkorb

- Vollständiges Rebranding "PoE Manager" -> TESM (TimEShepManager): neue
  Logos/Favicon (theme-aware), Sidebar/Login-Branding, Copyright-Zeile.
- Infrastruktur-Umbenennung: srv/poe_manager -> srv/tesm, alle POE_*-Env-Vars
  -> TESM_* (POE_SCRIPT bewusst unverändert, echtes PoE-Skript), systemd
  Units poe_web/rpi-check* -> tesm/tesm-check*, nginx-Site, netplan/logrotate
  Configs, Gitea-Repo alientim/Aruba-PoE-Modern -> alientim/tesm.
- LDAP/Active-Directory Same-Sign-On: Search+Bind, AD-Gruppen->App-Gruppen-
  Zuordnung (additiv), Konto-Sperren, AD-Vorab-Suche/-Anlage, eigene
  Einstellungsseite.
- Selektives Import/Export (8 Kategorien, Zwei-Schritt-Vorschau) mit eigenem
  R/E/X-Rechtemodell (X = Export, getrennt von E = Import).
- Papierkorb: Soft-Delete statt Hard-Delete für Geräte/Switche/Zugangsdaten/
  Benutzer/Gruppen (AD-Nutzer ausgenommen), eigene Seite unterhalb Wartung,
  konfigurierbare Aufbewahrungsfrist.
- rpi_ip -> ip Spalten-/Code-Umbenennung (Geräte sind längst nicht mehr auf
  Raspberry Pis beschränkt).
- README auf aktuellen Stand gebracht.
This commit is contained in:
2026-08-12 22:21:52 +02:00
parent 49634da0d9
commit d1b10bd970
50 changed files with 3641 additions and 728 deletions
+6 -6
View File
@@ -1,5 +1,5 @@
# Virtuelle Umgebung (Linux — das Projekt ist ausschließlich für Linux ausgelegt)
/srv/poe_manager/venv/
/srv/tesm/venv/
__pycache__/
*.pyc
@@ -7,10 +7,10 @@ __pycache__/
*.log
# Secrets — niemals committen
/srv/poe_manager/sqlite.db
/srv/poe_manager/fernet.key
/srv/poe_manager/secret.key
/srv/poe_manager/known_hosts
/srv/tesm/sqlite.db
/srv/tesm/fernet.key
/srv/tesm/secret.key
/srv/tesm/known_hosts
# Laufzeit-Uploads (Profilbilder) — werden bei Bedarf automatisch angelegt
/srv/poe_manager/static/uploads/
/srv/tesm/static/uploads/
+303 -85
View File
@@ -1,4 +1,4 @@
# PoE Manager — Modern UI Rebuild
# TESM (TimEShepManager)
Webbasierte Verwaltung und Monitoring von PoE-Devices und Aruba-Switchen —
funktionales Rebuild von [Aruba-PoE](https://gitea.int.eertmoed.net/WiS/Aruba-PoE)
@@ -47,6 +47,9 @@ Die App ermöglicht:
getrennt vom PoE-Neustart oben): Bulk-Update (`apt update && apt upgrade`)
und Neustart per SSH direkt auf dem Gerät, unabhängig von jeder
Switch-Zuordnung (siehe [Wartung](#wartung-ssh-bulk-update--neustart))
- **Optionale LDAP/Active-Directory-Anmeldung** mit dem Domänen-Passwort,
zusätzlich zu lokalen Konten — automatische Konto-Anlage beim ersten
Login (siehe [LDAP / Active Directory](#ldap--active-directory-same-sign-on))
- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) —
der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten
Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und
@@ -113,8 +116,8 @@ Die App ermöglicht:
überarbeitet — parallele statt sequentielle Geräte-Prüfung und echte
Erfolgskontrolle statt blind angenommenem Erfolg, siehe
[Geräte-Prüfung](#geräte-prüfung-poesh).
- **Aufgeräumtes Backend**: konsistente, konfigurierbare Pfade (`POE_DB_PATH`,
`POE_SCRIPT`, `POE_LOG_GLOB`, …) statt hart codierter/inkonsistenter Pfade;
- **Aufgeräumtes Backend**: konsistente, konfigurierbare Pfade (`TESM_DB_PATH`,
`POE_SCRIPT`, `TESM_LOG_DIR`, …) statt hart codierter/inkonsistenter Pfade;
Secret-Key und Fernet-Key werden beim ersten Start automatisch generiert
statt im Repo zu liegen.
@@ -137,29 +140,43 @@ Ein Benutzer kann mehreren Gruppen angehören, die Rechte addieren sich
### Zweistufiger Rechtebaum
Der Baum hat genau zwei Ebenen, exakt gespiegelt an den drei Sidebar-Gruppen
(Geräte/Logs/Einstellungen). Auf der Gruppen-Seite stehen sie als drei
kompakte Spalten nebeneinander, mit einer kurzen Legende direkt darüber.
Jede Spalte ist eine kleine Tabelle: die Kopfzeile enthält in EINER Zeile
sowohl den Bereichsnamen mit seinem Kästchen (dem Kill-Switch, dicht
nebeneinander statt an den Rand gestellt) als auch die Buchstaben-Spalten
**R**(ead/Lesen), **W**(rite/Schreiben, Anlegen), **E**(dit/Ändern) — nur
die Rechte, die dieser Bereich tatsächlich nutzt. Darunter je eine
eingerückte Zeile pro Unterpunkt. **„Ändern“ deckt Löschen überall mit
ab** — es gibt bewusst kein eigenes Löschen-Recht. Eine vierte Spalte **D**
gibt es nur bei Geräte → Clients, und sie bedeutet dort **nicht** Löschen,
sondern *PoE-Neustart über das Dashboard-Popup auslösen* (automatische
Neustarts bei Ausfall über `poe.sh` sind davon komplett unabhängig und
laufen immer). Für Zeilen ohne passendes Recht (z.B. „D“ bei Switche/
Zugangsdaten/Benutzer/Gruppen) wird eine ausgegraute, nicht anklickbare
Checkbox angezeigt statt die Zelle wegzulassen:
Der Baum hat genau zwei Ebenen, exakt gespiegelt an den drei
Rechte-Bereichen der Gruppen-Seite (Geräte/Logs/Einstellungen) — das ist
**unabhängig** von der Sidebar-Gliederung: „Benutzer“ und „Gruppen“ stehen
in der Sidebar zwar als eigene, flache Top-Level-Punkte (siehe
[Navigation anpassen](#navigation-anpassen)), gehören rechtlich aber
weiterhin zum Bereich „Einstellungen“. Auf der Gruppen-Seite stehen die drei
Bereiche als drei kompakte Spalten nebeneinander, mit einer kurzen Legende
direkt darüber. Jede Spalte ist eine kleine Tabelle: die Kopfzeile enthält
in EINER Zeile sowohl den Bereichsnamen mit seinem Kästchen (dem
Kill-Switch, dicht nebeneinander statt an den Rand gestellt) als auch die
Buchstaben-Spalten **R**(ead/Lesen), **W**(rite/Schreiben, Anlegen),
**E**(dit/Ändern) — nur die Rechte, die dieser Bereich tatsächlich nutzt.
Darunter je eine eingerückte Zeile pro Unterpunkt. **„Ändern“ deckt Löschen
überall mit ab** — es gibt bewusst kein eigenes Löschen-Recht (stattdessen
landet ein gelöschter Eintrag im [Papierkorb](#papierkorb)). Zwei Bereiche
haben eine zusätzliche, bereichsspezifische vierte Spalte: **D** nur bei
Geräte → Clients bedeutet **nicht** Löschen, sondern *PoE-Neustart über das
Dashboard-Popup auslösen* (automatische Neustarts bei Ausfall über `poe.sh`
sind davon komplett unabhängig und laufen immer); **X** nur bei
Einstellungen → Im-/Export bedeutet *Export ausführen* — bewusst getrennt
von E (Import), da die Export-Datei Passwörter im Klartext enthält (siehe
[Import/Export](#importexport)). Checken einer W/E/D/X-Zelle einer Zeile
hakt automatisch auch deren R an, und Abwählen von R wählt automatisch auch
die übrigen Spalten derselben Zeile ab (R ist logisch Voraussetzung für
alles andere). Für Zeilen ohne passendes Recht (z.B. „D“ bei Switche/
Zugangsdaten/Benutzer/Gruppen, oder „X“ bei allem außer Im-/Export) wird
eine ausgegraute, nicht anklickbare Checkbox angezeigt statt die Zelle
wegzulassen:
```
☑ Geräte R W E D ☑ Logs R W E ☑ Einstellungen R W E
Clients ☑ ☑ ☑ ☑ Live ☑ ☑ ☑ Benutzer ☑ ☑ ☑
Switche ☑ ☑ ☑ ▢ Änd. ☑ ☑ ☑ Gruppen ☑ ☑ ☑
Zugangsd. ☑ ☑ ☑ ▢ Systemeinst. ☑ ▢ ☑
Im-/Export ☑ ▢ ☑
☑ Geräte R W E D ☑ Logs R W E ☑ Einstellungen R W E X
Clients ☑ ☑ ☑ ☑ Live ☑ ☑ ☑ Benutzer ☑ ☑ ☑
Switche ☑ ☑ ☑ ▢ Änderungen☑ ☑ ☑ Gruppen ☑ ☑ ☑
Zugangsd. ☑ ☑ ☑ ▢ Kea-DHCP ☑ ☑ ☑ Systemeinst. ☑ ▢ ☑
Wartung ☑ ▢ ☑ ▢ LDAP ☑ ▢ ☑
DHCP ☑ ▢ ☑ ▢
Im-/Export ☑ ▢ ☑ ☑
```
Das jeweilige „Bereich sichtbar“-Recht (`devices_group.view` /
@@ -217,6 +234,136 @@ sämtliche `*.delete`-Rechte → `*.edit` (Löschen wurde nachträglich überall
eigenen Guard in der `settings`-Tabelle, damit z.B. ein bewusst deaktiviertes
Bereichs-Recht nicht bei jedem Neustart automatisch wieder gesetzt wird.
## LDAP / Active Directory (Same Sign-On)
Optionale Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten
— auf der eigenen Seite **Einstellungen → LDAP** (Recht `settings_ldap.view`
zum Ansehen, Speichern erfordert Admin).
**Wichtige Begriffsklärung, direkt aus einer Nutzerfrage entstanden:** das
ist **"Same Sign-On"**, kein automatisches, promptloses **Single Sign-On**.
Der Benutzer tippt sein AD-Passwort weiterhin auf der normalen Login-Seite
ein — es wird nur bei jedem Login **live** gegen Active Directory geprüft
statt gegen ein lokal gespeichertes Passwort. Eine Passwortänderung in AD
greift dadurch sofort, ohne dass irgendetwas synchronisiert werden müsste.
Echtes, promptloses Single Sign-On (der Browser meldet automatisch den
bereits an Windows angemeldeten Benutzer an) bräuchte zusätzlich:
- **Kerberos/SPNEGO** ("Integrated Windows Authentication"): ein SPN +
Keytab vom AD-Admin, eine per DNS auflösbare Hostname für die App, und
die Browser-Konfiguration der Clients (i.d.R. bereits Standard für die
Intranet-Zone auf domänenbeigetretenen PCs) — **oder**
- **SAML/OIDC über Entra ID (Azure AD)**, falls Microsoft 365/Entra
vorhanden ist — oft der pragmatischere Weg zu echtem SSO als klassisches
Kerberos.
Beides sind eigenständige, deutlich aufwändigere Ausbaustufen, die sich
später auf die hier beschriebene LDAP-Anmeldung aufsetzen ließen, aber
AD-seitige Vorarbeit (Keytab bzw. App-Registrierung) durch einen
Domain-Admin voraussetzen.
### Funktionsweise (Search+Bind)
Beim Login wird zunächst lokal gesucht — per exaktem Benutzernamen, per
E-Mail-Adresse, oder (case-insensitiv, nur für `auth_source='ldap'`-Konten)
per bereits bekanntem AD-Benutzernamen. Ist das gefundene (oder noch nicht
existierende) Konto gesperrt (`is_locked`, siehe unten) oder im Papierkorb,
wird der Login abgelehnt, ohne AD überhaupt zu kontaktieren. Existiert kein
lokales Konto und ist LDAP aktiviert, prüft die App die eingegebene Kennung
(sAMAccountName **oder** UPN, z.B. sowohl `jdoe` als auch
`jdoe@ad.eertmoed.net` funktionieren) und das Passwort per **Search+Bind**
gegen den konfigurierten Server:
1. Verbindung mit dem konfigurierten **Service-Konto** aufbauen (ein
normales, unprivilegiertes Domänenkonto reicht — es braucht nur
Lesezugriff auf das Verzeichnis, was jedes Domänenkonto in AD
standardmäßig hat).
2. Den Benutzer per konfigurierbarem Attribut suchen (Standard
`sAMAccountName` für Active Directory; für generisches LDAP z.B. `uid`).
3. Eine **zweite, separate** Verbindung mit der gefundenen DN des Benutzers
und dem eingegebenen Passwort aufbauen, um es zu verifizieren — robuster
als ein direktes UPN-Bind (`user@domain`), da es nicht voraussetzt, dass
`sAMAccountName` und der UPN-Präfix übereinstimmen.
4. Zusätzlich wird das `userAccountControl`-Bit `ACCOUNTDISABLE` geprüft
(auch wenn ein echter AD-DC den Bind eines deaktivierten Kontos i.d.R.
ohnehin schon selbst ablehnt) — zusätzliche Absicherung.
Die **Base-DN** wird standardmäßig automatisch per RootDSE
(`defaultNamingContext`) ermittelt, kann aber manuell überschrieben werden
(z.B. bei mehreren Domänen in einer Gesamtstruktur).
Bei erfolgreichem erstem Login wird automatisch ein lokales Konto angelegt
(`auth_source='ldap'`, kein Admin). Das gespeicherte Passwort dieses Kontos
ist nur ein **nie geprüfter Platzhalter-Hash** — bei jedem weiteren Login
wird live gegen AD verifiziert, nie gegen die lokale DB. Benutzernamen
werden dabei case-insensitiv abgeglichen (nur für `auth_source='ldap'`-
Konten, wirkt sich nie auf lokale Konten aus), damit z.B. "JDoe" und "jdoe"
nicht zwei verschiedene lokale Konten erzeugen.
**Gruppen-Zuordnung (AD-Gruppe → App-Gruppe).** Auf der LDAP-Einstellungsseite
lässt sich jeder AD-Gruppe (per DN) genau eine App-Gruppe zuordnen. Ist der
Benutzer beim Login Mitglied mehrerer zugeordneter AD-Gruppen, erhält er
**additiv** die Rechte aller passenden App-Gruppen (dieselbe
Vereinigungslogik wie bei mehrfacher lokaler Gruppenmitgliedschaft). Passt
keine der konfigurierten Zuordnungen, greift ersatzweise eine konfigurierbare
**Standardgruppe** (sonst die System-Standardgruppe „Benutzer“) — es sei denn,
eine der AD-Gruppen ist explizit der internen Admin-Zuordnung zugewiesen,
dann wird das Konto automatisch zum Admin. Die Zuordnung wird bei **jedem**
Login neu ausgewertet, nicht nur beim ersten — Änderungen an
AD-Gruppenmitgliedschaften wirken sich also ohne manuellen Eingriff auf die
nächste Anmeldung aus.
**Konten sperren.** Ein Admin kann jedes Konto (lokal wie AD) in „Benutzer“
per Schalter sperren (`is_locked`) — ein gesperrtes Konto kann sich nicht
mehr anmelden, unabhängig davon, ob das AD-Passwort weiterhin gültig ist.
Nützlich, um jemandem ohne Eingriff in AD selbst kurzfristig den Zugriff auf
die App zu entziehen.
**Vorab-Anlage aus Active Directory** („Benutzer" → „Aus Active Directory
hinzufügen"): eine Live-Suche (Substring, ab 2 Zeichen) im Verzeichnis,
gefiltert um bereits lokal bekannte Benutzernamen. So kann ein Admin einer
Person schon **vor** ihrem ersten Login eine Gruppe zuweisen, statt sich auf
die Standardgruppe zu verlassen — das Konto landet dabei sofort mit
`auth_source='ldap'` und demselben Platzhalter-Passwort-Mechanismus in der
DB, ganz so, als hätte die Person sich bereits einmal angemeldet.
**Lokale Konten bleiben vollständig erhalten** — der lokale Login-Pfad ist
unverändert, LDAP kommt nur als zusätzlicher Weg für Benutzernamen ohne
lokales Konto dazu. Wichtig als Fallback (Break-Glass-Zugang, falls
AD/LDAP mal nicht erreichbar ist) und für den allerersten
Admin-Bootstrap (`create_admin.py`).
Für AD-Konten wird die Passwort-Änderung sowohl unter „Mein Konto" als auch
im Admin-Bereich „Benutzer" ausgeblendet bzw. serverseitig ignoriert (ein
kleines "AD"-Symbol markiert sie in der Benutzerliste) — das Passwort wird
ausschließlich in Active Directory verwaltet.
**Löschen eines AD-Kontos** in „Benutzer" entfernt nur das lokale
Schatten-Konto — **AD-Konten landen dabei bewusst NIE im Papierkorb**
(anders als lokale Konten, siehe [Papierkorb](#papierkorb)), da sie sich bei
jedem gültigen Login ohnehin automatisch wieder anlegen würden und ein
"gelöscht, aber weiter nutzbar"-Zwischenzustand hier keinen Sinn ergibt.
Meldet sich die Person danach erneut mit gültigen AD-Zugangsdaten an, wird
automatisch wieder ein (neues, rechteloses) Konto angelegt. Um jemandem den
Zugriff wirklich zu entziehen, sperrt man das Konto entweder direkt in der
App (siehe oben) oder deaktiviert es in AD selbst (blockiert dann auch den
LDAP-Bind). Aus demselben Grund werden AD-Konten beim
[Export](#importexport) grundsätzlich nie mit exportiert — nur lokale
Konten landen in der Export-Datei.
**Sicherheitshinweis**: ohne LDAPS/StartTLS wird das Passwort unverschlüsselt
über das Netzwerk übertragen — nur für interne Tests in einem vertrauenswürdigen
Netz geeignet. Vor Produktivbetrieb sollte LDAPS auf dem Domain Controller
eingerichtet und in den Einstellungen aktiviert werden (das Server-Zertifikat
wird dafür noch nicht validiert — reine Transportverschlüsselung).
Live gegen einen echten Active-Directory-Domain-Controller verifiziert:
automatische Base-DN-Ermittlung, "Verbindung testen", erfolgreicher
Erst-Login mit automatischer Konto-Anlage und korrekter
Standardgruppen-Zuweisung, Ablehnung bei falschem Passwort, erneuter Login
verwendet das bereits angelegte Konto (keine Duplikate), unveränderter
lokaler Admin-Login, sowie dass das Bind-Passwort nie im Klartext im
gerenderten HTML landet.
## Zugangsdaten (wiederverwendbare SSH-Logins)
Zugangsdaten (Username + verschlüsseltes Passwort) sind eine eigene Entität
@@ -297,7 +444,7 @@ letzten Aktion, per Klick ein-/ausblendbar) wird per Polling (`/maintenance/stat
alle 2s solange etwas läuft, sonst alle 8s) aktualisiert, ohne die Seite neu
zu laden. Der Job-Status lebt bewusst nur im Prozessspeicher (wie beim
SSH-Terminal auch keine Sitzung persistiert wird) — ein Neustart von
`poe_web.service` verwirft nur die Anzeige, nicht die auf dem Zielgerät
`tesm.service` verwirft nur die Anzeige, nicht die auf dem Zielgerät
bereits laufende Aktion selbst. Ein abgeschlossener **erfolgreicher** Job
wird zusätzlich bei jedem (erneuten) Laden der Seite zurückgesetzt (Status
wieder „Noch keine Aktion“) — ein Erfolg ist nur so lange relevant, wie man
@@ -497,7 +644,7 @@ wurde korrekt mit der „Ohne Reservierung“-Markierung angezeigt.
## Geräte-Prüfung (`poe.sh`)
Der Hintergrund-Check-Dienst (`rpi-check.service`, ruft `poe.sh` ohne
Der Hintergrund-Check-Dienst (`tesm-check.service`, ruft `poe.sh` ohne
Argument auf) prüft in einer Endlosschleife alle Geräte per Ping und
startet bei Ausfall automatisch den PoE-Port neu, über den ein Gerät am
zugeordneten Switch hängt (`expect`, siehe unten). Zwei Korrekturen nach
@@ -543,7 +690,7 @@ fälschlich als 43× „PoE deaktiviert“/„PoE aktiviert“ geloggt.
## Netzwerkeinstellungen des Hosts (Systemeinstellungen)
Eigene, klar getrennte Funktion von obigem DHCP-*Server*: hier geht es um
die Netzwerkkonfiguration des Hosts, auf dem der PoE Manager selbst läuft
die Netzwerkkonfiguration des Hosts, auf dem TESM selbst läuft
(IP-Adresse, DNS-Server, DHCP-Client vs. statisch, Hostname) — nicht um
Adressen, die er an andere Geräte vergibt. Der Hostname wird separat und
risikolos über `hostnamectl set-hostname` gesetzt (kein Revert-Timer nötig,
@@ -578,7 +725,7 @@ da eine Änderung nicht die Erreichbarkeit kappt).
IP/Prefix/Gateway, sowie unabhängig davon eigene DNS-Server (funktioniert
in beiden Modi — z.B. IP per DHCP, aber feste DNS-Server). Bei netplan
landet die Änderung in einer eigenen, niedrig priorisierten Override-Datei
(`/etc/netplan/90-poe-manager.yaml`) statt in der vom Distributor bzw.
(`/etc/netplan/90-tesm.yaml`) statt in der vom Distributor bzw.
cloud-init mitgelieferten — netplan wendet Dateien alphabetisch an, spätere
Werte gewinnen pro Schlüssel, damit bleibt die Original-Datei unangetastet.
- **Sicherheitsnetz** (wie `netplan try`): vor jeder Änderung wird die
@@ -635,18 +782,18 @@ Monat Historie):
| Log | Datei | Einsehbar unter |
|-----|-------|------------------|
| Live (Ping-Check/PoE-Neustart) | `/var/log/poe-manager/live.log` | Logs → Live |
| Änderungen (Audit-Trail) | `/var/log/poe-manager/changes.log` | Logs → Änderungen (aus der DB) |
| App (Fehler/Warnungen der Flask-App) | `/var/log/poe-manager/app.log` | nur Datei, keine eigene Seite |
| Live (Ping-Check/PoE-Neustart) | `/var/log/tesm/live.log` | Logs → Live |
| Änderungen (Audit-Trail) | `/var/log/tesm/changes.log` | Logs → Änderungen (aus der DB) |
| App (Fehler/Warnungen der Flask-App) | `/var/log/tesm/app.log` | nur Datei, keine eigene Seite |
| Kea-DHCP | `/var/log/kea/kea-dhcp4.log` | Logs → Kea-DHCP (Recht `logs_kea.view`) |
- **Live-Log ist jetzt eine einzige, durchgehend fortgeschriebene Datei**
statt vorher bei jedem (auch täglich per `rpi-check-restart.timer`
statt vorher bei jedem (auch täglich per `tesm-check-restart.timer`
automatisch ausgelösten) Dienst-Neustart ein neues `rpi-<timestamp>.log`
`poe.sh` räumt nicht mehr selbst nach 30 Tagen auf, das übernimmt jetzt
zentral logrotate. Bestehende alte `rpi-*.log`-Dateien werden beim ersten
Start dieser Version einmalig aufgeräumt (Migration).
- **Kea-DHCP-Log liegt bewusst NICHT unter `/var/log/poe-manager/`**: Keas
- **Kea-DHCP-Log liegt bewusst NICHT unter `/var/log/tesm/`**: Keas
AppArmor-Profil (`/etc/apparmor.d/usr.sbin.kea-dhcp4`) erlaubt
Schreibzugriff exklusiv auf den Dateinamen `/var/log/kea/kea-dhcp4.log`
(kein Verzeichnis-Wildcard) — ein anderer Pfad wird mit `DENIED ... mknod
@@ -660,14 +807,14 @@ Monat Historie):
funktioniert ohne jede Kooperation des schreibenden Prozesses und läuft
als root, ist also auch von Keas AppArmor-Profil nicht betroffen.
- **App-Log** (`app.log`) fängt Fehler/Warnungen der Flask-App ab (vorher
nur im journalctl-Output von `poe_web.service` sichtbar, ohne von der App
nur im journalctl-Output von `tesm.service` sichtbar, ohne von der App
selbst kontrollierbare Aufbewahrung) — bewusst nur `WARNING` und höher,
kein Access-/Request-Rauschen.
- **Zeitzone** (Systemeinstellungen, zusammen mit dem Hostnamen in der
Kachel „Host“): setzt die System-Zeitzone per `timedatectl
set-timezone`, damit alle vier Logs sowie das Änderungslog die
tatsächliche lokale Zeit statt UTC zeigen. Wirkt über `time.tzset()`
sofort für die bereits laufende `poe_web.service`-Instanz (kein
sofort für die bereits laufende `tesm.service`-Instanz (kein
Neustart nötig) — `poe.sh`/`date` liest die Zeitzone ohnehin bei jedem
Prüf-Durchlauf frisch, da es ein neuer Prozess pro Durchlauf ist.
- **Systemeinstellungen neu sortiert**: Host (Hostname + Zeitzone in einer
@@ -682,20 +829,85 @@ bereits den neuen Zeitstempel.
## Import/Export
Unter **Einstellungen** lassen sich Geräte, Switche und Zugangsdaten
(inkl. entschlüsselter Passwörter) als ein einziges JSON-Bundle exportieren.
Unter **Einstellungen → Im-/Export** lassen sich acht Kategorien einzeln
auswählen und als ein JSON-Bundle exportieren: Geräte, Switche, Zugangsdaten
(inkl. entschlüsselter Passwörter), Benutzer, Gruppen, LDAP-Einstellungen
(inkl. Gruppenzuordnungen), DHCP-Konfiguration und Logs. **AD/LDAP-
Benutzerkonten werden dabei nie mitexportiert** — nur lokale Konten landen
in der Datei, siehe [LDAP-Kapitel](#ldap--active-directory-same-sign-on).
Das Bundle wird mit einem aus einer selbstgewählten Passphrase abgeleiteten
Schlüssel verschlüsselt (PBKDF2-HMAC-SHA256 + Fernet) — unabhängig vom
`fernet.key` dieser Installation, damit die Datei auf einer komplett neuen
Umgebung (mit eigenem, neu generiertem `fernet.key`) wieder eingelesen
werden kann. Gedacht für einen Umzug auf einen neuen Server.
werden kann. Gedacht für einen Umzug auf einen neuen Server oder ein
selektives Backup einzelner Kategorien.
Beim Import wird dieselbe Passphrase erneut abgefragt; Zugangsdaten werden
nach Name, Switche nach Hostname und Geräte nach MAC-Adresse abgeglichen —
bestehende Einträge mit gleichem Schlüssel werden aktualisiert, neue werden
angelegt. Switche referenzieren ihre Zugangsdaten dabei über den Namen (nicht
die interne ID), damit der Import auch bei abweichender ID-Vergabe auf dem
Zielsystem korrekt verknüpft.
**Zweistufiger Import.** Datei hochladen + Passphrase eingeben lädt das
Bundle serverseitig nur kurzzeitig in den Speicher (Token-basiert, läuft
nach einer Weile automatisch ab) und zeigt eine **Vorschau**: nur die
Kategorien, die tatsächlich in der Datei enthalten sind, jeweils mit Anzahl
der Einträge. Erst danach wählt man aus, welche der vorhandenen Kategorien
tatsächlich übernommen werden sollen — ein Import kann also z.B. nur die
Geräte aus einer Datei ziehen, ohne versehentlich auch Benutzer oder
LDAP-Einstellungen zu überschreiben. Abgeglichen wird pro Kategorie nach dem
jeweiligen natürlichen Schlüssel (Zugangsdaten nach Name, Switche nach
Hostname, Geräte nach MAC-Adresse, Benutzer nach Benutzername, Gruppen nach
Name) — bestehende Einträge werden aktualisiert, neue angelegt. Switche
referenzieren ihre Zugangsdaten dabei über den Namen (nicht die interne ID),
damit der Import auch bei abweichender ID-Vergabe auf dem Zielsystem korrekt
verknüpft.
**Berechtigungen.** Die Im-/Export-Seite hat drei eigene Rechte: **R**
(`settings_importexport.view`) zum Ansehen der Seite (Standardbedeutung wie
überall), **E** (`settings_importexport.edit`) zum Ausführen eines Imports,
und **X** (`settings_importexport.export`) — eine eigene, dedizierte
Berechtigung nur zum Ausführen eines Exports, da die Export-Datei Passwörter
im Klartext enthält (entschlüsselt vor der eigenen Passphrasen-
Verschlüsselung) und deshalb bewusst nicht automatisch an jeden mit
Änderungsrecht gekoppelt ist. Admins haben ohnehin immer alle drei; für
andere Gruppen lassen sich Import und Export unabhängig voneinander vergeben.
Zusätzlich sind die drei Kategorien **Benutzer, Gruppen und LDAP**
unabhängig von R/E/X grundsätzlich Admins vorbehalten (sowohl beim Export
als auch beim Import) — ein Nicht-Admin mit E/X sieht und nutzt dort nur die
übrigen fünf Kategorien.
## Papierkorb
Löschen ist für Geräte, Switche, Zugangsdaten, (lokale) Benutzer und
(benutzerdefinierte) Gruppen ein **Soft-Delete** statt eines echten
`DELETE` — der Eintrag bekommt nur einen Zeitstempel (`deleted_at`) und
verschwindet dadurch aus allen normalen Listen, Dropdowns und
Berechtigungsprüfungen, bleibt aber vollständig erhalten und wiederherstellbar.
Erreichbar über einen eigenen Navigationspunkt **„Papierkorb"** direkt
unterhalb von „Wartung" (sichtbar, sobald man für mindestens eine der fünf
Kategorien Leserecht hat) — dort ist jede Kategorie als eigene, permissions-
gegated Kachel mit „Wiederherstellen"- und „Endgültig löschen"-Aktion
aufgeführt.
**Ausnahmen**: System-/Standardgruppen (aktuell nur „Benutzer") lassen sich
grundsätzlich nicht löschen, landen also auch nie im Papierkorb — unverändert
gegenüber vorher. **AD/LDAP-Benutzerkonten landen ebenfalls nie im
Papierkorb** und werden bei „Löschen" weiterhin sofort endgültig entfernt, da
sie sich bei jedem gültigen Login automatisch neu anlegen (siehe
[LDAP-Kapitel](#ldap--active-directory-same-sign-on)) — ein
Papierkorb-Zwischenzustand wäre hier bedeutungslos.
**Reservierter Schlüssel, solange etwas im Papierkorb liegt**: MAC-Adresse,
Hostname, Zugangsdaten-Name, Benutzername bzw. Gruppenname eines
gelöschten Eintrags bleiben bewusst reserviert und blockieren die
Neuanlage eines gleichnamigen Eintrags, bis der alte entweder
wiederhergestellt oder endgültig gelöscht (purged) wird — der Fehlertext
beim Anlegen weist entsprechend auf den Papierkorb hin. Kind-Datensätze
(Gruppenmitgliedschaften, Gruppenrechte, DHCP-Werte pro Gerät) werden beim
Soft-Delete bewusst **nicht** mit entfernt, damit eine Wiederherstellung
exakt den vorherigen Zustand zurückbringt — aufgeräumt werden sie erst beim
**endgültigen** Löschen (Purge).
**Aufbewahrungsfrist**: konfigurierbar unter Systemeinstellungen
(`trash_retention_days`, Standard 30 Tage) — abgelaufene Einträge werden
beim Aufruf der Papierkorb-Seite automatisch endgültig gelöscht (kein
eigener Scheduler nötig), zusätzlich gibt es dort einen Button, um
abgelaufene Einträge sofort manuell zu bereinigen.
## Eigenes Konto & Profilbild
@@ -705,8 +917,9 @@ hochladen (PNG/JPG/GIF/WEBP) und das eigene Passwort ändern (erfordert
Eingabe des aktuellen Passworts). Das ist bewusst getrennt vom Admin-Bereich
„Benutzer“, der andere Konten verwaltet. Ist ein Profilbild hinterlegt,
erscheint es statt der Initialen sowohl in der Sidebar als auch im
Änderungslog neben dem Benutzernamen. Admins sehen auf derselben Seite
zusätzlich die Navbar-Reihenfolge (siehe unten).
Änderungslog neben dem Benutzernamen. Die Navbar-Reihenfolge (für Admins)
liegt dagegen unter Systemeinstellungen, siehe
[Navigation anpassen](#navigation-anpassen).
## Warnung bei ungespeicherten Änderungen
@@ -754,23 +967,28 @@ verursachen, obwohl der Code auf dem Server längst aktuell war.
## Navigation anpassen
Die Sidebar ist in drei aufklappbare Gruppen strukturiert:
Die Sidebar besteht aus zwei aufklappbaren Gruppen und mehreren flachen
Top-Level-Punkten dazwischen:
- **Geräte** — Clients (die eigentlichen Devices), Switche, Zugangsdaten,
Wartung (SSH-Bulk-Update/-Neustart)
- **Einstellungen** — Benutzer, Gruppen, Systemeinstellungen (Prüfintervall),
Im-/Export
- **Logs** — Live, Änderungen (Änderungslog)
- **Geräte** (Gruppe) — Clients (die eigentlichen Devices), Switche,
Zugangsdaten, Wartung (SSH-Bulk-Update/-Neustart), **Papierkorb**
(direkt unterhalb von Wartung, siehe [Papierkorb](#papierkorb))
- **Benutzer**, **Gruppen** — eigene, flache Top-Level-Punkte direkt nach
„Geräte“ (bewusst nicht in einer Untergruppe versteckt)
- **Einstellungen** (Gruppe) — Systemeinstellungen (Host/Prüfintervall/
Navbar-Reihenfolge/Papierkorb-Aufbewahrung), LDAP, DHCP, Im-/Export
- **Logs** (Gruppe) — Live, Änderungen, Kea-DHCP
Die Reihenfolge dieser drei Gruppen (und von „Dashboard“) ist für Admins
unter **Mein Konto** (Zahnrad-Symbol in der Sidebar) per Auf-/Ab-Buttons
konfigurierbar (gespeichert als JSON-Liste unter `settings.nav_order`) und
gilt global für alle Benutzer. Unabhängig von der Reihenfolge sieht jeder
Benutzer ohnehin nur, wofür er berechtigt ist — sowohl auf Gruppenebene als
auch pro Unterpunkt: hat ein Benutzer z.B. nur `devices.view`, erscheint die
Gruppe „Geräte“ nur mit dem Unterpunkt „Clients“ (ohne Switche/
Zugangsdaten); eine Gruppe verschwindet komplett, wenn kein Unterpunkt
sichtbar ist.
Die Reihenfolge der Top-Level-Punkte **und** der Unterpunkte innerhalb jeder
Gruppe ist für Admins unter **Systemeinstellungen** (eigene, nicht
einklappbare Karte „Navbar-Reihenfolge") per Auf-/Ab-Buttons konfigurierbar
(gespeichert als JSON unter `settings.nav_order` bzw.
`settings.nav_child_order`) und gilt global für alle Benutzer. Unabhängig
von der Reihenfolge sieht jeder Benutzer ohnehin nur, wofür er berechtigt
ist — sowohl auf Gruppenebene als auch pro Unterpunkt: hat ein Benutzer z.B.
nur `devices.view`, erscheint die Gruppe „Geräte“ nur mit dem Unterpunkt
„Clients“ (ohne Switche/Zugangsdaten/Wartung/Papierkorb); eine Gruppe
verschwindet komplett, wenn kein Unterpunkt sichtbar ist.
## Entwicklung unter WSL
@@ -783,7 +1001,7 @@ läuft — das Projektverzeichnis liegt dabei ganz normal unter
wsl --install -d Ubuntu # einmalig, falls noch nicht vorhanden
wsl -d Ubuntu
sudo apt update && sudo apt install -y python3 python3-venv python3-pip sqlite3 expect openssh-client
cd /mnt/c/Users/<dich>/aruba-poe-modern/srv/poe_manager
cd /mnt/c/Users/<dich>/aruba-poe-modern/srv/tesm
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
./venv/bin/python create_db.py
@@ -792,15 +1010,15 @@ python3 -m venv venv
# auch aus dem Windows-Browser erreichbar
```
**Hinweis zu `rpi-check.service`:** Der „Speichern & Service neustarten“-Button
**Hinweis zu `tesm-check.service`:** Der „Speichern & Service neustarten“-Button
unter Systemeinstellungen sowie das „Jetzt prüfen“-Icon funktionieren nur,
wenn `rpi-check.service` tatsächlich als echter systemd-Service läuft (viele
wenn `tesm-check.service` tatsächlich als echter systemd-Service läuft (viele
aktuelle WSL2-Installationen unterstützen systemd, siehe `wsl.conf`
`[boot] systemd=true`). Für lokale Tests reicht es, `etc/systemd/system/
rpi-check.service` sowie `usr/local/bin/custom/poe.sh` per Symlink an ihre
tesm-check.service` sowie `usr/local/bin/custom/poe.sh` per Symlink an ihre
Produktivpfade (`/etc/systemd/system/`, `/usr/local/bin/custom/`) zu legen
und `/srv/poe_manager` auf den Checkout zu verlinken, dann `systemctl
daemon-reload && systemctl enable --now rpi-check.service`. Ohne systemd
und `/srv/tesm` auf den Checkout zu verlinken, dann `systemctl
daemon-reload && systemctl enable --now tesm-check.service`. Ohne systemd
(z.B. `systemctl` nicht vorhanden) scheitert der Neustart-Aufruf lautlos —
das Intervall in der DB wird trotzdem aktualisiert, greift dann aber erst
nach einem manuellen Neustart des Prozesses.
@@ -808,7 +1026,7 @@ nach einem manuellen Neustart des Prozesses.
## Lokal starten (Entwicklung)
```bash
cd srv/poe_manager
cd srv/tesm
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
@@ -819,23 +1037,23 @@ python create_admin.py # legt ersten Admin-Benutzer an
python app.py # http://localhost:5000
```
## Produktiv-Deployment (auf der Ziel-VM, App landet unter `/srv/poe_manager`)
## Produktiv-Deployment (auf der Ziel-VM, App landet unter `/srv/tesm`)
Das Repo wird bewusst **nicht** direkt nach `/srv/poe_manager` geklont —
`install.sh` kopiert (`rsync`) den Inhalt von `srv/poe_manager/` aus dem Repo
Das Repo wird bewusst **nicht** direkt nach `/srv/tesm` geklont —
`install.sh` kopiert (`rsync`) den Inhalt von `srv/tesm/` aus dem Repo
gezielt dorthin und lässt Quell-Checkout und Zielverzeichnis damit sauber
getrennt (kein doppeltes `/srv/poe_manager/srv/poe_manager/…`):
getrennt (kein doppeltes `/srv/tesm/srv/tesm/…`):
```bash
sudo git clone <dieses-repo> /opt/aruba-poe-modern-src
cd /opt/aruba-poe-modern-src
sudo bash install.sh
sudo /srv/poe_manager/venv/bin/python3 /srv/poe_manager/create_admin.py
sudo /srv/tesm/venv/bin/python3 /srv/tesm/create_admin.py
```
Nach der Installation liegt die laufende App vollständig unter
**`/srv/poe_manager`** (so wie es `poe_web.service`, `rpi-check.service` und
`POE_SCRIPT`/`POE_DB_PATH` als Default erwarten) — `/opt/aruba-poe-modern-src`
**`/srv/tesm`** (so wie es `tesm.service`, `tesm-check.service` und
`POE_SCRIPT`/`TESM_DB_PATH` als Default erwarten) — `/opt/aruba-poe-modern-src`
wird nur als Checkout-Quelle für `install.sh` gebraucht und kann danach
liegen bleiben (für spätere Updates: `git pull` dort, dann `install.sh`
erneut ausführen) oder gelöscht werden.
@@ -843,19 +1061,19 @@ erneut ausführen) oder gelöscht werden.
`install.sh` installiert Systempakete, richtet die venv ein, initialisiert die
Datenbank (falls nicht vorhanden), kopiert `poe.sh` nach
`/usr/local/bin/custom/`, registriert die systemd-Units
(`poe_web.service`, `rpi-check.service`, `rpi-check-restart.timer`) und
(`tesm.service`, `tesm-check.service`, `tesm-check-restart.timer`) und
konfiguriert nginx als Reverse-Proxy auf Port 80.
### Relevante Umgebungsvariablen (optional)
| Variable | Zweck | Default |
|-----------------------|------------------------------------------|---------------------------------------|
| `POE_DB_PATH` | Pfad zur SQLite-DB | `<app-dir>/sqlite.db` |
| `TESM_DB_PATH` | Pfad zur SQLite-DB | `<app-dir>/sqlite.db` |
| `POE_SCRIPT` | Pfad zum PoE-Restart-Skript | `/usr/local/bin/custom/poe.sh` |
| `POE_LOG_GLOB` | Glob-Pattern der Check-Logs | `/var/log/rpi-*.log` |
| `POE_FERNET_KEY` | Pfad zum Fernet-Schlüssel | `<app-dir>/fernet.key` (auto-erzeugt)|
| `POE_KNOWN_HOSTS` | Pfad zur SSH-known_hosts-Datei des Terminals| `<app-dir>/known_hosts` (auto-erzeugt)|
| `POE_SECRET_KEY` | Flask Secret Key (überschreibt Datei) | zufällig, in `secret.key` gespeichert|
| `TESM_LOG_DIR` | Verzeichnis für `live.log`/`changes.log`/`app.log` | `/var/log/tesm` |
| `TESM_FERNET_KEY` | Pfad zum Fernet-Schlüssel | `<app-dir>/fernet.key` (auto-erzeugt)|
| `TESM_KNOWN_HOSTS` | Pfad zur SSH-known_hosts-Datei des Terminals| `<app-dir>/known_hosts` (auto-erzeugt)|
| `TESM_SECRET_KEY` | Flask Secret Key (überschreibt Datei) | zufällig, in `secret.key` gespeichert|
| `PORT`, `FLASK_DEBUG` | Flask-Serverport / Debug-Modus | `5000` / aus |
## Browser-SSH-Terminal (Switch-Verbindungstest)
@@ -881,24 +1099,24 @@ Voraussetzungen:
ohnehin ein WSGI-Server mit WebSocket-Unterstützung (z.B. Gunicorn mit
`gevent`- oder `eventlet`-Worker) statt des eingebauten Dev-Servers.
- **nginx muss den WebSocket-Upgrade für `/ws/` durchreichen** (`Upgrade`/
`Connection`-Header, siehe `etc/nginx/sites-available/poe_manager`) — ohne
`Connection`-Header, siehe `etc/nginx/sites-available/tesm`) — ohne
das lehnt Flask den Handshake mit `400 Bad Request` ab und das Terminal
bleibt leer auf „Getrennt“ hängen, obwohl derselbe Aufruf direkt gegen
Port 5000 (an nginx vorbei) funktioniert. War auf einer frisch per
`install.sh` aufgesetzten Ubuntu-VM live reproduzierbar, da alle bisherigen
Terminal-Tests ausschließlich direkt gegen Flask liefen, nie durch nginx.
- Da `poe_web.service` (wie im Original) als `root` läuft, kann über dieses
- Da `tesm.service` (wie im Original) als `root` läuft, kann über dieses
Terminal im Prinzip jeder erreichbare Host per SSH kontaktiert werden —
bewusst so gewählt, da Admins über die App ohnehin schon vollen Zugriff auf
alle hinterlegten Switch-Zugangsdaten haben. Für eine strengere Trennung
empfiehlt es sich, `poe_web.service` künftig unter einem eigenen,
empfiehlt es sich, `tesm.service` künftig unter einem eigenen,
unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier
aktuell `root`, um Änderungen am produktiven Setup nicht zu vergrößern).
## Projektstruktur
```
srv/poe_manager/ Flask-App (Backend + Templates + Static-Assets)
srv/tesm/ Flask-App (Backend + Templates + Static-Assets)
usr/local/bin/custom/ poe.sh — Ping-Check & PoE-Restart per SSH (expect)
etc/systemd/system/ Service-/Timer-Units (Web-App + Check-Dienst)
etc/nginx/ Reverse-Proxy-Konfiguration
@@ -37,7 +37,7 @@ server {
}
location /static/ {
alias /srv/poe_manager/static/;
alias /srv/tesm/static/;
expires 7d;
}
}
@@ -1,6 +0,0 @@
[Unit]
Description=Restart rpi-check.service
[Service]
Type=oneshot
ExecStart=/bin/systemctl restart rpi-check.service
@@ -1,10 +0,0 @@
[Unit]
Description=Restart RPI Check daily at 00:00
[Timer]
OnCalendar=*-*-* 00:00:00
Persistent=true
Unit=rpi-check-restart.service
[Install]
WantedBy=timers.target
-14
View File
@@ -1,14 +0,0 @@
[Unit]
Description=RPI Ping Check
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/srv/poe_manager
ExecStart=/srv/poe_manager/venv/bin/python3 /srv/poe_manager/poe_wrapper.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,6 @@
[Unit]
Description=Restart TESM Check
[Service]
Type=oneshot
ExecStart=/bin/systemctl restart tesm-check.service
@@ -0,0 +1,10 @@
[Unit]
Description=Restart TESM Check daily at 00:00
[Timer]
OnCalendar=*-*-* 00:00:00
Persistent=true
Unit=tesm-check-restart.service
[Install]
WantedBy=timers.target
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=TESM Ping Check
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/srv/tesm
ExecStart=/srv/tesm/venv/bin/python3 /srv/tesm/poe_wrapper.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
@@ -1,16 +1,15 @@
[Unit]
Description=PoE Manager Web App
Description=TESM Web App
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/srv/poe_manager
WorkingDirectory=/srv/tesm
# Nutze die virtuelle Umgebung
ExecStart=/srv/poe_manager/venv/bin/python3 /srv/poe_manager/app.py
ExecStart=/srv/tesm/venv/bin/python3 /srv/tesm/app.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+23 -23
View File
@@ -1,9 +1,9 @@
#!/bin/bash
# ============================================================================
# PoE Manager — Installer
# TESM — Installer
# Richtet System-Pakete, die Flask-App (systemd) sowie den Erreichbarkeits-
# Check-Dienst (systemd + Timer) ein. Auszuführen als root, nachdem dieses
# Repo z.B. nach /root/aruba-poe-modern geklont wurde.
# Repo z.B. nach /root/tesm geklont wurde.
# ============================================================================
set -e
@@ -13,45 +13,45 @@ GREEN='\033[0;32m'
NC='\033[0m'
print_status() { echo -e "${GREEN}${NC} ${1} completed."; }
step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/poe-manager-install.log; }
step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/tesm-install.log; }
clear
# ---- Pakete ----
step "Installing system packages"
sudo apt-get update >>/var/log/poe-manager-install.log 2>&1 && print_status "apt update"
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/poe-manager-install.log 2>&1 && print_status "Packages installed"
sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update"
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
# ---- Log-Verzeichnis ----
# Weltweit beschreibbar, da sowohl root (poe_web.service/poe.sh) als auch
# Weltweit beschreibbar, da sowohl root (tesm.service/poe.sh) als auch
# der i.d.R. unprivilegierte kea-dhcp4-server-Dienstbenutzer (_kea) hier
# ihre jeweils eigene Log-Datei anlegen/schreiben müssen. Die eigentliche
# logrotate-Konfiguration (/etc/logrotate.d/poe-manager) schreibt die App
# logrotate-Konfiguration (/etc/logrotate.d/tesm) schreibt die App
# selbst beim Start bzw. beim Speichern unter Systemeinstellungen → Logs.
sudo mkdir -p /var/log/poe-manager
sudo chmod 777 /var/log/poe-manager
sudo mkdir -p /var/log/tesm
sudo chmod 777 /var/log/tesm
# ---- App-Verzeichnis ----
step "Deploying application to /srv/poe_manager"
sudo mkdir -p /srv/poe_manager
step "Deploying application to /srv/tesm"
sudo mkdir -p /srv/tesm
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
"$REPO_DIR/srv/poe_manager/" /srv/poe_manager/ >>/var/log/poe-manager-install.log 2>&1
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
print_status "Application files copied"
# ---- Python venv ----
step "Setting up Python virtual environment"
cd /srv/poe_manager
cd /srv/tesm
sudo python3 -m venv venv
sudo ./venv/bin/pip install --upgrade pip >>/var/log/poe-manager-install.log 2>&1
sudo ./venv/bin/pip install -r requirements.txt >>/var/log/poe-manager-install.log 2>&1
sudo ./venv/bin/pip install --upgrade pip >>/var/log/tesm-install.log 2>&1
sudo ./venv/bin/pip install -r requirements.txt >>/var/log/tesm-install.log 2>&1
print_status "Virtual environment ready"
# ---- Datenbank ----
if [ ! -f /srv/poe_manager/sqlite.db ]; then
if [ ! -f /srv/tesm/sqlite.db ]; then
step "Initializing database"
sudo ./venv/bin/python3 create_db.py >>/var/log/poe-manager-install.log 2>&1
sudo ./venv/bin/python3 create_db.py >>/var/log/tesm-install.log 2>&1
print_status "Database initialized"
echo -e "${RED}${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen: sudo /srv/poe_manager/venv/bin/python3 /srv/poe_manager/create_admin.py"
echo -e "${RED}${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen: sudo /srv/tesm/venv/bin/python3 /srv/tesm/create_admin.py"
fi
# ---- Custom-Skripte + systemd ----
@@ -65,17 +65,17 @@ print_status "Scripts and units copied"
# ---- nginx ----
step "Configuring nginx reverse proxy"
sudo cp "$REPO_DIR/etc/nginx/sites-available/poe_manager" /etc/nginx/sites-available/poe_manager
sudo ln -sf /etc/nginx/sites-available/poe_manager /etc/nginx/sites-enabled/poe_manager
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm
sudo ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t >>/var/log/poe-manager-install.log 2>&1 && sudo systemctl reload nginx
sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx
print_status "nginx configured"
# ---- Dienste aktivieren ----
step "Enabling services"
sudo systemctl daemon-reload
sudo systemctl enable --now poe_web.service
sudo systemctl enable --now rpi-check.service rpi-check-restart.timer
sudo systemctl enable --now tesm.service
sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
print_status "Services enabled and started"
echo -e "${GREEN}${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)."
@@ -1,58 +0,0 @@
{% 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 %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Export</h2>
<div class="hint">Geräte, Switche und Zugangsdaten verschlüsselt sichern.</div>
</div>
</div>
<form method="post" action="{{ url_for('export_data') }}">
<div class="field">
<label for="export_passphrase">Passphrase</label>
<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>
<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>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Import</h2>
<div class="hint">Exportiertes Bundle einlesen — vorhandene Einträge werden aktualisiert, neue angelegt.</div>
</div>
</div>
{% if current_user.has_permission('settings_importexport.edit') %}
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data"
data-confirm="Import wirklich starten? Bestehende Einträge mit gleichem Namen/Hostname/MAC werden überschrieben.">
<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>
Import starten
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Import fehlt das Recht „Im-/Export ändern“.</p>
{% endif %}
</div>
</div>
{% endblock %}
-177
View File
@@ -1,177 +0,0 @@
{% 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 %}
<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') %}
<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>
{% endif %}
</div>
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table" data-sortable>
<thead><tr>
<th data-sort-key="username">Username</th>
<th data-sort-key="name">Name</th>
<th data-sort-key="group">Gruppe</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody>
{% for u in users %}
{% set full_name = [u['first_name'], u['last_name']]|select|join(' ') %}
{% set group_label = 'Admin' if u['is_admin'] else (u['group_names'] or '') %}
<tr data-sort-username="{{ u['username']|lower }}" data-sort-name="{{ full_name|lower }}" data-sort-group="{{ group_label|lower }}">
<td class="cell-name">{{ u['username'] }}</td>
<td class="text-dim">{{ full_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 %}
<button class="icon-btn" title="Bearbeiten"
onclick="openEditModal({{ u['id'] }}, '{{ u['username'] }}', '{{ u['first_name'] or '' }}', '{{ u['last_name'] 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>
<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 %}
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich löschen?">
<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="4">Noch keine Benutzer vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Modal: Neuer Benutzer -->
<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>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>
<!-- Modal: Benutzer bearbeiten (Stammdaten + optional neues Passwort;
Gruppe/Rolle wird ausschließlich über "Gruppe zuweisen" geändert) -->
<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>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>
<!-- Modal: Gruppe zuweisen (inkl. Admin als Auswahl) -->
<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</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 class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
</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 openEditModal(userId, username, firstName, lastName) {
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.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");
}
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Initialisiert die SQLite-Datenbank für den PoE Manager."""
"""Initialisiert die SQLite-Datenbank für TESM."""
import os
import sqlite3
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS switches (
c.execute("""
CREATE TABLE IF NOT EXISTS devices (
mac TEXT PRIMARY KEY,
rpi_ip TEXT NOT NULL,
ip TEXT NOT NULL,
switch_hostname TEXT,
port TEXT,
name TEXT NOT NULL,
@@ -58,7 +58,10 @@ CREATE TABLE IF NOT EXISTS users (
is_admin INTEGER DEFAULT 0,
first_name TEXT,
last_name TEXT,
avatar_filename TEXT
avatar_filename TEXT,
auth_source TEXT NOT NULL DEFAULT 'local',
email TEXT,
is_locked INTEGER NOT NULL DEFAULT 0
);
""")
@@ -23,9 +23,9 @@ def generate_ips_list():
}
devices = conn.execute("""
SELECT mac, rpi_ip, port, name, switch_hostname
SELECT mac, ip, port, name, switch_hostname
FROM devices
WHERE is_active=1
WHERE is_active=1 AND deleted_at IS NULL
""").fetchall()
conn.close()
@@ -44,7 +44,7 @@ def generate_ips_list():
port = dev["port"] or ""
print(
f"{dev['rpi_ip']}|"
f"{dev['ip']}|"
f"{dev['name']}|"
f"{switch_ip}|"
f"{switch_ssh_port}|"
@@ -9,8 +9,10 @@ Flask-Login==0.6.3
Flask-Sock==0.7.0
itsdangerous==2.2.0
Jinja2==3.1.6
ldap3==2.9.1
MarkupSafe==3.0.2
paramiko==3.5.1
pyasn1==0.6.4
pycparser==2.23
PyNaCl==1.5.0
PyYAML==6.0.2
@@ -25,3 +27,7 @@ Werkzeug==3.1.3
# 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.
@@ -1002,6 +1002,7 @@ select {
display: flex;
align-items: center;
justify-content: center;
gap: 5vw;
background:
radial-gradient(circle at 15% 15%, rgba(255, 113, 0, 0.14), transparent 45%),
radial-gradient(circle at 85% 85%, rgba(255, 113, 0, 0.08), transparent 40%),
@@ -1009,6 +1010,16 @@ select {
padding: 20px;
}
/* TESM-Wortmarke (mit Funktionszeile) als eigenes Panel neben der Login-Karte
-- nebeneinander statt überlagert, damit beides gleichzeitig sichtbar
bleibt. Auf schmalen Bildschirmen ausgeblendet (Platz reicht sonst nicht). */
.login-branding { display: none; pointer-events: none; }
.login-branding img { width: min(34vw, 520px); height: auto; }
@media (min-width: 860px) {
.login-branding { display: block; }
}
.login-card {
width: 100%;
max-width: 380px;
+12
View File
@@ -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

+12
View File
@@ -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

+9
View File
@@ -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

+9
View File
@@ -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

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -14,6 +14,17 @@
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() {
@@ -31,7 +31,7 @@
data-status="{{ 'disabled' if d['is_active'] == 0 else st }}"
data-mac="{{ d['mac'] }}"
data-name="{{ d['name'] }}"
data-ip="{{ d['rpi_ip'] }}"
{% if current_user.is_authenticated %}data-ip="{{ d['ip'] }}"{% endif %}
data-switch="{{ d['switch_hostname'] or '-' }}"
data-port="{{ d['port'] or '-' }}"
data-active="{{ d['is_active'] }}"
@@ -48,7 +48,7 @@
{% endif %}
</div>
<div class="dc-name">{{ d['name'] }}</div>
<div class="dc-ip">{{ d['rpi_ip'] }}</div>
{% if current_user.is_authenticated %}<div class="dc-ip">{{ d['ip'] }}</div>{% endif %}
</div>
{% endmacro %}
@@ -39,80 +39,26 @@
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Passwort ändern</h2>
<div class="hint">Erfordert Eingabe des aktuellen Passworts.</div>
<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>
{% 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" style="margin-top:24px;">
<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.</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 %}
{% 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);
}
</script>
{% endblock %}
@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or "PoE Manager" }}</title>
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}">
<title>{{ title or "TESM" }}</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>
@@ -25,6 +25,7 @@
"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">
@@ -34,10 +35,7 @@
<aside class="sidebar">
<div class="sidebar-brand">
<div>
<span class="brand-name">PoE Manager</span>
<span class="brand-sub">Device Monitoring</span>
</div>
<img id="sidebar-logo" src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM" style="width:100%; height:auto; display:block;">
</div>
<nav class="sidebar-nav">
@@ -91,6 +89,9 @@
<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 — TimEShepManager
</div>
</div>
</aside>
{% endif %}
@@ -23,8 +23,14 @@
</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="credentialSearch" placeholder="Zugangsdaten durchsuchen…" oninput="filterTable('credentialSearch','credentialsTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" data-sortable>
<table class="data-table" id="credentialsTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Name</th>
<th data-sort-key="username">Username</th>
@@ -171,6 +177,14 @@ function openEditCredentialModal(id, name, username, category) {
PoeUI.openModal("editCredentialModal");
}
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 validateCredentialForm(form, id) {
const pass = document.getElementById("password_" + id);
const confirm = document.getElementById("password_confirm_" + id);
@@ -50,9 +50,9 @@
</thead>
<tbody>
{% for d in devices %}
<tr data-sort-hostname="{{ d['name']|lower }}" data-sort-ip="{{ d['rpi_ip']|lower }}" data-sort-mac="{{ d['mac']|lower }}" data-sort-switch="{{ (d['switch_hostname'] or '')|lower }}" data-sort-port="{{ (d['port'] or '')|lower }}" data-sort-credential="{{ (d['credential_name'] or '')|lower }}">
<tr data-sort-hostname="{{ d['name']|lower }}" data-sort-ip="{{ d['ip']|lower }}" data-sort-mac="{{ d['mac']|lower }}" data-sort-switch="{{ (d['switch_hostname'] or '')|lower }}" data-sort-port="{{ (d['port'] or '')|lower }}" data-sort-credential="{{ (d['credential_name'] or '')|lower }}">
<td class="cell-name">{{ d['name'] }}</td>
<td class="mono">{{ d['rpi_ip'] }}</td>
<td class="mono">{{ d['ip'] }}</td>
<td class="mono">{{ d['mac'] }}</td>
<td>{{ d['switch_hostname'] or '—' }}</td>
<td>{{ d['port'] or '—' }}</td>
@@ -76,7 +76,7 @@
<div class="row-actions">
{% if can_edit %}
<button class="icon-btn" title="Bearbeiten"
onclick="openEditDeviceModal('{{ d['mac'] }}','{{ d['name'] }}','{{ d['rpi_ip'] }}','{{ d['port'] or '' }}','{{ d['ssh_port'] or '' }}','{{ d['credential_id'] or '' }}')">
onclick="openEditDeviceModal('{{ d['mac'] }}','{{ d['name'] }}','{{ d['ip'] }}','{{ d['port'] or '' }}','{{ d['ssh_port'] or '' }}','{{ d['credential_id'] 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>
<button class="icon-btn" title="Switch ändern"
@@ -119,7 +119,7 @@
<input type="text" name="name" required placeholder="z.B. Sensor01">
</div>
<div class="field"><label>IP-Adresse</label>
<input type="text" name="rpi_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>
<div class="field"><label>MAC-Adresse</label>
@@ -196,7 +196,7 @@
<input type="text" name="name" id="edit_name" required>
</div>
<div class="field"><label>IP-Adresse</label>
<input type="text" name="rpi_ip" id="edit_ip" required>
<input type="text" name="ip" id="edit_ip" required>
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div>
<div class="field"><label>MAC-Adresse</label>
@@ -361,7 +361,7 @@ function validateNewCredentialPassword(form) {
return true;
}
function validateDeviceForm(form) {
const ipInput = form.querySelector("input[name='rpi_ip']");
const ipInput = form.querySelector("input[name='ip']");
const macInput = form.querySelector("input[name='mac']");
let valid = true;
if (ipInput) valid = validateIP(ipInput) && valid;
@@ -370,7 +370,7 @@ function validateDeviceForm(form) {
return valid;
}
document.addEventListener("input", (e) => {
if (e.target.name === "rpi_ip") validateIP(e.target);
if (e.target.name === "ip") validateIP(e.target);
if (e.target.name === "mac") validateMAC(e.target);
if (e.target.name === "new_credential_password_confirm") {
const form = e.target.closest("form");
@@ -395,7 +395,7 @@ function resetCredentialChoice(id) {
// -------------------------------------------------------------------------
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) — identisch zum
// Muster in switches.html, nur mit rpi_ip statt ip als Feldname.
// Identisches Muster wie in switches.html.
// -------------------------------------------------------------------------
let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
@@ -444,7 +444,7 @@ function getCredentialInfo(form) {
}
function openTerminal(form) {
const host = (form.querySelector("input[name='rpi_ip']") || {}).value?.trim();
const host = (form.querySelector("input[name='ip']") || {}).value?.trim();
const port = parseInt((form.querySelector("input[name='ssh_port']") || {}).value, 10) || 22;
const { username, passwordInput } = getCredentialInfo(form);
activePasswordInput = passwordInput;
@@ -82,8 +82,9 @@
</div>
<div class="permission-legend">
<strong>R</strong> = Read (Lesen) · <strong>W</strong> = Write (Anlegen) ·
<strong>E</strong> = Edit (Ändern, inkl. Löschen) ·
<strong>D</strong> = Dashboard (PoE-Neustart über das Dashboard-Popup, nur bei Clients)
<strong>E</strong> = Edit (Ändern, inkl. Löschen — bei Im-/Export: Import ausführen) ·
<strong>D</strong> = Dashboard (PoE-Neustart über das Dashboard-Popup, nur bei Clients) ·
<strong>X</strong> = Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)
</div>
{% endmacro %}
@@ -104,8 +105,14 @@
</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" data-sortable>
<table class="data-table" id="groupsTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Gruppe</th>
<th data-sort-key="members">Mitglieder</th>
@@ -168,6 +175,7 @@
</div>
</td>
</tr>
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
<td colspan="3">
{% if can_edit_this %}
@@ -184,6 +192,32 @@
</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 %}
@@ -295,6 +329,31 @@
{% block scripts %}
<script>
// Filtert ganze <tbody>-Blöcke (Haupt- + Detail-Zeile je Gruppe gemeinsam,
// analog zur Sortierung) statt einzelner <tr> -- die gepinnte Admin-Gruppe
// (data-sort-pinned) bleibt dabei immer sichtbar, unabhängig vom Suchbegriff.
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";
});
}
// Freischalten der Systemgruppe "Benutzer": zeigt statt der Nur-Lese-Ansicht
// das editierbare Formular (inkl. unlock_system_group=1) -- serverseitig
// erneut geprüft (admin + Flag), das hier ist nur die UI-Bestätigung.
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 toggleDetail(id) {
const row = document.getElementById(id);
if (!row) return;
@@ -329,6 +388,33 @@ function applyPermissionGating() {
sync();
});
}
// R (erste Spalte/"Lesen") ist je Zeile Voraussetzung für W/E/D/X -- ein
// Recht ohne R wäre sonst über die UI nicht erreichbar (z.B. Nav-Link/Seite
// bleibt unsichtbar, obwohl das Kind-Recht technisch gesetzt ist). Abwahl
// von R nimmt deshalb automatisch auch die anderen Spalten dieser Zeile mit,
// Anwahl von W/E/D/X wählt automatisch R mit an.
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 %}
@@ -3,18 +3,23 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login · PoE Manager</title>
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}">
<title>Login · TESM</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="login-page">
<div class="login-branding">
<img src="{{ url_for('static', filename='images/logo-dark-subline.svg') }}" alt="TESM" id="login-bg-logo">
</div>
<div class="login-card">
<div class="login-logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
</div>
<h1>PoE Manager</h1>
<div style="display:flex; justify-content:center; margin-bottom:4px;">
<img id="sidebar-logo" src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM" style="height:32px; width:auto;">
</div>
<p class="login-sub">Melde dich an, um fortzufahren</p>
{% with messages = get_flashed_messages() %}
@@ -26,8 +26,14 @@
<form method="post" action="{{ url_for('maintenance_update') }}" id="updateForm">
<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="maintenanceSearch" placeholder="Geräte durchsuchen…" oninput="filterTable('maintenanceSearch','maintenanceTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" data-sortable>
<table class="data-table" id="maintenanceTable" data-sortable>
<thead><tr>
{% if can_run %}<th style="width:1%;"><input type="checkbox" id="selectAll" title="Alle auswählen"></th>{% endif %}
<th data-sort-key="name">Name</th>
@@ -39,12 +45,12 @@
<tbody>
{% for d in devices %}
{% set job = jobs.get(d['mac']) %}
<tr data-mac="{{ d['mac'] }}" data-sort-name="{{ d['name']|lower }}" data-sort-ip="{{ d['rpi_ip'] }}" data-sort-user="{{ d['cred_username']|lower }}">
<tr data-mac="{{ d['mac'] }}" data-sort-name="{{ d['name']|lower }}" data-sort-ip="{{ d['ip'] }}" data-sort-user="{{ d['cred_username']|lower }}">
{% if can_run %}
<td><input type="checkbox" name="macs" value="{{ d['mac'] }}" class="maint-check" {{ 'disabled' if not d['is_active'] else '' }}></td>
{% endif %}
<td class="cell-name">{{ d['name'] }}{% if not d['is_active'] %} <span class="text-faint" style="font-size:11px;">(deaktiviert)</span>{% endif %}</td>
<td class="mono">{{ d['rpi_ip'] }}:{{ d['ssh_port'] or 22 }}</td>
<td class="mono">{{ d['ip'] }}:{{ d['ssh_port'] or 22 }}</td>
<td class="mono">{{ d['cred_username'] }}</td>
<td class="maint-status">
{% if job %}
@@ -62,7 +68,7 @@
<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>
{% if can_run %}
<form method="post" action="{{ url_for('maintenance_reboot') }}" data-confirm="{{ d['name'] }} ({{ d['rpi_ip'] }}) jetzt per SSH neu starten?">
<form method="post" action="{{ url_for('maintenance_reboot') }}" data-confirm="{{ d['name'] }} ({{ d['ip'] }}) jetzt per SSH neu starten?">
<input type="hidden" name="macs" value="{{ d['mac'] }}">
<button type="submit" class="btn btn-sm btn-secondary">Neustart</button>
</form>
@@ -103,6 +109,17 @@ document.getElementById("selectAll")?.addEventListener("change", function () {
}, this);
});
function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
// Ausgabe-Zeile bleibt ausschließlich über toggleJobOutput() gesteuert,
// sonst könnte ein zufälliger Text-Treffer darin sie unabhängig vom
// dazugehörigen Geräte-Namen ein-/ausblenden.
if (row.classList.contains("empty-row") || row.classList.contains("job-output-row")) return;
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
function toggleJobOutput(mac) {
const row = document.getElementById("joboutput-" + mac);
if (row) row.style.display = (row.style.display === "none" || !row.style.display) ? "table-row" : "none";
+214
View File
@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% set active_page = "papierkorb" %}
{% block page_title %}Papierkorb{% endblock %}
{% block page_sub %}<div class="topbar-sub">Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Papierkorb</h2>
<div class="hint">
Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen — werden nach
{{ trash_retention_days }} Tag{{ 'en' if trash_retention_days != 1 else '' }} automatisch endgültig gelöscht
(einstellbar unter Systemeinstellungen). AD/LDAP-Benutzer landen nie hier, da sie sich bei jedem gültigen
Login automatisch neu anlegen.
</div>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:16px;">
{% if current_user.has_permission('devices.view') %}
<div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Clients ({{ trash.devices|length }})</h3>
{% if trash.devices %}
<div class="table-wrap"><div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Name</th><th>IP</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for d in trash.devices %}
<tr>
<td class="cell-name">{{ d['name'] }}</td>
<td class="mono">{{ d['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('devices.edit') %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_device', mac=d['mac']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
</form>
<form method="post" action="{{ url_for('purge_device', mac=d['mac']) }}" data-confirm="„{{ d['name'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig 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:12px;">Papierkorb ist leer.</p>
{% endif %}
</div>
{% endif %}
{% if current_user.has_permission('switches.view') %}
<div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Switche ({{ trash.switches|length }})</h3>
{% if trash.switches %}
<div class="table-wrap"><div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Hostname</th><th>IP</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for s in trash.switches %}
<tr>
<td class="cell-name">{{ s['hostname'] }}</td>
<td class="mono">{{ s['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('switches.edit') %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_switch', hostname=s['hostname']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
</form>
<form method="post" action="{{ url_for('purge_switch', hostname=s['hostname']) }}" data-confirm="„{{ s['hostname'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig 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:12px;">Papierkorb ist leer.</p>
{% endif %}
</div>
{% endif %}
{% if current_user.has_permission('credentials.view') %}
<div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Zugangsdaten ({{ trash.credentials|length }})</h3>
{% if trash.credentials %}
<div class="table-wrap"><div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Name</th><th>Username</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for c in trash.credentials %}
<tr>
<td class="cell-name">{{ c['name'] }}</td>
<td class="mono">{{ c['username'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('credentials.edit') %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_credential', cred_id=c['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
</form>
<form method="post" action="{{ url_for('purge_credential', cred_id=c['id']) }}" data-confirm="„{{ c['name'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig 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:12px;">Papierkorb ist leer.</p>
{% endif %}
</div>
{% endif %}
{% if current_user.has_permission('users.view') %}
<div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Benutzer ({{ trash.users|length }})</h3>
{% if trash.users %}
<div class="table-wrap"><div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Username</th><th>Name</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for u in trash.users %}
{% set full_name = [u['first_name'], u['last_name']]|select|join(' ') %}
<tr>
<td class="cell-name">{{ u['username'] }}</td>
<td class="text-dim">{{ full_name or '—' }}</td>
<td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('users.edit') %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_user', user_id=u['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
</form>
<form method="post" action="{{ url_for('purge_user', user_id=u['id']) }}" data-confirm="„{{ u['username'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig 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:12px;">Papierkorb ist leer.</p>
{% endif %}
</div>
{% endif %}
{% if current_user.has_permission('groups.view') %}
<div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Gruppen ({{ trash.groups|length }})</h3>
{% if trash.groups %}
<div class="table-wrap"><div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Name</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for g in trash.groups %}
<tr>
<td class="cell-name">{{ g['name'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('groups.edit') %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_group', group_id=g['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
</form>
<form method="post" action="{{ url_for('purge_group', group_id=g['id']) }}" data-confirm="„{{ g['name'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig 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:12px;">Papierkorb ist leer.</p>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
@@ -40,6 +40,18 @@
Zeitzone 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="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 (rpi-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 %}
<div class="field">
<label>Hostname</label>
@@ -49,6 +61,11 @@
<label>Zeitzone</label>
<input type="text" value="{{ current_timezone or '' }}" disabled>
</div>
<div class="field">
<label>Prüfintervall (Minuten)</label>
<input type="number" value="{{ interval }}" disabled>
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
</div>
{% endif %}
</div>
@@ -192,30 +209,98 @@
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Prüfintervall</h2>
<div class="hint">Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?</div>
<h2 style="font-size:16px;">Papierkorb</h2>
<div class="hint">Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen — Liste unter Geräte → Wartung.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<div class="field">
<label for="interval">Intervall (Minuten)</label>
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
<div class="field-hint">Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
<label for="trash_retention_days">Aufbewahrungsdauer (Tage)</label>
<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>
<button type="submit" 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>
Speichern & Service neustarten
Speichern
</button>
</form>
<form method="post" data-confirm="Alle bereits abgelaufenen Papierkorb-Einträge jetzt unwiderruflich löschen?" style="margin-top:10px;">
<input type="hidden" name="purge_trash_now" value="1">
<button type="submit" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);">
Abgelaufene Einträge jetzt bereinigen
</button>
</form>
{% else %}
<div class="field">
<label>Intervall (Minuten)</label>
<input type="number" value="{{ interval }}" disabled>
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
<label>Aufbewahrungsdauer (Tage)</label>
<input type="number" value="{{ trash_retention_days }}" disabled>
</div>
{% 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);
}
</script>
{% endblock %}
@@ -0,0 +1,114 @@
{% 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 %}
<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 s.admin_only %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
</label>
{% endfor %}
</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>
<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') %}
<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 key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
</label>
{% endfor %}
</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 class="field">
<label for="export_passphrase">Passphrase</label>
<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>
<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>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
{% endif %}
</div>
</div>
{% endblock %}
+229
View File
@@ -0,0 +1,229 @@
{% extends "base.html" %}
{% set active_page = "settings_ldap" %}
{% set can_edit = current_user.has_permission('settings_ldap.edit') %}
{% 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 %}
<div class="settings-grid" style="grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));">
<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</label>
<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 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</span>
</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 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)</span>
</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 class="field"><label>Bind-Konto (Service-Account)</label>
<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 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</label>
<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 class="field"><label>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 class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</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;">Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">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>
{% 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 %}
<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>
{% 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>
<script>
(function () {
var btn = document.getElementById('ldapMappingLoadGroupsBtn');
var select = document.getElementById('ldapMappingAdGroup');
var nameField = document.getElementById('ldapMappingAdGroupName');
var status = document.getElementById('ldapMappingLoadStatus');
if (!btn) return;
btn.addEventListener('click', function () {
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 (!groups.length) {
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;
select.appendChild(opt);
});
nameField.value = 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 () {
var opt = select.options[select.selectedIndex];
nameField.value = (opt && opt.dataset.name) || '';
});
})();
</script>
{% endif %}
{% endblock %}
+321
View File
@@ -0,0 +1,321 @@
{% 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 %}
<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 löschen?">
<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>
<!-- Modal: Neuer Benutzer -->
<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>
<!-- Modal: Benutzer bearbeiten (nur lokale Konten -- AD/LDAP-Konten lassen
sich nur sperren/entsperren und einer Gruppe zuweisen, siehe Tabelle) -->
<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>
<!-- Modal: Aus Active Directory hinzufügen (Vorab-Zuweisung vor dem ersten
Login des AD-Benutzers) -->
{% 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 %}
<!-- Modal: Gruppe zuweisen (inkl. Admin als Auswahl) -->
<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</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 class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
</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 %}