Compare commits

5 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 286a3464f1 Eigenes Recht fuer Tickets (tickets.view/.create/.edit)
Tickets liefen im Rechtebaum komplett ueber licenses.view/.create/.edit
mit, obwohl Tickets ein eigener, gleichberechtigter Navigationspunkt
neben Kunden und Lizenzen ist -- analog zu TESM, wo Clients/Switche/
Zugangsdaten trotz enger fachlicher Verwandtschaft jeweils eigene
Rechte haben. Ergaenzt die im vorigen Release (v1.2.4) behobene
Lizenz-Rechte-Luecke um diese zweite, gleich gelagerte Luecke.

- Neues Rechtepaar tickets.view/tickets.create/tickets.edit
  (PERMISSIONS/PERMISSION_LABELS), Property can_manage_tickets.
- Nav-Sichtbarkeit sowie tickets_list()/ticket_new()/ticket_edit()/
  ticket_delete() auf das neue Recht umgestellt.
- ticket_detail(): GET-Zugriff jetzt ueber can_manage_tickets. POST
  jetzt PRO SUB-AKTION geprueft statt eines einzigen Gates:
  'toggle_block' (Sperren/Entsperren) -> tickets.edit (neu, Ticket-
  Domain); 'issue_license' (erstellt eine LIZENZ) bleibt bewusst
  unveraendert an licenses.edit gebunden, damit sich 'Tickets
  verwalten' und 'Lizenzen ausstellen' unabhaengig voneinander
  vergeben lassen, ohne dass irgendjemand automatisch die eine
  Faehigkeit durch die andere verliert.
- tickets.html/ticket_detail.html: can_edit-Variable entsprechend in
  can_edit_ticket/can_issue_license aufgeteilt.
- Migration (_migrated_tickets_permission_v1, einmalig geguarded):
  jede Gruppe mit licenses.view/.create/.edit bekommt automatisch das
  gespiegelte tickets.view/.create/.edit dazu -- niemand verliert beim
  Update stillschweigend eine Faehigkeit, die vorher implizit ueber
  licenses.* mitlief.
- Getestet gegen isolierte Test-DB: Backfill korrekt (Gruppe mit voller
  Lizenz-Berechtigung bekommt volle Ticket-Berechtigung, Gruppe mit nur
  .view bekommt nur tickets.view), idempotent, keine Duplikate.
  PERMISSIONS/PERMISSION_LABELS/NAV_ITEMS erneut vollstaendig
  konsistent (keine Nav-Keys mehr ohne Rechte-Entsprechung), app.py
  syntaktisch fehlerfrei kompiliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:33:33 +02:00
alientimandClaude Sonnet 5 619673ac76 Eigenes Recht fuer die Lizenzseite (settings_license.view/.edit)
Die Lizenzseite (Einstellungen -> Lizenz) hatte im Rechtebaum keinen
eigenen Eintrag -- sie lief komplett ueber settings_system.view/.edit
mit, genau wie im TESM-Client (siehe dortiger Fix). Damit konnte
'Lizenz verwalten' nicht unabhaengig von 'Systemeinstellungen
verwalten' vergeben/eingeschraenkt werden.

- Neues Rechtepaar settings_license.view/settings_license.edit
  (PERMISSIONS/PERMISSION_LABELS), Property can_view_settings_license.
- Nav-Sichtbarkeit und alle /settings/license*-Routen auf das neue
  Recht umgestellt (view-Route: can_view_settings_license; alle
  mutierenden Routen -upload/-activate(/confirm)/-deactivate(/confirm):
  settings_license.edit). Das 'auch ohne aktive Lizenz sichtbar
  bleiben'-Verhalten bleibt unveraendert (andere Pruefung, license_active()).
- settings_license.html: can_edit auf settings_license.edit umgestellt.
- Migration (_migrated_settings_license_permission_v1, einmalig
  geguarded): jede Gruppe mit settings_system.view/.edit bekommt
  automatisch settings_license.view/.edit dazu -- niemand verliert beim
  Update stillschweigend Zugriff, den er vorher implizit hatte.
