Compare commits

6 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 c130c85b17 Sub-Lizenzserver melden ihren Kunden-/Ticket-/Lizenzstand an den Master
Ein Sub-Lizenzserver (role='license_server') meldet jetzt seinen
kompletten eigenen Kunden-/Ticket-/Lizenzstand an den Master, der ihn
als Ueberblick auf einer neuen Kunden-Detailseite anzeigt: unter dem
Kunden, der die Lizenzserver-Lizenz haelt, erscheinen dessen eigene
Kunden mit ihren Tickets/Lizenzen -- genau wie gewuenscht.

Transport: huckepack auf dem bereits bestehenden 6-Stunden-Heartbeat
(licensing.py: neues optionales 'report'-Feld in
build_client_request(), signiert wie jedes andere Feld -- ein
Angreifer ohne den privaten Lizenzschluessel kann keinen gefaelschten
Bericht unterschieben). Zusaetzlich sofortiger Best-Effort-Report per
Hintergrund-Thread nach jeder lokalen Ticket-/Kunden-/Lizenz-Aenderung
(_report_to_master_async()), damit der Master nicht bis zu 6h auf
Aktualitaet warten muss. Funktioniert automatisch auch ueber den
Offline-Code-Weg (/licenses/manual-code), da beide Transportwege
denselben _process_heartbeat()-Code nutzen.

- Drei neue, rein informative Tabellen (reported_sub_customers/
  _tickets/_licenses), verankert an source_license_id (die Lizenz,
  die den Sub-Lizenzserver identifiziert). Ein Bericht ist immer ein
  VOLLER Schnappschuss -- bei jedem Empfang werden alle bisherigen
  Zeilen dieser source_license_id ersetzt (kein Diffing noetig, ein
  beim Sub-Lizenzserver geloeschtes Ticket verschwindet dadurch
  automatisch korrekt auch hier).
- _build_own_issuance_report(): baut den Schnappschuss auf der
  Sub-Lizenzserver-Seite.
- _ingest_sub_report(): uebernimmt ihn auf der Master-Seite, NUR wenn
  row['role']=='license_server' fuer die absendende Lizenz (Verteid-
  igung in der Tiefe -- row['role'] steht in der Master-DB fest, nicht
  im Client-Request, ein gewoehnlicher Kunde kann sich also nicht als
  Lizenzserver ausgeben).
- Neue Kunden-Detailseite (/customers/<id>, customer_detail.html):
  Stammdaten, eigene Tickets bei uns, und -- falls dieser Kunde eine
  aktive role='license_server'-Lizenz haelt -- die gemeldeten eigenen
  Kunden mit Tickets/Lizenzen (Ampelfarbe/Resttage ueber dieselbe
  _license_dict_for_display()-Funktion wie ueberall sonst). Kunden-
  liste verlinkt jetzt auf die Detailseite, zeigt 'Lizenzserver'-Badge.

Getestet: licensing.py isoliert (report signiert+verifiziert,
Manipulation bricht die Signatur, unveraendertes Verhalten ohne
report-Argument), _ingest_sub_report() isoliert (Erstbericht korrekt
uebernommen, Zweitbericht ersetzt korrekt inkl. verschwindender
Tickets, Isolation zwischen mehreren source_license_id sauber).
ZUSAETZLICH ein vollstaendiger Zwei-Prozess-Integrationstest ueber
echtes HTTP (zwei unabhaengige, native laufende Instanzen: 'Master' +
'Sub-Lizenzserver' auf unterschiedlichen Ports): Ticket mit
role=license_server angelegt, Lizenz ausgestellt, auf der zweiten
Instanz hochgeladen und ONLINE aktiviert, dort ein eigener Endkunde
samt Ticket/Lizenz angelegt, automatischer Report ausgeloest --
Master-Kundendetailseite zeigt den gemeldeten Endkunden mit korrektem
Ticket-Typ/Laufzeit/Status und aktuellem Heartbeat-Zeitstempel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:45:17 +02:00
alientimandClaude Sonnet 5 ed2ed00a4e Sub-Lizenzserver: Lizenzen fuer Lizenzserver ausstellbar (role='license_server')
Der Master kann jetzt ueber den normalen Kunde->Ticket->Lizenz-Flow
Lizenzen ausstellen, die eine ANDERE Lizenzserver-Instanz voll
funktionsfaehig machen (Kunden/Tickets/Lizenzen fuer eigene Kunden
ausstellen, Benutzer/Gruppen/Einstellungen verwalten) -- mit genau
einer strukturellen Einschraenkung: der so lizenzierte Sub-Lizenzserver
kann selbst KEINE weiteren Lizenzserver-Lizenzen ausstellen. Die
eigene Lizenz DIESES Masters bleibt weiterhin ausschliesslich per
create_master_license.py erzeugbar, nie ueber die Ticket-UI.

