Compare commits

10 Commits
Author SHA1 Message Date
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
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
alientimandClaude Sonnet 5 cee5311b1d Ticketverwaltung, Kundenstammdaten, Kundenportal, Lifetime-Lizenzen, Dashboard-Kacheln (v1.2.0)
Reaktion auf mehrere Bug-Reports und Feature-Wuensche zum bestehenden
Ticket-System (v1.1.0): 'Logs gehen nicht', 'Ticketverwaltung fehlt',
'Lizenzen koennen nicht vom Dashboard geloescht werden', plus eine Reihe
konkreter neuer Anforderungen.

## Bugfixes

- /logs: behob einen Absturz (jinja2.exceptions.UndefinedError:
  'global_check_interval' is undefined) UND die eigentliche Ursache
  dahinter -- logs.html war unveraendert aus dem TESM-Fork uebernommen
  (PoE-Geraete-Polling-Text/-Farblogik, ein Template-Feld, das app.py nie
  befuellt hat) und ausserdem schrieb auf diesem Server ueberhaupt nichts
  in live.log (kein poe.sh-Aequivalent vorhanden). Live-Log zeigt jetzt
  echte Ticket-/Lizenzaktionen, mit passendem Hinweistext und Faerbung.
- Lizenz-Loeschung war bereits vorhanden, aber nur ueber Ticket- ->
  Lizenz-Detailseite erreichbar -- jetzt zusaetzlich inline in der
  Lizenzhistorie jedes Tickets.

## Ticketverwaltung (neu: eigene Seite /tickets)

- Volle CRUD: Liste mit Suche, Bearbeiten (Typ/Module/Laufzeit/Lifetime,
  wirkt nur auf die naechste Ausstellung), Loeschen (nur wenn keine
  offene Lizenz mehr besteht, dann inkl. Historie).
- Dashboard (/) zeigt seitdem NICHT mehr die Ticket-Tabelle, sondern eine
  rein informative Kachel-Uebersicht der aktuell AKTIVEN Lizenzen, exakt
  im Kachel-Stil von TESMs eigenem Geraete-Dashboard: Ampelfarben (gruen
  >90 Tage, orange 30-90, rot <30/abgelaufen), gruppiert, Firma + Hostname
  der Kundeninstanz. Hostname kommt neu vom TESM-Client per Activate/
  Heartbeat (srv/tesm/licensing.py, abwaerts-kompatibel: alte Clients ohne
  das Feld ueberschreiben nie einen bereits bekannten Hostnamen).

## Kundenstammdaten

- Neue Felder: Strasse/Hausnummer/PLZ/Ort/Ansprechpartner. E-Mail ist beim
  Anlegen jetzt Pflicht; beim Bearbeiten bewusst nur validiert, wenn
  ausgefuellt (sonst waeren Bestandskunden aus der Zeit vor dieser
  Umstellung fuer JEDE Aenderung blockiert gewesen).

## Kundenportal (neu, per Magic-Link, kein Login)

- /portal/<token>: zeigt einem Kunden ALLE seine Tickets/Lizenzen auf
  einen Blick (ergaenzt den bestehenden Pro-Ticket-Link
  /self-service/<ticket_id>) und erlaubt ihm, seine eigenen Kontakt-/
  Anschriftdaten selbst zu pflegen -- Firmenname und kommerzielle
  Ticket-Eckdaten bleiben bewusst admin-verwaltet. token = eigene Spalte
  license_customers.portal_token, rueckwirkend fuer Bestandskunden erzeugt.

## Lifetime-Lizenzen

- Neues Ticket-Flag 'Lifetime': ignoriert die Laufzeit fuer jede REGULAERE
  Ausstellung (Sentinel-Ablaufdatum 2099-12-31, licensing.
  LIFETIME_EXPIRES_AT) -- eine zusaetzlich erzeugte Trial-Lizenz aus
  demselben Ticket bleibt immer die normale 30-Tage-Variante. Trial ALS
  TICKET-GRUNDTYP + Lifetime wird serverseitig verhindert (haette eine
  dauerhafte, nie ablaufende Lizenz ganz ohne Module ergeben).

## Absicherung nach Review

Ein Adversarial-Review-Durchlauf ueber den kompletten Diff deckte vor dem
Deploy zusaetzlich auf und wurde behoben:
- TOCTOU-Race: zwei nahezu gleichzeitige 'Lizenz erstellen'-Anfragen fuer
  dasselbe Ticket (z.B. Doppelklick im Self-Service) konnten beide
  durchkommen. Jetzt durch einen partiellen Unique-Index
  (idx_one_open_license_per_ticket) auf DB-Ebene ausgeschlossen; die
  zweite Anfrage bekommt sauber die bereits erstellte Lizenz zurueck statt
  eine zweite zu erzeugen.
- Analoger Unique-Index auf license_customers.portal_token als Backstop
  fuer den neuen Magic-Link.
- tickets.html nutzte noch die alte, feste '<=30 Tage'-Schwelle statt der
  gemeinsamen Ampelfarbe -- Lizenzen im 30-90-Tage-Bereich blieben dort
  unmarkiert, obwohl das Dashboard sie schon als orange zeigte.

SCHEMA_VERSION 2 -> 3 (neue Spalten/Tabellen, ueber install.sh mit
Backup+Health-Check abgesichert).

Verifiziert: vollstaendige Migration gegen eine echte Kopie der
Live-Datenbank, anschliessend 22 Funktionstests direkt gegen die echten
Routen (Validierung, Lifetime-Ausstellung, Sperr-/Kaskadenregeln bei
Loeschungen, Kundenportal inkl. ungueltiger Eingaben) sowie ein Playwright-
Seitenladetest ohne Konsolenfehler -- beides nach den Review-Fixes erneut
komplett durchlaufen, inkl. direktem Nachweis, dass der neue Unique-Index
eine doppelte offene Lizenz pro Ticket tatsaechlich auf DB-Ebene ablehnt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:51:20 +02:00
alientimandClaude Sonnet 5 7e39320c87 Ticket-System fuer Kunden-Self-Service + Fingerprint-Auto-Supersede (v1.1.0)
Konzept: Kunde kauft eine Lizenz -> Admin legt einen Kunden an (oder nutzt
einen bestehenden) und weist ihm ein TICKET fuer eine Lizenz mit festen
Eckdaten (Typ/Module/Laufzeit) zu -- das Ticket ist die dauerhafte,
dem Kunden gehoerende Berechtigung, unabhaengig von der konkreten
signierten Lizenzdatei darunter. Admin ODER Kunde (per Self-Service-Link,
kein Login noetig) koennen aus einem offenen Ticket jederzeit eine Lizenz
erstellen, widerrufen und erneut erstellen -- ein Kunde kann mehrere
Tickets haben (z.B. mehrere Systeme).

Neu:
- license_tickets-Tabelle (id = unerratbares Token, customer_id, Typ/
  Module/Laufzeit, status 'open'/'blocked'), licenses.ticket_id-Spalte.
  Rueckwirkende Migration erzeugt fuer jede bereits VOR diesem Feature
  direkt ausgestellte Lizenz automatisch ein passendes Ticket (sonst
  waeren z.B. POETESTs bereits aktivierte Produktivlizenz oder die
  Demo-Lizenzen im neuen, rein ticket-basierten Dashboard verschwunden) --
  gegen eine echte Kopie der Live-Datenbank getestet.