- Getestet gegen isolierte Test-DB (create_db.py-Frischinstallation):
  Migration korrekt (inkl. Gruppe mit nur .view, die korrekt KEIN .edit
  bekommt), idempotent bei zweitem Lauf, keine Duplikate. PERMISSIONS/
  PERMISSION_LABELS/NAV_ITEMS strukturell konsistent geprueft, app.py
  syntaktisch fehlerfrei kompiliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:11:02 +02:00
alientimandClaude Sonnet 5 20abadb0e7 Dashboard erfordert Login, Magic-Links nutzen konfigurierten Endpunkt statt IP (v1.2.3)
## Dashboard nur noch intern

'/' (das Kachel-Dashboard) verlangt jetzt @login_required -- ein anonymer
Besuch landet zuerst auf der Login-Seite (ueber Flask-Logins bereits
konfigurierten login_manager.login_view), statt innerhalb der Dashboard-
Huelle einen 'bitte anmelden'-Platzhalter zu zeigen. Anders als bei TESM
selbst (dessen Geraete-Dashboard bewusst auch ohne Login als Status-Board
sichtbar bleibt) ist der Lizenzserver ein reines internes Verwaltungs-
werkzeug.

## Magic-Links zeigen den konfigurierten Lizenzserver-Endpunkt, nicht die IP

Self-Service-Link, Kundenportal-Anfrage/-Zugang und der in 'Lizenz per
E-Mail senden' eingebettete Link wurden bisher per url_for(...,
_external=True) gebaut -- das nutzt den Host-Header der GERADE laufenden
Anfrage, zeigt also z.B. die interne IP, ueber die zufaellig der Admin
gerade eingeloggt ist, statt der unter Einstellungen -> Lizenz ->
Anbieter & Lizenzserver gepflegten oeffentlichen Domain. Neue Hilfs-
funktion _customer_facing_url() baut diese Links jetzt stattdessen aus
dem admin-gepflegten Lizenzserver-Endpunkt (_get_master_endpoint(),
derselbe Wert, der auch in jede Lizenzdatei eingebettet wird) -- faellt
auf die alte, request-basierte externe URL zurueck, wenn der Endpunkt
noch nicht konfiguriert ist (frische Installation).

Verifiziert gegen eine echte Kopie der Live-Datenbank: mit konfiguriertem
Endpunkt zeigt der Self-Service-Link jetzt https://tesml.int.eertmoed.net/...
statt der IP, unabhaengig davon, ueber welchen Host der Admin gerade
zugreift; ohne konfigurierten Endpunkt bleibt der bisherige Fallback
erhalten.

## Ausserdem live behoben (kein Code-Fix, reine Konfiguration)

/etc/nginx/sites-available/tesm-license auf dem Master zeigte fuer
/static/ faelschlich auf /srv/tesm/static/ (ein laengst nicht mehr
existierendes Verzeichnis -- Ueberbleibsel aus der Zeit vor der
Repo-Trennung) statt auf /srv/tesm-license/static/, wodurch CSS/JS/
Bilder ueberall 404eten (nackte, ungestylte Seiten). install.sh schreibt
die nginx-Konfiguration bewusst nur bei einer FRISCHEN Installation
(um manuelle Anpassungen wie TLS-Zertifikate bei Updates nicht zu
ueberschreiben) -- das im Repo hinterlegte Template selbst war bereits
korrekt, nur die schon laenger bestehende Datei auf dem Master war es
nicht. Direkt auf dem Master korrigiert und nginx neu geladen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:34:39 +02:00
alientimandClaude Sonnet 5 2d920f581f Eigenstaendige Lizenzen-Seite, entschachtelte Ticket-Ansicht, zeitlich begrenzter Kundenportal-Zugang (v1.2.2)
Reaktion auf Feedback: 'Tickets und Lizenzen je Kunden war sehr sehr
unintuitiv dargestellt... Kunde -> Ticket -> Lizenz aber alles auf extra
Seiten, im Menü einzeln anwählbar ohne diese riesen Verschachtelung.'

