Master-Lizenzserver Phase 2: neue Schwester-Anwendung srv/tesm-license (v1.0.0)

Fork von TESM (srv/tesm), auf Kunden-/Lizenzverwaltung reduziert statt
Geraete-/PoE-Management. Wiederverwendet unveraendert: Login/Session/
Benutzer- und Gruppenverwaltung, LDAP/AD, NGINX- und Systemeinstellungen,
Live-Log/Verlauf/Auditlog, Im-/Export-Grundgeruest, das komplette
PERMISSIONS/NAV_ITEMS/inject_nav()-Rechtesystem sowie das in Phase 1
gebaute Lizenzsystem selbst sowohl fuer die MASTER-eigene Bootstrap-Lizenz
als auch fuer das exakt gleiche licensing.py (byte-identisch zu TESM --
Signieren/Verifizieren muss zwischen beiden Seiten kompatibel bleiben).

Entfernt: Clients/Switche/Zugangsdaten/DHCP/Fileshare/Wartung/Papierkorb/
manueller PoE-Neustart/SSH-Terminal inkl. aller zugehoerigen Tabellen,
Routen, Permissions, Nav-Eintraege und Abhaengigkeiten (paramiko/Flask-Sock/
simple-websocket/PyNaCl/pyasn1 aus requirements.txt). Benutzer-/Gruppen-
Loeschung von Soft- auf Hard-Delete umgestellt (kein Papierkorb mehr).
Eigener Pfad-/Env-Var-Namespace (TESM_LICENSE_* statt TESM_*, /var/log/
tesm-license statt /var/log/tesm usw.), damit Master und TESM testweise
sogar auf demselben Host nebeneinander laufen koennen, ohne sich Log-/
Config-Pfade streitig zu machen.

Neu -- der eigentliche Lizenzserver:
- license_customers/licenses-Tabellen, Master-Signaturschluessel
  (master_signing_key.json, einmalig erzeugt, NIE automatisch rotiert --
  jede Kundenlizenz traegt den zum Ausstellungszeitpunkt aktuellen
  master_pubkey fest eingebettet).
- Dashboard ('/') als Kunden-/Lizenzuebersicht (Typ, Module, Ablauf,
  Status, letzter Heartbeat), eigene Kunden-Verwaltungsseite.
- Lizenz-Ausstellung (Typ/Module/Laufzeit -> signierte Datei via
  licensing.issue_license), Detailseite, Download, Widerruf.
- /api/activate, /api/deactivate, /api/heartbeat (unauthentifiziert per
  Design -- die Signatur der Anfrage IST der Berechtigungsnachweis) sowie
  eine manuelle Offline-Code-Seite, die dieselben drei Verarbeitungs-
  funktionen (_process_activate/_process_deactivate/_process_heartbeat)
  nutzt wie die Online-API -- ein Protokoll, zwei Transportwege.
- E-Mail-Versand ausgestellter Lizenzen per Microsoft Graph
  (Client-Credentials-Flow, reine Standardbibliothek/urllib, keine neue
  Abhaengigkeit) inkl. Einrichtungsanleitung und Verbindungstest.
- create_master_license.py: lokales Bootstrap-/Erneuerungs-Skript fuer
  die eigene Lizenz des Masters (kein externer Super-Master noetig).

Verifiziert auf dem Testsystem (192.168.82.51): App laeuft parallel zu der
dort laufenden echten TESM-Instanz (Port 5001 vs. 80/5000, eigene
Log-/DB-Pfade, TESM unangetastet). Kompletter ECHTER End-to-End-Rundlauf
per Playwright durchgespielt -- kein simulierter Gegenpart: Kunde anlegen
-> Custom-Lizenz (dhcp+fileshare) ausstellen -> herunterladen -> auf der
echten TESM-Instanz hochladen -> ECHTE Online-Aktivierung (Master
verzeichnet Fingerprint, TESM zeigt 'Aktiviert') -> ECHTE Online-
Deaktivierung (TESM zeigt 'lizenzlos, Export bleibt moeglich', Master
zeigt Status 'Deaktiviert'). Lokale Syntax-/Templatepruefung (py_compile,
pyflakes, jinja2-Parse aller Templates) sauber.

