Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
682e9b4420 | ||
|
|
90e07aa310 | ||
|
|
c9ac0bf69b | ||
|
|
b70ebefb17 | ||
|
|
d90c27b8f8 |
@@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
client_max_body_size 4m;
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /srv/tesm-license/static/;
|
||||||
|
expires 7d;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM Lizenzserver (Master)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/srv/tesm-license
|
||||||
|
# Analog zu TESM_WEB_PROCESS bei TESM selbst -- markiert diesen Prozess als
|
||||||
|
# den echten Web-Dienst (startet den Lizenz-Heartbeat- und den Auditlog-
|
||||||
|
# Archivierungs-Thread genau einmal). Anders als bei TESM importiert hier
|
||||||
|
# kein Hilfsskript app.py periodisch als Modul (kein Geräte-Polling auf
|
||||||
|
# dem Lizenzserver) -- das Flag bleibt trotzdem gesetzt, für den Fall
|
||||||
|
# künftiger Hilfsskripte und zur Konsistenz mit dem TESM-Muster.
|
||||||
|
Environment=TESM_LICENSE_WEB_PROCESS=1
|
||||||
|
# Kein WebSocket-Terminal wie bei TESM -- gthread/--timeout 0 daher nicht
|
||||||
|
# nötig, ein paar Threads reichen für die parallele Aktivierungs-/
|
||||||
|
# Heartbeat-API mehrerer Kunden.
|
||||||
|
# "--bind 127.0.0.1:5000" wie bei TESM: nur über den nginx-Reverse-Proxy
|
||||||
|
# von außen erreichbar.
|
||||||
|
ExecStart=/srv/tesm-license/venv/bin/gunicorn --workers 1 --threads 8 \
|
||||||
|
--bind 127.0.0.1:5000 app:app
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================================
|
||||||
|
# TESM-Lizenzserver — Installer/Updater
|
||||||
|
# Fork von install.sh (TESM selbst), auf /srv/tesm-license statt /srv/tesm
|
||||||
|
# beschränkt -- deployt NUR den Master-Lizenzserver-Teil dieses Repos
|
||||||
|
# (srv/tesm-license/), lässt srv/tesm/ komplett unangetastet. Für dasselbe
|
||||||
|
# Sicherheitsnetz (Backup + Health-Check + automatischer Rückroll) wie bei
|
||||||
|
# TESM, siehe dortigen Kommentarkopf für die ausführliche Begründung --
|
||||||
|
# hier nur die Kurzfassung je Schritt.
|
||||||
|
#
|
||||||
|
# Auszuführen als root, nachdem dieses Repo/Release-Paket z.B. nach
|
||||||
|
# /root/tesm entpackt wurde:
|
||||||
|
# sudo ./install-license.sh
|
||||||
|
# ============================================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
print_status() { echo -e "${GREEN}✔${NC} ${1} completed."; }
|
||||||
|
step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-license-install.log; }
|
||||||
|
|
||||||
|
install_if_changed() {
|
||||||
|
local src="$1" dst="$2" mode="${3:-644}"
|
||||||
|
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sudo install -m "$mode" "$src" "$dst"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
clear 2>/dev/null || true
|
||||||
|
|
||||||
|
# ---- Bestehende Installation erkennen ----
|
||||||
|
EXISTING_INSTALL=0
|
||||||
|
[ -f /srv/tesm-license/sqlite.db ] && EXISTING_INSTALL=1
|
||||||
|
|
||||||
|
NEEDS_MIGRATION_GUARD=0
|
||||||
|
BACKUP_DIR=""
|
||||||
|
|
||||||
|
if [ "$EXISTING_INSTALL" -eq 1 ]; then
|
||||||
|
OLD_SCHEMA_VERSION=""
|
||||||
|
[ -f /srv/tesm-license/SCHEMA_VERSION ] && OLD_SCHEMA_VERSION="$(tr -d '[:space:]' < /srv/tesm-license/SCHEMA_VERSION)"
|
||||||
|
NEW_SCHEMA_VERSION=""
|
||||||
|
[ -f "$REPO_DIR/srv/tesm-license/SCHEMA_VERSION" ] && NEW_SCHEMA_VERSION="$(tr -d '[:space:]' < "$REPO_DIR/srv/tesm-license/SCHEMA_VERSION")"
|
||||||
|
|
||||||
|
step "Stopping tesm-license.service for update"
|
||||||
|
systemctl stop tesm-license.service 2>/dev/null || true
|
||||||
|
print_status "Service stopped"
|
||||||
|
|
||||||
|
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then
|
||||||
|
echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} In-Place-Update."
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Bestehende Installation erkannt, Schema-Version geändert oder unbekannt:${NC} Update mit automatischer Migration."
|
||||||
|
fi
|
||||||
|
NEEDS_MIGRATION_GUARD=1
|
||||||
|
BACKUP_DIR="/srv/tesm-license-backup-pre-update-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
step "Backing up current installation to $BACKUP_DIR before update"
|
||||||
|
cp -a /srv/tesm-license "$BACKUP_DIR"
|
||||||
|
print_status "Backup created"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Pakete ----
|
||||||
|
step "Installing system packages"
|
||||||
|
sudo apt-get update >>/var/log/tesm-license-install.log 2>&1 && print_status "apt update"
|
||||||
|
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 rsync logrotate certbot >>/var/log/tesm-license-install.log 2>&1 && print_status "Packages installed"
|
||||||
|
|
||||||
|
# ---- Log-Verzeichnis ----
|
||||||
|
sudo mkdir -p /var/log/tesm-license
|
||||||
|
sudo chmod 755 /var/log/tesm-license
|
||||||
|
|
||||||
|
# ---- App-Verzeichnis ----
|
||||||
|
step "Deploying application to /srv/tesm-license"
|
||||||
|
sudo mkdir -p /srv/tesm-license
|
||||||
|
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||||
|
--exclude 'license.json' --exclude 'master_signing_key.json' \
|
||||||
|
"$REPO_DIR/srv/tesm-license/" /srv/tesm-license/ >>/var/log/tesm-license-install.log 2>&1
|
||||||
|
print_status "Application files copied"
|
||||||
|
|
||||||
|
# ---- Python venv ----
|
||||||
|
step "Setting up Python virtual environment"
|
||||||
|
cd /srv/tesm-license
|
||||||
|
sudo python3 -m venv venv
|
||||||
|
sudo ./venv/bin/python3 -m pip install --upgrade pip >>/var/log/tesm-license-install.log 2>&1
|
||||||
|
sudo ./venv/bin/python3 -m pip install -r requirements.txt >>/var/log/tesm-license-install.log 2>&1
|
||||||
|
print_status "Virtual environment ready"
|
||||||
|
|
||||||
|
# ---- Datenbank ----
|
||||||
|
if [ ! -f /srv/tesm-license/sqlite.db ]; then
|
||||||
|
step "Initializing database"
|
||||||
|
sudo ./venv/bin/python3 create_db.py >>/var/log/tesm-license-install.log 2>&1
|
||||||
|
print_status "Database initialized"
|
||||||
|
echo -e "${RED}→${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen:"
|
||||||
|
echo " sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_admin.py"
|
||||||
|
echo -e "${RED}→${NC} Und die eigene Bootstrap-Lizenz erzeugen:"
|
||||||
|
echo " sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_master_license.py"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- systemd ----
|
||||||
|
step "Copying systemd unit"
|
||||||
|
UNITS_CHANGED=0
|
||||||
|
install_if_changed "$REPO_DIR/etc/systemd/system/tesm-license.service" /etc/systemd/system/tesm-license.service 644 && UNITS_CHANGED=1
|
||||||
|
print_status "Unit copied"
|
||||||
|
|
||||||
|
# ---- nginx ----
|
||||||
|
step "Configuring nginx reverse proxy"
|
||||||
|
if [ ! -f /etc/nginx/sites-available/tesm-license ]; then
|
||||||
|
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm-license" /etc/nginx/sites-available/tesm-license
|
||||||
|
fi
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/tesm-license /etc/nginx/sites-enabled/tesm-license
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/default
|
||||||
|
sudo nginx -t >>/var/log/tesm-license-install.log 2>&1 && sudo systemctl reload nginx
|
||||||
|
print_status "nginx configured"
|
||||||
|
|
||||||
|
# ---- Dienst aktivieren ----
|
||||||
|
step "Enabling service"
|
||||||
|
if [ "$UNITS_CHANGED" -eq 1 ]; then
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
sudo systemctl enable --now tesm-license.service
|
||||||
|
print_status "Service enabled and started"
|
||||||
|
|
||||||
|
# ---- Sicherheitsnetz prüfen ----
|
||||||
|
if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
|
||||||
|
step "Verifying migrated installation"
|
||||||
|
HEALTHY=0
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
sleep 2
|
||||||
|
if curl -fsS -o /dev/null http://127.0.0.1:5000/login 2>/dev/null; then
|
||||||
|
HEALTHY=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$HEALTHY" -eq 1 ]; then
|
||||||
|
print_status "Migration verified healthy"
|
||||||
|
rm -rf "$BACKUP_DIR"
|
||||||
|
echo -e "${GREEN}✔${NC} Update erfolgreich -- Backup wieder entfernt."
|
||||||
|
else
|
||||||
|
echo -e "${RED}✖ Update fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}"
|
||||||
|
sudo systemctl stop tesm-license.service 2>/dev/null || true
|
||||||
|
FAILED_DIR="/srv/tesm-license-failed-update-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
sudo mv /srv/tesm-license "$FAILED_DIR"
|
||||||
|
sudo cp -a "$BACKUP_DIR" /srv/tesm-license
|
||||||
|
sudo systemctl start tesm-license.service
|
||||||
|
echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update.${NC}"
|
||||||
|
echo "Backup bleibt erhalten unter: ${BACKUP_DIR}"
|
||||||
|
echo "Die fehlgeschlagene Installation liegt zur Fehlersuche unter: ${FAILED_DIR}"
|
||||||
|
echo "Bitte /var/log/tesm-license/app.log dort prüfen, bevor erneut aktualisiert wird."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✔${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)."
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.0.0
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Legt einen Admin-Benutzer im TESM-Lizenzserver an."""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from getpass import getpass
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DB_PATH = os.environ.get("TESM_LICENSE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
username = input("Admin-Benutzername: ").strip()
|
||||||
|
password = getpass("Passwort: ")
|
||||||
|
password_confirm = getpass("Passwort bestätigen: ")
|
||||||
|
|
||||||
|
if password != password_confirm:
|
||||||
|
print("Passwörter stimmen nicht überein!")
|
||||||
|
return
|
||||||
|
|
||||||
|
pw_hash = bcrypt.generate_password_hash(password).decode("utf-8")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
|
||||||
|
(username, pw_hash, 1),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
print(f"Admin-Benutzer '{username}' erfolgreich angelegt.")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
print("Benutzername existiert bereits!")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Initialisiert die SQLite-Datenbank für den TESM-Lizenzserver (Master).
|
||||||
|
|
||||||
|
WICHTIG: Wer hier eine neue Tabelle oder Spalte hinzufügt, muss auch die
|
||||||
|
Datei SCHEMA_VERSION (daneben, im selben Verzeichnis) um 1 erhöhen.
|
||||||
|
update.sh vergleicht diese Zahl zwischen der aktuell installierten und der
|
||||||
|
neuen Version, um zu entscheiden, ob ein Update in-place laufen darf
|
||||||
|
(bestehende Datenbank bleibt erhalten) oder eine frische Installation
|
||||||
|
nötig ist (Datenbank wird beiseitegesichert). Ein vergessenes Hochzählen
|
||||||
|
würde dazu führen, dass ein Host mit altem Schema faelschlich per
|
||||||
|
In-Place-Update aktualisiert wird, statt frisch installiert zu werden --
|
||||||
|
_ensure_schema() faengt die meisten Faelle zwar zur Laufzeit ab, ist aber
|
||||||
|
kein Ersatz fuer eine korrekte Versionsnummer hier.
|
||||||
|
|
||||||
|
Gegenüber TESM entfernt: credentials/switches/devices/dhcp_*/
|
||||||
|
ldap_fileshare_mappings (kein Geräte-/DHCP-/Fileshare-Management auf dem
|
||||||
|
Lizenzserver). Neu: license_customers/licenses (Kern des Lizenzservers,
|
||||||
|
siehe licensing.py für das zugehörige Kryptographie-/Protokollformat)."""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DB_PATH = os.environ.get("TESM_LICENSE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
is_admin INTEGER DEFAULT 0,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
avatar_filename TEXT,
|
||||||
|
auth_source TEXT NOT NULL DEFAULT 'local',
|
||||||
|
email TEXT,
|
||||||
|
is_locked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_at TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
is_default INTEGER DEFAULT 0,
|
||||||
|
is_system INTEGER DEFAULT 0,
|
||||||
|
deleted_at TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS group_permissions (
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, permission),
|
||||||
|
FOREIGN KEY (group_id) REFERENCES groups(id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_groups (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, group_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (group_id) REFERENCES groups(id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS service_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
purpose TEXT UNIQUE NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ad_group_dn TEXT UNIQUE NOT NULL,
|
||||||
|
ad_group_name TEXT NOT NULL,
|
||||||
|
app_group_id INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (app_group_id) REFERENCES groups(id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT,
|
||||||
|
details TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# --- Kern des Lizenzservers ---------------------------------------------
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS license_customers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
contact_email TEXT,
|
||||||
|
contact_phone TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS licenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
license_id TEXT UNIQUE NOT NULL,
|
||||||
|
customer_id INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
modules TEXT NOT NULL DEFAULT '[]',
|
||||||
|
issued_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
license_pubkey TEXT NOT NULL,
|
||||||
|
license_file_json TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'issued',
|
||||||
|
fingerprint TEXT,
|
||||||
|
activated_at TEXT,
|
||||||
|
deactivated_at TEXT,
|
||||||
|
last_heartbeat_at TEXT,
|
||||||
|
revoked_at TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'customer',
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5"))
|
||||||
|
|
||||||
|
cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",))
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
default_group_id = cur.lastrowid
|
||||||
|
c.executemany(
|
||||||
|
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
||||||
|
[
|
||||||
|
(default_group_id, "logs_group.view"),
|
||||||
|
(default_group_id, "logs_live.view"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"Datenbank '{DB_PATH}' wurde initialisiert inklusive Settings.")
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Erzeugt/erneuert die Bootstrap-Lizenz DIESES Lizenzservers selbst.
|
||||||
|
|
||||||
|
Läuft rein lokal (kein Netzwerk, kein externer Super-Master) -- der Master
|
||||||
|
prüft seine eigene Lizenz über denselben license_active()-Mechanismus wie
|
||||||
|
Kundeninstanzen (siehe app.py), nur ohne Heartbeat-Ziel: dieses Skript
|
||||||
|
setzt den Aktivierungsstatus direkt in der Datenbank, da es keine höhere
|
||||||
|
Instanz gibt, die eine Aktivierung bestätigen könnte.
|
||||||
|
|
||||||
|
Nutzt denselben Master-Signaturschlüssel, den der Server auch zum
|
||||||
|
Ausstellen von Kundenlizenzen verwendet (wird beim ersten Aufruf
|
||||||
|
automatisch erzeugt, falls noch nicht vorhanden) -- WICHTIG: dieser
|
||||||
|
Schlüssel wird beim erneuten Aufruf (Verlängerung) wiederverwendet, nie
|
||||||
|
neu erzeugt, siehe _load_or_create_master_signing_key() weiter unten und
|
||||||
|
die identische Funktion in app.py: ein Schlüsselwechsel würde die
|
||||||
|
Offline-Verifikation künftiger Aktivierungs-/Heartbeat-Antworten für ALLE
|
||||||
|
bereits an Kunden ausgegebenen Lizenzen brechen.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 create_master_license.py [--days 730] [--name "Firma XY"]
|
||||||
|
|
||||||
|
Erneuerung (z.B. kurz vor Ablauf): einfach erneut aufrufen -- überschreibt
|
||||||
|
die bestehende Bootstrap-Lizenz, der Signaturschlüssel bleibt unverändert.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import licensing
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
MASTER_SIGNING_KEY_PATH = os.environ.get("TESM_LICENSE_MASTER_KEY", os.path.join(BASE_DIR, "master_signing_key.json"))
|
||||||
|
LICENSE_PATH = os.environ.get("TESM_LICENSE_PATH", os.path.join(BASE_DIR, "license.json"))
|
||||||
|
DB_PATH = os.environ.get("TESM_LICENSE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_or_create_master_signing_key():
|
||||||
|
try:
|
||||||
|
with open(MASTER_SIGNING_KEY_PATH, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data["private"], data["public"], False
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
priv, pub = licensing.generate_keypair()
|
||||||
|
with open(MASTER_SIGNING_KEY_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"private": priv, "public": pub}, f)
|
||||||
|
try:
|
||||||
|
os.chmod(MASTER_SIGNING_KEY_PATH, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return priv, pub, True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Erzeugt/erneuert die Bootstrap-Lizenz dieses Lizenzservers.")
|
||||||
|
parser.add_argument("--days", type=int, default=730, help="Gültigkeitsdauer in Tagen (Standard: 730 = 2 Jahre)")
|
||||||
|
parser.add_argument("--name", default="Lizenzserver (Eigenbetrieb)", help="Anzeigename in der Lizenzdatei")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
master_priv, master_pub, created = _load_or_create_master_signing_key()
|
||||||
|
if created:
|
||||||
|
print(f"Neuer Master-Signaturschlüssel erzeugt: {MASTER_SIGNING_KEY_PATH}")
|
||||||
|
else:
|
||||||
|
print(f"Bestehenden Master-Signaturschlüssel wiederverwendet: {MASTER_SIGNING_KEY_PATH}")
|
||||||
|
|
||||||
|
license_file, _pub = licensing.issue_license(
|
||||||
|
customer={"name": args.name, "contact_email": ""},
|
||||||
|
license_type="enterprise",
|
||||||
|
modules=[],
|
||||||
|
valid_days=args.days,
|
||||||
|
master_private_key_b64=master_priv,
|
||||||
|
master_public_key_b64=master_pub,
|
||||||
|
master_endpoint="",
|
||||||
|
vendor={"name": "", "phone": "", "email": "", "address": "", "logo_base64": ""},
|
||||||
|
)
|
||||||
|
with open(LICENSE_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(license_file, f, indent=2)
|
||||||
|
try:
|
||||||
|
os.chmod(LICENSE_PATH, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
print(f"Bootstrap-Lizenz geschrieben: {LICENSE_PATH} (gültig {args.days} Tage, bis {license_file['expires_at']})")
|
||||||
|
|
||||||
|
if os.path.exists(DB_PATH):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO settings (key, value) VALUES ('license_activation_status', 'active') "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Aktivierungsstatus in der Datenbank auf 'active' gesetzt.")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"WARNUNG: Datenbank {DB_PATH} nicht gefunden -- bitte zuerst create_db.py ausführen, "
|
||||||
|
"dann dieses Skript erneut aufrufen, damit der Aktivierungsstatus gesetzt werden kann."
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Fertig -- Dienst neu starten (oder warten, bis der Prozess die Lizenz beim nächsten Zugriff neu lädt).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""
|
||||||
|
TESM Lizenzsystem — gemeinsame Kryptographie- und Protokoll-Bausteine für
|
||||||
|
Kundeninstanz (Client) UND Lizenzserver (Master). Beide Seiten importieren
|
||||||
|
exakt dieselbe Datei (auf dem Master 1:1 mitkopiert) — Signieren und
|
||||||
|
Verifizieren müssen bitidentisch funktionieren, jede Abweichung würde
|
||||||
|
Lizenzen der jeweils anderen Seite unlesbar machen.
|
||||||
|
|
||||||
|
Vollständiges Design: siehe C:\\Users\\tim\\.claude\\plans\\toasty-twirling-hickey.md
|
||||||
|
(Phase 0). Kurzfassung der Schlüsselhierarchie:
|
||||||
|
|
||||||
|
- Master-Signaturschlüssel (Ed25519): signiert Lizenzdateien selbst UND
|
||||||
|
jede Aktivierungs-/Deaktivierungs-/Heartbeat-Antwort. Privater Teil bleibt
|
||||||
|
ausschließlich auf dem Master, öffentlicher Teil steckt in jeder
|
||||||
|
ausgestellten Lizenzdatei (ermöglicht rein OFFLINE verifizierbare
|
||||||
|
Master-Antworten beim Kunden).
|
||||||
|
- Pro-Lizenz-Schlüsselpaar (Ed25519, einmalig je Lizenz erzeugt): der
|
||||||
|
private Teil reist in der Lizenzdatei zum Kunden und signiert dessen
|
||||||
|
Aktivierungs-/Deaktivierungs-/Heartbeat-*Anfragen*; der öffentliche Teil
|
||||||
|
bleibt beim Master in dessen Datenbank (ermöglicht dem Master, jede
|
||||||
|
Anfrage einer bestimmten Lizenz zweifelsfrei zuzuordnen, ohne die
|
||||||
|
ursprüngliche Aktivierung "live" gesehen haben zu müssen).
|
||||||
|
|
||||||
|
Ed25519 statt RSA: kleine Schlüssel/Signaturen (wichtig, da Aktivierungs-
|
||||||
|
Anfragen/-Antworten für den Offline-Fall als von Hand kopierbarer Code
|
||||||
|
dargestellt werden), fest vorgegebene, sichere Parameter (keine
|
||||||
|
Padding-/Hash-Wahl wie bei RSA-PSS nötig).
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
|
||||||
|
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
|
||||||
|
ALL_MODULES = ("dhcp", "fileshare", "maintenance")
|
||||||
|
|
||||||
|
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
|
||||||
|
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
|
||||||
|
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================== Schlüssel ==
|
||||||
|
|
||||||
|
def generate_keypair():
|
||||||
|
"""Erzeugt ein neues Ed25519-Schlüsselpaar. Rückgabe: (private_b64, public_b64)."""
|
||||||
|
priv = Ed25519PrivateKey.generate()
|
||||||
|
return _encode_private_key(priv), _encode_public_key(priv.public_key())
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_private_key(priv: Ed25519PrivateKey) -> str:
|
||||||
|
return base64.urlsafe_b64encode(priv.private_bytes_raw()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_public_key(pub: Ed25519PublicKey) -> str:
|
||||||
|
return base64.urlsafe_b64encode(pub.public_bytes_raw()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_private_key(s: str) -> Ed25519PrivateKey:
|
||||||
|
return Ed25519PrivateKey.from_private_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_public_key(s: str) -> Ed25519PublicKey:
|
||||||
|
return Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================== Signieren/Prüfen ==
|
||||||
|
|
||||||
|
def _canonical_bytes(payload: dict) -> bytes:
|
||||||
|
"""Deterministische JSON-Kodierung (sortierte Keys, kompakte Trenner) --
|
||||||
|
Voraussetzung dafür, dass Signieren und Verifizieren exakt dieselben
|
||||||
|
Bytes sehen, unabhängig von der Dict-Einfügereihenfolge."""
|
||||||
|
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_payload(payload: dict, private_key_b64: str) -> str:
|
||||||
|
priv = _decode_private_key(private_key_b64)
|
||||||
|
return base64.urlsafe_b64encode(priv.sign(_canonical_bytes(payload))).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_payload(payload: dict, signature_b64: str, public_key_b64: str) -> bool:
|
||||||
|
"""Gibt bewusst nur True/False zurück (nie eine Exception nach außen) --
|
||||||
|
ein Aufrufer soll "ungültig" nie mit einem Absturz verwechseln können."""
|
||||||
|
try:
|
||||||
|
pub = _decode_public_key(public_key_b64)
|
||||||
|
pub.verify(base64.urlsafe_b64decode(signature_b64.encode("ascii")), _canonical_bytes(payload))
|
||||||
|
return True
|
||||||
|
except (InvalidSignature, ValueError, TypeError, KeyError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Zeitformat ==
|
||||||
|
|
||||||
|
def _iso(ts: float) -> str:
|
||||||
|
return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(s: str) -> float:
|
||||||
|
return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ Lizenz ==
|
||||||
|
|
||||||
|
def issue_license(*, customer, license_type, modules, valid_days,
|
||||||
|
master_private_key_b64, master_public_key_b64,
|
||||||
|
master_endpoint, vendor, license_id=None, now=None):
|
||||||
|
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
|
||||||
|
frischem Pro-Lizenz-Schlüsselpaar.
|
||||||
|
|
||||||
|
Rückgabe: (license_file, license_pubkey) -- license_file ist die
|
||||||
|
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
|
||||||
|
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
|
||||||
|
bestimmt (Verifikation künftiger Aktivierungs-/Heartbeat-Anfragen
|
||||||
|
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
|
||||||
|
if license_type not in LICENSE_TYPES:
|
||||||
|
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
|
||||||
|
unknown = set(modules) - set(ALL_MODULES)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
|
||||||
|
|
||||||
|
license_priv, license_pub = generate_keypair()
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
payload = {
|
||||||
|
"license_id": license_id or str(uuid.uuid4()),
|
||||||
|
"customer": customer,
|
||||||
|
"type": license_type,
|
||||||
|
"modules": sorted(modules),
|
||||||
|
"issued_at": _iso(now),
|
||||||
|
"expires_at": _iso(now + valid_days * 86400),
|
||||||
|
"license_pubkey": license_pub,
|
||||||
|
"master_pubkey": master_public_key_b64,
|
||||||
|
"master_endpoint": master_endpoint,
|
||||||
|
"vendor": vendor,
|
||||||
|
}
|
||||||
|
signature = sign_payload(payload, master_private_key_b64)
|
||||||
|
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
|
||||||
|
return license_file, license_pub
|
||||||
|
|
||||||
|
|
||||||
|
def verify_license_file(license_file: dict) -> bool:
|
||||||
|
"""Prüft die Master-Signatur über die Lizenz-Nutzdaten. license_privkey
|
||||||
|
und signature selbst sind nicht Teil der signierten Nutzlast (die
|
||||||
|
Signatur wurde ja gerade über den Rest gebildet, siehe issue_license)."""
|
||||||
|
payload = {k: v for k, v in license_file.items() if k not in ("license_privkey", "signature")}
|
||||||
|
return verify_payload(payload, license_file.get("signature", ""), license_file.get("master_pubkey", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def license_status(license_file: dict, now=None) -> dict:
|
||||||
|
"""Berechnet den aktuellen Anzeige-/Gate-Status einer (bereits als
|
||||||
|
signaturgültig geprüften!) Lizenz -- ruft NICHT selbst verify_license_file
|
||||||
|
auf, das bleibt bewusst Sache des Aufrufers, damit hier niemand versehentlich
|
||||||
|
den Status einer manipulierten Datei berechnet, ohne die Prüfung
|
||||||
|
überhaupt gemacht zu haben."""
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
expires_at = _parse_iso(license_file["expires_at"])
|
||||||
|
days_left = (expires_at - now) / 86400
|
||||||
|
expired = days_left < 0
|
||||||
|
days_since_expiry = -days_left if expired else 0.0
|
||||||
|
grace_active = expired and days_since_expiry <= GRACE_PERIOD_DAYS
|
||||||
|
modules_active = (not expired) or grace_active
|
||||||
|
|
||||||
|
return {
|
||||||
|
"expired": expired,
|
||||||
|
"days_left": days_left,
|
||||||
|
"days_since_expiry": days_since_expiry,
|
||||||
|
"expiring_soon": (not expired) and days_left <= EXPIRY_WARNING_DAYS,
|
||||||
|
"grace_active": grace_active,
|
||||||
|
"modules_active": modules_active,
|
||||||
|
"modules": set(license_file.get("modules", [])) if modules_active else set(),
|
||||||
|
"type": license_file.get("type"),
|
||||||
|
"customer": license_file.get("customer"),
|
||||||
|
"expires_at": license_file.get("expires_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def heartbeat_stale_warning(last_heartbeat_ts, now=None) -> bool:
|
||||||
|
"""True, wenn der letzte erfolgreiche Heartbeat HEARTBEAT_WARNING_DAYS
|
||||||
|
oder länger zurückliegt. Rein informativ (siehe Moduldocstring) -- ein
|
||||||
|
fehlender/alter Heartbeat schaltet nie ein Modul ab, nur das
|
||||||
|
eingebettete Ablaufdatum selbst zählt dafür (siehe license_status)."""
|
||||||
|
if last_heartbeat_ts is None:
|
||||||
|
return False # noch nie verbunden gewesen ist der normale Ausgangszustand, kein Ausfall
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
return (now - last_heartbeat_ts) / 86400 >= HEARTBEAT_WARNING_DAYS
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================== Fingerprint (System-Bindung) ==
|
||||||
|
|
||||||
|
def system_fingerprint():
|
||||||
|
"""Stabiler Identifikator dieses Hosts: /etc/machine-id (von systemd bei
|
||||||
|
der Erstinstallation einmalig erzeugt, übersteht Reboots und normale
|
||||||
|
Updates) kombiniert mit dem Hostnamen, gehasht damit die rohe
|
||||||
|
machine-id nie im Klartext übertragen/gespeichert wird. Unter
|
||||||
|
Windows/ohne /etc/machine-id fällt dies auf den Hostnamen allein
|
||||||
|
zurück (nur für lokale Tests relevant, Produktivsysteme sind Linux)."""
|
||||||
|
machine_id = ""
|
||||||
|
try:
|
||||||
|
with open("/etc/machine-id", "r", encoding="utf-8") as f:
|
||||||
|
machine_id = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raw = f"{machine_id}:{socket.gethostname()}".encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Aktivierung / Deaktivierung / Heartbeat =====
|
||||||
|
# Ein Protokoll, zwei Transportwege: online automatisch per HTTPS-API,
|
||||||
|
# offline exakt dasselbe Anfrage/Antwort-Paar als von Hand kopierbarer Code
|
||||||
|
# (encode_code/decode_code) -- der Master ist dadurch in BEIDEN Fällen die
|
||||||
|
# einzige Quelle der Wahrheit dafür, an welches System eine Lizenz gerade
|
||||||
|
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
|
||||||
|
# verhindert".
|
||||||
|
|
||||||
|
def build_client_request(action, license_file, nonce=None) -> dict:
|
||||||
|
"""Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
|
||||||
|
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
|
||||||
|
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es
|
||||||
|
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt."""
|
||||||
|
if action not in ("activate", "deactivate", "heartbeat"):
|
||||||
|
raise ValueError(f"Unbekannte Aktion: {action!r}")
|
||||||
|
payload = {
|
||||||
|
"license_id": license_file["license_id"],
|
||||||
|
"fingerprint": system_fingerprint(),
|
||||||
|
"action": action,
|
||||||
|
"nonce": nonce or uuid.uuid4().hex,
|
||||||
|
"timestamp": _iso(time.time()),
|
||||||
|
}
|
||||||
|
signature = sign_payload(payload, license_file["license_privkey"])
|
||||||
|
return {**payload, "signature": signature}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_client_request(request: dict, license_public_key_b64: str) -> bool:
|
||||||
|
"""Vom MASTER aufgerufen: prüft eine Client-Anfrage gegen den bei
|
||||||
|
Ausstellung dieser Lizenz in der Master-DB gespeicherten Public-Key."""
|
||||||
|
payload = {k: v for k, v in request.items() if k != "signature"}
|
||||||
|
return verify_payload(payload, request.get("signature", ""), license_public_key_b64)
|
||||||
|
|
||||||
|
|
||||||
|
def build_master_response(action, license_id, fingerprint, master_private_key_b64,
|
||||||
|
status="ok", license_update=None, now=None) -> dict:
|
||||||
|
"""Vom MASTER aufgerufen: erzeugt eine signierte Antwort/Bestätigung
|
||||||
|
(z.B. "activated", "deactivated", oder das Ergebnis eines Heartbeats).
|
||||||
|
license_update: optional eine komplette, neu signierte Lizenzdatei
|
||||||
|
(z.B. nach nachträglicher Modul-Änderung durch den Admin) -- der Client
|
||||||
|
übernimmt sie nur nach eigener, erfolgreicher Signaturprüfung."""
|
||||||
|
payload = {
|
||||||
|
"license_id": license_id,
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
"action": action,
|
||||||
|
"status": status,
|
||||||
|
"timestamp": _iso(now if now is not None else time.time()),
|
||||||
|
}
|
||||||
|
if license_update is not None:
|
||||||
|
payload["license_update"] = license_update
|
||||||
|
signature = sign_payload(payload, master_private_key_b64)
|
||||||
|
return {**payload, "signature": signature}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_master_response(response: dict, master_public_key_b64: str) -> bool:
|
||||||
|
"""Vom CLIENT aufgerufen: prüft eine Master-Antwort komplett OFFLINE
|
||||||
|
gegen den in der eigenen Lizenzdatei eingebetteten master_pubkey."""
|
||||||
|
payload = {k: v for k, v in response.items() if k != "signature"}
|
||||||
|
return verify_payload(payload, response.get("signature", ""), master_public_key_b64)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================================================== Codes für Offline ==
|
||||||
|
|
||||||
|
def encode_code(data: dict) -> str:
|
||||||
|
"""Kodiert ein Anfrage-/Antwort-dict als kompakten, per Copy-Paste
|
||||||
|
übertragbaren Code (Base64 einer kanonischen JSON-Darstellung, keine
|
||||||
|
Zeilenumbrüche/Sonderzeichen, damit Kopieren aus/in ein Textfeld nichts
|
||||||
|
kaputt macht)."""
|
||||||
|
return base64.urlsafe_b64encode(_canonical_bytes(data)).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_code(code: str) -> dict:
|
||||||
|
return json.loads(base64.urlsafe_b64decode(code.strip().encode("ascii")).decode("utf-8"))
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
bcrypt==5.0.0
|
||||||
|
blinker==1.9.0
|
||||||
|
cffi==2.0.0
|
||||||
|
click==8.3.0
|
||||||
|
cryptography==46.0.1
|
||||||
|
Flask==3.1.2
|
||||||
|
Flask-Bcrypt==1.0.1
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
gunicorn==23.0.0
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
ldap3==2.9.1
|
||||||
|
MarkupSafe==3.0.2
|
||||||
|
pycparser==2.23
|
||||||
|
PyYAML==6.0.2
|
||||||
|
Werkzeug==3.1.3
|
||||||
|
|
||||||
|
# Gegenueber TESM entfernt: Flask-Sock/simple-websocket (Browser-SSH-
|
||||||
|
# Terminal fuer Switche gibt es hier nicht mehr), paramiko/PyNaCl/pyasn1
|
||||||
|
# (nur fuer SSH zu Switchen/Geraeten gebraucht -- der Lizenzserver macht
|
||||||
|
# kein Geraete-Management). cffi/pycparser bleiben: cryptography selbst
|
||||||
|
# haengt (ausserhalb PyPy) von cffi ab, das wiederum pycparser braucht --
|
||||||
|
# nicht optional, auch ohne paramiko.
|
||||||
|
#
|
||||||
|
# PyYAML wird für das netplan-Backend der Host-Netzwerkeinstellungen
|
||||||
|
# gebraucht (Systemeinstellungen → Netzwerkeinstellungen) — schreibt/liest
|
||||||
|
# die eigene Override-Datei unter /etc/netplan/.
|
||||||
|
#
|
||||||
|
# ldap3 (reines Python, keine System-Bibliothek wie libldap nötig) für die
|
||||||
|
# optionale Active-Directory/LDAP-Anmeldung (Systemeinstellungen → LDAP) —
|
||||||
|
# Search+Bind gegen einen AD-Domain-Controller oder generischen LDAP-Server.
|
||||||
|
#
|
||||||
|
# gunicorn: produktiver WSGI-Server für tesm-license.service (siehe dort)
|
||||||
|
# statt Flasks eigenem app.run()-Entwicklungsserver ("WARNING: This is a
|
||||||
|
# development server..."). Ein Worker-Prozess mit mehreren Threads
|
||||||
|
# (--worker-class gthread) reicht hier aus, da der Lizenzserver — anders
|
||||||
|
# als TESM — keine langlebigen WebSocket-Verbindungen mehr offen hält.
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scroll-area {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility:not(.debug),
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree {
|
||||||
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||||
|
* explicitly in the generated class and reset to 1 here */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-overline {
|
||||||
|
text-decoration: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||||
|
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||||
|
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||||
|
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||||
|
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 8;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="1" y="1" width="62" height="62" rx="14" fill="#10151B"></rect>
|
||||||
|
<g transform="translate(5,20.19) scale(0.16875)">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 842 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="1" y="1" width="62" height="62" rx="14" fill="#EDF1F2" stroke="#C1CBCE" stroke-width="2"></rect>
|
||||||
|
<g transform="translate(5,20.19) scale(0.16875)">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(150,15)">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
|
||||||
|
</g>
|
||||||
|
<text x="310" y="178" text-anchor="middle" font-family="ui-sans-serif, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
|
font-weight="600" font-size="13" letter-spacing="1.5" fill="#E2A63C">ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 320 140" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 699 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(150,15)">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
|
||||||
|
</g>
|
||||||
|
<text x="310" y="178" text-anchor="middle" font-family="ui-sans-serif, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
|
font-weight="600" font-size="13" letter-spacing="1.5" fill="#B5750E">ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 320 140" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
|
||||||
|
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||||
|
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
|
||||||
|
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
|
||||||
|
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 698 B |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,633 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
PoE Manager — shared UI behaviour (sidebar, theme, modals, toasts)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* ---------------- Theme ---------------- */
|
||||||
|
|
||||||
|
const THEME_KEY = "poe-theme";
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
document.querySelectorAll("[data-theme-icon]").forEach((el) => {
|
||||||
|
el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun;
|
||||||
|
});
|
||||||
|
// Favicon + Sidebar-Logo folgen demselben Theme wie der Rest der App
|
||||||
|
// (auch bei manuellem Umschalten, nicht nur bei System-Präferenz) --
|
||||||
|
// "dunkel"/"hell" bezeichnen hier die für den jeweiligen Modus gedachte
|
||||||
|
// Logo-Variante (helle Farben für Dark Mode, dunkle für Light Mode).
|
||||||
|
const suffix = theme === "light" ? "light" : "dark";
|
||||||
|
const favicon = document.getElementById("app-favicon");
|
||||||
|
if (favicon) favicon.href = "/static/images/icon-" + suffix + ".svg";
|
||||||
|
const sidebarLogo = document.getElementById("sidebar-logo");
|
||||||
|
if (sidebarLogo) sidebarLogo.src = "/static/images/logo-" + suffix + ".svg";
|
||||||
|
const loginBgLogo = document.getElementById("login-bg-logo");
|
||||||
|
if (loginBgLogo) loginBgLogo.src = "/static/images/logo-" + suffix + "-subline.svg";
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
const saved = localStorage.getItem(THEME_KEY) ||
|
||||||
|
(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
||||||
|
applyTheme(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
|
||||||
|
const next = current === "light" ? "dark" : "light";
|
||||||
|
localStorage.setItem(THEME_KEY, next);
|
||||||
|
applyTheme(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>',
|
||||||
|
moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- Sidebar (mobile) ---------------- */
|
||||||
|
|
||||||
|
function initSidebar() {
|
||||||
|
const sidebar = document.querySelector(".sidebar");
|
||||||
|
const backdrop = document.querySelector(".sidebar-backdrop");
|
||||||
|
const main = document.querySelector(".main");
|
||||||
|
const toggles = document.querySelectorAll("[data-sidebar-toggle]");
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
const DESKTOP_BREAKPOINT = 900;
|
||||||
|
const COLLAPSE_KEY = "poe-sidebar-collapsed";
|
||||||
|
|
||||||
|
// Mobile: temporäres Überlagern per Hamburger + Backdrop.
|
||||||
|
function open() {
|
||||||
|
sidebar.classList.add("open");
|
||||||
|
backdrop && backdrop.classList.add("open");
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
sidebar.classList.remove("open");
|
||||||
|
backdrop && backdrop.classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: dauerhaftes Ein-/Ausklappen, über Neuladen hinweg gemerkt.
|
||||||
|
function setCollapsed(collapsed) {
|
||||||
|
sidebar.classList.toggle("collapsed", collapsed);
|
||||||
|
main && main.classList.toggle("sidebar-collapsed", collapsed);
|
||||||
|
localStorage.setItem(COLLAPSE_KEY, collapsed ? "1" : "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem(COLLAPSE_KEY) === "1") setCollapsed(true);
|
||||||
|
|
||||||
|
toggles.forEach((btn) => btn.addEventListener("click", () => {
|
||||||
|
if (window.innerWidth <= DESKTOP_BREAKPOINT) {
|
||||||
|
sidebar.classList.contains("open") ? close() : open();
|
||||||
|
} else {
|
||||||
|
setCollapsed(!sidebar.classList.contains("collapsed"));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
backdrop && backdrop.addEventListener("click", close);
|
||||||
|
document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Modals ---------------- */
|
||||||
|
|
||||||
|
function openModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add("open");
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(el) {
|
||||||
|
const overlay = el.closest ? el.closest(".modal-overlay") : el;
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.remove("open");
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function initModals() {
|
||||||
|
document.querySelectorAll("[data-open-modal]").forEach((trigger) => {
|
||||||
|
trigger.addEventListener("click", () => openModal(trigger.getAttribute("data-open-modal")));
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-close-modal]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
|
||||||
|
overlay.addEventListener("click", (e) => {
|
||||||
|
if (e.target === overlay) closeModal(overlay);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
document.querySelectorAll(".modal-overlay.open").forEach((o) => closeModal(o));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Confirm dialog (replaces window.confirm) ---------------- */
|
||||||
|
|
||||||
|
function ensureConfirmModal() {
|
||||||
|
if (document.getElementById("confirm-modal")) return;
|
||||||
|
const html = `
|
||||||
|
<div class="modal-overlay" id="confirm-modal">
|
||||||
|
<div class="modal" style="max-width:380px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="confirm-title">Bist du sicher?</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="confirm-message" class="text-dim"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirm-ok" style="background:var(--danger);color:#fff;">Bestätigen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.insertAdjacentHTML("beforeend", html);
|
||||||
|
document.querySelectorAll("#confirm-modal [data-close-modal]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.getElementById("confirm-modal").addEventListener("click", (e) => {
|
||||||
|
if (e.target.id === "confirm-modal") closeModal(e.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.confirmAction = function (message, onConfirm, title) {
|
||||||
|
ensureConfirmModal();
|
||||||
|
document.getElementById("confirm-title").innerText = title || "Bist du sicher?";
|
||||||
|
document.getElementById("confirm-message").innerText = message;
|
||||||
|
const okBtn = document.getElementById("confirm-ok");
|
||||||
|
const freshBtn = okBtn.cloneNode(true);
|
||||||
|
okBtn.parentNode.replaceChild(freshBtn, okBtn);
|
||||||
|
freshBtn.addEventListener("click", () => {
|
||||||
|
closeModal(freshBtn);
|
||||||
|
onConfirm();
|
||||||
|
});
|
||||||
|
openModal("confirm-modal");
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- Ja/Nein-Dialog für ungespeicherte Änderungen ---------------- */
|
||||||
|
/* Eigener Dialog statt confirmAction (dort ist "Abbrechen" = beim Bleiben,
|
||||||
|
hier gibt es bewusst nur die zwei angefragten Optionen: speichern oder
|
||||||
|
verwerfen — beide verlassen die Seite, nur "Abbrechen"/Escape/Backdrop
|
||||||
|
bricht das Verlassen selbst ab und lässt die Seite unverändert offen. */
|
||||||
|
function ensureSaveDiscardModal() {
|
||||||
|
if (document.getElementById("save-discard-modal")) return;
|
||||||
|
const html = `
|
||||||
|
<div class="modal-overlay" id="save-discard-modal">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Ungespeicherte Änderungen</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-dim" id="save-discard-message"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" id="save-discard-no">Nein, verwerfen</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-discard-yes">Ja, speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.insertAdjacentHTML("beforeend", html);
|
||||||
|
document.querySelectorAll("#save-discard-modal [data-close-modal]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.getElementById("save-discard-modal").addEventListener("click", (e) => {
|
||||||
|
if (e.target.id === "save-discard-modal") closeModal(e.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.confirmSaveDiscard = function (message, onSave, onDiscard) {
|
||||||
|
ensureSaveDiscardModal();
|
||||||
|
document.getElementById("save-discard-message").innerText = message;
|
||||||
|
const yesBtn = document.getElementById("save-discard-yes");
|
||||||
|
const noBtn = document.getElementById("save-discard-no");
|
||||||
|
const freshYes = yesBtn.cloneNode(true);
|
||||||
|
yesBtn.parentNode.replaceChild(freshYes, yesBtn);
|
||||||
|
const freshNo = noBtn.cloneNode(true);
|
||||||
|
noBtn.parentNode.replaceChild(freshNo, noBtn);
|
||||||
|
freshYes.addEventListener("click", () => { closeModal(freshYes); onSave(); });
|
||||||
|
freshNo.addEventListener("click", () => { closeModal(freshNo); onDiscard(); });
|
||||||
|
openModal("save-discard-modal");
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- RAW-Log-Popup (Live-Log & Verlauf) ---------------- */
|
||||||
|
/* Gleiches Modal-Grundgerüst wie der Ja/Nein-Dialog für ungespeicherte
|
||||||
|
Änderungen (modal-overlay/modal-header/modal-body/modal-footer), nur
|
||||||
|
breiter/höher und mit lazy nachgeladenem Inhalt -- der komplette
|
||||||
|
Log-Inhalt wird bewusst erst beim tatsächlichen Öffnen per fetch
|
||||||
|
nachgeladen, nicht schon beim Seitenaufbau, damit genau das Problem
|
||||||
|
(unnötig große Ladezeiten) nicht durch das Popup selbst zurückkommt. */
|
||||||
|
function ensureRawLogModal() {
|
||||||
|
if (document.getElementById("raw-log-modal")) return;
|
||||||
|
const html = `
|
||||||
|
<div class="modal-overlay" id="raw-log-modal">
|
||||||
|
<div class="modal" style="max-width:min(1100px, 92vw); height:85vh;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="raw-log-modal-title">Komplettes Log (RAW)</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding:0; display:flex; flex-direction:column; flex:1; min-height:0;">
|
||||||
|
<pre id="raw-log-modal-content" class="raw-log-content"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<span class="text-faint mono" id="raw-log-modal-meta" style="margin-right:auto; font-size:11.5px;"></span>
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.insertAdjacentHTML("beforeend", html);
|
||||||
|
document.querySelectorAll("#raw-log-modal [data-close-modal]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.getElementById("raw-log-modal").addEventListener("click", (e) => {
|
||||||
|
if (e.target.id === "raw-log-modal") closeModal(e.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRawLogLines(container, text) {
|
||||||
|
// Eine Zeile = ein <div>, Nummer kommt per CSS-Counter (::before) --
|
||||||
|
// so bleibt der eigentliche Zeileninhalt reiner textContent (kein XSS-
|
||||||
|
// Risiko) und die Nummerierung muss nirgends von Hand mitgezählt werden.
|
||||||
|
const lines = text.split("\n");
|
||||||
|
if (lines.length && lines[lines.length - 1] === "") lines.pop(); // trailing \n erzeugt sonst eine Phantomzeile
|
||||||
|
container.textContent = "";
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "raw-log-line";
|
||||||
|
row.textContent = line;
|
||||||
|
frag.appendChild(row);
|
||||||
|
});
|
||||||
|
container.appendChild(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openRawLogModal = function (url, title, withLineNumbers) {
|
||||||
|
if (!url) return;
|
||||||
|
ensureRawLogModal();
|
||||||
|
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
|
||||||
|
const content = document.getElementById("raw-log-modal-content");
|
||||||
|
const meta = document.getElementById("raw-log-modal-meta");
|
||||||
|
content.classList.toggle("with-line-numbers", !!withLineNumbers);
|
||||||
|
content.textContent = "Lade …";
|
||||||
|
meta.textContent = "";
|
||||||
|
openModal("raw-log-modal");
|
||||||
|
fetch(url)
|
||||||
|
.then((r) => {
|
||||||
|
const logName = r.headers.get("X-Log-Name");
|
||||||
|
if (logName) meta.textContent = logName;
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then((text) => {
|
||||||
|
if (withLineNumbers) renderRawLogLines(content, text);
|
||||||
|
else content.textContent = text;
|
||||||
|
})
|
||||||
|
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- Hinweis-Icons (Hover-Tooltip + Klick-Modal) ---------------- */
|
||||||
|
/* Zentraler Mechanismus für ALLE "i"-Icons (siehe _hint_icon.html) app-weit
|
||||||
|
-- ein einziges delegiertes Set von Listenern statt pro Icon eigener
|
||||||
|
Handler, damit auch nachträglich per JS eingefügte Icons (z.B. in
|
||||||
|
dynamisch nachgeladenen Tabellenzeilen) ohne weiteres Zutun funktionieren.
|
||||||
|
Hover zeigt den Text als Tooltip neben dem Mauszeiger -- aber NUR auf
|
||||||
|
Geräten, die tatsächlich sinnvoll hovern können (matchMedia-Check bei
|
||||||
|
JEDEM mouseenter neu ausgewertet, nicht einmalig beim Laden gecacht,
|
||||||
|
damit z.B. ein Convertible/Tablet beim Umschalten Maus/Touch korrekt
|
||||||
|
reagiert). Auf reinen Touch-Geräten bleibt so nur Tap -> Modal übrig,
|
||||||
|
da dort ohnehin kein echtes mouseenter/mousemove-Hover stattfindet. */
|
||||||
|
function ensureHintTooltip() {
|
||||||
|
if (document.getElementById("hint-tooltip")) return;
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.id = "hint-tooltip";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHintModal() {
|
||||||
|
if (document.getElementById("hint-modal")) return;
|
||||||
|
const html = `
|
||||||
|
<div class="modal-overlay" id="hint-modal">
|
||||||
|
<div class="modal" style="max-width:440px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="hint-modal-title">Hinweis</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="hint-modal-body" class="text-dim" style="margin:0; line-height:1.6;"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.insertAdjacentHTML("beforeend", html);
|
||||||
|
document.querySelectorAll("#hint-modal [data-close-modal]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.getElementById("hint-modal").addEventListener("click", (e) => {
|
||||||
|
if (e.target.id === "hint-modal") closeModal(e.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canHover() {
|
||||||
|
return window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHintIcons() {
|
||||||
|
if (!document.querySelector(".hint-icon")) return;
|
||||||
|
ensureHintTooltip();
|
||||||
|
const tooltip = document.getElementById("hint-tooltip");
|
||||||
|
|
||||||
|
function positionTooltip(e) {
|
||||||
|
const offset = 14;
|
||||||
|
const rect = tooltip.getBoundingClientRect();
|
||||||
|
let x = e.clientX + offset;
|
||||||
|
let y = e.clientY + offset;
|
||||||
|
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offset;
|
||||||
|
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offset;
|
||||||
|
tooltip.style.left = Math.max(8, x) + "px";
|
||||||
|
tooltip.style.top = Math.max(8, y) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouseenter/mouseleave bubbeln nicht -- Delegation via Capture-Phase
|
||||||
|
// auf document funktioniert dafür trotzdem zuverlässig.
|
||||||
|
document.addEventListener("mouseenter", function (e) {
|
||||||
|
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||||
|
if (!icon || !canHover()) return;
|
||||||
|
tooltip.textContent = icon.dataset.hint || "";
|
||||||
|
tooltip.style.display = "block";
|
||||||
|
positionTooltip(e);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener("mouseleave", function (e) {
|
||||||
|
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||||
|
if (!icon) return;
|
||||||
|
tooltip.style.display = "none";
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", function (e) {
|
||||||
|
if (tooltip.style.display === "block") positionTooltip(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||||
|
if (!icon) return;
|
||||||
|
e.preventDefault();
|
||||||
|
tooltip.style.display = "none";
|
||||||
|
ensureHintModal();
|
||||||
|
document.getElementById("hint-modal-title").textContent = icon.dataset.hintTitle || "Hinweis";
|
||||||
|
document.getElementById("hint-modal-body").textContent = icon.dataset.hint || "";
|
||||||
|
openModal("hint-modal");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
|
||||||
|
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
||||||
|
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
|
||||||
|
Toggle-Buttons) seit dem Laden der Seite verändert wurde, und fragt vor
|
||||||
|
dem Verlassen (Klick auf einen Link, z.B. in der Navigation) per Ja/Nein,
|
||||||
|
ob zuerst gespeichert werden soll — statt Änderungen stillschweigend zu
|
||||||
|
verwerfen. "Ja" sendet das Formular ganz normal ab (echter POST, kein
|
||||||
|
AJAX-Nachbau nötig) und merkt sich das eigentliche Ziel in
|
||||||
|
sessionStorage, um nach dem Speichern automatisch dorthin
|
||||||
|
weiterzuleiten, statt dass der Link ein zweites Mal angeklickt werden
|
||||||
|
muss. Für Browser-eigene Navigation (Tab schließen, Reload, Adresszeile)
|
||||||
|
gibt es zusätzlich beforeunload — dort erlaubt der Browser aus
|
||||||
|
Sicherheitsgründen aber nur eine generische Warnung, kein eigenes
|
||||||
|
Ja/Nein/Speichern-Dialogfeld. */
|
||||||
|
const PENDING_NAV_KEY = "poe-pending-nav-after-save";
|
||||||
|
|
||||||
|
function serializeForm(form) {
|
||||||
|
return new URLSearchParams(new FormData(form)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initUnsavedChangesGuard() {
|
||||||
|
const pendingNav = sessionStorage.getItem(PENDING_NAV_KEY);
|
||||||
|
if (pendingNav) {
|
||||||
|
sessionStorage.removeItem(PENDING_NAV_KEY);
|
||||||
|
window.location.href = pendingNav;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackable = [];
|
||||||
|
document.querySelectorAll("form[method='post' i]").forEach((form) => {
|
||||||
|
if (form.hasAttribute("data-no-unsaved-guard")) return;
|
||||||
|
const fields = form.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea");
|
||||||
|
if (!fields.length) return; // reine Aktions-Formulare (Löschen/Toggle) ohne Eingabefeld
|
||||||
|
trackable.push({ form, initial: serializeForm(form) });
|
||||||
|
});
|
||||||
|
if (!trackable.length) return;
|
||||||
|
|
||||||
|
// Verhindert die native Browser-Warnung (beforeunload), wenn die Seite
|
||||||
|
// ohnehin schon bewusst verlassen wird — entweder weil eines der
|
||||||
|
// beobachteten Formulare ganz normal abgeschickt wurde (eigener
|
||||||
|
// "Speichern"/"Anlegen"-Button auf der Seite) oder weil unser eigener
|
||||||
|
// Ja/Nein-Dialog die Navigation ausgelöst hat. Ohne das würde beim
|
||||||
|
// normalen Speichern zusätzlich zum eigentlichen Erfolg immer noch die
|
||||||
|
// generische Browser-Meldung aufpoppen — die soll ausschließlich dann
|
||||||
|
// erscheinen, wenn tatsächlich UNGEFRAGT navigiert wird (Tab schließen,
|
||||||
|
// Reload, Adresszeile), nicht bei einem bewussten Speichern.
|
||||||
|
let navigatingAway = false;
|
||||||
|
trackable.forEach(({ form }) => {
|
||||||
|
form.addEventListener("submit", () => { navigatingAway = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
function dirtyForm() {
|
||||||
|
return trackable.find(({ form, initial }) => serializeForm(form) !== initial) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
const link = e.target.closest("a[href]");
|
||||||
|
if (!link || link.target === "_blank" || link.href.startsWith("javascript:") || link.hasAttribute("data-no-unsaved-guard")) return;
|
||||||
|
const dirty = dirtyForm();
|
||||||
|
if (!dirty) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const href = link.href;
|
||||||
|
window.confirmSaveDiscard(
|
||||||
|
"Es gibt ungespeicherte Änderungen auf dieser Seite. Vor dem Verlassen speichern?",
|
||||||
|
() => {
|
||||||
|
navigatingAway = true;
|
||||||
|
sessionStorage.setItem(PENDING_NAV_KEY, href);
|
||||||
|
dirty.form.requestSubmit ? dirty.form.requestSubmit() : dirty.form.submit();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
navigatingAway = true;
|
||||||
|
window.location.href = href;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", (e) => {
|
||||||
|
if (!navigatingAway && dirtyForm()) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Intercept forms/buttons marked with data-confirm="message" */
|
||||||
|
function initConfirmables() {
|
||||||
|
document.querySelectorAll("form[data-confirm]").forEach((form) => {
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
if (form.dataset.confirmed === "1") return;
|
||||||
|
e.preventDefault();
|
||||||
|
// Welcher Button hat den Submit ausgelöst? Muss beim erneuten
|
||||||
|
// requestSubmit() explizit mitgegeben werden — sonst geht bei
|
||||||
|
// Formularen, die ihre ID über name/value des Submit-Buttons
|
||||||
|
// transportieren (statt über ein <input type="hidden">), dieses
|
||||||
|
// Feld beim Neu-Absenden stillschweigend verloren (requestSubmit()
|
||||||
|
// ohne Argument zählt als "kein Button aktiviert").
|
||||||
|
const submitter = e.submitter;
|
||||||
|
window.confirmAction(form.getAttribute("data-confirm"), () => {
|
||||||
|
form.dataset.confirmed = "1";
|
||||||
|
form.requestSubmit ? form.requestSubmit(submitter) : form.submit();
|
||||||
|
}, form.getAttribute("data-confirm-title"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Toasts ---------------- */
|
||||||
|
|
||||||
|
function ensureToastStack() {
|
||||||
|
let stack = document.querySelector(".toast-stack");
|
||||||
|
if (!stack) {
|
||||||
|
stack = document.createElement("div");
|
||||||
|
stack.className = "toast-stack";
|
||||||
|
document.body.appendChild(stack);
|
||||||
|
}
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.showToast = function (message, type) {
|
||||||
|
const stack = ensureToastStack();
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = "toast " + (type || "info");
|
||||||
|
toast.innerHTML = `<span>${message}</span><span class="toast-close">×</span>`;
|
||||||
|
stack.appendChild(toast);
|
||||||
|
const remove = () => {
|
||||||
|
toast.classList.add("hide");
|
||||||
|
setTimeout(() => toast.remove(), 180);
|
||||||
|
};
|
||||||
|
toast.querySelector(".toast-close").addEventListener("click", remove);
|
||||||
|
setTimeout(remove, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
function initFlashedMessages() {
|
||||||
|
const data = document.getElementById("flashed-data");
|
||||||
|
if (!data) return;
|
||||||
|
try {
|
||||||
|
const messages = JSON.parse(data.textContent);
|
||||||
|
messages.forEach(([category, msg]) => {
|
||||||
|
const type = category === "danger" ? "danger" : category === "success" ? "success" : "info";
|
||||||
|
window.showToast(msg, type);
|
||||||
|
});
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen — der manuell
|
||||||
|
aufgeklappte Zustand bleibt über Seitenwechsel hinweg erhalten
|
||||||
|
(localStorage), zusätzlich zur automatischen Aufklappung der Gruppe
|
||||||
|
der aktuell aktiven Seite (server-seitig via "expanded"-Klasse). */
|
||||||
|
const NAV_EXPANDED_KEY = "poe-nav-expanded";
|
||||||
|
|
||||||
|
function getExpandedNavGroups() {
|
||||||
|
try {
|
||||||
|
return new Set(JSON.parse(localStorage.getItem(NAV_EXPANDED_KEY) || "[]"));
|
||||||
|
} catch (e) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveExpandedNavGroups(set) {
|
||||||
|
localStorage.setItem(NAV_EXPANDED_KEY, JSON.stringify(Array.from(set)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initNavGroups() {
|
||||||
|
const expanded = getExpandedNavGroups();
|
||||||
|
document.querySelectorAll("[data-nav-group]").forEach((group) => {
|
||||||
|
const key = group.dataset.navGroupKey;
|
||||||
|
if (key && expanded.has(key)) group.classList.add("expanded");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const group = btn.closest("[data-nav-group]");
|
||||||
|
const isExpanded = group.classList.toggle("expanded");
|
||||||
|
const key = group.dataset.navGroupKey;
|
||||||
|
if (!key) return;
|
||||||
|
const set = getExpandedNavGroups();
|
||||||
|
if (isExpanded) set.add(key); else set.delete(key);
|
||||||
|
saveExpandedNavGroups(set);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Sortierbare Tabellen ---------------- */
|
||||||
|
/*
|
||||||
|
* Klick auf <th data-sort-key="..."> sortiert die Tabelle. Zwei Modi:
|
||||||
|
* - Normale Tabellen: sortiert <tr>-Zeilen innerhalb des einzigen <tbody>
|
||||||
|
* anhand von data-sort-<key> auf der jeweiligen <tr>.
|
||||||
|
* - Akkordeon-Tabellen (mehrere <tbody>, z.B. Gruppen mit Detail-Zeile):
|
||||||
|
* sortiert ganze <tbody>-Blöcke anhand von data-sort-<key> auf dem
|
||||||
|
* jeweiligen <tbody>, damit Haupt- und Detail-Zeile zusammenbleiben.
|
||||||
|
* <tbody data-sort-pinned> (z.B. die virtuelle "Admin"-Zeile) bleibt
|
||||||
|
* dabei immer an ihrer Position.
|
||||||
|
*/
|
||||||
|
function compareSortValues(a, b, asc) {
|
||||||
|
a = (a === null || a === undefined) ? "" : String(a);
|
||||||
|
b = (b === null || b === undefined) ? "" : String(b);
|
||||||
|
const na = parseFloat(a), nb = parseFloat(b);
|
||||||
|
const bothNumeric = a !== "" && b !== "" && !isNaN(na) && !isNaN(nb);
|
||||||
|
const cmp = bothNumeric ? (na - nb) : a.toLowerCase().localeCompare(b.toLowerCase(), "de");
|
||||||
|
return asc ? cmp : -cmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTable(table, th) {
|
||||||
|
const key = th.dataset.sortKey;
|
||||||
|
const asc = th.dataset.sortDir !== "asc";
|
||||||
|
table.querySelectorAll("thead th[data-sort-key]").forEach((h) => {
|
||||||
|
delete h.dataset.sortDir;
|
||||||
|
h.classList.remove("sort-asc", "sort-desc");
|
||||||
|
});
|
||||||
|
th.dataset.sortDir = asc ? "asc" : "desc";
|
||||||
|
th.classList.add(asc ? "sort-asc" : "sort-desc");
|
||||||
|
|
||||||
|
if (table.tBodies.length > 1) {
|
||||||
|
const movable = Array.prototype.filter.call(table.tBodies, (tb) => !tb.hasAttribute("data-sort-pinned"));
|
||||||
|
movable.sort((a, b) => compareSortValues(
|
||||||
|
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
|
||||||
|
));
|
||||||
|
movable.forEach((tb) => table.appendChild(tb));
|
||||||
|
} else {
|
||||||
|
const tbody = table.tBodies[0];
|
||||||
|
const rows = Array.prototype.filter.call(tbody.rows, (r) => !r.classList.contains("empty-row"));
|
||||||
|
rows.sort((a, b) => compareSortValues(
|
||||||
|
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
|
||||||
|
));
|
||||||
|
rows.forEach((r) => tbody.appendChild(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSortableTables() {
|
||||||
|
document.querySelectorAll("table[data-sortable] thead th[data-sort-key]").forEach((th) => {
|
||||||
|
th.classList.add("sortable-col");
|
||||||
|
th.addEventListener("click", () => sortTable(th.closest("table"), th));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Init ---------------- */
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
initTheme();
|
||||||
|
initSidebar();
|
||||||
|
initModals();
|
||||||
|
initConfirmables();
|
||||||
|
initFlashedMessages();
|
||||||
|
initNavGroups();
|
||||||
|
initUnsavedChangesGuard();
|
||||||
|
initSortableTables();
|
||||||
|
initHintIcons();
|
||||||
|
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.PoeUI = { openModal, closeModal };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||||
|
//# sourceMappingURL=addon-fit.js.map
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{#
|
||||||
|
Gemeinsame Auditlog-Zeilendarstellung -- von activity_log.html (Erstladung)
|
||||||
|
UND _audit_log_rows.html (AJAX-Nachladen über "Weitere 500 laden")
|
||||||
|
importiert, damit beide garantiert dieselbe Darstellung erzeugen und
|
||||||
|
action_labels/action_icons/category_of nicht an zwei Stellen gepflegt
|
||||||
|
werden müssen.
|
||||||
|
#}
|
||||||
|
{% set action_labels = {
|
||||||
|
"settings.update": "Einstellung geändert",
|
||||||
|
"device.create": "Gerät angelegt",
|
||||||
|
"device.edit": "Gerät bearbeitet",
|
||||||
|
"device.delete": "Gerät gelöscht",
|
||||||
|
"device.activate": "Gerät aktiviert",
|
||||||
|
"device.deactivate": "Gerät deaktiviert",
|
||||||
|
"switch.create": "Switch angelegt",
|
||||||
|
"switch.edit": "Switch bearbeitet",
|
||||||
|
"switch.delete": "Switch gelöscht",
|
||||||
|
"credential.create": "Zugangsdaten angelegt",
|
||||||
|
"credential.edit": "Zugangsdaten bearbeitet",
|
||||||
|
"credential.delete": "Zugangsdaten gelöscht",
|
||||||
|
"user.create": "Benutzer angelegt",
|
||||||
|
"user.edit": "Benutzer bearbeitet",
|
||||||
|
"user.delete": "Benutzer gelöscht",
|
||||||
|
"user.assign_group": "Gruppe zugewiesen",
|
||||||
|
"group.create": "Gruppe angelegt",
|
||||||
|
"group.edit": "Gruppe bearbeitet",
|
||||||
|
"group.delete": "Gruppe gelöscht",
|
||||||
|
"group.assign_admins": "Admin-Zuweisung geändert",
|
||||||
|
"profile.update": "Profil aktualisiert",
|
||||||
|
"profile.password": "Passwort geändert",
|
||||||
|
"check.run_now": "Prüfung manuell gestartet",
|
||||||
|
"fileshare.upload": "Datei(en) hochgeladen",
|
||||||
|
"fileshare.mkdir": "Ordner angelegt",
|
||||||
|
"fileshare.delete": "Datei/Ordner gelöscht",
|
||||||
|
"fileshare.rename": "Datei/Ordner umbenannt",
|
||||||
|
"fileshare.download": "Als ZIP heruntergeladen",
|
||||||
|
"auditlog.archive": "Auditlog archiviert",
|
||||||
|
"auditlog.export_delete": "Auditlog-Archiv exportiert",
|
||||||
|
} %}
|
||||||
|
{% set action_icons = {
|
||||||
|
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
|
||||||
|
"create": '<path d="M12 5v14M5 12h14"/>',
|
||||||
|
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
|
||||||
|
"activate": '<path d="M20 6L9 17l-5-5"/>',
|
||||||
|
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
||||||
|
} %}
|
||||||
|
{% set create_kinds = ("create", "upload", "mkdir") %}
|
||||||
|
|
||||||
|
{% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %}
|
||||||
|
|
||||||
|
{% macro audit_row(e) %}
|
||||||
|
{% set kind = e['action'].split('.')[-1] %}
|
||||||
|
{% set cat = category_of(kind)|trim %}
|
||||||
|
<tr data-category="{{ cat }}" data-sort-ts="{{ e['ts'] }}" data-sort-user="{{ e['username']|lower }}"
|
||||||
|
data-sort-action="{{ action_labels.get(e['action'], e['action'])|lower }}" data-sort-target="{{ (e['target'] or '')|lower }}">
|
||||||
|
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
|
||||||
|
<td class="cell-name">{{ e['username'] }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="pill action-pill action-pill--{{ cat }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
|
||||||
|
{{ action_labels.get(e['action'], e['action']) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ e['target'] or '—' }}</td>
|
||||||
|
<td class="text-dim">{{ e['details'] or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{# Reines Zeilenfragment fürs AJAX-Nachladen (activity_log_more()) -- kein
|
||||||
|
umschließendes <table>/<tbody>, wird per insertAdjacentHTML direkt an das
|
||||||
|
bestehende tbody von #auditTable angehängt. #}
|
||||||
|
{% import "_audit_log_macros.html" as m %}
|
||||||
|
{% for e in entries %}{{ m.audit_row(e) }}{% endfor %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{#
|
||||||
|
Gemeinsames "i"-Hinweis-Icon: Hover zeigt den Text als Tooltip neben dem
|
||||||
|
Mauszeiger, Klick/Tap öffnet dasselbe (wiederverwendete) Modal mit dem
|
||||||
|
vollen Text -- Mechanik lebt zentral in app.js (initHintIcons), hier wird
|
||||||
|
nur das Icon mit seinen Daten-Attributen erzeugt. title = Kontext für die
|
||||||
|
Modal-Überschrift (z.B. das zugehörige Feld-Label), text = der eigentliche
|
||||||
|
Erklärungstext.
|
||||||
|
#}
|
||||||
|
{% macro hint_icon(text, title='Hinweis') %}<button type="button" class="hint-icon" data-hint="{{ text }}" data-hint-title="{{ title }}" aria-label="Hinweis: {{ title }}">i</button>{% endmacro %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block page_title %}Mein Konto{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">{{ current_user.username }}</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Profil</h2>
|
||||||
|
<div class="hint">Name und Profilbild ändern sich in der Sidebar und im Änderungslog.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:14px; margin-bottom:20px;">
|
||||||
|
{% if current_user.avatar_url %}
|
||||||
|
<img src="{{ current_user.avatar_url }}" alt="" style="width:56px; height:56px; border-radius:50%; object-fit:cover;">
|
||||||
|
{% else %}
|
||||||
|
<div class="user-avatar" style="width:56px; height:56px; font-size:18px;">{{ current_user.username[:2]|upper }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{{ url_for('profile') }}" enctype="multipart/form-data" id="avatarForm" style="flex:1;">
|
||||||
|
<input type="file" name="avatar" id="avatarInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none;" onchange="document.getElementById('avatarForm').requestSubmit();">
|
||||||
|
<button type="button" class="btn btn-secondary" style="width:100%;" onclick="document.getElementById('avatarInput').click();">Profilbild ändern</button>
|
||||||
|
<input type="hidden" name="upload_avatar" value="1">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('profile') }}" id="profileForm">
|
||||||
|
<div class="field"><label>Vorname</label><input type="text" name="first_name" value="{{ current_user.first_name or '' }}"></div>
|
||||||
|
<div class="field"><label>Name</label><input type="text" name="last_name" value="{{ current_user.last_name or '' }}"></div>
|
||||||
|
<div class="field"><label>Username</label><input type="text" value="{{ current_user.username }}" disabled></div>
|
||||||
|
<button type="submit" name="update_profile" value="1" class="btn btn-primary btn-block">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Passwort ändern</h2>
|
||||||
|
<div class="hint">
|
||||||
|
{% if current_user.is_ldap_user %}Wird über Active Directory verwaltet.{% else %}Erfordert Eingabe des aktuellen Passworts.{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if current_user.is_ldap_user %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
Dieses Konto meldet sich über Active Directory/LDAP an — das Passwort wird dort verwaltet
|
||||||
|
und kann in dieser App nicht geändert werden. Bitte das Domänen-Passwort wie gewohnt ändern.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('profile') }}" id="passwordForm">
|
||||||
|
<div class="field"><label>Aktuelles Passwort</label><input type="password" name="current_password" autocomplete="current-password"></div>
|
||||||
|
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" autocomplete="new-password"></div>
|
||||||
|
<div class="field"><label>Neues Passwort bestätigen</label><input type="password" name="confirm_password" autocomplete="new-password"></div>
|
||||||
|
<button type="submit" name="change_password" value="1" class="btn btn-secondary btn-block">Passwort ändern</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "logs" %}
|
||||||
|
{% block page_title %}Auditlog{% endblock %}
|
||||||
|
{% block page_sub %}
|
||||||
|
<div class="topbar-sub" id="auditEntryCount">
|
||||||
|
{% if has_more %}{{ entries|length }} von {{ total_count }} Einträgen geladen{% else %}{{ entries|length }} Eintrag{{ 'e' if entries|length != 1 else '' }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_audit_log_macros.html" as m %}
|
||||||
|
|
||||||
|
{% set ns = namespace(create=0, delete=0, edit=0) %}
|
||||||
|
{% for e in entries %}
|
||||||
|
{% set cat = m.category_of(e['action'].split('.')[-1])|trim %}
|
||||||
|
{% if cat == "create" %}{% set ns.create = ns.create + 1 %}
|
||||||
|
{% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %}
|
||||||
|
{% else %}{% set ns.edit = ns.edit + 1 %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% macro fmt_count(loaded, total) %}{% if loaded < total %}{{ loaded }} / {{ total }}{% else %}{{ loaded }}{% endif %}{% endmacro %}
|
||||||
|
|
||||||
|
<div class="stat-row" style="margin-bottom:18px;">
|
||||||
|
<div class="stat-card" data-category-filter="">
|
||||||
|
<div class="stat-label">Alle</div>
|
||||||
|
<div class="stat-value" id="statAll">{{ fmt_count(entries|length, total_count) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" data-category-filter="create">
|
||||||
|
<div class="stat-label">Hinzufügen</div>
|
||||||
|
<div class="stat-value" id="statCreate" style="color:var(--success);">{{ fmt_count(ns.create, total_create) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" data-category-filter="edit">
|
||||||
|
<div class="stat-label">Änderungen</div>
|
||||||
|
<div class="stat-value" id="statEdit" style="color:var(--accent-strong);">{{ fmt_count(ns.edit, total_edit) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" data-category-filter="delete">
|
||||||
|
<div class="stat-label">Löschungen</div>
|
||||||
|
<div class="stat-value" id="statDelete" style="color:var(--danger);">{{ fmt_count(ns.delete, total_delete) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<div class="search-input">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
<input type="text" id="auditSearch" placeholder="Benutzer, Aktion, Ziel oder Details durchsuchen...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table" id="auditTable" data-sortable>
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort-key="ts" style="width:1%; white-space:nowrap;">Zeitpunkt</th>
|
||||||
|
<th data-sort-key="user" style="width:1%; white-space:nowrap;">Benutzer</th>
|
||||||
|
<th data-sort-key="action" style="width:1%; white-space:nowrap;">Aktion</th>
|
||||||
|
<th data-sort-key="target" style="width:1%; white-space:nowrap;">Ziel</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in entries %}{{ m.audit_row(e) }}{% else %}
|
||||||
|
<tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="auditNoResults" class="text-faint hidden" style="padding:16px; text-align:center; font-size:12.5px;">Keine Einträge für diese Auswahl.</p>
|
||||||
|
<div id="auditLoadMoreWrap" class="{{ 'hidden' if not has_more }}" style="padding:16px; text-align:center; display:flex; gap:8px; justify-content:center;">
|
||||||
|
<button type="button" id="auditLoadMoreBtn" class="btn btn-primary" data-oldest-id="{{ oldest_loaded_id or '' }}">
|
||||||
|
Mehr laden (300)
|
||||||
|
</button>
|
||||||
|
<button type="button" id="auditLoadAllBtn" class="btn btn-secondary">
|
||||||
|
Alle laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const searchInput = document.getElementById("auditSearch");
|
||||||
|
const noResults = document.getElementById("auditNoResults");
|
||||||
|
let activeCategory = null;
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
|
||||||
|
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr"));
|
||||||
|
let anyVisible = false;
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
if (row.classList.contains("empty-row")) return;
|
||||||
|
const categoryMatches = !activeCategory || row.dataset.category === activeCategory;
|
||||||
|
const textMatches = !q || row.innerText.toLowerCase().includes(q);
|
||||||
|
const match = categoryMatches && textMatches;
|
||||||
|
row.style.display = match ? "" : "none";
|
||||||
|
if (match) anyVisible = true;
|
||||||
|
});
|
||||||
|
if (noResults) noResults.classList.toggle("hidden", anyVisible);
|
||||||
|
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
|
||||||
|
const isTotal = card.dataset.categoryFilter === "";
|
||||||
|
const active = activeCategory === null ? isTotal : card.dataset.categoryFilter === activeCategory;
|
||||||
|
card.classList.toggle("active", active);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchInput) searchInput.addEventListener("input", applyFilters);
|
||||||
|
|
||||||
|
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
|
||||||
|
card.addEventListener("click", function () {
|
||||||
|
const key = this.dataset.categoryFilter;
|
||||||
|
activeCategory = (!key || activeCategory === key) ? null : key;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- Nachladen älterer Einträge (Mehr laden / Alle laden) ---- */
|
||||||
|
const totalCount = {{ total_count }};
|
||||||
|
const totalCreate = {{ total_create }};
|
||||||
|
const totalEdit = {{ total_edit }};
|
||||||
|
const totalDelete = {{ total_delete }};
|
||||||
|
const entryCountLabel = document.getElementById("auditEntryCount");
|
||||||
|
|
||||||
|
function fmtCount(loaded, total) {
|
||||||
|
return loaded < total ? loaded + " / " + total : String(loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeStats() {
|
||||||
|
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr[data-category]"));
|
||||||
|
const counts = { create: 0, edit: 0, delete: 0 };
|
||||||
|
rows.forEach(function (r) { counts[r.dataset.category] = (counts[r.dataset.category] || 0) + 1; });
|
||||||
|
document.getElementById("statAll").textContent = fmtCount(rows.length, totalCount);
|
||||||
|
document.getElementById("statCreate").textContent = fmtCount(counts.create, totalCreate);
|
||||||
|
document.getElementById("statEdit").textContent = fmtCount(counts.edit, totalEdit);
|
||||||
|
document.getElementById("statDelete").textContent = fmtCount(counts.delete, totalDelete);
|
||||||
|
if (entryCountLabel) {
|
||||||
|
entryCountLabel.textContent = rows.length < totalCount
|
||||||
|
? rows.length + " von " + totalCount + " Einträgen geladen"
|
||||||
|
: rows.length + " Eintrag" + (rows.length === 1 ? "" : "e");
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMoreBtn = document.getElementById("auditLoadMoreBtn");
|
||||||
|
const loadAllBtn = document.getElementById("auditLoadAllBtn");
|
||||||
|
const loadMoreWrap = document.getElementById("auditLoadMoreWrap");
|
||||||
|
const tbody = document.querySelector("#auditTable tbody");
|
||||||
|
const MORE_URL = "{{ url_for('activity_log_more') }}";
|
||||||
|
|
||||||
|
// Laedt genau einen weiteren 300er-Block nach und haengt ihn an -- von
|
||||||
|
// beiden Buttons genutzt: "Mehr laden" ruft das einmal auf, "Alle laden"
|
||||||
|
// ruft es wiederholt auf, bis der Server "keine weiteren mehr" meldet.
|
||||||
|
function loadMoreOnce() {
|
||||||
|
const oldestId = loadMoreBtn.dataset.oldestId;
|
||||||
|
if (!oldestId) return Promise.resolve({ hasMore: false });
|
||||||
|
return fetch(MORE_URL + "?before_id=" + encodeURIComponent(oldestId))
|
||||||
|
.then(function (r) {
|
||||||
|
const hasMore = r.headers.get("X-Has-More") === "1";
|
||||||
|
const newOldestId = r.headers.get("X-Oldest-Id");
|
||||||
|
return r.text().then(function (html) {
|
||||||
|
return { html: html, hasMore: hasMore, newOldestId: newOldestId };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (result) {
|
||||||
|
const emptyRow = tbody.querySelector(".empty-row");
|
||||||
|
if (emptyRow) emptyRow.remove();
|
||||||
|
tbody.insertAdjacentHTML("beforeend", result.html);
|
||||||
|
if (result.newOldestId) loadMoreBtn.dataset.oldestId = result.newOldestId;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.addEventListener("click", function () {
|
||||||
|
loadMoreBtn.disabled = true;
|
||||||
|
if (loadAllBtn) loadAllBtn.disabled = true;
|
||||||
|
const originalText = loadMoreBtn.textContent;
|
||||||
|
loadMoreBtn.textContent = "Lädt …";
|
||||||
|
loadMoreOnce()
|
||||||
|
.then(function (result) {
|
||||||
|
if (!result.hasMore) loadMoreWrap.classList.add("hidden");
|
||||||
|
recomputeStats();
|
||||||
|
applyFilters();
|
||||||
|
})
|
||||||
|
.catch(function () { /* Button unten wird trotzdem wieder aktiviert */ })
|
||||||
|
.then(function () {
|
||||||
|
loadMoreBtn.disabled = false;
|
||||||
|
if (loadAllBtn) loadAllBtn.disabled = false;
|
||||||
|
loadMoreBtn.textContent = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadAllBtn) {
|
||||||
|
loadAllBtn.addEventListener("click", function () {
|
||||||
|
loadMoreBtn.disabled = true;
|
||||||
|
loadAllBtn.disabled = true;
|
||||||
|
const originalText = loadAllBtn.textContent;
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
loadAllBtn.textContent = "Lädt … (" + recomputeStats() + " von " + totalCount + ")";
|
||||||
|
return loadMoreOnce().then(function (result) {
|
||||||
|
return result.hasMore ? step() : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
step()
|
||||||
|
.then(function () {
|
||||||
|
loadMoreWrap.classList.add("hidden");
|
||||||
|
})
|
||||||
|
.catch(function () { /* teilweise geladene Eintraege bleiben stehen */ })
|
||||||
|
.then(function () {
|
||||||
|
recomputeStats();
|
||||||
|
applyFilters();
|
||||||
|
loadMoreBtn.disabled = false;
|
||||||
|
loadAllBtn.disabled = false;
|
||||||
|
loadAllBtn.textContent = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters();
|
||||||
|
recomputeStats();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ title or "TESM-Lizenzserver" }}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
|
||||||
|
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% set icons = {
|
||||||
|
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
|
||||||
|
"cpu": '<rect x="6" y="6" width="12" height="12" rx="1.5"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>',
|
||||||
|
"share": '<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/>',
|
||||||
|
"users": '<circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/>',
|
||||||
|
"groups": '<rect x="3" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="3" y="13" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/>',
|
||||||
|
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
|
||||||
|
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
|
||||||
|
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
||||||
|
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
|
||||||
|
"shield": '<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>',
|
||||||
|
"server": '<rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01M6 17.5h.01"/>',
|
||||||
|
"folder": '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
|
||||||
|
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
|
||||||
|
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
|
||||||
|
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
|
||||||
|
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
|
||||||
|
"network": '<rect x="9" y="2" width="6" height="6" rx="1.2"/><rect x="2" y="16" width="6" height="6" rx="1.2"/><rect x="16" y="16" width="6" height="6" rx="1.2"/><path d="M12 8v4M12 12H5v4M12 12h7v4"/>',
|
||||||
|
"wrench": '<path d="M14.7 6.3a4 4 0 11-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 015.4-5.4z"/>',
|
||||||
|
"trash": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6h16z"/><path d="M10 11v6M14 11v6"/>',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div class="sidebar-backdrop" data-sidebar-toggle></div>
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<img id="sidebar-logo" src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM-Lizenzserver" style="width:100%; height:auto; display:block;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
{% for item in nav_items_ordered %}
|
||||||
|
{% if item.children %}
|
||||||
|
{% set child_active = item.children|selectattr('endpoint', 'equalto', request.endpoint)|list %}
|
||||||
|
<div class="nav-group {% if child_active %}expanded active-group{% endif %}" data-nav-group data-nav-group-key="{{ item.key }}">
|
||||||
|
<button type="button" class="nav-group-toggle" data-nav-group-toggle>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
|
||||||
|
{{ item.label }}
|
||||||
|
<svg class="nav-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="nav-group-children">
|
||||||
|
{% for child in item.children %}
|
||||||
|
<a href="{{ url_for(child.endpoint) }}" class="nav-item {% if request.endpoint == child.endpoint %}active{% endif %}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[child.icon]|safe }}</svg>
|
||||||
|
{{ child.label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for(item.endpoint) }}" class="nav-item {% if active_page == item.key %}active{% endif %}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
|
||||||
|
{{ item.label }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-chip">
|
||||||
|
{% if current_user.avatar_url %}
|
||||||
|
<img class="user-avatar" src="{{ current_user.avatar_url }}" alt="" style="object-fit:cover;">
|
||||||
|
{% else %}
|
||||||
|
<div class="user-avatar">{{ current_user.username[:2]|upper }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="user-meta">
|
||||||
|
<div class="u-name">{{ current_user.display_name }}</div>
|
||||||
|
<div class="u-role">{{ "Administrator" if current_user.is_admin else current_user.group_names or "Benutzer" }}</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('account') }}" class="icon-btn" title="Einstellungen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['gear']|safe }}</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln">
|
||||||
|
<span data-theme-icon></span>
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('logout') }}" class="icon-btn" title="Abmelden" style="flex:1; display:flex;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin:auto;">{{ icons['logout']|safe }}</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-copyright" style="padding:10px 20px 4px; font-size:10.5px; color:var(--text-faint); text-align:center;">
|
||||||
|
© {{ current_year }} TESM-Lizenzserver v{{ tesm_version }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<button type="button" class="hamburger" data-sidebar-toggle aria-label="Menü">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<div class="topbar-title">{% block page_title %}Dashboard{% endblock %}</div>
|
||||||
|
{% block page_sub %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-logo">
|
||||||
|
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-right">
|
||||||
|
{% if license_topbar %}
|
||||||
|
<a href="{{ url_for('settings_license') }}" class="timer-pill" title="{{ license_topbar.text }}" style="text-decoration:none;">
|
||||||
|
<span class="dot {% if license_topbar.blink %}dot--blink-{{ license_topbar.level }}{% endif %}"
|
||||||
|
style="{% if not license_topbar.blink %}animation:none; box-shadow:none;{% endif %} background:{{ 'var(--danger)' if license_topbar.level == 'danger' else 'var(--warning)' }};"></span>{{ license_topbar.text }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if not current_user.is_authenticated %}
|
||||||
|
<a href="{{ url_for('login') }}" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
<script type="application/json" id="flashed-data">{{ messages|tojson }}</script>
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<script src="{{ asset_url('js/app.js') }}"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "customers" %}
|
||||||
|
{% block page_title %}Kunden{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">{{ customers|length }} Kunde{{ 'n' if customers|length != 1 else '' }}</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Kunden</h2>
|
||||||
|
</div>
|
||||||
|
{% if can_create %}
|
||||||
|
<button type="button" class="btn btn-primary" data-open-modal="addCustomerModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Neuer Kunde
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<div class="search-input">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="text" id="customerSearch" placeholder="Kunden durchsuchen…" oninput="filterCustomersTable()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table" id="customersTable" data-sortable>
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort-key="name">Kunde</th>
|
||||||
|
<th data-sort-key="email">Kontakt</th>
|
||||||
|
<th data-sort-key="licenses">Lizenzen</th>
|
||||||
|
<th style="width:1%;">Aktionen</th>
|
||||||
|
</tr></thead>
|
||||||
|
{% for c in customers %}
|
||||||
|
<tbody data-sort-name="{{ c.name|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-licenses="{{ c.license_count }}">
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name">{{ c.name }}</td>
|
||||||
|
<td class="text-dim">{{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %}</td>
|
||||||
|
<td class="text-dim">{{ c.license_count }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
{% if can_edit %}
|
||||||
|
<button class="icon-btn" title="Bearbeiten" data-open-modal="editCustomerModal{{ loop.index }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
{% if c.license_count == 0 %}
|
||||||
|
<form method="post" data-confirm="Kunde „{{ c.name }}“ wirklich löschen?">
|
||||||
|
<input type="hidden" name="delete_customer" value="{{ c.id }}">
|
||||||
|
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has_permission('licenses.create') %}
|
||||||
|
<a class="icon-btn" title="Lizenz ausstellen" href="{{ url_for('license_issue') }}?customer_id={{ c.id }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9m-4 0H3m2 0a2 2 0 100-4 2 2 0 000 4zm4 4h.01"/></svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
{% else %}
|
||||||
|
<tbody data-sort-pinned>
|
||||||
|
<tr class="empty-row"><td colspan="4">Noch keine Kunden angelegt.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for c in customers %}
|
||||||
|
<div class="modal-overlay" id="editCustomerModal{{ loop.index }}">
|
||||||
|
<div class="modal" style="max-width:480px;">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="edit_customer" value="{{ c.id }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Kunde bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" name="name" value="{{ c.name }}" required {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>E-Mail</label>
|
||||||
|
<input type="email" name="contact_email" value="{{ c.contact_email or '' }}" {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Telefon</label>
|
||||||
|
<input type="text" name="contact_phone" value="{{ c.contact_phone or '' }}" {% if not can_edit %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Notizen</label>
|
||||||
|
<textarea name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{{ c.notes or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="addCustomerModal">
|
||||||
|
<div class="modal" style="max-width:480px;">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="new_customer" value="1">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Neuer Kunde</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" name="name" required placeholder="z.B. Musterfirma GmbH">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>E-Mail</label>
|
||||||
|
<input type="email" name="contact_email">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Telefon</label>
|
||||||
|
<input type="text" name="contact_phone">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Notizen</label>
|
||||||
|
<textarea name="notes" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function filterCustomersTable() {
|
||||||
|
const q = document.getElementById("customerSearch").value.trim().toLowerCase();
|
||||||
|
document.querySelectorAll("#customersTable tbody").forEach(tbody => {
|
||||||
|
if (tbody.hasAttribute("data-sort-pinned")) return;
|
||||||
|
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "groups" %}
|
||||||
|
{% block page_title %}Gruppen{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
|
{% macro permission_table(group, group_key, checked_keys, readonly) %}
|
||||||
|
{% set row_types = group_row_types[group_key] %}
|
||||||
|
<div class="permission-group-col">
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="permission-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="permission-group-header-cell">
|
||||||
|
<label class="permission-group-toggle">
|
||||||
|
<input type="checkbox" name="permissions" value="{{ group['view_key'] }}"
|
||||||
|
title="{{ permission_labels.get(group['view_key'], group['label']) }}"
|
||||||
|
{% if group['view_key'] in checked_keys %}checked{% endif %}
|
||||||
|
{% if readonly %}disabled{% endif %}
|
||||||
|
class="permission-area-toggle-cb">
|
||||||
|
<span class="permission-group-name">{{ group['label'] }}</span>
|
||||||
|
</label>
|
||||||
|
</th>
|
||||||
|
{% for row_key, row_letter, row_label in row_types %}
|
||||||
|
<th title="{{ row_label }}">{{ row_letter }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for child_key, child in group['children'].items() %}
|
||||||
|
<tr>
|
||||||
|
<td class="permission-row-label">{{ child['label'] }}</td>
|
||||||
|
{% for row_key, row_letter, row_label in row_types %}
|
||||||
|
{% set perm_key = child['rows'].get(row_key) %}
|
||||||
|
<td>
|
||||||
|
{% if perm_key %}
|
||||||
|
<input type="checkbox" name="permissions" value="{{ perm_key }}"
|
||||||
|
title="{{ permission_labels.get(perm_key, perm_key) }}"
|
||||||
|
{% if perm_key in checked_keys %}checked{% endif %}
|
||||||
|
{% if readonly %}disabled{% endif %}
|
||||||
|
class="permission-child-cb">
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" disabled class="permission-cb-na" tabindex="-1">
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro permission_tree(checked_keys, readonly, compact=false) %}
|
||||||
|
<div class="permission-groups-row{{ ' permission-groups-row--compact' if compact }}">
|
||||||
|
{% for group_key, group in permission_catalog.items() %}
|
||||||
|
{{ permission_table(group, group_key, checked_keys, readonly) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="permission-legend">
|
||||||
|
<strong>R</strong> = Read (Lesen) · <strong>W</strong> = Write (Anlegen) ·
|
||||||
|
<strong>E</strong> = Edit (Ändern, inkl. Löschen — bei Im-/Export: Import ausführen) ·
|
||||||
|
<strong>X</strong> = Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Gruppen {{ hi.hint_icon("Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich. „Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.", "Gruppen") }}</h2>
|
||||||
|
</div>
|
||||||
|
{% if current_user.has_permission('groups.create') %}
|
||||||
|
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Neue Gruppe
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<div class="search-input">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="text" id="groupSearch" placeholder="Gruppen durchsuchen…" oninput="filterGroupsTable()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table" id="groupsTable" data-sortable>
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort-key="name">Gruppe</th>
|
||||||
|
<th data-sort-key="members">Mitglieder</th>
|
||||||
|
<th style="width:1%;">Aktionen</th>
|
||||||
|
</tr></thead>
|
||||||
|
|
||||||
|
<tbody data-sort-pinned>
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name">Admin <span class="pill admin">Systemrolle</span></td>
|
||||||
|
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="icon-btn" title="Rechte anzeigen" data-open-modal="adminGroupModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
{% for g in groups %}
|
||||||
|
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||||
|
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||||
|
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name">
|
||||||
|
{{ g.name }}
|
||||||
|
{% if g.is_system %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-dim">{{ g.member_names|length }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="icon-btn" title="{{ 'Bearbeiten' if (can_edit_this or can_unlock_system) else 'Anzeigen' }}" data-open-modal="editGroupModal{{ loop.index }}">
|
||||||
|
{% if can_edit_this or can_unlock_system %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||||
|
</button>
|
||||||
|
{% if current_user.has_permission('groups.edit') and not g.is_default and not g.is_system %}
|
||||||
|
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich endgültig löschen? Mitglieder verlieren die zugehörigen Rechte, das kann nicht rückgängig gemacht werden.">
|
||||||
|
<input type="hidden" name="delete_group" value="{{ g.id }}">
|
||||||
|
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
{% else %}
|
||||||
|
<tbody data-sort-pinned>
|
||||||
|
<tr class="empty-row"><td colspan="3">Noch keine weiteren Gruppen angelegt.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="adminGroupModal">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Admin — Rechte</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="adminMembersModal">
|
||||||
|
<div class="modal" style="max-width:380px;">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="assign_admins" value="1">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Admin-Mitglieder</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:0 0 12px;">Mindestens ein Admin muss bestehen bleiben.</p>
|
||||||
|
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
|
||||||
|
{% for u in all_users_all %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['is_admin'] %}checked{% endif %}>
|
||||||
|
{{ u['username'] }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for g in groups %}
|
||||||
|
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||||
|
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||||
|
<div class="modal-overlay" id="membersModal{{ loop.index }}">
|
||||||
|
<div class="modal" style="max-width:380px;">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
<input type="hidden" name="members_submitted" value="1">
|
||||||
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||||
|
<input type="hidden" name="name" value="{{ g.name }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Mitglieder — {{ g.name }}</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
|
||||||
|
{% for u in all_users %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['id'] in g.members %}checked{% endif %}>
|
||||||
|
{{ u['username'] }}
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12px;">Keine Nicht-Admin-Benutzer vorhanden.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="editGroupModal{{ loop.index }}">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
{% if can_edit_this %}
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
<input type="hidden" name="permissions_submitted" value="1">
|
||||||
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" name="name" value="{{ g.name }}" required>
|
||||||
|
</div>
|
||||||
|
{{ permission_tree(g.permissions, false, true) }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% elif can_unlock_system %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe „{{ g.name }}“</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field"><label>Name</label><input type="text" value="{{ g.name }}" disabled></div>
|
||||||
|
<div id="readonly-{{ g.id }}">
|
||||||
|
{{ permission_tree(g.permissions, true, true) }}
|
||||||
|
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px; flex-wrap:wrap;">
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="hidden" id="unlock-{{ g.id }}">
|
||||||
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
<input type="hidden" name="permissions_submitted" value="1">
|
||||||
|
<input type="hidden" name="unlock_system_group" value="1">
|
||||||
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||||
|
<input type="hidden" name="name" value="{{ g.name }}">
|
||||||
|
{{ permission_tree(g.permissions, false, true) }}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
|
||||||
|
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
|
||||||
|
Rechte hier können den Erst-Login neuer Konten einschränken.
|
||||||
|
</p>
|
||||||
|
<div class="flex" style="justify-content:flex-end;">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Rechte speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe „{{ g.name }}“</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ permission_tree(g.permissions, true, true) }}
|
||||||
|
{% if g.is_system %}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="addGroupModal">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
<form method="post">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Neue Gruppe</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="add_group" value="1">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name {{ hi.hint_icon("Mitglieder werden danach über die Gruppentabelle zugeordnet.", "Name") }}</label>
|
||||||
|
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
||||||
|
</div>
|
||||||
|
{{ permission_tree([], false) }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function filterGroupsTable() {
|
||||||
|
const q = document.getElementById("groupSearch").value.trim().toLowerCase();
|
||||||
|
document.querySelectorAll("#groupsTable tbody").forEach(tbody => {
|
||||||
|
if (tbody.hasAttribute("data-sort-pinned")) return;
|
||||||
|
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockSystemGroup(id) {
|
||||||
|
window.confirmAction(
|
||||||
|
"Rechte der Standardgruppe „Benutzer“ wirklich bearbeiten? Diese Gruppe ist der Login-Fallback für neue Benutzer (auch neue AD/LDAP-Konten) — zu restriktive Rechte können deren Erst-Login einschränken.",
|
||||||
|
() => {
|
||||||
|
document.getElementById("readonly-" + id).classList.add("hidden");
|
||||||
|
document.getElementById("unlock-" + id).classList.remove("hidden");
|
||||||
|
},
|
||||||
|
"Standardgruppe freischalten?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPermissionGating() {
|
||||||
|
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
||||||
|
const toggle = area.querySelector(".permission-area-toggle-cb");
|
||||||
|
if (!toggle || toggle.disabled) return;
|
||||||
|
const tbody = area.querySelector("tbody");
|
||||||
|
const children = area.querySelectorAll(".permission-child-cb");
|
||||||
|
const sync = function () {
|
||||||
|
children.forEach(function (cb) {
|
||||||
|
cb.disabled = !toggle.checked;
|
||||||
|
if (!toggle.checked) cb.checked = false;
|
||||||
|
});
|
||||||
|
if (tbody) tbody.classList.toggle("permission-locked", !toggle.checked);
|
||||||
|
};
|
||||||
|
toggle.addEventListener("change", sync);
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRowViewPrerequisite() {
|
||||||
|
document.querySelectorAll(".permission-table tbody tr").forEach(function (tr) {
|
||||||
|
const boxes = Array.from(tr.querySelectorAll(".permission-child-cb"));
|
||||||
|
if (boxes.length < 2) return;
|
||||||
|
const viewBox = boxes[0];
|
||||||
|
const restBoxes = boxes.slice(1);
|
||||||
|
restBoxes.forEach(function (cb) {
|
||||||
|
cb.addEventListener("change", function () {
|
||||||
|
if (cb.checked && !viewBox.checked && !viewBox.disabled) {
|
||||||
|
viewBox.checked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
viewBox.addEventListener("change", function () {
|
||||||
|
if (!viewBox.checked) {
|
||||||
|
restBoxes.forEach(function (cb) { cb.checked = false; });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener("DOMContentLoaded", applyPermissionGating);
|
||||||
|
document.addEventListener("DOMContentLoaded", applyRowViewPrerequisite);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "index" %}
|
||||||
|
{% block page_title %}Dashboard{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Kunden-/Lizenzübersicht</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if licenses is none %}
|
||||||
|
<div class="card card-pad" style="max-width:560px; margin:40px auto; text-align:center;">
|
||||||
|
<p class="text-faint" style="font-size:13px;">
|
||||||
|
{% if not current_user.is_authenticated %}Bitte anmelden.{% else %}Keine Berechtigung, Lizenzen anzusehen.{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Lizenzen</h2>
|
||||||
|
</div>
|
||||||
|
{% if can_create %}
|
||||||
|
<a href="{{ url_for('license_issue') }}" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Neue Lizenz ausstellen
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<div class="search-input">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="text" id="licenseSearch" placeholder="Lizenzen durchsuchen…" oninput="filterLicensesTable()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table" id="licensesTable" data-sortable>
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort-key="customer">Kunde</th>
|
||||||
|
<th data-sort-key="type">Typ</th>
|
||||||
|
<th>Module</th>
|
||||||
|
<th data-sort-key="expires">Ablauf</th>
|
||||||
|
<th data-sort-key="status">Status</th>
|
||||||
|
<th data-sort-key="heartbeat">Letzter Heartbeat</th>
|
||||||
|
<th style="width:1%;">Aktionen</th>
|
||||||
|
</tr></thead>
|
||||||
|
{% for l in licenses %}
|
||||||
|
<tbody data-sort-customer="{{ l.customer_name|lower }}" data-sort-type="{{ l.type }}"
|
||||||
|
data-sort-expires="{{ l.expires_at }}" data-sort-status="{{ l.status }}"
|
||||||
|
data-sort-heartbeat="{{ l.last_heartbeat_at or '' }}">
|
||||||
|
<tr>
|
||||||
|
<td class="cell-name">{{ l.customer_name }}</td>
|
||||||
|
<td>{{ type_labels.get(l.type, l.type) }}</td>
|
||||||
|
<td class="text-dim">{{ l.modules_list|join(', ') if l.modules_list else '—' }}</td>
|
||||||
|
<td class="text-dim">
|
||||||
|
{{ l.expires_at[:10] }}
|
||||||
|
{% if l.status == 'active' and l.days_left is not none %}
|
||||||
|
{% if l.is_expired %}<span style="color:var(--danger);">(abgelaufen)</span>
|
||||||
|
{% elif l.days_left <= 30 %}<span style="color:var(--warning);">({{ l.days_left }} Tage)</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(l.status, 'disabled') }}">
|
||||||
|
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-dim">{{ l.last_heartbeat_at or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
<a class="icon-btn" title="Details" href="{{ url_for('license_detail', license_id=l.license_id) }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
{% else %}
|
||||||
|
<tbody data-sort-pinned>
|
||||||
|
<tr class="empty-row"><td colspan="7">Noch keine Lizenzen ausgestellt.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function filterLicensesTable() {
|
||||||
|
const el = document.getElementById("licenseSearch");
|
||||||
|
if (!el) return;
|
||||||
|
const q = el.value.trim().toLowerCase();
|
||||||
|
document.querySelectorAll("#licensesTable tbody").forEach(tbody => {
|
||||||
|
if (tbody.hasAttribute("data-sort-pinned")) return;
|
||||||
|
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "index" %}
|
||||||
|
{% block page_title %}Lizenz{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">{{ customer.name if customer else '?' }}</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="settings-grid">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Status</h2>
|
||||||
|
</div>
|
||||||
|
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(license.status, 'disabled') }}">
|
||||||
|
{{ {"issued": "Ausgestellt (nicht aktiviert)", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(license.status, license.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Kunde</label><input type="text" value="{{ customer.name if customer else '?' }}" disabled></div>
|
||||||
|
<div class="field"><label>Lizenz-ID</label><input type="text" class="mono" value="{{ license.license_id }}" disabled></div>
|
||||||
|
<div class="field"><label>Typ</label><input type="text" value="{{ type_labels.get(license.type, license.type) }}" disabled></div>
|
||||||
|
<div class="field"><label>Module</label><input type="text" value="{{ license.modules_list|join(', ') if license.modules_list else 'keine' }}" disabled></div>
|
||||||
|
<div class="field"><label>Ausgestellt am</label><input type="text" value="{{ license.issued_at }}" disabled></div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Läuft ab am</label>
|
||||||
|
<input type="text" value="{{ license.expires_at }}{% if license.days_left is not none %} ({{ license.days_left }} Tage {{ 'verbleibend' if not license.is_expired else 'überschritten' }}){% endif %}" disabled>
|
||||||
|
</div>
|
||||||
|
{% if license.fingerprint %}
|
||||||
|
<div class="field"><label>System-Fingerabdruck</label><input type="text" class="mono" value="{{ license.fingerprint }}" disabled></div>
|
||||||
|
<div class="field"><label>Aktiviert am</label><input type="text" value="{{ license.activated_at or '' }}" disabled></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if license.last_heartbeat_at %}
|
||||||
|
<div class="field"><label>Letzter Heartbeat</label><input type="text" value="{{ license.last_heartbeat_at }}" disabled></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if license.deactivated_at %}
|
||||||
|
<div class="field"><label>Deaktiviert am</label><input type="text" value="{{ license.deactivated_at }}" disabled></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if license.revoked_at %}
|
||||||
|
<div class="field"><label>Widerrufen am</label><input type="text" value="{{ license.revoked_at }}" disabled></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div><h2 style="font-size:16px;">Aktionen</h2></div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('license_download', license_id=license.license_id) }}" class="btn btn-secondary btn-block" style="margin-bottom:10px;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
Lizenzdatei herunterladen
|
||||||
|
</a>
|
||||||
|
{% if can_edit and customer and customer.contact_email %}
|
||||||
|
<form method="post" action="{{ url_for('license_send_email', license_id=license.license_id) }}" style="margin-bottom:10px;">
|
||||||
|
<button type="submit" class="btn btn-secondary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16v16H4z"/><path d="M22 6l-10 7L2 6"/></svg>
|
||||||
|
Per E-Mail an {{ customer.contact_email }} senden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_edit and license.status not in ('revoked',) %}
|
||||||
|
<form method="post" action="{{ url_for('license_revoke', license_id=license.license_id) }}"
|
||||||
|
data-confirm="Lizenz wirklich widerrufen? Der Kunde verliert den Zugriff spätestens beim nächsten Heartbeat.">
|
||||||
|
<button type="submit" class="btn btn-danger btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>
|
||||||
|
Lizenz widerrufen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has_permission('licenses.edit') %}
|
||||||
|
<a href="{{ url_for('license_manual_code') }}" class="btn btn-secondary btn-block" style="margin-top:10px;">Offline-Code verarbeiten</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "index" %}
|
||||||
|
{% block page_title %}Lizenz ausstellen{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Neue signierte Lizenz für einen Kunden erzeugen</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
<div class="settings-grid">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
{% if not master_licensed %}
|
||||||
|
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
|
||||||
|
Dieser Lizenzserver hat selbst keine gültige Lizenz — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
|
||||||
|
Ausstellen ist erst danach wieder möglich.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="field">
|
||||||
|
<label for="customer_id">Kunde</label>
|
||||||
|
<select name="customer_id" id="customer_id" onchange="toggleNewCustomerFields()" required>
|
||||||
|
<option value="__new__" {% if not preselect_customer_id %}selected{% endif %}>+ Neuer Kunde…</option>
|
||||||
|
{% for c in customers %}
|
||||||
|
<option value="{{ c.id }}" {% if preselect_customer_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="newCustomerFields" class="field-group">
|
||||||
|
<div class="field">
|
||||||
|
<label for="new_customer_name">Name (neuer Kunde)</label>
|
||||||
|
<input type="text" name="new_customer_name" id="new_customer_name" placeholder="z.B. Musterfirma GmbH">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="new_customer_email">E-Mail (neuer Kunde)</label>
|
||||||
|
<input type="email" name="new_customer_email" id="new_customer_email">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="license_type">Lizenztyp {{ hi.hint_icon("Trial: zeitlich begrenzt, voller Zugriff. Standard: alle lizenzpflichtigen Basis-Funktionen, keine Zusatzmodule. Custom: Standard + einzeln wählbare Module. Enterprise: alles.", "Lizenztyp") }}</label>
|
||||||
|
<select name="license_type" id="license_type" onchange="toggleModuleFields()" required>
|
||||||
|
{% for t in license_types %}
|
||||||
|
<option value="{{ t }}" {% if t == 'standard' %}selected{% endif %}>{{ type_labels.get(t, t) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="moduleFields" class="field hidden">
|
||||||
|
<label>Module</label>
|
||||||
|
<div class="check-list">
|
||||||
|
{% for m in all_modules %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="modules" value="{{ m }}">
|
||||||
|
{{ module_labels.get(m, m) }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="valid_days">Gültigkeitsdauer (Tage) {{ hi.hint_icon("365 = 1 Jahr, 730 = 2 Jahre. Frei wählbar.", "Gültigkeitsdauer") }}</label>
|
||||||
|
<input type="number" name="valid_days" id="valid_days" value="365" min="1" required>
|
||||||
|
<div class="field-hint">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=30">30 Tage (Trial)</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=365">1 Jahr</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=730">2 Jahre</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-block" {% if not master_licensed %}disabled{% endif %}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Lizenz ausstellen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function toggleNewCustomerFields() {
|
||||||
|
const isNew = document.getElementById("customer_id").value === "__new__";
|
||||||
|
document.getElementById("newCustomerFields").classList.toggle("hidden", !isNew);
|
||||||
|
}
|
||||||
|
function toggleModuleFields() {
|
||||||
|
const type = document.getElementById("license_type").value;
|
||||||
|
document.getElementById("moduleFields").classList.toggle("hidden", type !== "custom");
|
||||||
|
}
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
toggleNewCustomerFields();
|
||||||
|
toggleModuleFields();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "index" %}
|
||||||
|
{% block page_title %}Offline-Code verarbeiten{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Aktivierung/Deaktivierung/Heartbeat für Kunden ohne Netzwerkzugriff auf diesen Server</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="settings-grid">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Code vom Kunden eintragen</h2>
|
||||||
|
<div class="hint">
|
||||||
|
Der Kunde liest diesen Code auf seiner "Lizenz"-Seite ab und teilt ihn mit (Telefon/E-Mail). Nach der
|
||||||
|
Verarbeitung hier den Antwortcode zurück an den Kunden geben — der trägt ihn dort ein.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post">
|
||||||
|
<div class="field">
|
||||||
|
<label for="client_code">Code vom Kunden</label>
|
||||||
|
<textarea class="mono" name="client_code" id="client_code" rows="6" style="width:100%; resize:vertical;" required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Verarbeiten
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if result_code %}
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Antwortcode für den Kunden</h2>
|
||||||
|
<div class="hint">{{ processed_action|capitalize }} erfolgreich verarbeitet — diesen Code an den Kunden zurückgeben.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<textarea class="mono" rows="6" readonly onclick="this.select()" style="width:100%; resize:vertical;">{{ result_code }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Login · TESM-Lizenzserver</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
|
||||||
|
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-bg">
|
||||||
|
{# logo-dark-subline.svg traegt TESMs Geraete-Tagline ("Ueberwachen, Booten, PXE...") fest eingebrannt --
|
||||||
|
fuer den Lizenzserver stattdessen das reine (taglinefreie) Wortmarken-SVG verwenden. #}
|
||||||
|
<img src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM-Lizenzserver" id="login-bg-logo">
|
||||||
|
</div>
|
||||||
|
<div class="login-card">
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="field" style="margin-bottom:18px;">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="toast danger" style="position:static; animation:none;">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="post" data-no-unsaved-guard>
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">Benutzername</label>
|
||||||
|
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
<div style="margin-top:16px; font-size:10.5px; color:var(--text-faint); text-align:center;">TESM-Lizenzserver v{{ tesm_version }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ asset_url('js/app.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "logs" %}
|
||||||
|
{% block page_title %}Live{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub" data-log-name>{{ log_name or "kein Logfile" }}</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Live-Log</h2>
|
||||||
|
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline). Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if current_user.can_view_log_history %}
|
||||||
|
<a href="{{ url_for('logs_history') }}" class="btn btn-secondary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
|
||||||
|
Verlauf
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="openRawLogModal('{{ url_for('get_log_raw') }}', 'Komplettes Live-Log (RAW)')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
|
Komplettes Log (RAW)
|
||||||
|
</button>
|
||||||
|
<button id="refresh-btn" class="btn btn-secondary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-shell">
|
||||||
|
<div class="log-toolbar">
|
||||||
|
<div class="log-dots"><span></span><span></span><span></span></div>
|
||||||
|
<span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span>
|
||||||
|
<span class="text-faint mono" style="font-size:11.5px;" id="log-line-info">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function colorizeLine(line) {
|
||||||
|
let cls = "";
|
||||||
|
if (line.includes(" ist erreichbar!")) cls = "online";
|
||||||
|
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
|
||||||
|
else if (line.startsWith("----")) cls = "sep";
|
||||||
|
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "log-line" + (cls ? " " + cls : "");
|
||||||
|
span.textContent = line;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const intervalMinutes = {{ global_check_interval | int }};
|
||||||
|
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
||||||
|
|
||||||
|
function renderLog(text, logName, totalLines) {
|
||||||
|
const box = document.getElementById("log-box");
|
||||||
|
box.innerHTML = "";
|
||||||
|
const lines = text.split("\n");
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
box.appendChild(colorizeLine(line));
|
||||||
|
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
|
||||||
|
});
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
if (logName) {
|
||||||
|
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
|
||||||
|
}
|
||||||
|
const info = document.getElementById("log-line-info");
|
||||||
|
if (info && totalLines) {
|
||||||
|
info.textContent = "letzte {{ tail_lines }} von " + totalLines + " Zeilen";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchLog() {
|
||||||
|
fetch("{{ url_for('get_log') }}")
|
||||||
|
.then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name"), r.headers.get("X-Total-Lines"))))
|
||||||
|
.catch(err => console.error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
|
||||||
|
document.addEventListener("poe:check-triggered", fetchLog);
|
||||||
|
fetchLog();
|
||||||
|
if (intervalMilliseconds) setInterval(fetchLog, intervalMilliseconds);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "logs" %}
|
||||||
|
{% block page_title %}Verlauf{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Log-Verlauf</h2>
|
||||||
|
<div class="hint">Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.</div>
|
||||||
|
<div class="hint">Zusätzlich weiter unten: bereits archivierte Auditlog-Tage (das Auditlog wird ab {{ "{:,}".format(audit_threshold).replace(",", ".") }} Einträgen automatisch tageweise archiviert, bis {{ "{:,}".format(audit_target).replace(",", ".") }} Einträge unterschritten sind).</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||||
|
Zurück zur Live-Ansicht
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-secondary"
|
||||||
|
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}', true)"
|
||||||
|
{% if not selected_name %}disabled{% endif %}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
|
Komplettes Log (RAW)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not files %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
Kein Live-Log gefunden.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="flex gap-2" style="align-items:center; margin-bottom:14px;">
|
||||||
|
<label for="history-file-select" class="text-faint" style="font-size:12.5px; font-weight:600;">Zeitraum:</label>
|
||||||
|
<select id="history-file-select" onchange="window.location.href=this.value;" class="mono" style="max-width:460px;">
|
||||||
|
{% for f in files %}
|
||||||
|
<option value="{{ url_for('logs_history', file=f.filename) }}" {% if f.filename == selected_name %}selected{% endif %}>
|
||||||
|
{% if f.generation == 0 %}Aktuell — {{ f.range_label }}{% else %}{{ f.range_label }}{% endif %} ({{ f.size_str }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-shell">
|
||||||
|
<div class="log-toolbar">
|
||||||
|
<div class="log-dots"><span></span><span></span><span></span></div>
|
||||||
|
<span class="text-faint mono" style="font-size:11.5px;">{{ selected_file.range_label if selected_file else "" }}</span>
|
||||||
|
<span class="text-faint mono" style="font-size:11.5px;">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen — RAW{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="section-head" style="margin-top:28px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Archivierte Auditlog-Tage {{ hi.hint_icon("Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.", "Archivierte Auditlog-Tage") }}</h2>
|
||||||
|
</div>
|
||||||
|
{% if current_user.can_manage_log_history and audit_files %}
|
||||||
|
<form method="post" action="{{ url_for('logs_history_audit_export') }}"
|
||||||
|
data-confirm="Alle {{ audit_files|length }} archivierten Auditlog-Datei(en) als ZIP herunterladen und danach vom Server löschen?"
|
||||||
|
data-confirm-title="Export & Löschen">
|
||||||
|
<button type="submit" class="btn btn-secondary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
Alle exportieren & löschen (ZIP)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if disk_warning %}
|
||||||
|
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ disk_warning }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not audit_files %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Noch keine archivierten Auditlog-Tage vorhanden.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:1%; white-space:nowrap;">Tag</th>
|
||||||
|
<th style="width:1%; white-space:nowrap;">Größe</th>
|
||||||
|
<th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in audit_files %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ f.day }}</td>
|
||||||
|
<td class="text-dim mono" style="font-size:12.5px;">{{ (f.size / 1024)|round(1) }} KB</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm"
|
||||||
|
onclick="openRawLogModal('{{ url_for('logs_history_audit_raw', file=f.filename) }}', 'Auditlog-Archiv — {{ f.day }}', true)">
|
||||||
|
Ansehen (RAW)
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const box = document.getElementById("log-box");
|
||||||
|
if (box) box.scrollTop = box.scrollHeight;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "settings_system" %}
|
||||||
|
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
<div class="settings-grid">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Host</h2>
|
||||||
|
<div class="hint">Name und Zeitzone dieses Hosts.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set can_edit_host = current_user.has_permission('settings_system.edit') %}
|
||||||
|
{% if can_edit_host %}
|
||||||
|
<form method="post">
|
||||||
|
<div class="field">
|
||||||
|
<label for="hostname">Hostname</label>
|
||||||
|
<input type="text" name="hostname" id="hostname" value="{{ current_hostname or '' }}" pattern="[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Hostname setzen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
||||||
|
<form method="post">
|
||||||
|
<div class="field">
|
||||||
|
<label for="timezone">Zeitzone {{ hi.hint_icon("Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.", "Zeitzone") }}</label>
|
||||||
|
<select name="timezone" id="timezone" required>
|
||||||
|
{% for tz in timezones %}
|
||||||
|
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Zeitzone setzen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="field">
|
||||||
|
<label>Hostname</label>
|
||||||
|
<input type="text" value="{{ current_hostname or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Zeitzone</label>
|
||||||
|
<input type="text" value="{{ current_timezone or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Netzwerkeinstellungen</h2>
|
||||||
|
<div class="hint">IP/DNS/DHCP-Umschaltung dieses Hosts.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2" style="align-items:center; margin-bottom:12px; flex-wrap:wrap;">
|
||||||
|
{% if net_backend == 'unknown' %}
|
||||||
|
<span class="pill unknown">Kein unterstütztes Backend erkannt</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="pill online">{{ {'networkmanager': 'NetworkManager', 'dhcpcd': 'dhcpcd', 'netplan': 'netplan'}[net_backend] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% for state in active_net_states %}
|
||||||
|
<div class="flex gap-2" style="align-items:center; margin-bottom:8px; flex-wrap:wrap;">
|
||||||
|
<span class="mono text-faint" style="font-size:12px;">
|
||||||
|
{{ state.interface }} — {{ state.ip }}/{{ state.prefix }}{% if state.gateway %}, Gateway {{ state.gateway }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="pill {{ 'user' if state.mode == 'static' else ('online' if state.mode == 'dhcp' else 'unknown') }}">
|
||||||
|
{{ {'static': 'Statisch', 'dhcp': 'DHCP', 'unknown': 'Modus unbekannt'}[state.mode] }}
|
||||||
|
</span>
|
||||||
|
{% if state.dns %}
|
||||||
|
<span class="text-faint" style="font-size:12px;">DNS: <span class="mono">{{ state.dns|join(', ') }}</span></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-faint" style="font-size:12px; margin-bottom:8px;">Kein aktives Interface mit IPv4-Adresse gefunden.</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div style="margin-bottom:16px;"></div>
|
||||||
|
|
||||||
|
{% if pending_network_token %}
|
||||||
|
<div class="card-pad" style="background:var(--warning-dim); border-radius:var(--radius-sm); margin-bottom:16px;">
|
||||||
|
<p style="margin:0 0 12px; font-size:13px;">
|
||||||
|
Neue Netzwerkkonfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung —
|
||||||
|
bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ net_revert_seconds }}s ohne Bestätigung).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="confirm_network" value="{{ pending_network_token }}">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Verbindung funktioniert — bestätigen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %}
|
||||||
|
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
|
||||||
|
<input type="hidden" name="apply_network" value="1">
|
||||||
|
<div class="field"><label>Interface {{ hi.hint_icon("Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.", "Interface") }}</label>
|
||||||
|
<select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)">
|
||||||
|
{% for iface in net_interfaces %}
|
||||||
|
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Modus</label>
|
||||||
|
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
|
||||||
|
<option value="dhcp" {% if net_state.mode != 'static' %}selected{% endif %}>DHCP (automatisch)</option>
|
||||||
|
<option value="static" {% if net_state.mode == 'static' %}selected{% endif %}>Statisch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="netStaticFields" class="{{ 'hidden' if net_state.mode != 'static' }}">
|
||||||
|
<div class="field"><label>IP-Adresse</label>
|
||||||
|
<input type="text" name="net_ip" id="netIpInput" value="{{ net_state.ip if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.50">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Prefix (CIDR-Bits)</label>
|
||||||
|
<input type="number" name="net_prefix" id="netPrefixInput" min="1" max="32" value="{{ net_state.prefix if net_state.mode == 'static' else '' }}" placeholder="z.B. 24">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Gateway</label>
|
||||||
|
<input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>DNS-Server {{ hi.hint_icon("Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.", "DNS-Server") }}</label>
|
||||||
|
<input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Netzwerkkonfiguration anwenden
|
||||||
|
</button>
|
||||||
|
<p class="text-faint" style="font-size:11px; margin-top:10px;">
|
||||||
|
⚠ Kann die Erreichbarkeit dieses Hosts unterbrechen. Ohne Bestätigung wird automatisch nach {{ net_revert_seconds }}s zurückgerollt.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% elif net_backend == 'unknown' %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
Weder NetworkManager, dhcpcd noch netplan/systemd-networkd aktiv erkannt — Netzwerkänderungen über diese Seite sind deaktiviert.
|
||||||
|
Bitte die Netzwerkkonfiguration dieses Hosts manuell vornehmen.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Logs</h2>
|
||||||
|
<div class="hint">Rotation & Aufbewahrung von Live-, Änderungs- und App-Log.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if current_user.has_permission('settings_system.edit') %}
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="save_log_rotation" value="1">
|
||||||
|
<div class="field">
|
||||||
|
<label for="log_rotation_interval">Rotations-Intervall</label>
|
||||||
|
<select name="log_rotation_interval" id="log_rotation_interval">
|
||||||
|
<option value="daily" {% if log_rotation_interval == "daily" %}selected{% endif %}>Täglich</option>
|
||||||
|
<option value="weekly" {% if log_rotation_interval == "weekly" %}selected{% endif %}>Wöchentlich</option>
|
||||||
|
<option value="monthly" {% if log_rotation_interval == "monthly" %}selected{% endif %}>Monatlich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen) {{ hi.hint_icon("Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).", "Aufbewahrung (Anzahl Rotationen)") }}</label>
|
||||||
|
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="field">
|
||||||
|
<label>Rotations-Intervall</label>
|
||||||
|
<input type="text" value="{{ log_rotation_interval }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Aufbewahrung (Anzahl Rotationen)</label>
|
||||||
|
<input type="text" value="{{ log_rotation_keep }}" disabled>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="field-hint mono" style="margin-top:10px; font-size:11px; line-height:1.6;">
|
||||||
|
Live: {{ log_paths.live }}<br>
|
||||||
|
Änderungen: {{ log_paths.changes }}<br>
|
||||||
|
App: {{ log_paths.app }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Anbieter & Lizenzserver</h2>
|
||||||
|
<div class="hint">Wird in jede ausgestellte Kundenlizenz eingebettet (Kontakt + wo der Client aktivieren/heartbeaten soll).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if current_user.has_permission('settings_system.edit') %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="save_vendor_settings" value="1">
|
||||||
|
<div class="field">
|
||||||
|
<label for="master_endpoint">Lizenzserver-Endpunkt (URL) {{ hi.hint_icon("Die von Kundeninstanzen aus erreichbare Basis-URL dieses Servers, z.B. https://lizenz.example.com. Wird in jede ausgestellte Lizenz eingebettet.", "Lizenzserver-Endpunkt") }}</label>
|
||||||
|
<input type="text" name="master_endpoint" id="master_endpoint" value="{{ master_endpoint }}" placeholder="https://lizenz.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="vendor_name">Firma</label>
|
||||||
|
<input type="text" name="vendor_name" id="vendor_name" value="{{ vendor.name }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="vendor_phone">Telefon</label>
|
||||||
|
<input type="text" name="vendor_phone" id="vendor_phone" value="{{ vendor.phone }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="vendor_email">E-Mail</label>
|
||||||
|
<input type="email" name="vendor_email" id="vendor_email" value="{{ vendor.email }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="vendor_address">Anschrift</label>
|
||||||
|
<textarea name="vendor_address" id="vendor_address" rows="2">{{ vendor.address }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="vendor_logo">Logo (optional, max. 200 KB)</label>
|
||||||
|
{% if vendor.logo_base64 %}
|
||||||
|
<div style="margin-bottom:8px;"><img src="{{ vendor.logo_base64 }}" alt="Logo" style="max-height:48px; max-width:200px;"></div>
|
||||||
|
{% endif %}
|
||||||
|
<input type="file" name="vendor_logo" id="vendor_logo" accept=".png,.jpg,.jpeg,.svg">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">E-Mail-Versand (Microsoft Graph)</h2>
|
||||||
|
<div class="hint">Für den direkten Versand ausgestellter Lizenzen an Kunden per E-Mail (optional -- Download funktioniert immer, auch ohne dies einzurichten).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details style="margin-bottom:14px;">
|
||||||
|
<summary style="cursor:pointer; font-size:12.5px; color:var(--text-dim);">Einrichtungsanleitung (Azure AD App-Registrierung)</summary>
|
||||||
|
<ol style="font-size:12px; color:var(--text-faint); margin:10px 0 0; padding-left:18px; line-height:1.7;">
|
||||||
|
<li>Im <a href="https://portal.azure.com" target="_blank" rel="noopener">Azure-Portal</a> unter „Azure Active Directory → App-Registrierungen“ eine neue App anlegen.</li>
|
||||||
|
<li>Unter „API-Berechtigungen“ die Anwendungsberechtigung (nicht delegiert!) <code>Mail.Send</code> für Microsoft Graph hinzufügen und per „Administratorzustimmung erteilen“ bestätigen.</li>
|
||||||
|
<li>Unter „Zertifikate & Geheimnisse“ einen neuen Client-Secret-Wert erzeugen und sofort kopieren (wird nur einmal angezeigt).</li>
|
||||||
|
<li>Tenant-ID und Client-ID stehen auf der Übersichtsseite der App-Registrierung.</li>
|
||||||
|
<li>Als Absender-Postfach eine echte, lizenzierte Mailbox im selben Tenant angeben (z.B. lizenz@firma.de).</li>
|
||||||
|
<li>Optional, aber empfohlen: per Exchange-Online-„Application Access Policy“ die App-Berechtigung auf genau dieses Postfach einschränken, statt tenant-weit jede Mailbox versenden zu lassen.</li>
|
||||||
|
<li>Da dies ein reiner App-zu-App-Login ohne Benutzeranmeldung ist (Client-Credentials-Flow), spielt eine ggf. für Benutzer aktivierte MFA-Pflicht hier keine Rolle.</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
{% if current_user.has_permission('settings_system.edit') %}
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="save_graph_settings" value="1">
|
||||||
|
<div class="field">
|
||||||
|
<label for="graph_tenant_id">Tenant-ID</label>
|
||||||
|
<input type="text" name="graph_tenant_id" id="graph_tenant_id" value="{{ graph.tenant_id }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="graph_client_id">Client-ID</label>
|
||||||
|
<input type="text" name="graph_client_id" id="graph_client_id" value="{{ graph.client_id }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="graph_client_secret">Client-Secret</label>
|
||||||
|
<input type="password" name="graph_client_secret" id="graph_client_secret" value="{{ graph.client_secret }}" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="graph_sender_mailbox">Absender-Postfach</label>
|
||||||
|
<input type="email" name="graph_sender_mailbox" id="graph_sender_mailbox" value="{{ graph.sender_mailbox }}" placeholder="lizenz@firma.de">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('settings_email_test') }}" style="margin-top:10px;">
|
||||||
|
<button type="submit" class="btn btn-secondary btn-block">Verbindung testen</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
{% macro nav_order_buttons() %}
|
||||||
|
<div class="nav-order-actions">
|
||||||
|
<button type="button" class="icon-btn" title="Nach oben" onclick="moveNavItem(this,-1)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="icon-btn" title="Nach unten" onclick="moveNavItem(this,1)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
|
||||||
|
<div class="hint">Reihenfolge der Sidebar-Menüpunkte inkl. Unterpunkte — gilt für alle Benutzer.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
|
||||||
|
<ul class="nav-order-list" id="navOrderList">
|
||||||
|
{% for item in full_nav_items %}
|
||||||
|
<li data-key="{{ item.key }}">
|
||||||
|
<div class="nav-order-row">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
{{ nav_order_buttons() }}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="nav_order" value="{{ item.key }}">
|
||||||
|
{% if item.children %}
|
||||||
|
<ul class="nav-order-sublist">
|
||||||
|
{% for child in item.children %}
|
||||||
|
<li data-key="{{ child.key }}">
|
||||||
|
<div class="nav-order-row">
|
||||||
|
<span>{{ child.label }}</span>
|
||||||
|
{{ nav_order_buttons() }}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="nav_child_order_{{ item.key }}" value="{{ child.key }}">
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Reihenfolge speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function moveNavItem(btn, dir) {
|
||||||
|
const li = btn.closest("li");
|
||||||
|
const target = dir === -1 ? li.previousElementSibling : li.nextElementSibling;
|
||||||
|
if (!target) return;
|
||||||
|
if (dir === -1) li.parentNode.insertBefore(li, target);
|
||||||
|
else li.parentNode.insertBefore(target, li);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNetworkState(iface) {
|
||||||
|
fetch("{{ url_for('settings_network_state') }}?interface=" + encodeURIComponent(iface))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(state => {
|
||||||
|
if (state.error) return;
|
||||||
|
const isStatic = state.mode === "static";
|
||||||
|
document.getElementById("netModeSelect").value = isStatic ? "static" : "dhcp";
|
||||||
|
document.getElementById("netStaticFields").classList.toggle("hidden", !isStatic);
|
||||||
|
document.getElementById("netIpInput").value = isStatic ? (state.ip || "") : "";
|
||||||
|
document.getElementById("netPrefixInput").value = isStatic ? (state.prefix || "") : "";
|
||||||
|
document.getElementById("netGatewayInput").value = isStatic ? (state.gateway || "") : "";
|
||||||
|
document.getElementById("netDnsInput").value = (state.dns || []).join(", ");
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "settings_importexport" %}
|
||||||
|
{% block page_title %}Im-/Export{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
<div class="settings-grid">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Import</h2>
|
||||||
|
<div class="hint">
|
||||||
|
{% if import_preview %}
|
||||||
|
Datei erfolgreich gelesen — wähle aus, welche Kategorien tatsächlich eingespielt werden sollen.
|
||||||
|
{% else %}
|
||||||
|
Exportiertes Bundle einlesen — nach dem Entschlüsseln wählst du aus, was übernommen wird.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if not current_user.has_permission('settings_importexport.edit') %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für den Import fehlt das Recht „Im-/Export ändern“.</p>
|
||||||
|
{% elif import_preview %}
|
||||||
|
<form method="post" action="{{ url_for('import_apply') }}"
|
||||||
|
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
|
||||||
|
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
|
||||||
|
<div class="field">
|
||||||
|
<label>Was importieren?</label>
|
||||||
|
<div class="check-list">
|
||||||
|
{% for s in import_preview.sections %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="import_sections" value="{{ s.key }}"
|
||||||
|
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
||||||
|
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
|
||||||
|
{{ s.label }} <span class="text-faint">({{ s.count }})</span>
|
||||||
|
{% if export_section_hints and s.key in export_section_hints %}{{ hi.hint_icon(export_section_hints[s.key], s.label) }}{% endif %}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Ausgewählte Kategorien importieren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a href="{{ url_for('settings_import_export') }}" class="text-faint" style="font-size:12px; display:inline-block; margin-top:10px;">Abbrechen / andere Datei wählen</a>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data">
|
||||||
|
<div class="field">
|
||||||
|
<label for="import_file">Export-Datei</label>
|
||||||
|
<input type="file" name="import_file" id="import_file" accept=".json" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="import_passphrase">Passphrase</label>
|
||||||
|
<input type="password" name="import_passphrase" id="import_passphrase" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 9V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4"/><path d="M7 14l5-5 5 5"/><path d="M12 9v12"/></svg>
|
||||||
|
Datei lesen & Vorschau anzeigen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Export</h2>
|
||||||
|
<div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if current_user.has_permission('settings_importexport.export') and license_export_allowed() %}
|
||||||
|
<form method="post" action="{{ url_for('export_data') }}">
|
||||||
|
<div class="field">
|
||||||
|
<label>Was exportieren?</label>
|
||||||
|
<div class="check-list">
|
||||||
|
{% for key, label in export_sections %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="export_sections" value="{{ key }}"
|
||||||
|
{% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
||||||
|
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
|
||||||
|
{{ label }}
|
||||||
|
{% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="export_passphrase">Passphrase {{ hi.hint_icon("Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.", "Passphrase") }}</label>
|
||||||
|
<input type="password" name="export_passphrase" id="export_passphrase" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
Export herunterladen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% elif current_user.has_permission('settings_importexport.export') %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für den Export ist keine gültige Lizenz vorhanden — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "settings_ldap" %}
|
||||||
|
{% set can_edit = current_user.has_permission('settings_ldap.edit') and license_active() %}
|
||||||
|
{% block page_title %}LDAP / Active Directory{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Verbindung</h2>
|
||||||
|
<div class="hint">Server, Bind-Konto und Suchparameter für die Anbindung an AD/LDAP.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<form method="post">
|
||||||
|
<div class="field">
|
||||||
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
|
<input type="checkbox" name="ldap_enabled" {% if ldap.enabled %}checked{% endif %}>
|
||||||
|
<span class="track"></span>
|
||||||
|
<span>LDAP-Anmeldung aktivieren</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Server {{ hi.hint_icon("DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.", "Server") }}</label>
|
||||||
|
<input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
|
<input type="checkbox" name="ldap_use_ssl" id="ldap_use_ssl" {% if ldap.use_ssl %}checked{% endif %}
|
||||||
|
onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;">
|
||||||
|
<span class="track"></span>
|
||||||
|
<span>LDAPS/TLS verwenden {{ hi.hint_icon("Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.", "LDAPS/TLS verwenden") }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Port</label>
|
||||||
|
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
|
<input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}>
|
||||||
|
<span class="track"></span>
|
||||||
|
<span>Zertifikatsprüfung überspringen (nur LDAPS) {{ hi.hint_icon("Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.", "Zertifikatsprüfung überspringen") }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Bind-Konto (Service-Account) {{ hi.hint_icon("Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.", "Bind-Konto (Service-Account)") }}</label>
|
||||||
|
<input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Bind-Passwort</label>
|
||||||
|
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Base-DN</label>
|
||||||
|
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Attribut für Benutzername {{ hi.hint_icon("Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.", "Attribut für Benutzername") }}</label>
|
||||||
|
<input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Standardgruppe für neue AD-Benutzer {{ hi.hint_icon("Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.", "Standardgruppe für neue AD-Benutzer") }}</label>
|
||||||
|
<select name="ldap_default_group">
|
||||||
|
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
|
||||||
|
{% for g in ldap_groups %}
|
||||||
|
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select name="ldap_required_login_group" id="requiredGroupSelect" style="flex:1;">
|
||||||
|
<option value="">Keine (jeder gültige AD-Benutzer darf sich anmelden)</option>
|
||||||
|
{% if ldap.required_login_group %}
|
||||||
|
<option value="{{ ldap.required_login_group }}" selected>{{ ldap.required_login_group }}</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="requiredGroupLoadBtn">Gruppen laden</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint" id="requiredGroupLoadStatus">Ist hier eine Gruppe ausgewählt, scheitert die Anmeldung für alle Benutzer, die ihr NICHT angehören (rekursiv, auch über verschachtelte Gruppen) — wie bei falschen Zugangsdaten, ohne Hinweis auf den eigentlichen Grund.</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2" style="flex-wrap:wrap;">
|
||||||
|
<button type="submit" name="save_ldap" value="1" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="test_ldap" value="1" class="btn btn-secondary" formnovalidate>
|
||||||
|
Verbindung testen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if ldap.bind_dn %}
|
||||||
|
<form method="post" data-confirm="LDAP-Bind-Konto wirklich löschen? Die LDAP-Anmeldung wird dabei deaktiviert." style="margin-top:10px;">
|
||||||
|
<input type="hidden" name="clear_ldap_bind" value="1">
|
||||||
|
<button type="submit" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);">
|
||||||
|
Bind-Konto löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
{% if current_user.has_permission('settings_ldap.edit') %}
|
||||||
|
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
|
||||||
|
{% else %}
|
||||||
|
Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">AD-Gruppenzuordnungen {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.", "AD-Gruppenzuordnungen") }}</h2>
|
||||||
|
</div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Zuordnung hinzufügen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if mappings %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>AD-Gruppe</th><th>App-Rechtegruppe</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in mappings %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ m.ad_group_name }}<div class="text-faint mono" style="font-size:11px;">{{ m.ad_group_dn }}</div></td>
|
||||||
|
<td>{{ m.app_group_name or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="icon-btn" title="Bearbeiten"
|
||||||
|
onclick="openEditLdapMappingModal('{{ m.id }}','{{ m.ad_group_dn|e }}','{{ m.ad_group_name|e }}','{{ m.app_group_id }}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?">
|
||||||
|
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}">
|
||||||
|
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Noch keine Zuordnung angelegt — alle neuen AD-Benutzer erhalten nur die Standardgruppe.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="modal-overlay" id="addLdapMappingModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="add_ldap_group_mapping" value="1">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>AD-Gruppenzuordnung hinzufügen</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>AD-Gruppe</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select name="ad_group_dn" id="ldapMappingAdGroup" required style="flex:1;">
|
||||||
|
<option value="">— zuerst laden —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="ldapMappingLoadGroupsBtn">Gruppen laden</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="ad_group_name" id="ldapMappingAdGroupName">
|
||||||
|
<div class="field-hint" id="ldapMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>App-Rechtegruppe</label>
|
||||||
|
<select name="app_group_id" required>
|
||||||
|
<option value="">— auswählen —</option>
|
||||||
|
<option value="admin">Admin (alle Rechte)</option>
|
||||||
|
{% for g in ldap_groups %}
|
||||||
|
<option value="{{ g['id'] }}">{{ g['name'] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="editLdapMappingModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="edit_ldap_group_mapping" id="editLdapMappingId" value="">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>AD-Gruppenzuordnung bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>AD-Gruppe</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select name="ad_group_dn" id="editLdapMappingAdGroup" required style="flex:1;"></select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="editLdapMappingLoadGroupsBtn">Gruppen laden</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="ad_group_name" id="editLdapMappingAdGroupName">
|
||||||
|
<div class="field-hint" id="editLdapMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>App-Rechtegruppe</label>
|
||||||
|
<select name="app_group_id" id="editLdapMappingAppGroup" required>
|
||||||
|
<option value="">— auswählen —</option>
|
||||||
|
<option value="admin">Admin (alle Rechte)</option>
|
||||||
|
{% for g in ldap_groups %}
|
||||||
|
<option value="{{ g['id'] }}">{{ g['name'] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function wireAdGroupLoader(btnId, selectId, nameFieldId, statusId, includeEmptyOption) {
|
||||||
|
var btn = document.getElementById(btnId);
|
||||||
|
var select = document.getElementById(selectId);
|
||||||
|
var nameField = nameFieldId ? document.getElementById(nameFieldId) : null;
|
||||||
|
var status = document.getElementById(statusId);
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
// Aktuellen Wert (z.B. die vorher gespeicherte, noch nicht per
|
||||||
|
// Klartext-Namen aufgeloeste Gruppe) merken -- bleibt nach dem
|
||||||
|
// Neuaufbau der Optionsliste ausgewaehlt, falls sie unter den
|
||||||
|
// geladenen Gruppen auftaucht.
|
||||||
|
var currentValue = select.value;
|
||||||
|
status.textContent = 'Lade Gruppen …';
|
||||||
|
fetch("{{ url_for('settings_ldap_ad_groups') }}")
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (groups) {
|
||||||
|
if (!Array.isArray(groups)) {
|
||||||
|
status.textContent = groups.error || 'Fehler beim Laden.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.innerHTML = '';
|
||||||
|
if (includeEmptyOption) {
|
||||||
|
var emptyOpt = document.createElement('option');
|
||||||
|
emptyOpt.value = '';
|
||||||
|
emptyOpt.textContent = 'Keine (jeder gültige AD-Benutzer darf sich anmelden)';
|
||||||
|
select.appendChild(emptyOpt);
|
||||||
|
}
|
||||||
|
if (!groups.length) {
|
||||||
|
if (!includeEmptyOption) select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
|
||||||
|
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
groups.forEach(function (g) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = g.dn;
|
||||||
|
opt.textContent = g.name;
|
||||||
|
opt.dataset.name = g.name;
|
||||||
|
if (g.dn === currentValue) opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (nameField) nameField.value = (select.options[select.selectedIndex] && select.options[select.selectedIndex].dataset.name) || '';
|
||||||
|
status.textContent = groups.length + ' Gruppe(n) geladen.';
|
||||||
|
})
|
||||||
|
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
|
||||||
|
});
|
||||||
|
|
||||||
|
select.addEventListener('change', function () {
|
||||||
|
if (!nameField) return;
|
||||||
|
var opt = select.options[select.selectedIndex];
|
||||||
|
nameField.value = (opt && opt.dataset.name) || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wireAdGroupLoader('ldapMappingLoadGroupsBtn', 'ldapMappingAdGroup', 'ldapMappingAdGroupName', 'ldapMappingLoadStatus', false);
|
||||||
|
wireAdGroupLoader('requiredGroupLoadBtn', 'requiredGroupSelect', null, 'requiredGroupLoadStatus', true);
|
||||||
|
wireAdGroupLoader('editLdapMappingLoadGroupsBtn', 'editLdapMappingAdGroup', 'editLdapMappingAdGroupName', 'editLdapMappingLoadStatus', false);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Bearbeiten-Modals vorbefuellen -- die AD-Gruppe steht dabei zunaechst nur
|
||||||
|
// als DN+Name aus der Datenbank zur Verfuegung (ohne erneute LDAP-Abfrage);
|
||||||
|
// "Gruppen laden" ersetzt die Auswahlliste bei Bedarf durch die vollstaendige,
|
||||||
|
// aktuelle AD-Gruppenliste und behaelt den bisherigen Wert dabei bei (siehe
|
||||||
|
// wireAdGroupLoader oben).
|
||||||
|
function seedMappingSelect(selectId, dn, name) {
|
||||||
|
var select = document.getElementById(selectId);
|
||||||
|
select.innerHTML = '';
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = dn;
|
||||||
|
opt.textContent = name || dn;
|
||||||
|
opt.dataset.name = name || dn;
|
||||||
|
opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditLdapMappingModal(id, dn, name, appGroupId) {
|
||||||
|
document.getElementById('editLdapMappingId').value = id;
|
||||||
|
seedMappingSelect('editLdapMappingAdGroup', dn, name);
|
||||||
|
document.getElementById('editLdapMappingAdGroupName').value = name;
|
||||||
|
document.getElementById('editLdapMappingAppGroup').value = appGroupId;
|
||||||
|
document.getElementById('editLdapMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
|
||||||
|
PoeUI.openModal('editLdapMappingModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "settings_license" %}
|
||||||
|
{% set can_edit = current_user.has_permission('settings_system.edit') %}
|
||||||
|
{% block page_title %}Lizenz{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
<div class="settings-grid">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Status</h2>
|
||||||
|
<div class="hint">Aktuell installierte Lizenz und ihr Zustand.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if license_error %}
|
||||||
|
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ license_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not license_file %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Keine Lizenzdatei vorhanden — bitte unten eine Lizenzdatei hochladen.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="field">
|
||||||
|
<label>Kunde</label>
|
||||||
|
<input type="text" value="{{ license_file.customer.name or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Lizenztyp</label>
|
||||||
|
<input type="text" value="{{ {'trial': 'Trial', 'standard': 'Standard', 'custom': 'Custom', 'enterprise': 'Enterprise'}.get(license_file.type, license_file.type) }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Module {{ hi.hint_icon("Zusätzlich zu den immer verfügbaren Standard-Funktionen freigeschaltete Vollmodule.", "Module") }}</label>
|
||||||
|
<input type="text" value="{{ (license_file.modules or [])|join(', ') if license_file.modules else 'keine' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Ausgestellt am</label>
|
||||||
|
<input type="text" value="{{ license_file.issued_at or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Läuft ab am</label>
|
||||||
|
<input type="text" value="{{ license_file.expires_at or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Aktivierungsstatus</label>
|
||||||
|
<input type="text" value="{{ 'Aktiviert' if license_is_activated else 'Nicht aktiviert' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Letzter Heartbeat</label>
|
||||||
|
<input type="text" value="{{ license_last_heartbeat_at or 'noch nie' }}" disabled>
|
||||||
|
</div>
|
||||||
|
{% if license_last_error %}
|
||||||
|
<div class="field">
|
||||||
|
<label>Letzter Fehler</label>
|
||||||
|
<input type="text" value="{{ license_last_error }}" disabled>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="field">
|
||||||
|
<label>System-Fingerabdruck {{ hi.hint_icon("Eindeutiger Identifikator dieses Systems, an den die Lizenz bei der Aktivierung gebunden wird.", "System-Fingerabdruck") }}</label>
|
||||||
|
<input type="text" class="mono" value="{{ license_fingerprint }}" disabled>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if license_file and license_valid %}
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Aktivierung</h2>
|
||||||
|
<div class="hint">
|
||||||
|
Ein Protokoll, zwei Wege: automatisch online, oder — falls kein Netzwerkzugriff auf den
|
||||||
|
Lizenzserver besteht — per Code manuell mit dem Lizenzserver-Administrator ausgetauscht.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not can_edit %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für Aktivierung/Deaktivierung fehlt das Recht „Systemeinstellungen ändern“.</p>
|
||||||
|
|
||||||
|
{% elif pending_action %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px; margin-bottom:10px;">
|
||||||
|
Offene {{ 'Aktivierungs' if pending_action.action == 'activate' else 'Deaktivierungs' }}-Anfrage — folgenden Code
|
||||||
|
beim Lizenzserver-Administrator eingeben:
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<textarea class="mono" rows="4" readonly onclick="this.select()" style="width:100%; resize:vertical;">{{ pending_code }}</textarea>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('license_activate_confirm' if pending_action.action == 'activate' else 'license_deactivate_confirm') }}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="confirmation_code">Bestätigungscode vom Lizenzserver</label>
|
||||||
|
<textarea class="mono" name="confirmation_code" id="confirmation_code" rows="4" style="width:100%; resize:vertical;" required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Code bestätigen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('license_activate' if pending_action.action == 'activate' else 'license_deactivate') }}" style="margin-top:10px;">
|
||||||
|
<button type="submit" class="btn btn-secondary btn-block">Erneut online versuchen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% elif license_is_activated %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px; margin-bottom:10px;">
|
||||||
|
Lizenz ist aktiviert. Bei einem Systemwechsel zuerst hier deaktivieren — danach kann der Kunde sich beim
|
||||||
|
Anbieter selbst eine neue Lizenz für das neue System ausstellen lassen.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('license_deactivate') }}"
|
||||||
|
data-confirm="Lizenz wirklich deaktivieren? Alle lizenzpflichtigen Module/Funktionen werden danach sofort inaktiv (Export bleibt bis zur nächsten Aktivierung möglich).">
|
||||||
|
<button type="submit" class="btn btn-danger btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>
|
||||||
|
Lizenz deaktivieren (Systemwechsel)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('license_activate') }}">
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Jetzt aktivieren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Lizenzdatei hochladen</h2>
|
||||||
|
<div class="hint">Vom Anbieter erhaltene Lizenzdatei einspielen — muss danach noch aktiviert werden.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<form method="post" action="{{ url_for('license_upload') }}" enctype="multipart/form-data">
|
||||||
|
<div class="field">
|
||||||
|
<label for="license_file">Lizenzdatei</label>
|
||||||
|
<input type="file" name="license_file" id="license_file" accept=".json,.lic" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||||
|
Hochladen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für den Upload fehlt das Recht „Systemeinstellungen ändern“.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if license_file and license_file.vendor %}
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Anbieter</h2>
|
||||||
|
<div class="hint">Kontakt für Rückfragen zu dieser Lizenz.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Firma</label>
|
||||||
|
<input type="text" value="{{ license_file.vendor.name or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Telefon</label>
|
||||||
|
<input type="text" value="{{ license_file.vendor.phone or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>E-Mail</label>
|
||||||
|
<input type="text" value="{{ license_file.vendor.email or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Anschrift</label>
|
||||||
|
<input type="text" value="{{ license_file.vendor.address or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "settings_nginx" %}
|
||||||
|
{% set can_edit = current_user.has_permission('settings_nginx.edit') and license_active() %}
|
||||||
|
{% block page_title %}NGINX{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Reverse-Proxy</h2>
|
||||||
|
<div class="hint">Domain (server_name), Ports und HTTPS/HSTS für den nginx-Reverse-Proxy vor der App.</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm"
|
||||||
|
onclick="openRawLogModal('{{ url_for('settings_nginx_raw') }}', 'Aktuelle nginx-Konfiguration (RAW)')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
|
RAW-Konfiguration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pending_nginx_token %}
|
||||||
|
<div class="card-pad" style="background:var(--warning-dim); border-radius:var(--radius-sm); margin-bottom:16px;">
|
||||||
|
<p style="margin:0 0 12px; font-size:13px;">
|
||||||
|
Neue nginx-Konfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung —
|
||||||
|
bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ nginx_revert_seconds }}s ohne Bestätigung).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="confirm_nginx" value="{{ pending_nginx_token }}">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Verbindung funktioniert — bestätigen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% elif can_edit %}
|
||||||
|
<form method="post"
|
||||||
|
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
|
||||||
|
<div class="field">
|
||||||
|
<label for="server_name">Domain (server_name) {{ hi.hint_icon("\"_\" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.", "Domain (server_name)") }}</label>
|
||||||
|
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="field field--half">
|
||||||
|
<label for="http_port">HTTP-Port</label>
|
||||||
|
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="field field--half">
|
||||||
|
<label for="https_port">HTTPS-Port {{ hi.hint_icon("Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) — weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.", "HTTP-Port / HTTPS-Port") }}</label>
|
||||||
|
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
|
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
|
||||||
|
<span class="track"></span>
|
||||||
|
<span>SSL/HTTPS aktivieren</span>
|
||||||
|
</label>
|
||||||
|
{% if not cert_info %}<div class="field-hint">Erst nach Hochladen oder Anfordern eines Zertifikats verfügbar.</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
|
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
|
||||||
|
<span class="track"></span>
|
||||||
|
<span>HSTS (Strict-Transport-Security) {{ hi.hint_icon("Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.", "HSTS") }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="apply_nginx" value="1" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
|
||||||
|
Anwenden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
{% if current_user.has_permission('settings_nginx.edit') %}
|
||||||
|
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
|
||||||
|
{% else %}
|
||||||
|
Für Änderungen fehlt das Recht „NGINX ändern“.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Zertifikat</h2>
|
||||||
|
<div class="hint">Hochladen (PEM) oder automatisch per Let's Encrypt anfordern — mit automatischer Verlängerung über certbots eigenen Timer.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if cert_info %}
|
||||||
|
<div class="detail-list" style="margin-bottom:18px;">
|
||||||
|
<div class="detail-row"><span class="k">Quelle</span><span class="v">{{ "Let's Encrypt" if cert_info.source == "letsencrypt" else "Hochgeladen" }}</span></div>
|
||||||
|
<div class="detail-row"><span class="k">Subject</span><span class="v" style="text-align:right; word-break:break-all;">{{ cert_info.subject }}</span></div>
|
||||||
|
<div class="detail-row"><span class="k">Aussteller</span><span class="v" style="text-align:right; word-break:break-all;">{{ cert_info.issuer }}</span></div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="k">Gültig bis</span>
|
||||||
|
<span class="v">
|
||||||
|
{{ cert_info.not_after.strftime('%d.%m.%Y') }}
|
||||||
|
{% if cert_info.days_left < 0 %}
|
||||||
|
<span class="pill offline" style="margin-left:6px;">abgelaufen</span>
|
||||||
|
{% elif cert_info.days_left <= 30 %}
|
||||||
|
<span class="pill unknown" style="margin-left:6px;">noch {{ cert_info.days_left }} Tage</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="pill online" style="margin-left:6px;">noch {{ cert_info.days_left }} Tage</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if cert_info.source == "letsencrypt" %}
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="k">Automatische Verlängerung</span>
|
||||||
|
<span class="v">
|
||||||
|
{% if certbot_timer and certbot_timer.active %}
|
||||||
|
<span class="pill online">aktiv</span>
|
||||||
|
{% if certbot_timer.next_run %}<span class="text-faint" style="font-size:11px; display:block; margin-top:2px;">nächster Lauf: {{ certbot_timer.next_run }}</span>{% endif %}
|
||||||
|
{% elif certbot_timer %}
|
||||||
|
<span class="pill offline">inaktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="pill unknown">unbekannt</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if cert_info.source == "letsencrypt" and can_edit %}
|
||||||
|
<div class="flex gap-2" style="margin-bottom:18px;">
|
||||||
|
<form method="post" style="flex:1;">
|
||||||
|
<button type="submit" name="test_renewal" value="1" class="btn btn-secondary btn-block btn-sm">Verlängerung testen (Dry-Run)</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="flex:1;" data-confirm="Zertifikat jetzt sofort erneuern (zählt gegen Let's Encrypts Rate-Limits)?">
|
||||||
|
<button type="submit" name="force_renew" value="1" class="btn btn-secondary btn-block btn-sm">Jetzt manuell verlängern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Noch kein Zertifikat hinterlegt.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="field">
|
||||||
|
<label style="font-weight:600; font-size:13px;">Let's Encrypt anfordern</label>
|
||||||
|
<div class="field-hint" style="margin-bottom:8px;">Setzt voraus, dass die Domain öffentlich auf diesen Host auflöst und Port 80 aus dem Internet erreichbar ist (HTTP-01-Validierung) — für rein interne Instanzen ohne öffentliche Domain nicht nutzbar, dann stattdessen hochladen. Verlängerung läuft danach automatisch über certbots eigenen Timer{% if certbot_timer %} ({{ "aktiv" if certbot_timer.active else "inaktiv!" }}){% endif %}, zweimal täglich.</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" style="margin-bottom:18px;">
|
||||||
|
<div class="field">
|
||||||
|
<label for="le_domain">Domain</label>
|
||||||
|
<input type="text" name="le_domain" id="le_domain" placeholder="z.B. tesm.example.com" value="{{ server_name if server_name != '_' else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="le_email">E-Mail (optional, für Ablauf-Erinnerungen)</label>
|
||||||
|
<input type="email" name="le_email" id="le_email" placeholder="admin@example.com">
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="request_letsencrypt" value="1" class="btn btn-secondary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>
|
||||||
|
Zertifikat anfordern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="field"><label style="font-weight:600; font-size:13px;">Manuell hochladen</label></div>
|
||||||
|
<form method="post" enctype="multipart/form-data" style="margin-bottom:10px;">
|
||||||
|
<div class="field">
|
||||||
|
<label for="cert_file">Zertifikat (PEM, .crt/.pem)</label>
|
||||||
|
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt) {{ hi.hint_icon("Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.", "Privater Schlüssel") }}</label>
|
||||||
|
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||||
|
Zertifikat + Schlüssel hochladen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if cert_info and not ssl_enabled %}
|
||||||
|
<form method="post" data-confirm="Hinterlegtes Zertifikat wirklich entfernen?" data-no-unsaved-guard>
|
||||||
|
<button type="submit" name="remove_cert" value="1" class="btn btn-danger btn-block">Zertifikat entfernen</button>
|
||||||
|
</form>
|
||||||
|
{% elif cert_info %}
|
||||||
|
<p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
{% if current_user.has_permission('settings_nginx.edit') %}
|
||||||
|
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
|
||||||
|
{% else %}
|
||||||
|
Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const sslCheck = document.getElementById("ssl_enabled_check");
|
||||||
|
const hstsCheck = document.getElementById("hsts_enabled_check");
|
||||||
|
if (sslCheck && hstsCheck) {
|
||||||
|
sslCheck.addEventListener("change", () => {
|
||||||
|
hstsCheck.disabled = !sslCheck.checked;
|
||||||
|
if (!sslCheck.checked) hstsCheck.checked = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "users" %}
|
||||||
|
{% block page_title %}Benutzer{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Benutzer</h2>
|
||||||
|
<div class="hint">Die Gruppe bestimmt die Rechte eines Benutzers.</div>
|
||||||
|
</div>
|
||||||
|
{% if current_user.has_permission('users.create') %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if ldap_enabled %}
|
||||||
|
<button type="button" class="btn btn-secondary" data-open-modal="ldapAddModal" onclick="resetLdapSearch();">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
Aus Active Directory hinzufügen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Neuer Benutzer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<div class="search-input">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
<input type="text" id="userSearch" placeholder="Benutzer durchsuchen…" oninput="filterTable('userSearch','usersTable')">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table" id="usersTable" data-sortable>
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort-key="username">Username</th>
|
||||||
|
<th data-sort-key="firstname">Vorname</th>
|
||||||
|
<th data-sort-key="lastname">Nachname</th>
|
||||||
|
<th data-sort-key="group">Gruppe</th>
|
||||||
|
<th style="width:1%;">Aktionen</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
{% set group_label = 'Admin' if u['is_admin'] else (u['group_names'] or '') %}
|
||||||
|
{% set is_ldap = u['auth_source'] == 'ldap' %}
|
||||||
|
<tr data-sort-username="{{ u['username']|lower }}" data-sort-firstname="{{ (u['first_name'] or '')|lower }}" data-sort-lastname="{{ (u['last_name'] or '')|lower }}" data-sort-group="{{ group_label|lower }}">
|
||||||
|
<td class="cell-name">
|
||||||
|
{{ u['username'] }}
|
||||||
|
{% if is_ldap %}<span class="pill user" style="font-size:10px; padding:2px 7px;" title="Konto stammt aus Active Directory/LDAP, Passwort wird dort verwaltet">AD</span>{% endif %}
|
||||||
|
{% if u['is_locked'] %}<span class="pill" style="font-size:10px; padding:2px 7px; background:var(--danger-dim); color:var(--danger);" title="Login für dieses Konto ist gesperrt">Gesperrt</span>{% endif %}
|
||||||
|
{% if u['email'] %}<div class="text-faint" style="font-size:11px;">{{ u['email'] }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-dim">{{ u['first_name'] or '—' }}</td>
|
||||||
|
<td class="text-dim">{{ u['last_name'] or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if u['is_admin'] %}
|
||||||
|
<span class="pill admin">Admin</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-dim">{{ u['group_names'] or '—' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% set may_touch_target = current_user.is_admin or not u['is_admin'] %}
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
{% if current_user.has_permission('users.edit') and may_touch_target and not is_ldap %}
|
||||||
|
<button class="icon-btn" title="Bearbeiten"
|
||||||
|
onclick="openEditModal({{ u['id'] }}, '{{ u['username'] }}', '{{ u['first_name'] or '' }}', '{{ u['last_name'] or '' }}', '{{ u['email'] or '' }}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has_permission('users.edit') and may_touch_target %}
|
||||||
|
<button class="icon-btn" title="Gruppe zuweisen"
|
||||||
|
onclick="openGroupModal({{ u['id'] }}, '{{ 'admin' if u['is_admin'] else (u['group_id'] or '') }}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has_permission('users.edit') and may_touch_target and u['id'] != current_user.id %}
|
||||||
|
<form method="post" data-confirm="„{{ u['username'] }}“ wirklich {{ 'entsperren' if u['is_locked'] else 'sperren' }}?">
|
||||||
|
<input type="hidden" name="toggle_lock" value="{{ u['id'] }}">
|
||||||
|
<button type="submit" class="icon-btn" title="{{ 'Entsperren' if u['is_locked'] else 'Sperren' }}">
|
||||||
|
{% if u['is_locked'] %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 019.9-1"/></svg>
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has_permission('users.edit') and may_touch_target %}
|
||||||
|
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden.">
|
||||||
|
<input type="hidden" name="delete_user" value="{{ u['id'] }}">
|
||||||
|
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr class="empty-row"><td colspan="5">Noch keine Benutzer vorhanden.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="userModal">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
<form method="post" id="userForm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Neuen Benutzer anlegen</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field"><label>Vorname</label><input type="text" name="first_name"></div>
|
||||||
|
<div class="field"><label>Name</label><input type="text" name="last_name"></div>
|
||||||
|
<div class="field"><label>Username</label><input type="text" name="username" required></div>
|
||||||
|
<div class="field"><label>E-Mail</label><input type="email" name="email" placeholder="optional — ermöglicht Login per E-Mail"></div>
|
||||||
|
<div class="field"><label>Passwort</label><input type="password" name="password" required></div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Gruppe</label>
|
||||||
|
<select name="group_id">
|
||||||
|
{% for g in all_groups %}<option value="{{ g['id'] }}" {% if g['is_default'] %}selected{% endif %}>{{ g['name'] }}</option>{% endfor %}
|
||||||
|
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" name="add_user" value="1" class="btn btn-primary">Anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="editModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post" id="editForm">
|
||||||
|
<input type="hidden" name="user_id" id="edit_user_id">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Benutzer bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field"><label>Vorname</label><input type="text" name="first_name" id="edit_first_name"></div>
|
||||||
|
<div class="field"><label>Name</label><input type="text" name="last_name" id="edit_last_name"></div>
|
||||||
|
<div class="field"><label>Username</label><input type="text" name="username" id="edit_username" required></div>
|
||||||
|
<div class="field"><label>E-Mail</label><input type="email" name="email" id="edit_email" placeholder="optional — ermöglicht Login per E-Mail"></div>
|
||||||
|
<div class="field"><label>Neues Passwort</label>
|
||||||
|
<input type="password" name="new_password" placeholder="Nur bei Änderung ausfüllen">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" name="edit_user" value="1" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if ldap_enabled %}
|
||||||
|
<div class="modal-overlay" id="ldapAddModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post" id="ldapAddForm">
|
||||||
|
<input type="hidden" name="ldap_username" id="ldap_add_username">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Aus Active Directory hinzufügen</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Suche</label>
|
||||||
|
<input type="text" id="ldapSearchInput" placeholder="Name, Benutzername oder UPN eingeben …" autocomplete="off">
|
||||||
|
<div class="field-hint" id="ldapSearchStatus">Mindestens 2 Zeichen eingeben.</div>
|
||||||
|
</div>
|
||||||
|
<div id="ldapSearchResults" style="max-height:240px; overflow-y:auto; display:flex; flex-direction:column; gap:4px;"></div>
|
||||||
|
<div class="field" id="ldapAddGroupField" style="display:none;">
|
||||||
|
<label>Gruppe für <span id="ldapAddSelectedName"></span></label>
|
||||||
|
<select name="group_id" id="ldapAddGroupSelect">
|
||||||
|
{% for g in all_groups %}<option value="{{ g['id'] }}" {% if g['is_default'] %}selected{% endif %}>{{ g['name'] }}</option>{% endfor %}
|
||||||
|
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" name="ldap_add_user" value="1" class="btn btn-primary" id="ldapAddSubmit" disabled>Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="groupModal">
|
||||||
|
<div class="modal" style="max-width:380px;">
|
||||||
|
<form method="post" id="groupForm">
|
||||||
|
<input type="hidden" name="user_id" id="group_user_id">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe zuweisen</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Gruppe {{ hi.hint_icon("Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.", "Gruppe") }}</label>
|
||||||
|
<select name="group_id" id="group_select">
|
||||||
|
<option value="">Keine Gruppe</option>
|
||||||
|
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
|
||||||
|
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" name="assign_group" value="1" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function filterTable(inputId, tableId) {
|
||||||
|
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
||||||
|
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||||||
|
if (row.classList.contains("empty-row")) return;
|
||||||
|
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(userId, username, firstName, lastName, email) {
|
||||||
|
document.getElementById("edit_user_id").value = userId;
|
||||||
|
document.getElementById("edit_username").value = username;
|
||||||
|
document.getElementById("edit_first_name").value = firstName;
|
||||||
|
document.getElementById("edit_last_name").value = lastName;
|
||||||
|
document.getElementById("edit_email").value = email || "";
|
||||||
|
document.querySelector("#editForm input[name='new_password']").value = "";
|
||||||
|
PoeUI.openModal("editModal");
|
||||||
|
}
|
||||||
|
function openGroupModal(userId, groupChoice) {
|
||||||
|
document.getElementById("group_user_id").value = userId;
|
||||||
|
document.getElementById("group_select").value = groupChoice || "";
|
||||||
|
PoeUI.openModal("groupModal");
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if ldap_enabled %}
|
||||||
|
function resetLdapSearch() {
|
||||||
|
document.getElementById("ldapSearchInput").value = "";
|
||||||
|
document.getElementById("ldapSearchResults").innerHTML = "";
|
||||||
|
document.getElementById("ldapSearchStatus").textContent = "Mindestens 2 Zeichen eingeben.";
|
||||||
|
document.getElementById("ldapAddGroupField").style.display = "none";
|
||||||
|
document.getElementById("ldap_add_username").value = "";
|
||||||
|
document.getElementById("ldapAddSubmit").disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var input = document.getElementById("ldapSearchInput");
|
||||||
|
var results = document.getElementById("ldapSearchResults");
|
||||||
|
var status = document.getElementById("ldapSearchStatus");
|
||||||
|
if (!input) return;
|
||||||
|
var debounceTimer = null;
|
||||||
|
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
var q = input.value.trim();
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
if (q.length < 2) {
|
||||||
|
results.innerHTML = "";
|
||||||
|
status.textContent = "Mindestens 2 Zeichen eingeben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = "Suche …";
|
||||||
|
debounceTimer = setTimeout(function () {
|
||||||
|
fetch("{{ url_for('users_ldap_search') }}?q=" + encodeURIComponent(q))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
status.textContent = data.error || "Fehler bei der Suche.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.innerHTML = "";
|
||||||
|
if (!data.length) {
|
||||||
|
status.textContent = "Keine Treffer (oder bereits lokal bekannt).";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = data.length + " Treffer:";
|
||||||
|
data.forEach(function (u) {
|
||||||
|
var full = [u.first_name, u.last_name].filter(Boolean).join(" ");
|
||||||
|
var row = document.createElement("button");
|
||||||
|
row.type = "button";
|
||||||
|
row.className = "btn btn-secondary btn-sm";
|
||||||
|
row.style.textAlign = "left";
|
||||||
|
row.style.justifyContent = "flex-start";
|
||||||
|
row.textContent = u.username + (full ? " — " + full : "") + (u.email ? " (" + u.email + ")" : "");
|
||||||
|
row.addEventListener("click", function () {
|
||||||
|
document.getElementById("ldap_add_username").value = u.username;
|
||||||
|
document.getElementById("ldapAddSelectedName").textContent = u.username;
|
||||||
|
document.getElementById("ldapAddGroupField").style.display = "";
|
||||||
|
document.getElementById("ldapAddSubmit").disabled = false;
|
||||||
|
});
|
||||||
|
results.appendChild(row);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { status.textContent = "Fehler bei der Suche."; });
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1 +1 @@
|
|||||||
1.1.11
|
1.2.0
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|||||||
from cryptography.hazmat.primitives import hashes, serialization
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import base64, csv, io, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
|
import base64, csv, io, ipaddress, logging, math, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
|
||||||
import paramiko
|
import paramiko
|
||||||
import yaml
|
import yaml
|
||||||
import ssl
|
import ssl
|
||||||
@@ -32,6 +32,9 @@ from ldap3.core.exceptions import (
|
|||||||
LDAPResponseTimeoutError,
|
LDAPResponseTimeoutError,
|
||||||
)
|
)
|
||||||
from ldap3.utils.conv import escape_filter_chars
|
from ldap3.utils.conv import escape_filter_chars
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import licensing
|
||||||
|
|
||||||
LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh")
|
|||||||
FERNET_KEY_PATH = os.environ.get("TESM_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
|
FERNET_KEY_PATH = os.environ.get("TESM_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
|
||||||
SSH_KNOWN_HOSTS_PATH = os.environ.get("TESM_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts"))
|
SSH_KNOWN_HOSTS_PATH = os.environ.get("TESM_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts"))
|
||||||
SECRET_KEY_PATH = os.environ.get("TESM_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key"))
|
SECRET_KEY_PATH = os.environ.get("TESM_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key"))
|
||||||
|
LICENSE_PATH = os.environ.get("TESM_LICENSE_PATH", os.path.join(BASE_DIR, "license.json"))
|
||||||
AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars")
|
AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars")
|
||||||
|
|
||||||
TESM_LOG_DIR = os.environ.get("TESM_LOG_DIR", "/var/log/tesm")
|
TESM_LOG_DIR = os.environ.get("TESM_LOG_DIR", "/var/log/tesm")
|
||||||
@@ -438,6 +442,7 @@ NAV_ITEMS = [
|
|||||||
{"key": "fileshare", "label": "Dateifreigaben", "icon": "folder", "endpoint": "fileshare"},
|
{"key": "fileshare", "label": "Dateifreigaben", "icon": "folder", "endpoint": "fileshare"},
|
||||||
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
|
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
|
||||||
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
|
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
|
||||||
|
{"key": "settings_license", "label": "Lizenz", "icon": "key", "endpoint": "settings_license"},
|
||||||
{"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
|
{"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
|
||||||
{"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"},
|
{"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"},
|
||||||
{"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
|
{"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
|
||||||
@@ -465,7 +470,7 @@ def _nav_key_visible(key, user):
|
|||||||
if key == "credentials":
|
if key == "credentials":
|
||||||
return user.can_manage_credentials
|
return user.can_manage_credentials
|
||||||
if key == "maintenance":
|
if key == "maintenance":
|
||||||
return user.can_view_maintenance
|
return user.can_view_maintenance and module_licensed("maintenance")
|
||||||
if key == "papierkorb":
|
if key == "papierkorb":
|
||||||
return user.can_view_papierkorb
|
return user.can_view_papierkorb
|
||||||
if key == "users":
|
if key == "users":
|
||||||
@@ -473,13 +478,22 @@ def _nav_key_visible(key, user):
|
|||||||
if key == "groups":
|
if key == "groups":
|
||||||
return user.can_manage_groups
|
return user.can_manage_groups
|
||||||
if key == "fileshare":
|
if key == "fileshare":
|
||||||
return user.has_permission("fileshare.view") and bool(_current_fileshare_mounts())
|
return (
|
||||||
|
user.has_permission("fileshare.view")
|
||||||
|
and bool(_current_fileshare_mounts())
|
||||||
|
and module_licensed("fileshare")
|
||||||
|
)
|
||||||
if key == "settings_system":
|
if key == "settings_system":
|
||||||
return user.can_view_settings_system
|
return user.can_view_settings_system
|
||||||
|
if key == "settings_license":
|
||||||
|
# Immer erreichbar für jeden mit Systemeinstellungen-Leserecht --
|
||||||
|
# muss auch OHNE Lizenz sichtbar bleiben, sonst könnte niemand
|
||||||
|
# jemals eine erste Lizenz hochladen/aktivieren.
|
||||||
|
return user.can_view_settings_system
|
||||||
if key == "settings_importexport":
|
if key == "settings_importexport":
|
||||||
return user.can_view_settings_importexport
|
return user.can_view_settings_importexport
|
||||||
if key == "settings_dhcp":
|
if key == "settings_dhcp":
|
||||||
return user.can_view_settings_dhcp
|
return user.can_view_settings_dhcp and module_licensed("dhcp")
|
||||||
if key == "settings_ldap":
|
if key == "settings_ldap":
|
||||||
return user.can_view_settings_ldap
|
return user.can_view_settings_ldap
|
||||||
if key == "settings_nginx":
|
if key == "settings_nginx":
|
||||||
@@ -491,7 +505,7 @@ def _nav_key_visible(key, user):
|
|||||||
if key == "logs_activity":
|
if key == "logs_activity":
|
||||||
return user.can_view_activity_log
|
return user.can_view_activity_log
|
||||||
if key == "logs_kea":
|
if key == "logs_kea":
|
||||||
return user.can_view_kea_log
|
return user.can_view_kea_log and module_licensed("dhcp")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -2100,6 +2114,211 @@ if _IS_WEB_PROCESS:
|
|||||||
_fileshare_cleanup_all_on_startup()
|
_fileshare_cleanup_all_on_startup()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Lizenzsystem ==
|
||||||
|
# Kryptographie/Protokoll: siehe licensing.py. Hier nur der TESM-seitige
|
||||||
|
# Zustand: In-Memory-Cache der aktuell installierten Lizenzdatei (nach dem
|
||||||
|
# gleichen Prinzip wie _active_fileshare_mounts oben -- einmal geladen,
|
||||||
|
# NICHT pro Request neu von der Platte gelesen) plus die zwei zentralen
|
||||||
|
# Gate-Funktionen license_active()/module_licensed(), die von
|
||||||
|
# _nav_key_visible(), den Routen-Guards und den Templates aus aufgerufen
|
||||||
|
# werden. Kleine operative Zusatzangaben (letzter Heartbeat, letzter
|
||||||
|
# Fehler, Aktivierungsstatus) leben als gewöhnliche settings-Zeilen
|
||||||
|
# (get_setting/set_setting) -- die Lizenzdatei selbst bleibt eine flache
|
||||||
|
# Datei neben app.py (LICENSE_PATH), genau wie fernet.key/known_hosts.
|
||||||
|
LICENSE_HEARTBEAT_INTERVAL_SECONDS = 6 * 3600
|
||||||
|
|
||||||
|
_license_state = {"file": None, "valid": False, "status": None, "error": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_license():
|
||||||
|
"""Liest LICENSE_PATH neu ein und prüft die Master-Signatur. Wird beim
|
||||||
|
Modulimport einmal sowie nach jedem Upload/jeder Aktivierung/
|
||||||
|
Deaktivierung/erfolgreichem Heartbeat erneut aufgerufen -- niemals
|
||||||
|
implizit pro Request (siehe TESM_VERSION weiter oben für dasselbe
|
||||||
|
Prinzip: Neuladen ist ein bewusster, seltener Vorgang, kein Read-Pfad)."""
|
||||||
|
global _license_state
|
||||||
|
try:
|
||||||
|
with open(LICENSE_PATH, "r", encoding="utf-8") as f:
|
||||||
|
license_file = json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
_license_state = {"file": None, "valid": False, "status": None, "error": None}
|
||||||
|
return
|
||||||
|
if not licensing.verify_license_file(license_file):
|
||||||
|
_license_state = {
|
||||||
|
"file": license_file, "valid": False, "status": None,
|
||||||
|
"error": "Signatur ungültig -- Lizenzdatei beschädigt oder manipuliert.",
|
||||||
|
}
|
||||||
|
return
|
||||||
|
_license_state = {
|
||||||
|
"file": license_file, "valid": True,
|
||||||
|
"status": licensing.license_status(license_file), "error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_load_license()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_license_file(license_file):
|
||||||
|
"""Schreibt eine neue, signaturgeprüfte Lizenzdatei nach LICENSE_PATH
|
||||||
|
und lädt den In-Memory-Zustand sofort neu (kein Dienst-Neustart
|
||||||
|
nötig -- anders als z.B. bei der Versionsanzeige, siehe TESM_VERSION)."""
|
||||||
|
with open(LICENSE_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(license_file, f, indent=2)
|
||||||
|
try:
|
||||||
|
os.chmod(LICENSE_PATH, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_load_license()
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_license_file():
|
||||||
|
"""Löscht die lokale Lizenzdatei unwiderruflich -- Schritt 5 der
|
||||||
|
Deaktivierung (siehe licensing.py-Moduldocstring): erst NACH
|
||||||
|
erfolgreich verifizierter Deaktivierungs-Bestätigung des Masters
|
||||||
|
aufrufen, nie vorher."""
|
||||||
|
try:
|
||||||
|
os.remove(LICENSE_PATH)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
set_setting("license_activation_status", "")
|
||||||
|
_load_license()
|
||||||
|
|
||||||
|
|
||||||
|
def license_activated():
|
||||||
|
"""True erst NACH einer beim Master erfolgreich abgeschlossenen
|
||||||
|
Aktivierung (online oder per Offline-Code) -- reines Hochladen einer
|
||||||
|
gültig signierten Lizenzdatei allein reicht laut Protokoll bewusst
|
||||||
|
NICHT aus, siehe licensing.py-Moduldocstring Schritt 5."""
|
||||||
|
return get_setting("license_activation_status", "") == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def license_active():
|
||||||
|
"""Zentrales Gate für alles, was 'sichtbar, aber ohne Lizenz inaktiv'
|
||||||
|
ist (LDAP, NGINX, Papierkorb-Bearbeiten, Export, Prüfintervall,
|
||||||
|
automatischer PoE-Neustart). True nur bei: signaturgültiger,
|
||||||
|
aktivierter Lizenz, deren Ablaufdatum entweder noch nicht erreicht ist
|
||||||
|
oder deren 30-tägige Gnadenfrist danach noch läuft
|
||||||
|
(licensing.GRACE_PERIOD_DAYS)."""
|
||||||
|
return bool(
|
||||||
|
_license_state["valid"]
|
||||||
|
and _license_state["status"]
|
||||||
|
and _license_state["status"]["modules_active"]
|
||||||
|
and license_activated()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def module_licensed(module_key):
|
||||||
|
"""Gate für die komplett ausgeblendeten Vollmodule (dhcp/fileshare/
|
||||||
|
maintenance) -- zusätzlich zu license_active() muss das Modul auch
|
||||||
|
tatsächlich in der Lizenz enthalten sein."""
|
||||||
|
return license_active() and module_key in _license_state["status"]["modules"]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_module(module_key):
|
||||||
|
"""Serverseitiger Guard für die drei komplett ausgeblendeten Vollmodule
|
||||||
|
(dhcp/fileshare/maintenance) -- ein direkter URL-Aufruf ohne Lizenz
|
||||||
|
muss ebenso abgewiesen werden wie das reine Fehlen des Nav-Eintrags
|
||||||
|
(_nav_key_visible). Rückgabe: eine fertige Flash+Redirect-Response,
|
||||||
|
falls das Modul nicht lizenziert ist, sonst None -- Aufrufer prüft
|
||||||
|
`if resp := _require_module(...): return resp` nach dem eigentlichen
|
||||||
|
Berechtigungs-Check, exakt im gleichen Stil wie die übrigen
|
||||||
|
Permission-Guards in diesem File."""
|
||||||
|
if module_licensed(module_key):
|
||||||
|
return None
|
||||||
|
flash("Für dieses Modul ist keine gültige Lizenz vorhanden.", "danger")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
def license_export_allowed():
|
||||||
|
"""Export ist die EINE bewusste Ausnahme: bleibt auch im lizenzlosen
|
||||||
|
Zustand nutzbar, wenn dieser Zustand durch eine vom Kunden selbst
|
||||||
|
ausgelöste Deaktivierung (Systemwechsel-Fall) entstanden ist, damit
|
||||||
|
die eigenen Daten vor dem Umzug noch exportierbar sind. Sobald wieder
|
||||||
|
eine Lizenz aktiviert wird, greift ohnehin license_active() normal."""
|
||||||
|
return license_active() or get_setting("license_export_grace", "") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def license_info():
|
||||||
|
"""Für Topbar/Settings-Seite: kompletter aktueller Rohzustand."""
|
||||||
|
return _license_state
|
||||||
|
|
||||||
|
|
||||||
|
app.jinja_env.globals["license_active"] = license_active
|
||||||
|
app.jinja_env.globals["module_licensed"] = module_licensed
|
||||||
|
app.jinja_env.globals["license_export_allowed"] = license_export_allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _license_api_post(endpoint, path, payload, timeout=10):
|
||||||
|
"""Einfacher JSON-POST gegen den Lizenzserver über die Python-
|
||||||
|
Standardbibliothek (urllib) -- bewusst ohne zusätzliche Abhängigkeit
|
||||||
|
wie 'requests', da genau drei schmale Aufrufe (activate/deactivate/
|
||||||
|
heartbeat) das nicht rechtfertigen. Wirft bei jedem Fehler (Netzwerk,
|
||||||
|
Timeout, HTTP-Fehlerstatus, kaputtes JSON) eine Exception -- der
|
||||||
|
Aufrufer entscheidet, ob das den Online- oder den Offline-Code-Pfad
|
||||||
|
bedeutet."""
|
||||||
|
url = endpoint.rstrip("/") + path
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _send_heartbeat():
|
||||||
|
"""Ein einzelner Heartbeat-Versuch gegen den in der Lizenz hinterlegten
|
||||||
|
master_endpoint. Bei Erfolg: license_last_heartbeat_at aktualisieren
|
||||||
|
und eine ggf. mitgeschickte aktualisierte Lizenz NUR nach eigener
|
||||||
|
Signaturprüfung übernehmen. Bei Fehlschlag lediglich den Fehler
|
||||||
|
vermerken -- ein ausbleibender Heartbeat schaltet für sich genommen
|
||||||
|
nie ein Modul ab (siehe licensing.heartbeat_stale_warning, rein
|
||||||
|
informativ in der Topbar)."""
|
||||||
|
license_file = _license_state.get("file")
|
||||||
|
if not license_file or not _license_state.get("valid") or not license_activated():
|
||||||
|
return
|
||||||
|
endpoint = license_file.get("master_endpoint", "")
|
||||||
|
if not endpoint:
|
||||||
|
return
|
||||||
|
request_payload = licensing.build_client_request("heartbeat", license_file)
|
||||||
|
try:
|
||||||
|
response_payload = _license_api_post(endpoint, "/api/heartbeat", request_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
set_setting("license_last_error", f"Heartbeat fehlgeschlagen: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")):
|
||||||
|
set_setting("license_last_error", "Heartbeat-Antwort mit ungültiger Signatur erhalten -- ignoriert.")
|
||||||
|
return
|
||||||
|
|
||||||
|
set_setting("license_last_heartbeat_at", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
set_setting("license_last_error", "")
|
||||||
|
|
||||||
|
if response_payload.get("status") == "revoked":
|
||||||
|
log_action_system("license.revoked_remote", license_file.get("license_id"),
|
||||||
|
"Lizenz vom Lizenzserver per Heartbeat widerrufen.")
|
||||||
|
_delete_license_file()
|
||||||
|
return
|
||||||
|
|
||||||
|
license_update = response_payload.get("license_update")
|
||||||
|
if license_update and licensing.verify_license_file(license_update):
|
||||||
|
_write_license_file(license_update)
|
||||||
|
log_action_system("license.updated_remote", license_update.get("license_id"),
|
||||||
|
"Aktualisierte Lizenz per Heartbeat übernommen.")
|
||||||
|
|
||||||
|
|
||||||
|
def _license_heartbeat_loop():
|
||||||
|
"""Läuft alle LICENSE_HEARTBEAT_INTERVAL_SECONDS (6h, siehe Plan) und
|
||||||
|
direkt einmal beim Start, analog zu _audit_archive_loop weiter oben."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
_send_heartbeat()
|
||||||
|
except Exception:
|
||||||
|
app.logger.error("Lizenz-Heartbeat fehlgeschlagen:\n%s", traceback.format_exc())
|
||||||
|
time.sleep(LICENSE_HEARTBEAT_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
if _IS_WEB_PROCESS:
|
||||||
|
threading.Thread(target=_license_heartbeat_loop, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_host_preferring_ipv4(hostname):
|
def _resolve_host_preferring_ipv4(hostname):
|
||||||
"""Löst EINEN Hostnamen auf -- IPv4-Adresse wenn vorhanden, sonst
|
"""Löst EINEN Hostnamen auf -- IPv4-Adresse wenn vorhanden, sonst
|
||||||
IPv6, sonst None. IPv4 wird bevorzugt, weil sie in der Praxis
|
IPv6, sonst None. IPv4 wird bevorzugt, weil sie in der Praxis
|
||||||
@@ -2375,6 +2594,8 @@ def fileshare():
|
|||||||
if not current_user.has_permission("fileshare.view"):
|
if not current_user.has_permission("fileshare.view"):
|
||||||
flash("Keine Berechtigung, Dateifreigaben einzusehen.", "danger")
|
flash("Keine Berechtigung, Dateifreigaben einzusehen.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
if resp := _require_module("fileshare"):
|
||||||
|
return resp
|
||||||
mounts = _current_fileshare_mounts()
|
mounts = _current_fileshare_mounts()
|
||||||
if not mounts:
|
if not mounts:
|
||||||
flash(
|
flash(
|
||||||
@@ -3087,7 +3308,11 @@ def inject_dhcp_topbar_status():
|
|||||||
für Benutzer mit mindestens Leserecht auf DHCP, und nur ein einzelner,
|
für Benutzer mit mindestens Leserecht auf DHCP, und nur ein einzelner,
|
||||||
kurzer systemctl-Aufruf (nicht die volle Status-Erkennung von
|
kurzer systemctl-Aufruf (nicht die volle Status-Erkennung von
|
||||||
_detect_dhcp_status), damit das nicht jede Seite spürbar verlangsamt."""
|
_detect_dhcp_status), damit das nicht jede Seite spürbar verlangsamt."""
|
||||||
if not current_user.is_authenticated or not current_user.has_permission("settings_dhcp.view"):
|
if (
|
||||||
|
not current_user.is_authenticated
|
||||||
|
or not current_user.has_permission("settings_dhcp.view")
|
||||||
|
or not module_licensed("dhcp")
|
||||||
|
):
|
||||||
return {"dhcp_topbar_active": None}
|
return {"dhcp_topbar_active": None}
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=2)
|
result = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=2)
|
||||||
@@ -3097,6 +3322,50 @@ def inject_dhcp_topbar_status():
|
|||||||
return {"dhcp_topbar_active": active}
|
return {"dhcp_topbar_active": active}
|
||||||
|
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_license_topbar():
|
||||||
|
"""Lizenz-Ampel neben dem Prüfintervall-Countdown -- für jeden
|
||||||
|
angemeldeten Benutzer sichtbar, unabhängig von Rechten (genau wie der
|
||||||
|
Countdown selbst). Prioritätsreihenfolge exakt wie im Lizenz-Plan
|
||||||
|
festgelegt: fehlt > abgelaufen > läuft in ≤30 Tagen ab > Heartbeat seit
|
||||||
|
≥14 Tagen ausgeblieben (rein informativ) > alles in Ordnung (keine
|
||||||
|
Anzeige)."""
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return {"license_topbar": None}
|
||||||
|
|
||||||
|
state = _license_state
|
||||||
|
if not state["valid"] or not license_activated():
|
||||||
|
return {"license_topbar": {"level": "danger", "blink": True, "text": "Lizenz fehlt"}}
|
||||||
|
|
||||||
|
status = state["status"]
|
||||||
|
if status["expired"]:
|
||||||
|
try:
|
||||||
|
expiry_str = datetime.strptime(state["file"]["expires_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d.%m.")
|
||||||
|
except ValueError:
|
||||||
|
expiry_str = "?"
|
||||||
|
return {"license_topbar": {"level": "danger", "blink": True, "text": f"Lizenz abgelaufen (seit {expiry_str})"}}
|
||||||
|
|
||||||
|
if status["expiring_soon"]:
|
||||||
|
days_left = max(1, math.ceil(status["days_left"]))
|
||||||
|
plural = "en" if days_left != 1 else ""
|
||||||
|
return {"license_topbar": {"level": "warning", "blink": True, "text": f"Lizenz läuft in {days_left} Tag{plural} ab"}}
|
||||||
|
|
||||||
|
last_heartbeat = get_setting("license_last_heartbeat_at")
|
||||||
|
if last_heartbeat:
|
||||||
|
try:
|
||||||
|
last_ts = datetime.strptime(last_heartbeat, "%Y-%m-%d %H:%M:%S").timestamp()
|
||||||
|
except ValueError:
|
||||||
|
last_ts = None
|
||||||
|
if last_ts and licensing.heartbeat_stale_warning(last_ts):
|
||||||
|
days = int((time.time() - last_ts) / 86400)
|
||||||
|
return {"license_topbar": {
|
||||||
|
"level": "warning", "blink": False,
|
||||||
|
"text": f"Lizenzserver seit {days} Tag{'en' if days != 1 else ''} nicht erreichbar",
|
||||||
|
}}
|
||||||
|
|
||||||
|
return {"license_topbar": None}
|
||||||
|
|
||||||
|
|
||||||
def get_device_status(devices):
|
def get_device_status(devices):
|
||||||
"""
|
"""
|
||||||
Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät:
|
Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät:
|
||||||
@@ -4037,6 +4306,9 @@ def settings():
|
|||||||
return redirect(url_for("settings"))
|
return redirect(url_for("settings"))
|
||||||
|
|
||||||
if "interval" in request.form:
|
if "interval" in request.form:
|
||||||
|
if not license_active():
|
||||||
|
flash("Das Prüfintervall kann nur mit gültiger Lizenz geändert werden.", "danger")
|
||||||
|
return redirect(url_for("settings"))
|
||||||
new_interval = int(request.form["interval"])
|
new_interval = int(request.form["interval"])
|
||||||
set_setting("interval", new_interval)
|
set_setting("interval", new_interval)
|
||||||
set_setting("check_interval", new_interval * 60)
|
set_setting("check_interval", new_interval * 60)
|
||||||
@@ -4194,6 +4466,190 @@ def settings():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/settings/license")
|
||||||
|
@login_required
|
||||||
|
def settings_license():
|
||||||
|
if not current_user.can_view_settings_system:
|
||||||
|
flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
pending_raw = get_setting("license_pending_request", "")
|
||||||
|
pending = json.loads(pending_raw) if pending_raw else None
|
||||||
|
return render_template(
|
||||||
|
"settings_license.html",
|
||||||
|
license_file=_license_state["file"],
|
||||||
|
license_valid=_license_state["valid"],
|
||||||
|
license_error=_license_state["error"],
|
||||||
|
license_status=_license_state["status"],
|
||||||
|
license_is_activated=license_activated(),
|
||||||
|
license_last_heartbeat_at=get_setting("license_last_heartbeat_at", ""),
|
||||||
|
license_last_error=get_setting("license_last_error", ""),
|
||||||
|
license_fingerprint=licensing.system_fingerprint(),
|
||||||
|
pending_action=pending,
|
||||||
|
pending_code=licensing.encode_code(pending) if pending else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/settings/license/upload", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def license_upload():
|
||||||
|
if not current_user.has_permission("settings_system.edit"):
|
||||||
|
flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
uploaded = request.files.get("license_file")
|
||||||
|
if not uploaded or not uploaded.filename:
|
||||||
|
flash("Bitte eine Lizenzdatei auswählen.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
try:
|
||||||
|
license_file = json.loads(uploaded.read().decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
flash("Datei ist keine gültige Lizenzdatei (kein lesbares JSON).", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
if not licensing.verify_license_file(license_file):
|
||||||
|
flash("Lizenzdatei-Signatur ungültig -- Datei beschädigt, manipuliert oder von einem anderen Lizenzserver.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
_write_license_file(license_file)
|
||||||
|
set_setting("license_activation_status", "")
|
||||||
|
set_setting("license_pending_request", "")
|
||||||
|
customer_name = license_file.get("customer", {}).get("name", "?")
|
||||||
|
log_action("license.upload", license_file.get("license_id", ""), f"Lizenz für {customer_name} hochgeladen -- muss noch aktiviert werden.")
|
||||||
|
flash("Lizenzdatei eingespielt. Bitte jetzt aktivieren.", "success")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
|
|
||||||
|
def _license_handshake(action, endpoint_path, success_setting_fn, success_log_action, success_flash):
|
||||||
|
"""Gemeinsame Logik für Aktivierung/Deaktivierung: EIN Protokoll, zwei
|
||||||
|
Transportwege (siehe licensing.py-Moduldocstring). Versucht zuerst
|
||||||
|
automatisch online; schlägt das fehl (keine Netzwerkverbindung zum
|
||||||
|
Lizenzserver o.ä.), wird die Anfrage als Code hinterlegt, den der
|
||||||
|
Admin manuell beim Lizenzserver eingeben kann -- die Bestätigung
|
||||||
|
kommt dann über *_confirm() unten zurück."""
|
||||||
|
license_file = _license_state.get("file")
|
||||||
|
request_payload = licensing.build_client_request(action, license_file)
|
||||||
|
endpoint = license_file.get("master_endpoint", "")
|
||||||
|
try:
|
||||||
|
if not endpoint:
|
||||||
|
raise RuntimeError("Kein Lizenzserver in der Lizenzdatei hinterlegt.")
|
||||||
|
response_payload = _license_api_post(endpoint, endpoint_path, request_payload)
|
||||||
|
if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")):
|
||||||
|
raise RuntimeError("Antwort mit ungültiger Signatur erhalten.")
|
||||||
|
if response_payload.get("status") != "ok":
|
||||||
|
raise RuntimeError(response_payload.get("status", "abgelehnt"))
|
||||||
|
success_setting_fn()
|
||||||
|
set_setting("license_pending_request", "")
|
||||||
|
log_action(success_log_action, license_file.get("license_id", ""), "Online beim Lizenzserver bestätigt.")
|
||||||
|
flash(success_flash, "success")
|
||||||
|
except Exception as exc:
|
||||||
|
set_setting("license_pending_request", json.dumps(request_payload))
|
||||||
|
set_setting("license_last_error", f"Online-{action} fehlgeschlagen: {exc}")
|
||||||
|
flash(
|
||||||
|
"Online-Verbindung zum Lizenzserver nicht möglich. Bitte den unten angezeigten Code beim "
|
||||||
|
"Lizenzserver-Administrator eingeben und den dort erhaltenen Bestätigungscode unten eintragen.",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
|
|
||||||
|
def _license_handshake_confirm(action, success_setting_fn, success_log_action, success_flash):
|
||||||
|
"""Gegenstück zu _license_handshake() für den Offline-Zweig: der Admin
|
||||||
|
hat den vom Master erzeugten Bestätigungscode erhalten und trägt ihn
|
||||||
|
hier ein."""
|
||||||
|
license_file = _license_state.get("file")
|
||||||
|
pending_raw = get_setting("license_pending_request", "")
|
||||||
|
if not license_file or not pending_raw:
|
||||||
|
flash("Keine offene Anfrage vorhanden.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
code = request.form.get("confirmation_code", "").strip()
|
||||||
|
try:
|
||||||
|
response_payload = licensing.decode_code(code)
|
||||||
|
except Exception:
|
||||||
|
flash("Code konnte nicht gelesen werden -- bitte vollständig kopieren.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")):
|
||||||
|
flash("Code hat eine ungültige Signatur -- bitte erneut vom Lizenzserver kopieren.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
pending = json.loads(pending_raw)
|
||||||
|
if (response_payload.get("license_id") != pending.get("license_id")
|
||||||
|
or response_payload.get("fingerprint") != pending.get("fingerprint")):
|
||||||
|
flash("Code passt nicht zur offenen Anfrage.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
if response_payload.get("status") != "ok":
|
||||||
|
flash(f"Lizenzserver hat die Anfrage abgelehnt ({response_payload.get('status')}).", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
success_setting_fn()
|
||||||
|
set_setting("license_pending_request", "")
|
||||||
|
log_action(success_log_action, license_file.get("license_id", ""), "Offline per Code bestätigt.")
|
||||||
|
flash(success_flash, "success")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/settings/license/activate", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def license_activate():
|
||||||
|
if not current_user.has_permission("settings_system.edit"):
|
||||||
|
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
if not _license_state.get("file") or not _license_state.get("valid"):
|
||||||
|
flash("Keine gültige Lizenzdatei vorhanden.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
|
def on_success():
|
||||||
|
set_setting("license_activation_status", "active")
|
||||||
|
set_setting("license_export_grace", "")
|
||||||
|
|
||||||
|
return _license_handshake("activate", "/api/activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/settings/license/activate/confirm", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def license_activate_confirm():
|
||||||
|
if not current_user.has_permission("settings_system.edit"):
|
||||||
|
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
|
def on_success():
|
||||||
|
set_setting("license_activation_status", "active")
|
||||||
|
set_setting("license_export_grace", "")
|
||||||
|
|
||||||
|
return _license_handshake_confirm("activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/settings/license/deactivate", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def license_deactivate():
|
||||||
|
if not current_user.has_permission("settings_system.edit"):
|
||||||
|
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
if not _license_state.get("file") or not license_activated():
|
||||||
|
flash("Keine aktive Lizenz zum Deaktivieren vorhanden.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
|
def on_success():
|
||||||
|
set_setting("license_export_grace", "1")
|
||||||
|
_delete_license_file()
|
||||||
|
|
||||||
|
return _license_handshake(
|
||||||
|
"deactivate", "/api/deactivate", on_success, "license.deactivated",
|
||||||
|
"Lizenz deaktiviert. Das System ist jetzt lizenzlos -- Export bleibt bis zur nächsten Aktivierung möglich.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/settings/license/deactivate/confirm", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def license_deactivate_confirm():
|
||||||
|
if not current_user.has_permission("settings_system.edit"):
|
||||||
|
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
||||||
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
|
def on_success():
|
||||||
|
set_setting("license_export_grace", "1")
|
||||||
|
_delete_license_file()
|
||||||
|
|
||||||
|
return _license_handshake_confirm(
|
||||||
|
"deactivate", on_success, "license.deactivated",
|
||||||
|
"Lizenz deaktiviert. Das System ist jetzt lizenzlos -- Export bleibt bis zur nächsten Aktivierung möglich.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/settings/network_state")
|
@app.route("/settings/network_state")
|
||||||
@login_required
|
@login_required
|
||||||
def settings_network_state():
|
def settings_network_state():
|
||||||
@@ -4782,6 +5238,8 @@ def settings_dhcp():
|
|||||||
if request.method == "GET" and not current_user.can_view_settings_dhcp:
|
if request.method == "GET" and not current_user.can_view_settings_dhcp:
|
||||||
flash("Keine Berechtigung, die DHCP-Einstellungen anzusehen.", "danger")
|
flash("Keine Berechtigung, die DHCP-Einstellungen anzusehen.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
if resp := _require_module("dhcp"):
|
||||||
|
return resp
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
|
||||||
@@ -5451,6 +5909,7 @@ EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS)
|
|||||||
# Zusatzverhalten, das über den reinen Namen hinausgeht.
|
# Zusatzverhalten, das über den reinen Namen hinausgeht.
|
||||||
EXPORT_SECTION_HINTS = {
|
EXPORT_SECTION_HINTS = {
|
||||||
"users": "Nur lokale Konten. AD/LDAP-Benutzer werden NICHT gesichert — sie werden über Active Directory verwaltet, nicht über diese App, und legen sich beim nächsten Login automatisch wieder an.",
|
"users": "Nur lokale Konten. AD/LDAP-Benutzer werden NICHT gesichert — sie werden über Active Directory verwaltet, nicht über diese App, und legen sich beim nächsten Login automatisch wieder an.",
|
||||||
|
"ldap": "Gesichert werden Server-/Bind-Einstellungen, die automatischen AD-Gruppen- und Fileshare-Zuordnungen sowie für bereits bekannte AD-Benutzer deren Sperrstatus und individuelle Gruppenzuweisungen — damit ein gesperrtes AD-Konto auf dem Zielsystem gesperrt bleibt und manuell vergebene Rechte erhalten bleiben, statt beim nächsten Login verloren zu gehen.",
|
||||||
"credentials": "Enthält zusätzlich den Inhalt von known_hosts (bereits bestätigte SSH-Host-Keys) — wird beim Import ergänzend (nicht überschreibend) eingespielt.",
|
"credentials": "Enthält zusätzlich den Inhalt von known_hosts (bereits bestätigte SSH-Host-Keys) — wird beim Import ergänzend (nicht überschreibend) eingespielt.",
|
||||||
"nginx": "Enthält Domain, Ports und SSL/HSTS-Schalter sowie — falls vorhanden — das aktuell hinterlegte TLS-Zertifikat und den privaten Schlüssel selbst.",
|
"nginx": "Enthält Domain, Ports und SSL/HSTS-Schalter sowie — falls vorhanden — das aktuell hinterlegte TLS-Zertifikat und den privaten Schlüssel selbst.",
|
||||||
"logs": "Alle aktuell in der Datenbank vorhandenen Einträge (bereits archivierte Auditlog-Tage liegen als eigene Dateien unter Verlauf und sind kein Teil dieses Exports).",
|
"logs": "Alle aktuell in der Datenbank vorhandenen Einträge (bereits archivierte Auditlog-Tage liegen als eigene Dateien unter Verlauf und sind kein Teil dieses Exports).",
|
||||||
@@ -5537,9 +5996,18 @@ def _export_ldap(conn):
|
|||||||
(frische Installation vergibt Gruppen-IDs neu). Exportiert wird daher
|
(frische Installation vergibt Gruppen-IDs neu). Exportiert wird daher
|
||||||
der Gruppenname (analog _export_switches()' credential_name), _import_ldap
|
der Gruppenname (analog _export_switches()' credential_name), _import_ldap
|
||||||
löst ihn auf dem Zielsystem wieder zur dortigen ID auf -- gleiches
|
löst ihn auf dem Zielsystem wieder zur dortigen ID auf -- gleiches
|
||||||
"admin"/g.name-Mapping wie in _ldap_group_mappings() weiter oben."""
|
"admin"/g.name-Mapping wie in _ldap_group_mappings() weiter oben.
|
||||||
|
|
||||||
|
ad_user_states: Sperrstatus + aktuelle Gruppenzugehörigkeit (inkl.
|
||||||
|
Admin-Sentinel) je bereits bekanntem AD-Benutzer -- bewusst hier bei
|
||||||
|
LDAP statt bei "users" (das AD-Konten komplett ausschließt, siehe
|
||||||
|
_export_users()), da es explizit um AD-Konten geht: ein gesperrtes
|
||||||
|
AD-Konto soll auf dem Zielsystem gesperrt bleiben (nicht durch die
|
||||||
|
Migration wieder hereinkommen), und individuell/manuell vergebene
|
||||||
|
Rechte sollen erhalten bleiben statt beim nächsten Login verloren zu
|
||||||
|
gehen."""
|
||||||
settings_data = {k: get_setting(k) for k in LDAP_SETTING_KEYS if get_setting(k) not in (None, "")}
|
settings_data = {k: get_setting(k) for k in LDAP_SETTING_KEYS if get_setting(k) not in (None, "")}
|
||||||
data = {"settings": settings_data, "group_mappings": [], "fileshare_mappings": []}
|
data = {"settings": settings_data, "group_mappings": [], "fileshare_mappings": [], "ad_user_states": []}
|
||||||
bind = conn.execute(
|
bind = conn.execute(
|
||||||
"SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)
|
"SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -5554,7 +6022,19 @@ def _export_ldap(conn):
|
|||||||
data["fileshare_mappings"] = [dict(m) for m in conn.execute(
|
data["fileshare_mappings"] = [dict(m) for m in conn.execute(
|
||||||
"SELECT ad_group_dn, ad_group_name, share_label, share_unc FROM ldap_fileshare_mappings"
|
"SELECT ad_group_dn, ad_group_name, share_label, share_unc FROM ldap_fileshare_mappings"
|
||||||
).fetchall()]
|
).fetchall()]
|
||||||
if not (settings_data or "bind_username" in data or data["group_mappings"] or data["fileshare_mappings"]):
|
ad_users = conn.execute(
|
||||||
|
"SELECT id, username, is_admin, is_locked FROM users WHERE auth_source='ldap' AND deleted_at IS NULL"
|
||||||
|
).fetchall()
|
||||||
|
for u in ad_users:
|
||||||
|
groups = [g["name"] for g in conn.execute(
|
||||||
|
"SELECT g.name FROM user_groups ug JOIN groups g ON g.id=ug.group_id "
|
||||||
|
"WHERE ug.user_id=? AND g.deleted_at IS NULL", (u["id"],)
|
||||||
|
).fetchall()]
|
||||||
|
if u["is_admin"]:
|
||||||
|
groups.append("Admin")
|
||||||
|
data["ad_user_states"].append({"username": u["username"], "is_locked": u["is_locked"], "groups": groups})
|
||||||
|
if not (settings_data or "bind_username" in data or data["group_mappings"] or data["fileshare_mappings"]
|
||||||
|
or data["ad_user_states"]):
|
||||||
return None
|
return None
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -5800,6 +6280,39 @@ def _import_ldap(conn, data):
|
|||||||
(m["ad_group_dn"], m["ad_group_name"], m["share_label"], m["share_unc"]),
|
(m["ad_group_dn"], m["ad_group_name"], m["share_label"], m["share_unc"]),
|
||||||
)
|
)
|
||||||
n += 1
|
n += 1
|
||||||
|
for u in data.get("ad_user_states", []):
|
||||||
|
groups = u.get("groups", [])
|
||||||
|
is_admin = 1 if "Admin" in groups else 0
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM users WHERE username=? AND auth_source='ldap'", (u["username"],)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
user_id = existing["id"]
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET is_locked=?, is_admin=? WHERE id=?",
|
||||||
|
(u.get("is_locked", 0), is_admin, user_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Konto hat sich auf dem Zielsystem noch nie eingeloggt -- ein
|
||||||
|
# Platzhalter-Passwort-Hash wird nie geprüft (der Login-Zweig
|
||||||
|
# für auth_source='ldap' authentifiziert immer gegen AD, siehe
|
||||||
|
# login()), er füllt nur das NOT-NULL-Feld. Wichtig ist einzig,
|
||||||
|
# dass is_locked schon VOR dem ersten Login greift.
|
||||||
|
placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8")
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO users (username, password, is_admin, auth_source, is_locked) VALUES (?, ?, ?, 'ldap', ?)",
|
||||||
|
(u["username"], placeholder_hash, is_admin, u.get("is_locked", 0)),
|
||||||
|
)
|
||||||
|
user_id = cur.lastrowid
|
||||||
|
for gname in groups:
|
||||||
|
if gname == "Admin":
|
||||||
|
continue
|
||||||
|
grow = conn.execute(
|
||||||
|
"SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (gname,)
|
||||||
|
).fetchone()
|
||||||
|
if grow:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, grow["id"]))
|
||||||
|
n += 1
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
@@ -5919,6 +6432,9 @@ def export_data():
|
|||||||
if not current_user.has_permission("settings_importexport.export"):
|
if not current_user.has_permission("settings_importexport.export"):
|
||||||
flash("Keine Berechtigung, Daten zu exportieren.", "danger")
|
flash("Keine Berechtigung, Daten zu exportieren.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
if not license_export_allowed():
|
||||||
|
flash("Für den Export ist keine gültige Lizenz vorhanden.", "danger")
|
||||||
|
return redirect(url_for("settings_import_export"))
|
||||||
|
|
||||||
passphrase = request.form.get("export_passphrase", "")
|
passphrase = request.form.get("export_passphrase", "")
|
||||||
sections = [s for s in request.form.getlist("export_sections") if s in EXPORT_BUILDERS]
|
sections = [s for s in request.form.getlist("export_sections") if s in EXPORT_BUILDERS]
|
||||||
@@ -6679,7 +7195,7 @@ def _purge_expired_trash():
|
|||||||
@app.route("/devices/restore/<mac>", methods=["POST"])
|
@app.route("/devices/restore/<mac>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def restore_device(mac):
|
def restore_device(mac):
|
||||||
if not current_user.has_permission("papierkorb.edit"):
|
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||||
flash("Keine Berechtigung, Geräte wiederherzustellen.", "danger")
|
flash("Keine Berechtigung, Geräte wiederherzustellen.", "danger")
|
||||||
return redirect(url_for("papierkorb"))
|
return redirect(url_for("papierkorb"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -6695,7 +7211,7 @@ def restore_device(mac):
|
|||||||
@app.route("/devices/purge/<mac>", methods=["POST"])
|
@app.route("/devices/purge/<mac>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def purge_device(mac):
|
def purge_device(mac):
|
||||||
if not current_user.has_permission("papierkorb.edit"):
|
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||||
flash("Keine Berechtigung, Geräte endgültig zu löschen.", "danger")
|
flash("Keine Berechtigung, Geräte endgültig zu löschen.", "danger")
|
||||||
return redirect(url_for("papierkorb"))
|
return redirect(url_for("papierkorb"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -6711,7 +7227,7 @@ def purge_device(mac):
|
|||||||
@app.route("/switches/restore/<hostname>", methods=["POST"])
|
@app.route("/switches/restore/<hostname>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def restore_switch(hostname):
|
def restore_switch(hostname):
|
||||||
if not current_user.has_permission("papierkorb.edit"):
|
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||||
flash("Keine Berechtigung, Switche wiederherzustellen.", "danger")
|
flash("Keine Berechtigung, Switche wiederherzustellen.", "danger")
|
||||||
return redirect(url_for("papierkorb"))
|
return redirect(url_for("papierkorb"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -6726,7 +7242,7 @@ def restore_switch(hostname):
|
|||||||
@app.route("/switches/purge/<hostname>", methods=["POST"])
|
@app.route("/switches/purge/<hostname>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def purge_switch(hostname):
|
def purge_switch(hostname):
|
||||||
if not current_user.has_permission("papierkorb.edit"):
|
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||||
flash("Keine Berechtigung, Switche endgültig zu löschen.", "danger")
|
flash("Keine Berechtigung, Switche endgültig zu löschen.", "danger")
|
||||||
return redirect(url_for("papierkorb"))
|
return redirect(url_for("papierkorb"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -6741,7 +7257,7 @@ def purge_switch(hostname):
|
|||||||
@app.route("/credentials/restore/<int:cred_id>", methods=["POST"])
|
@app.route("/credentials/restore/<int:cred_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def restore_credential(cred_id):
|
def restore_credential(cred_id):
|
||||||
if not current_user.has_permission("papierkorb.edit"):
|
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||||
flash("Keine Berechtigung, Zugangsdaten wiederherzustellen.", "danger")
|
flash("Keine Berechtigung, Zugangsdaten wiederherzustellen.", "danger")
|
||||||
return redirect(url_for("papierkorb"))
|
return redirect(url_for("papierkorb"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -6757,7 +7273,7 @@ def restore_credential(cred_id):
|
|||||||
@app.route("/credentials/purge/<int:cred_id>", methods=["POST"])
|
@app.route("/credentials/purge/<int:cred_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def purge_credential(cred_id):
|
def purge_credential(cred_id):
|
||||||
if not current_user.has_permission("papierkorb.edit"):
|
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||||
flash("Keine Berechtigung, Zugangsdaten endgültig zu löschen.", "danger")
|
flash("Keine Berechtigung, Zugangsdaten endgültig zu löschen.", "danger")
|
||||||
return redirect(url_for("papierkorb"))
|
return redirect(url_for("papierkorb"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -6773,7 +7289,7 @@ def purge_credential(cred_id):
|
|||||||
@app.route("/users/restore/<int:user_id>", methods=["POST"])
|
@app.route("/users/restore/<int:user_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def restore_user(user_id):
|
def restore_user(user_id):
|
||||||
if not current_user.has_permission("papierkorb.edit"):
|
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||||
flash("Keine Berechtigung, Benutzer wiederherzustellen.", "danger")
|
flash("Keine Berechtigung, Benutzer wiederherzustellen.", "danger")
|
||||||
return redirect(url_for("papierkorb"))
|
return redirect(url_for("papierkorb"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -6795,7 +7311,7 @@ def restore_user(user_id):
|
|||||||
@app.route("/users/purge/<int:user_id>", methods=["POST"])
|
@app.route("/users/purge/<int:user_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def purge_user(user_id):
|
def purge_user(user_id):
|
||||||
if not current_user.has_permission("papierkorb.edit"):
|
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||||
flash("Keine Berechtigung, Benutzer endgültig zu löschen.", "danger")
|
flash("Keine Berechtigung, Benutzer endgültig zu löschen.", "danger")
|
||||||
return redirect(url_for("papierkorb"))
|
return redirect(url_for("papierkorb"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -6817,7 +7333,7 @@ def purge_user(user_id):
|
|||||||
@app.route("/groups/restore/<int:group_id>", methods=["POST"])
|
@app.route("/groups/restore/<int:group_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def restore_group(group_id):
|
def restore_group(group_id):
|
||||||
if not current_user.has_permission("papierkorb.edit"):
|
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||||
flash("Keine Berechtigung, Gruppen wiederherzustellen.", "danger")
|
flash("Keine Berechtigung, Gruppen wiederherzustellen.", "danger")
|
||||||
return redirect(url_for("papierkorb"))
|
return redirect(url_for("papierkorb"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -6833,7 +7349,7 @@ def restore_group(group_id):
|
|||||||
@app.route("/groups/purge/<int:group_id>", methods=["POST"])
|
@app.route("/groups/purge/<int:group_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def purge_group(group_id):
|
def purge_group(group_id):
|
||||||
if not current_user.has_permission("papierkorb.edit"):
|
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||||
flash("Keine Berechtigung, Gruppen endgültig zu löschen.", "danger")
|
flash("Keine Berechtigung, Gruppen endgültig zu löschen.", "danger")
|
||||||
return redirect(url_for("papierkorb"))
|
return redirect(url_for("papierkorb"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -6852,6 +7368,8 @@ def maintenance():
|
|||||||
if not current_user.can_view_maintenance:
|
if not current_user.can_view_maintenance:
|
||||||
flash("Keine Berechtigung für die Wartungsseite.", "danger")
|
flash("Keine Berechtigung für die Wartungsseite.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
if resp := _require_module("maintenance"):
|
||||||
|
return resp
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
devices_rows = _maintenance_devices(conn)
|
devices_rows = _maintenance_devices(conn)
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -7885,6 +8403,8 @@ def kea_log():
|
|||||||
if not current_user.can_view_kea_log:
|
if not current_user.can_view_kea_log:
|
||||||
flash("Keine Berechtigung, das Kea-DHCP-Log einzusehen.", "danger")
|
flash("Keine Berechtigung, das Kea-DHCP-Log einzusehen.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
if resp := _require_module("dhcp"):
|
||||||
|
return resp
|
||||||
try:
|
try:
|
||||||
with open(TESM_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f:
|
with open(TESM_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f:
|
||||||
log_content = f.read()
|
log_content = f.read()
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""
|
||||||
|
TESM Lizenzsystem — gemeinsame Kryptographie- und Protokoll-Bausteine für
|
||||||
|
Kundeninstanz (Client) UND Lizenzserver (Master). Beide Seiten importieren
|
||||||
|
exakt dieselbe Datei (auf dem Master 1:1 mitkopiert) — Signieren und
|
||||||
|
Verifizieren müssen bitidentisch funktionieren, jede Abweichung würde
|
||||||
|
Lizenzen der jeweils anderen Seite unlesbar machen.
|
||||||
|
|
||||||
|
Vollständiges Design: siehe C:\\Users\\tim\\.claude\\plans\\toasty-twirling-hickey.md
|
||||||
|
(Phase 0). Kurzfassung der Schlüsselhierarchie:
|
||||||
|
|
||||||
|
- Master-Signaturschlüssel (Ed25519): signiert Lizenzdateien selbst UND
|
||||||
|
jede Aktivierungs-/Deaktivierungs-/Heartbeat-Antwort. Privater Teil bleibt
|
||||||
|
ausschließlich auf dem Master, öffentlicher Teil steckt in jeder
|
||||||
|
ausgestellten Lizenzdatei (ermöglicht rein OFFLINE verifizierbare
|
||||||
|
Master-Antworten beim Kunden).
|
||||||
|
- Pro-Lizenz-Schlüsselpaar (Ed25519, einmalig je Lizenz erzeugt): der
|
||||||
|
private Teil reist in der Lizenzdatei zum Kunden und signiert dessen
|
||||||
|
Aktivierungs-/Deaktivierungs-/Heartbeat-*Anfragen*; der öffentliche Teil
|
||||||
|
bleibt beim Master in dessen Datenbank (ermöglicht dem Master, jede
|
||||||
|
Anfrage einer bestimmten Lizenz zweifelsfrei zuzuordnen, ohne die
|
||||||
|
ursprüngliche Aktivierung "live" gesehen haben zu müssen).
|
||||||
|
|
||||||
|
Ed25519 statt RSA: kleine Schlüssel/Signaturen (wichtig, da Aktivierungs-
|
||||||
|
Anfragen/-Antworten für den Offline-Fall als von Hand kopierbarer Code
|
||||||
|
dargestellt werden), fest vorgegebene, sichere Parameter (keine
|
||||||
|
Padding-/Hash-Wahl wie bei RSA-PSS nötig).
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
|
||||||
|
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
|
||||||
|
ALL_MODULES = ("dhcp", "fileshare", "maintenance")
|
||||||
|
|
||||||
|
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
|
||||||
|
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
|
||||||
|
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================== Schlüssel ==
|
||||||
|
|
||||||
|
def generate_keypair():
|
||||||
|
"""Erzeugt ein neues Ed25519-Schlüsselpaar. Rückgabe: (private_b64, public_b64)."""
|
||||||
|
priv = Ed25519PrivateKey.generate()
|
||||||
|
return _encode_private_key(priv), _encode_public_key(priv.public_key())
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_private_key(priv: Ed25519PrivateKey) -> str:
|
||||||
|
return base64.urlsafe_b64encode(priv.private_bytes_raw()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_public_key(pub: Ed25519PublicKey) -> str:
|
||||||
|
return base64.urlsafe_b64encode(pub.public_bytes_raw()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_private_key(s: str) -> Ed25519PrivateKey:
|
||||||
|
return Ed25519PrivateKey.from_private_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_public_key(s: str) -> Ed25519PublicKey:
|
||||||
|
return Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================== Signieren/Prüfen ==
|
||||||
|
|
||||||
|
def _canonical_bytes(payload: dict) -> bytes:
|
||||||
|
"""Deterministische JSON-Kodierung (sortierte Keys, kompakte Trenner) --
|
||||||
|
Voraussetzung dafür, dass Signieren und Verifizieren exakt dieselben
|
||||||
|
Bytes sehen, unabhängig von der Dict-Einfügereihenfolge."""
|
||||||
|
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_payload(payload: dict, private_key_b64: str) -> str:
|
||||||
|
priv = _decode_private_key(private_key_b64)
|
||||||
|
return base64.urlsafe_b64encode(priv.sign(_canonical_bytes(payload))).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_payload(payload: dict, signature_b64: str, public_key_b64: str) -> bool:
|
||||||
|
"""Gibt bewusst nur True/False zurück (nie eine Exception nach außen) --
|
||||||
|
ein Aufrufer soll "ungültig" nie mit einem Absturz verwechseln können."""
|
||||||
|
try:
|
||||||
|
pub = _decode_public_key(public_key_b64)
|
||||||
|
pub.verify(base64.urlsafe_b64decode(signature_b64.encode("ascii")), _canonical_bytes(payload))
|
||||||
|
return True
|
||||||
|
except (InvalidSignature, ValueError, TypeError, KeyError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Zeitformat ==
|
||||||
|
|
||||||
|
def _iso(ts: float) -> str:
|
||||||
|
return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(s: str) -> float:
|
||||||
|
return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ Lizenz ==
|
||||||
|
|
||||||
|
def issue_license(*, customer, license_type, modules, valid_days,
|
||||||
|
master_private_key_b64, master_public_key_b64,
|
||||||
|
master_endpoint, vendor, license_id=None, now=None):
|
||||||
|
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
|
||||||
|
frischem Pro-Lizenz-Schlüsselpaar.
|
||||||
|
|
||||||
|
Rückgabe: (license_file, license_pubkey) -- license_file ist die
|
||||||
|
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
|
||||||
|
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
|
||||||
|
bestimmt (Verifikation künftiger Aktivierungs-/Heartbeat-Anfragen
|
||||||
|
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
|
||||||
|
if license_type not in LICENSE_TYPES:
|
||||||
|
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
|
||||||
|
unknown = set(modules) - set(ALL_MODULES)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
|
||||||
|
|
||||||
|
license_priv, license_pub = generate_keypair()
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
payload = {
|
||||||
|
"license_id": license_id or str(uuid.uuid4()),
|
||||||
|
"customer": customer,
|
||||||
|
"type": license_type,
|
||||||
|
"modules": sorted(modules),
|
||||||
|
"issued_at": _iso(now),
|
||||||
|
"expires_at": _iso(now + valid_days * 86400),
|
||||||
|
"license_pubkey": license_pub,
|
||||||
|
"master_pubkey": master_public_key_b64,
|
||||||
|
"master_endpoint": master_endpoint,
|
||||||
|
"vendor": vendor,
|
||||||
|
}
|
||||||
|
signature = sign_payload(payload, master_private_key_b64)
|
||||||
|
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
|
||||||
|
return license_file, license_pub
|
||||||
|
|
||||||
|
|
||||||
|
def verify_license_file(license_file: dict) -> bool:
|
||||||
|
"""Prüft die Master-Signatur über die Lizenz-Nutzdaten. license_privkey
|
||||||
|
und signature selbst sind nicht Teil der signierten Nutzlast (die
|
||||||
|
Signatur wurde ja gerade über den Rest gebildet, siehe issue_license)."""
|
||||||
|
payload = {k: v for k, v in license_file.items() if k not in ("license_privkey", "signature")}
|
||||||
|
return verify_payload(payload, license_file.get("signature", ""), license_file.get("master_pubkey", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def license_status(license_file: dict, now=None) -> dict:
|
||||||
|
"""Berechnet den aktuellen Anzeige-/Gate-Status einer (bereits als
|
||||||
|
signaturgültig geprüften!) Lizenz -- ruft NICHT selbst verify_license_file
|
||||||
|
auf, das bleibt bewusst Sache des Aufrufers, damit hier niemand versehentlich
|
||||||
|
den Status einer manipulierten Datei berechnet, ohne die Prüfung
|
||||||
|
überhaupt gemacht zu haben."""
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
expires_at = _parse_iso(license_file["expires_at"])
|
||||||
|
days_left = (expires_at - now) / 86400
|
||||||
|
expired = days_left < 0
|
||||||
|
days_since_expiry = -days_left if expired else 0.0
|
||||||
|
grace_active = expired and days_since_expiry <= GRACE_PERIOD_DAYS
|
||||||
|
modules_active = (not expired) or grace_active
|
||||||
|
|
||||||
|
return {
|
||||||
|
"expired": expired,
|
||||||
|
"days_left": days_left,
|
||||||
|
"days_since_expiry": days_since_expiry,
|
||||||
|
"expiring_soon": (not expired) and days_left <= EXPIRY_WARNING_DAYS,
|
||||||
|
"grace_active": grace_active,
|
||||||
|
"modules_active": modules_active,
|
||||||
|
"modules": set(license_file.get("modules", [])) if modules_active else set(),
|
||||||
|
"type": license_file.get("type"),
|
||||||
|
"customer": license_file.get("customer"),
|
||||||
|
"expires_at": license_file.get("expires_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def heartbeat_stale_warning(last_heartbeat_ts, now=None) -> bool:
|
||||||
|
"""True, wenn der letzte erfolgreiche Heartbeat HEARTBEAT_WARNING_DAYS
|
||||||
|
oder länger zurückliegt. Rein informativ (siehe Moduldocstring) -- ein
|
||||||
|
fehlender/alter Heartbeat schaltet nie ein Modul ab, nur das
|
||||||
|
eingebettete Ablaufdatum selbst zählt dafür (siehe license_status)."""
|
||||||
|
if last_heartbeat_ts is None:
|
||||||
|
return False # noch nie verbunden gewesen ist der normale Ausgangszustand, kein Ausfall
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
return (now - last_heartbeat_ts) / 86400 >= HEARTBEAT_WARNING_DAYS
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================== Fingerprint (System-Bindung) ==
|
||||||
|
|
||||||
|
def system_fingerprint():
|
||||||
|
"""Stabiler Identifikator dieses Hosts: /etc/machine-id (von systemd bei
|
||||||
|
der Erstinstallation einmalig erzeugt, übersteht Reboots und normale
|
||||||
|
Updates) kombiniert mit dem Hostnamen, gehasht damit die rohe
|
||||||
|
machine-id nie im Klartext übertragen/gespeichert wird. Unter
|
||||||
|
Windows/ohne /etc/machine-id fällt dies auf den Hostnamen allein
|
||||||
|
zurück (nur für lokale Tests relevant, Produktivsysteme sind Linux)."""
|
||||||
|
machine_id = ""
|
||||||
|
try:
|
||||||
|
with open("/etc/machine-id", "r", encoding="utf-8") as f:
|
||||||
|
machine_id = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raw = f"{machine_id}:{socket.gethostname()}".encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Aktivierung / Deaktivierung / Heartbeat =====
|
||||||
|
# Ein Protokoll, zwei Transportwege: online automatisch per HTTPS-API,
|
||||||
|
# offline exakt dasselbe Anfrage/Antwort-Paar als von Hand kopierbarer Code
|
||||||
|
# (encode_code/decode_code) -- der Master ist dadurch in BEIDEN Fällen die
|
||||||
|
# einzige Quelle der Wahrheit dafür, an welches System eine Lizenz gerade
|
||||||
|
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
|
||||||
|
# verhindert".
|
||||||
|
|
||||||
|
def build_client_request(action, license_file, nonce=None) -> dict:
|
||||||
|
"""Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
|
||||||
|
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
|
||||||
|
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es
|
||||||
|
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt."""
|
||||||
|
if action not in ("activate", "deactivate", "heartbeat"):
|
||||||
|
raise ValueError(f"Unbekannte Aktion: {action!r}")
|
||||||
|
payload = {
|
||||||
|
"license_id": license_file["license_id"],
|
||||||
|
"fingerprint": system_fingerprint(),
|
||||||
|
"action": action,
|
||||||
|
"nonce": nonce or uuid.uuid4().hex,
|
||||||
|
"timestamp": _iso(time.time()),
|
||||||
|
}
|
||||||
|
signature = sign_payload(payload, license_file["license_privkey"])
|
||||||
|
return {**payload, "signature": signature}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_client_request(request: dict, license_public_key_b64: str) -> bool:
|
||||||
|
"""Vom MASTER aufgerufen: prüft eine Client-Anfrage gegen den bei
|
||||||
|
Ausstellung dieser Lizenz in der Master-DB gespeicherten Public-Key."""
|
||||||
|
payload = {k: v for k, v in request.items() if k != "signature"}
|
||||||
|
return verify_payload(payload, request.get("signature", ""), license_public_key_b64)
|
||||||
|
|
||||||
|
|
||||||
|
def build_master_response(action, license_id, fingerprint, master_private_key_b64,
|
||||||
|
status="ok", license_update=None, now=None) -> dict:
|
||||||
|
"""Vom MASTER aufgerufen: erzeugt eine signierte Antwort/Bestätigung
|
||||||
|
(z.B. "activated", "deactivated", oder das Ergebnis eines Heartbeats).
|
||||||
|
license_update: optional eine komplette, neu signierte Lizenzdatei
|
||||||
|
(z.B. nach nachträglicher Modul-Änderung durch den Admin) -- der Client
|
||||||
|
übernimmt sie nur nach eigener, erfolgreicher Signaturprüfung."""
|
||||||
|
payload = {
|
||||||
|
"license_id": license_id,
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
"action": action,
|
||||||
|
"status": status,
|
||||||
|
"timestamp": _iso(now if now is not None else time.time()),
|
||||||
|
}
|
||||||
|
if license_update is not None:
|
||||||
|
payload["license_update"] = license_update
|
||||||
|
signature = sign_payload(payload, master_private_key_b64)
|
||||||
|
return {**payload, "signature": signature}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_master_response(response: dict, master_public_key_b64: str) -> bool:
|
||||||
|
"""Vom CLIENT aufgerufen: prüft eine Master-Antwort komplett OFFLINE
|
||||||
|
gegen den in der eigenen Lizenzdatei eingebetteten master_pubkey."""
|
||||||
|
payload = {k: v for k, v in response.items() if k != "signature"}
|
||||||
|
return verify_payload(payload, response.get("signature", ""), master_public_key_b64)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================================================== Codes für Offline ==
|
||||||
|
|
||||||
|
def encode_code(data: dict) -> str:
|
||||||
|
"""Kodiert ein Anfrage-/Antwort-dict als kompakten, per Copy-Paste
|
||||||
|
übertragbaren Code (Base64 einer kanonischen JSON-Darstellung, keine
|
||||||
|
Zeilenumbrüche/Sonderzeichen, damit Kopieren aus/in ein Textfeld nichts
|
||||||
|
kaputt macht)."""
|
||||||
|
return base64.urlsafe_b64encode(_canonical_bytes(data)).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_code(code: str) -> dict:
|
||||||
|
return json.loads(base64.urlsafe_b64decode(code.strip().encode("ascii")).decode("utf-8"))
|
||||||
@@ -574,6 +574,22 @@ button { font-family: inherit; }
|
|||||||
70% { box-shadow: 0 0 0 6px rgba(47,208,122,0); }
|
70% { box-shadow: 0 0 0 6px rgba(47,208,122,0); }
|
||||||
100% { box-shadow: 0 0 0 0 rgba(47,208,122,0); }
|
100% { box-shadow: 0 0 0 0 rgba(47,208,122,0); }
|
||||||
}
|
}
|
||||||
|
/* Lizenz-Topbar-Pill: pulse-dot ist fest auf Grün (Erfolg) verdrahtet --
|
||||||
|
eigene Varianten für die rot/orange blinkenden Lizenz-Zustände, sonst
|
||||||
|
würde die Halo-Animation weiter grün pulsieren, egal welche
|
||||||
|
Hintergrundfarbe der Punkt selbst per Inline-Style bekommt. */
|
||||||
|
.timer-pill .dot.dot--blink-danger { animation: pulse-dot-danger 1.4s infinite; }
|
||||||
|
.timer-pill .dot.dot--blink-warning { animation: pulse-dot-warning 1.4s infinite; }
|
||||||
|
@keyframes pulse-dot-danger {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(240,71,92,0.5); }
|
||||||
|
70% { box-shadow: 0 0 0 6px rgba(240,71,92,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(240,71,92,0); }
|
||||||
|
}
|
||||||
|
@keyframes pulse-dot-warning {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(245,166,35,0.5); }
|
||||||
|
70% { box-shadow: 0 0 0 6px rgba(245,166,35,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(245,166,35,0); }
|
||||||
|
}
|
||||||
|
|
||||||
.timer-pill-refresh {
|
.timer-pill-refresh {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
|||||||
@@ -129,6 +129,12 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
{% if license_topbar %}
|
||||||
|
<a href="{{ url_for('settings_license') }}" class="timer-pill" title="{{ license_topbar.text }}" style="text-decoration:none;">
|
||||||
|
<span class="dot {% if license_topbar.blink %}dot--blink-{{ license_topbar.level }}{% endif %}"
|
||||||
|
style="{% if not license_topbar.blink %}animation:none; box-shadow:none;{% endif %} background:{{ 'var(--danger)' if license_topbar.level == 'danger' else 'var(--warning)' }};"></span>{{ license_topbar.text }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if dhcp_topbar_active is not none %}
|
{% if dhcp_topbar_active is not none %}
|
||||||
<span class="timer-pill" title="DHCP-Dienst: {{ 'aktiv' if dhcp_topbar_active else 'inaktiv' }}">
|
<span class="timer-pill" title="DHCP-Dienst: {{ 'aktiv' if dhcp_topbar_active else 'inaktiv' }}">
|
||||||
<span class="dot" style="animation:none; background:{{ 'var(--success)' if dhcp_topbar_active else 'var(--danger)' }}; box-shadow:none;"></span>DHCP
|
<span class="dot" style="animation:none; background:{{ 'var(--success)' if dhcp_topbar_active else 'var(--danger)' }}; box-shadow:none;"></span>DHCP
|
||||||
|
|||||||
@@ -17,6 +17,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not license_active() %}
|
||||||
|
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
|
||||||
|
Wiederherstellen und endgültiges Löschen sind nur mit gültiger Lizenz verfügbar — siehe
|
||||||
|
<a href="{{ url_for('settings_license') }}">Lizenz</a>. Ansehen ist weiterhin uneingeschränkt möglich.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div style="display:flex; flex-direction:column; gap:16px;">
|
<div style="display:flex; flex-direction:column; gap:16px;">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -44,7 +51,7 @@
|
|||||||
<td class="mono">{{ d['ip'] }}</td>
|
<td class="mono">{{ d['ip'] }}</td>
|
||||||
<td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td>
|
<td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.has_permission('papierkorb.edit') %}
|
{% if current_user.has_permission('papierkorb.edit') and license_active() %}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<form method="post" action="{{ url_for('restore_device', mac=d['mac']) }}">
|
<form method="post" action="{{ url_for('restore_device', mac=d['mac']) }}">
|
||||||
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
||||||
@@ -92,7 +99,7 @@
|
|||||||
<td class="mono">{{ s['ip'] }}</td>
|
<td class="mono">{{ s['ip'] }}</td>
|
||||||
<td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td>
|
<td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.has_permission('papierkorb.edit') %}
|
{% if current_user.has_permission('papierkorb.edit') and license_active() %}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<form method="post" action="{{ url_for('restore_switch', hostname=s['hostname']) }}">
|
<form method="post" action="{{ url_for('restore_switch', hostname=s['hostname']) }}">
|
||||||
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
||||||
@@ -140,7 +147,7 @@
|
|||||||
<td class="mono">{{ c['username'] }}</td>
|
<td class="mono">{{ c['username'] }}</td>
|
||||||
<td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td>
|
<td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.has_permission('papierkorb.edit') %}
|
{% if current_user.has_permission('papierkorb.edit') and license_active() %}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<form method="post" action="{{ url_for('restore_credential', cred_id=c['id']) }}">
|
<form method="post" action="{{ url_for('restore_credential', cred_id=c['id']) }}">
|
||||||
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
||||||
@@ -189,7 +196,7 @@
|
|||||||
<td class="text-dim">{{ full_name or '—' }}</td>
|
<td class="text-dim">{{ full_name or '—' }}</td>
|
||||||
<td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td>
|
<td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.has_permission('papierkorb.edit') %}
|
{% if current_user.has_permission('papierkorb.edit') and license_active() %}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<form method="post" action="{{ url_for('restore_user', user_id=u['id']) }}">
|
<form method="post" action="{{ url_for('restore_user', user_id=u['id']) }}">
|
||||||
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
||||||
@@ -235,7 +242,7 @@
|
|||||||
<td class="cell-name">{{ g['name'] }}</td>
|
<td class="cell-name">{{ g['name'] }}</td>
|
||||||
<td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td>
|
<td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.has_permission('papierkorb.edit') %}
|
{% if current_user.has_permission('papierkorb.edit') and license_active() %}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<form method="post" action="{{ url_for('restore_group', group_id=g['id']) }}">
|
<form method="post" action="{{ url_for('restore_group', group_id=g['id']) }}">
|
||||||
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
<div class="hint">Name und Zeitzone dieses Hosts.</div>
|
<div class="hint">Name und Zeitzone dieses Hosts.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has_permission('settings_system.edit') %}
|
{% set can_edit_host = current_user.has_permission('settings_system.edit') %}
|
||||||
|
{% if can_edit_host %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="hostname">Hostname</label>
|
<label for="hostname">Hostname</label>
|
||||||
@@ -40,7 +41,18 @@
|
|||||||
Zeitzone setzen
|
Zeitzone setzen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="field">
|
||||||
|
<label>Hostname</label>
|
||||||
|
<input type="text" value="{{ current_hostname or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Zeitzone</label>
|
||||||
|
<input type="text" value="{{ current_timezone or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
||||||
|
{% if can_edit_host and license_active() %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="interval">Prüfintervall (Minuten) {{ hi.hint_icon("Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.", "Prüfintervall (Minuten)") }}</label>
|
<label for="interval">Prüfintervall (Minuten) {{ hi.hint_icon("Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.", "Prüfintervall (Minuten)") }}</label>
|
||||||
@@ -52,18 +64,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="field">
|
|
||||||
<label>Hostname</label>
|
|
||||||
<input type="text" value="{{ current_hostname or '' }}" disabled>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Zeitzone</label>
|
|
||||||
<input type="text" value="{{ current_timezone or '' }}" disabled>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Prüfintervall (Minuten)</label>
|
<label>Prüfintervall (Minuten)</label>
|
||||||
<input type="number" value="{{ interval }}" disabled>
|
<input type="number" value="{{ interval }}" disabled>
|
||||||
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
|
<div class="field-hint">
|
||||||
|
{% if not can_edit_host %}
|
||||||
|
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
|
||||||
|
{% else %}
|
||||||
|
Nur mit gültiger Lizenz änderbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
|
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
|
||||||
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
|
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Was importieren? {{ hi.hint_icon("AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet, nicht über diese App, und beim nächsten Login automatisch neu angelegt.", "Was importieren?") }}</label>
|
<label>Was importieren?</label>
|
||||||
<div class="check-list">
|
<div class="check-list">
|
||||||
{% for s in import_preview.sections %}
|
{% for s in import_preview.sections %}
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
||||||
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
|
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
|
||||||
{{ s.label }} <span class="text-faint">({{ s.count }})</span>
|
{{ s.label }} <span class="text-faint">({{ s.count }})</span>
|
||||||
{% if s.admin_only %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
|
{% if export_section_hints and s.key in export_section_hints %}{{ hi.hint_icon(export_section_hints[s.key], s.label) }}{% endif %}
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -71,10 +71,10 @@
|
|||||||
<div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div>
|
<div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has_permission('settings_importexport.export') %}
|
{% if current_user.has_permission('settings_importexport.export') and license_export_allowed() %}
|
||||||
<form method="post" action="{{ url_for('export_data') }}">
|
<form method="post" action="{{ url_for('export_data') }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Was exportieren? {{ hi.hint_icon("AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet (nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.", "Was exportieren?") }}</label>
|
<label>Was exportieren?</label>
|
||||||
<div class="check-list">
|
<div class="check-list">
|
||||||
{% for key, label in export_sections %}
|
{% for key, label in export_sections %}
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
@@ -83,7 +83,6 @@
|
|||||||
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
|
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
{% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %}
|
{% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %}
|
||||||
{% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
|
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -97,6 +96,8 @@
|
|||||||
Export herunterladen
|
Export herunterladen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% elif current_user.has_permission('settings_importexport.export') %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für den Export ist keine gültige Lizenz vorhanden — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
|
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "settings_ldap" %}
|
{% set active_page = "settings_ldap" %}
|
||||||
{% set can_edit = current_user.has_permission('settings_ldap.edit') %}
|
{% set can_edit = current_user.has_permission('settings_ldap.edit') and license_active() %}
|
||||||
{% block page_title %}LDAP / Active Directory{% endblock %}
|
{% block page_title %}LDAP / Active Directory{% endblock %}
|
||||||
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
||||||
|
|
||||||
@@ -96,7 +96,13 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.</p>
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
{% if current_user.has_permission('settings_ldap.edit') %}
|
||||||
|
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
|
||||||
|
{% else %}
|
||||||
|
Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "settings_license" %}
|
||||||
|
{% set can_edit = current_user.has_permission('settings_system.edit') %}
|
||||||
|
{% block page_title %}Lizenz{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
<div class="settings-grid">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Status</h2>
|
||||||
|
<div class="hint">Aktuell installierte Lizenz und ihr Zustand.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if license_error %}
|
||||||
|
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ license_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not license_file %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Keine Lizenzdatei vorhanden — bitte unten eine Lizenzdatei hochladen.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="field">
|
||||||
|
<label>Kunde</label>
|
||||||
|
<input type="text" value="{{ license_file.customer.name or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Lizenztyp</label>
|
||||||
|
<input type="text" value="{{ {'trial': 'Trial', 'standard': 'Standard', 'custom': 'Custom', 'enterprise': 'Enterprise'}.get(license_file.type, license_file.type) }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Module {{ hi.hint_icon("Zusätzlich zu den immer verfügbaren Standard-Funktionen freigeschaltete Vollmodule.", "Module") }}</label>
|
||||||
|
<input type="text" value="{{ (license_file.modules or [])|join(', ') if license_file.modules else 'keine' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Ausgestellt am</label>
|
||||||
|
<input type="text" value="{{ license_file.issued_at or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Läuft ab am</label>
|
||||||
|
<input type="text" value="{{ license_file.expires_at or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Aktivierungsstatus</label>
|
||||||
|
<input type="text" value="{{ 'Aktiviert' if license_is_activated else 'Nicht aktiviert' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Letzter Heartbeat</label>
|
||||||
|
<input type="text" value="{{ license_last_heartbeat_at or 'noch nie' }}" disabled>
|
||||||
|
</div>
|
||||||
|
{% if license_last_error %}
|
||||||
|
<div class="field">
|
||||||
|
<label>Letzter Fehler</label>
|
||||||
|
<input type="text" value="{{ license_last_error }}" disabled>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="field">
|
||||||
|
<label>System-Fingerabdruck {{ hi.hint_icon("Eindeutiger Identifikator dieses Systems, an den die Lizenz bei der Aktivierung gebunden wird.", "System-Fingerabdruck") }}</label>
|
||||||
|
<input type="text" class="mono" value="{{ license_fingerprint }}" disabled>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if license_file and license_valid %}
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Aktivierung</h2>
|
||||||
|
<div class="hint">
|
||||||
|
Ein Protokoll, zwei Wege: automatisch online, oder — falls kein Netzwerkzugriff auf den
|
||||||
|
Lizenzserver besteht — per Code manuell mit dem Lizenzserver-Administrator ausgetauscht.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not can_edit %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für Aktivierung/Deaktivierung fehlt das Recht „Systemeinstellungen ändern“.</p>
|
||||||
|
|
||||||
|
{% elif pending_action %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px; margin-bottom:10px;">
|
||||||
|
Offene {{ 'Aktivierungs' if pending_action.action == 'activate' else 'Deaktivierungs' }}-Anfrage — folgenden Code
|
||||||
|
beim Lizenzserver-Administrator eingeben:
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<textarea class="mono" rows="4" readonly onclick="this.select()" style="width:100%; resize:vertical;">{{ pending_code }}</textarea>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('license_activate_confirm' if pending_action.action == 'activate' else 'license_deactivate_confirm') }}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="confirmation_code">Bestätigungscode vom Lizenzserver</label>
|
||||||
|
<textarea class="mono" name="confirmation_code" id="confirmation_code" rows="4" style="width:100%; resize:vertical;" required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Code bestätigen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('license_activate' if pending_action.action == 'activate' else 'license_deactivate') }}" style="margin-top:10px;">
|
||||||
|
<button type="submit" class="btn btn-secondary btn-block">Erneut online versuchen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% elif license_is_activated %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px; margin-bottom:10px;">
|
||||||
|
Lizenz ist aktiviert. Bei einem Systemwechsel zuerst hier deaktivieren — danach kann der Kunde sich beim
|
||||||
|
Anbieter selbst eine neue Lizenz für das neue System ausstellen lassen.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('license_deactivate') }}"
|
||||||
|
data-confirm="Lizenz wirklich deaktivieren? Alle lizenzpflichtigen Module/Funktionen werden danach sofort inaktiv (Export bleibt bis zur nächsten Aktivierung möglich).">
|
||||||
|
<button type="submit" class="btn btn-danger btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>
|
||||||
|
Lizenz deaktivieren (Systemwechsel)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('license_activate') }}">
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Jetzt aktivieren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Lizenzdatei hochladen</h2>
|
||||||
|
<div class="hint">Vom Anbieter erhaltene Lizenzdatei einspielen — muss danach noch aktiviert werden.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<form method="post" action="{{ url_for('license_upload') }}" enctype="multipart/form-data">
|
||||||
|
<div class="field">
|
||||||
|
<label for="license_file">Lizenzdatei</label>
|
||||||
|
<input type="file" name="license_file" id="license_file" accept=".json,.lic" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||||
|
Hochladen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für den Upload fehlt das Recht „Systemeinstellungen ändern“.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if license_file and license_file.vendor %}
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Anbieter</h2>
|
||||||
|
<div class="hint">Kontakt für Rückfragen zu dieser Lizenz.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Firma</label>
|
||||||
|
<input type="text" value="{{ license_file.vendor.name or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Telefon</label>
|
||||||
|
<input type="text" value="{{ license_file.vendor.phone or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>E-Mail</label>
|
||||||
|
<input type="text" value="{{ license_file.vendor.email or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Anschrift</label>
|
||||||
|
<input type="text" value="{{ license_file.vendor.address or '' }}" disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "settings_nginx" %}
|
{% set active_page = "settings_nginx" %}
|
||||||
{% set can_edit = current_user.has_permission('settings_nginx.edit') %}
|
{% set can_edit = current_user.has_permission('settings_nginx.edit') and license_active() %}
|
||||||
{% block page_title %}NGINX{% endblock %}
|
{% block page_title %}NGINX{% endblock %}
|
||||||
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
||||||
|
|
||||||
@@ -73,7 +73,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „NGINX ändern“.</p>
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
{% if current_user.has_permission('settings_nginx.edit') %}
|
||||||
|
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
|
||||||
|
{% else %}
|
||||||
|
Für Änderungen fehlt das Recht „NGINX ändern“.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,7 +183,13 @@
|
|||||||
<p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p>
|
<p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-faint" style="font-size:12.5px;">Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.</p>
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
{% if current_user.has_permission('settings_nginx.edit') %}
|
||||||
|
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
|
||||||
|
{% else %}
|
||||||
|
Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================================
|
||||||
|
# TESM-Lizenzserver — Update/Reinstall (Bootstrapper)
|
||||||
|
# Fork von update.sh (TESM selbst) -- lädt dasselbe gepackte Gitea-Release
|
||||||
|
# herunter (das Repo enthält beide Apps: srv/tesm/ UND srv/tesm-license/)
|
||||||
|
# und übergibt an install-license.sh aus dem Paket, statt install.sh.
|
||||||
|
#
|
||||||
|
# Auszuführen als root auf dem Zielsystem:
|
||||||
|
# sudo ./update-license.sh
|
||||||
|
# Optional übersteuerbar (z.B. für eine bestimmte Version statt "latest"):
|
||||||
|
# TESM_RELEASE_TAG=v1.0.0 sudo -E ./update-license.sh
|
||||||
|
# ============================================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
GITEA_BASE="${TESM_GITEA_BASE:-https://gitea.int.eertmoed.net}"
|
||||||
|
REPO_OWNER="${TESM_REPO_OWNER:-alientim}"
|
||||||
|
REPO_NAME="${TESM_REPO_NAME:-tesm}"
|
||||||
|
RELEASE_TAG="${TESM_RELEASE_TAG:-latest}"
|
||||||
|
GITEA_USER="${TESM_GITEA_USER:-}"
|
||||||
|
GITEA_TOKEN="${TESM_GITEA_TOKEN:-}"
|
||||||
|
PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz"
|
||||||
|
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
||||||
|
|
||||||
|
WORK_DIR="/tmp/tesm-license-update-$(date +%s)"
|
||||||
|
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "Bitte als root ausführen (sudo ./update-license.sh)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}============================================================================${NC}"
|
||||||
|
echo -e "${YELLOW} TESM-Lizenzserver Update/Reinstall — Release \"${RELEASE_TAG}\"${NC}"
|
||||||
|
echo -e "${YELLOW}============================================================================${NC}"
|
||||||
|
echo
|
||||||
|
echo "Lädt das Release-Paket herunter (enthält TESM und den Lizenzserver"
|
||||||
|
echo "zusammen) und übergibt Installation/Update an install-license.sh"
|
||||||
|
echo "daraus -- deployt ausschließlich srv/tesm-license/, lässt eine"
|
||||||
|
echo "eventuell auf demselben Host vorhandene TESM-Installation unberührt."
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -r -p "Fortfahren? [y/N] " confirm
|
||||||
|
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
||||||
|
echo "Abgebrochen — nichts wurde verändert."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${RED}→${NC} Lade Paket von ${DOWNLOAD_URL}..."
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
CURL_AUTH=()
|
||||||
|
if [ -n "$GITEA_USER" ] && [ -n "$GITEA_TOKEN" ]; then
|
||||||
|
CURL_AUTH=(-u "${GITEA_USER}:${GITEA_TOKEN}")
|
||||||
|
fi
|
||||||
|
curl -fsSL "${CURL_AUTH[@]}" -o "$PACKAGE_FILE" "$DOWNLOAD_URL"
|
||||||
|
echo -e "${GREEN}✔${NC} Paket heruntergeladen ($(du -h "$PACKAGE_FILE" | cut -f1))."
|
||||||
|
|
||||||
|
echo -e "${RED}→${NC} Entpacke Paket..."
|
||||||
|
mkdir -p "$WORK_DIR/pkg"
|
||||||
|
tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1
|
||||||
|
echo -e "${GREEN}✔${NC} Paket entpackt."
|
||||||
|
|
||||||
|
echo -e "${RED}→${NC} Starte install-license.sh aus dem Paket..."
|
||||||
|
bash "$WORK_DIR/pkg/install-license.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${GREEN}============================================================================${NC}"
|
||||||
|
echo -e "${GREEN} TESM-Lizenzserver Update/Reinstall (Release \"${RELEASE_TAG}\") abgeschlossen.${NC}"
|
||||||
|
echo -e "${GREEN}============================================================================${NC}"
|
||||||
@@ -80,6 +80,7 @@ EOF
|
|||||||
function check_device() {
|
function check_device() {
|
||||||
local rpi_ip=$1 dev_name=$2 switch_ip=$3 switch_ssh_port=$4
|
local rpi_ip=$1 dev_name=$2 switch_ip=$3 switch_ssh_port=$4
|
||||||
local switch_hostname=$5 switch_port=$6 switch_user=$7 switch_pass=$8
|
local switch_hostname=$5 switch_port=$6 switch_user=$7 switch_pass=$8
|
||||||
|
local license_active=$9
|
||||||
|
|
||||||
if ping -c 1 -W 2 "$rpi_ip" &> /dev/null; then
|
if ping -c 1 -W 2 "$rpi_ip" &> /dev/null; then
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist erreichbar!" >> "$LOGFILE"
|
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist erreichbar!" >> "$LOGFILE"
|
||||||
@@ -92,6 +93,15 @@ function check_device() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Automatischer PoE-Neustart bei Ausfall ist ein lizenzpflichtiges
|
||||||
|
# Feature (siehe Lizenzsystem) -- der manuelle Neustart-Button im
|
||||||
|
# Dashboard (manual_restart() weiter unten) ruft disable_poe/enable_poe
|
||||||
|
# dagegen IMMER direkt auf und bleibt davon bewusst unberührt.
|
||||||
|
if [ "$license_active" != "1" ]; then
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: automatischer PoE-Neustart übersprungen -- keine gültige Lizenz." >> "$LOGFILE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
if ! disable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass" "$switch_ssh_port"; then
|
if ! disable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass" "$switch_ssh_port"; then
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: Switch $switch_hostname nicht erreichbar oder Kommando abgelehnt — PoE-Deaktivierung fehlgeschlagen, kein Neustart durchgeführt." >> "$LOGFILE"
|
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: Switch $switch_hostname nicht erreichbar oder Kommando abgelehnt — PoE-Deaktivierung fehlgeschlagen, kein Neustart durchgeführt." >> "$LOGFILE"
|
||||||
return
|
return
|
||||||
@@ -146,8 +156,19 @@ fi
|
|||||||
while true; do
|
while true; do
|
||||||
echo "--------------------------------------------------------------------" >> "$LOGFILE"
|
echo "--------------------------------------------------------------------" >> "$LOGFILE"
|
||||||
|
|
||||||
|
# Einmal pro Schleifendurchlauf gelesen (wie SLEEP oben beim Skriptstart),
|
||||||
|
# nicht pro Gerät -- reicht als Shell-Variable an check_device() durch.
|
||||||
|
LICENSE_ACTIVE=$(python3 - <<'END'
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/srv/tesm")
|
||||||
|
from app import license_active
|
||||||
|
print("1" if license_active() else "0")
|
||||||
|
END
|
||||||
|
)
|
||||||
|
LICENSE_ACTIVE=${LICENSE_ACTIVE:-0}
|
||||||
|
|
||||||
while IFS='|' read -r rpi_ip dev_name switch_ip switch_ssh_port switch_hostname switch_port switch_user switch_pass mac; do
|
while IFS='|' read -r rpi_ip dev_name switch_ip switch_ssh_port switch_hostname switch_port switch_user switch_pass mac; do
|
||||||
check_device "$rpi_ip" "$dev_name" "$switch_ip" "$switch_ssh_port" "$switch_hostname" "$switch_port" "$switch_user" "$switch_pass" &
|
check_device "$rpi_ip" "$dev_name" "$switch_ip" "$switch_ssh_port" "$switch_hostname" "$switch_port" "$switch_user" "$switch_pass" "$LICENSE_ACTIVE" &
|
||||||
done < <(python3 /srv/tesm/generate_ips.py)
|
done < <(python3 /srv/tesm/generate_ips.py)
|
||||||
|
|
||||||
wait
|
wait
|
||||||
|
|||||||