Files
tesm-license/srv/tesm-license/templates/ticket_detail.html
T
alientimandClaude Sonnet 5 2d920f581f Eigenstaendige Lizenzen-Seite, entschachtelte Ticket-Ansicht, zeitlich begrenzter Kundenportal-Zugang (v1.2.2)
Reaktion auf Feedback: 'Tickets und Lizenzen je Kunden war sehr sehr
unintuitiv dargestellt... Kunde -> Ticket -> Lizenz aber alles auf extra
Seiten, im Menü einzeln anwählbar ohne diese riesen Verschachtelung.'

## Lizenzen als eigene, gleichberechtigte Ebene (neu: /licenses)

Bisher war eine Lizenz nur ueber Kunde -> gefilterte Tickets -> Ticket-
Detail -> eingebettete Historie-Tabelle erreichbar. Jetzt: Kunden,
Tickets UND Lizenzen sind drei eigene, direkt in der Navigation
erreichbare Seiten (Reihenfolge im Menü spiegelt bewusst die fachliche
Hierarchie). Kunden- und Ticket-Seite verlinken mit Filter (?customer_id=/
?ticket_id=) dorthin, statt Lizenzdaten selbst einzubetten.

Ticket-Detailseite dadurch massiv entschlackt: statt einer vollen
Lizenz-Historie-Tabelle nur noch eine kompakte 'Aktuelle Lizenz'-Zeile
mit Link auf die (ggf. gefilterte) Lizenzen-Seite fuer die volle Historie.

## Kundenportal: kein dauerhafter Link mehr, sondern zeitlich begrenzter Zugang

Vorheriger Ansatz (v1.2.0): ein dauerhafter, unbefristeter Token pro
Kunde, von Admins verteilt. Auf Nachfrage ('wie bekommt der Kunde seinen
Magic-Link, wenn er ihn verloren hat?') kam eine oeffentliche
'Zugang anfordern'-Seite dazu (E-Mail eingeben -> Link zugeschickt) --
und direkt im Anschluss die berechtigte Sicherheitsfrage, ob so ein
oeffentlich auf blossen Zuruf ausloesbarer Link nicht zeitlich begrenzt
sein sollte. Ergebnis: der dauerhafte Link wurde komplett entfernt.

Neuer, einziger Weg ins Kundenportal:
1. /portal/request -- Kunde gibt seine E-Mail ein (oeffentlich, keine
   Anmeldung noetig). Immer dieselbe generische Erfolgsmeldung,
   unabhaengig davon ob die Adresse bekannt ist (keine Enumeration).
2. Bei bekannter Adresse: ein frischer, einmalig nutzbarer Zugangs-Token
   (portal_access_tokens-Tabelle) wird per E-Mail verschickt, gueltig nur
   PORTAL_ACCESS_LINK_MINUTES (30) ab Anforderung.
3. /portal/access/<token> prueft Gueltigkeit + Einmalnutzung, markiert
   den Token als benutzt und setzt eine Session, die ihrerseits nach
   PORTAL_SESSION_MINUTES (60) automatisch ablaeuft.
4. /portal/me zeigt das eigentliche Portal (alle Tickets/Lizenzen,
   Kontaktdaten pflegen) -- ausschliesslich session-basiert erreichbar.

license_customers.portal_token bleibt in der DB bestehen (keine riskante
Schema-Ruecknahme), wird fuer die Zugriffsentscheidung aber nicht mehr
herangezogen -- alle Stellen, die vorher den dauerhaften Portal-Link
eingebettet haben (Ticket-Seite, Lizenz-per-E-Mail, Self-Service-Seite),
verweisen jetzt stattdessen auf /portal/request.

Verifiziert: bestehende Funktionstests erneut komplett durchlaufen (neue
Lizenzen-Seite, entschachtelte Ticket-Seite, entfernter dauerhafter
Zugang liefert 404), plus 11 neue Tests fuer den kompletten Magic-Link-
Fluss (Token-Erzeugung, Einmalnutzung, Ablehnen bei Wiederverwendung/
unbekanntem/abgelaufenem Token, Session bleibt nach Erstnutzung gueltig,
/portal/me ohne Session leitet zur Anfrage-Seite um) -- alle bestanden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:03:55 +02:00

189 lines
9.9 KiB
HTML

{% 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>
<span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}">
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }}
</span>
</div>
<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>
<div class="field"><label>Module</label><input type="text" value="{{ modules_list|join(', ') if modules_list else 'keine' }}" disabled></div>
<div class="field"><label>Gültigkeitsdauer</label><input type="text" value="{{ 'Lifetime (unbegrenzt)' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}" disabled></div>
<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>
{% if can_edit %}
<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_edit %}
<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>
<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 %}
</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 %}
<div class="modal-overlay" id="editTicketModal">
<div class="modal" style="max-width:520px;">
<form method="post" action="{{ url_for('ticket_edit', ticket_id=ticket.id) }}">
<div class="modal-header">
<h3>Ticket bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="hint" style="margin-bottom:14px;">
Wirkt sich nur auf die NÄCHSTE aus diesem Ticket erstellte Lizenz aus -- bereits ausgestellte Lizenzen bleiben unverändert.
</div>
<div class="field">
<label>Lizenztyp</label>
<select name="license_type" id="editLicenseType" onchange="toggleEditModuleFields()">
{% for lt in license_types %}
<option value="{{ lt }}" {% if lt == ticket.type %}selected{% endif %}>{{ type_labels.get(lt, lt) }}</option>
{% endfor %}
</select>
</div>
<div id="editModuleFields" class="field {% if ticket.type != 'custom' %}hidden{% endif %}">
<label>Module</label>
<div class="check-list">
{% for m in all_modules %}
<label class="check-row">
<input type="checkbox" name="modules" value="{{ m }}" {% if m in modules_list %}checked{% endif %}>
{{ module_labels.get(m, m) }}
</label>
{% endfor %}
</div>
</div>
<div class="field">
<label class="check-row">
<input type="checkbox" name="lifetime" value="1" id="editLifetime" {% if ticket.lifetime %}checked{% endif %}
{% if ticket.type == 'trial' %}disabled{% endif %} onchange="toggleEditLifetime()">
Lifetime (unbegrenzte Laufzeit)
</label>
</div>
<div class="field" id="editValidDaysField" {% if ticket.lifetime %}style="display:none;"{% endif %}>
<label>Gültigkeitsdauer (Tage)</label>
<input type="number" name="valid_days" id="editValidDays" value="{{ ticket.valid_days }}" min="1">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function toggleEditModuleFields() {
const type = document.getElementById("editLicenseType").value;
document.getElementById("editModuleFields").classList.toggle("hidden", type !== "custom");
const lifetimeBox = document.getElementById("editLifetime");
lifetimeBox.disabled = (type === "trial");
if (type === "trial" && lifetimeBox.checked) {
lifetimeBox.checked = false;
toggleEditLifetime();
}
}
function toggleEditLifetime() {
document.getElementById("editValidDaysField").style.display = document.getElementById("editLifetime").checked ? "none" : "";
}
</script>
{% endblock %}