Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c130c85b17 | ||
|
|
ed2ed00a4e | ||
|
|
286a3464f1 | ||
|
|
619673ac76 | ||
|
|
20abadb0e7 | ||
|
|
2d920f581f | ||
|
|
c2989a6e2f |
@@ -1 +1 @@
|
||||
1.2.0
|
||||
1.2.7
|
||||
|
||||
+806
-99
File diff suppressed because it is too large
Load Diff
@@ -162,12 +162,67 @@ CREATE TABLE IF NOT EXISTS license_tickets (
|
||||
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)
|
||||
);
|
||||
""")
|
||||
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS reported_sub_customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_license_id TEXT NOT NULL,
|
||||
remote_customer_ref TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
contact_email TEXT, contact_phone TEXT, contact_person TEXT,
|
||||
street TEXT, house_number TEXT, postal_code TEXT, city TEXT,
|
||||
last_reported_at TEXT NOT NULL,
|
||||
UNIQUE (source_license_id, remote_customer_ref)
|
||||
);
|
||||
""")
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS reported_sub_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_license_id TEXT NOT NULL,
|
||||
remote_ticket_id TEXT NOT NULL,
|
||||
remote_customer_ref TEXT NOT NULL,
|
||||
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
|
||||
valid_days INTEGER, lifetime INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'open', role TEXT NOT NULL DEFAULT 'customer',
|
||||
created_at TEXT,
|
||||
last_reported_at TEXT NOT NULL,
|
||||
UNIQUE (source_license_id, remote_ticket_id)
|
||||
);
|
||||
""")
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS reported_sub_licenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_license_id TEXT NOT NULL,
|
||||
remote_license_id TEXT NOT NULL,
|
||||
remote_ticket_id TEXT,
|
||||
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
|
||||
issued_at TEXT, expires_at TEXT, status TEXT NOT NULL, lifetime INTEGER NOT NULL DEFAULT 0,
|
||||
fingerprint TEXT, hostname TEXT, activated_at TEXT, last_heartbeat_at TEXT,
|
||||
last_reported_at TEXT NOT NULL,
|
||||
UNIQUE (source_license_id, remote_license_id)
|
||||
);
|
||||
""")
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_customers_source ON reported_sub_customers(source_license_id)")
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_tickets_source ON reported_sub_tickets(source_license_id)")
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_licenses_source ON reported_sub_licenses(source_license_id)")
|
||||
|
||||
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5"))
|
||||
|
||||
cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",))
|
||||
|
||||
@@ -74,6 +74,12 @@ def main():
|
||||
master_public_key_b64=master_pub,
|
||||
master_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)
|
||||
|
||||
@@ -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)
|
||||
@@ -120,13 +145,23 @@ LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
|
||||
|
||||
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, lifetime=False):
|
||||
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
|
||||
@@ -134,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})")
|
||||
@@ -151,6 +188,7 @@ def issue_license(*, customer, license_type, modules, valid_days,
|
||||
"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}
|
||||
@@ -190,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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -231,11 +275,24 @@ def system_fingerprint():
|
||||
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
|
||||
# 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:
|
||||
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
|
||||
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"):
|
||||
raise ValueError(f"Unbekannte Aktion: {action!r}")
|
||||
payload = {
|
||||
@@ -252,6 +309,8 @@ def build_client_request(action, license_file, nonce=None) -> dict:
|
||||
"nonce": nonce or uuid.uuid4().hex,
|
||||
"timestamp": _iso(time.time()),
|
||||
}
|
||||
if report is not None:
|
||||
payload["report"] = report
|
||||
signature = sign_payload(payload, license_file["license_privkey"])
|
||||
return {**payload, "signature": signature}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "customers" %}
|
||||
{% block page_title %}Kunde{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ customer.name }}</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">
|
||||
{{ customer.name }}
|
||||
{% if sub_license %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
|
||||
</h2>
|
||||
</div>
|
||||
<a href="{{ url_for('customers') }}" class="btn btn-secondary">← Alle Kunden</a>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div><h2 style="font-size:16px;">Stammdaten</h2></div>
|
||||
{% if can_edit %}
|
||||
<a href="{{ url_for('customers') }}" class="btn btn-sm btn-secondary">Bearbeiten</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field"><label>E-Mail</label><input type="text" value="{{ customer.contact_email or '—' }}" disabled></div>
|
||||
<div class="field"><label>Telefon</label><input type="text" value="{{ customer.contact_phone or '—' }}" disabled></div>
|
||||
<div class="field"><label>Ansprechpartner</label><input type="text" value="{{ customer.contact_person or '—' }}" disabled></div>
|
||||
<div class="field">
|
||||
<label>Anschrift</label>
|
||||
<input type="text" value="{{ [customer.street, customer.house_number]|select|join(' ') or '—' }}{{ ', ' ~ [customer.postal_code, customer.city]|select|join(' ') if customer.postal_code or customer.city else '' }}" disabled>
|
||||
</div>
|
||||
{% if customer.notes %}
|
||||
<div class="field"><label>Notizen</label><textarea disabled rows="3">{{ customer.notes }}</textarea></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div><h2 style="font-size:16px;">Eigene Tickets bei uns</h2></div>
|
||||
<a href="{{ url_for('ticket_new') }}?customer_id={{ customer.id }}" class="btn btn-sm btn-secondary">Neues Ticket</a>
|
||||
</div>
|
||||
{% if tickets %}
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Typ</th><th>Rolle</th><th>Ticket</th><th>Lizenz-Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in tickets %}
|
||||
<tr>
|
||||
<td>{{ type_labels.get(t.type, t.type) }}</td>
|
||||
<td>{% if t.role == 'license_server' %}<span class="pill admin">Lizenzserver</span>{% else %}Kunde{% endif %}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('ticket_detail', ticket_id=t.id) }}" class="pill {{ 'online' if t.status == 'open' else 'offline' }}">
|
||||
{{ 'Offen' if t.status == 'open' else 'Gesperrt' }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if t.license %}
|
||||
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(t.license.status, 'disabled') }}">
|
||||
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }}
|
||||
</span>
|
||||
{% else %}<span class="text-faint">Noch keine Lizenz</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Noch keine Tickets für diesen Kunden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if sub_license %}
|
||||
<div class="card card-pad" style="margin-top:20px;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Von diesem Lizenzserver verwaltete Kunden</h2>
|
||||
<div class="hint">
|
||||
Gemeldet vom Sub-Lizenzserver selbst bei jedem Heartbeat {{ hi.hint_icon("Rein informativ -- diese Daten werden nicht von uns verwaltet, sondern vom Sub-Lizenzserver selbst bei jedem Heartbeat (alle 6h, zusaetzlich sofort nach jeder dortigen Aenderung) als vollstaendiger Schnappschuss gemeldet und hier nur angezeigt.", "Gemeldete Daten") }}
|
||||
{% if sub_license.last_heartbeat_at %}— zuletzt {{ sub_license.last_heartbeat_at }}{% else %}— noch kein Heartbeat empfangen{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if reported_customers %}
|
||||
{% for rc in reported_customers %}
|
||||
<div class="card card-pad" style="margin-bottom:12px; background:var(--bg-elevated);">
|
||||
<div class="section-head" style="margin-bottom:10px;">
|
||||
<div><strong>{{ rc.name }}</strong> <span class="text-dim" style="font-size:12px;">{{ rc.contact_email or '' }}</span></div>
|
||||
</div>
|
||||
{% set rtickets = reported_tickets_by_customer.get(rc.remote_customer_ref, []) %}
|
||||
{% if rtickets %}
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Typ</th><th>Laufzeit</th><th>Ticket-Status</th><th>Lizenz-Status</th><th>Ablauf</th></tr></thead>
|
||||
<tbody>
|
||||
{% for rt in rtickets %}
|
||||
{% set rlicenses = reported_licenses_by_ticket.get(rt.remote_ticket_id, []) %}
|
||||
{% set rl = rlicenses[0] if rlicenses else none %}
|
||||
<tr>
|
||||
<td>{{ type_labels.get(rt.type, rt.type) }}</td>
|
||||
<td class="text-dim">{{ 'Lifetime' if rt.lifetime else (rt.valid_days ~ ' Tage' if rt.valid_days else '—') }}</td>
|
||||
<td><span class="pill {{ 'online' if rt.status == 'open' else 'offline' }}">{{ 'Offen' if rt.status == 'open' else 'Gesperrt' }}</span></td>
|
||||
{% if rl %}
|
||||
<td>
|
||||
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(rl.status, 'disabled') }}">
|
||||
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(rl.status, rl.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-dim">
|
||||
{% if rl.is_lifetime %}Lifetime
|
||||
{% elif rl.days_left is not none %}
|
||||
<span style="{{ 'color:var(--danger);' if rl.color == 'red' else ('color:var(--warning);' if rl.color == 'orange' else '') }}">{{ rl.days_left }} Tage</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-faint" colspan="2">Noch keine Lizenz</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Noch keine Tickets gemeldet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Dieser Lizenzserver hat noch keine eigenen Kunden gemeldet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -31,12 +31,16 @@
|
||||
<th data-sort-key="address">Anschrift</th>
|
||||
<th data-sort-key="email">Kontakt</th>
|
||||
<th data-sort-key="tickets">Tickets</th>
|
||||
<th data-sort-key="licenses">Lizenzen</th>
|
||||
<th style="width:1%;">Aktionen</th>
|
||||
</tr></thead>
|
||||
{% for c in customers %}
|
||||
<tbody data-sort-name="{{ c.name|lower }}" data-sort-address="{{ (c.city or '')|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}">
|
||||
<tbody data-sort-name="{{ c.name|lower }}" data-sort-address="{{ (c.city or '')|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}" data-sort-licenses="{{ c.license_count }}">
|
||||
<tr>
|
||||
<td class="cell-name">{{ c.name }}</td>
|
||||
<td class="cell-name">
|
||||
<a href="{{ url_for('customer_detail', customer_id=c.id) }}">{{ c.name }}</a>
|
||||
{% if c.is_sub_license_server %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-dim">
|
||||
{% if c.street or c.city %}
|
||||
{{ c.street }} {{ c.house_number }}{% if c.street %}<br>{% endif %}{{ c.postal_code }} {{ c.city }}
|
||||
@@ -47,6 +51,7 @@
|
||||
{% if c.contact_person %}<br><span class="text-faint" style="font-size:11px;">{{ c.contact_person }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-dim"><a href="{{ url_for('tickets_list', customer_id=c.id) }}">{{ c.ticket_count }}</a></td>
|
||||
<td class="text-dim"><a href="{{ url_for('licenses_list', customer_id=c.id) }}">{{ c.license_count }}</a></td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if can_edit %}
|
||||
@@ -73,7 +78,7 @@
|
||||
</tbody>
|
||||
{% else %}
|
||||
<tbody data-sort-pinned>
|
||||
<tr class="empty-row"><td colspan="5">Noch keine Kunden angelegt.</td></tr>
|
||||
<tr class="empty-row"><td colspan="6">Noch keine Kunden angelegt.</td></tr>
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
@@ -130,12 +135,6 @@
|
||||
<label>Notizen</label>
|
||||
<textarea name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{{ c.notes or '' }}</textarea>
|
||||
</div>
|
||||
{% if c.portal_token %}
|
||||
<div class="field">
|
||||
<label>Kundenportal-Link (alle Tickets)</label>
|
||||
<input type="text" class="mono" value="{{ url_for('customer_portal', token=c.portal_token, _external=True) }}" readonly onclick="this.select()">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -37,7 +37,10 @@
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button>
|
||||
</form>
|
||||
<div style="margin-top:16px; font-size:10.5px; color:var(--text-faint); text-align:center;">TESM-Lizenzserver v{{ tesm_version }}</div>
|
||||
<div style="margin-top:16px; font-size:11px; text-align:center;">
|
||||
Kunde? <a href="{{ url_for('portal_request') }}">Zugang zu meinem Konto anfordern</a>
|
||||
</div>
|
||||
<div style="margin-top:12px; font-size:10.5px; color:var(--text-faint); text-align:center;">TESM-Lizenzserver v{{ tesm_version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zugang anfordern · TESM-Lizenzserver</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
|
||||
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-bg">
|
||||
<img src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM-Lizenzserver" id="login-bg-logo">
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<h1>Zugang zu meinem Konto</h1>
|
||||
<div class="hint" style="text-align:center; margin-bottom:18px;">
|
||||
Für Kunden: Link zum eigenen Kundenportal erneut zuschicken lassen.
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="field" style="margin-bottom:18px;">
|
||||
{% for message in messages %}
|
||||
<div class="toast success" style="position:static; animation:none;">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post" data-no-unsaved-guard>
|
||||
<div class="field">
|
||||
<label for="email">Ihre E-Mail-Adresse</label>
|
||||
<input type="email" id="email" name="email" autocomplete="email" required autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Link zusenden</button>
|
||||
</form>
|
||||
<div style="margin-top:16px; font-size:11px; text-align:center;">
|
||||
<a href="{{ url_for('login') }}">← Zurück zum Login</a>
|
||||
</div>
|
||||
<div style="margin-top:16px; font-size:10.5px; color:var(--text-faint); text-align:center;">TESM-Lizenzserver</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ asset_url('js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,7 +17,7 @@
|
||||
<h1>Meine Lizenz</h1>
|
||||
<div class="hint" style="text-align:center; margin-bottom:18px;">
|
||||
{{ customer.name if customer else '' }}
|
||||
{% if portal_url %}<br><a href="{{ portal_url }}">← Alle meine Tickets</a>{% endif %}
|
||||
<br><a href="{{ portal_request_url }}">← Alle meine Tickets anzeigen</a>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "settings_license" %}
|
||||
{% set can_edit = current_user.has_permission('settings_system.edit') %}
|
||||
{% set can_edit = current_user.has_permission('settings_license.edit') %}
|
||||
{% block page_title %}Lizenz{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
|
||||
|
||||
|
||||
@@ -10,26 +10,40 @@
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div><h2 style="font-size:16px;">Ticket</h2></div>
|
||||
<span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}">
|
||||
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }}
|
||||
</span>
|
||||
<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>
|
||||
{% if portal_url %}
|
||||
<div class="field">
|
||||
<label>Kundenportal-Link (alle Tickets des Kunden) {{ hi.hint_icon("Zeigt dem Kunden ALLE seine Tickets samt Lizenzstatus und erlaubt ihm, seine Kontaktdaten selbst zu pflegen.", "Kundenportal-Link") }}</label>
|
||||
<input type="text" class="mono" value="{{ portal_url }}" readonly onclick="this.select()">
|
||||
<div class="hint" style="margin-top:6px;">
|
||||
Übersicht über ALLE Tickets des Kunden {{ hi.hint_icon("Es gibt bewusst keinen dauerhaften Kundenportal-Link mehr -- der Kunde fordert sich unter " ~ portal_request_url ~ " bei Bedarf selbst einen zeitlich begrenzten Zugang an.", "Kundenportal") }}:
|
||||
der Kunde fordert ihn selbst über <a href="{{ portal_request_url }}" target="_blank" rel="noopener">{{ portal_request_url }}</a> an (zeitlich begrenzter Zugang, keine dauerhafte Freigabe).
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
{% if can_edit_ticket %}
|
||||
<div class="flex gap-2" style="margin-top:14px;">
|
||||
<button type="button" class="btn btn-secondary" style="flex:1;" data-open-modal="editTicketModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
@@ -71,7 +85,7 @@
|
||||
Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann
|
||||
eine neue erstellt werden.
|
||||
</p>
|
||||
{% elif can_edit %}
|
||||
{% elif can_issue_license %}
|
||||
<form method="post" style="margin-bottom:10px;">
|
||||
<input type="hidden" name="issue_license" value="regular">
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
@@ -79,58 +93,44 @@
|
||||
{{ 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;">Lizenzen zu diesem Ticket</h2></div>
|
||||
<div><h2 style="font-size:16px;">Aktuelle Lizenz</h2></div>
|
||||
<a href="{{ url_for('licenses_list', ticket_id=ticket.id) }}" class="btn btn-secondary">
|
||||
Alle Lizenzen dieses Tickets →
|
||||
</a>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Lizenz-ID</th><th>Typ</th><th>Ablauf</th><th>Status</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
{% for l in licenses %}
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="mono text-dim">{{ l.license_id[:8] }}…</td>
|
||||
<td>{{ type_labels.get(l.type, l.type) }}</td>
|
||||
<td class="text-dim">{{ 'Lifetime' if l.is_lifetime else l.expires_at[:10] }}</td>
|
||||
<td>
|
||||
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(l.status, 'disabled') }}">
|
||||
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(l.status, l.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<a class="icon-btn" title="Details" href="{{ url_for('license_detail', license_id=l.license_id) }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</a>
|
||||
{% if can_edit and l.status in ('revoked', 'issued') %}
|
||||
<form method="post" action="{{ url_for('license_delete', license_id=l.license_id) }}"
|
||||
data-confirm="Lizenz wirklich löschen? Das Ticket bleibt bestehen -- eine neue Lizenz mit denselben Eckdaten kann jederzeit daraus erstellt werden.">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{% else %}
|
||||
<tbody><tr class="empty-row"><td colspan="5">Noch keine Lizenz aus diesem Ticket erstellt.</td></tr></tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% 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 %}
|
||||
{% 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) }}">
|
||||
|
||||
@@ -35,6 +35,15 @@
|
||||
<div class="hint" style="margin-bottom:14px;">Weitere Angaben (Anschrift, Ansprechpartner, Telefon) lassen sich anschließend unter <a href="{{ url_for('customers') }}">Kunden</a> ergänzen.</div>
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
@@ -90,7 +99,12 @@ function toggleNewCustomerFields() {
|
||||
}
|
||||
function toggleModuleFields() {
|
||||
const type = document.getElementById("license_type").value;
|
||||
document.getElementById("moduleFields").classList.toggle("hidden", type !== "custom");
|
||||
const roleBox = document.getElementById("role_license_server");
|
||||
const isLicenseServer = roleBox && roleBox.checked;
|
||||
// Module sind bei einer Lizenzserver-Lizenz bedeutungslos (siehe
|
||||
// ticket_new() in app.py, erzwingt dort ohnehin modules=[]) -- Feld
|
||||
// bleibt unabhaengig vom Lizenztyp ausgeblendet.
|
||||
document.getElementById("moduleFields").classList.toggle("hidden", isLicenseServer || type !== "custom");
|
||||
// Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne
|
||||
// jedes Modul -- serverseitig ohnehin unterbunden (siehe ticket_new()
|
||||
// in app.py), hier nur als klares UI-Signal ausgegraut.
|
||||
@@ -101,6 +115,9 @@ function toggleModuleFields() {
|
||||
toggleLifetime();
|
||||
}
|
||||
}
|
||||
function toggleLicenseServerRole() {
|
||||
toggleModuleFields();
|
||||
}
|
||||
function toggleLifetime() {
|
||||
const isLifetime = document.getElementById("lifetime").checked;
|
||||
document.getElementById("validDaysField").classList.toggle("hidden", isLifetime);
|
||||
|
||||
@@ -45,7 +45,10 @@
|
||||
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 }}</td>
|
||||
<td class="cell-name">
|
||||
{{ t.customer_name }}
|
||||
{% if t.role == 'license_server' %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ type_labels.get(t.type, t.type) }}</td>
|
||||
<td 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>
|
||||
|
||||
Reference in New Issue
Block a user