diff --git a/srv/tesm-license/VERSION b/srv/tesm-license/VERSION index 6085e94..23aa839 100644 --- a/srv/tesm-license/VERSION +++ b/srv/tesm-license/VERSION @@ -1 +1 @@ -1.2.1 +1.2.2 diff --git a/srv/tesm-license/app.py b/srv/tesm-license/app.py index 3681c3c..1e7d5dd 100644 --- a/srv/tesm-license/app.py +++ b/srv/tesm-license/app.py @@ -13,7 +13,7 @@ Phase 1 ergänzte Lizenzsystem selbst (licensing.py). Das Dashboard ("/") ist aktuell nur ein Platzhalter -- die eigentliche Kunden-/ Lizenzübersicht folgt in einem späteren Schritt. """ -from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_file, abort +from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_file, abort, session from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user from flask_bcrypt import Bcrypt from werkzeug.utils import secure_filename @@ -21,7 +21,7 @@ from cryptography.fernet import Fernet from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes, serialization from cryptography import x509 -from datetime import datetime +from datetime import datetime, timedelta import base64, io, ipaddress, logging, math, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback, zipfile import yaml import ssl @@ -320,8 +320,12 @@ DEFAULT_GROUP_PERMISSIONS = [ NAV_ITEMS = [ {"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"}, - {"key": "tickets", "label": "Tickets", "icon": "key", "endpoint": "tickets_list"}, + # Reihenfolge spiegelt bewusst die fachliche Hierarchie Kunde -> Ticket + # -> Lizenz wider -- jede Ebene eine eigene, direkt ueber die Navigation + # erreichbare Seite statt ineinander verschachtelter Detailansichten. {"key": "customers", "label": "Kunden", "icon": "users", "endpoint": "customers"}, + {"key": "tickets", "label": "Tickets", "icon": "key", "endpoint": "tickets_list"}, + {"key": "licenses", "label": "Lizenzen", "icon": "shield", "endpoint": "licenses_list"}, {"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"}, {"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"}, {"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [ @@ -346,6 +350,8 @@ def _nav_key_visible(key, user): return True if key == "tickets": return user.has_permission("licenses.view") + if key == "licenses": + return user.has_permission("licenses.view") if key == "customers": return user.can_manage_customers if key == "users": @@ -712,11 +718,14 @@ def _ensure_schema(): ): if _col not in customer_cols: conn.execute(_ddl) - # Jeder Kunde braucht einen eigenen, dauerhaften Portal-Token fuer den - # kundenweiten Magic-Link (Kundenportal-Login ohne Passwort, siehe - # customer_portal()) -- bestehende Kunden (vor Einfuehrung dieses - # Felds) bekommen hier rueckwirkend einen zugewiesen, statt bis zur - # naechsten manuellen Bearbeitung ohne Portal-Zugang dazustehen. + # Historisch: ein dauerhafter Portal-Token pro Kunde (vorher Basis + # eines UNBEFRISTETEN Magic-Links). Dieser direkte, permanente Zugang + # wurde bewusst wieder entfernt -- der einzige Weg ins Kundenportal + # ist jetzt der zeitlich begrenzte Ablauf über portal_request() / + # portal_access() (siehe dort). Die Spalte samt Backfill bleibt + # bestehen (keine riskante Schema-Ruecknahme fuer reine Aufraeum- + # zwecke), wird aber fuer die Zugriffsentscheidung selbst nicht mehr + # herangezogen. for _row in conn.execute("SELECT id FROM license_customers WHERE portal_token IS NULL OR portal_token=''").fetchall(): conn.execute( "UPDATE license_customers SET portal_token=? WHERE id=?", @@ -807,10 +816,10 @@ def _ensure_schema(): "Konnte idx_one_open_license_per_ticket nicht anlegen -- vermutlich hat mindestens ein " "Ticket bereits mehr als eine offene Lizenz. Bitte manuell bereinigen (siehe licenses-Tabelle)." ) - # Analoger Backstop fuer den Kundenportal-Magic-Link: portal_token - # autorisiert komplett ohne Login (siehe customer_portal()), ein - # doppelt vergebener oder NULL-Token wuerde sonst stillschweigend die - # falsche Kundenzeile zurueckgeben bzw. den Kunden dauerhaft aussperren. + # Historisch (siehe Kommentar beim Backfill oben) -- portal_token wird + # fuer die eigentliche Zugriffsentscheidung nicht mehr genutzt, der + # Unique-Index bleibt trotzdem als harmlose Datenintegritaets- + # Absicherung bestehen. try: conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_portal_token " @@ -822,6 +831,25 @@ def _ensure_schema(): "portal_token. Bitte manuell bereinigen (siehe license_customers-Tabelle)." ) + # Kurzlebige, EINMAL nutzbare Zugangs-Tokens fuer /portal/request (siehe + # dortiger Docstring): anders als der dauerhafte portal_token (oben, + # von Admins ueber die Ticket-/Kundenseite verteilt, bewusst + # unbefristet) ist DIESER Weg oeffentlich von JEDEM auf blossen + # Zuruf einer E-Mail-Adresse ausloesbar -- ein zusaetzliches + # zeitliches Sicherheitsnetz ist hier angemessen, waehrend die + # bereits etablierten, admin-verteilten Links (Ticket-Seite, + # Lizenz-per-E-Mail) unveraendert dauerhaft bleiben. + conn.execute(""" + CREATE TABLE IF NOT EXISTS portal_access_tokens ( + token TEXT PRIMARY KEY, + customer_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + FOREIGN KEY (customer_id) REFERENCES license_customers(id) + ) + """) + # Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung # des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) -- # sonst würden genau diese Lizenzen (auf einer echten Installation z.B. @@ -2460,9 +2488,11 @@ def customers(): return redirect(url_for("customers")) customers_rows = conn.execute(""" - SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count + SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count, + COUNT(DISTINCT licenses.id) AS license_count FROM license_customers LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id + LEFT JOIN licenses ON licenses.customer_id = license_customers.id GROUP BY license_customers.id ORDER BY license_customers.name COLLATE NOCASE """).fetchall() @@ -2606,7 +2636,7 @@ def ticket_detail(ticket_id): type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, master_licensed=license_active(), self_service_url=url_for("self_service", ticket_id=ticket_id, _external=True), - portal_url=url_for("customer_portal", token=customer["portal_token"], _external=True) if customer and customer["portal_token"] else None, + portal_request_url=url_for("portal_request", _external=True), can_edit=current_user.has_permission("licenses.edit"), ) @@ -2851,9 +2881,11 @@ def license_send_email(license_id): f"\nÜber diesen Link können Sie Ihre Lizenz jederzeit selbst herunterladen, " f"widerrufen oder (z.B. nach einem Systemwechsel) neu erstellen:\n{self_service_url}\n" ) - if customer["portal_token"]: - portal_url = url_for("customer_portal", token=customer["portal_token"], _external=True) - self_service_note += f"\nEine Übersicht all Ihrer Tickets/Lizenzen finden Sie hier:\n{portal_url}\n" + portal_request_url = url_for("portal_request", _external=True) + self_service_note += ( + f"\nEine Übersicht all Ihrer Tickets/Lizenzen können Sie sich jederzeit unter folgendem Link per " + f"E-Mail zuschicken lassen:\n{portal_request_url}\n" + ) valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"] try: _graph_send_mail( @@ -2948,7 +2980,7 @@ def self_service(ticket_id): license=_license_dict_for_display(current) if current else None, can_issue_new=can_issue_new, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, - portal_url=url_for("customer_portal", token=customer["portal_token"]) if customer and customer["portal_token"] else None, + portal_request_url=url_for("portal_request"), ) @@ -3014,25 +3046,84 @@ def self_service_issue(ticket_id): return redirect(url_for("self_service", ticket_id=ticket_id)) -@app.route("/portal/", methods=["GET", "POST"]) -def customer_portal(token): - """Kundenweites Self-Service-Portal -- anders als /self-service/ - (ein Magic-Link PRO TICKET) ist dieser Link PRO KUNDE: zeigt alle - eigenen Tickets samt aktueller Lizenz auf einen Blick und erlaubt dem - Kunden, seine eigenen Kontakt-/Anschriftdaten selbst zu pflegen. - Bewusst NICHT bearbeitbar: Firmenname sowie die kommerziellen - Ticket-Eckdaten (Typ/Module/Laufzeit) -- das bleibt Sache des Anbieters. - Fuer die eigentlichen Lizenzaktionen (herunterladen/widerrufen/neu - erstellen) verlinkt jede Ticket-Zeile auf deren bestehende - /self-service/-Seite, statt die Logik hier zu duplizieren. - Kein Login noetig -- Autorisierung ausschliesslich durch Besitz des - unerratbaren Tokens (siehe license_customers.portal_token).""" - conn = get_db_connection() - customer = conn.execute("SELECT * FROM license_customers WHERE portal_token=?", (token,)).fetchone() - if not customer: - conn.close() - abort(404) +@app.route("/portal/request", methods=["GET", "POST"]) +def portal_request(): + """Oeffentlich erreichbar (kein Login): fuer einen Kunden, der seinen + Magic-Link verloren hat oder nie einen bekommen hat (z.B. weil er nur + telefonisch beim Anbieter bekannt ist), gibt es sonst KEINEN Weg + zurueck ins Kundenportal ausser einen Admin zu kontaktieren -- diese + Seite schliesst die Luecke: E-Mail-Adresse eingeben, Link wird (falls + hinterlegt) zugeschickt. Werkzeug routet die literale Regel + /portal/request bevorzugt vor der dynamischen /portal/ weiter + unten -- keine Kollisionsgefahr mit einem Kunden, dessen Token + zufaellig 'request' laeutet waere ohnehin praktisch ausgeschlossen + (192 Bit Zufall). + Zeigt IMMER dieselbe generische Erfolgsmeldung, unabhaengig davon, ob + die eingegebene Adresse tatsaechlich zu einem Kunden gehoert -- + verhindert, dass sich ueber die Antwort erraten laesst, welche + E-Mail-Adressen im System hinterlegt sind. + + Bewusst OHNE dauerhaften Portal-Link (der wurde entfernt, genau weil + dieser Weg OEFFENTLICH von jedem auf blossen Zuruf einer E-Mail- + Adresse ausloesbar ist): stattdessen ein frischer, einmalig nutzbarer, + nur PORTAL_ACCESS_LINK_MINUTES gueltiger Zugangs-Token (siehe + portal_access()).""" + if request.method == "POST": + email = request.form.get("email", "").strip() + if email: + conn = get_db_connection() + customer = conn.execute( + "SELECT * FROM license_customers WHERE contact_email IS NOT NULL AND lower(contact_email) = lower(?)", + (email,), + ).fetchone() + if customer: + now = datetime.now() + grant_token = secrets.token_urlsafe(32) + conn.execute( + "INSERT INTO portal_access_tokens (token, customer_id, created_at, expires_at) VALUES (?, ?, ?, ?)", + (grant_token, customer["id"], now.strftime("%Y-%m-%d %H:%M:%S"), + (now + timedelta(minutes=PORTAL_ACCESS_LINK_MINUTES)).strftime("%Y-%m-%d %H:%M:%S")), + ) + conn.commit() + access_url = url_for("portal_access", token=grant_token, _external=True) + try: + _graph_send_mail( + customer["contact_email"], + "Ihr Zugang zum TESM-Kundenportal", + "Hallo,\n\n" + "über folgenden Link erreichen Sie für kurze Zeit eine Übersicht Ihrer Tickets und Lizenzen " + "und können Ihre Kontaktdaten selbst pflegen:\n" + f"{access_url}\n\n" + f"Dieser Link ist nur {PORTAL_ACCESS_LINK_MINUTES} Minuten lang gültig und danach nicht " + "mehr nutzbar -- falls er abläuft, fordern Sie bitte einfach einen neuen an. " + "Bitte nicht an Dritte weitergeben.", + ) + log_action_system("customer.portal_link_requested", customer["name"]) + except Exception: + # Absichtlich NICHT an den Nutzer durchgereicht (siehe Docstring, + # Anti-Enumeration) -- aber fuer den Admin sichtbar loggen, sonst + # merkt niemand, dass z.B. Microsoft Graph nicht konfiguriert ist. + app.logger.error("Versand des Kundenportal-Links fehlgeschlagen:\n%s", traceback.format_exc()) + conn.close() + flash( + "Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit einem " + f"{PORTAL_ACCESS_LINK_MINUTES} Minuten gültigen Zugangslink verschickt.", + "success", + ) + return redirect(url_for("portal_request")) + return render_template("portal_request.html") + + +def _render_customer_portal(customer): + """GET/POST-Logik des Kundenportals -- Zugriff ausschliesslich ueber + die zeitlich begrenzte Session nach einem frisch angeforderten + Magic-Link (siehe portal_request()/portal_access()/ + customer_portal_session()). Es gibt bewusst KEINEN dauerhaften, + direkt aufrufbaren Portal-Link mehr -- `customer` ist hier bereits + als authentisch geprueft (Aufrufer hat eine gueltige Session + verifiziert), diese Funktion selbst prueft nichts mehr davon.""" + conn = get_db_connection() if request.method == "POST": contact_email = request.form.get("contact_email", "").strip() if not _EMAIL_RE.match(contact_email): @@ -3049,7 +3140,7 @@ def customer_portal(token): log_action_system("customer.update", customer["name"], "Kunde hat eigene Kontaktdaten im Portal aktualisiert") flash("Ihre Daten wurden aktualisiert.", "success") conn.close() - return redirect(url_for("customer_portal", token=token)) + return redirect(url_for("customer_portal_session")) now = time.time() tickets_view = [] @@ -3066,6 +3157,81 @@ def customer_portal(token): ) +PORTAL_ACCESS_LINK_MINUTES = 30 +PORTAL_SESSION_MINUTES = 60 + + +@app.route("/portal/access/") +def portal_access(token): + """Loest den kurzlebigen, EINMAL nutzbaren Link aus einer per + portal_request() angeforderten E-Mail ein -- gueltig fuer + PORTAL_ACCESS_LINK_MINUTES ab Anforderung, danach oder nach dem + ersten Aufruf nicht mehr nutzbar. Bei Erfolg wird eine Session + gesetzt, die ihrerseits nach PORTAL_SESSION_MINUTES ohne neue + Anforderung automatisch ablaeuft (siehe customer_portal_session()) -- + zwei getrennte Zeitfenster: eines fuer den Link selbst (muss zeitnah + angeklickt werden), eines fuer die resultierende Sitzung (muss danach + zeitnah genutzt werden).""" + conn = get_db_connection() + grant = conn.execute("SELECT * FROM portal_access_tokens WHERE token=?", (token,)).fetchone() + valid = bool(grant) and not grant["used_at"] + if valid: + try: + valid = datetime.now() < datetime.strptime(grant["expires_at"], "%Y-%m-%d %H:%M:%S") + except ValueError: + valid = False + if not valid: + conn.close() + flash("Dieser Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.", "danger") + return redirect(url_for("portal_request")) + conn.execute( + "UPDATE portal_access_tokens SET used_at=? WHERE token=?", + (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), token), + ) + conn.commit() + customer = _customer_row(conn, grant["customer_id"]) + conn.close() + if not customer: + flash("Dieser Zugang ist nicht mehr gültig.", "danger") + return redirect(url_for("portal_request")) + session["portal_customer_id"] = customer["id"] + session["portal_session_expires_at"] = ( + datetime.now() + timedelta(minutes=PORTAL_SESSION_MINUTES) + ).strftime("%Y-%m-%d %H:%M:%S") + log_action_system("customer.portal_link_used", customer["name"]) + return redirect(url_for("customer_portal_session")) + + +@app.route("/portal/me", methods=["GET", "POST"]) +def customer_portal_session(): + """Session-basierter Kundenportal-Zugang nach einem frisch + angeforderten Magic-Link (portal_request() -> portal_access()) -- + laeuft PORTAL_SESSION_MINUTES nach dem Anklicken des Links automatisch + ab, im Unterschied zum dauerhaften /portal/ (siehe dortiger + Docstring).""" + customer_id = session.get("portal_customer_id") + expires_at_str = session.get("portal_session_expires_at") + valid = False + if customer_id and expires_at_str: + try: + valid = datetime.now() < datetime.strptime(expires_at_str, "%Y-%m-%d %H:%M:%S") + except ValueError: + valid = False + if not valid: + session.pop("portal_customer_id", None) + session.pop("portal_session_expires_at", None) + flash("Ihre Sitzung ist abgelaufen -- bitte fordern Sie einen neuen Zugangslink an.", "danger") + return redirect(url_for("portal_request")) + conn = get_db_connection() + customer = _customer_row(conn, customer_id) + conn.close() + if not customer: + session.pop("portal_customer_id", None) + session.pop("portal_session_expires_at", None) + abort(404) + return _render_customer_portal(customer) + + def _audit_archive_loop(): """Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob die audit_log-Tabelle den Schwellenwert überschritten hat, und @@ -3522,6 +3688,54 @@ def tickets_list(): ) +@app.route("/licenses") +@login_required +def licenses_list(): + """Eigenstaendige Lizenzuebersicht -- zeigt ALLE ausgestellten Lizenzen + flach in einer Tabelle (statt nur ueber die Historie eines einzelnen + Tickets erreichbar zu sein). Ergaenzt Kunden/Tickets/Lizenzen zu drei + gleichberechtigten, direkt ueber die Navigation erreichbaren Ebenen -- + vorher war eine Lizenz nur durch Kunde -> gefilterte Tickets -> Ticket- + Detail -> eingebettete Historie auffindbar. Optional gefiltert nach + ?ticket_id=... oder ?customer_id=... (von Ticket- bzw. Kundenseite aus + verlinkt).""" + if not current_user.has_permission("licenses.view"): + flash("Keine Berechtigung, Lizenzen anzusehen.", "danger") + return redirect(url_for("index")) + conn = get_db_connection() + filter_ticket_id = request.args.get("ticket_id") + filter_customer_id = request.args.get("customer_id", type=int) + query = """ + SELECT licenses.*, license_customers.name AS customer_name + FROM licenses + JOIN license_customers ON license_customers.id = licenses.customer_id + WHERE 1=1 + """ + params = [] + if filter_ticket_id: + query += " AND licenses.ticket_id = ?" + params.append(filter_ticket_id) + if filter_customer_id: + query += " AND licenses.customer_id = ?" + params.append(filter_customer_id) + query += " ORDER BY licenses.created_at DESC" + rows = conn.execute(query, params).fetchall() + now = time.time() + licenses_view = [] + for row in rows: + d = _license_dict_for_display(row, now) + d["customer_name"] = row["customer_name"] + licenses_view.append(d) + filter_ticket = _ticket_row(conn, filter_ticket_id) if filter_ticket_id else None + filter_customer = _customer_row(conn, filter_customer_id) if filter_customer_id else None + conn.close() + return render_template( + "licenses.html", licenses=licenses_view, type_labels=LICENSE_TYPE_LABELS, + filter_ticket=filter_ticket, filter_customer=filter_customer, + can_edit=current_user.has_permission("licenses.edit"), + ) + + NETWORK_REVERT_SECONDS = 45 _pending_network_revert = {} NETPLAN_CONFIG_PATH = "/etc/netplan/90-tesm.yaml" diff --git a/srv/tesm-license/create_db.py b/srv/tesm-license/create_db.py index d5c244b..6326fc9 100644 --- a/srv/tesm-license/create_db.py +++ b/srv/tesm-license/create_db.py @@ -168,6 +168,17 @@ CREATE TABLE IF NOT EXISTS license_tickets ( ); """) +c.execute(""" +CREATE TABLE IF NOT EXISTS portal_access_tokens ( + token TEXT PRIMARY KEY, + customer_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + FOREIGN KEY (customer_id) REFERENCES license_customers(id) +); +""") + c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5")) cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",)) diff --git a/srv/tesm-license/templates/customers.html b/srv/tesm-license/templates/customers.html index 7cac4fd..728ad7b 100644 --- a/srv/tesm-license/templates/customers.html +++ b/srv/tesm-license/templates/customers.html @@ -31,10 +31,11 @@ Anschrift Kontakt Tickets + Lizenzen Aktionen {% for c in customers %} - + {{ c.name }} @@ -47,6 +48,7 @@ {% if c.contact_person %}
{{ c.contact_person }}{% endif %} {{ c.ticket_count }} + {{ c.license_count }}
{% if can_edit %} @@ -73,7 +75,7 @@ {% else %} - Noch keine Kunden angelegt. + Noch keine Kunden angelegt. {% endfor %} @@ -130,12 +132,6 @@
- {% if c.portal_token %} -
- - -
- {% endif %} {% if can_edit %} -
TESM-Lizenzserver v{{ tesm_version }}
+ +
TESM-Lizenzserver v{{ tesm_version }}
diff --git a/srv/tesm-license/templates/portal_request.html b/srv/tesm-license/templates/portal_request.html new file mode 100644 index 0000000..7d8c13e --- /dev/null +++ b/srv/tesm-license/templates/portal_request.html @@ -0,0 +1,48 @@ + + + + + +Zugang anfordern · TESM-Lizenzserver + + + + + + + + + + diff --git a/srv/tesm-license/templates/self_service.html b/srv/tesm-license/templates/self_service.html index b5218be..4073d55 100644 --- a/srv/tesm-license/templates/self_service.html +++ b/srv/tesm-license/templates/self_service.html @@ -17,7 +17,7 @@

Meine Lizenz

{{ customer.name if customer else '' }} - {% if portal_url %}
← Alle meine Tickets{% endif %} +
← Alle meine Tickets anzeigen
{% with messages = get_flashed_messages() %} diff --git a/srv/tesm-license/templates/ticket_detail.html b/srv/tesm-license/templates/ticket_detail.html index 5b286a8..c733f52 100644 --- a/srv/tesm-license/templates/ticket_detail.html +++ b/srv/tesm-license/templates/ticket_detail.html @@ -22,12 +22,10 @@ - {% if portal_url %} -
- - +
+ Ü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 {{ portal_request_url }} an (zeitlich begrenzter Zugang, keine dauerhafte Freigabe).
- {% endif %} {% if can_edit %}
@@ -88,44 +86,28 @@
-

Lizenzen zu diesem Ticket

+

Aktuelle Lizenz

+ + Alle Lizenzen dieses Tickets → +
-
- - - {% for l in licenses %} - - - - - - - - - - {% else %} - - {% endfor %} -
Lizenz-IDTypAblaufStatusAktionen
{{ l.license_id[:8] }}…{{ type_labels.get(l.type, l.type) }}{{ 'Lifetime' if l.is_lifetime else l.expires_at[:10] }} - - {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }} - - -
- - - - {% if can_edit and l.status in ('revoked', 'issued') %} -
- -
- {% endif %} -
-
Noch keine Lizenz aus diesem Ticket erstellt.
+ {% if licenses %} + {% set l = licenses[0] %} +
+ {{ l.license_id[:8] }}… + {{ type_labels.get(l.type, l.type) }} + {{ 'Lifetime' if l.is_lifetime else 'bis ' ~ l.expires_at[:10] }} + + {{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }} + + Details →
+ {% if licenses|length > 1 %} +
{{ licenses|length - 1 }} weitere ältere Lizenz{{ 'en' if licenses|length > 2 else '' }} aus diesem Ticket -- siehe „Alle Lizenzen dieses Tickets“ oben.
+ {% endif %} + {% else %} +

Noch keine Lizenz aus diesem Ticket erstellt.

+ {% endif %}