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) # Virtuelle Umgebung (Linux — das Projekt ist ausschließlich für Linux ausgelegt)
/srv/poe_manager/venv/ /srv/tesm/venv/
__pycache__/ __pycache__/
*.pyc *.pyc
@@ -7,10 +7,10 @@ __pycache__/
*.log *.log
# Secrets — niemals committen # Secrets — niemals committen
/srv/poe_manager/sqlite.db /srv/tesm/sqlite.db
/srv/poe_manager/fernet.key /srv/tesm/fernet.key
/srv/poe_manager/secret.key /srv/tesm/secret.key
/srv/poe_manager/known_hosts /srv/tesm/known_hosts
# Laufzeit-Uploads (Profilbilder) — werden bei Bedarf automatisch angelegt # 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 — Webbasierte Verwaltung und Monitoring von PoE-Devices und Aruba-Switchen —
funktionales Rebuild von [Aruba-PoE](https://gitea.int.eertmoed.net/WiS/Aruba-PoE) 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`) getrennt vom PoE-Neustart oben): Bulk-Update (`apt update && apt upgrade`)
und Neustart per SSH direkt auf dem Gerät, unabhängig von jeder und Neustart per SSH direkt auf dem Gerät, unabhängig von jeder
Switch-Zuordnung (siehe [Wartung](#wartung-ssh-bulk-update--neustart)) 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) — - Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) —
der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten
Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und 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 überarbeitet — parallele statt sequentielle Geräte-Prüfung und echte
Erfolgskontrolle statt blind angenommenem Erfolg, siehe Erfolgskontrolle statt blind angenommenem Erfolg, siehe
[Geräte-Prüfung](#geräte-prüfung-poesh). [Geräte-Prüfung](#geräte-prüfung-poesh).
- **Aufgeräumtes Backend**: konsistente, konfigurierbare Pfade (`POE_DB_PATH`, - **Aufgeräumtes Backend**: konsistente, konfigurierbare Pfade (`TESM_DB_PATH`,
`POE_SCRIPT`, `POE_LOG_GLOB`, …) statt hart codierter/inkonsistenter Pfade; `POE_SCRIPT`, `TESM_LOG_DIR`, …) statt hart codierter/inkonsistenter Pfade;
Secret-Key und Fernet-Key werden beim ersten Start automatisch generiert Secret-Key und Fernet-Key werden beim ersten Start automatisch generiert
statt im Repo zu liegen. statt im Repo zu liegen.
@@ -137,29 +140,43 @@ Ein Benutzer kann mehreren Gruppen angehören, die Rechte addieren sich
### Zweistufiger Rechtebaum ### Zweistufiger Rechtebaum
Der Baum hat genau zwei Ebenen, exakt gespiegelt an den drei Sidebar-Gruppen Der Baum hat genau zwei Ebenen, exakt gespiegelt an den drei
(Geräte/Logs/Einstellungen). Auf der Gruppen-Seite stehen sie als drei Rechte-Bereichen der Gruppen-Seite (Geräte/Logs/Einstellungen) — das ist
kompakte Spalten nebeneinander, mit einer kurzen Legende direkt darüber. **unabhängig** von der Sidebar-Gliederung: „Benutzer“ und „Gruppen“ stehen
Jede Spalte ist eine kleine Tabelle: die Kopfzeile enthält in EINER Zeile in der Sidebar zwar als eigene, flache Top-Level-Punkte (siehe
sowohl den Bereichsnamen mit seinem Kästchen (dem Kill-Switch, dicht [Navigation anpassen](#navigation-anpassen)), gehören rechtlich aber
nebeneinander statt an den Rand gestellt) als auch die Buchstaben-Spalten weiterhin zum Bereich „Einstellungen“. Auf der Gruppen-Seite stehen die drei
**R**(ead/Lesen), **W**(rite/Schreiben, Anlegen), **E**(dit/Ändern) — nur Bereiche als drei kompakte Spalten nebeneinander, mit einer kurzen Legende
die Rechte, die dieser Bereich tatsächlich nutzt. Darunter je eine direkt darüber. Jede Spalte ist eine kleine Tabelle: die Kopfzeile enthält
eingerückte Zeile pro Unterpunkt. **„Ändern“ deckt Löschen überall mit in EINER Zeile sowohl den Bereichsnamen mit seinem Kästchen (dem
ab** — es gibt bewusst kein eigenes Löschen-Recht. Eine vierte Spalte **D** Kill-Switch, dicht nebeneinander statt an den Rand gestellt) als auch die
gibt es nur bei Geräte → Clients, und sie bedeutet dort **nicht** Löschen, Buchstaben-Spalten **R**(ead/Lesen), **W**(rite/Schreiben, Anlegen),
sondern *PoE-Neustart über das Dashboard-Popup auslösen* (automatische **E**(dit/Ändern) — nur die Rechte, die dieser Bereich tatsächlich nutzt.
Neustarts bei Ausfall über `poe.sh` sind davon komplett unabhängig und Darunter je eine eingerückte Zeile pro Unterpunkt. **„Ändern“ deckt Löschen
laufen immer). Für Zeilen ohne passendes Recht (z.B. „D“ bei Switche/ überall mit ab** — es gibt bewusst kein eigenes Löschen-Recht (stattdessen
Zugangsdaten/Benutzer/Gruppen) wird eine ausgegraute, nicht anklickbare landet ein gelöschter Eintrag im [Papierkorb](#papierkorb)). Zwei Bereiche
Checkbox angezeigt statt die Zelle wegzulassen: 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 ☑ Geräte R W E D ☑ Logs R W E ☑ Einstellungen R W E X
Clients ☑ ☑ ☑ ☑ Live ☑ ☑ ☑ Benutzer ☑ ☑ ☑ Clients ☑ ☑ ☑ ☑ Live ☑ ☑ ☑ Benutzer ☑ ☑ ☑
Switche ☑ ☑ ☑ ▢ Änd. ☑ ☑ ☑ Gruppen ☑ ☑ ☑ Switche ☑ ☑ ☑ ▢ Änderungen☑ ☑ ☑ Gruppen ☑ ☑ ☑
Zugangsd. ☑ ☑ ☑ ▢ Systemeinst. ☑ ▢ ☑ Zugangsd. ☑ ☑ ☑ ▢ Kea-DHCP ☑ ☑ ☑ Systemeinst. ☑ ▢ ☑
Im-/Export ☑ ▢ ☑ Wartung ☑ ▢ ☑ ▢ LDAP ☑ ▢ ☑
DHCP ☑ ▢ ☑ ▢
Im-/Export ☑ ▢ ☑ ☑
``` ```
Das jeweilige „Bereich sichtbar“-Recht (`devices_group.view` / 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 eigenen Guard in der `settings`-Tabelle, damit z.B. ein bewusst deaktiviertes
Bereichs-Recht nicht bei jedem Neustart automatisch wieder gesetzt wird. 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 (wiederverwendbare SSH-Logins)
Zugangsdaten (Username + verschlüsseltes Passwort) sind eine eigene Entität 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 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 zu laden. Der Job-Status lebt bewusst nur im Prozessspeicher (wie beim
SSH-Terminal auch keine Sitzung persistiert wird) — ein Neustart von 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 bereits laufende Aktion selbst. Ein abgeschlossener **erfolgreicher** Job
wird zusätzlich bei jedem (erneuten) Laden der Seite zurückgesetzt (Status 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 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`) ## 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 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 startet bei Ausfall automatisch den PoE-Port neu, über den ein Gerät am
zugeordneten Switch hängt (`expect`, siehe unten). Zwei Korrekturen nach 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) ## Netzwerkeinstellungen des Hosts (Systemeinstellungen)
Eigene, klar getrennte Funktion von obigem DHCP-*Server*: hier geht es um 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 (IP-Adresse, DNS-Server, DHCP-Client vs. statisch, Hostname) — nicht um
Adressen, die er an andere Geräte vergibt. Der Hostname wird separat und Adressen, die er an andere Geräte vergibt. Der Hostname wird separat und
risikolos über `hostnamectl set-hostname` gesetzt (kein Revert-Timer nötig, 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 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 in beiden Modi — z.B. IP per DHCP, aber feste DNS-Server). Bei netplan
landet die Änderung in einer eigenen, niedrig priorisierten Override-Datei 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 cloud-init mitgelieferten — netplan wendet Dateien alphabetisch an, spätere
Werte gewinnen pro Schlüssel, damit bleibt die Original-Datei unangetastet. Werte gewinnen pro Schlüssel, damit bleibt die Original-Datei unangetastet.
- **Sicherheitsnetz** (wie `netplan try`): vor jeder Änderung wird die - **Sicherheitsnetz** (wie `netplan try`): vor jeder Änderung wird die
@@ -635,18 +782,18 @@ Monat Historie):
| Log | Datei | Einsehbar unter | | Log | Datei | Einsehbar unter |
|-----|-------|------------------| |-----|-------|------------------|
| Live (Ping-Check/PoE-Neustart) | `/var/log/poe-manager/live.log` | Logs → Live | | Live (Ping-Check/PoE-Neustart) | `/var/log/tesm/live.log` | Logs → Live |
| Änderungen (Audit-Trail) | `/var/log/poe-manager/changes.log` | Logs → Änderungen (aus der DB) | | Änderungen (Audit-Trail) | `/var/log/tesm/changes.log` | Logs → Änderungen (aus der DB) |
| App (Fehler/Warnungen der Flask-App) | `/var/log/poe-manager/app.log` | nur Datei, keine eigene Seite | | 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`) | | Kea-DHCP | `/var/log/kea/kea-dhcp4.log` | Logs → Kea-DHCP (Recht `logs_kea.view`) |
- **Live-Log ist jetzt eine einzige, durchgehend fortgeschriebene Datei** - **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` automatisch ausgelösten) Dienst-Neustart ein neues `rpi-<timestamp>.log`
`poe.sh` räumt nicht mehr selbst nach 30 Tagen auf, das übernimmt jetzt `poe.sh` räumt nicht mehr selbst nach 30 Tagen auf, das übernimmt jetzt
zentral logrotate. Bestehende alte `rpi-*.log`-Dateien werden beim ersten zentral logrotate. Bestehende alte `rpi-*.log`-Dateien werden beim ersten
Start dieser Version einmalig aufgeräumt (Migration). 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 AppArmor-Profil (`/etc/apparmor.d/usr.sbin.kea-dhcp4`) erlaubt
Schreibzugriff exklusiv auf den Dateinamen `/var/log/kea/kea-dhcp4.log` Schreibzugriff exklusiv auf den Dateinamen `/var/log/kea/kea-dhcp4.log`
(kein Verzeichnis-Wildcard) — ein anderer Pfad wird mit `DENIED ... mknod (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 funktioniert ohne jede Kooperation des schreibenden Prozesses und läuft
als root, ist also auch von Keas AppArmor-Profil nicht betroffen. als root, ist also auch von Keas AppArmor-Profil nicht betroffen.
- **App-Log** (`app.log`) fängt Fehler/Warnungen der Flask-App ab (vorher - **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, selbst kontrollierbare Aufbewahrung) — bewusst nur `WARNING` und höher,
kein Access-/Request-Rauschen. kein Access-/Request-Rauschen.
- **Zeitzone** (Systemeinstellungen, zusammen mit dem Hostnamen in der - **Zeitzone** (Systemeinstellungen, zusammen mit dem Hostnamen in der
Kachel „Host“): setzt die System-Zeitzone per `timedatectl Kachel „Host“): setzt die System-Zeitzone per `timedatectl
set-timezone`, damit alle vier Logs sowie das Änderungslog die set-timezone`, damit alle vier Logs sowie das Änderungslog die
tatsächliche lokale Zeit statt UTC zeigen. Wirkt über `time.tzset()` 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 Neustart nötig) — `poe.sh`/`date` liest die Zeitzone ohnehin bei jedem
Prüf-Durchlauf frisch, da es ein neuer Prozess pro Durchlauf ist. Prüf-Durchlauf frisch, da es ein neuer Prozess pro Durchlauf ist.
- **Systemeinstellungen neu sortiert**: Host (Hostname + Zeitzone in einer - **Systemeinstellungen neu sortiert**: Host (Hostname + Zeitzone in einer
@@ -682,20 +829,85 @@ bereits den neuen Zeitstempel.
## Import/Export ## Import/Export
Unter **Einstellungen** lassen sich Geräte, Switche und Zugangsdaten Unter **Einstellungen → Im-/Export** lassen sich acht Kategorien einzeln
(inkl. entschlüsselter Passwörter) als ein einziges JSON-Bundle exportieren. 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 Das Bundle wird mit einem aus einer selbstgewählten Passphrase abgeleiteten
Schlüssel verschlüsselt (PBKDF2-HMAC-SHA256 + Fernet) — unabhängig vom Schlüssel verschlüsselt (PBKDF2-HMAC-SHA256 + Fernet) — unabhängig vom
`fernet.key` dieser Installation, damit die Datei auf einer komplett neuen `fernet.key` dieser Installation, damit die Datei auf einer komplett neuen
Umgebung (mit eigenem, neu generiertem `fernet.key`) wieder eingelesen 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 **Zweistufiger Import.** Datei hochladen + Passphrase eingeben lädt das
nach Name, Switche nach Hostname und Geräte nach MAC-Adresse abgeglichen — Bundle serverseitig nur kurzzeitig in den Speicher (Token-basiert, läuft
bestehende Einträge mit gleichem Schlüssel werden aktualisiert, neue werden nach einer Weile automatisch ab) und zeigt eine **Vorschau**: nur die
angelegt. Switche referenzieren ihre Zugangsdaten dabei über den Namen (nicht Kategorien, die tatsächlich in der Datei enthalten sind, jeweils mit Anzahl
die interne ID), damit der Import auch bei abweichender ID-Vergabe auf dem der Einträge. Erst danach wählt man aus, welche der vorhandenen Kategorien
Zielsystem korrekt verknüpft. 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 ## 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 Eingabe des aktuellen Passworts). Das ist bewusst getrennt vom Admin-Bereich
„Benutzer“, der andere Konten verwaltet. Ist ein Profilbild hinterlegt, „Benutzer“, der andere Konten verwaltet. Ist ein Profilbild hinterlegt,
erscheint es statt der Initialen sowohl in der Sidebar als auch im erscheint es statt der Initialen sowohl in der Sidebar als auch im
Änderungslog neben dem Benutzernamen. Admins sehen auf derselben Seite Änderungslog neben dem Benutzernamen. Die Navbar-Reihenfolge (für Admins)
zusätzlich die Navbar-Reihenfolge (siehe unten). liegt dagegen unter Systemeinstellungen, siehe
[Navigation anpassen](#navigation-anpassen).
## Warnung bei ungespeicherten Änderungen ## Warnung bei ungespeicherten Änderungen
@@ -754,23 +967,28 @@ verursachen, obwohl der Code auf dem Server längst aktuell war.
## Navigation anpassen ## 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, - **Geräte** (Gruppe) — Clients (die eigentlichen Devices), Switche,
Wartung (SSH-Bulk-Update/-Neustart) Zugangsdaten, Wartung (SSH-Bulk-Update/-Neustart), **Papierkorb**
- **Einstellungen** — Benutzer, Gruppen, Systemeinstellungen (Prüfintervall), (direkt unterhalb von Wartung, siehe [Papierkorb](#papierkorb))
Im-/Export - **Benutzer**, **Gruppen** — eigene, flache Top-Level-Punkte direkt nach
- **Logs** — Live, Änderungen (Änderungslog) „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 Die Reihenfolge der Top-Level-Punkte **und** der Unterpunkte innerhalb jeder
unter **Mein Konto** (Zahnrad-Symbol in der Sidebar) per Auf-/Ab-Buttons Gruppe ist für Admins unter **Systemeinstellungen** (eigene, nicht
konfigurierbar (gespeichert als JSON-Liste unter `settings.nav_order`) und einklappbare Karte „Navbar-Reihenfolge") per Auf-/Ab-Buttons konfigurierbar
gilt global für alle Benutzer. Unabhängig von der Reihenfolge sieht jeder (gespeichert als JSON unter `settings.nav_order` bzw.
Benutzer ohnehin nur, wofür er berechtigt ist — sowohl auf Gruppenebene als `settings.nav_child_order`) und gilt global für alle Benutzer. Unabhängig
auch pro Unterpunkt: hat ein Benutzer z.B. nur `devices.view`, erscheint die von der Reihenfolge sieht jeder Benutzer ohnehin nur, wofür er berechtigt
Gruppe „Geräte“ nur mit dem Unterpunkt „Clients“ (ohne Switche/ ist — sowohl auf Gruppenebene als auch pro Unterpunkt: hat ein Benutzer z.B.
Zugangsdaten); eine Gruppe verschwindet komplett, wenn kein Unterpunkt nur `devices.view`, erscheint die Gruppe „Geräte“ nur mit dem Unterpunkt
sichtbar ist. „Clients“ (ohne Switche/Zugangsdaten/Wartung/Papierkorb); eine Gruppe
verschwindet komplett, wenn kein Unterpunkt sichtbar ist.
## Entwicklung unter WSL ## 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 --install -d Ubuntu # einmalig, falls noch nicht vorhanden
wsl -d Ubuntu wsl -d Ubuntu
sudo apt update && sudo apt install -y python3 python3-venv python3-pip sqlite3 expect openssh-client 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 python3 -m venv venv
./venv/bin/pip install -r requirements.txt ./venv/bin/pip install -r requirements.txt
./venv/bin/python create_db.py ./venv/bin/python create_db.py
@@ -792,15 +1010,15 @@ python3 -m venv venv
# auch aus dem Windows-Browser erreichbar # 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, 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` aktuelle WSL2-Installationen unterstützen systemd, siehe `wsl.conf`
`[boot] systemd=true`). Für lokale Tests reicht es, `etc/systemd/system/ `[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 Produktivpfade (`/etc/systemd/system/`, `/usr/local/bin/custom/`) zu legen
und `/srv/poe_manager` auf den Checkout zu verlinken, dann `systemctl und `/srv/tesm` auf den Checkout zu verlinken, dann `systemctl
daemon-reload && systemctl enable --now rpi-check.service`. Ohne systemd daemon-reload && systemctl enable --now tesm-check.service`. Ohne systemd
(z.B. `systemctl` nicht vorhanden) scheitert der Neustart-Aufruf lautlos — (z.B. `systemctl` nicht vorhanden) scheitert der Neustart-Aufruf lautlos —
das Intervall in der DB wird trotzdem aktualisiert, greift dann aber erst das Intervall in der DB wird trotzdem aktualisiert, greift dann aber erst
nach einem manuellen Neustart des Prozesses. nach einem manuellen Neustart des Prozesses.
@@ -808,7 +1026,7 @@ nach einem manuellen Neustart des Prozesses.
## Lokal starten (Entwicklung) ## Lokal starten (Entwicklung)
```bash ```bash
cd srv/poe_manager cd srv/tesm
python3 -m venv venv python3 -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
@@ -819,23 +1037,23 @@ python create_admin.py # legt ersten Admin-Benutzer an
python app.py # http://localhost:5000 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 — Das Repo wird bewusst **nicht** direkt nach `/srv/tesm` geklont —
`install.sh` kopiert (`rsync`) den Inhalt von `srv/poe_manager/` aus dem Repo `install.sh` kopiert (`rsync`) den Inhalt von `srv/tesm/` aus dem Repo
gezielt dorthin und lässt Quell-Checkout und Zielverzeichnis damit sauber 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 ```bash
sudo git clone <dieses-repo> /opt/aruba-poe-modern-src sudo git clone <dieses-repo> /opt/aruba-poe-modern-src
cd /opt/aruba-poe-modern-src cd /opt/aruba-poe-modern-src
sudo bash install.sh 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 Nach der Installation liegt die laufende App vollständig unter
**`/srv/poe_manager`** (so wie es `poe_web.service`, `rpi-check.service` und **`/srv/tesm`** (so wie es `tesm.service`, `tesm-check.service` und
`POE_SCRIPT`/`POE_DB_PATH` als Default erwarten) — `/opt/aruba-poe-modern-src` `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 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` liegen bleiben (für spätere Updates: `git pull` dort, dann `install.sh`
erneut ausführen) oder gelöscht werden. 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 `install.sh` installiert Systempakete, richtet die venv ein, initialisiert die
Datenbank (falls nicht vorhanden), kopiert `poe.sh` nach Datenbank (falls nicht vorhanden), kopiert `poe.sh` nach
`/usr/local/bin/custom/`, registriert die systemd-Units `/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. konfiguriert nginx als Reverse-Proxy auf Port 80.
### Relevante Umgebungsvariablen (optional) ### Relevante Umgebungsvariablen (optional)
| Variable | Zweck | Default | | 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_SCRIPT` | Pfad zum PoE-Restart-Skript | `/usr/local/bin/custom/poe.sh` |
| `POE_LOG_GLOB` | Glob-Pattern der Check-Logs | `/var/log/rpi-*.log` | | `TESM_LOG_DIR` | Verzeichnis für `live.log`/`changes.log`/`app.log` | `/var/log/tesm` |
| `POE_FERNET_KEY` | Pfad zum Fernet-Schlüssel | `<app-dir>/fernet.key` (auto-erzeugt)| | `TESM_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)| | `TESM_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_SECRET_KEY` | Flask Secret Key (überschreibt Datei) | zufällig, in `secret.key` gespeichert|
| `PORT`, `FLASK_DEBUG` | Flask-Serverport / Debug-Modus | `5000` / aus | | `PORT`, `FLASK_DEBUG` | Flask-Serverport / Debug-Modus | `5000` / aus |
## Browser-SSH-Terminal (Switch-Verbindungstest) ## Browser-SSH-Terminal (Switch-Verbindungstest)
@@ -881,24 +1099,24 @@ Voraussetzungen:
ohnehin ein WSGI-Server mit WebSocket-Unterstützung (z.B. Gunicorn mit ohnehin ein WSGI-Server mit WebSocket-Unterstützung (z.B. Gunicorn mit
`gevent`- oder `eventlet`-Worker) statt des eingebauten Dev-Servers. `gevent`- oder `eventlet`-Worker) statt des eingebauten Dev-Servers.
- **nginx muss den WebSocket-Upgrade für `/ws/` durchreichen** (`Upgrade`/ - **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 das lehnt Flask den Handshake mit `400 Bad Request` ab und das Terminal
bleibt leer auf „Getrennt“ hängen, obwohl derselbe Aufruf direkt gegen bleibt leer auf „Getrennt“ hängen, obwohl derselbe Aufruf direkt gegen
Port 5000 (an nginx vorbei) funktioniert. War auf einer frisch per Port 5000 (an nginx vorbei) funktioniert. War auf einer frisch per
`install.sh` aufgesetzten Ubuntu-VM live reproduzierbar, da alle bisherigen `install.sh` aufgesetzten Ubuntu-VM live reproduzierbar, da alle bisherigen
Terminal-Tests ausschließlich direkt gegen Flask liefen, nie durch nginx. 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 — Terminal im Prinzip jeder erreichbare Host per SSH kontaktiert werden —
bewusst so gewählt, da Admins über die App ohnehin schon vollen Zugriff auf bewusst so gewählt, da Admins über die App ohnehin schon vollen Zugriff auf
alle hinterlegten Switch-Zugangsdaten haben. Für eine strengere Trennung 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 unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier
aktuell `root`, um Änderungen am produktiven Setup nicht zu vergrößern). aktuell `root`, um Änderungen am produktiven Setup nicht zu vergrößern).
## Projektstruktur ## 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) usr/local/bin/custom/ poe.sh — Ping-Check & PoE-Restart per SSH (expect)
etc/systemd/system/ Service-/Timer-Units (Web-App + Check-Dienst) etc/systemd/system/ Service-/Timer-Units (Web-App + Check-Dienst)
etc/nginx/ Reverse-Proxy-Konfiguration etc/nginx/ Reverse-Proxy-Konfiguration
@@ -37,7 +37,7 @@ server {
} }
location /static/ { location /static/ {
alias /srv/poe_manager/static/; alias /srv/tesm/static/;
expires 7d; 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] [Unit]
Description=PoE Manager Web App Description=TESM Web App
After=network.target After=network.target
[Service] [Service]
Type=simple Type=simple
User=root User=root
WorkingDirectory=/srv/poe_manager WorkingDirectory=/srv/tesm
# Nutze die virtuelle Umgebung # 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 Restart=always
RestartSec=5 RestartSec=5
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+23 -23
View File
@@ -1,9 +1,9 @@
#!/bin/bash #!/bin/bash
# ============================================================================ # ============================================================================
# PoE Manager — Installer # TESM — Installer
# Richtet System-Pakete, die Flask-App (systemd) sowie den Erreichbarkeits- # Richtet System-Pakete, die Flask-App (systemd) sowie den Erreichbarkeits-
# Check-Dienst (systemd + Timer) ein. Auszuführen als root, nachdem dieses # 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 set -e
@@ -13,45 +13,45 @@ GREEN='\033[0;32m'
NC='\033[0m' NC='\033[0m'
print_status() { echo -e "${GREEN}${NC} ${1} completed."; } print_status() { echo -e "${GREEN}${NC} ${1} completed."; }
step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/poe-manager-install.log; } step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/tesm-install.log; }
clear clear
# ---- Pakete ---- # ---- Pakete ----
step "Installing system packages" step "Installing system packages"
sudo apt-get update >>/var/log/poe-manager-install.log 2>&1 && print_status "apt update" sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update"
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/poe-manager-install.log 2>&1 && print_status "Packages installed" sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
# ---- Log-Verzeichnis ---- # ---- 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 # der i.d.R. unprivilegierte kea-dhcp4-server-Dienstbenutzer (_kea) hier
# ihre jeweils eigene Log-Datei anlegen/schreiben müssen. Die eigentliche # 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. # selbst beim Start bzw. beim Speichern unter Systemeinstellungen → Logs.
sudo mkdir -p /var/log/poe-manager sudo mkdir -p /var/log/tesm
sudo chmod 777 /var/log/poe-manager sudo chmod 777 /var/log/tesm
# ---- App-Verzeichnis ---- # ---- App-Verzeichnis ----
step "Deploying application to /srv/poe_manager" step "Deploying application to /srv/tesm"
sudo mkdir -p /srv/poe_manager sudo mkdir -p /srv/tesm
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \ sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
"$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" print_status "Application files copied"
# ---- Python venv ---- # ---- Python venv ----
step "Setting up Python virtual environment" step "Setting up Python virtual environment"
cd /srv/poe_manager cd /srv/tesm
sudo python3 -m venv venv 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 --upgrade pip >>/var/log/tesm-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 -r requirements.txt >>/var/log/tesm-install.log 2>&1
print_status "Virtual environment ready" print_status "Virtual environment ready"
# ---- Datenbank ---- # ---- Datenbank ----
if [ ! -f /srv/poe_manager/sqlite.db ]; then if [ ! -f /srv/tesm/sqlite.db ]; then
step "Initializing database" 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" 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 fi
# ---- Custom-Skripte + systemd ---- # ---- Custom-Skripte + systemd ----
@@ -65,17 +65,17 @@ print_status "Scripts and units copied"
# ---- nginx ---- # ---- nginx ----
step "Configuring nginx reverse proxy" step "Configuring nginx reverse proxy"
sudo cp "$REPO_DIR/etc/nginx/sites-available/poe_manager" /etc/nginx/sites-available/poe_manager sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm
sudo ln -sf /etc/nginx/sites-available/poe_manager /etc/nginx/sites-enabled/poe_manager sudo ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm
sudo rm -f /etc/nginx/sites-enabled/default sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t >>/var/log/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" print_status "nginx configured"
# ---- Dienste aktivieren ---- # ---- Dienste aktivieren ----
step "Enabling services" step "Enabling services"
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now poe_web.service sudo systemctl enable --now tesm.service
sudo systemctl enable --now rpi-check.service rpi-check-restart.timer sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
print_status "Services enabled and started" print_status "Services enabled and started"
echo -e "${GREEN}${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)." 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 #!/usr/bin/env python3
"""Initialisiert die SQLite-Datenbank für den PoE Manager.""" """Initialisiert die SQLite-Datenbank für TESM."""
import os import os
import sqlite3 import sqlite3
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS switches (
c.execute(""" c.execute("""
CREATE TABLE IF NOT EXISTS devices ( CREATE TABLE IF NOT EXISTS devices (
mac TEXT PRIMARY KEY, mac TEXT PRIMARY KEY,
rpi_ip TEXT NOT NULL, ip TEXT NOT NULL,
switch_hostname TEXT, switch_hostname TEXT,
port TEXT, port TEXT,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -58,7 +58,10 @@ CREATE TABLE IF NOT EXISTS users (
is_admin INTEGER DEFAULT 0, is_admin INTEGER DEFAULT 0,
first_name TEXT, first_name TEXT,
last_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(""" devices = conn.execute("""
SELECT mac, rpi_ip, port, name, switch_hostname SELECT mac, ip, port, name, switch_hostname
FROM devices FROM devices
WHERE is_active=1 WHERE is_active=1 AND deleted_at IS NULL
""").fetchall() """).fetchall()
conn.close() conn.close()
@@ -44,7 +44,7 @@ def generate_ips_list():
port = dev["port"] or "" port = dev["port"] or ""
print( print(
f"{dev['rpi_ip']}|" f"{dev['ip']}|"
f"{dev['name']}|" f"{dev['name']}|"
f"{switch_ip}|" f"{switch_ip}|"
f"{switch_ssh_port}|" f"{switch_ssh_port}|"
@@ -9,8 +9,10 @@ Flask-Login==0.6.3
Flask-Sock==0.7.0 Flask-Sock==0.7.0
itsdangerous==2.2.0 itsdangerous==2.2.0
Jinja2==3.1.6 Jinja2==3.1.6
ldap3==2.9.1
MarkupSafe==3.0.2 MarkupSafe==3.0.2
paramiko==3.5.1 paramiko==3.5.1
pyasn1==0.6.4
pycparser==2.23 pycparser==2.23
PyNaCl==1.5.0 PyNaCl==1.5.0
PyYAML==6.0.2 PyYAML==6.0.2
@@ -25,3 +27,7 @@ Werkzeug==3.1.3
# PyYAML wird für das netplan-Backend der Host-Netzwerkeinstellungen # PyYAML wird für das netplan-Backend der Host-Netzwerkeinstellungen
# gebraucht (Systemeinstellungen → Netzwerkeinstellungen) — schreibt/liest # gebraucht (Systemeinstellungen → Netzwerkeinstellungen) — schreibt/liest
# die eigene Override-Datei unter /etc/netplan/. # 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 5vw;
background: background:
radial-gradient(circle at 15% 15%, rgba(255, 113, 0, 0.14), transparent 45%), 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%), radial-gradient(circle at 85% 85%, rgba(255, 113, 0, 0.08), transparent 40%),
@@ -1009,6 +1010,16 @@ select {
padding: 20px; 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 { .login-card {
width: 100%; width: 100%;
max-width: 380px; 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) => { document.querySelectorAll("[data-theme-icon]").forEach((el) => {
el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun; 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() { function initTheme() {
@@ -31,7 +31,7 @@
data-status="{{ 'disabled' if d['is_active'] == 0 else st }}" data-status="{{ 'disabled' if d['is_active'] == 0 else st }}"
data-mac="{{ d['mac'] }}" data-mac="{{ d['mac'] }}"
data-name="{{ d['name'] }}" 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-switch="{{ d['switch_hostname'] or '-' }}"
data-port="{{ d['port'] or '-' }}" data-port="{{ d['port'] or '-' }}"
data-active="{{ d['is_active'] }}" data-active="{{ d['is_active'] }}"
@@ -48,7 +48,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="dc-name">{{ d['name'] }}</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> </div>
{% endmacro %} {% endmacro %}
@@ -39,80 +39,26 @@
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Passwort ändern</h2> <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> </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"> <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>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</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> <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> <button type="submit" name="change_password" value="1" class="btn btn-secondary btn-block">Passwort ändern</button>
</form> </form>
{% endif %}
</div> </div>
</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 %} {% endblock %}
@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or "PoE Manager" }}</title> <title>{{ title or "TESM" }}</title>
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}"> <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') }}"> <link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </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"/>', "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"/>', "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"/>', "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"> <div class="app-shell">
@@ -34,10 +35,7 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand">
<div> <img id="sidebar-logo" src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM" style="width:100%; height:auto; display:block;">
<span class="brand-name">PoE Manager</span>
<span class="brand-sub">Device Monitoring</span>
</div>
</div> </div>
<nav class="sidebar-nav"> <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> <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> </a>
</div> </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> </div>
</aside> </aside>
{% endif %} {% endif %}
@@ -23,8 +23,14 @@
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><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;"> <div style="overflow-x:auto;">
<table class="data-table" data-sortable> <table class="data-table" id="credentialsTable" data-sortable>
<thead><tr> <thead><tr>
<th data-sort-key="name">Name</th> <th data-sort-key="name">Name</th>
<th data-sort-key="username">Username</th> <th data-sort-key="username">Username</th>
@@ -171,6 +177,14 @@ function openEditCredentialModal(id, name, username, category) {
PoeUI.openModal("editCredentialModal"); 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) { function validateCredentialForm(form, id) {
const pass = document.getElementById("password_" + id); const pass = document.getElementById("password_" + id);
const confirm = document.getElementById("password_confirm_" + id); const confirm = document.getElementById("password_confirm_" + id);
@@ -50,9 +50,9 @@
</thead> </thead>
<tbody> <tbody>
{% for d in devices %} {% 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="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 class="mono">{{ d['mac'] }}</td>
<td>{{ d['switch_hostname'] or '—' }}</td> <td>{{ d['switch_hostname'] or '—' }}</td>
<td>{{ d['port'] or '—' }}</td> <td>{{ d['port'] or '—' }}</td>
@@ -76,7 +76,7 @@
<div class="row-actions"> <div class="row-actions">
{% if can_edit %} {% if can_edit %}
<button class="icon-btn" title="Bearbeiten" <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> <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>
<button class="icon-btn" title="Switch ändern" <button class="icon-btn" title="Switch ändern"
@@ -119,7 +119,7 @@
<input type="text" name="name" required placeholder="z.B. Sensor01"> <input type="text" name="name" required placeholder="z.B. Sensor01">
</div> </div>
<div class="field"><label>IP-Adresse</label> <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 class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div> </div>
<div class="field"><label>MAC-Adresse</label> <div class="field"><label>MAC-Adresse</label>
@@ -196,7 +196,7 @@
<input type="text" name="name" id="edit_name" required> <input type="text" name="name" id="edit_name" required>
</div> </div>
<div class="field"><label>IP-Adresse</label> <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 class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div> </div>
<div class="field"><label>MAC-Adresse</label> <div class="field"><label>MAC-Adresse</label>
@@ -361,7 +361,7 @@ function validateNewCredentialPassword(form) {
return true; return true;
} }
function validateDeviceForm(form) { 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']"); const macInput = form.querySelector("input[name='mac']");
let valid = true; let valid = true;
if (ipInput) valid = validateIP(ipInput) && valid; if (ipInput) valid = validateIP(ipInput) && valid;
@@ -370,7 +370,7 @@ function validateDeviceForm(form) {
return valid; return valid;
} }
document.addEventListener("input", (e) => { 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 === "mac") validateMAC(e.target);
if (e.target.name === "new_credential_password_confirm") { if (e.target.name === "new_credential_password_confirm") {
const form = e.target.closest("form"); const form = e.target.closest("form");
@@ -395,7 +395,7 @@ function resetCredentialChoice(id) {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) — identisch zum // 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; let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
@@ -444,7 +444,7 @@ function getCredentialInfo(form) {
} }
function openTerminal(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 port = parseInt((form.querySelector("input[name='ssh_port']") || {}).value, 10) || 22;
const { username, passwordInput } = getCredentialInfo(form); const { username, passwordInput } = getCredentialInfo(form);
activePasswordInput = passwordInput; activePasswordInput = passwordInput;
@@ -82,8 +82,9 @@
</div> </div>
<div class="permission-legend"> <div class="permission-legend">
<strong>R</strong> = Read (Lesen) · <strong>W</strong> = Write (Anlegen) · <strong>R</strong> = Read (Lesen) · <strong>W</strong> = Write (Anlegen) ·
<strong>E</strong> = Edit (Ändern, inkl. Löschen) · <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>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> </div>
{% endmacro %} {% endmacro %}
@@ -104,8 +105,14 @@
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><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;"> <div style="overflow-x:auto;">
<table class="data-table" data-sortable> <table class="data-table" id="groupsTable" data-sortable>
<thead><tr> <thead><tr>
<th data-sort-key="name">Gruppe</th> <th data-sort-key="name">Gruppe</th>
<th data-sort-key="members">Mitglieder</th> <th data-sort-key="members">Mitglieder</th>
@@ -168,6 +175,7 @@
</div> </div>
</td> </td>
</tr> </tr>
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<tr class="group-detail-row hidden" id="detail-{{ g.id }}"> <tr class="group-detail-row hidden" id="detail-{{ g.id }}">
<td colspan="3"> <td colspan="3">
{% if can_edit_this %} {% if can_edit_this %}
@@ -184,6 +192,32 @@
</button> </button>
</div> </div>
</form> </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 %} {% else %}
{{ permission_tree(g.permissions, true, true) }} {{ permission_tree(g.permissions, true, true) }}
{% if g.is_system %} {% if g.is_system %}
@@ -295,6 +329,31 @@
{% block scripts %} {% block scripts %}
<script> <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) { function toggleDetail(id) {
const row = document.getElementById(id); const row = document.getElementById(id);
if (!row) return; if (!row) return;
@@ -329,6 +388,33 @@ function applyPermissionGating() {
sync(); 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", applyPermissionGating);
document.addEventListener("DOMContentLoaded", applyRowViewPrerequisite);
</script> </script>
{% endblock %} {% endblock %}
@@ -3,18 +3,23 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login · PoE Manager</title> <title>Login · TESM</title>
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}"> <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') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head> </head>
<body> <body>
<div class="login-page"> <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-card">
<div class="login-logo"> <div class="login-logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS"> <img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
</div> </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> <p class="login-sub">Melde dich an, um fortzufahren</p>
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}
@@ -26,8 +26,14 @@
<form method="post" action="{{ url_for('maintenance_update') }}" id="updateForm"> <form method="post" action="{{ url_for('maintenance_update') }}" id="updateForm">
<div class="table-wrap"> <div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><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;"> <div style="overflow-x:auto;">
<table class="data-table" data-sortable> <table class="data-table" id="maintenanceTable" data-sortable>
<thead><tr> <thead><tr>
{% if can_run %}<th style="width:1%;"><input type="checkbox" id="selectAll" title="Alle auswählen"></th>{% endif %} {% 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> <th data-sort-key="name">Name</th>
@@ -39,12 +45,12 @@
<tbody> <tbody>
{% for d in devices %} {% for d in devices %}
{% set job = jobs.get(d['mac']) %} {% 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 %} {% if can_run %}
<td><input type="checkbox" name="macs" value="{{ d['mac'] }}" class="maint-check" {{ 'disabled' if not d['is_active'] else '' }}></td> <td><input type="checkbox" name="macs" value="{{ d['mac'] }}" class="maint-check" {{ 'disabled' if not d['is_active'] else '' }}></td>
{% endif %} {% 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="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="mono">{{ d['cred_username'] }}</td>
<td class="maint-status"> <td class="maint-status">
{% if job %} {% 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> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button> </button>
{% if can_run %} {% 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'] }}"> <input type="hidden" name="macs" value="{{ d['mac'] }}">
<button type="submit" class="btn btn-sm btn-secondary">Neustart</button> <button type="submit" class="btn btn-sm btn-secondary">Neustart</button>
</form> </form>
@@ -103,6 +109,17 @@ document.getElementById("selectAll")?.addEventListener("change", function () {
}, this); }, 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) { function toggleJobOutput(mac) {
const row = document.getElementById("joboutput-" + mac); const row = document.getElementById("joboutput-" + mac);
if (row) row.style.display = (row.style.display === "none" || !row.style.display) ? "table-row" : "none"; 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 Zeitzone setzen
</button> </button>
</form> </form>
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post">
<div class="field">
<label for="interval">Prüfintervall (Minuten)</label>
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
<div class="field-hint">Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (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 %} {% else %}
<div class="field"> <div class="field">
<label>Hostname</label> <label>Hostname</label>
@@ -49,6 +61,11 @@
<label>Zeitzone</label> <label>Zeitzone</label>
<input type="text" value="{{ current_timezone or '' }}" disabled> <input type="text" value="{{ current_timezone or '' }}" disabled>
</div> </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 %} {% endif %}
</div> </div>
@@ -192,30 +209,98 @@
<div class="card card-pad"> <div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Prüfintervall</h2> <h2 style="font-size:16px;">Papierkorb</h2>
<div class="hint">Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?</div> <div class="hint">Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen — Liste unter Geräte → Wartung.</div>
</div> </div>
</div> </div>
{% if current_user.has_permission('settings_system.edit') %} {% if current_user.has_permission('settings_system.edit') %}
<form method="post"> <form method="post">
<div class="field"> <div class="field">
<label for="interval">Intervall (Minuten)</label> <label for="trash_retention_days">Aufbewahrungsdauer (Tage)</label>
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required> <input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required>
<div class="field-hint">Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.</div> <div class="field-hint">Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" name="save_trash_retention" value="1" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
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> </button>
</form> </form>
{% else %} {% else %}
<div class="field"> <div class="field">
<label>Intervall (Minuten)</label> <label>Aufbewahrungsdauer (Tage)</label>
<input type="number" value="{{ interval }}" disabled> <input type="number" value="{{ trash_retention_days }}" disabled>
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
</div> </div>
{% endif %} {% 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">
<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> </div>
{% endblock %} {% 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 %}