## Lizenzen als eigene, gleichberechtigte Ebene (neu: /licenses)

Bisher war eine Lizenz nur ueber Kunde -> gefilterte Tickets -> Ticket-
Detail -> eingebettete Historie-Tabelle erreichbar. Jetzt: Kunden,
Tickets UND Lizenzen sind drei eigene, direkt in der Navigation
erreichbare Seiten (Reihenfolge im Menü spiegelt bewusst die fachliche
Hierarchie). Kunden- und Ticket-Seite verlinken mit Filter (?customer_id=/
?ticket_id=) dorthin, statt Lizenzdaten selbst einzubetten.

Ticket-Detailseite dadurch massiv entschlackt: statt einer vollen
Lizenz-Historie-Tabelle nur noch eine kompakte 'Aktuelle Lizenz'-Zeile
mit Link auf die (ggf. gefilterte) Lizenzen-Seite fuer die volle Historie.

## Kundenportal: kein dauerhafter Link mehr, sondern zeitlich begrenzter Zugang

Vorheriger Ansatz (v1.2.0): ein dauerhafter, unbefristeter Token pro
Kunde, von Admins verteilt. Auf Nachfrage ('wie bekommt der Kunde seinen
Magic-Link, wenn er ihn verloren hat?') kam eine oeffentliche
'Zugang anfordern'-Seite dazu (E-Mail eingeben -> Link zugeschickt) --
und direkt im Anschluss die berechtigte Sicherheitsfrage, ob so ein
oeffentlich auf blossen Zuruf ausloesbarer Link nicht zeitlich begrenzt
sein sollte. Ergebnis: der dauerhafte Link wurde komplett entfernt.

Neuer, einziger Weg ins Kundenportal:
1. /portal/request -- Kunde gibt seine E-Mail ein (oeffentlich, keine
   Anmeldung noetig). Immer dieselbe generische Erfolgsmeldung,
   unabhaengig davon ob die Adresse bekannt ist (keine Enumeration).
2. Bei bekannter Adresse: ein frischer, einmalig nutzbarer Zugangs-Token
   (portal_access_tokens-Tabelle) wird per E-Mail verschickt, gueltig nur
   PORTAL_ACCESS_LINK_MINUTES (30) ab Anforderung.
3. /portal/access/<token> prueft Gueltigkeit + Einmalnutzung, markiert
   den Token als benutzt und setzt eine Session, die ihrerseits nach
   PORTAL_SESSION_MINUTES (60) automatisch ablaeuft.
4. /portal/me zeigt das eigentliche Portal (alle Tickets/Lizenzen,
   Kontaktdaten pflegen) -- ausschliesslich session-basiert erreichbar.

license_customers.portal_token bleibt in der DB bestehen (keine riskante
Schema-Ruecknahme), wird fuer die Zugriffsentscheidung aber nicht mehr
herangezogen -- alle Stellen, die vorher den dauerhaften Portal-Link
eingebettet haben (Ticket-Seite, Lizenz-per-E-Mail, Self-Service-Seite),
verweisen jetzt stattdessen auf /portal/request.

Verifiziert: bestehende Funktionstests erneut komplett durchlaufen (neue
Lizenzen-Seite, entschachtelte Ticket-Seite, entfernter dauerhafter
Zugang liefert 404), plus 11 neue Tests fuer den kompletten Magic-Link-
Fluss (Token-Erzeugung, Einmalnutzung, Ablehnen bei Wiederverwendung/
unbekanntem/abgelaufenem Token, Session bleibt nach Erstnutzung gueltig,
/portal/me ohne Session leitet zur Anfrage-Seite um) -- alle bestanden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:03:55 +02:00
alientimandClaude Sonnet 5 c2989a6e2f KRITISCH: 'database is locked' beim Aktivieren blockierte Online- UND Offline-Aktivierung (v1.2.1)
_process_activate() hielt beim Ersetzen einer bereits aktiven Lizenz
fuer denselben Fingerprint (z.B. Trial durch Enterprise, oder schlicht
eine Re-Aktivierung nach einem Systemwechsel) eine offene, noch nicht
committete Schreibtransaktion auf 'conn', waehrend es innerhalb der
Supersede-Schleife log_action_system() aufrief -- diese Funktion oeffnet
INTERN eine EIGENE, zweite Verbindung zur selben Datenbank und versucht
sofort zu committen. SQLite erlaubt aber nur einen Schreiber gleichzeitig
(kein WAL-Modus): die zweite Verbindung wartet auf die erste, die
wiederum auf die zweite wartet, bis das Busy-Timeout (Python-Default 5s)
mit 'sqlite3.OperationalError: database is locked' aufgibt.

Live reproduziert: sowohl /api/activate (Online) als auch
/licenses/manual-code (Offline-Code) schlugen dadurch fehl, sobald ein
Kunde eine Lizenz aktivierte, die eine fuer denselben Fingerprint bereits
aktive ersetzt -- de facto war jede Aktivierung auf einem bereits
bekannten System blockiert.

Fix: die betroffenen log_action_system()-Aufrufe in der Supersede-
Schleife werden nur noch vorgemerkt (reine Lesezugriffe auf die
offene Verbindung sind unproblematisch) und erst NACH conn.commit()/
conn.close() nachgeholt. Zusaetzlich get_db_connection() auf
timeout=15 (statt Python-Default 5s) angehoben als generelle
Verteidigungslinie gegen kurze, legitime Ueberschneidungen -- ersetzt
nicht die eigentliche Regel (niemals eine zweite Verbindung waehrend
einer noch offenen Schreibtransaktion committen), ist aber zusaetzliche
Sicherheit.

Alle anderen log_action()/log_action_system()-Aufrufstellen in dieser
Session (customers(), ticket_new/-edit/-delete, license_delete,
customer_portal, _issue_license_from_ticket, _revoke_license_row,
_process_deactivate, _process_heartbeat) wurden systematisch geprueft --
committen bereits korrekt VOR dem Logging, betroffen war ausschliesslich
diese eine Stelle.

Verifiziert: direkter Reproduktionstest gegen eine echte Kopie der
Live-Datenbank (POETESTs echte aktive Lizenz wird durch eine zweite
ersetzt, exakt das gemeldete Szenario) -- vorher deadlock/Timeout,
nachher 0.04s, korrekt superseded + Hostname + Audit-Log-Eintraege.
Als Notfall-Hotfix bereits direkt auf dem Live-Master eingespielt
(Dienst neu gestartet, seither keine weiteren 'database is locked'-
Fehler); dieser Commit bringt Repo/Release auf denselben Stand, damit
'latest' nicht wieder auf die kaputte v1.2.0 zurueckfaellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:33:16 +02:00
10 changed files with 640 additions and 131 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.0
1.2.5
+414 -65
View File
@@ -13,7 +13,7 @@ Phase 1 ergänzte Lizenzsystem selbst (licensing.py). Das Dashboard ("/")
ist aktuell nur ein Platzhalter -- die eigentliche Kunden-/
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_bcrypt import Bcrypt
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 import hashes, serialization
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 yaml
import ssl
@@ -212,6 +212,10 @@ PERMISSIONS = {
"label": "Kunden",
"rows": {"view": "customers.view", "create": "customers.create", "edit": "customers.edit"},
},
"tickets": {
"label": "Tickets",
"rows": {"view": "tickets.view", "create": "tickets.create", "edit": "tickets.edit"},
},
"licenses": {
"label": "Lizenzen",
"rows": {"view": "licenses.view", "create": "licenses.create", "edit": "licenses.edit"},
@@ -251,6 +255,10 @@ PERMISSIONS = {
"label": "Systemeinstellungen",
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
},
"settings_license": {
"label": "Lizenz",
"rows": {"view": "settings_license.view", "edit": "settings_license.edit"},
},
"settings_importexport": {
"label": "Im-/Export",
"rows": {
@@ -275,6 +283,8 @@ PERMISSION_LABELS = {
"licensing_group.view": "Lizenzierung-Bereich anzeigen",
"customers.view": "Kunden lesen", "customers.create": "Kunden anlegen",
"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.create": "Lizenzen ausstellen",
"licenses.edit": "Lizenzen ändern (Deaktivieren/Widerrufen, erneut versenden)",
@@ -288,6 +298,8 @@ PERMISSION_LABELS = {
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
"groups.edit": "Gruppen ändern (inkl. Löschen)",
"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.edit": "Daten importieren",
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
@@ -320,8 +332,12 @@ DEFAULT_GROUP_PERMISSIONS = [
NAV_ITEMS = [
{"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": "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": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
@@ -345,6 +361,8 @@ def _nav_key_visible(key, user):
if key == "index":
return True
if key == "tickets":
return user.has_permission("tickets.view")
if key == "licenses":
return user.has_permission("licenses.view")
if key == "customers":
return user.can_manage_customers
@@ -355,10 +373,11 @@ def _nav_key_visible(key, user):
if key == "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
# Eigenes Recht (settings_license.view), unabhängig von
# settings_system -- muss auch OHNE eine aktive Lizenz sichtbar
# bleiben (das ist eine andere Prüfung, siehe license_active()),
# sonst könnte niemand jemals eine erste Lizenz hochladen/aktivieren.
return user.can_view_settings_license
if key == "settings_importexport":
return user.can_view_settings_importexport
if key == "settings_ldap":
@@ -531,6 +550,12 @@ class User(UserMixin):
["customers.view", "customers.create", "customers.edit"]
)
@property
def can_manage_tickets(self):
return self.has_any_permission(
["tickets.view", "tickets.create", "tickets.edit"]
)
@property
def can_manage_licenses(self):
return self.has_any_permission(
@@ -553,6 +578,10 @@ class User(UserMixin):
def can_view_settings_system(self):
return self.has_permission("settings_system.view")
@property
def can_view_settings_license(self):
return self.has_permission("settings_license.view")
@property
def can_view_settings_importexport(self):
return self.has_any_permission([
@@ -580,7 +609,14 @@ class User(UserMixin):
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
return conn
@@ -705,11 +741,14 @@ def _ensure_schema():
):
if _col not in customer_cols:
conn.execute(_ddl)
# Jeder Kunde braucht einen eigenen, dauerhaften Portal-Token fuer den
# kundenweiten Magic-Link (Kundenportal-Login ohne Passwort, siehe
# customer_portal()) -- bestehende Kunden (vor Einfuehrung dieses
# Felds) bekommen hier rueckwirkend einen zugewiesen, statt bis zur
# naechsten manuellen Bearbeitung ohne Portal-Zugang dazustehen.
# Historisch: ein dauerhafter Portal-Token pro Kunde (vorher Basis
# eines UNBEFRISTETEN Magic-Links). Dieser direkte, permanente Zugang
# wurde bewusst wieder entfernt -- der einzige Weg ins Kundenportal
# ist jetzt der zeitlich begrenzte Ablauf über portal_request() /
# 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():
conn.execute(
"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 "
"Ticket bereits mehr als eine offene Lizenz. Bitte manuell bereinigen (siehe licenses-Tabelle)."
)
# Analoger Backstop fuer den Kundenportal-Magic-Link: portal_token
# autorisiert komplett ohne Login (siehe customer_portal()), ein
# doppelt vergebener oder NULL-Token wuerde sonst stillschweigend die
# falsche Kundenzeile zurueckgeben bzw. den Kunden dauerhaft aussperren.
# Historisch (siehe Kommentar beim Backfill oben) -- portal_token wird
# fuer die eigentliche Zugriffsentscheidung nicht mehr genutzt, der
# Unique-Index bleibt trotzdem als harmlose Datenintegritaets-
# Absicherung bestehen.
try:
conn.execute(
"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)."
)
# 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
# des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) --
# 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("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"
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"):
@@ -1942,6 +2058,23 @@ def _get_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):
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!=?",
(fingerprint, license_id),
).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:
conn.execute(
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
(now, old["license_id"]),
)
old_customer = _customer_row(conn, old["customer_id"])
log_action_system(
"license.superseded", old_customer["name"] if old_customer else old["license_id"],
superseded_logs.append((
old_customer["name"] if old_customer else old["license_id"],
f"Automatisch deaktiviert -- Fingerprint {fingerprint} hat jetzt Lizenz {license_id} aktiviert.",
)
))
conn.commit()
customer = _customer_row(conn, row["customer_id"])
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}")
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"))
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
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
ORDER BY license_customers.name COLLATE NOCASE
""").fetchall()
@@ -2460,7 +2609,7 @@ def ticket_new():
Lizenz mit bestimmten Eckdaten. Erstellt bewusst noch KEINE Lizenz;
das übernimmt ein separater Schritt auf der Ticket-Detailseite (durch
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")
return redirect(url_for("index"))
conn = get_db_connection()
@@ -2531,7 +2680,7 @@ def ticket_new():
@app.route("/tickets/<ticket_id>", methods=["GET", "POST"])
@login_required
def ticket_detail(ticket_id):
if not current_user.can_manage_licenses:
if not current_user.can_manage_tickets:
flash("Keine Berechtigung.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
@@ -2542,10 +2691,14 @@ def ticket_detail(ticket_id):
return redirect(url_for("index"))
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"):
flash("Keine Berechtigung.", "danger")
elif "issue_license" in request.form:
if not license_active():
flash("Keine Berechtigung, Lizenzen auszustellen.", "danger")
elif not license_active():
flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger")
elif ticket["status"] != "open":
flash("Ticket ist gesperrt.", "danger")
@@ -2558,6 +2711,9 @@ def ticket_detail(ticket_id):
flash("Lizenz ausgestellt.", "success")
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
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"
conn.execute("UPDATE license_tickets SET status=? WHERE id=?", (new_status, ticket_id))
conn.commit()
@@ -2584,9 +2740,10 @@ def ticket_detail(ticket_id):
license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES,
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
master_licensed=license_active(),
self_service_url=url_for("self_service", ticket_id=ticket_id, _external=True),
portal_url=url_for("customer_portal", token=customer["portal_token"], _external=True) if customer and customer["portal_token"] else None,
can_edit=current_user.has_permission("licenses.edit"),
self_service_url=_customer_facing_url("self_service", ticket_id=ticket_id),
portal_request_url=_customer_facing_url("portal_request"),
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
diesen Kunden gebunden, u.a. weil er ueber dessen Portal-Token
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")
return redirect(url_for("tickets_list"))
conn = get_db_connection()
@@ -2655,7 +2812,7 @@ def ticket_delete(ticket_id):
System verschwinden). Etwaige bereits widerrufene/deaktivierte
Lizenzen darunter sind reine Historie und werden mitgeloescht, sonst
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")
return redirect(url_for("tickets_list"))
conn = get_db_connection()
@@ -2825,14 +2982,16 @@ def license_send_email(license_id):
return redirect(url_for("license_detail", license_id=license_id))
self_service_note = ""
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 = (
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"
)
if customer["portal_token"]:
portal_url = url_for("customer_portal", token=customer["portal_token"], _external=True)
self_service_note += f"\nEine Übersicht all Ihrer Tickets/Lizenzen finden Sie hier:\n{portal_url}\n"
portal_request_url = _customer_facing_url("portal_request")
self_service_note += (
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"]
try:
_graph_send_mail(
@@ -2927,7 +3086,7 @@ def self_service(ticket_id):
license=_license_dict_for_display(current) if current else None,
can_issue_new=can_issue_new,
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))
@app.route("/portal/<token>", methods=["GET", "POST"])
def customer_portal(token):
"""Kundenweites Self-Service-Portal -- anders als /self-service/<ticket_id>
(ein Magic-Link PRO TICKET) ist dieser Link PRO KUNDE: zeigt alle
eigenen Tickets samt aktueller Lizenz auf einen Blick und erlaubt dem
Kunden, seine eigenen Kontakt-/Anschriftdaten selbst zu pflegen.
Bewusst NICHT bearbeitbar: Firmenname sowie die kommerziellen
Ticket-Eckdaten (Typ/Module/Laufzeit) -- das bleibt Sache des Anbieters.
Fuer die eigentlichen Lizenzaktionen (herunterladen/widerrufen/neu
erstellen) verlinkt jede Ticket-Zeile auf deren bestehende
/self-service/<ticket_id>-Seite, statt die Logik hier zu duplizieren.
Kein Login noetig -- Autorisierung ausschliesslich durch Besitz des
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)
@app.route("/portal/request", methods=["GET", "POST"])
def portal_request():
"""Oeffentlich erreichbar (kein Login): fuer einen Kunden, der seinen
Magic-Link verloren hat oder nie einen bekommen hat (z.B. weil er nur
telefonisch beim Anbieter bekannt ist), gibt es sonst KEINEN Weg
zurueck ins Kundenportal ausser einen Admin zu kontaktieren -- diese
Seite schliesst die Luecke: E-Mail-Adresse eingeben, Link wird (falls
hinterlegt) zugeschickt. Werkzeug routet die literale Regel
/portal/request bevorzugt vor der dynamischen /portal/<token> weiter
unten -- keine Kollisionsgefahr mit einem Kunden, dessen Token
zufaellig 'request' laeutet waere ohnehin praktisch ausgeschlossen
(192 Bit Zufall).
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":
contact_email = request.form.get("contact_email", "").strip()
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")
flash("Ihre Daten wurden aktualisiert.", "success")
conn.close()
return redirect(url_for("customer_portal", token=token))
return redirect(url_for("customer_portal_session"))
now = time.time()
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():
"""Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob
die audit_log-Tabelle den Schwellenwert überschritten hat, und
@@ -3417,6 +3710,7 @@ def inject_license_topbar():
@app.route("/")
@login_required
def index():
"""Dashboard des Master-Lizenzservers -- eine rein informative Kachel-
uebersicht der gerade AKTIVEN Lizenzen (analog zur Geraete-Kachel-
@@ -3426,8 +3720,15 @@ def index():
mit status='active' -- alles andere (Tickets anlegen/bearbeiten/
loeschen, Lizenzen ausstellen/widerrufen/loeschen) ist Verwaltung und
lebt seit der Kachel-Umstellung auf der eigenen Tickets-Seite
(tickets_list())."""
if not current_user.is_authenticated or not current_user.has_permission("licenses.view"):
(tickets_list()).
@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)
conn = get_db_connection()
active_rows = conn.execute("""
@@ -3463,7 +3764,7 @@ def tickets_list():
Dashboards auf eine rein informative Kacheluebersicht (siehe index())
lebt die eigentliche Verwaltung hier, eigenstaendig ueber die
Navigation erreichbar."""
if not current_user.has_permission("licenses.view"):
if not current_user.has_permission("tickets.view"):
flash("Keine Berechtigung, Tickets anzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
@@ -3496,7 +3797,55 @@ def tickets_list():
return render_template(
"tickets.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
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"),
)
@@ -4439,7 +4788,7 @@ def settings():
@app.route("/settings/license")
@login_required
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")
return redirect(url_for("index"))
pending_raw = get_setting("license_pending_request", "")
@@ -4462,7 +4811,7 @@ def settings_license():
@app.route("/settings/license/upload", methods=["POST"])
@login_required
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")
return redirect(url_for("settings_license"))
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"])
@login_required
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")
return redirect(url_for("settings_license"))
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"])
@login_required
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")
return redirect(url_for("settings_license"))
@@ -4586,7 +4935,7 @@ def license_activate_confirm():
@app.route("/settings/license/deactivate", methods=["POST"])
@login_required
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")
return redirect(url_for("settings_license"))
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"])
@login_required
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")
return redirect(url_for("settings_license"))
+11
View File
@@ -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"))
cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",))
+4 -8
View File
@@ -31,10 +31,11 @@
<th data-sort-key="address">Anschrift</th>
<th data-sort-key="email">Kontakt</th>
<th data-sort-key="tickets">Tickets</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-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>
<td class="cell-name">{{ c.name }}</td>
<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 %}
</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>
<div class="row-actions">
{% if can_edit %}
@@ -73,7 +75,7 @@
</tbody>
{% else %}
<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>
{% endfor %}
</table>
@@ -130,12 +132,6 @@
<label>Notizen</label>
<textarea name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{{ c.notes or '' }}</textarea>
</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>
{% if can_edit %}
<div class="modal-footer">
+120
View File
@@ -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 %}
+4 -1
View File
@@ -37,7 +37,10 @@
</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 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>
@@ -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>
+1 -1
View File
@@ -17,7 +17,7 @@
<h1>Meine Lizenz</h1>
<div class="hint" style="text-align:center; margin-bottom:18px;">
{{ 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>
{% with messages = get_flashed_messages() %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% 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_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
+23 -41
View File
@@ -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>
<input type="text" class="mono" value="{{ self_service_url }}" readonly onclick="this.select()">
</div>
{% if portal_url %}
<div class="field">
<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>
<input type="text" class="mono" value="{{ portal_url }}" readonly onclick="this.select()">
<div class="hint" style="margin-top:6px;">
Ü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") }}:
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).
</div>
{% endif %}
{% if can_edit %}
{% if can_edit_ticket %}
<div class="flex gap-2" style="margin-top:14px;">
<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>
@@ -71,7 +69,7 @@
Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann
eine neue erstellt werden.
</p>
{% elif can_edit %}
{% elif can_issue_license %}
<form method="post" style="margin-bottom:10px;">
<input type="hidden" name="issue_license" value="regular">
<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="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 style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Lizenz-ID</th><th>Typ</th><th>Ablauf</th><th>Status</th><th style="width:1%;">Aktionen</th></tr></thead>
{% for l in licenses %}
<tbody>
<tr>
<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>
{% if licenses %}
{% set l = licenses[0] %}
<div class="flex gap-2" style="align-items:center; flex-wrap:wrap;">
<span class="mono text-dim">{{ l.license_id[:8] }}…</span>
<span>{{ type_labels.get(l.type, l.type) }}</span>
<span class="text-dim">{{ 'Lifetime' if l.is_lifetime else 'bis ' ~ l.expires_at[:10] }}</span>
<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>
<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 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>
<a href="{{ url_for('license_detail', license_id=l.license_id) }}" class="btn btn-sm btn-secondary">Details →</a>
</div>
{% if licenses|length > 1 %}
<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>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch keine Lizenz aus diesem Ticket erstellt.</p>
{% endif %}
</div>
</td>
</tr>
</tbody>
{% else %}
<tbody><tr class="empty-row"><td colspan="5">Noch keine Lizenz aus diesem Ticket erstellt.</td></tr></tbody>
{% endfor %}
</table>
</div>
</div>
</div>
{% if can_edit %}
{% if can_edit_ticket %}
<div class="modal-overlay" id="editTicketModal">
<div class="modal" style="max-width:520px;">
<form method="post" action="{{ url_for('ticket_edit', ticket_id=ticket.id) }}">