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.
@@ -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/
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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>×</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>×</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>×</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 %}
|
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="1" y="1" width="62" height="62" rx="14" fill="#10151B"></rect>
|
||||||
|
<g transform="translate(5,20.19) scale(0.16875)">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 842 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="1" y="1" width="62" height="62" rx="14" fill="#EDF1F2" stroke="#C1CBCE" stroke-width="2"></rect>
|
||||||
|
<g transform="translate(5,20.19) scale(0.16875)">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(150,15)">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
|
||||||
|
</g>
|
||||||
|
<text x="310" y="178" text-anchor="middle" font-family="ui-sans-serif, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
|
font-weight="600" font-size="13" letter-spacing="1.5" fill="#E2A63C">ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 320 140" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 699 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(150,15)">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
|
||||||
|
</g>
|
||||||
|
<text x="310" y="178" text-anchor="middle" font-family="ui-sans-serif, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
|
font-weight="600" font-size="13" letter-spacing="1.5" fill="#B5750E">ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 320 140" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 698 B |
|
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>
|
||||||
</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 %}
|
{% endif %}
|
||||||
</li>
|
</div>
|
||||||
{% 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>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% 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 %}
|
|
||||||
@@ -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";
|
||||||
@@ -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 & 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 %}
|
||||||
@@ -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>×</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 %}
|
||||||
@@ -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>×</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>×</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>×</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>×</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 %}
|
||||||