fix(webui): Ansicht nach dem Speichern beibehalten, Lizenzmodul zusammengelegt

Rücksprung auf das Dashboard
Nach jedem Speichern landete man auf der Standard-Unteransicht des Moduls
statt dort, wo man gearbeitet hatte. Drei Ursachen:

- Die Handler setzten nur den Modul-Anker (#tab-watchdog). Damit blieb die
  Unteransicht unbestimmt und es wurde die erste angezeigt.
- Die Wiederherstellung aus dem Anker leitete den Modulnamen aus dem Namen der
  Unteransicht ab (sub-<modul>-<rest>). Für "sub-update-releases" ergab das
  "update" statt "updateservice".
- Der zugehörige Navigationsknopf wurde über seinen Beschriftungstext gesucht,
  der den Modulnamen nie enthält. Der Rückweg schlug also immer fehl.

Jetzt teilt das Formular beim Absenden mit, welche Unteransicht offen war
(_return), und der Server leitet dorthin zurück. Umgesetzt als zentraler
submit-Zuhörer, damit künftige Formulare das Verhalten erben. Die Zuordnung
Unteransicht -> Modul kommt aus derselben Liste, die auch die Navigation
aufbaut, und die Knöpfe tragen eine eindeutige Kennung statt Text.

Zusätzlich wird die Ansicht jetzt auch bei Vor- und Zurück-Navigation im
Browser wiederhergestellt.

Lizenzmodul
Vier Unteransichten für eine Aufgabe sind auf zwei sinnvolle reduziert:
- "Dashboard" zeigte drei Zahlen; sie stehen jetzt über der Liste.
- "Lizenz Bearbeiten & HW" ist entfallen. Zum Bearbeiten musste man die gerade
  angeklickte Lizenz in einer Auswahlliste erneut suchen. Jetzt öffnet sich ein
  Dialog direkt über der Tabelle, die Hardware-Liste steht daneben.
- "Settings" enthielt zwei fest verdrahtete URLs und ist in "Integration"
  aufgegangen, zusammen mit einem Beispielaufruf.
- Der dort angezeigte "Ed25519 Public Key" war frei erfunden und wurde
  entfernt; die Offline-Vorschau nennt jetzt das tatsächlich verwendete
  HMAC-SHA256 und weist darauf hin, dass nur der Download signiert ist.

Nebenbei
Der Inhalt des Projekte-Moduls lag direkt im Modul-Container, ohne
subtab-content. Der Navigationseintrag "Projekte Übersicht" zeigte damit ins
Leere. Jetzt einheitlich wie alle anderen Module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-08 10:39:35 +02:00
co-authored by Claude Opus 5
parent 92bb400f02
commit 65e60899f9
+458 -196
View File
@@ -226,9 +226,25 @@ function dc_flash(string $message, string $type = 'success'): void
/**
* Post/Redirect/Get: leitet nach einer Aktion auf dieselbe Seite um, damit
* ein Neuladen das Formular nicht erneut abschickt.
*
* Das Formular teilt beim Absenden im Feld _return mit, welche Unteransicht
* gerade geoeffnet war; dorthin wird zurueckgeleitet. Der als $fragment
* uebergebene Wert dient nur noch als Rueckfallebene, etwa wenn JavaScript
* abgeschaltet ist.
*
* Zuvor wurde ausschliesslich der Modul-Anker gesetzt. Man legte einen Monitor
* an und landete anschliessend auf dem Watchdog-Dashboard statt im Formular.
*/
function dc_redirect(string $fragment = ''): void
{
$return = $_POST['_return'] ?? null;
// Nur eigene Ansichtskennungen zulassen - der Wert landet in einem
// Location-Header.
if (is_string($return) && preg_match('/^(?:tab|sub)-[a-z0-9-]{1,60}$/', $return) === 1) {
$fragment = '#' . $return;
}
$query = $_SERVER['QUERY_STRING'] ?? '';
$target = '/index.php' . ($query !== '' ? '?' . $query : '') . $fragment;
@@ -1722,6 +1738,10 @@ $csrfField = Csrf::field();
<!-- ================= MODULE 1: PROJEKTE ================= -->
<div id="tab-projects" class="tab-content">
<!-- Einheitlich wie alle anderen Module: der Inhalt liegt in einer
benannten Unteransicht. Zuvor stand er direkt im Modul, wodurch
der Navigationseintrag "Projekte Uebersicht" ins Leere zeigte. -->
<div id="sub-projects-list" class="subtab-content active">
<div class="card">
<div class="card-header"><h2 class="card-title">📁 Neues Projekt anlegen / Bearbeiten</h2></div>
<form method="POST" action="index.php#tab-projects">
@@ -1789,6 +1809,7 @@ $csrfField = Csrf::field();
</tbody>
</table>
</div>
</div>
</div>
<!-- Delete Project Safety Modal -->
@@ -1951,48 +1972,60 @@ $csrfField = Csrf::field();
</form>
<!-- ================= MODULE 2: LIZENZEN ================= -->
<!--
Zusammengelegt: "Dashboard", "Lizenzverwaltung", "Lizenz Bearbeiten & HW"
und "Settings" waren vier Unteransichten fuer eine Aufgabe. Das
Dashboard zeigte drei Zahlen, Settings zwei fest verdrahtete URLs,
und zum Bearbeiten sprang man in eine eigene Ansicht mit einer
Auswahlliste, in der man die gerade angeklickte Lizenz erneut
suchen musste. Jetzt eine Ansicht mit einem Bearbeiten-Dialog.
-->
<div id="tab-license" class="tab-content">
<!-- Subtab: License Dashboard -->
<div id="sub-license-dashboard" class="subtab-content active">
<div class="stats-grid">
<div id="sub-license-licenses" class="subtab-content active">
<?php
$activeLicenses = count(array_filter($licenses, static fn($l): bool => $l['status'] === 'active'));
$blockedActivations = count(array_filter($activations, static fn($a): bool => !empty($a['is_blocked'])));
?>
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
<div class="stat-card">
<div class="stat-header">Registrierte Projekte</div>
<div class="stat-value"><?= count($projects) ?></div>
<div class="stat-header">Lizenzen gesamt</div>
<div class="stat-value"><?= count($licenses) ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Gültige Lizenzen</div>
<div class="stat-value"><?= count(array_filter($licenses, fn($l) => $l['status'] === 'active')) ?></div>
<div class="stat-header">davon aktiv</div>
<div class="stat-value" style="color:var(--success);"><?= $activeLicenses ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Aktivierte Systeme</div>
<div class="stat-value"><?= count($activations) ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Gesperrte Hardware</div>
<div class="stat-value" style="color:<?= $blockedActivations > 0 ? 'var(--danger)' : 'var(--text-muted)' ?>;"><?= $blockedActivations ?></div>
</div>
</div>
</div>
<!-- Subtab: Licenses -->
<div id="sub-license-licenses" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">Neuen Lizenzschlüssel generieren</h2></div>
<div class="card-header"><h2 class="card-title">🔑 Neuen Lizenzschlüssel generieren</h2></div>
<form method="POST" action="index.php#sub-license-licenses">
<?= $csrfField ?>
<?= $csrfField ?>
<input type="hidden" name="action" value="create_license">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt</label>
<select name="product_id" class="form-input" required>
<?php foreach ($projects as $p): ?>
<option value="<?= $p['id'] ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
<option value="<?= (int)$p['id'] ?>"><?= e($p['name']) ?> (<?= e($p['slug']) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Kunden Name</label>
<label class="form-label">Kunden-Name</label>
<input type="text" name="customer_name" class="form-input" placeholder="Musterfirma GmbH">
</div>
<div class="form-group">
<label class="form-label">Kunden E-Mail</label>
<label class="form-label">Kunden-E-Mail</label>
<input type="email" name="customer_email" class="form-input" placeholder="kunde@example.com">
</div>
<div class="form-group">
@@ -2004,7 +2037,7 @@ $csrfField = Csrf::field();
<input type="date" name="expires_at" class="form-input">
</div>
</div>
<button type="submit" class="btn">Lizenz Generieren</button>
<button type="submit" class="btn">Lizenz generieren</button>
</form>
</div>
@@ -2023,81 +2056,77 @@ $csrfField = Csrf::field();
</tr>
</thead>
<tbody>
<?php foreach ($licenses as $l): ?>
<?php if ($licenses === []): ?>
<tr>
<td><?= htmlspecialchars($l['product_name']) ?></td>
<td><code style="font-size:0.9rem; font-weight:bold; color:var(--primary);"><?= htmlspecialchars($l['license_key']) ?></code></td>
<td><?= htmlspecialchars($l['customer_name'] ?? '-') ?></td>
<td><?= $l['active_count'] ?> / <?= $l['max_activations'] ?></td>
<td><span class="badge badge-<?= $l['status'] === 'active' ? 'up' : 'down' ?>"><?= strtoupper($l['status']) ?></span></td>
<td><?= $l['expires_at'] ? htmlspecialchars($l['expires_at']) : 'Unbefristet' ?></td>
<td style="display:flex; gap:0.35rem;">
<a href="index.php?action=download_lic&id=<?= $l['id'] ?>" class="btn btn-sm btn-secondary" title=".lic Datei herunterladen">📥 .lic</a>
<button class="btn btn-sm btn-secondary" onclick="openEditLicense(<?= htmlspecialchars(json_encode($l)) ?>)">✏️ Edit</button>
<td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">
Noch keine Lizenzen angelegt.
</td>
</tr>
<?php endif; ?>
<?php foreach ($licenses as $l): ?>
<?php
$used = (int)$l['active_count'];
$max = (int)$l['max_activations'];
$full = $max > 0 && $used >= $max;
$licJson = e((string)json_encode($l, JSON_INVALID_UTF8_SUBSTITUTE));
?>
<tr>
<td><?= e($l['product_name']) ?></td>
<td><code style="font-size:0.85rem; font-weight:bold; color:var(--primary);"><?= e($l['license_key']) ?></code></td>
<td>
<?= e($l['customer_name'] ?? '—') ?>
<?php if (!empty($l['customer_email'])): ?>
<div style="font-size:0.72rem; color:var(--text-muted);"><?= e($l['customer_email']) ?></div>
<?php endif; ?>
</td>
<td>
<span class="badge <?= $full ? 'badge-warning' : 'badge-stopped' ?>">
<?= $used ?> / <?= $max ?>
</span>
</td>
<td>
<span class="badge badge-<?= $l['status'] === 'active' ? 'up' : 'down' ?>">
<?= e(strtoupper((string)$l['status'])) ?>
</span>
</td>
<td style="font-size:0.8rem;">
<?= !empty($l['expires_at']) ? e(dc_dt((string)$l['expires_at'], 'd.m.Y')) : 'Unbefristet' ?>
</td>
<td>
<div style="display:flex; gap:0.35rem; flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-secondary"
onclick="openEditLicense(<?= $licJson ?>)">✏️ Bearbeiten</button>
<a href="index.php?action=download_lic&amp;id=<?= (int)$l['id'] ?>"
class="btn btn-sm btn-secondary" title="Signierte Offline-Lizenzdatei herunterladen">📥 .lic</a>
<form method="POST" action="index.php#sub-license-licenses" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="toggle_license_status">
<input type="hidden" name="license_id" value="<?= (int)$l['id'] ?>">
<input type="hidden" name="new_status" value="<?= $l['status'] === 'active' ? 'revoked' : 'active' ?>">
<button type="submit" class="btn btn-sm <?= $l['status'] === 'active' ? 'btn-danger' : 'btn' ?>">
<?= $l['status'] === 'active' ? 'Widerrufen' : 'Aktivieren' ?>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Subtab: License Details & Full Editing -->
<div id="sub-license-details" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">✏️ Lizenz Vollständig Bearbeiten</h2></div>
<form method="POST" action="index.php#sub-license-details">
<?= $csrfField ?>
<input type="hidden" name="action" value="edit_license">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Lizenz auswählen</label>
<select name="license_id" id="licEditSelect" class="form-input" required onchange="loadLicenseData(this)">
<?php foreach ($licenses as $l): ?>
<option value="<?= $l['id'] ?>" data-cname="<?= htmlspecialchars($l['customer_name'] ?? '') ?>" data-cemail="<?= htmlspecialchars($l['customer_email'] ?? '') ?>" data-status="<?= $l['status'] ?>" data-max="<?= $l['max_activations'] ?>" data-exp="<?= $l['expires_at'] ? date('Y-m-d', strtotime($l['expires_at'])) : '' ?>" data-notes="<?= htmlspecialchars($l['notes'] ?? '') ?>">
<?= htmlspecialchars($l['license_key']) ?> (<?= htmlspecialchars($l['customer_name'] ?? 'Unbekannt') ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Kunden Name</label>
<input type="text" id="editCustomerName" name="customer_name" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Kunden E-Mail</label>
<input type="email" id="editCustomerEmail" name="customer_email" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select id="editStatus" name="status" class="form-input">
<option value="active">Aktiv</option>
<option value="revoked">Widerrufen</option>
<option value="suspended">Pausiert</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Max. Aktivierungen</label>
<input type="number" id="editMaxAct" name="max_activations" class="form-input" min="1">
</div>
<div class="form-group">
<label class="form-label">Ablaufdatum</label>
<input type="date" id="editExpiresAt" name="expires_at" class="form-input">
</div>
</div>
<button type="submit" class="btn">Lizenzdaten Speichern</button>
</form>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Aktivierte Hardware-IDs verwalten</h2></div>
<div class="card-header">
<h2 class="card-title">🖥️ Aktivierte Hardware</h2>
</div>
<table>
<thead>
<tr>
<th>Projekt</th>
<th>Lizenzschlüssel</th>
<th>Hardware-ID / Quelle</th>
<th>Plattform / Ver.</th>
<th>Plattform</th>
<th>Hostname</th>
<th>Zuletzt gesehen</th>
<th>Status</th>
@@ -2105,44 +2134,57 @@ $csrfField = Csrf::field();
</tr>
</thead>
<tbody>
<?php if ($activations === []): ?>
<tr>
<td colspan="8" style="text-align:center; padding:2rem; color:var(--text-muted);">
Noch keine Aktivierungen.
</td>
</tr>
<?php endif; ?>
<?php foreach ($activations as $a): ?>
<tr>
<td><?= htmlspecialchars($a['product_name']) ?></td>
<td><code><?= htmlspecialchars($a['license_key']) ?></code></td>
<td><?= e($a['product_name']) ?></td>
<td><code style="font-size:0.78rem;"><?= e($a['license_key']) ?></code></td>
<td>
<code><?= htmlspecialchars($a['hardware_id']) ?></code>
<code style="font-size:0.72rem; word-break:break-all;"><?= e($a['hardware_id']) ?></code>
<?php if (!empty($a['hwid_source'])): ?>
<br><small style="color:var(--text-muted);">Quelle: <?= htmlspecialchars($a['hwid_source']) ?></small>
<div style="font-size:0.7rem; color:var(--text-muted);">Quelle: <?= e($a['hwid_source']) ?></div>
<?php endif; ?>
</td>
<td>
<span class="badge" style="background:var(--bg-card); border:1px solid var(--border-color); color:var(--text-main);">
<?= htmlspecialchars(strtoupper($a['platform'] ?? 'WIN')) ?> (v<?= (int)($a['hwid_version'] ?? 1) ?>)
<span class="badge badge-stopped">
<?= e(strtoupper((string)($a['platform'] ?? 'WIN'))) ?> · v<?= (int)($a['hwid_version'] ?? 1) ?>
</span>
</td>
<td><?= htmlspecialchars($a['hostname'] ?? '-') ?></td>
<td><?= htmlspecialchars($a['last_seen']) ?></td>
<td>
<span class="badge badge-<?= $a['is_blocked'] ? 'down' : 'up' ?>"><?= $a['is_blocked'] ? 'GESPERRT' : 'AKTIV' ?></span>
<td style="font-size:0.8rem;"><?= e($a['hostname'] ?? '') ?></td>
<td style="font-size:0.78rem; color:var(--text-muted);" title="<?= e(dc_dt($a['last_seen'] ?? null, 'd.m.Y H:i:s')) ?>">
<?= e(dc_ago($a['last_seen'] ?? null)) ?>
</td>
<td>
<form method="POST" action="index.php#sub-license-details" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="toggle_block_activation">
<input type="hidden" name="activation_id" value="<?= $a['id'] ?>">
<input type="hidden" name="block_state" value="<?= $a['is_blocked'] ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm <?= $a['is_blocked'] ? 'btn' : 'btn-danger' ?>">
<?= $a['is_blocked'] ? 'Entsperren' : 'Sperren' ?>
</button>
</form>
<form method="POST" action="index.php#sub-license-details" style="display:inline" onsubmit="return confirm('Möchtest du diese Aktivierung wirklich freigeben (löschen)?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="release_activation">
<input type="hidden" name="activation_id" value="<?= $a['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" style="margin-left:4px;">
Freigeben
</button>
</form>
<span class="badge badge-<?= !empty($a['is_blocked']) ? 'down' : 'up' ?>">
<?= !empty($a['is_blocked']) ? 'GESPERRT' : 'AKTIV' ?>
</span>
</td>
<td>
<div style="display:flex; gap:0.35rem; flex-wrap:wrap;">
<form method="POST" action="index.php#sub-license-licenses" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="toggle_block_activation">
<input type="hidden" name="activation_id" value="<?= (int)$a['id'] ?>">
<input type="hidden" name="block_state" value="<?= !empty($a['is_blocked']) ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm <?= !empty($a['is_blocked']) ? 'btn' : 'btn-danger' ?>">
<?= !empty($a['is_blocked']) ? 'Entsperren' : 'Sperren' ?>
</button>
</form>
<form method="POST" action="index.php#sub-license-licenses" style="display:inline"
onsubmit="return confirm('Aktivierung wirklich freigeben? Der Platz wird für ein anderes System frei.');">
<?= $csrfField ?>
<input type="hidden" name="action" value="release_activation">
<input type="hidden" name="activation_id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary">Freigeben</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
@@ -2154,29 +2196,35 @@ $csrfField = Csrf::field();
<!-- Subtab: Offline Licenses -->
<div id="sub-license-offline" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">💾 Signierte Offline-Lizenzdatei erzeugen (.lic)</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1rem;">
Erzeugt eine signierte <code>.lic</code> Offline-Lizenzdatei für Air-Gapped Kundensysteme.
<div class="card-header"><h2 class="card-title">💾 Offline-Lizenzdatei (.lic)</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1rem; max-width:70ch;">
Für Systeme ohne Internetzugang. Die Datei wird serverseitig erzeugt und mit
HMAC-SHA256 über den Schlüssel aus <code>security.license_key</code> signiert.
Am schnellsten geht es über den 📥&nbsp;.lic-Knopf direkt in der Lizenzliste.
</p>
<div class="form-grid">
<div class="form-group">
<label class="form-label">Lizenz auswählen</label>
<select id="offlineLicSelect" class="form-input">
<?php foreach ($licenses as $l): ?>
<option value="<?= htmlspecialchars($l['license_key']) ?>"><?= htmlspecialchars($l['license_key']) ?> (<?= htmlspecialchars($l['customer_name'] ?? 'Unbekannt') ?>)</option>
<option value="<?= e($l['license_key']) ?>"><?= e($l['license_key']) ?> (<?= e($l['customer_name'] ?? 'ohne Kundenangabe') ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Ziel-Hardware-ID (optional)</label>
<input type="text" id="offlineHwId" class="form-input" placeholder="HWID-88A9-99B1-CC02">
<input type="text" id="offlineHwId" class="form-input" placeholder="2:win:a765bd47...">
</div>
</div>
<button class="btn" onclick="generateOfflinePayload()">Offline Payload Generieren</button>
<button class="btn" onclick="generateOfflinePayload()">Vorschau erzeugen</button>
<div style="margin-top:1.5rem;">
<label class="form-label">Generierte Offline .lic Payload (JSON)</label>
<div class="prompt-box" id="offlineResultBox">Wählen Sie oben eine Lizenz aus und klicken Sie auf 'Offline Payload Generieren'.</div>
<label class="form-label">Vorschau (unsigniert)</label>
<div class="prompt-box" id="offlineResultBox">Lizenz auswählen und auf „Vorschau erzeugen“ klicken.</div>
<p style="color:var(--text-muted); font-size:0.78rem; margin-top:0.5rem;">
Diese Vorschau dient nur der Ansicht. Die gültige, signierte Datei liefert
der 📥&nbsp;.lic-Knopf in der Lizenzliste.
</p>
</div>
</div>
</div>
@@ -2196,13 +2244,16 @@ $csrfField = Csrf::field();
</tr>
</thead>
<tbody>
<?php if ($auditLogs === []): ?>
<tr><td colspan="5" style="text-align:center; padding:2rem; color:var(--text-muted);">Noch keine Einträge.</td></tr>
<?php endif; ?>
<?php foreach ($auditLogs as $log): ?>
<tr>
<td><?= $log['id'] ?></td>
<td><?= $log['created_at'] ?></td>
<td><code><?= htmlspecialchars($log['actor']) ?></code></td>
<td><strong><?= htmlspecialchars($log['action']) ?></strong></td>
<td><code style="font-size:0.8rem;"><?= htmlspecialchars($log['details'] ?? '-') ?></code></td>
<td><?= (int)$log['id'] ?></td>
<td style="font-size:0.8rem;" title="<?= e((string)$log['created_at']) ?> UTC"><?= e(dc_dt((string)$log['created_at'])) ?></td>
<td><code style="font-size:0.78rem;"><?= e($log['actor']) ?></code></td>
<td><strong><?= e($log['action']) ?></strong></td>
<td><code style="font-size:0.75rem; word-break:break-all;"><?= e($log['details'] ?? '') ?></code></td>
</tr>
<?php endforeach; ?>
</tbody>
@@ -2210,37 +2261,51 @@ $csrfField = Csrf::field();
</div>
</div>
<!-- Subtab: Integration & Prompts -->
<!-- Subtab: Integration -->
<div id="sub-license-integration" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🔑 Server Ed25519 Public Key & Endpunkte</h2></div>
<div class="card-header"><h2 class="card-title">💻 Endpunkte & Anbindung</h2></div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">Server Public Key (Ed25519 Base64)</label>
<input type="text" id="pubKeyInput" readonly class="form-input" value="MCowBQYDK2VwAyEA9f8J7K2mX4vQ8n1L6s5t4r3q2p1o0n9m8l7k6j5h4g3f">
<label class="form-label">Prüfung (öffentlich, ohne Token)</label>
<input type="text" readonly class="form-input" value="<?= e($baseUrl) ?>/api/license/v1/validate">
</div>
<div class="form-group">
<label class="form-label">API Validate Endpoint</label>
<input type="text" readonly class="form-input" value="<?= $baseUrl ?>/api/license/v1/validate">
<label class="form-label">Deaktivierung (authentifiziert)</label>
<input type="text" readonly class="form-input" value="<?= e($baseUrl) ?>/api/license/v1/deactivate">
</div>
</div>
</div>
</div>
<!-- Subtab: Settings -->
<div id="sub-license-settings" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">⚙️ Moduleinstellungen & API Konfiguration</h2></div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">REST API Validation Endpoint (Public)</label>
<input type="text" readonly class="form-input" value="<?= $baseUrl ?>/api/license/v1/validate">
</div>
<div class="form-group">
<label class="form-label">REST API Deactivation Endpoint (Geschützt - Auth erforderlich)</label>
<input type="text" readonly class="form-input" value="<?= $baseUrl ?>/api/license/v1/deactivate">
</div>
<div class="alert" style="background:rgba(91,157,255,0.10); border:1px solid rgba(91,157,255,0.25); color:#bfdbfe; font-weight:500; font-size:0.82rem; margin-top:1.25rem;">
Die Antwort von <code>/validate</code> trägt den Lizenzzustand im Feld
<code>status</code> auf oberster Ebene (<code>valid</code>, <code>revoked</code>,
<code>expired</code>, <code>not_found</code>, <code>activation_limit</code>)
bewusst ohne den Umschlag der übrigen API, weil bereits ausgelieferte Clients
dieses Format erwarten.
</div>
<div class="prompt-box" style="margin-top:1rem;">POST <?= e($baseUrl) ?>/api/license/v1/validate
Content-Type: application/json
{
"product": "myapp",
"license_key": "XXXXX-XXXXX-XXXXX-XXXXX-XXXXX",
"hardware_id": "2:win:a765bd47...",
"hostname": "DESKTOP-01",
"app_version": "v1.4.2",
"nonce": "zufallswert"
}
Antwort:
{ "type": "validation_result", "status": "valid",
"expires_at": 1786435199, "cache_ttl_hours": 168,
"message": "License is valid" }</div>
<p style="color:var(--text-muted); font-size:0.82rem; margin-top:1rem;">
Fertige Anbindung für .NET: <code>Deploymentcenter.Client</code>
(<code>LicenseClient</code>, <code>HardwareId</code>, verschlüsselter Offline-Cache).
Ausführlich in <code>docs/LICENSE_INTEGRATION_GUIDE.md</code>.
</p>
</div>
</div>
</div>
@@ -3785,13 +3850,13 @@ SYSTEM
{ id: 'sub-projects-list', label: '📊 Projekte Übersicht', active: true }
],
'license': [
{ id: 'sub-license-dashboard', label: '📊 Dashboard', active: true },
{ id: 'sub-license-licenses', label: '🔑 Lizenzverwaltung' },
{ id: 'sub-license-details', label: '🔍 Lizenz Bearbeiten & HW' },
// Dashboard, "Lizenz Bearbeiten & HW" und Settings sind entfallen:
// vier Unteransichten fuer eine Aufgabe. Bearbeitet wird jetzt
// ueber einen Dialog direkt in der Liste.
{ id: 'sub-license-licenses', label: '🔑 Lizenzen & Hardware', active: true },
{ id: 'sub-license-offline', label: '💾 Offline-Lizenzen (.lic)' },
{ id: 'sub-license-audit', label: '📜 Audit-Log' },
{ id: 'sub-license-integration', label: '💻 Integration' },
{ id: 'sub-license-settings', label: '⚙️ Settings' }
{ id: 'sub-license-integration', label: '💻 Integration' }
],
'watchdog': [
{ id: 'sub-watchdog-dashboard', label: '📊 Dashboard', active: true },
@@ -3822,21 +3887,80 @@ SYSTEM
]
};
/**
* Ermittelt, zu welchem Modul eine Unteransicht gehoert.
*
* Zuvor wurde das aus dem Namen geraten (sub-<modul>-<rest>), was bei
* "sub-update-releases" fehlschlug - das Modul heisst updateservice.
* Die Zuordnung kommt jetzt aus derselben Liste, die auch die
* Navigation aufbaut, und ist damit immer korrekt.
*/
function moduleOfSubtab(subtabId) {
for (const moduleName of Object.keys(subnavItems)) {
if (subnavItems[moduleName].some(item => item.id === subtabId)) {
return moduleName;
}
}
return null;
}
// Render Subnav links in Top-Bar
function renderTopBarSubnav(moduleName) {
function renderTopBarSubnav(moduleName, activeSubtabId) {
const container = document.getElementById('topbarSubnav');
container.innerHTML = '';
const items = subnavItems[moduleName] || [];
items.forEach(item => {
const isActive = activeSubtabId ? (item.id === activeSubtabId) : !!item.active;
const btn = document.createElement('button');
btn.className = `subnav-link ${item.active ? 'active' : ''}`;
btn.className = `subnav-link ${isActive ? 'active' : ''}`;
btn.innerText = item.label;
// Eindeutige Kennung statt Textvergleich - der Beschriftungstext
// taugt nicht zum Wiederfinden.
btn.dataset.subtab = item.id;
btn.onclick = () => switchSubTab(moduleName, item.id, btn);
container.appendChild(btn);
});
}
/**
* Merkt sich beim Absenden, welche Unteransicht offen war.
*
* Der Server haengt diesen Wert nach dem Speichern wieder an die URL.
* Ohne das landete man stets auf dem Dashboard des Moduls - man legte
* einen Monitor an und wurde aus dem Formular geworfen.
*
* Als zentraler Zuhoerer umgesetzt, damit auch kuenftige Formulare das
* Verhalten automatisch erben.
*/
document.addEventListener('submit', (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
if ((form.method || '').toLowerCase() !== 'post') {
return;
}
if (form.querySelector('input[name="_return"]')) {
return;
}
const activeSub = document.querySelector('.subtab-content.active');
const activeTab = document.querySelector('.main-content > .tab-content.active');
const target = activeSub ? activeSub.id : (activeTab ? activeTab.id : '');
if (!target) {
return;
}
const field = document.createElement('input');
field.type = 'hidden';
field.name = '_return';
field.value = target;
form.appendChild(field);
}, true);
// Sidebar Toggle Handler
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
@@ -3869,7 +3993,11 @@ SYSTEM
}
// Main Module Navigation Switcher
function switchMainTab(moduleName, el) {
//
// activeSubtabId und updateHash werden beim Wiederherstellen aus der URL
// gesetzt: dort soll eine bestimmte Unteransicht geoeffnet und der
// Anker nicht ueberschrieben werden.
function switchMainTab(moduleName, el, activeSubtabId, updateHash = true) {
document.querySelectorAll('.main-content > .tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.nav-link').forEach(n => n.classList.remove('active'));
@@ -3877,8 +4005,10 @@ SYSTEM
if (targetTab) {
targetTab.classList.add('active');
}
if (el) {
el.classList.add('active');
const navLink = el || document.querySelector(`.nav-link[onclick*="'${moduleName}'"]`);
if (navLink) {
navLink.classList.add('active');
}
const pageTitles = {
@@ -3892,8 +4022,25 @@ SYSTEM
'system': 'System & Datenbank Status'
};
document.getElementById('topPageTitle').innerText = pageTitles[moduleName] || 'Deploymentcenter';
renderTopBarSubnav(moduleName);
location.hash = 'tab-' + moduleName;
renderTopBarSubnav(moduleName, activeSubtabId);
// Unteransicht passend zur Navigation einblenden
const items = subnavItems[moduleName] || [];
const wanted = activeSubtabId
|| (items.find(item => item.active) || items[0] || {}).id;
if (targetTab && wanted) {
targetTab.querySelectorAll('.subtab-content').forEach(s => s.classList.remove('active'));
const sub = document.getElementById(wanted);
if (sub) {
sub.classList.add('active');
}
}
if (updateHash) {
location.hash = wanted || ('tab-' + moduleName);
}
}
// Die frueher hier stehende Funktion filterBugtrackerTable() ist entfallen.
@@ -4115,9 +4262,15 @@ SYSTEM
if (targetSub) {
targetSub.classList.add('active');
}
if (el) {
el.classList.add('active');
// Ohne uebergebenen Knopf ueber die Kennung finden, damit die
// Markierung auch beim Wiederherstellen aus der URL stimmt.
const button = el
|| document.querySelector(`#topbarSubnav .subnav-link[data-subtab="${subtabId}"]`);
if (button) {
button.classList.add('active');
}
location.hash = subtabId;
}
@@ -4210,25 +4363,47 @@ SYSTEM
}
}
// License Editing Functions
function openEditLicense(l) {
switchMainTab('license', document.querySelector('.nav-link[onclick*="license"]'));
const subLink = document.querySelector('#topbarSubnav .subnav-link:nth-child(3)');
switchSubTab('license', 'sub-license-details', subLink);
// ------------------------------------------------------------------
// Lizenz bearbeiten
//
// Frueher sprang der Edit-Knopf in eine eigene Unteransicht mit einer
// Auswahlliste, in der man die gerade angeklickte Lizenz erneut suchen
// musste. Jetzt oeffnet sich ein Dialog direkt ueber der Tabelle.
// ------------------------------------------------------------------
function openEditLicense(license) {
const setVal = (id, value) => {
const el = document.getElementById(id);
if (el) el.value = value ?? '';
};
const select = document.getElementById('licEditSelect');
select.value = l.id;
loadLicenseData(select);
window.scrollTo({ top: 0, behavior: 'smooth' });
setVal('licEditId', license.id);
setVal('licEditCustomerName', license.customer_name);
setVal('licEditCustomerEmail', license.customer_email);
setVal('licEditStatus', license.status || 'active');
setVal('licEditMaxAct', license.max_activations || 2);
setVal('licEditNotes', license.notes);
// Das Datumsfeld erwartet YYYY-MM-DD, die Datenbank liefert einen
// vollstaendigen Zeitstempel.
const expires = license.expires_at ? String(license.expires_at).substring(0, 10) : '';
setVal('licEditExpiresAt', expires);
const title = document.getElementById('licEditTitle');
if (title) {
title.textContent = `${license.license_key} — ${license.customer_name || 'ohne Kundenangabe'}`;
}
const activationInfo = document.getElementById('licEditActivations');
if (activationInfo) {
activationInfo.textContent =
`${license.active_count ?? 0} von ${license.max_activations ?? 0} Aktivierungen belegt`;
}
document.getElementById('licEditModal').style.display = 'flex';
}
function loadLicenseData(selectEl) {
const opt = selectEl.options[selectEl.selectedIndex];
document.getElementById('editCustomerName').value = opt.getAttribute('data-cname') || '';
document.getElementById('editCustomerEmail').value = opt.getAttribute('data-cemail') || '';
document.getElementById('editStatus').value = opt.getAttribute('data-status') || 'active';
document.getElementById('editMaxAct').value = opt.getAttribute('data-max') || '2';
document.getElementById('editExpiresAt').value = opt.getAttribute('data-exp') || '';
function closeEditLicense() {
document.getElementById('licEditModal').style.display = 'none';
}
// Token Masking Toggle & Copy
@@ -4257,47 +4432,70 @@ SYSTEM
}
}
// Reine Ansichtsvorschau. Die vorherige Fassung erzeugte hier eine
// Zeichenkette, die sie "ED25519_SIG_" nannte - tatsaechlich war es ein
// base64-kodierter Schluessel ohne jede Signaturwirkung. Die gueltige,
// serverseitig signierte Datei liefert der .lic-Knopf in der Liste.
function generateOfflinePayload() {
const key = document.getElementById('offlineLicSelect').value;
const hwId = document.getElementById('offlineHwId').value || 'HWID-GENERAL';
const hwId = document.getElementById('offlineHwId').value || '(beliebig)';
const payload = {
"type": "offline_license_file",
"issued_at": Math.floor(Date.now() / 1000),
"license_key": key,
"hardware_id": hwId,
"status": "valid",
"valid_until": "2027-12-31 23:59:59",
"signature": "ED25519_SIG_" + btoa(key + hwId).substring(0, 32)
const preview = {
type: 'offline_license_file',
issued_at: Math.floor(Date.now() / 1000),
license_key: key,
hardware_id: hwId,
signature_alg: 'HMAC-SHA256',
signature: '(wird beim Herunterladen serverseitig erzeugt)'
};
document.getElementById('offlineResultBox').innerText = JSON.stringify(payload, null, 2);
document.getElementById('offlineResultBox').innerText = JSON.stringify(preview, null, 2);
}
// Initialize default subnav for Overview
renderTopBarSubnav('overview');
// Restore tab based on URL hash
window.addEventListener('load', () => {
/**
* Stellt Modul und Unteransicht aus dem Anker der URL wieder her.
*
* Die vorherige Fassung leitete den Modulnamen aus dem Anker ab
* (sub-<modul>-<rest>) und suchte den Navigationsknopf anhand seines
* Beschriftungstextes. Beides ging schief: "sub-update-releases" gehoert
* zum Modul updateservice, und keine Beschriftung enthaelt den
* Modulnamen. Der Rueckweg landete deshalb immer auf dem Dashboard.
*/
function restoreViewFromHash() {
const hash = location.hash.replace('#', '');
if (hash) {
if (hash.startsWith('tab-')) {
const module = hash.replace('tab-', '');
const link = document.querySelector(`.nav-link[onclick*="${module}"]`);
if (link) switchMainTab(module, link);
} else if (hash.startsWith('sub-')) {
const moduleParts = hash.split('-');
const module = moduleParts[1];
const mainLink = document.querySelector(`.nav-link[onclick*="${module}"]`);
if (mainLink) switchMainTab(module, mainLink);
setTimeout(() => {
const subLink = Array.from(document.querySelectorAll('#topbarSubnav .subnav-link')).find(l => l.innerText.toLowerCase().includes(module));
if (subLink) switchSubTab(module, hash, subLink);
}, 50);
}
if (!hash) {
return;
}
});
if (hash.startsWith('tab-')) {
const moduleName = hash.substring(4);
if (subnavItems[moduleName]) {
switchMainTab(moduleName, null, null, false);
}
return;
}
if (!hash.startsWith('sub-')) {
return;
}
const moduleName = moduleOfSubtab(hash);
if (!moduleName) {
return;
}
// Die Unteransicht wird direkt mit uebergeben - die Navigation wird
// synchron aufgebaut, ein Timeout ist dafuer nicht noetig.
switchMainTab(moduleName, null, hash, false);
}
window.addEventListener('load', restoreViewFromHash);
// Vor- und Zurueck-Navigation im Browser ebenfalls beruecksichtigen
window.addEventListener('hashchange', restoreViewFromHash);
</script>
<!-- Bugtracker Details & Timeline Modal -->
@@ -4357,6 +4555,70 @@ SYSTEM
</div>
</div>
<!--
Lizenz bearbeiten
Ersetzt die frühere Unteransicht "Lizenz Bearbeiten & HW". Dort musste
man die gerade angeklickte Lizenz in einer Auswahlliste erneut suchen.
-->
<div id="licEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
<div class="card" style="width:100%; max-width:620px; background:#161c28; border:1px solid var(--border-glass); max-height:90vh; overflow-y:auto;">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 class="card-title" style="margin:0; color:#fff;">✏️ Lizenz bearbeiten</h3>
<button type="button" class="btn btn-sm btn-secondary" onclick="closeEditLicense()"></button>
</div>
<p style="font-size:0.85rem; color:var(--text-muted); margin-bottom:0.35rem;">
<code id="licEditTitle" style="color:var(--primary);"></code>
</p>
<p style="font-size:0.75rem; color:var(--text-muted); margin-bottom:1.25rem;" id="licEditActivations"></p>
<form method="POST" action="index.php#sub-license-licenses">
<?= $csrfField ?>
<input type="hidden" name="action" value="edit_license">
<input type="hidden" name="license_id" id="licEditId" value="0">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Kunden-Name</label>
<input type="text" name="customer_name" id="licEditCustomerName" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Kunden-E-Mail</label>
<input type="email" name="customer_email" id="licEditCustomerEmail" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" id="licEditStatus" class="form-input">
<option value="active">Aktiv</option>
<option value="suspended">Pausiert</option>
<option value="revoked">Widerrufen</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Max. Aktivierungen</label>
<input type="number" name="max_activations" id="licEditMaxAct" class="form-input" min="1">
</div>
<div class="form-group">
<label class="form-label">Ablaufdatum (leer = unbegrenzt)</label>
<input type="date" name="expires_at" id="licEditExpiresAt" class="form-input">
</div>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Notizen</label>
<textarea name="notes" id="licEditNotes" class="form-input" rows="2"
placeholder="Vertragsnummer, Ansprechpartner, Besonderheiten..."></textarea>
</div>
<div style="display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1.25rem;">
<button type="button" class="btn btn-secondary" onclick="closeEditLicense()">Abbrechen</button>
<button type="submit" class="btn">Änderungen speichern</button>
</div>
</form>
</div>
</div>
<!-- Bugtracker Edit Modal -->
<div id="btEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
<div class="card" style="width:100%; max-width:650px; background:#161c28; border:1px solid var(--border-glass);">