diff --git a/DeskHub-Webseite/includes/bootstrap.php b/DeskHub-Webseite/includes/bootstrap.php
index 59c4f085..fbd0509f 100644
--- a/DeskHub-Webseite/includes/bootstrap.php
+++ b/DeskHub-Webseite/includes/bootstrap.php
@@ -36,11 +36,13 @@ require_once __DIR__ . '/ticket_links.php';
require_once __DIR__ . '/ticket_attention.php';
require_once __DIR__ . '/wait_reminders.php';
require_once __DIR__ . '/my_week.php';
+require_once __DIR__ . '/signout.php';
require_once __DIR__ . '/scheduled_replies.php';
require_once __DIR__ . '/report_schedules.php';
require_once __DIR__ . '/gdpr.php';
require_once __DIR__ . '/plugins.php';
require_once __DIR__ . '/app_downloads.php';
+require_once __DIR__ . '/permissions.php';
require_once __DIR__ . '/theme_catalog.php';
require_once __DIR__ . '/security_check.php';
require_once __DIR__ . '/update_security.php';
diff --git a/DeskHub-Webseite/includes/csat.php b/DeskHub-Webseite/includes/csat.php
index cb4e3e30..3d5aaf42 100644
--- a/DeskHub-Webseite/includes/csat.php
+++ b/DeskHub-Webseite/includes/csat.php
@@ -76,7 +76,7 @@ function csat_mail_html(array $ticket, string $token): string
for ($i = 1; $i <= 5; $i++) {
$stars .= '★';
}
- return '
Wie zufrieden waren Sie?
'
+ return 'Wie zufrieden waren Sie?
'
. '' . ($name !== '' ? 'Hallo ' . e($name) . ', ' : '')
. 'Ihr Ticket [#' . e((string) $ticket['ticket_number']) . '] ' . e((string) $ticket['subject']) . ' ist abgeschlossen. '
. 'Eine kurze Bewertung hilft uns, besser zu werden. Es dauert nur einen Klick.
'
diff --git a/DeskHub-Webseite/includes/fcm.php b/DeskHub-Webseite/includes/fcm.php
index d6723a5e..1a2ec68c 100644
--- a/DeskHub-Webseite/includes/fcm.php
+++ b/DeskHub-Webseite/includes/fcm.php
@@ -502,6 +502,7 @@ function fcm_wants(array $prefs, string $type): bool
function fcm_notify_overdue(array $ticket): void
{
if (!fcm_configured() || empty($ticket['ticket_number'])) return;
+ if (function_exists('sla_notify_mode') && sla_notify_mode() === 'admins') return; // nur Administratoren und nur per E-Mail
$dept = (int) ($ticket['department_id'] ?? 0);
$assigned = (int) ($ticket['assigned_staff_id'] ?? 0);
$to = fcm_recipients_for_department(get_db(), $dept ?: null);
diff --git a/DeskHub-Webseite/includes/functions.php b/DeskHub-Webseite/includes/functions.php
index 6b35b635..e256f367 100644
--- a/DeskHub-Webseite/includes/functions.php
+++ b/DeskHub-Webseite/includes/functions.php
@@ -316,10 +316,38 @@ function ticket_sla_completed_pause_seconds(PDO $pdo, int $ticketId): int
} catch (Throwable $e) { error_log('DeskHub SLA-Pausenzeiten nicht lesbar: ' . $e->getMessage()); return 0; }
}
+/** Startet die SLA eines wieder geöffneten Tickets neu: neue Lösungsfrist ab jetzt, Überfällig-Markierung weg. */
+function ticket_sla_restart(PDO $pdo, int $ticketId, ?int $staffId = null): void
+{
+ $q = $pdo->prepare('SELECT t.priority_id, t.department_id, t.help_topic_id FROM tickets t WHERE t.id = ?');
+ $q->execute([$ticketId]);
+ $t = $q->fetch();
+ if (!$t) return;
+ $p = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
+ $p->execute([(int) $t['priority_id']]);
+ $priority = $p->fetch();
+ if (!$priority) { $pdo->prepare('UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); return; }
+ $now = db_now();
+ $dates = calc_sla_dates($priority, $now, (int) $t['department_id'] ?: null, (int) $t['help_topic_id'] ?: null);
+ $pdo->prepare('UPDATE tickets SET resolution_due_at = ?, is_overdue = 0, sla_paused_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$dates['resolution_due_at'], $ticketId]);
+ ticket_sla_history_add($pdo, $ticketId, 'restarted', 'resolution', 'SLA neu gestartet: Ticket wurde wieder geöffnet', $staffId, $now);
+}
+
/** Call after the ticket status has been persisted. pending/on_hold pause both SLA clocks. */
function ticket_sla_status_transition_impl(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
{
if ($oldStatus === $newStatus) return;
+ // Wieder geöffnet (zum Beispiel weil der Kunde auf ein gelöstes Ticket antwortet): Die alte Lösungsfrist ist längst abgelaufen und würde
+ // sofort „SLA überschritten“ auslösen. Die SLA beginnt deshalb neu, mit den Zeiten der Priorität bzw. des SLA-Plans ab jetzt.
+ $terminal = ['resolved', 'closed'];
+ if (in_array($oldStatus, $terminal, true) && !in_array($newStatus, $terminal, true)) {
+ ticket_sla_restart($pdo, $ticketId, $staffId);
+ }
+ // Erledigt (gelöst oder geschlossen) ist dasselbe: Die SLA zählt nicht weiter, „überfällig“ und eine Wiedervorlage entfallen
+ if (!in_array($oldStatus, $terminal, true) && in_array($newStatus, $terminal, true)) {
+ try { $pdo->prepare('UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } catch (Throwable $e) { error_log('SLA erledigt: ' . $e->getMessage()); }
+ try { $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } catch (Throwable $e) { /* Spalte fehlt vor dem Datenbank-Update */ }
+ }
$wasPaused = ticket_sla_is_paused_status($oldStatus); $willPause = ticket_sla_is_paused_status($newStatus);
if (!$wasPaused && $willPause) {
$now = db_now();
@@ -2186,60 +2214,7 @@ function add_business_hours(string $from, int $hours): string
// Rollen & Berechtigungen
// ====================================================================
-/**
- * Standard-Berechtigungen je Admin/Agent-Rolle.
- * Kann durch eine benutzerdefinierte Rolle (roles.permissions) überschrieben werden.
- */
-function default_permissions(string $role): array
-{
- if ($role === 'admin') {
- return array_fill_keys(array_merge([
- 'reply_ticket','close_ticket','delete_ticket','transfer_ticket',
- 'post_note','assign_ticket','manage_canned','view_reports',
- 'manage_kb','create_ticket',
- ], array_keys(admin_section_permissions())), true);
- }
- // Standard-Agent
- return array_merge([
- 'reply_ticket' => true,
- 'close_ticket' => true,
- 'delete_ticket' => false,
- 'transfer_ticket' => true,
- 'post_note' => true,
- 'assign_ticket' => true,
- 'manage_canned' => false,
- 'view_reports' => true,
- 'manage_kb' => false,
- 'create_ticket' => true,
- ], array_fill_keys(array_keys(admin_section_permissions()), false));
-}
-
-/**
- * Feinkörnige Rechte für einzelne Adminportal-Bereiche – damit ein Admin einem Agenten
- * (per benutzerdefinierter Rolle, siehe staff/roles.php) gezielt einzelne Admin-Funktionen
- * freigeben kann, ohne ihm die volle Administrator-Rolle zu geben ("Co-Admin").
- * Admins haben über has_permission() ohnehin immer automatisch alle Rechte.
- */
-function admin_section_permissions(): array
-{
- return [
- 'manage_departments' => 'Abteilungen verwalten',
- 'manage_priorities' => 'Prioritäten / SLA verwalten',
- 'manage_help_topics' => 'Hilfe-Themen verwalten',
- 'manage_kb' => 'Wissensdatenbank verwalten',
- 'manage_email_addresses' => 'E-Mail-Adressen verwalten',
- 'manage_email_templates' => 'E-Mail-Vorlagen verwalten',
- 'manage_organizations' => 'Organisationen verwalten',
- 'manage_teams' => 'Teams verwalten',
- 'manage_roles' => 'Rollen verwalten',
- 'manage_staff' => 'Mitarbeiter verwalten',
- 'view_audit_log' => 'Admin-Protokoll einsehen',
- 'manage_escalations' => 'Eskalationsregeln verwalten',
- 'view_time_entries' => 'Zeiterfassung einsehen',
- 'manage_business_hours' => 'Geschäftszeiten verwalten',
- 'manage_assistant' => 'Digitalen Assistenten verwalten',
- ];
-}
+// Rechte-Katalog, Standardrechte und Vorlagen: siehe includes/permissions.php (permission_catalog(), default_permissions(), admin_section_permissions())
/**
* Prüft ob ein Mitarbeiter eine bestimmte Berechtigung hat.
@@ -2274,7 +2249,8 @@ function has_permission(array $staff, string $permission, ?int $departmentId = n
$roleCache[$rid] = ($row && ($row['is_active'] ?? 1)) ? (json_decode((string) $row['permissions'], true) ?? []) : null;
}
$perms = $roleCache[$rid];
- if ($perms !== null) return (bool) ($perms[$permission] ?? false);
+ // Fehlt ein Recht in der gespeicherten Rolle: ein altes Recht gilt wie bisher als „nein“, ein neueres mit dem Standardwert des Katalogs
+ if ($perms !== null) return array_key_exists($permission, $perms) ? (bool) $perms[$permission] : (!in_array($permission, permission_legacy_keys(), true) && (bool) (default_permissions('agent')[$permission] ?? false));
}
$defaults = default_permissions('agent');
diff --git a/DeskHub-Webseite/includes/login_guard.php b/DeskHub-Webseite/includes/login_guard.php
index 3fa8a07a..92408a3f 100644
--- a/DeskHub-Webseite/includes/login_guard.php
+++ b/DeskHub-Webseite/includes/login_guard.php
@@ -31,6 +31,70 @@ function login_guard_retention_days(): int
return max(7, min(1095, (int) get_setting('login_guard_retention_days', '180')));
}
+/**
+ * Ist dieser Browser ein bekanntes Gerät dieses Mitarbeiters (Cookie des Anmelde-Wächters, bei einer früheren Anmeldung gemerkt)?
+ * Liest nur, setzt kein Cookie. Nur für Browser: Die Kennung von Apps stammt aus frei wählbaren Angaben und taugt dafür nicht.
+ */
+function login_guard_known_device(PDO $pdo, int $staffId): bool
+{
+ $id = (string) ($_COOKIE[LOGIN_GUARD_COOKIE] ?? '');
+ if (!preg_match('/^[a-f0-9]{32}$/', $id)) return false;
+ $st = $pdo->prepare('SELECT 1 FROM login_devices WHERE staff_id = ? AND device_hash = ?');
+ $st->execute([$staffId, hash('sha256', $staffId . '|' . $id)]);
+ return (bool) $st->fetchColumn();
+}
+
+/** Wie login_guard_known_device(), aber für eine E-Mail-Adresse (unbekannte Adresse = nein). */
+function login_guard_known_device_for_email(string $email): bool
+{
+ if (!login_guard_enabled() || trim($email) === '') return false;
+ try {
+ $pdo = get_db();
+ $st = $pdo->prepare('SELECT id FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
+ $st->execute([$email]);
+ $id = (int) $st->fetchColumn();
+ return $id > 0 && login_guard_known_device($pdo, $id);
+ } catch (Throwable $e) {
+ error_log('login_guard_known_device: ' . $e->getMessage());
+ return false;
+ }
+}
+
+/** Ist die Anmeldung dieses Kontos gerade wegen zu vieler Fehlversuche gesperrt (Web oder App)? */
+function login_guard_is_locked(PDO $pdo, string $email): bool
+{
+ $email = strtolower(trim($email));
+ if ($email === '') return false;
+ $since = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60);
+ $st = $pdo->prepare("SELECT COUNT(*) FROM rate_limit_log WHERE bucket IN ('staff_login_email','mobile_staff_login_email') AND identifier = ? AND created_at >= ?");
+ $st->execute([$email, $since]);
+ return (int) $st->fetchColumn() >= login_guard_max_attempts();
+}
+
+/**
+ * Hebt die Sperre eines Kontos sofort auf: die Fehlversuch-Zähler für Web, App und 2FA-Code werden gelöscht.
+ * Die Sperre je IP-Adresse (viele Versuche von einer Adresse) bleibt bewusst bestehen.
+ * @return int Anzahl der gelöschten Zähler-Einträge
+ */
+function login_guard_unlock(PDO $pdo, int $staffId, ?int $byStaffId = null, string $reason = ''): int
+{
+ $st = $pdo->prepare('SELECT email FROM staff WHERE id = ?');
+ $st->execute([$staffId]);
+ $email = strtolower(trim((string) $st->fetchColumn()));
+ if ($email === '') return 0;
+ $n = 0;
+ $del = $pdo->prepare("DELETE FROM rate_limit_log WHERE bucket IN ('staff_login_email','mobile_staff_login_email') AND identifier = ?");
+ $del->execute([$email]);
+ $n += $del->rowCount();
+ $del = $pdo->prepare("DELETE FROM rate_limit_log WHERE bucket = 'staff_totp_attempt' AND identifier = ?");
+ $del->execute([(string) $staffId]);
+ $n += $del->rowCount();
+ $detail = $reason !== '' ? $reason : ($byStaffId !== null && $byStaffId !== $staffId ? 'Sperre aufgehoben durch Administrator #' . $byStaffId : 'Sperre aufgehoben');
+ try { login_guard_record($pdo, 'unlocked', $staffId, $email, 'web', $detail); } catch (Throwable $e) { error_log('login_guard_unlock log: ' . $e->getMessage()); }
+ audit_log($byStaffId ?? $staffId, "Anmeldesperre aufgehoben für Mitarbeiter #{$staffId}" . ($reason !== '' ? ' (' . $reason . ')' : ''));
+ return $n;
+}
+
/** Anzeigename des Geräts aus dem User-Agent, z. B. „Chrome auf Windows“. */
function login_guard_describe_device(string $ua): string
{
@@ -167,7 +231,7 @@ function login_guard_alert(PDO $pdo, array $staff, int $eventId, string $reason,
$link = base_url() . '/staff/login_alert.php?t=' . $token;
if (getenv('DESKHUB_TESTING') === '1') $GLOBALS['login_guard_last_link'] = $link; // nur für Tests
$name = e((string) ($staff['name'] ?? ''));
- $inner = 'Neue Anmeldung bei Ihrem Konto
'
+ $inner = 'Neue Anmeldung bei Ihrem Konto
'
. 'Hallo ' . $name . ', Ihr Mitarbeiter-Konto wurde gerade von einem bisher unbekannten ' . ($reason === 'Neuer Ort' ? 'Ort' : 'Gerät') . ' aus genutzt.
'
. '| Zeit | ' . e(date('d.m.Y H:i')) . ' |
'
. '| Gerät | ' . e($device) . ($channel === 'app' ? ' (App)' : '') . ' |
'
@@ -209,7 +273,7 @@ function login_guard_locked(string $email, string $channel): void
if (!$staff) return; // unbekannte Adresse: kein Eintrag und keine Mail (sonst lässt sich der Posteingang der Administratoren füllen); die Fehlversuche stehen schon im Protokoll
login_guard_record($pdo, 'locked', (int) $staff['id'], $email, $channel, 'Zu viele Fehlversuche, Anmeldung für ' . login_guard_lock_minutes() . ' Minuten gesperrt');
$ip = client_ip();
- $msg = 'Anmeldung gesperrt
'
+ $msg = 'Anmeldung gesperrt
'
. 'Für das Konto ' . e($email) . ' gab es zu viele falsche Anmeldeversuche. Die Anmeldung ist für ' . login_guard_lock_minutes() . ' Minuten gesperrt.
'
. 'Letzte IP-Adresse: ' . e($ip) . '. Waren Sie das nicht, ändern Sie bitte Ihr Passwort, sobald die Sperre vorbei ist.
';
send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg));
@@ -254,13 +318,15 @@ function login_guard_revoke(PDO $pdo, array $event): bool
$pdo->prepare('DELETE FROM mobile_api_tokens WHERE staff_id = ?')->execute([$sid]);
$pdo->prepare('DELETE FROM login_devices WHERE staff_id = ?')->execute([$sid]);
$pdo->prepare('DELETE FROM login_places WHERE staff_id = ?')->execute([$sid]);
+ // Auch Passkeys: Wer das Konto übernommen hat, könnte einen eigenen eingetragen haben
+ try { $pdo->prepare('DELETE FROM staff_passkeys WHERE staff_id = ?')->execute([$sid]); } catch (Throwable $e) { /* Tabelle fehlt vor dem Datenbank-Update */ }
set_setting('login_guard_revoke_' . $sid, (string) time());
login_guard_record($pdo, 'revoked', $sid, (string) $staff['email'], 'web', 'Konto gesichert: „Das war ich nicht“ nach Eintrag ' . (int) $event['id']);
- $msg = 'Konto gesichert
'
+ $msg = 'Konto gesichert
'
. '' . e((string) $staff['name']) . ' (' . e((string) $staff['email']) . ') hat eine Anmeldung als „nicht von mir“ gemeldet. Das Passwort wurde ungültig gemacht, alle App-Anmeldungen wurden beendet.
'
. 'Gemeldet war: ' . e((string) $event['device']) . ', ' . e((string) $event['place']) . ', IP ' . e((string) $event['ip']) . '. Bitte prüfen Sie das Protokoll im Adminportal unter „Anmelde-Wächter“.
';
login_guard_notify_admins('Konto gesichert: ' . $staff['email'], $msg);
- send_mail((string) $staff['email'], 'Ihr Konto wurde gesichert', email_wrap('Ihr Konto wurde gesichert
Das Passwort ist ungültig. Setzen Sie über „Passwort vergessen?“ auf der Anmeldeseite ein neues Passwort.
'));
+ send_mail((string) $staff['email'], 'Ihr Konto wurde gesichert', email_wrap('Ihr Konto wurde gesichert
Das Passwort ist ungültig. Setzen Sie über „Passwort vergessen?“ auf der Anmeldeseite ein neues Passwort.
'));
return true;
}
@@ -281,5 +347,9 @@ function login_guard_event_labels(): array
'failed' => 'Fehlversuch',
'locked' => 'Gesperrt',
'revoked' => 'Konto gesichert',
+ 'signout' => 'Überall abgemeldet',
+ 'unlocked' => 'Sperre aufgehoben',
+ 'passkey_added' => 'Passkey hinzugefügt',
+ 'passkey_removed' => 'Passkey entfernt',
];
}
diff --git a/DeskHub-Webseite/includes/mailer.php b/DeskHub-Webseite/includes/mailer.php
index 27ebc006..d096798f 100644
--- a/DeskHub-Webseite/includes/mailer.php
+++ b/DeskHub-Webseite/includes/mailer.php
@@ -195,70 +195,118 @@ function render_email_template(string $key, array $vars, string $defaultSubject,
// Einheitliches, modernes E-Mail-Design
// ====================================================================
// email_wrap() umschließt JEDEN Vorlagentext (Standard oder vom Admin
-// angepasst) mit demselben Branding-Rahmen, damit alle System-Mails
-// gleich aussehen. email_badge()/email_button() sind wiederverwendbare
-// Bausteine für die Standardtexte der einzelnen Vorlagen.
+// angepasst) mit demselben Rahmen, damit alle System-Mails gleich aussehen.
+// email_badge()/email_button()/email_p() sind wiederverwendbare Bausteine.
+// Alles ist tabellenbasiert mit Inline-Styles und für Outlook (Word-Engine),
+// Gmail, Apple Mail und Handys gebaut: feste Breite 600 px, die auf kleinen
+// Bildschirmen schrumpft; der Knopf ist in Outlook ein echter Knopf (VML).
-/** Branding-Rahmen (Header, weiße Karte, Footer) um einen HTML-Textkörper. */
+const EMAIL_FONT = "'Segoe UI',-apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif";
+
+/** Rahmen (Kopf mit Name oder Logo, weiße Karte, Fußzeile) um einen HTML-Textkörper. */
function email_wrap(string $innerHtml): string
{
- $siteName = e(get_setting('display_name', config()['site']['name'] ?? 'DeskHub Support'));
+ $siteNameRaw = (string) get_setting('display_name', config()['site']['name'] ?? 'DeskHub Support');
+ $siteName = e($siteNameRaw);
+ $font = EMAIL_FONT;
+ $logo = (string) get_setting('logo_path', '');
+ $brand = '' . $siteName . '';
+ if ($logo !== '' && (str_starts_with($logo, '/') || preg_match('#^https://#i', $logo))) {
+ $src = e(str_starts_with($logo, '/') ? base_url() . $logo : $logo);
+ $img = '
';
+ $brand = get_setting('logo_hide_name', '0') === '1' ? $img : $img . '' . $siteName . '';
+ }
+ $home = e(base_url());
return <<
-
-
- |
- {$siteName}
- |
-
-
- |
- {$innerHtml}
- |
-
-
- |
- Diese E-Mail wurde automatisch von {$siteName} versendet.
- |
-
-
-
+
+
+
+
+
+ |
+ {$brand}
+ |
+
+
+ | |
+
+
+ |
+ {$innerHtml}
+ |
+
+
+ |
+ Diese E-Mail wurde automatisch von {$siteName} versendet. Bitte antworten Sie nur, wenn es um Ihr Ticket geht.
+ |
+
+
+ |
+
+
HTML;
}
-/** Farbige Hervorhebungs-Box, z.B. für die Ticketnummer. $label/$value dürfen bereits ersetzte {platzhalter} enthalten. */
+/** Hervorgehobene Karte, z.B. für die Ticketnummer (volle Breite, farbiger Balken links). $label/$value dürfen {platzhalter} enthalten. */
function email_badge(string $label, string $value, string $color = '#0f6f6a', string $bg = '#e6f4f3'): string
{
+ $font = EMAIL_FONT;
return <<
+
- |
- {$label}
- {$value}
+ | |
+
+ {$label}
+ {$value}
|
HTML;
}
-/** Button-artiger Call-to-Action-Link. */
+/** Knopf mit Link: in Outlook ein echter Knopf (VML), sonst ein abgerundeter Link. */
function email_button(string $label, string $url, string $color = '#0f6f6a'): string
{
+ $font = EMAIL_FONT;
+ $plain = trim(strip_tags($label));
+ $w = max(180, (int) round(mb_strlen($plain) * 9.2 + 64));
return <<
+
HTML;
}
-/** Wiederverwendeter Inline-Style für normale Absätze in Mail-Vorlagen. */
+/** Normaler Absatz. */
function email_p(string $html, string $marginBottom = '16px'): string
{
- return "{$html}
";
+ return "{$html}
";
+}
+
+/** Zitat-/Textkasten für die eigentliche Nachricht (zum Beispiel die Antwort des Supports). */
+function email_quote(string $html): string
+{
+ return ''
+ . '| | '
+ . '' . $html . ' | '
+ . '
';
+}
+
+/** Überschrift im Mailtext. */
+function email_h(string $text): string
+{
+ return '' . $text . '
';
+}
+
+/** Kleingedruckter Hinweis unter dem Knopf. */
+function email_small(string $html): string
+{
+ return '' . $html . '
';
}
/**
@@ -268,25 +316,28 @@ function email_p(string $html, string $marginBottom = '16px'): string
*/
function email_ticket_created_default_body(): string
{
- return email_p('Hallo {customer_name},')
+ return email_h('Wir haben Ihre Anfrage erhalten')
+ . email_p('Hallo {customer_name},')
. email_p('vielen Dank für Ihre Anfrage. Wir haben Ihr Ticket erhalten und kümmern uns so schnell wie möglich darum.', '20px')
. email_badge('Ihre Ticketnummer', '#{ticket_number}')
. email_p('Betreff: {subject}', '24px')
. email_button('Ticket ansehen', '{ticket_url}')
- . 'Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter {lookup_url} prüfen.
';
+ . email_small('Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter {lookup_url} prüfen.');
}
function email_new_reply_default_body(): string
{
- return email_p('Hallo {customer_name},')
- . email_p('es gibt eine neue Antwort zu Ihrem Ticket #{ticket_number}:')
- . '{message}
'
+ return email_h('Neue Antwort zu Ihrem Ticket')
+ . email_p('Hallo {customer_name},')
+ . email_p('es gibt eine neue Antwort zu Ihrem Ticket #{ticket_number}:', '18px')
+ . email_quote('{message}')
. email_button('Ticket ansehen', '{ticket_url}');
}
function email_new_ticket_department_default_body(): string
{
- return email_p('Ein neues Ticket wurde erstellt.')
+ return email_h('Neues Ticket')
+ . email_p('Ein neues Ticket wurde erstellt.', '18px')
. email_badge('Ticket', '#{ticket_number}')
. email_p('Von: {customer_name} ({customer_email})', '6px')
. email_p('Abteilung: {department_name}', '6px')
@@ -296,7 +347,8 @@ function email_new_ticket_department_default_body(): string
function email_ticket_overdue_default_body(): string
{
- return email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:')
+ return email_h('SLA-Frist überschritten')
+ . email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:', '18px')
. email_badge('Ticket', '#{ticket_number}', '#b91c1c', '#fee2e2')
. email_p('Betreff: {subject}', '6px')
. email_p('Abteilung: {department_name}', '24px')
@@ -305,8 +357,9 @@ function email_ticket_overdue_default_body(): string
function email_live_chat_transcript_default_body(): string
{
- return email_p('Hallo {customer_name},')
- . email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Unterlage:', '20px')
+ return email_h('Ihr Chat-Verlauf')
+ . email_p('Hallo {customer_name},')
+ . email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Anfrage:', '20px')
. '{transcript}'
. email_p('Bei weiteren Fragen antworten Sie einfach direkt auf diese E-Mail.', '20px')
. email_button('Ticket ansehen', '{ticket_url}');
@@ -315,9 +368,10 @@ function email_live_chat_transcript_default_body(): string
/** Fertige, versandbereite HTML-Mail für Passwort-zurücksetzen-Links (Kunden- und Mitarbeiterbereich). */
function email_password_reset_body(string $link): string
{
- $inner = email_p('Klicken Sie auf den folgenden Button, um Ihr Passwort zurückzusetzen. Der Link ist 1 Stunde lang gültig.')
+ $inner = email_h('Passwort zurücksetzen')
+ . email_p('Klicken Sie auf den folgenden Knopf, um ein neues Passwort festzulegen. Der Link ist 1 Stunde lang gültig.', '22px')
. email_button('Passwort zurücksetzen', e($link))
- . 'Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail einfach – es wird nichts verändert.
';
+ . email_small('Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail einfach – es wird nichts verändert.');
return email_wrap($inner);
}
@@ -349,10 +403,11 @@ function notify_customer_new_reply(array $ticket, string $message, string $signa
/** Standardtext der Erinnerung an Kunden (siehe cron/remind_customers.php). */
function email_customer_reminder_default_body(): string
{
- return 'Brauchen Sie noch Hilfe?
'
- . 'Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket #{ticket_number} („{subject}“) geantwortet und noch nichts von Ihnen gehört. Hat sich Ihr Anliegen erledigt, brauchen Sie nichts zu tun. Haben Sie noch Fragen, antworten Sie einfach.
'
+ return email_h('Brauchen Sie noch Hilfe?')
+ . email_p('Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket #{ticket_number} („{subject}“) geantwortet und noch nichts von Ihnen gehört. Hat sich Ihr Anliegen erledigt, brauchen Sie nichts zu tun. Haben Sie noch Fragen, antworten Sie einfach.', '16px')
. '{close_note}'
- . 'Ticket ansehen
';
+ . '
'
+ . email_button('Ticket ansehen', '{ticket_url}');
}
function notify_customer_ticket_created(array $ticket): void
@@ -516,10 +571,20 @@ function notify_admins_overdue_ticket(array $ticket): void
* Mitarbeiter die aktiven Administratoren. Nie die Postfach-Adresse der Abteilung: Sie wird vom E-Mail-Abruf gelesen und würde Tickets erzeugen.
* @return list E-Mail-Adressen
*/
+/** Wer bei SLA-Überschreitung benachrichtigt wird: „admins“ (Standard: nur die Administratoren, nur per E-Mail) oder „assignee“ (Bearbeiter bzw. Abteilung, Mail und Push). */
+function sla_notify_mode(): string
+{
+ return get_setting('sla_notify_mode', 'admins') === 'assignee' ? 'assignee' : 'admins';
+}
+
function sla_overdue_recipients(PDO $pdo, array $ticket): array
{
$emails = [];
$assignee = (int) ($ticket['assigned_staff_id'] ?? 0);
+ if (sla_notify_mode() === 'admins') {
+ $assignee = 0; // nur Administratoren: weder der Bearbeiter noch die Abteilung
+ $ticket['department_id'] = null;
+ }
if ($assignee > 0) {
// Im Urlaub: an die Vertretung; ohne nutzbare Vertretung gar nicht an den Abwesenden (dann gehen die Abteilung bzw. die Administratoren vor)
$target = staff_is_on_vacation($assignee) ? vacation_target($pdo, $assignee) : $assignee;
diff --git a/DeskHub-Webseite/includes/manual_file.php b/DeskHub-Webseite/includes/manual_file.php
new file mode 100644
index 00000000..78adfc10
--- /dev/null
+++ b/DeskHub-Webseite/includes/manual_file.php
@@ -0,0 +1,31 @@
+ $size, 'mtime' => $mtime, 'sha256' => (string) hash_file('sha256', $path)];
+ set_setting('manual_file_cache', json_encode($cache));
+ }
+ return ['size' => $size, 'modified_at' => date('Y-m-d H:i:s', $mtime), 'sha256' => (string) $cache['sha256']];
+}
diff --git a/DeskHub-Webseite/includes/permissions.php b/DeskHub-Webseite/includes/permissions.php
new file mode 100644
index 00000000..ce4c8e17
--- /dev/null
+++ b/DeskHub-Webseite/includes/permissions.php
@@ -0,0 +1,184 @@
+ ['label' => $label, 'hint' => $hint, 'agent' => $agent, 'area' => $area];
+ return [
+ 'tickets' => ['title' => 'Tickets bearbeiten', 'items' => [
+ 'reply_ticket' => $p('Tickets beantworten', 'Antworten, Antworten einplanen, Anhänge aus E-Mails nachladen', true),
+ 'post_note' => $p('Interne Notizen schreiben', 'Notizen, die nur das Team sieht', true),
+ 'close_ticket' => $p('Tickets schließen/lösen', 'Status auf Gelöst oder Geschlossen setzen, als Spam markieren', true),
+ 'assign_ticket' => $p('Tickets zuweisen (Mitarbeiter/Team)', 'Bearbeiter oder Team ändern', true),
+ 'transfer_ticket' => $p('Tickets übertragen (Abteilung ändern)', 'Ticket in eine andere Abteilung verschieben', true),
+ 'create_ticket' => $p('Tickets im Namen von Kunden erstellen', 'Neues Ticket anlegen', true),
+ 'merge_split_ticket' => $p('Tickets zusammenführen und teilen', 'Doppelte Tickets vereinen, Nachrichten abspalten', true),
+ 'link_tickets' => $p('Tickets verknüpfen', 'Verwandt, doppelt oder blockiert', true),
+ 'delete_ticket' => $p('Tickets löschen', 'Endgültig löschen, auch per Sammelaktion', false),
+ ]],
+ 'customers' => ['title' => 'Kunden und Sperren', 'items' => [
+ 'view_customers' => $p('Kundenprofile ansehen', 'Profil eines Kunden mit allen seinen Tickets', true),
+ 'manage_customers' => $p('Kundenverzeichnis verwalten', 'Alle Kunden-Konten ansehen und Organisationen zuordnen', false, true),
+ 'manage_organizations' => $p('Organisationen verwalten', 'Mehrere Kunden zu einer Firma bündeln', false, true),
+ 'manage_banlist' => $p('Sperrliste und Absender sperren', 'Adressen und Domains blockieren', true),
+ ]],
+ 'reports' => ['title' => 'Auswertung und Export', 'items' => [
+ 'view_reports' => $p('Berichte einsehen', 'Übersicht, Berichte, Bericht-Baukasten (nur eigene Abteilungen)', true),
+ 'schedule_reports' => $p('Berichte per E-Mail versenden', 'Zeitpläne im Bericht-Baukasten (externe Empfänger nur Administratoren)', true),
+ 'export_tickets' => $p('Tickets als CSV exportieren', 'Ticketliste für Excel herunterladen', false),
+ 'view_time_entries' => $p('Zeiterfassung einsehen', 'Auswertung der erfassten Zeiten', false, true),
+ 'view_audit_log' => $p('Admin-Protokoll einsehen', 'Wer hat was geändert', false, true),
+ ]],
+ 'mail' => ['title' => 'E-Mail', 'items' => [
+ 'fetch_mail' => $p('E-Mails jetzt abrufen', 'Den Abruf per Knopf auslösen', true),
+ 'manage_email_addresses' => $p('E-Mail-Adressen und Postfächer verwalten', 'Absender, Eingang, SMTP und IMAP je Adresse', false, true),
+ 'manage_email_templates' => $p('E-Mail-Vorlagen verwalten', 'Texte der automatischen Mails', false, true),
+ 'view_mail_queue' => $p('Mail-Warteschlange verwalten', 'Nicht zugestellte Mails ansehen, erneut senden, löschen', false, true),
+ ]],
+ 'content' => ['title' => 'Inhalte und Vorlagen', 'items' => [
+ 'manage_canned' => $p('Textbausteine verwalten', 'Vorlagen für Antworten', false, true),
+ 'manage_ticket_templates' => $p('Ticket-Vorlagen verwalten', 'Vorgefüllte Tickets für Mitarbeiter und Kunden', false, true),
+ 'manage_kb' => $p('Wissensdatenbank verwalten', 'Artikel und Kategorien', false, true),
+ 'manage_assistant' => $p('Digitalen Assistenten verwalten', 'Antworten, Fragen-Log, Einstellungen', false, true),
+ 'manage_site_pages' => $p('Seiten im Kundenportal verwalten', 'Impressum, AGB und weitere Seiten', false, true),
+ 'manage_languages' => $p('Sprachen verwalten', 'Sprachpakete und Standardsprache', false, true),
+ 'manage_status_page' => $p('Statusseite verwalten', 'Dienste und Störungsmeldungen', false, true),
+ 'share_views' => $p('Ansichten mit dem Team teilen', 'Gespeicherte Filter für alle sichtbar machen', true),
+ ]],
+ 'setup' => ['title' => 'Einrichtung', 'items' => [
+ 'manage_departments' => $p('Abteilungen verwalten', 'Abteilungen, Formulare, automatische Zuweisung', false, true),
+ 'manage_priorities' => $p('Prioritäten / SLA verwalten', 'Prioritäten und SLA-Pläne', false, true),
+ 'manage_help_topics' => $p('Hilfethemen verwalten', 'Auswahl für Kunden beim Ticket-Erstellen', false, true),
+ 'manage_custom_fields' => $p('Ticket-Felder und Listen verwalten', 'Zusatzfelder und Auswahllisten', false, true),
+ 'manage_automation' => $p('Ticket-Regeln und wiederkehrende Tickets verwalten', 'Automatische Aktionen bei neuen Tickets, regelmäßige Tickets', false, true),
+ 'manage_escalations' => $p('Eskalationsregeln verwalten', 'Aktionen bei überfälligen oder liegengebliebenen Tickets', false, true),
+ 'manage_business_hours' => $p('Geschäftszeiten verwalten', 'Zeitfenster und Feiertage für die SLA', false, true),
+ 'manage_problems' => $p('Problemmanagement', 'Probleme erfassen und mit Tickets verknüpfen', false, true),
+ ]],
+ 'team' => ['title' => 'Team und Sicherheit', 'items' => [
+ 'manage_staff' => $p('Mitarbeiter verwalten', 'Konten, Sperre aufheben, Überall abmelden, Passkeys entfernen', false, true),
+ 'manage_teams' => $p('Teams verwalten', 'Gruppen von Mitarbeitern', false, true),
+ 'manage_roles' => $p('Rollen verwalten', 'Rechte vergeben', false, true),
+ 'manage_availability' => $p('Status und Schichten der Kollegen ändern', 'Status anderer Mitarbeiter setzen, automatisches Offline einstellen', false, true),
+ 'view_login_guard' => $p('Anmelde-Wächter ansehen', 'Anmeldeprotokoll und gesperrte Konten; Einstellungen bleiben Administratoren', false, true),
+ 'view_help' => $p('Hilfe und Handbuch', 'Hilfe-Seite mit dem Handbuch (PDF)', false, true),
+ ]],
+ ];
+}
+
+/** Alle Rechte als schlüssel => Beschriftung (Reihenfolge wie im Katalog). */
+function permission_labels(): array
+{
+ $out = [];
+ foreach (permission_catalog() as $g) foreach ($g['items'] as $k => $i) $out[$k] = $i['label'];
+ return $out;
+}
+
+/** Rechte für Adminportal-Bereiche („Co-Admin“): schlüssel => Beschriftung. */
+function admin_section_permissions(): array
+{
+ $out = [];
+ foreach (permission_catalog() as $g) foreach ($g['items'] as $k => $i) if ($i['area']) $out[$k] = $i['label'];
+ return $out;
+}
+
+/** Standardrechte: Administrator alles, Mitarbeiter die Standardwerte des Katalogs. */
+function default_permissions(string $role): array
+{
+ $out = [];
+ foreach (permission_catalog() as $g) foreach ($g['items'] as $k => $i) $out[$k] = $role === 'admin' ? true : (bool) $i['agent'];
+ return $out;
+}
+
+/**
+ * Vorlagen für Rollen: Schlüssel => [Name, Beschreibung, Rechte, als Rolle anlegen?].
+ * Die Vorlagen mit „true“ werden bei der Installation und beim Update als echte Rollen angelegt (siehe permission_seed_default_roles()); sie lassen sich danach
+ * wie jede andere Rolle ändern oder löschen. „Mitarbeiter“ ist nur eine Vorlage im Formular, weil das die Standardrechte ohne Rolle sind.
+ */
+function permission_presets(): array
+{
+ $all = array_keys(permission_labels());
+ $on = fn(array $keys) => array_fill_keys($keys, true) + array_fill_keys($all, false);
+ $agent = array_keys(array_filter(default_permissions('agent')));
+ $lead = array_merge($agent, ['delete_ticket', 'export_tickets', 'view_time_entries', 'manage_canned', 'manage_kb', 'manage_teams', 'manage_availability']);
+ return [
+ 'mitarbeiter' => ['Mitarbeiter', 'Die Standardrechte ohne Rolle: Tickets bearbeiten, Berichte der eigenen Abteilungen.', $on($agent), false],
+ 'co_admin' => ['Co-Administrator', 'Alle Rechte, die sich vergeben lassen. Nur Einstellungen, Backup, System-Update, Plugins, API-Schlüssel, Webhooks, Import und Sicherheitsprüfung bleiben echten Administratoren.', $on($all), true],
+ 'support_manager' => ['Support-Manager', 'Leitet den Support: Tickets, Einrichtung (Abteilungen, Prioritäten, Themen, Regeln, Felder), Mitarbeiter, Teams, Protokolle und Mail-Warteschlange.', $on(array_merge($lead, [
+ 'manage_departments', 'manage_priorities', 'manage_help_topics', 'manage_custom_fields', 'manage_automation', 'manage_escalations', 'manage_business_hours', 'manage_problems',
+ 'manage_ticket_templates', 'manage_staff', 'view_audit_log', 'view_login_guard', 'view_mail_queue', 'manage_customers', 'manage_organizations', 'manage_email_templates', 'view_help'])), true],
+ 'it' => ['IT / Technik', 'Betrieb und Technik: Tickets bearbeiten, dazu Postfächer und E-Mail-Adressen, Mail-Warteschlange, Anmelde-Wächter, Statusseite, Regeln, Eskalationen, Protokoll und Hilfe.', $on(array_merge($agent, [
+ 'manage_email_addresses', 'view_mail_queue', 'view_login_guard', 'manage_status_page', 'manage_automation', 'manage_escalations', 'view_audit_log', 'manage_problems', 'manage_custom_fields', 'view_help'])), true],
+ 'teamleitung' => ['Teamleitung', 'Führt ein Team: wie Mitarbeiter, dazu Löschen, Export, Zeiterfassung, Textbausteine, Wissensdatenbank, Teams und Schichten.', $on($lead), true],
+ 'support_2' => ['Support 2nd Level', 'Erfahrene Mitarbeiter: Tickets bearbeiten, Probleme verwalten, Export und Zeiterfassung.', $on(array_merge($agent, ['manage_problems', 'export_tickets', 'view_time_entries'])), true],
+ 'support_1' => ['Support 1st Level', 'Einstieg: antworten, Notizen, zuweisen, Tickets anlegen und verknüpfen, E-Mails abrufen. Kein Schließen, Verschieben, Zusammenführen oder Sperren.', $on(['reply_ticket', 'post_note', 'assign_ticket', 'create_ticket', 'link_tickets', 'view_customers', 'fetch_mail']), true],
+ 'redaktion' => ['Redaktion (Wissen und Inhalte)', 'Pflegt Inhalte: Wissensdatenbank, digitaler Assistent, Textbausteine, Ticket- und E-Mail-Vorlagen, Seiten und Sprachen.', $on(['reply_ticket', 'post_note', 'view_customers', 'manage_kb', 'manage_assistant', 'manage_canned', 'manage_ticket_templates', 'manage_email_templates', 'manage_site_pages', 'manage_languages']), true],
+ 'auswertung' => ['Auswertung / Controlling', 'Zahlen und Berichte: Berichte (auch per E-Mail), Export, Zeiterfassung, Protokoll, Ansichten teilen. Kann keine Tickets bearbeiten.', $on(['view_reports', 'schedule_reports', 'export_tickets', 'view_time_entries', 'view_audit_log', 'share_views', 'view_customers']), true],
+ 'teamverwaltung' => ['Teamverwaltung / Schichtplanung', 'Verwaltet Personal: Mitarbeiter, Teams, Status und Schichten, Geschäftszeiten, Anmelde-Wächter ansehen.', $on(['view_reports', 'manage_staff', 'manage_teams', 'manage_availability', 'manage_business_hours', 'view_login_guard', 'view_audit_log']), true],
+ 'aushilfe' => ['Aushilfe / Praktikant', 'Nur das Nötigste: antworten und Notizen schreiben, Kundenprofile ansehen.', $on(['reply_ticket', 'post_note', 'view_customers']), true],
+ 'nur_lesen' => ['Nur lesen', 'Tickets, Kundenprofile und Berichte ansehen, nichts ändern.', $on(['view_customers', 'view_reports']), true],
+ ];
+}
+
+/**
+ * Legt die vordefinierten Rollen an, die es noch nicht gibt (Vergleich über den Namen, Groß-/Kleinschreibung egal). Vorhandene Rollen werden nie verändert.
+ * @return list Namen der neu angelegten Rollen
+ */
+function permission_seed_default_roles(PDO $pdo): array
+{
+ $created = [];
+ $exists = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?)');
+ $now = date('Y-m-d H:i:s');
+ foreach (permission_presets() as [$name, $desc, $perms, $seed]) {
+ if (!$seed) continue;
+ $exists->execute([$name]);
+ if ((int) $exists->fetchColumn() > 0) continue;
+ $pdo->prepare('INSERT INTO roles (name, permissions, is_active, notes, created_at, updated_at) VALUES (?,?,1,?,?,?)')
+ ->execute([$name, json_encode($perms), 'Vordefinierte Rolle – frei änderbar. ' . $desc, $now, $now]);
+ $created[] = $name;
+ }
+ return $created;
+}
+
+/** Namen der vordefinierten Rollen, die auf dieser Installation fehlen. @return list */
+function permission_missing_default_roles(PDO $pdo): array
+{
+ $have = array_map('mb_strtolower', $pdo->query('SELECT name FROM roles')->fetchAll(PDO::FETCH_COLUMN));
+ $missing = [];
+ foreach (permission_presets() as [$name, , , $seed]) if ($seed && !in_array(mb_strtolower($name), $have, true)) $missing[] = $name;
+ return $missing;
+}
+
+/**
+ * Rechte, die es schon vor dem Katalog gab. Fehlt eines davon in einer gespeicherten Rolle, gilt wie bisher „nein“; bei allen neueren Rechten gilt
+ * der Standardwert des Katalogs (so behalten bestehende Rollen, was bisher für alle offen war, und neue Adminbereiche bleiben aus).
+ */
+function permission_legacy_keys(): array
+{
+ return ['reply_ticket', 'close_ticket', 'delete_ticket', 'transfer_ticket', 'post_note', 'assign_ticket', 'manage_canned', 'view_reports', 'manage_kb', 'create_ticket',
+ 'manage_departments', 'manage_priorities', 'manage_help_topics', 'manage_email_addresses', 'manage_email_templates', 'manage_organizations', 'manage_teams', 'manage_roles',
+ 'manage_staff', 'view_audit_log', 'manage_escalations', 'view_time_entries', 'manage_business_hours', 'manage_assistant'];
+}
+
+/** Wirksame Rechte einer gespeicherten Rolle (so, wie has_permission() sie auswertet): fehlende alte Rechte = nein, fehlende neuere = Standardwert. */
+function permission_effective(array $rolePerms): array
+{
+ $defaults = default_permissions('agent');
+ $legacy = permission_legacy_keys();
+ $out = [];
+ foreach (array_keys($defaults) as $k) {
+ $out[$k] = array_key_exists($k, $rolePerms) ? (bool) $rolePerms[$k] : (!in_array($k, $legacy, true) && $defaults[$k]);
+ }
+ return $out;
+}
+
diff --git a/DeskHub-Webseite/includes/plugins.php b/DeskHub-Webseite/includes/plugins.php
index f929a8e1..284c926c 100644
--- a/DeskHub-Webseite/includes/plugins.php
+++ b/DeskHub-Webseite/includes/plugins.php
@@ -630,6 +630,21 @@ function catalog_refresh_background(): void
*
* @return array
*/
+/**
+ * Installiert alle Plugin-Updates aus dem Katalog (jedes einzeln, mit Prüfsumme und Signatur; ein Fehler stoppt die anderen nicht).
+ * @return array{updated:list,failed:array} Namen mit Version bzw. Slug => Fehlertext
+ */
+function plugin_catalog_update_all(PDO $pdo): array
+{
+ $out = ['updated' => [], 'failed' => []];
+ foreach (plugin_updates_available($pdo) as $u) {
+ $r = plugin_catalog_install($pdo, $u['slug']);
+ if (!empty($r['ok'])) $out['updated'][] = $u['name'] . ' ' . $r['version'];
+ else $out['failed'][$u['slug']] = (string) ($r['error'] ?? 'Unbekannter Fehler');
+ }
+ return $out;
+}
+
function plugin_updates_available(PDO $pdo): array
{
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
diff --git a/DeskHub-Webseite/includes/signout.php b/DeskHub-Webseite/includes/signout.php
new file mode 100644
index 00000000..c68584ab
--- /dev/null
+++ b/DeskHub-Webseite/includes/signout.php
@@ -0,0 +1,76 @@
+“, siehe includes/login_guard.php)
+ * - Anmeldungen der Apps (Android, Windows-Programm, Chrome-Erweiterung): ihre Tokens werden gelöscht, die Programme fragen danach neu nach der Anmeldung
+ * - Push: Browser-Abonnements und Android-Geräte, damit ein verlorenes Gerät keine Benachrichtigungen mehr bekommt
+ * - Ticket-Sperren des Mitarbeiters und laufende Zeiterfassung
+ * Das Passwort bleibt unverändert; wer es zusätzlich ändern will, tut das im Profil oder über „Das war ich nicht“.
+ */
+
+/**
+ * @param bool $keepCurrentSession true = die Sitzung, in der das gerade geschieht, bleibt angemeldet (nur bei eigener Abmeldung sinnvoll)
+ * @return array{at:int,apps:int,browsers:int,devices:int,locks:int}
+ */
+function staff_sign_out_everywhere(PDO $pdo, int $staffId, ?int $byStaffId = null, bool $keepCurrentSession = false): array
+{
+ $st = $pdo->prepare('SELECT id, name, email FROM staff WHERE id = ?');
+ $st->execute([$staffId]);
+ $staff = $st->fetch();
+ $res = ['at' => 0, 'apps' => 0, 'browsers' => 0, 'devices' => 0, 'locks' => 0];
+ if (!$staff) return $res;
+
+ $count = function (string $table) use ($pdo, $staffId): int {
+ try {
+ $c = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE staff_id = ?");
+ $c->execute([$staffId]);
+ return (int) $c->fetchColumn();
+ } catch (Throwable $e) {
+ return 0;
+ }
+ };
+ $res['apps'] = $count('mobile_api_tokens');
+ $res['browsers'] = $count('push_subscriptions');
+ $res['devices'] = $count('push_devices');
+
+ $at = time();
+ $res['at'] = $at;
+ set_setting('login_guard_revoke_' . $staffId, (string) $at);
+ foreach (['mobile_api_tokens', 'push_subscriptions', 'push_devices'] as $table) {
+ try { $pdo->prepare("DELETE FROM {$table} WHERE staff_id = ?")->execute([$staffId]); } catch (Throwable $e) { error_log('signout ' . $table . ': ' . $e->getMessage()); }
+ }
+ try {
+ $l = $pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL, updated_at = updated_at WHERE locked_by = ?');
+ $l->execute([$staffId]);
+ $res['locks'] = $l->rowCount();
+ } catch (Throwable $e) {
+ error_log('signout locks: ' . $e->getMessage());
+ }
+ if (function_exists('finalize_all_sessions_for_staff')) {
+ try { finalize_all_sessions_for_staff($pdo, $staffId); } catch (Throwable $e) { error_log('signout time: ' . $e->getMessage()); }
+ }
+
+ // Die eigene Sitzung bleibt, wenn gewünscht: Ihr Anmeldezeitpunkt liegt dann hinter dem Zeitpunkt des Widerrufs
+ if ($keepCurrentSession && $byStaffId === $staffId && PHP_SAPI !== 'cli' && session_status() === PHP_SESSION_ACTIVE) {
+ $_SESSION['staff_login_at'] = $at + 1;
+ }
+
+ $self = $byStaffId === null || $byStaffId === $staffId;
+ $detail = $self ? 'Von allen Geräten abgemeldet (eigene Veranlassung)' : 'Von allen Geräten abgemeldet durch Administrator #' . (int) $byStaffId;
+ try { login_guard_record($pdo, 'signout', $staffId, (string) $staff['email'], 'web', $detail); } catch (Throwable $e) { error_log('signout log: ' . $e->getMessage()); }
+ audit_log($byStaffId ?? $staffId, "Mitarbeiter #{$staffId} von allen Geräten abgemeldet ({$res['apps']} App-Anmeldungen, {$res['browsers']} Browser-Push, {$res['devices']} App-Geräte)");
+
+ if (!$self && trim((string) $staff['email']) !== '') {
+ send_mail((string) $staff['email'], 'Sie wurden von allen Geräten abgemeldet',
+ email_wrap('Von allen Geräten abgemeldet
Ein Administrator hat Ihre Anmeldungen in DeskHub beendet. Bitte melden Sie sich neu an. Haben Sie das nicht erwartet, wenden Sie sich an Ihre Administration.
'));
+ }
+ return $res;
+}
+
+/** Aktive App-Anmeldungen eines Mitarbeiters (Android, Windows, Chrome) für die Anzeige. @return list> */
+function staff_app_logins(PDO $pdo, int $staffId): array
+{
+ $s = $pdo->prepare('SELECT device_name, last_used_at, created_at FROM mobile_api_tokens WHERE staff_id = ? AND revoked_at IS NULL AND expires_at > ? ORDER BY COALESCE(last_used_at, created_at) DESC');
+ $s->execute([$staffId, db_now()]);
+ return $s->fetchAll();
+}