- licensing.py: neues, signiertes role-Feld (master/license_server/
  customer), siehe separates TESM-Release fuer die synchronisierte
  Kopie (harte Invariante: beide Dateien bleiben identisch).
- create_master_license.py: setzt role='master' beim Selbstausstellen.
- Neue STRUKTURELLE Sperre can_issue_license_server_licenses(): True
  NUR wenn die eigene, aktive Lizenz dieser Instanz role=='master' ist
  -- haengt an der Instanz-Rolle, nicht an einer vergebbaren Permission,
  daher von lokalen Admins eines Sub-Lizenzservers nicht aufhebbar.
  Kombiniert mit current_user.is_admin an jeder Aufrufstelle (nur echte
  Admins duerfen es je nutzen, wie gewuenscht -- kein delegierbares
  Recht).
- license_tickets.role-Spalte (Migration, Default 'customer').
  license_tickets.role bestimmt, welche Rolle eine daraus ausgestellte
  Lizenz traegt -- nach Anlage unveraendert wie customer_id.
- ticket_new(): neue, nur fuer can_issue_license_server_licenses()+Admin
  sichtbare Option 'Dies ist eine Lizenzserver-Lizenz'. Jeder andere
  Versuch (manipuliertes Formular, Sub-Lizenzserver, Nicht-Admin) wird
  serverseitig still auf 'customer' zurueckgestuft. Module werden fuer
  role!='customer' erzwungen auf [] (TESM-Client-Konzept, fuer einen
  Lizenzserver bedeutungslos).
- Self-Service (unauthentifiziert, nur ueber die Ticket-ID) ist fuer
  role!='customer'-Tickets komplett gesperrt (404) -- Lizenzserver-
  Lizenzen sind ausschliesslich admin-verwaltet, kein Kundenweg. Trial-
  Ausstellung ('30 Tage, alle Module') ebenfalls nur fuer role=customer
  moeglich (ValueError sonst, sauber abgefangen statt 500er).
- license_send_email(): ueberspringt Self-Service-/Kundenportal-Hinweise
  fuer role='license_server'-Lizenzen (waeren ohnehin blockiert).
- UI: 'Lizenzserver'-Badge auf Tickets-/Lizenzen-Liste und Ticket-
  Detailseite.

