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>
189 lines
9.9 KiB
HTML
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>×</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 %}
|