Compare commits

...
4 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 a193b9d6e9 licensing.py: optionales report-Feld fuer Sub-Lizenzserver-Meldungen
Rein synchronisierende Aenderung (harte Invariante: licensing.py bleibt
byte-identisch in beiden Repos). build_client_request() akzeptiert
jetzt einen optionalen report-Parameter, der bei Heartbeats einer
role='license_server'-Lizenz den eigenen Kunden-/Ticket-/Lizenzstand
signiert mitschickt -- Kern des neuen Sub-Lizenzserver-Reportings auf
dem Lizenzserver (siehe dortiges Release). Bestehende Aufrufer (TESM-
Client-Heartbeats, activate/deactivate) uebergeben report nie -- das
Feld fehlt dann wie bisher komplett in der Payload, keine
Verhaltensaenderung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:43:17 +02:00
alientimandClaude Sonnet 5 56974a4308 licensing.py: neues role-Feld fuer Lizenzserver-Lizenzen (Master-Feature)
Rein synchronisierende Aenderung, um die harte Invariante 'licensing.py
byte-identisch in beiden Repos' zu wahren -- der TESM-Client selbst
nutzt role aktuell nicht aktiv (er ist immer 'customer'), aber das
Sub-Lizenzserver-Feature auf dem Lizenzserver (siehe dortiges Release)
braucht die Erweiterung in derselben Datei.

- LICENSE_ROLES = ('master', 'license_server', 'customer'), neues
  role-Feld in der signierten Lizenzdatei-Payload (issue_license()).
- license_status() liefert role mit, faellt bei fehlendem Feld
  (aeltere Lizenzdateien) auf 'customer' zurueck.
- Verifiziert: role ist Teil der signierten Payload -- eine nachtraeg-
  liche Manipulation der Rolle macht die Signatur ungueltig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:06:16 +02:00
alientimandClaude Sonnet 5 b69d0745dd 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. Damit konnte 'Lizenz verwalten' nicht unabhaengig von 'Systemein-
stellungen verwalten' vergeben/eingeschraenkt werden (Hostname/Zeitzone/
Pruefintervall/Log-Rotation/Papierkorb-Frist/Nav-Reihenfolge).

- 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 Kopie der Dev-DB: Backfill korrekt (inkl.
  Gruppe mit nur .view, die korrekt KEIN .edit bekommt), idempotent bei
  zweitem Lauf, has_permission()/_nav_key_visible() liefern die
  erwarteten Werte je Testgruppe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:48:27 +02:00