Bewusst NICHT nach main gemergt/getaggt/ausgerollt -- folgt zusammen mit
Phase 3 (Infrastruktur-Umzug + POETEST-Enterprise-Lizenz), siehe Plan
toasty-twirling-hickey.md (Phase 2 von 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 02:07:30 +02:00
co-authored by Claude Sonnet 5
parent c9ac0bf69b
commit 90e07aa310
43 changed files with 11714 additions and 0 deletions
+633
View File
@@ -0,0 +1,633 @@
/* ==========================================================================
PoE Manager — shared UI behaviour (sidebar, theme, modals, toasts)
========================================================================== */
(function () {
"use strict";
/* ---------------- Theme ---------------- */
const THEME_KEY = "poe-theme";
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
document.querySelectorAll("[data-theme-icon]").forEach((el) => {
el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun;
});
// Favicon + Sidebar-Logo folgen demselben Theme wie der Rest der App
// (auch bei manuellem Umschalten, nicht nur bei System-Präferenz) --
// "dunkel"/"hell" bezeichnen hier die für den jeweiligen Modus gedachte
// Logo-Variante (helle Farben für Dark Mode, dunkle für Light Mode).
const suffix = theme === "light" ? "light" : "dark";
const favicon = document.getElementById("app-favicon");
if (favicon) favicon.href = "/static/images/icon-" + suffix + ".svg";
const sidebarLogo = document.getElementById("sidebar-logo");
if (sidebarLogo) sidebarLogo.src = "/static/images/logo-" + suffix + ".svg";
const loginBgLogo = document.getElementById("login-bg-logo");
if (loginBgLogo) loginBgLogo.src = "/static/images/logo-" + suffix + "-subline.svg";
}
function initTheme() {
const saved = localStorage.getItem(THEME_KEY) ||
(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
applyTheme(saved);
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
const next = current === "light" ? "dark" : "light";
localStorage.setItem(THEME_KEY, next);
applyTheme(next);
}
const ICONS = {
sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>',
moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z"/></svg>',
};
/* ---------------- Sidebar (mobile) ---------------- */
function initSidebar() {
const sidebar = document.querySelector(".sidebar");
const backdrop = document.querySelector(".sidebar-backdrop");
const main = document.querySelector(".main");
const toggles = document.querySelectorAll("[data-sidebar-toggle]");
if (!sidebar) return;
const DESKTOP_BREAKPOINT = 900;
const COLLAPSE_KEY = "poe-sidebar-collapsed";
// Mobile: temporäres Überlagern per Hamburger + Backdrop.
function open() {
sidebar.classList.add("open");
backdrop && backdrop.classList.add("open");
}
function close() {
sidebar.classList.remove("open");
backdrop && backdrop.classList.remove("open");
}
// Desktop: dauerhaftes Ein-/Ausklappen, über Neuladen hinweg gemerkt.
function setCollapsed(collapsed) {
sidebar.classList.toggle("collapsed", collapsed);
main && main.classList.toggle("sidebar-collapsed", collapsed);
localStorage.setItem(COLLAPSE_KEY, collapsed ? "1" : "0");
}
if (localStorage.getItem(COLLAPSE_KEY) === "1") setCollapsed(true);
toggles.forEach((btn) => btn.addEventListener("click", () => {
if (window.innerWidth <= DESKTOP_BREAKPOINT) {
sidebar.classList.contains("open") ? close() : open();
} else {
setCollapsed(!sidebar.classList.contains("collapsed"));
}
}));
backdrop && backdrop.addEventListener("click", close);
document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close));
}
/* ---------------- Modals ---------------- */
function openModal(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.add("open");
document.body.style.overflow = "hidden";
}
function closeModal(el) {
const overlay = el.closest ? el.closest(".modal-overlay") : el;
if (!overlay) return;
overlay.classList.remove("open");
document.body.style.overflow = "";
}
function initModals() {
document.querySelectorAll("[data-open-modal]").forEach((trigger) => {
trigger.addEventListener("click", () => openModal(trigger.getAttribute("data-open-modal")));
});
document.querySelectorAll("[data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeModal(overlay);
});
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
document.querySelectorAll(".modal-overlay.open").forEach((o) => closeModal(o));
}
});
}
/* ---------------- Confirm dialog (replaces window.confirm) ---------------- */
function ensureConfirmModal() {
if (document.getElementById("confirm-modal")) return;
const html = `
<div class="modal-overlay" id="confirm-modal">
<div class="modal" style="max-width:380px;">
<div class="modal-header">
<h3 id="confirm-title">Bist du sicher?</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p id="confirm-message" class="text-dim"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="button" class="btn btn-danger" id="confirm-ok" style="background:var(--danger);color:#fff;">Bestätigen</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#confirm-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("confirm-modal").addEventListener("click", (e) => {
if (e.target.id === "confirm-modal") closeModal(e.target);
});
}
window.confirmAction = function (message, onConfirm, title) {
ensureConfirmModal();
document.getElementById("confirm-title").innerText = title || "Bist du sicher?";
document.getElementById("confirm-message").innerText = message;
const okBtn = document.getElementById("confirm-ok");
const freshBtn = okBtn.cloneNode(true);
okBtn.parentNode.replaceChild(freshBtn, okBtn);
freshBtn.addEventListener("click", () => {
closeModal(freshBtn);
onConfirm();
});
openModal("confirm-modal");
};
/* ---------------- Ja/Nein-Dialog für ungespeicherte Änderungen ---------------- */
/* Eigener Dialog statt confirmAction (dort ist "Abbrechen" = beim Bleiben,
hier gibt es bewusst nur die zwei angefragten Optionen: speichern oder
verwerfen — beide verlassen die Seite, nur "Abbrechen"/Escape/Backdrop
bricht das Verlassen selbst ab und lässt die Seite unverändert offen. */
function ensureSaveDiscardModal() {
if (document.getElementById("save-discard-modal")) return;
const html = `
<div class="modal-overlay" id="save-discard-modal">
<div class="modal" style="max-width:420px;">
<div class="modal-header">
<h3>Ungespeicherte Änderungen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-dim" id="save-discard-message"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="save-discard-no">Nein, verwerfen</button>
<button type="button" class="btn btn-primary" id="save-discard-yes">Ja, speichern</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#save-discard-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("save-discard-modal").addEventListener("click", (e) => {
if (e.target.id === "save-discard-modal") closeModal(e.target);
});
}
window.confirmSaveDiscard = function (message, onSave, onDiscard) {
ensureSaveDiscardModal();
document.getElementById("save-discard-message").innerText = message;
const yesBtn = document.getElementById("save-discard-yes");
const noBtn = document.getElementById("save-discard-no");
const freshYes = yesBtn.cloneNode(true);
yesBtn.parentNode.replaceChild(freshYes, yesBtn);
const freshNo = noBtn.cloneNode(true);
noBtn.parentNode.replaceChild(freshNo, noBtn);
freshYes.addEventListener("click", () => { closeModal(freshYes); onSave(); });
freshNo.addEventListener("click", () => { closeModal(freshNo); onDiscard(); });
openModal("save-discard-modal");
};
/* ---------------- RAW-Log-Popup (Live-Log & Verlauf) ---------------- */
/* Gleiches Modal-Grundgerüst wie der Ja/Nein-Dialog für ungespeicherte
Änderungen (modal-overlay/modal-header/modal-body/modal-footer), nur
breiter/höher und mit lazy nachgeladenem Inhalt -- der komplette
Log-Inhalt wird bewusst erst beim tatsächlichen Öffnen per fetch
nachgeladen, nicht schon beim Seitenaufbau, damit genau das Problem
(unnötig große Ladezeiten) nicht durch das Popup selbst zurückkommt. */
function ensureRawLogModal() {
if (document.getElementById("raw-log-modal")) return;
const html = `
<div class="modal-overlay" id="raw-log-modal">
<div class="modal" style="max-width:min(1100px, 92vw); height:85vh;">
<div class="modal-header">
<h3 id="raw-log-modal-title">Komplettes Log (RAW)</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body" style="padding:0; display:flex; flex-direction:column; flex:1; min-height:0;">
<pre id="raw-log-modal-content" class="raw-log-content"></pre>
</div>
<div class="modal-footer">
<span class="text-faint mono" id="raw-log-modal-meta" style="margin-right:auto; font-size:11.5px;"></span>
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#raw-log-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("raw-log-modal").addEventListener("click", (e) => {
if (e.target.id === "raw-log-modal") closeModal(e.target);
});
}
function renderRawLogLines(container, text) {
// Eine Zeile = ein <div>, Nummer kommt per CSS-Counter (::before) --
// so bleibt der eigentliche Zeileninhalt reiner textContent (kein XSS-
// Risiko) und die Nummerierung muss nirgends von Hand mitgezählt werden.
const lines = text.split("\n");
if (lines.length && lines[lines.length - 1] === "") lines.pop(); // trailing \n erzeugt sonst eine Phantomzeile
container.textContent = "";
const frag = document.createDocumentFragment();
lines.forEach((line) => {
const row = document.createElement("div");
row.className = "raw-log-line";
row.textContent = line;
frag.appendChild(row);
});
container.appendChild(frag);
}
window.openRawLogModal = function (url, title, withLineNumbers) {
if (!url) return;
ensureRawLogModal();
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
const content = document.getElementById("raw-log-modal-content");
const meta = document.getElementById("raw-log-modal-meta");
content.classList.toggle("with-line-numbers", !!withLineNumbers);
content.textContent = "Lade …";
meta.textContent = "";
openModal("raw-log-modal");
fetch(url)
.then((r) => {
const logName = r.headers.get("X-Log-Name");
if (logName) meta.textContent = logName;
return r.text();
})
.then((text) => {
if (withLineNumbers) renderRawLogLines(content, text);
else content.textContent = text;
})
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
};
/* ---------------- Hinweis-Icons (Hover-Tooltip + Klick-Modal) ---------------- */
/* Zentraler Mechanismus für ALLE "i"-Icons (siehe _hint_icon.html) app-weit
-- ein einziges delegiertes Set von Listenern statt pro Icon eigener
Handler, damit auch nachträglich per JS eingefügte Icons (z.B. in
dynamisch nachgeladenen Tabellenzeilen) ohne weiteres Zutun funktionieren.
Hover zeigt den Text als Tooltip neben dem Mauszeiger -- aber NUR auf
Geräten, die tatsächlich sinnvoll hovern können (matchMedia-Check bei
JEDEM mouseenter neu ausgewertet, nicht einmalig beim Laden gecacht,
damit z.B. ein Convertible/Tablet beim Umschalten Maus/Touch korrekt
reagiert). Auf reinen Touch-Geräten bleibt so nur Tap -> Modal übrig,
da dort ohnehin kein echtes mouseenter/mousemove-Hover stattfindet. */
function ensureHintTooltip() {
if (document.getElementById("hint-tooltip")) return;
const el = document.createElement("div");
el.id = "hint-tooltip";
document.body.appendChild(el);
}
function ensureHintModal() {
if (document.getElementById("hint-modal")) return;
const html = `
<div class="modal-overlay" id="hint-modal">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h3 id="hint-modal-title">Hinweis</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p id="hint-modal-body" class="text-dim" style="margin:0; line-height:1.6;"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#hint-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("hint-modal").addEventListener("click", (e) => {
if (e.target.id === "hint-modal") closeModal(e.target);
});
}
function canHover() {
return window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
function initHintIcons() {
if (!document.querySelector(".hint-icon")) return;
ensureHintTooltip();
const tooltip = document.getElementById("hint-tooltip");
function positionTooltip(e) {
const offset = 14;
const rect = tooltip.getBoundingClientRect();
let x = e.clientX + offset;
let y = e.clientY + offset;
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offset;
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offset;
tooltip.style.left = Math.max(8, x) + "px";
tooltip.style.top = Math.max(8, y) + "px";
}
// mouseenter/mouseleave bubbeln nicht -- Delegation via Capture-Phase
// auf document funktioniert dafür trotzdem zuverlässig.
document.addEventListener("mouseenter", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon || !canHover()) return;
tooltip.textContent = icon.dataset.hint || "";
tooltip.style.display = "block";
positionTooltip(e);
}, true);
document.addEventListener("mouseleave", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon) return;
tooltip.style.display = "none";
}, true);
document.addEventListener("mousemove", function (e) {
if (tooltip.style.display === "block") positionTooltip(e);
});
document.addEventListener("click", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon) return;
e.preventDefault();
tooltip.style.display = "none";
ensureHintModal();
document.getElementById("hint-modal-title").textContent = icon.dataset.hintTitle || "Hinweis";
document.getElementById("hint-modal-body").textContent = icon.dataset.hint || "";
openModal("hint-modal");
});
}
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
Toggle-Buttons) seit dem Laden der Seite verändert wurde, und fragt vor
dem Verlassen (Klick auf einen Link, z.B. in der Navigation) per Ja/Nein,
ob zuerst gespeichert werden soll — statt Änderungen stillschweigend zu
verwerfen. "Ja" sendet das Formular ganz normal ab (echter POST, kein
AJAX-Nachbau nötig) und merkt sich das eigentliche Ziel in
sessionStorage, um nach dem Speichern automatisch dorthin
weiterzuleiten, statt dass der Link ein zweites Mal angeklickt werden
muss. Für Browser-eigene Navigation (Tab schließen, Reload, Adresszeile)
gibt es zusätzlich beforeunload — dort erlaubt der Browser aus
Sicherheitsgründen aber nur eine generische Warnung, kein eigenes
Ja/Nein/Speichern-Dialogfeld. */
const PENDING_NAV_KEY = "poe-pending-nav-after-save";
function serializeForm(form) {
return new URLSearchParams(new FormData(form)).toString();
}
function initUnsavedChangesGuard() {
const pendingNav = sessionStorage.getItem(PENDING_NAV_KEY);
if (pendingNav) {
sessionStorage.removeItem(PENDING_NAV_KEY);
window.location.href = pendingNav;
return;
}
const trackable = [];
document.querySelectorAll("form[method='post' i]").forEach((form) => {
if (form.hasAttribute("data-no-unsaved-guard")) return;
const fields = form.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea");
if (!fields.length) return; // reine Aktions-Formulare (Löschen/Toggle) ohne Eingabefeld
trackable.push({ form, initial: serializeForm(form) });
});
if (!trackable.length) return;
// Verhindert die native Browser-Warnung (beforeunload), wenn die Seite
// ohnehin schon bewusst verlassen wird — entweder weil eines der
// beobachteten Formulare ganz normal abgeschickt wurde (eigener
// "Speichern"/"Anlegen"-Button auf der Seite) oder weil unser eigener
// Ja/Nein-Dialog die Navigation ausgelöst hat. Ohne das würde beim
// normalen Speichern zusätzlich zum eigentlichen Erfolg immer noch die
// generische Browser-Meldung aufpoppen — die soll ausschließlich dann
// erscheinen, wenn tatsächlich UNGEFRAGT navigiert wird (Tab schließen,
// Reload, Adresszeile), nicht bei einem bewussten Speichern.
let navigatingAway = false;
trackable.forEach(({ form }) => {
form.addEventListener("submit", () => { navigatingAway = true; });
});
function dirtyForm() {
return trackable.find(({ form, initial }) => serializeForm(form) !== initial) || null;
}
document.addEventListener("click", (e) => {
const link = e.target.closest("a[href]");
if (!link || link.target === "_blank" || link.href.startsWith("javascript:") || link.hasAttribute("data-no-unsaved-guard")) return;
const dirty = dirtyForm();
if (!dirty) return;
e.preventDefault();
const href = link.href;
window.confirmSaveDiscard(
"Es gibt ungespeicherte Änderungen auf dieser Seite. Vor dem Verlassen speichern?",
() => {
navigatingAway = true;
sessionStorage.setItem(PENDING_NAV_KEY, href);
dirty.form.requestSubmit ? dirty.form.requestSubmit() : dirty.form.submit();
},
() => {
navigatingAway = true;
window.location.href = href;
}
);
});
window.addEventListener("beforeunload", (e) => {
if (!navigatingAway && dirtyForm()) {
e.preventDefault();
e.returnValue = "";
}
});
}
/* Intercept forms/buttons marked with data-confirm="message" */
function initConfirmables() {
document.querySelectorAll("form[data-confirm]").forEach((form) => {
form.addEventListener("submit", function (e) {
if (form.dataset.confirmed === "1") return;
e.preventDefault();
// Welcher Button hat den Submit ausgelöst? Muss beim erneuten
// requestSubmit() explizit mitgegeben werden — sonst geht bei
// Formularen, die ihre ID über name/value des Submit-Buttons
// transportieren (statt über ein <input type="hidden">), dieses
// Feld beim Neu-Absenden stillschweigend verloren (requestSubmit()
// ohne Argument zählt als "kein Button aktiviert").
const submitter = e.submitter;
window.confirmAction(form.getAttribute("data-confirm"), () => {
form.dataset.confirmed = "1";
form.requestSubmit ? form.requestSubmit(submitter) : form.submit();
}, form.getAttribute("data-confirm-title"));
});
});
}
/* ---------------- Toasts ---------------- */
function ensureToastStack() {
let stack = document.querySelector(".toast-stack");
if (!stack) {
stack = document.createElement("div");
stack.className = "toast-stack";
document.body.appendChild(stack);
}
return stack;
}
window.showToast = function (message, type) {
const stack = ensureToastStack();
const toast = document.createElement("div");
toast.className = "toast " + (type || "info");
toast.innerHTML = `<span>${message}</span><span class="toast-close">&times;</span>`;
stack.appendChild(toast);
const remove = () => {
toast.classList.add("hide");
setTimeout(() => toast.remove(), 180);
};
toast.querySelector(".toast-close").addEventListener("click", remove);
setTimeout(remove, 5000);
};
function initFlashedMessages() {
const data = document.getElementById("flashed-data");
if (!data) return;
try {
const messages = JSON.parse(data.textContent);
messages.forEach(([category, msg]) => {
const type = category === "danger" ? "danger" : category === "success" ? "success" : "info";
window.showToast(msg, type);
});
} catch (e) { /* noop */ }
}
/* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen — der manuell
aufgeklappte Zustand bleibt über Seitenwechsel hinweg erhalten
(localStorage), zusätzlich zur automatischen Aufklappung der Gruppe
der aktuell aktiven Seite (server-seitig via "expanded"-Klasse). */
const NAV_EXPANDED_KEY = "poe-nav-expanded";
function getExpandedNavGroups() {
try {
return new Set(JSON.parse(localStorage.getItem(NAV_EXPANDED_KEY) || "[]"));
} catch (e) {
return new Set();
}
}
function saveExpandedNavGroups(set) {
localStorage.setItem(NAV_EXPANDED_KEY, JSON.stringify(Array.from(set)));
}
function initNavGroups() {
const expanded = getExpandedNavGroups();
document.querySelectorAll("[data-nav-group]").forEach((group) => {
const key = group.dataset.navGroupKey;
if (key && expanded.has(key)) group.classList.add("expanded");
});
document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const group = btn.closest("[data-nav-group]");
const isExpanded = group.classList.toggle("expanded");
const key = group.dataset.navGroupKey;
if (!key) return;
const set = getExpandedNavGroups();
if (isExpanded) set.add(key); else set.delete(key);
saveExpandedNavGroups(set);
});
});
}
/* ---------------- Sortierbare Tabellen ---------------- */
/*
* Klick auf <th data-sort-key="..."> sortiert die Tabelle. Zwei Modi:
* - Normale Tabellen: sortiert <tr>-Zeilen innerhalb des einzigen <tbody>
* anhand von data-sort-<key> auf der jeweiligen <tr>.
* - Akkordeon-Tabellen (mehrere <tbody>, z.B. Gruppen mit Detail-Zeile):
* sortiert ganze <tbody>-Blöcke anhand von data-sort-<key> auf dem
* jeweiligen <tbody>, damit Haupt- und Detail-Zeile zusammenbleiben.
* <tbody data-sort-pinned> (z.B. die virtuelle "Admin"-Zeile) bleibt
* dabei immer an ihrer Position.
*/
function compareSortValues(a, b, asc) {
a = (a === null || a === undefined) ? "" : String(a);
b = (b === null || b === undefined) ? "" : String(b);
const na = parseFloat(a), nb = parseFloat(b);
const bothNumeric = a !== "" && b !== "" && !isNaN(na) && !isNaN(nb);
const cmp = bothNumeric ? (na - nb) : a.toLowerCase().localeCompare(b.toLowerCase(), "de");
return asc ? cmp : -cmp;
}
function sortTable(table, th) {
const key = th.dataset.sortKey;
const asc = th.dataset.sortDir !== "asc";
table.querySelectorAll("thead th[data-sort-key]").forEach((h) => {
delete h.dataset.sortDir;
h.classList.remove("sort-asc", "sort-desc");
});
th.dataset.sortDir = asc ? "asc" : "desc";
th.classList.add(asc ? "sort-asc" : "sort-desc");
if (table.tBodies.length > 1) {
const movable = Array.prototype.filter.call(table.tBodies, (tb) => !tb.hasAttribute("data-sort-pinned"));
movable.sort((a, b) => compareSortValues(
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
));
movable.forEach((tb) => table.appendChild(tb));
} else {
const tbody = table.tBodies[0];
const rows = Array.prototype.filter.call(tbody.rows, (r) => !r.classList.contains("empty-row"));
rows.sort((a, b) => compareSortValues(
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
));
rows.forEach((r) => tbody.appendChild(r));
}
}
function initSortableTables() {
document.querySelectorAll("table[data-sortable] thead th[data-sort-key]").forEach((th) => {
th.classList.add("sortable-col");
th.addEventListener("click", () => sortTable(th.closest("table"), th));
});
}
/* ---------------- Init ---------------- */
document.addEventListener("DOMContentLoaded", function () {
initTheme();
initSidebar();
initModals();
initConfirmables();
initFlashedMessages();
initNavGroups();
initUnsavedChangesGuard();
initSortableTables();
initHintIcons();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
});
window.PoeUI = { openModal, closeModal };
})();