- /tickets/new (Ticket anlegen, admin), /tickets/<id> (Details, Lizenz
  aus Ticket erstellen -- reguraer laut Ticket-Typ ODER als Trial mit
  vollem Modulumfang unabhaengig vom Ticket-Typ --, Ticket sperren/
  entsperren).
- /self-service/<ticket_id>: oeffentlich erreichbar (kein Login), Zugriff
  ueber Besitz des unerratbaren Ticket-Tokens (192 Bit Zufall) als 'Magic
  Link' -- Kunde kann eigene Lizenz herunterladen, widerrufen und (mit den
  Ticket-Eckdaten) neu erstellen. Link wird auf der Ticket-Seite angezeigt
  und in die 'Lizenz per E-Mail senden'-Nachricht aufgenommen.
- /licenses/<id>/delete (admin): raeumt widerrufene oder nie aktivierte
  Lizenzen auf, OHNE das zugehoerige Ticket anzutasten -- der Kunde (oder
  Admin) kann sich ueber das Ticket jederzeit eine neue mit denselben
  Eckdaten erzeugen.
- Bugfix: wird fuer einen Fingerprint eine neue Lizenz aktiviert (z.B.
  Trial durch Enterprise ersetzt), werden jetzt automatisch alle ANDEREN
  fuer denselben Fingerprint noch 'active' vermerkten Lizenzen auf
  'deactivated' gesetzt -- vorher blieben beide faelschlich gleichzeitig
  aktiv in der Uebersicht stehen. Bewusst nach Fingerprint gefiltert (ein
  Kunde darf mehrere Systeme mit je eigener Lizenz betreiben).
- Dashboard zeigt jetzt Tickets statt roher Lizenzzeilen (inkl. aktueller
  Lizenz je Ticket), Kunden-Seite zeigt Ticket-Anzahl statt Lizenz-Anzahl
  mit Filter-Link aufs Dashboard.

SCHEMA_VERSION 1 -> 2 (neue Tabelle + Spalte, ueber install.sh-
Sicherheitsnetz mit Backup+Health-Check abgesichert).