Getestet: licensing.py isoliert (role signiert+verifiziert, Manipulation
bricht die Signatur, Altformat ohne role faellt auf 'customer' zurueck),
can_issue_license_server_licenses()-Logik (6 Kombinationen), Schema-
Migration (Alt-DB ohne role-Spalte, idempotent), Ende-zu-Ende Ticket->
Lizenz-Ausstellung gegen echte create_db.py-DB. ZUSAETZLICH gegen einen
echten, lokal laufenden Flask-Server (native Windows-Python, WSL-
Netzwerksandbox in dieser Umgebung unzuverlaessig) voll durchgespielt:
Checkbox erscheint nur fuer role=master, Ticket+Lizenz-Ausstellung mit
role=license_server funktioniert Ende-zu-Ende (signierte Datei, Module
leer, Self-Service liefert 404), UND der Sicherheitstest: nach Wechsel
der eigenen Lizenz auf role=license_server verschwindet die Checkbox
UND ein direkter manipulierter POST mit role=license_server wird
serverseitig zuverlaessig auf 'customer' zurueckgestuft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:08:19 +02:00
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
15 changed files with 1295 additions and 159 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.1 1.2.7
+763 -77
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -162,12 +162,67 @@ CREATE TABLE IF NOT EXISTS license_tickets (
valid_days INTEGER NOT NULL, valid_days INTEGER NOT NULL,
lifetime INTEGER NOT NULL DEFAULT 0, lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open', status TEXT NOT NULL DEFAULT 'open',
role TEXT NOT NULL DEFAULT 'customer',
created_by TEXT, created_by TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES license_customers(id) FOREIGN KEY (customer_id) REFERENCES license_customers(id)
); );
""") """)
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("""
CREATE TABLE IF NOT EXISTS reported_sub_customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_customer_ref TEXT NOT NULL,
name TEXT NOT NULL,
contact_email TEXT, contact_phone TEXT, contact_person TEXT,
street TEXT, house_number TEXT, postal_code TEXT, city TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE (source_license_id, remote_customer_ref)
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS reported_sub_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_ticket_id TEXT NOT NULL,
remote_customer_ref TEXT NOT NULL,
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
valid_days INTEGER, lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open', role TEXT NOT NULL DEFAULT 'customer',
created_at TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE (source_license_id, remote_ticket_id)
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS reported_sub_licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_license_id TEXT NOT NULL,
remote_ticket_id TEXT,
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
issued_at TEXT, expires_at TEXT, status TEXT NOT NULL, lifetime INTEGER NOT NULL DEFAULT 0,
fingerprint TEXT, hostname TEXT, activated_at TEXT, last_heartbeat_at TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE (source_license_id, remote_license_id)
);
""")
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_customers_source ON reported_sub_customers(source_license_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_tickets_source ON reported_sub_tickets(source_license_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_licenses_source ON reported_sub_licenses(source_license_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",))
@@ -74,6 +74,12 @@ def main():
master_public_key_b64=master_pub, master_public_key_b64=master_pub,
master_endpoint="", master_endpoint="",
vendor={"name": "", "phone": "", "email": "", "address": "", "logo_base64": ""}, vendor={"name": "", "phone": "", "email": "", "address": "", "logo_base64": ""},
# role="master": markiert dies als die Wurzel-Instanz -- NUR dieses
# Skript darf role="master" setzen (siehe LICENSE_ROLES in
# licensing.py). app.py bietet role="master" an keiner Stelle über
# die normale Ticket-/Ausstellungs-UI an; das ist die einzige Rolle,
# die selbst weitere role="license_server"-Lizenzen ausstellen darf.
role="master",
) )
with open(LICENSE_PATH, "w", encoding="utf-8") as f: with open(LICENSE_PATH, "w", encoding="utf-8") as f:
json.dump(license_file, f, indent=2) json.dump(license_file, f, indent=2)
+62 -3
View File
@@ -39,6 +39,31 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise") LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
ALL_MODULES = ("dhcp", "fileshare", "maintenance") ALL_MODULES = ("dhcp", "fileshare", "maintenance")
# Rolle der lizenzierten Instanz, eingebettet in JEDE ausgestellte
# Lizenzdatei (signiert, siehe issue_license) -- NICHT nur DB-seitige
# Buchfuehrung wie das gleichnamige, bislang ungenutzte licenses.role-Feld
# in der Master-DB vorher. Ermoeglicht einer lizenzierten Instanz, anhand
# ihrer EIGENEN, geladenen Lizenz strukturell zu erkennen, was sie darf:
# "master" -- die Wurzel-Instanz. Wird AUSSCHLIESSLICH lokal per
# create_master_license.py erzeugt, NIE ueber die
# normale Ticket-/Ausstellungs-UI -- das waere sonst
# eine Moeglichkeit, sich selbst zur Wurzel zu
# erklaeren. Einzige Rolle, die weitere
# "license_server"-Lizenzen ausstellen darf.
# "license_server" -- ein vom Master (oder einer anderen "master"-Instanz)
# ausgestellter Lizenzserver ("Sub-Lizenzserver").
# Darf alles, was ein Lizenzserver koennen soll
# (Kunden/Tickets/Lizenzen fuer eigene TESM-Kunden
# ausstellen, Benutzer/Gruppen/Einstellungen
# verwalten) -- AUSSER selbst weitere
# "license_server"-Lizenzen ausstellen. Diese Sperre
# ist strukturell (haengt an der eigenen Rolle, nicht
# an einem vergebbaren Recht) und daher auch von einem
# lokalen Admin dieser Instanz nicht aufhebbar.
# "customer" -- eine gewoehnliche TESM-Kundeninstanz (Default,
# unveraendertes Verhalten).
LICENSE_ROLES = ("master", "license_server", "customer")
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab) HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
@@ -120,13 +145,23 @@ LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
def issue_license(*, customer, license_type, modules, valid_days, def issue_license(*, customer, license_type, modules, valid_days,
master_private_key_b64, master_public_key_b64, master_private_key_b64, master_public_key_b64,
master_endpoint, vendor, license_id=None, now=None, lifetime=False): master_endpoint, vendor, license_id=None, now=None, lifetime=False,
role="customer"):
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt """Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
frischem Pro-Lizenz-Schlüsselpaar. frischem Pro-Lizenz-Schlüsselpaar.
lifetime=True ignoriert valid_days komplett und setzt stattdessen das lifetime=True ignoriert valid_days komplett und setzt stattdessen das
feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar. feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar.
role (siehe LICENSE_ROLES): "customer" (Default, unverändertes
Verhalten) für gewöhnliche TESM-Kundeninstanzen, "license_server" für
einen vom Aufrufer ausgestellten Sub-Lizenzserver. role="master" darf
NUR von create_master_license.py verwendet werden (lokal, ohne diese
Funktion über die normale Ticket-UI aufzurufen) -- diese Funktion prüft
das nicht selbst, das Verbot ist stattdessen dadurch durchgesetzt, dass
die App-seitige Ausstellungsfunktion role="master" gar nicht erst als
Option anbietet (siehe app.py can_issue_license_server_licenses()).
Rückgabe: (license_file, license_pubkey) -- license_file ist die Rückgabe: (license_file, license_pubkey) -- license_file ist die
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
@@ -134,6 +169,8 @@ def issue_license(*, customer, license_type, modules, valid_days,
dieser Lizenz) und wird nicht an den Kunden weitergegeben.""" dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
if license_type not in LICENSE_TYPES: if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})") raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
if role not in LICENSE_ROLES:
raise ValueError(f"Unbekannte Rolle: {role!r} (erlaubt: {LICENSE_ROLES})")
unknown = set(modules) - set(ALL_MODULES) unknown = set(modules) - set(ALL_MODULES)
if unknown: if unknown:
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})") raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
@@ -151,6 +188,7 @@ def issue_license(*, customer, license_type, modules, valid_days,
"master_pubkey": master_public_key_b64, "master_pubkey": master_public_key_b64,
"master_endpoint": master_endpoint, "master_endpoint": master_endpoint,
"vendor": vendor, "vendor": vendor,
"role": role,
} }
signature = sign_payload(payload, master_private_key_b64) signature = sign_payload(payload, master_private_key_b64)
license_file = {**payload, "license_privkey": license_priv, "signature": signature} license_file = {**payload, "license_privkey": license_priv, "signature": signature}
@@ -190,6 +228,12 @@ def license_status(license_file: dict, now=None) -> dict:
"type": license_file.get("type"), "type": license_file.get("type"),
"customer": license_file.get("customer"), "customer": license_file.get("customer"),
"expires_at": license_file.get("expires_at"), "expires_at": license_file.get("expires_at"),
# .get(..., "customer"): aeltere, vor Einfuehrung von role signierte
# Lizenzdateien haben dieses Feld noch nicht -- fehlend bedeutet
# unveraendert "gewoehnliche Kundeninstanz", nie "master"/
# "license_server" (sonst koennte eine alte Datei rueckwirkend
# unbeabsichtigt privilegiert erscheinen).
"role": license_file.get("role", "customer"),
} }
@@ -231,11 +275,24 @@ def system_fingerprint():
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam # gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
# verhindert". # verhindert".
def build_client_request(action, license_file, nonce=None) -> dict: def build_client_request(action, license_file, nonce=None, report=None) -> dict:
"""Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action: """Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für "activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt.""" zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt.
report: NUR für Heartbeats einer role="license_server"-Lizenz gedacht
(siehe Lizenzserver-app.py _build_own_issuance_report()) -- ein
optionaler Schnappschuss der eigenen Kunden/Tickets/Lizenzen dieses
Sub-Lizenzservers, den der Master beim Empfang in seine
reported_sub_*-Tabellen übernimmt, um einen Gesamtüberblick zu haben.
Bewusst Teil DIESER generischen, gemeinsam genutzten Funktion (nicht
ein eigenes Protokoll-Feature) -- ein normaler TESM-Client/eine
"customer"-Lizenz übergibt hier einfach nie einen Wert, das Feld fehlt
dann in der Payload wie bisher (keine Verhaltensänderung für alle
bestehenden Aufrufer). Wie jedes andere Feld wird auch report
mitsigniert -- ein Angreifer ohne den privaten Lizenzschlüssel kann
keine gefälschten Berichte unterschieben."""
if action not in ("activate", "deactivate", "heartbeat"): if action not in ("activate", "deactivate", "heartbeat"):
raise ValueError(f"Unbekannte Aktion: {action!r}") raise ValueError(f"Unbekannte Aktion: {action!r}")
payload = { payload = {
@@ -252,6 +309,8 @@ def build_client_request(action, license_file, nonce=None) -> dict:
"nonce": nonce or uuid.uuid4().hex, "nonce": nonce or uuid.uuid4().hex,
"timestamp": _iso(time.time()), "timestamp": _iso(time.time()),
} }
if report is not None:
payload["report"] = report
signature = sign_payload(payload, license_file["license_privkey"]) signature = sign_payload(payload, license_file["license_privkey"])
return {**payload, "signature": signature} return {**payload, "signature": signature}
@@ -0,0 +1,137 @@
{% extends "base.html" %}
{% set active_page = "customers" %}
{% block page_title %}Kunde{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ customer.name }}</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">
{{ customer.name }}
{% if sub_license %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
</h2>
</div>
<a href="{{ url_for('customers') }}" class="btn btn-secondary">← Alle Kunden</a>
</div>
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Stammdaten</h2></div>
{% if can_edit %}
<a href="{{ url_for('customers') }}" class="btn btn-sm btn-secondary">Bearbeiten</a>
{% endif %}
</div>
<div class="field"><label>E-Mail</label><input type="text" value="{{ customer.contact_email or '—' }}" disabled></div>
<div class="field"><label>Telefon</label><input type="text" value="{{ customer.contact_phone or '—' }}" disabled></div>
<div class="field"><label>Ansprechpartner</label><input type="text" value="{{ customer.contact_person or '—' }}" disabled></div>
<div class="field">
<label>Anschrift</label>
<input type="text" value="{{ [customer.street, customer.house_number]|select|join(' ') or '—' }}{{ ', ' ~ [customer.postal_code, customer.city]|select|join(' ') if customer.postal_code or customer.city else '' }}" disabled>
</div>
{% if customer.notes %}
<div class="field"><label>Notizen</label><textarea disabled rows="3">{{ customer.notes }}</textarea></div>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Eigene Tickets bei uns</h2></div>
<a href="{{ url_for('ticket_new') }}?customer_id={{ customer.id }}" class="btn btn-sm btn-secondary">Neues Ticket</a>
</div>
{% if tickets %}
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Typ</th><th>Rolle</th><th>Ticket</th><th>Lizenz-Status</th></tr></thead>
<tbody>
{% for t in tickets %}
<tr>
<td>{{ type_labels.get(t.type, t.type) }}</td>
<td>{% if t.role == 'license_server' %}<span class="pill admin">Lizenzserver</span>{% else %}Kunde{% endif %}</td>
<td>
<a href="{{ url_for('ticket_detail', ticket_id=t.id) }}" class="pill {{ 'online' if t.status == 'open' else 'offline' }}">
{{ 'Offen' if t.status == 'open' else 'Gesperrt' }}
</a>
</td>
<td>
{% if t.license %}
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(t.license.status, 'disabled') }}">
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }}
</span>
{% else %}<span class="text-faint">Noch keine Lizenz</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch keine Tickets für diesen Kunden.</p>
{% endif %}
</div>
</div>
{% if sub_license %}
<div class="card card-pad" style="margin-top:20px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Von diesem Lizenzserver verwaltete Kunden</h2>
<div class="hint">
Gemeldet vom Sub-Lizenzserver selbst bei jedem Heartbeat {{ hi.hint_icon("Rein informativ -- diese Daten werden nicht von uns verwaltet, sondern vom Sub-Lizenzserver selbst bei jedem Heartbeat (alle 6h, zusaetzlich sofort nach jeder dortigen Aenderung) als vollstaendiger Schnappschuss gemeldet und hier nur angezeigt.", "Gemeldete Daten") }}
{% if sub_license.last_heartbeat_at %}— zuletzt {{ sub_license.last_heartbeat_at }}{% else %}— noch kein Heartbeat empfangen{% endif %}
</div>
</div>
</div>
{% if reported_customers %}
{% for rc in reported_customers %}
<div class="card card-pad" style="margin-bottom:12px; background:var(--bg-elevated);">
<div class="section-head" style="margin-bottom:10px;">
<div><strong>{{ rc.name }}</strong> <span class="text-dim" style="font-size:12px;">{{ rc.contact_email or '' }}</span></div>
</div>
{% set rtickets = reported_tickets_by_customer.get(rc.remote_customer_ref, []) %}
{% if rtickets %}
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Typ</th><th>Laufzeit</th><th>Ticket-Status</th><th>Lizenz-Status</th><th>Ablauf</th></tr></thead>
<tbody>
{% for rt in rtickets %}
{% set rlicenses = reported_licenses_by_ticket.get(rt.remote_ticket_id, []) %}
{% set rl = rlicenses[0] if rlicenses else none %}
<tr>
<td>{{ type_labels.get(rt.type, rt.type) }}</td>
<td class="text-dim">{{ 'Lifetime' if rt.lifetime else (rt.valid_days ~ ' Tage' if rt.valid_days else '—') }}</td>
<td><span class="pill {{ 'online' if rt.status == 'open' else 'offline' }}">{{ 'Offen' if rt.status == 'open' else 'Gesperrt' }}</span></td>
{% if rl %}
<td>
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(rl.status, 'disabled') }}">
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(rl.status, rl.status) }}
</span>
</td>
<td class="text-dim">
{% if rl.is_lifetime %}Lifetime
{% elif rl.days_left is not none %}
<span style="{{ 'color:var(--danger);' if rl.color == 'red' else ('color:var(--warning);' if rl.color == 'orange' else '') }}">{{ rl.days_left }} Tage</span>
{% else %}—{% endif %}
</td>
{% else %}
<td class="text-faint" colspan="2">Noch keine Lizenz</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-faint" style="font-size:12px;">Noch keine Tickets gemeldet.</p>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">Dieser Lizenzserver hat noch keine eigenen Kunden gemeldet.</p>
{% endif %}
</div>
{% endif %}
{% endblock %}
+8 -9
View File
@@ -31,12 +31,16 @@
<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">
<a href="{{ url_for('customer_detail', customer_id=c.id) }}">{{ c.name }}</a>
{% if c.is_sub_license_server %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
</td>
<td class="text-dim"> <td class="text-dim">
{% if c.street or c.city %} {% if c.street or c.city %}
{{ c.street }} {{ c.house_number }}{% if c.street %}<br>{% endif %}{{ c.postal_code }} {{ c.city }} {{ c.street }} {{ c.house_number }}{% if c.street %}<br>{% endif %}{{ c.postal_code }} {{ c.city }}
@@ -47,6 +51,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 +78,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 +135,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">
+123
View File
@@ -0,0 +1,123 @@
{% 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 }}
{% if l.role == 'license_server' %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
</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> </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>
+1 -1
View File
@@ -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 %}
+40 -40
View File
@@ -10,26 +10,40 @@
<div class="card card-pad"> <div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Ticket</h2></div> <div><h2 style="font-size:16px;">Ticket</h2></div>
<div class="flex gap-2">
{% if ticket.role == 'license_server' %}
<span class="pill admin">Lizenzserver</span>
{% endif %}
<span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}"> <span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}">
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }} {{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }}
</span> </span>
</div> </div>
</div>
{% if ticket.role == 'license_server' %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Lizenzserver-Ticket: eine daraus ausgestellte Lizenz macht die Zielinstanz zu einem vollwertigen Sub-Lizenzserver
(darf eigene Kunden/Tickets/Lizenzen ausstellen, Benutzer/Gruppen/Einstellungen verwalten) — außer selbst weitere
Lizenzserver-Lizenzen ausstellen. Kein Self-Service-Zugang für diesen Ticket-Typ.
</div>
{% endif %}
<div class="field"><label>Kunde</label><input type="text" value="{{ customer.name if customer else '?' }}" disabled></div> <div class="field"><label>Kunde</label><input type="text" value="{{ customer.name if customer else '?' }}" disabled></div>
<div class="field"><label>Lizenztyp</label><input type="text" value="{{ type_labels.get(ticket.type, ticket.type) }}" disabled></div> <div class="field"><label>Lizenztyp</label><input type="text" value="{{ type_labels.get(ticket.type, ticket.type) }}" disabled></div>
{% if ticket.role == 'customer' %}
<div class="field"><label>Module</label><input type="text" value="{{ modules_list|join(', ') if modules_list else 'keine' }}" disabled></div> <div class="field"><label>Module</label><input type="text" value="{{ modules_list|join(', ') if modules_list else 'keine' }}" disabled></div>
{% endif %}
<div class="field"><label>Gültigkeitsdauer</label><input type="text" value="{{ 'Lifetime (unbegrenzt)' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}" disabled></div> <div class="field"><label>Gültigkeitsdauer</label><input type="text" value="{{ 'Lifetime (unbegrenzt)' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}" disabled></div>
{% if ticket.role == 'customer' %}
<div class="field"> <div class="field">
<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 %} {% 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 +85,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">
@@ -79,58 +93,44 @@
{{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}) {{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }})
</button> </button>
</form> </form>
{% if ticket.role == 'customer' %}
<form method="post"> <form method="post">
<input type="hidden" name="issue_license" value="trial"> <input type="hidden" name="issue_license" value="trial">
<button type="submit" class="btn btn-secondary btn-block">Stattdessen Trial erstellen (30 Tage, alle Module)</button> <button type="submit" class="btn btn-secondary btn-block">Stattdessen Trial erstellen (30 Tage, alle Module)</button>
</form> </form>
{% endif %} {% endif %}
{% endif %}
</div> </div>
<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> {% endif %}
</a> {% else %}
{% if can_edit and l.status in ('revoked', 'issued') %} <p class="text-faint" style="font-size:12.5px;">Noch keine Lizenz aus diesem Ticket erstellt.</p>
<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> </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> </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) }}">
+18 -1
View File
@@ -35,6 +35,15 @@
<div class="hint" style="margin-bottom:14px;">Weitere Angaben (Anschrift, Ansprechpartner, Telefon) lassen sich anschließend unter <a href="{{ url_for('customers') }}">Kunden</a> ergänzen.</div> <div class="hint" style="margin-bottom:14px;">Weitere Angaben (Anschrift, Ansprechpartner, Telefon) lassen sich anschließend unter <a href="{{ url_for('customers') }}">Kunden</a> ergänzen.</div>
</div> </div>
{% if can_issue_license_server %}
<div class="field">
<label class="check-row">
<input type="checkbox" name="role" value="license_server" id="role_license_server" onchange="toggleLicenseServerRole()">
Dies ist eine Lizenzserver-Lizenz {{ hi.hint_icon("Für einen Sub-Lizenzserver, nicht für eine gewöhnliche TESM-Kundeninstanz. Die daraus ausgestellte Lizenz macht die Zielinstanz zu einem vollwertigen Lizenzserver -- eigene Kunden/Tickets/Lizenzen ausstellen, Benutzer/Gruppen/Einstellungen verwalten -- außer selbst weitere Lizenzserver-Lizenzen ausstellen (das bleibt strukturell dieser Instanz vorbehalten). Kein Self-Service-Zugang, Module sind bedeutungslos.", "Lizenzserver-Lizenz") }}
</label>
</div>
{% endif %}
<div class="field"> <div class="field">
<label for="license_type">Lizenztyp {{ hi.hint_icon("Trial: zeitlich begrenzt, voller Zugriff. Standard: alle lizenzpflichtigen Basis-Funktionen, keine Zusatzmodule. Custom: Standard + einzeln wählbare Module. Enterprise: alles. Unabhängig vom hier gewählten Typ kann aus jedem Ticket zusätzlich jederzeit eine separate Trial-Lizenz erzeugt werden.", "Lizenztyp") }}</label> <label for="license_type">Lizenztyp {{ hi.hint_icon("Trial: zeitlich begrenzt, voller Zugriff. Standard: alle lizenzpflichtigen Basis-Funktionen, keine Zusatzmodule. Custom: Standard + einzeln wählbare Module. Enterprise: alles. Unabhängig vom hier gewählten Typ kann aus jedem Ticket zusätzlich jederzeit eine separate Trial-Lizenz erzeugt werden.", "Lizenztyp") }}</label>
<select name="license_type" id="license_type" onchange="toggleModuleFields()" required> <select name="license_type" id="license_type" onchange="toggleModuleFields()" required>
@@ -90,7 +99,12 @@ function toggleNewCustomerFields() {
} }
function toggleModuleFields() { function toggleModuleFields() {
const type = document.getElementById("license_type").value; const type = document.getElementById("license_type").value;
document.getElementById("moduleFields").classList.toggle("hidden", type !== "custom"); const roleBox = document.getElementById("role_license_server");
const isLicenseServer = roleBox && roleBox.checked;
// Module sind bei einer Lizenzserver-Lizenz bedeutungslos (siehe
// ticket_new() in app.py, erzwingt dort ohnehin modules=[]) -- Feld
// bleibt unabhaengig vom Lizenztyp ausgeblendet.
document.getElementById("moduleFields").classList.toggle("hidden", isLicenseServer || type !== "custom");
// Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne // Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne
// jedes Modul -- serverseitig ohnehin unterbunden (siehe ticket_new() // jedes Modul -- serverseitig ohnehin unterbunden (siehe ticket_new()
// in app.py), hier nur als klares UI-Signal ausgegraut. // in app.py), hier nur als klares UI-Signal ausgegraut.
@@ -101,6 +115,9 @@ function toggleModuleFields() {
toggleLifetime(); toggleLifetime();
} }
} }
function toggleLicenseServerRole() {
toggleModuleFields();
}
function toggleLifetime() { function toggleLifetime() {
const isLifetime = document.getElementById("lifetime").checked; const isLifetime = document.getElementById("lifetime").checked;
document.getElementById("validDaysField").classList.toggle("hidden", isLifetime); document.getElementById("validDaysField").classList.toggle("hidden", isLifetime);
+4 -1
View File
@@ -45,7 +45,10 @@
data-sort-status="{{ t.license.status if t.license else '' }}" data-sort-status="{{ t.license.status if t.license else '' }}"
data-sort-heartbeat="{{ t.license.last_heartbeat_at if t.license and t.license.last_heartbeat_at else '' }}"> data-sort-heartbeat="{{ t.license.last_heartbeat_at if t.license and t.license.last_heartbeat_at else '' }}">
<tr> <tr>
<td class="cell-name">{{ t.customer_name }}</td> <td class="cell-name">
{{ t.customer_name }}
{% if t.role == 'license_server' %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
</td>
<td>{{ type_labels.get(t.type, t.type) }}</td> <td>{{ type_labels.get(t.type, t.type) }}</td>
<td class="text-dim">{{ t.modules_list|join(', ') if t.modules_list else '—' }}</td> <td class="text-dim">{{ t.modules_list|join(', ') if t.modules_list else '—' }}</td>
<td class="text-dim">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</td> <td class="text-dim">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</td>