Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2ed00a4e | ||
|
|
286a3464f1 | ||
|
|
619673ac76 | ||
|
|
20abadb0e7 | ||
|
|
2d920f581f | ||
|
|
c2989a6e2f | ||
|
|
cee5311b1d | ||
|
|
7e39320c87 | ||
|
|
b280cbf121 | ||
|
|
5fa60ec3ef |
@@ -1 +1 @@
|
||||
1
|
||||
3
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.2.6
|
||||
|
||||
+1323
-108
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 90 Tage, orange 30-90 Tage, rot < 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 (< 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 class="stat-card">
|
||||
<div class="stat-label">Grün (> 90 Tage / Lifetime)</div>
|
||||
<div class="stat-value online">{{ stats.green }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Aktiv gesamt</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</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) }}
|
||||
|
||||
{% 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>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<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 %}
|
||||
<tbody data-sort-pinned>
|
||||
<tr class="empty-row"><td colspan="7">Noch keine Lizenzen ausgestellt.</td></tr>
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>×</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>×</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 %}
|
||||
@@ -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..."
|
||||
|
||||
Reference in New Issue
Block a user