alientimandClaude Sonnet 5 7ab6358b9f Anbieter-Logo auf der Lizenz-Seite anzeigen (v1.2.5)
Der Lizenzserver erlaubt bereits laenger das Hochladen eines Anbieter-
Logos (Einstellungen -> Lizenz -> Anbieter & Lizenzserver) und bettet es
seit jeher als vendor.logo_base64 in jede ausgestellte Lizenzdatei ein
(siehe _get_vendor_info() im Lizenzserver) -- die Kundeninstanz zeigte
auf ihrer eigenen Lizenz-Seite zwar bereits Firma/Telefon/E-Mail/
Anschrift des Anbieters an (dieselbe eingebettete vendor-Struktur), aber
nie das Logo selbst. Reine Anzeige-Ergaenzung, keine Aenderung am
Lizenzformat oder an der Uebertragung noetig -- das Logo steckt in jeder
schon ausgestellten Lizenz bereits drin, muss also fuer bestehende
Kunden nicht neu ausgestellt werden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:14:47 +02:00
4 changed files with 116 additions and 15 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.4 1.2.8
+47 -10
View File
@@ -326,6 +326,10 @@ PERMISSIONS = {
"label": "Systemeinstellungen", "label": "Systemeinstellungen",
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"}, "rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
}, },
"settings_license": {
"label": "Lizenz",
"rows": {"view": "settings_license.view", "edit": "settings_license.edit"},
},
"settings_importexport": { "settings_importexport": {
"label": "Im-/Export", "label": "Im-/Export",
"rows": { "rows": {
@@ -392,6 +396,8 @@ PERMISSION_LABELS = {
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen", "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
"groups.edit": "Gruppen ändern (inkl. Löschen)", "groups.edit": "Gruppen ändern (inkl. Löschen)",
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern", "settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
"settings_license.view": "Lizenzstatus ansehen",
"settings_license.edit": "Lizenzdatei hochladen sowie Lizenz aktivieren/deaktivieren (online oder per Code)",
"settings_importexport.view": "Im-/Export-Seite ansehen", "settings_importexport.view": "Im-/Export-Seite ansehen",
"settings_importexport.edit": "Daten importieren", "settings_importexport.edit": "Daten importieren",
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)", "settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
@@ -486,10 +492,11 @@ def _nav_key_visible(key, user):
if key == "settings_system": if key == "settings_system":
return user.can_view_settings_system return user.can_view_settings_system
if key == "settings_license": if key == "settings_license":
# Immer erreichbar für jeden mit Systemeinstellungen-Leserecht -- # Eigenes Recht (settings_license.view), unabhängig von
# muss auch OHNE Lizenz sichtbar bleiben, sonst könnte niemand # settings_system -- muss auch OHNE eine aktive Lizenz sichtbar
# jemals eine erste Lizenz hochladen/aktivieren. # bleiben (das ist eine andere Prüfung, siehe license_active()),
return user.can_view_settings_system # sonst könnte niemand jemals eine erste Lizenz hochladen/aktivieren.
return user.can_view_settings_license
if key == "settings_importexport": if key == "settings_importexport":
return user.can_view_settings_importexport return user.can_view_settings_importexport
if key == "settings_dhcp": if key == "settings_dhcp":
@@ -726,6 +733,10 @@ class User(UserMixin):
def can_view_settings_system(self): def can_view_settings_system(self):
return self.has_permission("settings_system.view") return self.has_permission("settings_system.view")
@property
def can_view_settings_license(self):
return self.has_permission("settings_license.view")
@property @property
def can_view_settings_importexport(self): def can_view_settings_importexport(self):
return self.has_any_permission([ return self.has_any_permission([
@@ -1146,6 +1157,32 @@ def _ensure_schema():
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,) "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,)
) )
_migration_key_license_perm = "_migrated_settings_license_permission_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_license_perm,)).fetchone():
# "Lizenz" (settings_license) hatte bislang KEIN eigenes Recht --
# die Seite lief komplett über settings_system.view/.edit mit.
# Backfill: jede Gruppe, die settings_system.view/.edit bereits
# hatte, bekommt automatisch das neue, gespiegelte
# settings_license.view/.edit dazu -- ohne diesen Schritt würden
# bestehende Gruppen beim Update stillschweigend den Zugriff auf
# die Lizenzseite verlieren, den sie vorher implizit hatten.
_system_to_license = {
"settings_system.view": "settings_license.view",
"settings_system.edit": "settings_license.edit",
}
for system_perm, license_perm in _system_to_license.items():
rows = conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (system_perm,)
).fetchall()
for row in rows:
conn.execute(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
(row["group_id"], license_perm),
)
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_license_perm,)
)
_migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1" _migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone(): if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone():
for stale_log in glob.glob("/var/log/rpi-*.log"): for stale_log in glob.glob("/var/log/rpi-*.log"):
@@ -4469,7 +4506,7 @@ def settings():
@app.route("/settings/license") @app.route("/settings/license")
@login_required @login_required
def settings_license(): def settings_license():
if not current_user.can_view_settings_system: if not current_user.can_view_settings_license:
flash("Keine Berechtigung, die Lizenz anzusehen.", "danger") flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
return redirect(url_for("index")) return redirect(url_for("index"))
pending_raw = get_setting("license_pending_request", "") pending_raw = get_setting("license_pending_request", "")
@@ -4492,7 +4529,7 @@ def settings_license():
@app.route("/settings/license/upload", methods=["POST"]) @app.route("/settings/license/upload", methods=["POST"])
@login_required @login_required
def license_upload(): def license_upload():
if not current_user.has_permission("settings_system.edit"): if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu ändern.", "danger") flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
return redirect(url_for("settings_license")) return redirect(url_for("settings_license"))
uploaded = request.files.get("license_file") uploaded = request.files.get("license_file")
@@ -4585,7 +4622,7 @@ def _license_handshake_confirm(action, success_setting_fn, success_log_action, s
@app.route("/settings/license/activate", methods=["POST"]) @app.route("/settings/license/activate", methods=["POST"])
@login_required @login_required
def license_activate(): def license_activate():
if not current_user.has_permission("settings_system.edit"): if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger") flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
return redirect(url_for("settings_license")) return redirect(url_for("settings_license"))
if not _license_state.get("file") or not _license_state.get("valid"): if not _license_state.get("file") or not _license_state.get("valid"):
@@ -4614,7 +4651,7 @@ def license_activate():
@app.route("/settings/license/activate/confirm", methods=["POST"]) @app.route("/settings/license/activate/confirm", methods=["POST"])
@login_required @login_required
def license_activate_confirm(): def license_activate_confirm():
if not current_user.has_permission("settings_system.edit"): if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger") flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
return redirect(url_for("settings_license")) return redirect(url_for("settings_license"))
@@ -4640,7 +4677,7 @@ def license_activate_confirm():
@app.route("/settings/license/deactivate", methods=["POST"]) @app.route("/settings/license/deactivate", methods=["POST"])
@login_required @login_required
def license_deactivate(): def license_deactivate():
if not current_user.has_permission("settings_system.edit"): if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger") flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
return redirect(url_for("settings_license")) return redirect(url_for("settings_license"))
if not _license_state.get("file") or not license_activated(): if not _license_state.get("file") or not license_activated():
@@ -4660,7 +4697,7 @@ def license_deactivate():
@app.route("/settings/license/deactivate/confirm", methods=["POST"]) @app.route("/settings/license/deactivate/confirm", methods=["POST"])
@login_required @login_required
def license_deactivate_confirm(): def license_deactivate_confirm():
if not current_user.has_permission("settings_system.edit"): if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger") flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
return redirect(url_for("settings_license")) return redirect(url_for("settings_license"))
+62 -3
View File
@@ -39,6 +39,31 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise") LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
ALL_MODULES = ("dhcp", "fileshare", "maintenance") ALL_MODULES = ("dhcp", "fileshare", "maintenance")
# Rolle der lizenzierten Instanz, eingebettet in JEDE ausgestellte
# Lizenzdatei (signiert, siehe issue_license) -- NICHT nur DB-seitige
# Buchfuehrung wie das gleichnamige, bislang ungenutzte licenses.role-Feld
# in der Master-DB vorher. Ermoeglicht einer lizenzierten Instanz, anhand
# ihrer EIGENEN, geladenen Lizenz strukturell zu erkennen, was sie darf:
# "master" -- die Wurzel-Instanz. Wird AUSSCHLIESSLICH lokal per
# create_master_license.py erzeugt, NIE ueber die
# normale Ticket-/Ausstellungs-UI -- das waere sonst
# eine Moeglichkeit, sich selbst zur Wurzel zu
# erklaeren. Einzige Rolle, die weitere
# "license_server"-Lizenzen ausstellen darf.
# "license_server" -- ein vom Master (oder einer anderen "master"-Instanz)
# ausgestellter Lizenzserver ("Sub-Lizenzserver").
# Darf alles, was ein Lizenzserver koennen soll
# (Kunden/Tickets/Lizenzen fuer eigene TESM-Kunden
# ausstellen, Benutzer/Gruppen/Einstellungen
# verwalten) -- AUSSER selbst weitere
# "license_server"-Lizenzen ausstellen. Diese Sperre
# ist strukturell (haengt an der eigenen Rolle, nicht
# an einem vergebbaren Recht) und daher auch von einem
# lokalen Admin dieser Instanz nicht aufhebbar.
# "customer" -- eine gewoehnliche TESM-Kundeninstanz (Default,
# unveraendertes Verhalten).
LICENSE_ROLES = ("master", "license_server", "customer")
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab) HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
@@ -120,13 +145,23 @@ LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
def issue_license(*, customer, license_type, modules, valid_days, def issue_license(*, customer, license_type, modules, valid_days,
master_private_key_b64, master_public_key_b64, master_private_key_b64, master_public_key_b64,
master_endpoint, vendor, license_id=None, now=None, lifetime=False): master_endpoint, vendor, license_id=None, now=None, lifetime=False,
role="customer"):
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt """Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
frischem Pro-Lizenz-Schlüsselpaar. frischem Pro-Lizenz-Schlüsselpaar.
lifetime=True ignoriert valid_days komplett und setzt stattdessen das lifetime=True ignoriert valid_days komplett und setzt stattdessen das
feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar. feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar.
role (siehe LICENSE_ROLES): "customer" (Default, unverändertes
Verhalten) für gewöhnliche TESM-Kundeninstanzen, "license_server" für
einen vom Aufrufer ausgestellten Sub-Lizenzserver. role="master" darf
NUR von create_master_license.py verwendet werden (lokal, ohne diese
Funktion über die normale Ticket-UI aufzurufen) -- diese Funktion prüft
das nicht selbst, das Verbot ist stattdessen dadurch durchgesetzt, dass
die App-seitige Ausstellungsfunktion role="master" gar nicht erst als
Option anbietet (siehe app.py can_issue_license_server_licenses()).
Rückgabe: (license_file, license_pubkey) -- license_file ist die Rückgabe: (license_file, license_pubkey) -- license_file ist die
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
@@ -134,6 +169,8 @@ def issue_license(*, customer, license_type, modules, valid_days,
dieser Lizenz) und wird nicht an den Kunden weitergegeben.""" dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
if license_type not in LICENSE_TYPES: if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})") raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
if role not in LICENSE_ROLES:
raise ValueError(f"Unbekannte Rolle: {role!r} (erlaubt: {LICENSE_ROLES})")
unknown = set(modules) - set(ALL_MODULES) unknown = set(modules) - set(ALL_MODULES)
if unknown: if unknown:
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})") raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
@@ -151,6 +188,7 @@ def issue_license(*, customer, license_type, modules, valid_days,
"master_pubkey": master_public_key_b64, "master_pubkey": master_public_key_b64,
"master_endpoint": master_endpoint, "master_endpoint": master_endpoint,
"vendor": vendor, "vendor": vendor,
"role": role,
} }
signature = sign_payload(payload, master_private_key_b64) signature = sign_payload(payload, master_private_key_b64)
license_file = {**payload, "license_privkey": license_priv, "signature": signature} license_file = {**payload, "license_privkey": license_priv, "signature": signature}
@@ -190,6 +228,12 @@ def license_status(license_file: dict, now=None) -> dict:
"type": license_file.get("type"), "type": license_file.get("type"),
"customer": license_file.get("customer"), "customer": license_file.get("customer"),
"expires_at": license_file.get("expires_at"), "expires_at": license_file.get("expires_at"),
# .get(..., "customer"): aeltere, vor Einfuehrung von role signierte
# Lizenzdateien haben dieses Feld noch nicht -- fehlend bedeutet
# unveraendert "gewoehnliche Kundeninstanz", nie "master"/
# "license_server" (sonst koennte eine alte Datei rueckwirkend
# unbeabsichtigt privilegiert erscheinen).
"role": license_file.get("role", "customer"),
} }
@@ -231,11 +275,24 @@ def system_fingerprint():
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam # gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
# verhindert". # verhindert".
def build_client_request(action, license_file, nonce=None) -> dict: def build_client_request(action, license_file, nonce=None, report=None) -> dict:
"""Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action: """Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für "activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt.""" zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt.
report: NUR für Heartbeats einer role="license_server"-Lizenz gedacht
(siehe Lizenzserver-app.py _build_own_issuance_report()) -- ein
optionaler Schnappschuss der eigenen Kunden/Tickets/Lizenzen dieses
Sub-Lizenzservers, den der Master beim Empfang in seine
reported_sub_*-Tabellen übernimmt, um einen Gesamtüberblick zu haben.
Bewusst Teil DIESER generischen, gemeinsam genutzten Funktion (nicht
ein eigenes Protokoll-Feature) -- ein normaler TESM-Client/eine
"customer"-Lizenz übergibt hier einfach nie einen Wert, das Feld fehlt
dann in der Payload wie bisher (keine Verhaltensänderung für alle
bestehenden Aufrufer). Wie jedes andere Feld wird auch report
mitsigniert -- ein Angreifer ohne den privaten Lizenzschlüssel kann
keine gefälschten Berichte unterschieben."""
if action not in ("activate", "deactivate", "heartbeat"): if action not in ("activate", "deactivate", "heartbeat"):
raise ValueError(f"Unbekannte Aktion: {action!r}") raise ValueError(f"Unbekannte Aktion: {action!r}")
payload = { payload = {
@@ -252,6 +309,8 @@ def build_client_request(action, license_file, nonce=None) -> dict:
"nonce": nonce or uuid.uuid4().hex, "nonce": nonce or uuid.uuid4().hex,
"timestamp": _iso(time.time()), "timestamp": _iso(time.time()),
} }
if report is not None:
payload["report"] = report
signature = sign_payload(payload, license_file["license_privkey"]) signature = sign_payload(payload, license_file["license_privkey"])
return {**payload, "signature": signature} return {**payload, "signature": signature}
+6 -1
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "settings_license" %} {% set active_page = "settings_license" %}
{% set can_edit = current_user.has_permission('settings_system.edit') %} {% set can_edit = current_user.has_permission('settings_license.edit') %}
{% block page_title %}Lizenz{% endblock %} {% block page_title %}Lizenz{% endblock %}
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
@@ -156,6 +156,11 @@
<div class="hint">Kontakt für Rückfragen zu dieser Lizenz.</div> <div class="hint">Kontakt für Rückfragen zu dieser Lizenz.</div>
</div> </div>
</div> </div>
{% if license_file.vendor.logo_base64 %}
<div class="field" style="text-align:center;">
<img src="{{ license_file.vendor.logo_base64 }}" alt="{{ license_file.vendor.name or 'Anbieter' }}" style="max-width:220px; max-height:90px; margin:0 auto 8px;">
</div>
{% endif %}
<div class="field"> <div class="field">
<label>Firma</label> <label>Firma</label>
<input type="text" value="{{ license_file.vendor.name or '' }}" disabled> <input type="text" value="{{ license_file.vendor.name or '' }}" disabled>