Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
286a3464f1 | ||
|
|
619673ac76 | ||
|
|
20abadb0e7 | ||
|
|
2d920f581f | ||
|
|
c2989a6e2f |
@@ -1 +1 @@
|
|||||||
1.2.0
|
1.2.5
|
||||||
|
|||||||
+414
-65
@@ -13,7 +13,7 @@ Phase 1 ergänzte Lizenzsystem selbst (licensing.py). Das Dashboard ("/")
|
|||||||
ist aktuell nur ein Platzhalter -- die eigentliche Kunden-/
|
ist aktuell nur ein Platzhalter -- die eigentliche Kunden-/
|
||||||
Lizenzübersicht folgt in einem späteren Schritt.
|
Lizenzübersicht folgt in einem späteren Schritt.
|
||||||
"""
|
"""
|
||||||
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_file, abort
|
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_file, abort, session
|
||||||
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
|
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
|
||||||
from flask_bcrypt import Bcrypt
|
from flask_bcrypt import Bcrypt
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
@@ -21,7 +21,7 @@ from cryptography.fernet import Fernet
|
|||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
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
|
from datetime import datetime, timedelta
|
||||||
import base64, io, ipaddress, logging, math, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback, zipfile
|
import base64, io, ipaddress, logging, math, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback, zipfile
|
||||||
import yaml
|
import yaml
|
||||||
import ssl
|
import ssl
|
||||||
@@ -212,6 +212,10 @@ PERMISSIONS = {
|
|||||||
"label": "Kunden",
|
"label": "Kunden",
|
||||||
"rows": {"view": "customers.view", "create": "customers.create", "edit": "customers.edit"},
|
"rows": {"view": "customers.view", "create": "customers.create", "edit": "customers.edit"},
|
||||||
},
|
},
|
||||||
|
"tickets": {
|
||||||
|
"label": "Tickets",
|
||||||
|
"rows": {"view": "tickets.view", "create": "tickets.create", "edit": "tickets.edit"},
|
||||||
|
},
|
||||||
"licenses": {
|
"licenses": {
|
||||||
"label": "Lizenzen",
|
"label": "Lizenzen",
|
||||||
"rows": {"view": "licenses.view", "create": "licenses.create", "edit": "licenses.edit"},
|
"rows": {"view": "licenses.view", "create": "licenses.create", "edit": "licenses.edit"},
|
||||||
@@ -251,6 +255,10 @@ PERMISSIONS = {
|
|||||||
"label": "Systemeinstellungen",
|
"label": "Systemeinstellungen",
|
||||||
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
|
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
|
||||||
},
|
},
|
||||||
|
"settings_license": {
|
||||||
|
"label": "Lizenz",
|
||||||
|
"rows": {"view": "settings_license.view", "edit": "settings_license.edit"},
|
||||||
|
},
|
||||||
"settings_importexport": {
|
"settings_importexport": {
|
||||||
"label": "Im-/Export",
|
"label": "Im-/Export",
|
||||||
"rows": {
|
"rows": {
|
||||||
@@ -275,6 +283,8 @@ PERMISSION_LABELS = {
|
|||||||
"licensing_group.view": "Lizenzierung-Bereich anzeigen",
|
"licensing_group.view": "Lizenzierung-Bereich anzeigen",
|
||||||
"customers.view": "Kunden lesen", "customers.create": "Kunden anlegen",
|
"customers.view": "Kunden lesen", "customers.create": "Kunden anlegen",
|
||||||
"customers.edit": "Kunden ändern (inkl. Löschen)",
|
"customers.edit": "Kunden ändern (inkl. Löschen)",
|
||||||
|
"tickets.view": "Tickets lesen", "tickets.create": "Tickets anlegen",
|
||||||
|
"tickets.edit": "Tickets ändern (Eckdaten, Sperren/Entsperren, Löschen)",
|
||||||
"licenses.view": "Lizenzen und Aktivierungsstatus lesen",
|
"licenses.view": "Lizenzen und Aktivierungsstatus lesen",
|
||||||
"licenses.create": "Lizenzen ausstellen",
|
"licenses.create": "Lizenzen ausstellen",
|
||||||
"licenses.edit": "Lizenzen ändern (Deaktivieren/Widerrufen, erneut versenden)",
|
"licenses.edit": "Lizenzen ändern (Deaktivieren/Widerrufen, erneut versenden)",
|
||||||
@@ -288,6 +298,8 @@ PERMISSION_LABELS = {
|
|||||||
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
|
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
|
||||||
"groups.edit": "Gruppen ändern (inkl. Löschen)",
|
"groups.edit": "Gruppen ändern (inkl. Löschen)",
|
||||||
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
|
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
|
||||||
|
"settings_license.view": "Lizenzstatus ansehen",
|
||||||
|
"settings_license.edit": "Lizenzdatei hochladen sowie Lizenz aktivieren/deaktivieren (online oder per Code)",
|
||||||
"settings_importexport.view": "Im-/Export-Seite ansehen",
|
"settings_importexport.view": "Im-/Export-Seite ansehen",
|
||||||
"settings_importexport.edit": "Daten importieren",
|
"settings_importexport.edit": "Daten importieren",
|
||||||
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
|
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
|
||||||
@@ -320,8 +332,12 @@ DEFAULT_GROUP_PERMISSIONS = [
|
|||||||
|
|
||||||
NAV_ITEMS = [
|
NAV_ITEMS = [
|
||||||
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
|
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
|
||||||
{"key": "tickets", "label": "Tickets", "icon": "key", "endpoint": "tickets_list"},
|
# Reihenfolge spiegelt bewusst die fachliche Hierarchie Kunde -> Ticket
|
||||||
|
# -> Lizenz wider -- jede Ebene eine eigene, direkt ueber die Navigation
|
||||||
|
# erreichbare Seite statt ineinander verschachtelter Detailansichten.
|
||||||
{"key": "customers", "label": "Kunden", "icon": "users", "endpoint": "customers"},
|
{"key": "customers", "label": "Kunden", "icon": "users", "endpoint": "customers"},
|
||||||
|
{"key": "tickets", "label": "Tickets", "icon": "key", "endpoint": "tickets_list"},
|
||||||
|
{"key": "licenses", "label": "Lizenzen", "icon": "shield", "endpoint": "licenses_list"},
|
||||||
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
||||||
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
|
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
|
||||||
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
|
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
|
||||||
@@ -345,6 +361,8 @@ def _nav_key_visible(key, user):
|
|||||||
if key == "index":
|
if key == "index":
|
||||||
return True
|
return True
|
||||||
if key == "tickets":
|
if key == "tickets":
|
||||||
|
return user.has_permission("tickets.view")
|
||||||
|
if key == "licenses":
|
||||||
return user.has_permission("licenses.view")
|
return user.has_permission("licenses.view")
|
||||||
if key == "customers":
|
if key == "customers":
|
||||||
return user.can_manage_customers
|
return user.can_manage_customers
|
||||||
@@ -355,10 +373,11 @@ def _nav_key_visible(key, user):
|
|||||||
if key == "settings_system":
|
if key == "settings_system":
|
||||||
return user.can_view_settings_system
|
return user.can_view_settings_system
|
||||||
if key == "settings_license":
|
if key == "settings_license":
|
||||||
# Immer erreichbar für jeden mit Systemeinstellungen-Leserecht --
|
# Eigenes Recht (settings_license.view), unabhängig von
|
||||||
# muss auch OHNE Lizenz sichtbar bleiben, sonst könnte niemand
|
# settings_system -- muss auch OHNE eine aktive Lizenz sichtbar
|
||||||
# jemals eine erste Lizenz hochladen/aktivieren.
|
# bleiben (das ist eine andere Prüfung, siehe license_active()),
|
||||||
return user.can_view_settings_system
|
# sonst könnte niemand jemals eine erste Lizenz hochladen/aktivieren.
|
||||||
|
return user.can_view_settings_license
|
||||||
if key == "settings_importexport":
|
if key == "settings_importexport":
|
||||||
return user.can_view_settings_importexport
|
return user.can_view_settings_importexport
|
||||||
if key == "settings_ldap":
|
if key == "settings_ldap":
|
||||||
@@ -531,6 +550,12 @@ class User(UserMixin):
|
|||||||
["customers.view", "customers.create", "customers.edit"]
|
["customers.view", "customers.create", "customers.edit"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_manage_tickets(self):
|
||||||
|
return self.has_any_permission(
|
||||||
|
["tickets.view", "tickets.create", "tickets.edit"]
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_manage_licenses(self):
|
def can_manage_licenses(self):
|
||||||
return self.has_any_permission(
|
return self.has_any_permission(
|
||||||
@@ -553,6 +578,10 @@ class User(UserMixin):
|
|||||||
def can_view_settings_system(self):
|
def can_view_settings_system(self):
|
||||||
return self.has_permission("settings_system.view")
|
return self.has_permission("settings_system.view")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_view_settings_license(self):
|
||||||
|
return self.has_permission("settings_license.view")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_view_settings_importexport(self):
|
def can_view_settings_importexport(self):
|
||||||
return self.has_any_permission([
|
return self.has_any_permission([
|
||||||
@@ -580,7 +609,14 @@ class User(UserMixin):
|
|||||||
|
|
||||||
|
|
||||||
def get_db_connection():
|
def get_db_connection():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
# timeout=15 statt Python-Default 5s: reine Verteidigungslinie gegen
|
||||||
|
# kurze, legitime Ueberschneidungen zweier Schreibzugriffe (SQLite
|
||||||
|
# erlaubt nur EINEN Schreiber gleichzeitig) -- ersetzt NICHT die
|
||||||
|
# eigentliche Regel, niemals eine zweite Verbindung waehrend einer noch
|
||||||
|
# offenen Schreibtransaktion auf derselben zu committen (siehe
|
||||||
|
# _process_activate für ein Beispiel, wo genau das den Fehler
|
||||||
|
# "database is locked" ausgeloest hat).
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=15)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@@ -705,11 +741,14 @@ def _ensure_schema():
|
|||||||
):
|
):
|
||||||
if _col not in customer_cols:
|
if _col not in customer_cols:
|
||||||
conn.execute(_ddl)
|
conn.execute(_ddl)
|
||||||
# Jeder Kunde braucht einen eigenen, dauerhaften Portal-Token fuer den
|
# Historisch: ein dauerhafter Portal-Token pro Kunde (vorher Basis
|
||||||
# kundenweiten Magic-Link (Kundenportal-Login ohne Passwort, siehe
|
# eines UNBEFRISTETEN Magic-Links). Dieser direkte, permanente Zugang
|
||||||
# customer_portal()) -- bestehende Kunden (vor Einfuehrung dieses
|
# wurde bewusst wieder entfernt -- der einzige Weg ins Kundenportal
|
||||||
# Felds) bekommen hier rueckwirkend einen zugewiesen, statt bis zur
|
# ist jetzt der zeitlich begrenzte Ablauf über portal_request() /
|
||||||
# naechsten manuellen Bearbeitung ohne Portal-Zugang dazustehen.
|
# portal_access() (siehe dort). Die Spalte samt Backfill bleibt
|
||||||
|
# bestehen (keine riskante Schema-Ruecknahme fuer reine Aufraeum-
|
||||||
|
# zwecke), wird aber fuer die Zugriffsentscheidung selbst nicht mehr
|
||||||
|
# herangezogen.
|
||||||
for _row in conn.execute("SELECT id FROM license_customers WHERE portal_token IS NULL OR portal_token=''").fetchall():
|
for _row in conn.execute("SELECT id FROM license_customers WHERE portal_token IS NULL OR portal_token=''").fetchall():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE license_customers SET portal_token=? WHERE id=?",
|
"UPDATE license_customers SET portal_token=? WHERE id=?",
|
||||||
@@ -800,10 +839,10 @@ def _ensure_schema():
|
|||||||
"Konnte idx_one_open_license_per_ticket nicht anlegen -- vermutlich hat mindestens ein "
|
"Konnte idx_one_open_license_per_ticket nicht anlegen -- vermutlich hat mindestens ein "
|
||||||
"Ticket bereits mehr als eine offene Lizenz. Bitte manuell bereinigen (siehe licenses-Tabelle)."
|
"Ticket bereits mehr als eine offene Lizenz. Bitte manuell bereinigen (siehe licenses-Tabelle)."
|
||||||
)
|
)
|
||||||
# Analoger Backstop fuer den Kundenportal-Magic-Link: portal_token
|
# Historisch (siehe Kommentar beim Backfill oben) -- portal_token wird
|
||||||
# autorisiert komplett ohne Login (siehe customer_portal()), ein
|
# fuer die eigentliche Zugriffsentscheidung nicht mehr genutzt, der
|
||||||
# doppelt vergebener oder NULL-Token wuerde sonst stillschweigend die
|
# Unique-Index bleibt trotzdem als harmlose Datenintegritaets-
|
||||||
# falsche Kundenzeile zurueckgeben bzw. den Kunden dauerhaft aussperren.
|
# Absicherung bestehen.
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_portal_token "
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_portal_token "
|
||||||
@@ -815,6 +854,25 @@ def _ensure_schema():
|
|||||||
"portal_token. Bitte manuell bereinigen (siehe license_customers-Tabelle)."
|
"portal_token. Bitte manuell bereinigen (siehe license_customers-Tabelle)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Kurzlebige, EINMAL nutzbare Zugangs-Tokens fuer /portal/request (siehe
|
||||||
|
# dortiger Docstring): anders als der dauerhafte portal_token (oben,
|
||||||
|
# von Admins ueber die Ticket-/Kundenseite verteilt, bewusst
|
||||||
|
# unbefristet) ist DIESER Weg oeffentlich von JEDEM auf blossen
|
||||||
|
# Zuruf einer E-Mail-Adresse ausloesbar -- ein zusaetzliches
|
||||||
|
# zeitliches Sicherheitsnetz ist hier angemessen, waehrend die
|
||||||
|
# bereits etablierten, admin-verteilten Links (Ticket-Seite,
|
||||||
|
# Lizenz-per-E-Mail) unveraendert dauerhaft bleiben.
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS portal_access_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung
|
# Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung
|
||||||
# des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) --
|
# des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) --
|
||||||
# sonst würden genau diese Lizenzen (auf einer echten Installation z.B.
|
# sonst würden genau diese Lizenzen (auf einer echten Installation z.B.
|
||||||
@@ -842,6 +900,64 @@ def _ensure_schema():
|
|||||||
conn.execute("UPDATE licenses SET ticket_id=? WHERE license_id=?", (ticket_id, lic["license_id"]))
|
conn.execute("UPDATE licenses SET ticket_id=? WHERE license_id=?", (ticket_id, lic["license_id"]))
|
||||||
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets,))
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets,))
|
||||||
|
|
||||||
|
_migration_key_license_perm = "_migrated_settings_license_permission_v1"
|
||||||
|
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_license_perm,)).fetchone():
|
||||||
|
# "Lizenz" (settings_license) hatte bislang KEIN eigenes Recht --
|
||||||
|
# die Seite lief komplett über settings_system.view/.edit mit.
|
||||||
|
# Backfill: jede Gruppe, die settings_system.view/.edit bereits
|
||||||
|
# hatte, bekommt automatisch das neue, gespiegelte
|
||||||
|
# settings_license.view/.edit dazu -- ohne diesen Schritt würden
|
||||||
|
# bestehende Gruppen beim Update stillschweigend den Zugriff auf
|
||||||
|
# die Lizenzseite verlieren, den sie vorher implizit hatten.
|
||||||
|
_system_to_license = {
|
||||||
|
"settings_system.view": "settings_license.view",
|
||||||
|
"settings_system.edit": "settings_license.edit",
|
||||||
|
}
|
||||||
|
for system_perm, license_perm in _system_to_license.items():
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (system_perm,)
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
||||||
|
(row["group_id"], license_perm),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_license_perm,)
|
||||||
|
)
|
||||||
|
|
||||||
|
_migration_key_tickets_perm = "_migrated_tickets_permission_v1"
|
||||||
|
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_tickets_perm,)).fetchone():
|
||||||
|
# "Tickets" hatte bislang KEIN eigenes Recht -- Ticketverwaltung
|
||||||
|
# (anlegen/bearbeiten/sperren/loeschen) lief komplett ueber
|
||||||
|
# licenses.view/.create/.edit mit, obwohl Tickets ein eigener,
|
||||||
|
# gleichberechtigter Navigationspunkt neben Kunden und Lizenzen
|
||||||
|
# ist (genau wie bei TESM Clients/Switche/Zugangsdaten trotz enger
|
||||||
|
# fachlicher Verwandtschaft jeweils eigene Rechte haben). Backfill:
|
||||||
|
# jede Gruppe mit licenses.view/.create/.edit bekommt automatisch
|
||||||
|
# das gespiegelte tickets.view/.create/.edit dazu -- das
|
||||||
|
# Ausstellen einer Lizenz AUS einem Ticket heraus bleibt bewusst
|
||||||
|
# weiterhin an licenses.edit gebunden (siehe ticket_detail()),
|
||||||
|
# nur Sperren/Entsperren/Bearbeiten/Loeschen des Tickets selbst
|
||||||
|
# wandert auf das neue, unabhaengige Recht.
|
||||||
|
_license_to_ticket = {
|
||||||
|
"licenses.view": "tickets.view",
|
||||||
|
"licenses.create": "tickets.create",
|
||||||
|
"licenses.edit": "tickets.edit",
|
||||||
|
}
|
||||||
|
for license_perm, ticket_perm in _license_to_ticket.items():
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (license_perm,)
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
||||||
|
(row["group_id"], ticket_perm),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets_perm,)
|
||||||
|
)
|
||||||
|
|
||||||
_migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1"
|
_migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1"
|
||||||
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone():
|
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone():
|
||||||
for stale_log in glob.glob("/var/log/rpi-*.log"):
|
for stale_log in glob.glob("/var/log/rpi-*.log"):
|
||||||
@@ -1942,6 +2058,23 @@ def _get_master_endpoint():
|
|||||||
return get_setting("master_endpoint", "")
|
return get_setting("master_endpoint", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_facing_url(endpoint, **values):
|
||||||
|
"""Baut eine externe URL fuer an KUNDEN gerichtete Magic-Links (Self-
|
||||||
|
Service, Kundenportal-Anfrage/-Zugang, Lizenz-per-E-Mail) auf Basis
|
||||||
|
des admin-gepflegten Lizenzserver-Endpunkts (siehe _get_master_endpoint(),
|
||||||
|
Einstellungen -> Lizenz -> Anbieter & Lizenzserver) -- NICHT per
|
||||||
|
url_for(..., _external=True), das stattdessen den Host-Header der
|
||||||
|
GERADE laufenden Anfrage verwendet und damit z.B. die interne IP
|
||||||
|
zeigt, ueber die zufaellig der Admin gerade eingeloggt ist, statt der
|
||||||
|
fuer Kunden eigentlich gedachten oeffentlichen Domain. Faellt auf die
|
||||||
|
normale request-basierte externe URL zurueck, wenn der Endpunkt noch
|
||||||
|
nicht konfiguriert ist (z.B. auf einer frischen Installation)."""
|
||||||
|
base = _get_master_endpoint().rstrip("/")
|
||||||
|
if not base:
|
||||||
|
return url_for(endpoint, _external=True, **values)
|
||||||
|
return base + url_for(endpoint, **values)
|
||||||
|
|
||||||
|
|
||||||
def _customer_row(conn, customer_id):
|
def _customer_row(conn, customer_id):
|
||||||
return conn.execute("SELECT * FROM license_customers WHERE id=?", (customer_id,)).fetchone()
|
return conn.execute("SELECT * FROM license_customers WHERE id=?", (customer_id,)).fetchone()
|
||||||
|
|
||||||
@@ -2202,19 +2335,33 @@ def _process_activate(data):
|
|||||||
"SELECT license_id, customer_id FROM licenses WHERE fingerprint=? AND status='active' AND license_id!=?",
|
"SELECT license_id, customer_id FROM licenses WHERE fingerprint=? AND status='active' AND license_id!=?",
|
||||||
(fingerprint, license_id),
|
(fingerprint, license_id),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
# log_action_system() oeffnet INTERN eine EIGENE, zweite Verbindung und
|
||||||
|
# committet sofort -- solange `conn` hier noch eine offene Schreib-
|
||||||
|
# transaktion haelt (erstes UPDATE oben, noch nicht committet), wuerde
|
||||||
|
# dieser zweite Commit-Versuch auf sich selbst warten (SQLite erlaubt
|
||||||
|
# nur EINEN Schreiber gleichzeitig) und nach Ablauf des Busy-Timeouts
|
||||||
|
# mit "database is locked" fehlschlagen -- live reproduziert beim
|
||||||
|
# Aktivieren einer Lizenz, die eine andere fuer denselben Fingerprint
|
||||||
|
# ersetzt. Deshalb werden die Log-Eintraege hier nur VORGEMERKT (reine
|
||||||
|
# Lesezugriffe auf `conn` sind unproblematisch, nur der schreibende
|
||||||
|
# log_action_system()-Aufruf selbst muss warten) und dann als allerletzter
|
||||||
|
# Schritt nach `conn.commit()`/`conn.close()` unten nachgeholt.
|
||||||
|
superseded_logs = []
|
||||||
for old in superseded:
|
for old in superseded:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
|
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
|
||||||
(now, old["license_id"]),
|
(now, old["license_id"]),
|
||||||
)
|
)
|
||||||
old_customer = _customer_row(conn, old["customer_id"])
|
old_customer = _customer_row(conn, old["customer_id"])
|
||||||
log_action_system(
|
superseded_logs.append((
|
||||||
"license.superseded", old_customer["name"] if old_customer else old["license_id"],
|
old_customer["name"] if old_customer else old["license_id"],
|
||||||
f"Automatisch deaktiviert -- Fingerprint {fingerprint} hat jetzt Lizenz {license_id} aktiviert.",
|
f"Automatisch deaktiviert -- Fingerprint {fingerprint} hat jetzt Lizenz {license_id} aktiviert.",
|
||||||
)
|
))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
customer = _customer_row(conn, row["customer_id"])
|
customer = _customer_row(conn, row["customer_id"])
|
||||||
conn.close()
|
conn.close()
|
||||||
|
for target, details in superseded_logs:
|
||||||
|
log_action_system("license.superseded", target, details)
|
||||||
log_action_system("license.activated_remote", customer["name"] if customer else license_id, f"Fingerprint {fingerprint}")
|
log_action_system("license.activated_remote", customer["name"] if customer else license_id, f"Fingerprint {fingerprint}")
|
||||||
return licensing.build_master_response("activated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200
|
return licensing.build_master_response("activated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200
|
||||||
|
|
||||||
@@ -2439,9 +2586,11 @@ def customers():
|
|||||||
return redirect(url_for("customers"))
|
return redirect(url_for("customers"))
|
||||||
|
|
||||||
customers_rows = conn.execute("""
|
customers_rows = conn.execute("""
|
||||||
SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count
|
SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count,
|
||||||
|
COUNT(DISTINCT licenses.id) AS license_count
|
||||||
FROM license_customers
|
FROM license_customers
|
||||||
LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id
|
LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id
|
||||||
|
LEFT JOIN licenses ON licenses.customer_id = license_customers.id
|
||||||
GROUP BY license_customers.id
|
GROUP BY license_customers.id
|
||||||
ORDER BY license_customers.name COLLATE NOCASE
|
ORDER BY license_customers.name COLLATE NOCASE
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
@@ -2460,7 +2609,7 @@ def ticket_new():
|
|||||||
Lizenz mit bestimmten Eckdaten. Erstellt bewusst noch KEINE Lizenz;
|
Lizenz mit bestimmten Eckdaten. Erstellt bewusst noch KEINE Lizenz;
|
||||||
das übernimmt ein separater Schritt auf der Ticket-Detailseite (durch
|
das übernimmt ein separater Schritt auf der Ticket-Detailseite (durch
|
||||||
Admin ODER Kunde per Self-Service)."""
|
Admin ODER Kunde per Self-Service)."""
|
||||||
if not current_user.has_permission("licenses.create"):
|
if not current_user.has_permission("tickets.create"):
|
||||||
flash("Keine Berechtigung, Tickets anzulegen.", "danger")
|
flash("Keine Berechtigung, Tickets anzulegen.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -2531,7 +2680,7 @@ def ticket_new():
|
|||||||
@app.route("/tickets/<ticket_id>", methods=["GET", "POST"])
|
@app.route("/tickets/<ticket_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def ticket_detail(ticket_id):
|
def ticket_detail(ticket_id):
|
||||||
if not current_user.can_manage_licenses:
|
if not current_user.can_manage_tickets:
|
||||||
flash("Keine Berechtigung.", "danger")
|
flash("Keine Berechtigung.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -2542,10 +2691,14 @@ def ticket_detail(ticket_id):
|
|||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
if "issue_license" in request.form:
|
||||||
|
# Ausstellen erzeugt eine LIZENZ (nicht nur eine Ticket-Aenderung)
|
||||||
|
# -- bewusst weiterhin ueber das Lizenz-Recht gegated, nicht ueber
|
||||||
|
# tickets.edit, damit sich "Tickets verwalten" und "Lizenzen
|
||||||
|
# ausstellen" unabhaengig voneinander vergeben lassen.
|
||||||
if not current_user.has_permission("licenses.edit"):
|
if not current_user.has_permission("licenses.edit"):
|
||||||
flash("Keine Berechtigung.", "danger")
|
flash("Keine Berechtigung, Lizenzen auszustellen.", "danger")
|
||||||
elif "issue_license" in request.form:
|
elif not license_active():
|
||||||
if not license_active():
|
|
||||||
flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger")
|
flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger")
|
||||||
elif ticket["status"] != "open":
|
elif ticket["status"] != "open":
|
||||||
flash("Ticket ist gesperrt.", "danger")
|
flash("Ticket ist gesperrt.", "danger")
|
||||||
@@ -2558,6 +2711,9 @@ def ticket_detail(ticket_id):
|
|||||||
flash("Lizenz ausgestellt.", "success")
|
flash("Lizenz ausgestellt.", "success")
|
||||||
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
|
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
|
||||||
elif "toggle_block" in request.form:
|
elif "toggle_block" in request.form:
|
||||||
|
if not current_user.has_permission("tickets.edit"):
|
||||||
|
flash("Keine Berechtigung, Tickets zu sperren/entsperren.", "danger")
|
||||||
|
else:
|
||||||
new_status = "blocked" if ticket["status"] == "open" else "open"
|
new_status = "blocked" if ticket["status"] == "open" else "open"
|
||||||
conn.execute("UPDATE license_tickets SET status=? WHERE id=?", (new_status, ticket_id))
|
conn.execute("UPDATE license_tickets SET status=? WHERE id=?", (new_status, ticket_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -2584,9 +2740,10 @@ def ticket_detail(ticket_id):
|
|||||||
license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES,
|
license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES,
|
||||||
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||||||
master_licensed=license_active(),
|
master_licensed=license_active(),
|
||||||
self_service_url=url_for("self_service", ticket_id=ticket_id, _external=True),
|
self_service_url=_customer_facing_url("self_service", ticket_id=ticket_id),
|
||||||
portal_url=url_for("customer_portal", token=customer["portal_token"], _external=True) if customer and customer["portal_token"] else None,
|
portal_request_url=_customer_facing_url("portal_request"),
|
||||||
can_edit=current_user.has_permission("licenses.edit"),
|
can_edit_ticket=current_user.has_permission("tickets.edit"),
|
||||||
|
can_issue_license=current_user.has_permission("licenses.edit"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2602,7 +2759,7 @@ def ticket_edit(ticket_id):
|
|||||||
hier bewusst nicht vorgesehen (das Self-Service-Ticket ist an genau
|
hier bewusst nicht vorgesehen (das Self-Service-Ticket ist an genau
|
||||||
diesen Kunden gebunden, u.a. weil er ueber dessen Portal-Token
|
diesen Kunden gebunden, u.a. weil er ueber dessen Portal-Token
|
||||||
erreichbar ist)."""
|
erreichbar ist)."""
|
||||||
if not current_user.has_permission("licenses.edit"):
|
if not current_user.has_permission("tickets.edit"):
|
||||||
flash("Keine Berechtigung, Tickets zu bearbeiten.", "danger")
|
flash("Keine Berechtigung, Tickets zu bearbeiten.", "danger")
|
||||||
return redirect(url_for("tickets_list"))
|
return redirect(url_for("tickets_list"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -2655,7 +2812,7 @@ def ticket_delete(ticket_id):
|
|||||||
System verschwinden). Etwaige bereits widerrufene/deaktivierte
|
System verschwinden). Etwaige bereits widerrufene/deaktivierte
|
||||||
Lizenzen darunter sind reine Historie und werden mitgeloescht, sonst
|
Lizenzen darunter sind reine Historie und werden mitgeloescht, sonst
|
||||||
blieben verwaiste Zeilen mit einer toten ticket_id zurueck."""
|
blieben verwaiste Zeilen mit einer toten ticket_id zurueck."""
|
||||||
if not current_user.has_permission("licenses.edit"):
|
if not current_user.has_permission("tickets.edit"):
|
||||||
flash("Keine Berechtigung, Tickets zu löschen.", "danger")
|
flash("Keine Berechtigung, Tickets zu löschen.", "danger")
|
||||||
return redirect(url_for("tickets_list"))
|
return redirect(url_for("tickets_list"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -2825,14 +2982,16 @@ def license_send_email(license_id):
|
|||||||
return redirect(url_for("license_detail", license_id=license_id))
|
return redirect(url_for("license_detail", license_id=license_id))
|
||||||
self_service_note = ""
|
self_service_note = ""
|
||||||
if row["ticket_id"]:
|
if row["ticket_id"]:
|
||||||
self_service_url = url_for("self_service", ticket_id=row["ticket_id"], _external=True)
|
self_service_url = _customer_facing_url("self_service", ticket_id=row["ticket_id"])
|
||||||
self_service_note = (
|
self_service_note = (
|
||||||
f"\nÜber diesen Link können Sie Ihre Lizenz jederzeit selbst herunterladen, "
|
f"\nÜber diesen Link können Sie Ihre Lizenz jederzeit selbst herunterladen, "
|
||||||
f"widerrufen oder (z.B. nach einem Systemwechsel) neu erstellen:\n{self_service_url}\n"
|
f"widerrufen oder (z.B. nach einem Systemwechsel) neu erstellen:\n{self_service_url}\n"
|
||||||
)
|
)
|
||||||
if customer["portal_token"]:
|
portal_request_url = _customer_facing_url("portal_request")
|
||||||
portal_url = url_for("customer_portal", token=customer["portal_token"], _external=True)
|
self_service_note += (
|
||||||
self_service_note += f"\nEine Übersicht all Ihrer Tickets/Lizenzen finden Sie hier:\n{portal_url}\n"
|
f"\nEine Übersicht all Ihrer Tickets/Lizenzen können Sie sich jederzeit unter folgendem Link per "
|
||||||
|
f"E-Mail zuschicken lassen:\n{portal_request_url}\n"
|
||||||
|
)
|
||||||
valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"]
|
valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"]
|
||||||
try:
|
try:
|
||||||
_graph_send_mail(
|
_graph_send_mail(
|
||||||
@@ -2927,7 +3086,7 @@ def self_service(ticket_id):
|
|||||||
license=_license_dict_for_display(current) if current else None,
|
license=_license_dict_for_display(current) if current else None,
|
||||||
can_issue_new=can_issue_new,
|
can_issue_new=can_issue_new,
|
||||||
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||||||
portal_url=url_for("customer_portal", token=customer["portal_token"]) if customer and customer["portal_token"] else None,
|
portal_request_url=url_for("portal_request"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2993,25 +3152,84 @@ def self_service_issue(ticket_id):
|
|||||||
return redirect(url_for("self_service", ticket_id=ticket_id))
|
return redirect(url_for("self_service", ticket_id=ticket_id))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/portal/<token>", methods=["GET", "POST"])
|
@app.route("/portal/request", methods=["GET", "POST"])
|
||||||
def customer_portal(token):
|
def portal_request():
|
||||||
"""Kundenweites Self-Service-Portal -- anders als /self-service/<ticket_id>
|
"""Oeffentlich erreichbar (kein Login): fuer einen Kunden, der seinen
|
||||||
(ein Magic-Link PRO TICKET) ist dieser Link PRO KUNDE: zeigt alle
|
Magic-Link verloren hat oder nie einen bekommen hat (z.B. weil er nur
|
||||||
eigenen Tickets samt aktueller Lizenz auf einen Blick und erlaubt dem
|
telefonisch beim Anbieter bekannt ist), gibt es sonst KEINEN Weg
|
||||||
Kunden, seine eigenen Kontakt-/Anschriftdaten selbst zu pflegen.
|
zurueck ins Kundenportal ausser einen Admin zu kontaktieren -- diese
|
||||||
Bewusst NICHT bearbeitbar: Firmenname sowie die kommerziellen
|
Seite schliesst die Luecke: E-Mail-Adresse eingeben, Link wird (falls
|
||||||
Ticket-Eckdaten (Typ/Module/Laufzeit) -- das bleibt Sache des Anbieters.
|
hinterlegt) zugeschickt. Werkzeug routet die literale Regel
|
||||||
Fuer die eigentlichen Lizenzaktionen (herunterladen/widerrufen/neu
|
/portal/request bevorzugt vor der dynamischen /portal/<token> weiter
|
||||||
erstellen) verlinkt jede Ticket-Zeile auf deren bestehende
|
unten -- keine Kollisionsgefahr mit einem Kunden, dessen Token
|
||||||
/self-service/<ticket_id>-Seite, statt die Logik hier zu duplizieren.
|
zufaellig 'request' laeutet waere ohnehin praktisch ausgeschlossen
|
||||||
Kein Login noetig -- Autorisierung ausschliesslich durch Besitz des
|
(192 Bit Zufall).
|
||||||
unerratbaren Tokens (siehe license_customers.portal_token)."""
|
|
||||||
conn = get_db_connection()
|
|
||||||
customer = conn.execute("SELECT * FROM license_customers WHERE portal_token=?", (token,)).fetchone()
|
|
||||||
if not customer:
|
|
||||||
conn.close()
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
|
Zeigt IMMER dieselbe generische Erfolgsmeldung, unabhaengig davon, ob
|
||||||
|
die eingegebene Adresse tatsaechlich zu einem Kunden gehoert --
|
||||||
|
verhindert, dass sich ueber die Antwort erraten laesst, welche
|
||||||
|
E-Mail-Adressen im System hinterlegt sind.
|
||||||
|
|
||||||
|
Bewusst OHNE dauerhaften Portal-Link (der wurde entfernt, genau weil
|
||||||
|
dieser Weg OEFFENTLICH von jedem auf blossen Zuruf einer E-Mail-
|
||||||
|
Adresse ausloesbar ist): stattdessen ein frischer, einmalig nutzbarer,
|
||||||
|
nur PORTAL_ACCESS_LINK_MINUTES gueltiger Zugangs-Token (siehe
|
||||||
|
portal_access())."""
|
||||||
|
if request.method == "POST":
|
||||||
|
email = request.form.get("email", "").strip()
|
||||||
|
if email:
|
||||||
|
conn = get_db_connection()
|
||||||
|
customer = conn.execute(
|
||||||
|
"SELECT * FROM license_customers WHERE contact_email IS NOT NULL AND lower(contact_email) = lower(?)",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if customer:
|
||||||
|
now = datetime.now()
|
||||||
|
grant_token = secrets.token_urlsafe(32)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO portal_access_tokens (token, customer_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(grant_token, customer["id"], now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
(now + timedelta(minutes=PORTAL_ACCESS_LINK_MINUTES)).strftime("%Y-%m-%d %H:%M:%S")),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
access_url = _customer_facing_url("portal_access", token=grant_token)
|
||||||
|
try:
|
||||||
|
_graph_send_mail(
|
||||||
|
customer["contact_email"],
|
||||||
|
"Ihr Zugang zum TESM-Kundenportal",
|
||||||
|
"Hallo,\n\n"
|
||||||
|
"über folgenden Link erreichen Sie für kurze Zeit eine Übersicht Ihrer Tickets und Lizenzen "
|
||||||
|
"und können Ihre Kontaktdaten selbst pflegen:\n"
|
||||||
|
f"{access_url}\n\n"
|
||||||
|
f"Dieser Link ist nur {PORTAL_ACCESS_LINK_MINUTES} Minuten lang gültig und danach nicht "
|
||||||
|
"mehr nutzbar -- falls er abläuft, fordern Sie bitte einfach einen neuen an. "
|
||||||
|
"Bitte nicht an Dritte weitergeben.",
|
||||||
|
)
|
||||||
|
log_action_system("customer.portal_link_requested", customer["name"])
|
||||||
|
except Exception:
|
||||||
|
# Absichtlich NICHT an den Nutzer durchgereicht (siehe Docstring,
|
||||||
|
# Anti-Enumeration) -- aber fuer den Admin sichtbar loggen, sonst
|
||||||
|
# merkt niemand, dass z.B. Microsoft Graph nicht konfiguriert ist.
|
||||||
|
app.logger.error("Versand des Kundenportal-Links fehlgeschlagen:\n%s", traceback.format_exc())
|
||||||
|
conn.close()
|
||||||
|
flash(
|
||||||
|
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit einem "
|
||||||
|
f"{PORTAL_ACCESS_LINK_MINUTES} Minuten gültigen Zugangslink verschickt.",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return redirect(url_for("portal_request"))
|
||||||
|
return render_template("portal_request.html")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_customer_portal(customer):
|
||||||
|
"""GET/POST-Logik des Kundenportals -- Zugriff ausschliesslich ueber
|
||||||
|
die zeitlich begrenzte Session nach einem frisch angeforderten
|
||||||
|
Magic-Link (siehe portal_request()/portal_access()/
|
||||||
|
customer_portal_session()). Es gibt bewusst KEINEN dauerhaften,
|
||||||
|
direkt aufrufbaren Portal-Link mehr -- `customer` ist hier bereits
|
||||||
|
als authentisch geprueft (Aufrufer hat eine gueltige Session
|
||||||
|
verifiziert), diese Funktion selbst prueft nichts mehr davon."""
|
||||||
|
conn = get_db_connection()
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
contact_email = request.form.get("contact_email", "").strip()
|
contact_email = request.form.get("contact_email", "").strip()
|
||||||
if not _EMAIL_RE.match(contact_email):
|
if not _EMAIL_RE.match(contact_email):
|
||||||
@@ -3028,7 +3246,7 @@ def customer_portal(token):
|
|||||||
log_action_system("customer.update", customer["name"], "Kunde hat eigene Kontaktdaten im Portal aktualisiert")
|
log_action_system("customer.update", customer["name"], "Kunde hat eigene Kontaktdaten im Portal aktualisiert")
|
||||||
flash("Ihre Daten wurden aktualisiert.", "success")
|
flash("Ihre Daten wurden aktualisiert.", "success")
|
||||||
conn.close()
|
conn.close()
|
||||||
return redirect(url_for("customer_portal", token=token))
|
return redirect(url_for("customer_portal_session"))
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
tickets_view = []
|
tickets_view = []
|
||||||
@@ -3045,6 +3263,81 @@ def customer_portal(token):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PORTAL_ACCESS_LINK_MINUTES = 30
|
||||||
|
PORTAL_SESSION_MINUTES = 60
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/portal/access/<token>")
|
||||||
|
def portal_access(token):
|
||||||
|
"""Loest den kurzlebigen, EINMAL nutzbaren Link aus einer per
|
||||||
|
portal_request() angeforderten E-Mail ein -- gueltig fuer
|
||||||
|
PORTAL_ACCESS_LINK_MINUTES ab Anforderung, danach oder nach dem
|
||||||
|
ersten Aufruf nicht mehr nutzbar. Bei Erfolg wird eine Session
|
||||||
|
gesetzt, die ihrerseits nach PORTAL_SESSION_MINUTES ohne neue
|
||||||
|
Anforderung automatisch ablaeuft (siehe customer_portal_session()) --
|
||||||
|
zwei getrennte Zeitfenster: eines fuer den Link selbst (muss zeitnah
|
||||||
|
angeklickt werden), eines fuer die resultierende Sitzung (muss danach
|
||||||
|
zeitnah genutzt werden)."""
|
||||||
|
conn = get_db_connection()
|
||||||
|
grant = conn.execute("SELECT * FROM portal_access_tokens WHERE token=?", (token,)).fetchone()
|
||||||
|
valid = bool(grant) and not grant["used_at"]
|
||||||
|
if valid:
|
||||||
|
try:
|
||||||
|
valid = datetime.now() < datetime.strptime(grant["expires_at"], "%Y-%m-%d %H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
valid = False
|
||||||
|
if not valid:
|
||||||
|
conn.close()
|
||||||
|
flash("Dieser Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.", "danger")
|
||||||
|
return redirect(url_for("portal_request"))
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE portal_access_tokens SET used_at=? WHERE token=?",
|
||||||
|
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), token),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
customer = _customer_row(conn, grant["customer_id"])
|
||||||
|
conn.close()
|
||||||
|
if not customer:
|
||||||
|
flash("Dieser Zugang ist nicht mehr gültig.", "danger")
|
||||||
|
return redirect(url_for("portal_request"))
|
||||||
|
session["portal_customer_id"] = customer["id"]
|
||||||
|
session["portal_session_expires_at"] = (
|
||||||
|
datetime.now() + timedelta(minutes=PORTAL_SESSION_MINUTES)
|
||||||
|
).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
log_action_system("customer.portal_link_used", customer["name"])
|
||||||
|
return redirect(url_for("customer_portal_session"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/portal/me", methods=["GET", "POST"])
|
||||||
|
def customer_portal_session():
|
||||||
|
"""Session-basierter Kundenportal-Zugang nach einem frisch
|
||||||
|
angeforderten Magic-Link (portal_request() -> portal_access()) --
|
||||||
|
laeuft PORTAL_SESSION_MINUTES nach dem Anklicken des Links automatisch
|
||||||
|
ab, im Unterschied zum dauerhaften /portal/<token> (siehe dortiger
|
||||||
|
Docstring)."""
|
||||||
|
customer_id = session.get("portal_customer_id")
|
||||||
|
expires_at_str = session.get("portal_session_expires_at")
|
||||||
|
valid = False
|
||||||
|
if customer_id and expires_at_str:
|
||||||
|
try:
|
||||||
|
valid = datetime.now() < datetime.strptime(expires_at_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
valid = False
|
||||||
|
if not valid:
|
||||||
|
session.pop("portal_customer_id", None)
|
||||||
|
session.pop("portal_session_expires_at", None)
|
||||||
|
flash("Ihre Sitzung ist abgelaufen -- bitte fordern Sie einen neuen Zugangslink an.", "danger")
|
||||||
|
return redirect(url_for("portal_request"))
|
||||||
|
conn = get_db_connection()
|
||||||
|
customer = _customer_row(conn, customer_id)
|
||||||
|
conn.close()
|
||||||
|
if not customer:
|
||||||
|
session.pop("portal_customer_id", None)
|
||||||
|
session.pop("portal_session_expires_at", None)
|
||||||
|
abort(404)
|
||||||
|
return _render_customer_portal(customer)
|
||||||
|
|
||||||
|
|
||||||
def _audit_archive_loop():
|
def _audit_archive_loop():
|
||||||
"""Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob
|
"""Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob
|
||||||
die audit_log-Tabelle den Schwellenwert überschritten hat, und
|
die audit_log-Tabelle den Schwellenwert überschritten hat, und
|
||||||
@@ -3417,6 +3710,7 @@ def inject_license_topbar():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
"""Dashboard des Master-Lizenzservers -- eine rein informative Kachel-
|
"""Dashboard des Master-Lizenzservers -- eine rein informative Kachel-
|
||||||
uebersicht der gerade AKTIVEN Lizenzen (analog zur Geraete-Kachel-
|
uebersicht der gerade AKTIVEN Lizenzen (analog zur Geraete-Kachel-
|
||||||
@@ -3426,8 +3720,15 @@ def index():
|
|||||||
mit status='active' -- alles andere (Tickets anlegen/bearbeiten/
|
mit status='active' -- alles andere (Tickets anlegen/bearbeiten/
|
||||||
loeschen, Lizenzen ausstellen/widerrufen/loeschen) ist Verwaltung und
|
loeschen, Lizenzen ausstellen/widerrufen/loeschen) ist Verwaltung und
|
||||||
lebt seit der Kachel-Umstellung auf der eigenen Tickets-Seite
|
lebt seit der Kachel-Umstellung auf der eigenen Tickets-Seite
|
||||||
(tickets_list())."""
|
(tickets_list()).
|
||||||
if not current_user.is_authenticated or not current_user.has_permission("licenses.view"):
|
|
||||||
|
@login_required (anders als bei TESM selbst, wo das Geraete-Dashboard
|
||||||
|
bewusst auch ohne Login als reines Status-Board sichtbar bleibt): der
|
||||||
|
Lizenzserver ist reines internes Verwaltungswerkzeug -- ein anonymer
|
||||||
|
Besuch auf '/' soll immer zuerst auf die Login-Seite treffen (via
|
||||||
|
login_manager.login_view), nicht auf einen 'bitte anmelden'-Platzhalter
|
||||||
|
innerhalb der Dashboard-Huelle."""
|
||||||
|
if not current_user.has_permission("licenses.view"):
|
||||||
return render_template("index.html", buckets=None)
|
return render_template("index.html", buckets=None)
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
active_rows = conn.execute("""
|
active_rows = conn.execute("""
|
||||||
@@ -3463,7 +3764,7 @@ def tickets_list():
|
|||||||
Dashboards auf eine rein informative Kacheluebersicht (siehe index())
|
Dashboards auf eine rein informative Kacheluebersicht (siehe index())
|
||||||
lebt die eigentliche Verwaltung hier, eigenstaendig ueber die
|
lebt die eigentliche Verwaltung hier, eigenstaendig ueber die
|
||||||
Navigation erreichbar."""
|
Navigation erreichbar."""
|
||||||
if not current_user.has_permission("licenses.view"):
|
if not current_user.has_permission("tickets.view"):
|
||||||
flash("Keine Berechtigung, Tickets anzusehen.", "danger")
|
flash("Keine Berechtigung, Tickets anzusehen.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -3496,7 +3797,55 @@ def tickets_list():
|
|||||||
return render_template(
|
return render_template(
|
||||||
"tickets.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
"tickets.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||||||
all_modules=licensing.ALL_MODULES, license_types=licensing.LICENSE_TYPES,
|
all_modules=licensing.ALL_MODULES, license_types=licensing.LICENSE_TYPES,
|
||||||
can_create=current_user.has_permission("licenses.create"),
|
can_create=current_user.has_permission("tickets.create"),
|
||||||
|
can_edit=current_user.has_permission("tickets.edit"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/licenses")
|
||||||
|
@login_required
|
||||||
|
def licenses_list():
|
||||||
|
"""Eigenstaendige Lizenzuebersicht -- zeigt ALLE ausgestellten Lizenzen
|
||||||
|
flach in einer Tabelle (statt nur ueber die Historie eines einzelnen
|
||||||
|
Tickets erreichbar zu sein). Ergaenzt Kunden/Tickets/Lizenzen zu drei
|
||||||
|
gleichberechtigten, direkt ueber die Navigation erreichbaren Ebenen --
|
||||||
|
vorher war eine Lizenz nur durch Kunde -> gefilterte Tickets -> Ticket-
|
||||||
|
Detail -> eingebettete Historie auffindbar. Optional gefiltert nach
|
||||||
|
?ticket_id=... oder ?customer_id=... (von Ticket- bzw. Kundenseite aus
|
||||||
|
verlinkt)."""
|
||||||
|
if not current_user.has_permission("licenses.view"):
|
||||||
|
flash("Keine Berechtigung, Lizenzen anzusehen.", "danger")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
conn = get_db_connection()
|
||||||
|
filter_ticket_id = request.args.get("ticket_id")
|
||||||
|
filter_customer_id = request.args.get("customer_id", type=int)
|
||||||
|
query = """
|
||||||
|
SELECT licenses.*, license_customers.name AS customer_name
|
||||||
|
FROM licenses
|
||||||
|
JOIN license_customers ON license_customers.id = licenses.customer_id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
if filter_ticket_id:
|
||||||
|
query += " AND licenses.ticket_id = ?"
|
||||||
|
params.append(filter_ticket_id)
|
||||||
|
if filter_customer_id:
|
||||||
|
query += " AND licenses.customer_id = ?"
|
||||||
|
params.append(filter_customer_id)
|
||||||
|
query += " ORDER BY licenses.created_at DESC"
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
now = time.time()
|
||||||
|
licenses_view = []
|
||||||
|
for row in rows:
|
||||||
|
d = _license_dict_for_display(row, now)
|
||||||
|
d["customer_name"] = row["customer_name"]
|
||||||
|
licenses_view.append(d)
|
||||||
|
filter_ticket = _ticket_row(conn, filter_ticket_id) if filter_ticket_id else None
|
||||||
|
filter_customer = _customer_row(conn, filter_customer_id) if filter_customer_id else None
|
||||||
|
conn.close()
|
||||||
|
return render_template(
|
||||||
|
"licenses.html", licenses=licenses_view, type_labels=LICENSE_TYPE_LABELS,
|
||||||
|
filter_ticket=filter_ticket, filter_customer=filter_customer,
|
||||||
can_edit=current_user.has_permission("licenses.edit"),
|
can_edit=current_user.has_permission("licenses.edit"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4439,7 +4788,7 @@ def settings():
|
|||||||
@app.route("/settings/license")
|
@app.route("/settings/license")
|
||||||
@login_required
|
@login_required
|
||||||
def settings_license():
|
def settings_license():
|
||||||
if not current_user.can_view_settings_system:
|
if not current_user.can_view_settings_license:
|
||||||
flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
|
flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
pending_raw = get_setting("license_pending_request", "")
|
pending_raw = get_setting("license_pending_request", "")
|
||||||
@@ -4462,7 +4811,7 @@ def settings_license():
|
|||||||
@app.route("/settings/license/upload", methods=["POST"])
|
@app.route("/settings/license/upload", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def license_upload():
|
def license_upload():
|
||||||
if not current_user.has_permission("settings_system.edit"):
|
if not current_user.has_permission("settings_license.edit"):
|
||||||
flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
|
flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
|
||||||
return redirect(url_for("settings_license"))
|
return redirect(url_for("settings_license"))
|
||||||
uploaded = request.files.get("license_file")
|
uploaded = request.files.get("license_file")
|
||||||
@@ -4555,7 +4904,7 @@ def _license_handshake_confirm(action, success_setting_fn, success_log_action, s
|
|||||||
@app.route("/settings/license/activate", methods=["POST"])
|
@app.route("/settings/license/activate", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def license_activate():
|
def license_activate():
|
||||||
if not current_user.has_permission("settings_system.edit"):
|
if not current_user.has_permission("settings_license.edit"):
|
||||||
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
||||||
return redirect(url_for("settings_license"))
|
return redirect(url_for("settings_license"))
|
||||||
if not _license_state.get("file") or not _license_state.get("valid"):
|
if not _license_state.get("file") or not _license_state.get("valid"):
|
||||||
@@ -4572,7 +4921,7 @@ def license_activate():
|
|||||||
@app.route("/settings/license/activate/confirm", methods=["POST"])
|
@app.route("/settings/license/activate/confirm", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def license_activate_confirm():
|
def license_activate_confirm():
|
||||||
if not current_user.has_permission("settings_system.edit"):
|
if not current_user.has_permission("settings_license.edit"):
|
||||||
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
||||||
return redirect(url_for("settings_license"))
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
@@ -4586,7 +4935,7 @@ def license_activate_confirm():
|
|||||||
@app.route("/settings/license/deactivate", methods=["POST"])
|
@app.route("/settings/license/deactivate", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def license_deactivate():
|
def license_deactivate():
|
||||||
if not current_user.has_permission("settings_system.edit"):
|
if not current_user.has_permission("settings_license.edit"):
|
||||||
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
||||||
return redirect(url_for("settings_license"))
|
return redirect(url_for("settings_license"))
|
||||||
if not _license_state.get("file") or not license_activated():
|
if not _license_state.get("file") or not license_activated():
|
||||||
@@ -4606,7 +4955,7 @@ def license_deactivate():
|
|||||||
@app.route("/settings/license/deactivate/confirm", methods=["POST"])
|
@app.route("/settings/license/deactivate/confirm", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def license_deactivate_confirm():
|
def license_deactivate_confirm():
|
||||||
if not current_user.has_permission("settings_system.edit"):
|
if not current_user.has_permission("settings_license.edit"):
|
||||||
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
||||||
return redirect(url_for("settings_license"))
|
return redirect(url_for("settings_license"))
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,17 @@ CREATE TABLE IF NOT EXISTS license_tickets (
|
|||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS portal_access_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5"))
|
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",))
|
cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",))
|
||||||
|
|||||||
@@ -31,10 +31,11 @@
|
|||||||
<th data-sort-key="address">Anschrift</th>
|
<th data-sort-key="address">Anschrift</th>
|
||||||
<th data-sort-key="email">Kontakt</th>
|
<th data-sort-key="email">Kontakt</th>
|
||||||
<th data-sort-key="tickets">Tickets</th>
|
<th data-sort-key="tickets">Tickets</th>
|
||||||
|
<th data-sort-key="licenses">Lizenzen</th>
|
||||||
<th style="width:1%;">Aktionen</th>
|
<th style="width:1%;">Aktionen</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
{% for c in customers %}
|
{% for c in customers %}
|
||||||
<tbody data-sort-name="{{ c.name|lower }}" data-sort-address="{{ (c.city or '')|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}">
|
<tbody data-sort-name="{{ c.name|lower }}" data-sort-address="{{ (c.city or '')|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}" data-sort-licenses="{{ c.license_count }}">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="cell-name">{{ c.name }}</td>
|
<td class="cell-name">{{ c.name }}</td>
|
||||||
<td class="text-dim">
|
<td class="text-dim">
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
{% if c.contact_person %}<br><span class="text-faint" style="font-size:11px;">{{ c.contact_person }}</span>{% endif %}
|
{% if c.contact_person %}<br><span class="text-faint" style="font-size:11px;">{{ c.contact_person }}</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-dim"><a href="{{ url_for('tickets_list', customer_id=c.id) }}">{{ c.ticket_count }}</a></td>
|
<td class="text-dim"><a href="{{ url_for('tickets_list', customer_id=c.id) }}">{{ c.ticket_count }}</a></td>
|
||||||
|
<td class="text-dim"><a href="{{ url_for('licenses_list', customer_id=c.id) }}">{{ c.license_count }}</a></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
@@ -73,7 +75,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tbody data-sort-pinned>
|
<tbody data-sort-pinned>
|
||||||
<tr class="empty-row"><td colspan="5">Noch keine Kunden angelegt.</td></tr>
|
<tr class="empty-row"><td colspan="6">Noch keine Kunden angelegt.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
@@ -130,12 +132,6 @@
|
|||||||
<label>Notizen</label>
|
<label>Notizen</label>
|
||||||
<textarea name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{{ c.notes or '' }}</textarea>
|
<textarea name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{{ c.notes or '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
{% if c.portal_token %}
|
|
||||||
<div class="field">
|
|
||||||
<label>Kundenportal-Link (alle Tickets)</label>
|
|
||||||
<input type="text" class="mono" value="{{ url_for('customer_portal', token=c.portal_token, _external=True) }}" readonly onclick="this.select()">
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "licenses" %}
|
||||||
|
{% block page_title %}Lizenzen{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Alle ausgestellten Lizenzen{% if filter_customer %} -- {{ filter_customer.name }}{% elif filter_ticket %} -- Ticket{% endif %}</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Lizenzen</h2>
|
||||||
|
<div class="hint">
|
||||||
|
Jede Zeile ist eine konkrete, signierte Lizenzdatei -- ausgestellt aus einem Ticket (siehe <a href="{{ url_for('tickets_list') }}" style="color:var(--accent-strong); font-weight:600;">Tickets</a>). Herunterladen, per E-Mail versenden oder widerrufen geschieht auf der Detailseite.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if filter_ticket or filter_customer %}
|
||||||
|
<div class="notice-banner" style="margin-bottom:14px;">
|
||||||
|
Gefiltert nach {% if filter_customer %}Kunde „{{ filter_customer.name }}“{% endif %}{% if filter_ticket %}Ticket{% endif %} --
|
||||||
|
<a href="{{ url_for('licenses_list') }}">Filter entfernen</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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>Lizenz-ID</th>
|
||||||
|
<th data-sort-key="type">Typ</th>
|
||||||
|
<th data-sort-key="issued">Ausgestellt</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-issued="{{ l.issued_at }}" 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 class="mono text-dim">{{ l.license_id[:8] }}…</td>
|
||||||
|
<td>{{ type_labels.get(l.type, l.type) }}</td>
|
||||||
|
<td class="text-dim">{{ l.issued_at[:10] }}</td>
|
||||||
|
<td class="text-dim">
|
||||||
|
{% if l.is_lifetime %}Lifetime
|
||||||
|
{% else %}
|
||||||
|
{{ l.expires_at[:10] }}
|
||||||
|
{% if l.status == 'active' %}
|
||||||
|
{% if l.is_expired %}<span style="color:var(--danger);">(abgelaufen)</span>
|
||||||
|
{% elif l.color in ('red', 'orange') %}<span style="color:{{ 'var(--danger)' if l.color == 'red' else 'var(--warning)' }};">({{ l.days_left }} Tage)</span>
|
||||||
|
{% endif %}
|
||||||
|
{% 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>
|
||||||
|
{% if can_edit %}
|
||||||
|
<a class="icon-btn" title="Herunterladen" href="{{ url_for('license_download', 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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
</a>
|
||||||
|
{% if l.status not in ('revoked',) %}
|
||||||
|
<form method="post" action="{{ url_for('license_revoke', license_id=l.license_id) }}"
|
||||||
|
data-confirm="Lizenz wirklich widerrufen?">
|
||||||
|
<button type="submit" class="icon-btn" title="Widerrufen">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if l.status in ('revoked', 'issued') %}
|
||||||
|
<form method="post" action="{{ url_for('license_delete', license_id=l.license_id) }}"
|
||||||
|
data-confirm="Lizenz wirklich löschen? Das zugehörige Ticket bleibt bestehen.">
|
||||||
|
<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 %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
{% else %}
|
||||||
|
<tbody data-sort-pinned>
|
||||||
|
<tr class="empty-row"><td colspan="8">Noch keine Lizenz ausgestellt.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function filterLicensesTable() {
|
||||||
|
const q = document.getElementById("licenseSearch").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 %}
|
||||||
@@ -37,7 +37,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button>
|
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
<div style="margin-top:16px; font-size:10.5px; color:var(--text-faint); text-align:center;">TESM-Lizenzserver v{{ tesm_version }}</div>
|
<div style="margin-top:16px; font-size:11px; text-align:center;">
|
||||||
|
Kunde? <a href="{{ url_for('portal_request') }}">Zugang zu meinem Konto anfordern</a>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px; font-size:10.5px; color:var(--text-faint); text-align:center;">TESM-Lizenzserver v{{ tesm_version }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Zugang anfordern · 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">
|
||||||
|
<img src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM-Lizenzserver" id="login-bg-logo">
|
||||||
|
</div>
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>Zugang zu meinem Konto</h1>
|
||||||
|
<div class="hint" style="text-align:center; margin-bottom:18px;">
|
||||||
|
Für Kunden: Link zum eigenen Kundenportal erneut zuschicken lassen.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="field" style="margin-bottom:18px;">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="toast success" style="position:static; animation:none;">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="post" data-no-unsaved-guard>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Ihre E-Mail-Adresse</label>
|
||||||
|
<input type="email" id="email" name="email" autocomplete="email" required autofocus>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Link zusenden</button>
|
||||||
|
</form>
|
||||||
|
<div style="margin-top:16px; font-size:11px; text-align:center;">
|
||||||
|
<a href="{{ url_for('login') }}">← Zurück zum Login</a>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px; font-size:10.5px; color:var(--text-faint); text-align:center;">TESM-Lizenzserver</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ asset_url('js/app.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<h1>Meine Lizenz</h1>
|
<h1>Meine Lizenz</h1>
|
||||||
<div class="hint" style="text-align:center; margin-bottom:18px;">
|
<div class="hint" style="text-align:center; margin-bottom:18px;">
|
||||||
{{ customer.name if customer else '' }}
|
{{ customer.name if customer else '' }}
|
||||||
{% if portal_url %}<br><a href="{{ portal_url }}">← Alle meine Tickets</a>{% endif %}
|
<br><a href="{{ portal_request_url }}">← Alle meine Tickets anzeigen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages() %}
|
{% with messages = get_flashed_messages() %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "settings_license" %}
|
{% set active_page = "settings_license" %}
|
||||||
{% set can_edit = current_user.has_permission('settings_system.edit') %}
|
{% set can_edit = current_user.has_permission('settings_license.edit') %}
|
||||||
{% block page_title %}Lizenz{% endblock %}
|
{% block page_title %}Lizenz{% endblock %}
|
||||||
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,12 @@
|
|||||||
<label>Self-Service-Link (dieses Ticket) {{ hi.hint_icon("Diesen Link an den Kunden weitergeben -- damit kann er seine Lizenz selbst herunterladen, widerrufen und (mit diesen Eckdaten) neu erstellen, ohne Login.", "Self-Service-Link") }}</label>
|
<label>Self-Service-Link (dieses Ticket) {{ hi.hint_icon("Diesen Link an den Kunden weitergeben -- damit kann er seine Lizenz selbst herunterladen, widerrufen und (mit diesen Eckdaten) neu erstellen, ohne Login.", "Self-Service-Link") }}</label>
|
||||||
<input type="text" class="mono" value="{{ self_service_url }}" readonly onclick="this.select()">
|
<input type="text" class="mono" value="{{ self_service_url }}" readonly onclick="this.select()">
|
||||||
</div>
|
</div>
|
||||||
{% if portal_url %}
|
<div class="hint" style="margin-top:6px;">
|
||||||
<div class="field">
|
Übersicht über ALLE Tickets des Kunden {{ hi.hint_icon("Es gibt bewusst keinen dauerhaften Kundenportal-Link mehr -- der Kunde fordert sich unter " ~ portal_request_url ~ " bei Bedarf selbst einen zeitlich begrenzten Zugang an.", "Kundenportal") }}:
|
||||||
<label>Kundenportal-Link (alle Tickets des Kunden) {{ hi.hint_icon("Zeigt dem Kunden ALLE seine Tickets samt Lizenzstatus und erlaubt ihm, seine Kontaktdaten selbst zu pflegen.", "Kundenportal-Link") }}</label>
|
der Kunde fordert ihn selbst über <a href="{{ portal_request_url }}" target="_blank" rel="noopener">{{ portal_request_url }}</a> an (zeitlich begrenzter Zugang, keine dauerhafte Freigabe).
|
||||||
<input type="text" class="mono" value="{{ portal_url }}" readonly onclick="this.select()">
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit_ticket %}
|
||||||
<div class="flex gap-2" style="margin-top:14px;">
|
<div class="flex gap-2" style="margin-top:14px;">
|
||||||
<button type="button" class="btn btn-secondary" style="flex:1;" data-open-modal="editTicketModal">
|
<button type="button" class="btn btn-secondary" style="flex:1;" data-open-modal="editTicketModal">
|
||||||
<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>
|
<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>
|
||||||
@@ -71,7 +69,7 @@
|
|||||||
Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann
|
Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann
|
||||||
eine neue erstellt werden.
|
eine neue erstellt werden.
|
||||||
</p>
|
</p>
|
||||||
{% elif can_edit %}
|
{% elif can_issue_license %}
|
||||||
<form method="post" style="margin-bottom:10px;">
|
<form method="post" style="margin-bottom:10px;">
|
||||||
<input type="hidden" name="issue_license" value="regular">
|
<input type="hidden" name="issue_license" value="regular">
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
@@ -88,49 +86,33 @@
|
|||||||
|
|
||||||
<div class="card card-pad" style="grid-column: 1 / -1;">
|
<div class="card card-pad" style="grid-column: 1 / -1;">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div><h2 style="font-size:16px;">Lizenzen zu diesem Ticket</h2></div>
|
<div><h2 style="font-size:16px;">Aktuelle Lizenz</h2></div>
|
||||||
|
<a href="{{ url_for('licenses_list', ticket_id=ticket.id) }}" class="btn btn-secondary">
|
||||||
|
Alle Lizenzen dieses Tickets →
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-x:auto;">
|
{% if licenses %}
|
||||||
<table class="data-table">
|
{% set l = licenses[0] %}
|
||||||
<thead><tr><th>Lizenz-ID</th><th>Typ</th><th>Ablauf</th><th>Status</th><th style="width:1%;">Aktionen</th></tr></thead>
|
<div class="flex gap-2" style="align-items:center; flex-wrap:wrap;">
|
||||||
{% for l in licenses %}
|
<span class="mono text-dim">{{ l.license_id[:8] }}…</span>
|
||||||
<tbody>
|
<span>{{ type_labels.get(l.type, l.type) }}</span>
|
||||||
<tr>
|
<span class="text-dim">{{ 'Lifetime' if l.is_lifetime else 'bis ' ~ l.expires_at[:10] }}</span>
|
||||||
<td class="mono text-dim">{{ l.license_id[:8] }}…</td>
|
|
||||||
<td>{{ type_labels.get(l.type, l.type) }}</td>
|
|
||||||
<td class="text-dim">{{ 'Lifetime' if l.is_lifetime else l.expires_at[:10] }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(l.status, 'disabled') }}">
|
<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) }}
|
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
<a href="{{ url_for('license_detail', license_id=l.license_id) }}" class="btn btn-sm btn-secondary">Details →</a>
|
||||||
<td>
|
</div>
|
||||||
<div class="row-actions">
|
{% if licenses|length > 1 %}
|
||||||
<a class="icon-btn" title="Details" href="{{ url_for('license_detail', license_id=l.license_id) }}">
|
<div class="hint" style="margin-top:10px;">{{ licenses|length - 1 }} weitere ältere Lizenz{{ 'en' if licenses|length > 2 else '' }} aus diesem Ticket -- siehe „Alle Lizenzen dieses Tickets“ oben.</div>
|
||||||
<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>
|
|
||||||
{% if can_edit and l.status in ('revoked', 'issued') %}
|
|
||||||
<form method="post" action="{{ url_for('license_delete', license_id=l.license_id) }}"
|
|
||||||
data-confirm="Lizenz wirklich löschen? Das Ticket bleibt bestehen -- eine neue Lizenz mit denselben Eckdaten kann jederzeit daraus erstellt werden.">
|
|
||||||
<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 %}
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<tbody><tr class="empty-row"><td colspan="5">Noch keine Lizenz aus diesem Ticket erstellt.</td></tr></tbody>
|
<p class="text-faint" style="font-size:12.5px;">Noch keine Lizenz aus diesem Ticket erstellt.</p>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit_ticket %}
|
||||||
<div class="modal-overlay" id="editTicketModal">
|
<div class="modal-overlay" id="editTicketModal">
|
||||||
<div class="modal" style="max-width:520px;">
|
<div class="modal" style="max-width:520px;">
|
||||||
<form method="post" action="{{ url_for('ticket_edit', ticket_id=ticket.id) }}">
|
<form method="post" action="{{ url_for('ticket_edit', ticket_id=ticket.id) }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user