diff --git a/DeskHub-Webseite/plugins/README.md b/DeskHub-Webseite/plugins/README.md
index 588f2430..03b8bfc5 100644
--- a/DeskHub-Webseite/plugins/README.md
+++ b/DeskHub-Webseite/plugins/README.md
@@ -32,7 +32,7 @@ plugins/mein-plugin/
}
```
-`permissions` nennt die Erweiterungspunkte, die das Plugin nutzt (`hooks.tickets`, `hooks.cron`, `ui.menu`, `ui.sidebar`, `api.endpoints`, `db.migrations`). Der Admin bestätigt sie bei der Aktivierung; nicht deklarierte Erweiterungspunkte werden abgelehnt. Das ist keine Sandbox. Vollständige Dokumentation inklusive Signieren: README.md im Hauptverzeichnis.
+`permissions` nennt die Erweiterungspunkte, die das Plugin nutzt (`hooks.tickets`, `hooks.cron`, `ui.menu`, `ui.sidebar`, `api.endpoints`, `api.public`, `db.migrations`). Der Admin bestätigt sie bei der Aktivierung; nicht deklarierte Erweiterungspunkte werden abgelehnt. Das ist keine Sandbox. Die Hooks `cron.minute` (etwa jede Minute, für kurze Prüfabstände) und `cron.run` (höchstens alle 5 Minuten), beide mit der Berechtigung `hooks.cron`, werden ausgelöst vom echten Server-Cronjob (`cron/run_plugins.php`, wird unter Einstellungen → Cron-Jobs eingetragen) und vom eingebauten Hintergrund-Betrieb, nie doppelt. Der Filter `staff.page.top` (Berechtigung `ui.sidebar`) ergänzt einen Hinweis oben auf jeder Seite des Mitarbeiterbereichs (fertiges HTML; Kontext: `staff`, `activePage`). Vollständige Dokumentation inklusive Signieren: README.md im Hauptverzeichnis.
## Erweiterungspunkte
diff --git a/DeskHub-Webseite/staff/forgot_password.php b/DeskHub-Webseite/staff/forgot_password.php
index 243d930d..95ea0ab3 100644
--- a/DeskHub-Webseite/staff/forgot_password.php
+++ b/DeskHub-Webseite/staff/forgot_password.php
@@ -45,6 +45,7 @@ $pageTitle = 'Passwort vergessen';
= site_icon_links() ?>
Jede Sprache ist ein Plugin („Sprachpaket“): Deutsch ist eingebaut, weitere Sprachen installieren Sie unter Plugins. Besucher und Mitarbeiter wechseln die Sprache über die Flaggen. Texte ohne Übersetzung bleiben deutsch.
Noch kein Sprachpaket aktiv. Installieren Sie zum Beispiel „English“ unter Plugins; dann erscheinen die Flaggen.
+
+
+
+
+
+
Gesammelte Texte: = $collected ?>
+
Das sind deutsche Texte, die der Sammelmodus gesehen hat. Sie bilden die Vorlage für ein neues Sprachpaket.
+
+
+
+
+
Eigene Sprache als Plugin erstellen
+
+
Sammelmodus einschalten, die Seiten durchklicken, dann bei Deutsch „Leere Vorlage laden“ (alle gesammelten Texte, noch ohne Übersetzung). Für Ergänzungen zu einer vorhandenen Sprache bei dieser „Vorlage laden“: bereits Übersetztes ist eingetragen.
+
In der Datei strings.json die Werte übersetzen. Der Schlüssel ist der deutsche Text, {} steht für wechselnde Teile („Willkommen, {}“ → „Welcome, {}“).
+
Ordner mit diesen Dateien packen: plugin.json, plugin.php (kann leer sein), flag.svg (Flagge, höchstens 40 KB), strings.json.
+
Die plugin.json sieht so aus: {"name":"Français","version":"1.0.0","entry":"plugin.php","language":{"code":"fr","name":"Français","flag":"flag.svg","strings":"strings.json"}}
+
ZIP unter Plugins hochladen und aktivieren (das Sprachpaket braucht keine Berechtigungen). Die Flagge erscheint sofort.
+
+
+
diff --git a/DeskHub-Webseite/staff/live_tickets.php b/DeskHub-Webseite/staff/live_tickets.php
new file mode 100644
index 00000000..461408a6
--- /dev/null
+++ b/DeskHub-Webseite/staff/live_tickets.php
@@ -0,0 +1,64 @@
+ 'login']);
+ exit;
+}
+// Abgelaufene Sitzung (Inaktivität) gilt auch hier als abgemeldet; dieser Abruf verlängert die Sitzung nie
+$__timeout = (int) (get_setting('staff_session_timeout', '30') ?: 30);
+$__last = (int) ($_SESSION['staff_last_activity'] ?? 0);
+if ($__last && (time() - $__last) > $__timeout * 60) {
+ http_response_code(401);
+ echo json_encode(['error' => 'login', 'sessionExpired' => true]);
+ exit;
+}
+// Die Sitzungsdatei sofort freigeben: Der Abruf läuft alle paar Sekunden und soll andere Seitenaufrufe nicht ausbremsen
+if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
+
+$pdo = get_db();
+$since = max(0, (int) ($_GET['since'] ?? 0));
+[$deptFilter, $deptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
+$count = function (string $where, array $params = []) use ($pdo, $deptFilter, $deptParams): int {
+ $s = $pdo->prepare("SELECT COUNT(*) FROM tickets t WHERE {$where}{$deptFilter}");
+ $s->execute(array_merge($params, $deptParams));
+ return (int) $s->fetchColumn();
+};
+$open = "t.status NOT IN ('resolved','closed')";
+$counts = [
+ 'open' => $count($open),
+ 'mine' => $count("t.assigned_staff_id = ? AND {$open}", [(int) $staff['id']]),
+ 'unassigned' => $count("t.assigned_staff_id IS NULL AND {$open}"),
+ 'overdue' => $count("{$open} AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ?", [db_now()]),
+ 'unanswered' => $count("t.status = 'open'"),
+];
+
+// Neue Tickets: ohne Spam, ohne Live-Chats (die haben eigene Hinweise) und ohne zusammengeführte
+$visible = "t.is_spam = 0 AND t.source <> 'chat' AND t.merged_into IS NULL";
+$m = $pdo->prepare("SELECT COALESCE(MAX(t.id), 0) FROM tickets t WHERE {$visible}{$deptFilter}");
+$m->execute($deptParams);
+$latest = (int) $m->fetchColumn();
+$tickets = [];
+$more = 0;
+if ($since > 0 && $latest > $since) {
+ $s = $pdo->prepare("SELECT t.id, t.ticket_number, t.subject, t.requester_name, d.name AS department FROM tickets t LEFT JOIN departments d ON d.id = t.department_id
+ WHERE t.id > ? AND {$visible}{$deptFilter} ORDER BY t.id ASC LIMIT 6");
+ $s->execute(array_merge([$since], $deptParams));
+ foreach ($s->fetchAll() as $t) {
+ $tickets[] = ['id' => (int) $t['id'], 'number' => (string) $t['ticket_number'], 'subject' => mb_substr((string) $t['subject'], 0, 120), 'name' => mb_substr((string) $t['requester_name'], 0, 60), 'department' => (string) ($t['department'] ?? '')];
+ }
+ $c = $pdo->prepare("SELECT COUNT(*) FROM tickets t WHERE t.id > ? AND {$visible}{$deptFilter}");
+ $c->execute(array_merge([$since], $deptParams));
+ $more = max(0, (int) $c->fetchColumn() - count($tickets));
+}
+echo json_encode(['latest' => $latest, 'counts' => $counts, 'tickets' => $tickets, 'more' => $more], JSON_UNESCAPED_UNICODE);
diff --git a/DeskHub-Webseite/staff/login.php b/DeskHub-Webseite/staff/login.php
index c9c01ecb..0525fc89 100644
--- a/DeskHub-Webseite/staff/login.php
+++ b/DeskHub-Webseite/staff/login.php
@@ -9,8 +9,10 @@ if (current_staff()) {
$errors = [];
$need2fa = false;
-if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- verify_csrf();
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && !csrf_valid()) {
+ // Die Anmeldeseite war so lange offen, dass die Sitzung auf dem Server inzwischen aufgeräumt wurde: neu anmelden statt Fehlerseite
+ $errors[] = 'Die Anmeldeseite war zu lange geöffnet und die Sitzung ist abgelaufen. Bitte melden Sie sich jetzt noch einmal an.';
+} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'login';
if ($action === 'verify_totp') {
@@ -82,25 +84,26 @@ $displayName = get_setting('display_name', config()['site']['name']);
= site_icon_links() ?>
-
+ style="background-image:url('= e($banner) ?>'); background-size:cover; background-position:center">
+= i18n_switcher_html('floating') ?>
-
-