Verifiziert: Migration gegen eine echte Kopie der Live-Datenbank
(POETESTs aktive Produktivlizenz + 3 Demo-Lizenzen) durchlaufen lassen --
alle 8 bestehenden Lizenzen haben danach ein Ticket, keine verwaist.
Anschliessend alle neuen Seiten (Dashboard, Ticket-Anlage, Ticket-Detail,
Self-Service) per Playwright gegen diese migrierte Kopie auf einem
isolierten Scratch-Port durchgeklickt, keine Fehler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:41:51 +02:00
alientimandClaude Sonnet 5 b280cbf121 Version 1.0.1: update.sh-Fix fuer 'latest'-Aufloesung
Kein App-Code geaendert -- reiner Versions-/Release-Bump, damit 'latest'
auch als heruntergeladenes Paket das bereits korrigierte update.sh
enthaelt (siehe vorheriger Commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:50:48 +02:00
alientimandClaude Sonnet 5 5fa60ec3ef update.sh: 'latest' zuverlaessig ueber die Gitea-API aufloesen
Identischer Bug/Fix wie im TESM-Hauptrepo (siehe dortiger Commit fuer
Details): /releases/download/latest/tesm-license-latest.tar.gz liefert
HTTP 200, aber Giteas automatisch generiertes Quellcode-Archiv des
aktuellen Default-Branch-Stands statt des echten Release-Assets.
'latest' wird jetzt zuerst per Gitea-API auf den echten Tag aufgeloest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:48:27 +02:00
21 changed files with 2552 additions and 320 deletions
+1 -1
View File
@@ -1 +1 @@
1
3
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.2.6
+1323 -108
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -115,6 +115,12 @@ CREATE TABLE IF NOT EXISTS license_customers (
name TEXT NOT NULL,
contact_email TEXT,
contact_phone TEXT,
contact_person TEXT,
street TEXT,
house_number TEXT,
postal_code TEXT,
city TEXT,
portal_token TEXT,
notes TEXT,
created_at TEXT NOT NULL
);
@@ -133,6 +139,7 @@ CREATE TABLE IF NOT EXISTS licenses (
license_file_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'issued',
fingerprint TEXT,
hostname TEXT,
activated_at TEXT,
deactivated_at TEXT,
last_heartbeat_at TEXT,
@@ -140,6 +147,35 @@ CREATE TABLE IF NOT EXISTS licenses (
role TEXT NOT NULL DEFAULT 'customer',
created_by TEXT,
created_at TEXT NOT NULL,
ticket_id TEXT,
lifetime INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS license_tickets (
id TEXT PRIMARY KEY,
customer_id INTEGER NOT NULL,
type TEXT NOT NULL,
modules TEXT NOT NULL DEFAULT '[]',
valid_days INTEGER NOT NULL,
lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
role TEXT NOT NULL DEFAULT 'customer',
created_by TEXT,
created_at TEXT NOT NULL,
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)
);
""")
@@ -74,6 +74,12 @@ def main():
master_public_key_b64=master_pub,
master_endpoint="",
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:
json.dump(license_file, f, indent=2)
+69 -2
View File
@@ -39,6 +39,31 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
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
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)
@@ -103,14 +128,40 @@ def _parse_iso(s: str) -> float:
return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()
# Sentinel-Ablaufdatum fuer "Lifetime"-Lizenzen. Bewusst ein fixes, weit in
# der Zukunft liegendes Datum statt eines Sonderwerts wie None/0 fuer
# expires_at -- dadurch bleibt JEDE bestehende Datumsvergleichs-/
# Differenzrechnung (hier, im Master-Dashboard, UND im TESM-Client) ohne
# jede Sonderbehandlung korrekt: "in > 90 Tagen faellig" o.ae. ist fuer ein
# Datum im Jahr 2099 schlicht immer wahr. Ob eine Lizenz als "Lifetime"
# geflaggt ist, wird zusaetzlich explizit in der Master-DB gespeichert
# (licenses.lifetime) -- dieses Sentinel-Datum ist nur die tatsaechlich in
# die signierte Lizenzdatei eingebettete, fuer den Client sichtbare Reprae-
# sentation davon.
LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
# ================================================================ Lizenz ==
def issue_license(*, customer, license_type, modules, valid_days,
master_private_key_b64, master_public_key_b64,
master_endpoint, vendor, license_id=None, now=None):
master_endpoint, vendor, license_id=None, now=None, lifetime=False,
role="customer"):
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
frischem Pro-Lizenz-Schlüsselpaar.
lifetime=True ignoriert valid_days komplett und setzt stattdessen das
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
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
@@ -118,6 +169,8 @@ def issue_license(*, customer, license_type, modules, valid_days,
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
if role not in LICENSE_ROLES:
raise ValueError(f"Unbekannte Rolle: {role!r} (erlaubt: {LICENSE_ROLES})")
unknown = set(modules) - set(ALL_MODULES)
if unknown:
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
@@ -130,11 +183,12 @@ def issue_license(*, customer, license_type, modules, valid_days,
"type": license_type,
"modules": sorted(modules),
"issued_at": _iso(now),
"expires_at": _iso(now + valid_days * 86400),
"expires_at": LIFETIME_EXPIRES_AT if lifetime else _iso(now + valid_days * 86400),
"license_pubkey": license_pub,
"master_pubkey": master_public_key_b64,
"master_endpoint": master_endpoint,
"vendor": vendor,
"role": role,
}
signature = sign_payload(payload, master_private_key_b64)
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
@@ -174,6 +228,12 @@ def license_status(license_file: dict, now=None) -> dict:
"type": license_file.get("type"),
"customer": license_file.get("customer"),
"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"),
}
@@ -225,6 +285,13 @@ def build_client_request(action, license_file, nonce=None) -> dict:
payload = {
"license_id": license_file["license_id"],
"fingerprint": system_fingerprint(),
# Rein informativ fuer die Dashboard-Kacheln des Masters (siehe
# dortiges index()) -- fliesst NICHT in irgendeine Sicherheits-
# entscheidung ein (die bleibt allein Sache des Fingerprints), wird
# aber wie alle anderen Felder mitsigniert, damit ein Angreifer
# ohne den privaten Lizenzschluessel keinen falschen Hostnamen
# unterschieben kann.
"hostname": socket.gethostname(),
"action": action,
"nonce": nonce or uuid.uuid4().hex,
"timestamp": _iso(time.time()),
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mein Konto · 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" style="max-width:720px;">
<h1>Mein Konto</h1>
<div class="hint" style="text-align:center; margin-bottom:18px;">{{ customer.name }}</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="field" style="margin-bottom:18px;">
{% for message in messages %}
<div class="toast danger" style="position:static; animation:none;">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="card card-pad" style="margin-bottom:20px;">
<h2 style="font-size:14px; margin-bottom:14px;">Meine Tickets</h2>
{% if tickets %}
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Typ</th><th>Module</th><th>Laufzeit</th><th>Lizenz-Status</th><th style="width:1%;"></th></tr></thead>
{% for t in tickets %}
<tbody>
<tr>
<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">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</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>
{% if t.license.status == 'active' %}
{% if t.license.is_lifetime %} · Lifetime
{% elif t.license.is_expired %} · <span style="color:var(--danger);">abgelaufen</span>
{% elif t.license.days_left is not none %} · {{ t.license.days_left }} Tage
{% endif %}
{% endif %}
{% else %}
<span class="text-faint">Noch keine Lizenz</span>
{% endif %}
</td>
<td><a href="{{ url_for('self_service', ticket_id=t.id) }}" class="btn btn-sm btn-secondary">Verwalten →</a></td>
</tr>
</tbody>
{% endfor %}
</table>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Sie sind aktuell keine Tickets hinterlegt.</p>
{% endif %}
</div>
<div class="card card-pad">
<h2 style="font-size:14px; margin-bottom:14px;">Meine Kontaktdaten</h2>
<form method="post">
<div class="field"><label>Ansprechpartner</label><input type="text" name="contact_person" value="{{ customer.contact_person or '' }}"></div>
<div class="field"><label>Telefonnummer</label><input type="text" name="contact_phone" value="{{ customer.contact_phone or '' }}"></div>
<div class="field"><label>E-Mail-Adresse *</label><input type="email" name="contact_email" value="{{ customer.contact_email or '' }}" required></div>
<div class="field-group">
<div class="field"><label>Straße</label><input type="text" name="street" value="{{ customer.street or '' }}"></div>
<div class="field"><label>Hausnummer</label><input type="text" name="house_number" value="{{ customer.house_number or '' }}"></div>
</div>
<div class="field-group">
<div class="field"><label>PLZ</label><input type="text" name="postal_code" value="{{ customer.postal_code or '' }}"></div>
<div class="field"><label>Ort</label><input type="text" name="city" value="{{ customer.city or '' }}"></div>
</div>
<button type="submit" class="btn btn-primary btn-block">Speichern</button>
</form>
</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>
+61 -14
View File
@@ -27,24 +27,35 @@
<div style="overflow-x:auto;">
<table class="data-table" id="customersTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Kunde</th>
<th data-sort-key="name">Firma</th>
<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-email="{{ (c.contact_email or '')|lower }}" data-sort-licenses="{{ c.license_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">{{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %}</td>
<td class="text-dim">{{ c.license_count }}</td>
<td class="text-dim">
{% if c.street or c.city %}
{{ c.street }} {{ c.house_number }}{% if c.street %}<br>{% endif %}{{ c.postal_code }} {{ c.city }}
{% else %}—{% endif %}
</td>
<td class="text-dim">
{{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %}
{% 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 %}
<button class="icon-btn" title="Bearbeiten" data-open-modal="editCustomerModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
{% if c.license_count == 0 %}
{% if c.ticket_count == 0 %}
<form method="post" data-confirm="Kunde „{{ c.name }}“ wirklich löschen?">
<input type="hidden" name="delete_customer" value="{{ c.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
@@ -54,7 +65,7 @@
{% endif %}
{% endif %}
{% if current_user.has_permission('licenses.create') %}
<a class="icon-btn" title="Lizenz ausstellen" href="{{ url_for('license_issue') }}?customer_id={{ c.id }}">
<a class="icon-btn" title="Neues Ticket" href="{{ url_for('ticket_new') }}?customer_id={{ c.id }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9m-4 0H3m2 0a2 2 0 100-4 2 2 0 000 4zm4 4h.01"/></svg>
</a>
{% endif %}
@@ -64,7 +75,7 @@
</tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="4">Noch keine Kunden angelegt.</td></tr>
<tr class="empty-row"><td colspan="6">Noch keine Kunden angelegt.</td></tr>
</tbody>
{% endfor %}
</table>
@@ -73,7 +84,7 @@
{% for c in customers %}
<div class="modal-overlay" id="editCustomerModal{{ loop.index }}">
<div class="modal" style="max-width:480px;">
<div class="modal" style="max-width:520px;">
<form method="post">
<input type="hidden" name="edit_customer" value="{{ c.id }}">
<div class="modal-header">
@@ -82,17 +93,41 @@
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<label>Firma / Name</label>
<input type="text" name="name" value="{{ c.name }}" required {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>E-Mail</label>
<label>Ansprechpartner</label>
<input type="text" name="contact_person" value="{{ c.contact_person or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>E-Mail{% if not c.contact_email %} <span class="text-faint" style="font-weight:400;">(fehlt noch -- bitte nachtragen)</span>{% endif %}</label>
<input type="email" name="contact_email" value="{{ c.contact_email or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>Telefon</label>
<input type="text" name="contact_phone" value="{{ c.contact_phone or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field-group">
<div class="field">
<label>Straße</label>
<input type="text" name="street" value="{{ c.street or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>Hausnummer</label>
<input type="text" name="house_number" value="{{ c.house_number or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="field-group">
<div class="field">
<label>PLZ</label>
<input type="text" name="postal_code" value="{{ c.postal_code or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
<div class="field">
<label>Ort</label>
<input type="text" name="city" value="{{ c.city or '' }}" {% if not can_edit %}disabled{% endif %}>
</div>
</div>
<div class="field">
<label>Notizen</label>
<textarea name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{{ c.notes or '' }}</textarea>
@@ -113,7 +148,7 @@
{% endfor %}
<div class="modal-overlay" id="addCustomerModal">
<div class="modal" style="max-width:480px;">
<div class="modal" style="max-width:520px;">
<form method="post">
<input type="hidden" name="new_customer" value="1">
<div class="modal-header">
@@ -122,17 +157,29 @@
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<label>Firma / Name</label>
<input type="text" name="name" required placeholder="z.B. Musterfirma GmbH">
</div>
<div class="field">
<label>E-Mail</label>
<input type="email" name="contact_email">
<label>Ansprechpartner</label>
<input type="text" name="contact_person">
</div>
<div class="field">
<label>E-Mail *</label>
<input type="email" name="contact_email" required>
</div>
<div class="field">
<label>Telefon</label>
<input type="text" name="contact_phone">
</div>
<div class="field-group">
<div class="field"><label>Straße</label><input type="text" name="street"></div>
<div class="field"><label>Hausnummer</label><input type="text" name="house_number"></div>
</div>
<div class="field-group">
<div class="field"><label>PLZ</label><input type="text" name="postal_code"></div>
<div class="field"><label>Ort</label><input type="text" name="city"></div>
</div>
<div class="field">
<label>Notizen</label>
<textarea name="notes" rows="3"></textarea>
+87 -69
View File
@@ -1,11 +1,11 @@
{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Dashboard{% endblock %}
{% block page_sub %}<div class="topbar-sub">Kunden-/Lizenzübersicht</div>{% endblock %}
{% block page_sub %}<div class="topbar-sub">Übersicht aktiver Kundeninstanzen</div>{% endblock %}
{% block content %}
{% if licenses is none %}
{% if buckets is none %}
<div class="card card-pad" style="max-width:560px; margin:40px auto; text-align:center;">
<p class="text-faint" style="font-size:13px;">
{% if not current_user.is_authenticated %}Bitte anmelden.{% else %}Keine Berechtigung, Lizenzen anzusehen.{% endif %}
@@ -15,87 +15,105 @@
<div class="section-head">
<div>
<h2 style="font-size:16px;">Lizenzen</h2>
<h2 style="font-size:16px;">Aktive Lizenzen</h2>
<div class="hint">
Rein informative Übersicht nach Restlaufzeit der Ablauffristen (grün &gt; 90 Tage, orange 30-90 Tage, rot &lt; 30 Tage). Tickets anlegen, bearbeiten oder Lizenzen ausstellen/widerrufen geschieht unter <a href="{{ url_for('tickets_list') }}" style="color:var(--accent-strong); font-weight:600;">Tickets</a>.
</div>
</div>
{% if can_create %}
<a href="{{ url_for('license_issue') }}" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Lizenz ausstellen
</a>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="licenseSearch" placeholder="Lizenzen durchsuchen…" oninput="filterLicensesTable()">
<div class="stat-row">
<div class="stat-card">
<div class="stat-label">Rot (&lt; 30 Tage)</div>
<div class="stat-value offline">{{ stats.red }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Orange (30-90 Tage)</div>
<div class="stat-value" style="color:var(--warning);">{{ stats.orange }}</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="licensesTable" data-sortable>
<thead><tr>
<th data-sort-key="customer">Kunde</th>
<th data-sort-key="type">Typ</th>
<th>Module</th>
<th data-sort-key="expires">Ablauf</th>
<th data-sort-key="status">Status</th>
<th data-sort-key="heartbeat">Letzter Heartbeat</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
{% for l in licenses %}
<tbody data-sort-customer="{{ l.customer_name|lower }}" data-sort-type="{{ l.type }}"
data-sort-expires="{{ l.expires_at }}" data-sort-status="{{ l.status }}"
data-sort-heartbeat="{{ l.last_heartbeat_at or '' }}">
<tr>
<td class="cell-name">{{ l.customer_name }}</td>
<td>{{ type_labels.get(l.type, l.type) }}</td>
<td class="text-dim">{{ l.modules_list|join(', ') if l.modules_list else '—' }}</td>
<td class="text-dim">
{{ l.expires_at[:10] }}
{% if l.status == 'active' and l.days_left is not none %}
{% if l.is_expired %}<span style="color:var(--danger);">(abgelaufen)</span>
{% elif l.days_left <= 30 %}<span style="color:var(--warning);">({{ l.days_left }} Tage)</span>
{% endif %}
{% endif %}
</td>
<td>
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(l.status, 'disabled') }}">
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }}
</span>
</td>
<td class="text-dim">{{ l.last_heartbeat_at or '—' }}</td>
<td>
<div class="row-actions">
<a class="icon-btn" title="Details" href="{{ url_for('license_detail', license_id=l.license_id) }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</a>
<div class="stat-card">
<div class="stat-label">Grün (&gt; 90 Tage / Lifetime)</div>
<div class="stat-value online">{{ stats.green }}</div>
</div>
</td>
</tr>
</tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="7">Noch keine Lizenzen ausgestellt.</td></tr>
</tbody>
{% endfor %}
</table>
<div class="stat-card">
<div class="stat-label">Aktiv gesamt</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
</div>
{% macro license_tile(l) %}
{% set ds = {'red': 'offline', 'orange': 'unbekannt', 'green': 'online'}[l.color] %}
<div class="device-card is-readonly" data-status="{{ ds }}">
<div class="dc-top">
<div class="dc-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="16" r="1.5"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</div>
<span class="pill {{ {'red': 'offline', 'orange': 'unknown', 'green': 'online'}[l.color] }}">
{% if l.is_lifetime %}Lifetime{% elif l.is_expired %}Abgelaufen{% else %}{{ l.days_left }} Tage{% endif %}
</span>
</div>
<div class="dc-name">{{ l.customer_name }}</div>
<div class="dc-ip">{{ l.hostname or 'Hostname unbekannt' }}</div>
<div class="text-faint" style="font-size:11px; margin-top:2px;">{{ type_labels.get(l.type, l.type) }}</div>
</div>
{% endmacro %}
{% macro section_chevron() %}<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>{% endmacro %}
{% if stats.total %}
{% if buckets.red %}
<div class="dash-section" data-status="red">
<div class="dash-section-title">{{ section_chevron() }}Rot <span class="pill offline">{{ buckets.red|length }}</span></div>
<div class="device-grid">
{% for l in buckets.red %}{{ license_tile(l) }}{% endfor %}
</div>
</div>
{% endif %}
{% if buckets.orange %}
<div class="dash-section" data-status="orange">
<div class="dash-section-title">{{ section_chevron() }}Orange <span class="pill unknown">{{ buckets.orange|length }}</span></div>
<div class="device-grid">
{% for l in buckets.orange %}{{ license_tile(l) }}{% endfor %}
</div>
</div>
{% endif %}
{% if buckets.green %}
<div class="dash-section" data-status="green">
<div class="dash-section-title">{{ section_chevron() }}Grün <span class="pill online">{{ buckets.green|length }}</span></div>
<div class="device-grid">
{% for l in buckets.green %}{{ license_tile(l) }}{% endfor %}
</div>
</div>
{% endif %}
{% else %}
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
Aktuell keine aktivierte Lizenz. Neue Tickets/Lizenzen unter <a href="{{ url_for('tickets_list') }}" style="color:var(--accent-strong); font-weight:600;">Tickets</a> anlegen.
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function filterLicensesTable() {
const el = document.getElementById("licenseSearch");
if (!el) return;
const q = el.value.trim().toLowerCase();
document.querySelectorAll("#licensesTable tbody").forEach(tbody => {
if (tbody.hasAttribute("data-sort-pinned")) return;
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
document.addEventListener("DOMContentLoaded", () => {
const collapsed = new Set();
function apply() {
document.querySelectorAll(".dash-section").forEach(section => {
section.classList.toggle("collapsed", collapsed.has(section.dataset.status));
});
}
}
document.querySelectorAll(".dash-section-title").forEach(title => {
title.addEventListener("click", function () {
const key = this.closest(".dash-section").dataset.status;
if (collapsed.has(key)) collapsed.delete(key); else collapsed.add(key);
apply();
});
});
});
</script>
{% endblock %}
+20 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% set active_page = "index" %}
{% set active_page = "tickets" %}
{% block page_title %}Lizenz{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ customer.name if customer else '?' }}</div>{% endblock %}
@@ -10,6 +10,9 @@
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Status</h2>
{% if license.ticket_id %}
<div class="hint"><a href="{{ url_for('ticket_detail', ticket_id=license.ticket_id) }}">← Zum Ticket</a></div>
{% endif %}
</div>
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(license.status, 'disabled') }}">
{{ {"issued": "Ausgestellt (nicht aktiviert)", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(license.status, license.status) }}
@@ -22,10 +25,17 @@
<div class="field"><label>Ausgestellt am</label><input type="text" value="{{ license.issued_at }}" disabled></div>
<div class="field">
<label>Läuft ab am</label>
{% if license.is_lifetime %}
<input type="text" value="Lifetime (läuft nie ab)" disabled>
{% else %}
<input type="text" value="{{ license.expires_at }}{% if license.days_left is not none %} ({{ license.days_left }} Tage {{ 'verbleibend' if not license.is_expired else 'überschritten' }}){% endif %}" disabled>
{% endif %}
</div>
{% if license.fingerprint %}
<div class="field"><label>System-Fingerabdruck</label><input type="text" class="mono" value="{{ license.fingerprint }}" disabled></div>
{% if license.hostname %}
<div class="field"><label>Hostname</label><input type="text" value="{{ license.hostname }}" disabled></div>
{% endif %}
<div class="field"><label>Aktiviert am</label><input type="text" value="{{ license.activated_at or '' }}" disabled></div>
{% endif %}
{% if license.last_heartbeat_at %}
@@ -67,6 +77,15 @@
{% if current_user.has_permission('licenses.edit') %}
<a href="{{ url_for('license_manual_code') }}" class="btn btn-secondary btn-block" style="margin-top:10px;">Offline-Code verarbeiten</a>
{% endif %}
{% if can_edit and license.status in ('revoked', 'issued') %}
<form method="post" action="{{ url_for('license_delete', license_id=license.license_id) }}" style="margin-top:10px;"
data-confirm="Lizenz wirklich löschen? Das zugehörige Ticket bleibt bestehen -- eine neue Lizenz mit denselben Eckdaten kann jederzeit daraus erstellt werden.">
<button type="submit" class="btn btn-danger btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
Lizenz löschen
</button>
</form>
{% endif %}
</div>
</div>
@@ -1,95 +0,0 @@
{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Lizenz ausstellen{% endblock %}
{% block page_sub %}<div class="topbar-sub">Neue signierte Lizenz für einen Kunden erzeugen</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
{% if not master_licensed %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Dieser Lizenzserver hat selbst keine gültige Lizenz — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
Ausstellen ist erst danach wieder möglich.
</div>
{% endif %}
<form method="post">
<div class="field">
<label for="customer_id">Kunde</label>
<select name="customer_id" id="customer_id" onchange="toggleNewCustomerFields()" required>
<option value="__new__" {% if not preselect_customer_id %}selected{% endif %}>+ Neuer Kunde…</option>
{% for c in customers %}
<option value="{{ c.id }}" {% if preselect_customer_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div id="newCustomerFields" class="field-group">
<div class="field">
<label for="new_customer_name">Name (neuer Kunde)</label>
<input type="text" name="new_customer_name" id="new_customer_name" placeholder="z.B. Musterfirma GmbH">
</div>
<div class="field">
<label for="new_customer_email">E-Mail (neuer Kunde)</label>
<input type="email" name="new_customer_email" id="new_customer_email">
</div>
</div>
<div class="field">
<label for="license_type">Lizenztyp {{ hi.hint_icon("Trial: zeitlich begrenzt, voller Zugriff. Standard: alle lizenzpflichtigen Basis-Funktionen, keine Zusatzmodule. Custom: Standard + einzeln wählbare Module. Enterprise: alles.", "Lizenztyp") }}</label>
<select name="license_type" id="license_type" onchange="toggleModuleFields()" required>
{% for t in license_types %}
<option value="{{ t }}" {% if t == 'standard' %}selected{% endif %}>{{ type_labels.get(t, t) }}</option>
{% endfor %}
</select>
</div>
<div id="moduleFields" class="field hidden">
<label>Module</label>
<div class="check-list">
{% for m in all_modules %}
<label class="check-row">
<input type="checkbox" name="modules" value="{{ m }}">
{{ module_labels.get(m, m) }}
</label>
{% endfor %}
</div>
</div>
<div class="field">
<label for="valid_days">Gültigkeitsdauer (Tage) {{ hi.hint_icon("365 = 1 Jahr, 730 = 2 Jahre. Frei wählbar.", "Gültigkeitsdauer") }}</label>
<input type="number" name="valid_days" id="valid_days" value="365" min="1" required>
<div class="field-hint">
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=30">30 Tage (Trial)</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=365">1 Jahr</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=730">2 Jahre</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block" {% if not master_licensed %}disabled{% endif %}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Lizenz ausstellen
</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleNewCustomerFields() {
const isNew = document.getElementById("customer_id").value === "__new__";
document.getElementById("newCustomerFields").classList.toggle("hidden", !isNew);
}
function toggleModuleFields() {
const type = document.getElementById("license_type").value;
document.getElementById("moduleFields").classList.toggle("hidden", type !== "custom");
}
document.addEventListener("DOMContentLoaded", function () {
toggleNewCustomerFields();
toggleModuleFields();
});
</script>
{% endblock %}
+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>
<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>
+7 -7
View File
@@ -8,7 +8,7 @@
<div class="section-head">
<div>
<h2 style="font-size:16px;">Live-Log</h2>
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline). Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.</div>
<div class="hint">Laufende Ticket-/Lizenzaktionen (Ausstellung, Aktivierung, Widerruf, Heartbeat) dieses Lizenzservers. Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.</div>
</div>
<div class="flex gap-2">
{% if current_user.can_view_log_history %}
@@ -42,10 +42,9 @@
<script>
function colorizeLine(line) {
let cls = "";
if (line.includes(" ist erreichbar!")) cls = "online";
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
if (line.includes(".issue") || line.includes(".activated") || line.includes(".create")) cls = "online";
else if (line.includes(".revoke") || line.includes(".delete") || line.includes(".block") || line.includes("fehlgeschlagen")) cls = "offline";
else if (line.startsWith("----")) cls = "sep";
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
const span = document.createElement("span");
span.className = "log-line" + (cls ? " " + cls : "");
span.textContent = line;
@@ -53,8 +52,10 @@ function colorizeLine(line) {
}
document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000;
// Anders als bei TESM gibt es hier kein konfigurierbares Prüfintervall
// (kein Geräte-Polling) -- ein fester, kurzer Wert reicht für die paar
// Ticket-/Lizenzaktionen pro Tag völlig aus.
const intervalMilliseconds = 20000;
function renderLog(text, logName, totalLines) {
const box = document.getElementById("log-box");
@@ -81,7 +82,6 @@ document.addEventListener("DOMContentLoaded", () => {
}
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
document.addEventListener("poe:check-triggered", fetchLog);
fetchLog();
if (intervalMilliseconds) setInterval(fetchLog, intervalMilliseconds);
});
@@ -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>
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Meine Lizenz · 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" style="max-width:520px;">
<h1>Meine Lizenz</h1>
<div class="hint" style="text-align:center; margin-bottom:18px;">
{{ customer.name if customer else '' }}
<br><a href="{{ portal_request_url }}">← Alle meine Tickets anzeigen</a>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="field" style="margin-bottom:18px;">
{% for message in messages %}
<div class="toast danger" style="position:static; animation:none;">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% if ticket.status != 'open' %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Dieses Ticket ist gesperrt. Bitte wenden Sie sich an Ihren Ansprechpartner.
</div>
{% endif %}
<div class="field"><label>Lizenztyp</label><input type="text" value="{{ type_labels.get(ticket.type, ticket.type) }}" disabled></div>
<div class="field"><label>Module</label><input type="text" value="{{ modules_list|join(', ') if modules_list else 'keine' }}" disabled></div>
{% if license %}
<div class="field">
<label>Status</label>
<input type="text" value="{{ {'issued': 'Ausgestellt (noch nicht aktiviert)', 'active': 'Aktiv', 'deactivated': 'Deaktiviert', 'revoked': 'Widerrufen'}.get(license.status, license.status) }}" disabled>
</div>
<div class="field"><label>Gültig bis</label><input type="text" value="{{ 'Lifetime (läuft nie ab)' if license.is_lifetime else license.expires_at }}" disabled></div>
{% if license.status in ('issued', 'active') %}
<a href="{{ url_for('self_service_download', ticket_id=ticket.id) }}" class="btn btn-secondary btn-block" style="margin-bottom:10px;">
Lizenzdatei herunterladen
</a>
<form method="post" action="{{ url_for('self_service_revoke', ticket_id=ticket.id) }}"
data-confirm="Lizenz wirklich widerrufen? Das aktuelle System verliert danach den Zugriff. Sie können anschließend jederzeit eine neue Lizenz erstellen.">
<button type="submit" class="btn btn-danger btn-block">Lizenz widerrufen (z.B. bei Systemwechsel)</button>
</form>
{% else %}
<a href="{{ url_for('self_service_download', ticket_id=ticket.id) }}" class="btn btn-secondary btn-block">
Letzte Lizenzdatei herunterladen
</a>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für dieses Ticket wurde noch keine Lizenz erstellt.</p>
{% endif %}
{% if can_issue_new %}
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post" action="{{ url_for('self_service_issue', ticket_id=ticket.id) }}" style="margin-bottom:10px;">
<input type="hidden" name="issue_license" value="regular">
<button type="submit" class="btn btn-primary btn-block">
Neue {{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }})
</button>
</form>
<form method="post" action="{{ url_for('self_service_issue', ticket_id=ticket.id) }}">
<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>
</form>
{% endif %}
<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,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 %}
@@ -0,0 +1,206 @@
{% extends "base.html" %}
{% set active_page = "tickets" %}
{% block page_title %}Ticket{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ customer.name if customer else '?' }}</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">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' }}">
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }}
</span>
</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>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>
{% endif %}
<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">
<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>
<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_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>
Bearbeiten
</button>
<form method="post" style="flex:1;">
<input type="hidden" name="toggle_block" value="1">
<button type="submit" class="btn {{ 'btn-danger' if ticket.status == 'open' else 'btn-secondary' }} btn-block">
{{ 'Sperren' if ticket.status == 'open' else 'Entsperren' }}
</button>
</form>
</div>
{% if can_delete_ticket %}
<form method="post" action="{{ url_for('ticket_delete', ticket_id=ticket.id) }}" style="margin-top:10px;"
data-confirm="Ticket für „{{ customer.name if customer else '?' }}“ wirklich löschen? Die Lizenzhistorie dieses Tickets geht dabei verloren.">
<button type="submit" class="btn btn-danger btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
Ticket löschen
</button>
</form>
{% else %}
<div class="hint" style="margin-top:10px;">Ticket kann erst gelöscht werden, wenn keine aktive/ausgestellte Lizenz mehr besteht.</div>
{% endif %}
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Lizenz aus diesem Ticket erstellen</h2></div>
</div>
{% if not master_licensed %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Dieser Lizenzserver hat selbst keine gültige Lizenz — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
</div>
{% elif ticket.status != 'open' %}
<p class="text-faint" style="font-size:12.5px;">Ticket ist gesperrt — bitte zuerst entsperren.</p>
{% elif has_open_license %}
<p class="text-faint" style="font-size:12.5px;">
Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann
eine neue erstellt werden.
</p>
{% 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">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
{{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }})
</button>
</form>
{% if ticket.role == 'customer' %}
<form method="post">
<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>
</form>
{% endif %}
{% endif %}
</div>
<div class="card card-pad" style="grid-column: 1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<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>
{% 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>
<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>
</div>
{% 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) }}">
<div class="modal-header">
<h3>Ticket bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="hint" style="margin-bottom:14px;">
Wirkt sich nur auf die NÄCHSTE aus diesem Ticket erstellte Lizenz aus -- bereits ausgestellte Lizenzen bleiben unverändert.
</div>
<div class="field">
<label>Lizenztyp</label>
<select name="license_type" id="editLicenseType" onchange="toggleEditModuleFields()">
{% for lt in license_types %}
<option value="{{ lt }}" {% if lt == ticket.type %}selected{% endif %}>{{ type_labels.get(lt, lt) }}</option>
{% endfor %}
</select>
</div>
<div id="editModuleFields" class="field {% if ticket.type != 'custom' %}hidden{% endif %}">
<label>Module</label>
<div class="check-list">
{% for m in all_modules %}
<label class="check-row">
<input type="checkbox" name="modules" value="{{ m }}" {% if m in modules_list %}checked{% endif %}>
{{ module_labels.get(m, m) }}
</label>
{% endfor %}
</div>
</div>
<div class="field">
<label class="check-row">
<input type="checkbox" name="lifetime" value="1" id="editLifetime" {% if ticket.lifetime %}checked{% endif %}
{% if ticket.type == 'trial' %}disabled{% endif %} onchange="toggleEditLifetime()">
Lifetime (unbegrenzte Laufzeit)
</label>
</div>
<div class="field" id="editValidDaysField" {% if ticket.lifetime %}style="display:none;"{% endif %}>
<label>Gültigkeitsdauer (Tage)</label>
<input type="number" name="valid_days" id="editValidDays" value="{{ ticket.valid_days }}" min="1">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function toggleEditModuleFields() {
const type = document.getElementById("editLicenseType").value;
document.getElementById("editModuleFields").classList.toggle("hidden", type !== "custom");
const lifetimeBox = document.getElementById("editLifetime");
lifetimeBox.disabled = (type === "trial");
if (type === "trial" && lifetimeBox.checked) {
lifetimeBox.checked = false;
toggleEditLifetime();
}
}
function toggleEditLifetime() {
document.getElementById("editValidDaysField").style.display = document.getElementById("editLifetime").checked ? "none" : "";
}
</script>
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% set active_page = "tickets" %}
{% block page_title %}Neues Ticket{% endblock %}
{% block page_sub %}<div class="topbar-sub">Berechtigung eines Kunden auf eine Lizenz mit festen Eckdaten anlegen</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
<div class="hint" style="margin-bottom:16px;">
Legt nur die Berechtigung an (Typ/Module/Laufzeit) — die eigentliche signierte Lizenzdatei wird danach auf der
Ticket-Seite erzeugt, durch dich oder direkt durch den Kunden selbst über seinen Self-Service-Link.
</div>
<form method="post">
<div class="field">
<label for="customer_id">Kunde</label>
<select name="customer_id" id="customer_id" onchange="toggleNewCustomerFields()" required>
<option value="__new__" {% if not preselect_customer_id %}selected{% endif %}>+ Neuer Kunde…</option>
{% for c in customers %}
<option value="{{ c.id }}" {% if preselect_customer_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div id="newCustomerFields" class="field-group">
<div class="field">
<label for="new_customer_name">Name (neuer Kunde)</label>
<input type="text" name="new_customer_name" id="new_customer_name" placeholder="z.B. Musterfirma GmbH">
</div>
<div class="field">
<label for="new_customer_email">E-Mail (neuer Kunde) *</label>
<input type="email" name="new_customer_email" id="new_customer_email">
</div>
<div 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>
{% 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">
<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>
{% for t in license_types %}
<option value="{{ t }}" {% if t == 'standard' %}selected{% endif %}>{{ type_labels.get(t, t) }}</option>
{% endfor %}
</select>
</div>
<div id="moduleFields" class="field hidden">
<label>Module</label>
<div class="check-list">
{% for m in all_modules %}
<label class="check-row">
<input type="checkbox" name="modules" value="{{ m }}">
{{ module_labels.get(m, m) }}
</label>
{% endfor %}
</div>
</div>
<div class="field">
<label class="check-row">
<input type="checkbox" name="lifetime" value="1" id="lifetime" onchange="toggleLifetime()">
Lifetime (unbegrenzte Laufzeit) {{ hi.hint_icon("Ignoriert die Gültigkeitsdauer für jede REGULÄRE Lizenz aus diesem Ticket -- diese läuft dann nie ab. Eine zusätzlich erzeugte Trial-Lizenz bleibt davon unberührt und läuft weiterhin nach 30 Tagen ab. Bei Lizenztyp Trial nicht wählbar, da eine dauerhafte Trial-Lizenz ohne Module keinen Sinn ergäbe.", "Lifetime") }}
</label>
</div>
<div class="field" id="validDaysField">
<label for="valid_days">Gültigkeitsdauer (Tage)</label>
<input type="number" name="valid_days" id="valid_days" value="365" min="1" required>
<div class="field-hint">
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=365">1 Jahr</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="document.getElementById('valid_days').value=730">2 Jahre</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Ticket anlegen
</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleNewCustomerFields() {
const isNew = document.getElementById("customer_id").value === "__new__";
document.getElementById("newCustomerFields").classList.toggle("hidden", !isNew);
}
function toggleModuleFields() {
const type = document.getElementById("license_type").value;
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
// jedes Modul -- serverseitig ohnehin unterbunden (siehe ticket_new()
// in app.py), hier nur als klares UI-Signal ausgegraut.
const lifetimeBox = document.getElementById("lifetime");
lifetimeBox.disabled = (type === "trial");
if (type === "trial" && lifetimeBox.checked) {
lifetimeBox.checked = false;
toggleLifetime();
}
}
function toggleLicenseServerRole() {
toggleModuleFields();
}
function toggleLifetime() {
const isLifetime = document.getElementById("lifetime").checked;
document.getElementById("validDaysField").classList.toggle("hidden", isLifetime);
document.getElementById("valid_days").required = !isLifetime;
}
document.addEventListener("DOMContentLoaded", function () {
toggleNewCustomerFields();
toggleModuleFields();
toggleLifetime();
});
</script>
{% endblock %}
+193
View File
@@ -0,0 +1,193 @@
{% extends "base.html" %}
{% set active_page = "tickets" %}
{% block page_title %}Tickets{% endblock %}
{% block page_sub %}<div class="topbar-sub">Ticketverwaltung -- Anlegen, Bearbeiten, Löschen</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Tickets</h2>
<div class="hint">Jedes Ticket ist die dauerhafte Berechtigung eines Kunden auf eine Lizenz mit festen Eckdaten -- die eigentliche Lizenzdatei darunter ist austauschbar.</div>
</div>
{% if can_create %}
<a href="{{ url_for('ticket_new') }}" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neues Ticket
</a>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="ticketSearch" placeholder="Tickets durchsuchen…" oninput="filterTicketsTable()">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="ticketsTable" data-sortable>
<thead><tr>
<th data-sort-key="customer">Kunde</th>
<th data-sort-key="type">Typ</th>
<th>Module</th>
<th data-sort-key="duration">Laufzeit</th>
<th data-sort-key="ticketstatus">Ticket</th>
<th data-sort-key="status">Lizenz-Status</th>
<th data-sort-key="heartbeat">Letzter Heartbeat</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
{% for t in tickets %}
<tbody data-sort-customer="{{ t.customer_name|lower }}" data-sort-type="{{ t.type }}"
data-sort-duration="{{ 999999 if t.lifetime else t.valid_days }}"
data-sort-ticketstatus="{{ t.status }}"
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 '' }}">
<tr>
<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 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><span class="pill {{ 'online' if t.status == 'open' else 'offline' }}">{{ 'Offen' if t.status == 'open' else 'Gesperrt' }}</span></td>
{% if t.license %}
<td>
<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>
{% if t.license.status == 'active' and not t.license.is_lifetime and t.license.days_left is not none %}
{% if t.license.is_expired %}<span style="color:var(--danger);">(abgelaufen)</span>
{% elif t.license.color in ('red', 'orange') %}
<span style="color:{{ 'var(--danger)' if t.license.color == 'red' else 'var(--warning)' }};">({{ t.license.days_left }} Tage)</span>
{% endif %}
{% endif %}
</td>
<td class="text-dim">{{ t.license.last_heartbeat_at or '—' }}</td>
{% else %}
<td class="text-faint">Noch keine Lizenz</td>
<td class="text-dim"></td>
{% endif %}
<td>
<div class="row-actions">
<a class="icon-btn" title="Ticket-Details" href="{{ url_for('ticket_detail', ticket_id=t.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 %}
<button class="icon-btn" title="Bearbeiten" data-open-modal="editTicketModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
{% if t.can_delete %}
<form method="post" action="{{ url_for('ticket_delete', ticket_id=t.id) }}"
data-confirm="Ticket für „{{ t.customer_name }}“ wirklich löschen? Die Lizenzhistorie dieses Tickets geht dabei verloren.">
<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 Tickets angelegt.</td></tr>
</tbody>
{% endfor %}
</table>
</div>
</div>
{% if can_edit %}
{% for t in tickets %}
<div class="modal-overlay" id="editTicketModal{{ loop.index }}">
<div class="modal" style="max-width:520px;">
<form method="post" action="{{ url_for('ticket_edit', ticket_id=t.id) }}">
<div class="modal-header">
<h3>Ticket bearbeiten -- {{ t.customer_name }}</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="hint" style="margin-bottom:14px;">
Wirkt sich nur auf die NÄCHSTE aus diesem Ticket erstellte Lizenz aus -- bereits ausgestellte Lizenzen bleiben unverändert.
</div>
<div class="field">
<label>Lizenztyp</label>
<select name="license_type" id="editLicenseType{{ loop.index }}" onchange="toggleEditModuleFields({{ loop.index }})">
{% for lt in license_types %}
<option value="{{ lt }}" {% if lt == t.type %}selected{% endif %}>{{ type_labels.get(lt, lt) }}</option>
{% endfor %}
</select>
</div>
<div id="editModuleFields{{ loop.index }}" class="field {% if t.type != 'custom' %}hidden{% endif %}">
<label>Module</label>
<div class="check-list">
{% for m in all_modules %}
<label class="check-row">
<input type="checkbox" name="modules" value="{{ m }}" {% if m in t.modules_list %}checked{% endif %}>
{{ module_labels.get(m, m) }}
</label>
{% endfor %}
</div>
</div>
<div class="field">
<label class="check-row">
<input type="checkbox" name="lifetime" value="1" id="editLifetime{{ loop.index }}"
{% if t.lifetime %}checked{% endif %} {% if t.type == 'trial' %}disabled{% endif %}
onchange="toggleEditLifetime({{ loop.index }})">
Lifetime (unbegrenzte Laufzeit)
</label>
</div>
<div class="field" id="editValidDaysField{{ loop.index }}" {% if t.lifetime %}style="display:none;"{% endif %}>
<label>Gültigkeitsdauer (Tage)</label>
<input type="number" name="valid_days" id="editValidDays{{ loop.index }}" value="{{ t.valid_days }}" min="1">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function filterTicketsTable() {
const el = document.getElementById("ticketSearch");
if (!el) return;
const q = el.value.trim().toLowerCase();
document.querySelectorAll("#ticketsTable tbody").forEach(tbody => {
if (tbody.hasAttribute("data-sort-pinned")) return;
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
function toggleEditModuleFields(i) {
const type = document.getElementById("editLicenseType" + i).value;
document.getElementById("editModuleFields" + i).classList.toggle("hidden", type !== "custom");
// Trial + Lifetime ergibt keinen Sinn (dauerhaft nie ablaufende Lizenz
// ohne jedes Modul) -- serverseitig ohnehin unterbunden, hier nur
// als UI-Signal ausgegraut.
const lifetimeBox = document.getElementById("editLifetime" + i);
lifetimeBox.disabled = (type === "trial");
if (type === "trial" && lifetimeBox.checked) {
lifetimeBox.checked = false;
toggleEditLifetime(i);
}
}
function toggleEditLifetime(i) {
const checked = document.getElementById("editLifetime" + i).checked;
document.getElementById("editValidDaysField" + i).style.display = checked ? "none" : "";
}
</script>
{% endblock %}
+43 -7
View File
@@ -24,11 +24,8 @@ REPO_NAME="${TESM_REPO_NAME:-tesm-license}"
RELEASE_TAG="${TESM_RELEASE_TAG:-latest}"
GITEA_USER="${TESM_GITEA_USER:-}"
GITEA_TOKEN="${TESM_GITEA_TOKEN:-}"
PACKAGE_NAME="tesm-license-${RELEASE_TAG}.tar.gz"
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
WORK_DIR="/tmp/tesm-license-update-$(date +%s)"
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -40,6 +37,39 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1
fi
CURL_AUTH=()
if [ -n "$GITEA_USER" ] && [ -n "$GITEA_TOKEN" ]; then
CURL_AUTH=(-u "${GITEA_USER}:${GITEA_TOKEN}")
fi
# ---- "latest" IMMER über die Gitea-API auflösen, nie als Literal in die
# Download-URL einsetzen ----
# Live reproduziert (im Schwester-Repo tesm, identisches Verhalten hier):
# /releases/download/latest/tesm-license-latest.tar.gz liefert HTTP 200 --
# aber keinen echten Release-Asset, sondern (weil kein Asset exakt so
# heißt) Giteas automatisch generiertes Quellcode-Archiv des AKTUELLEN
# Default-Branch-Stands, kommentarlos und ohne Fehlermeldung. Ohne diesen
# Auflösungsschritt würde ein einfaches "sudo ./update.sh" (ohne
# explizites TESM_RELEASE_TAG) also lautlos einen unversionierten,
# potenziell halbfertigen Zwischenstand installieren statt des
# tatsächlich neuesten Releases.
if [ "$RELEASE_TAG" == "latest" ]; then
echo -e "${RED}${NC} Ermittle aktuellsten Release-Tag von ${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}..."
RESOLVED_TAG="$(curl -fsSL "${CURL_AUTH[@]}" "${GITEA_BASE}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('tag_name',''))" 2>/dev/null)"
if [ -z "$RESOLVED_TAG" ]; then
echo "✖ Konnte den aktuellsten Release-Tag nicht über die Gitea-API ermitteln -- Abbruch." >&2
echo " (Alternative: TESM_RELEASE_TAG=vX.Y.Z explizit setzen.)" >&2
exit 1
fi
RELEASE_TAG="$RESOLVED_TAG"
echo -e "${GREEN}${NC} Aktuellster Release ist ${RELEASE_TAG}."
fi
PACKAGE_NAME="tesm-license-${RELEASE_TAG}.tar.gz"
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
echo -e "${YELLOW}============================================================================${NC}"
echo -e "${YELLOW} TESM-Lizenzserver Update/Reinstall — Release \"${RELEASE_TAG}\"${NC}"
echo -e "${YELLOW}============================================================================${NC}"
@@ -58,16 +88,22 @@ fi
echo -e "${RED}${NC} Lade Paket von ${DOWNLOAD_URL}..."
mkdir -p "$WORK_DIR"
CURL_AUTH=()
if [ -n "$GITEA_USER" ] && [ -n "$GITEA_TOKEN" ]; then
CURL_AUTH=(-u "${GITEA_USER}:${GITEA_TOKEN}")
fi
curl -fsSL "${CURL_AUTH[@]}" -o "$PACKAGE_FILE" "$DOWNLOAD_URL"
echo -e "${GREEN}${NC} Paket heruntergeladen ($(du -h "$PACKAGE_FILE" | cut -f1))."
echo -e "${RED}${NC} Entpacke Paket..."
mkdir -p "$WORK_DIR/pkg"
tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1
# Absicherung gegen genau das oben beschriebene Fallback-Verhalten: statt
# eines späten, kryptischen "No such file or directory" beim Aufruf von
# install.sh hier klar benennen, WENN das heruntergeladene Paket nicht das
# erwartete ist (z.B. weil sich Giteas Verhalten wieder ändert).
if [ ! -f "$WORK_DIR/pkg/install.sh" ]; then
echo "✖ Entpacktes Paket enthält kein install.sh -- vermutlich wurde nicht das" >&2
echo " erwartete Release-Asset heruntergeladen (siehe ${DOWNLOAD_URL})." >&2
exit 1
fi
echo -e "${GREEN}${NC} Paket entpackt."
echo -e "${RED}${NC} Starte install.sh aus dem Paket..."