From e90c1a4563be0005ed926de391fa2897ee52154b Mon Sep 17 00:00:00 2001 From: M_Viper Date: Thu, 5 Nov 2026 21:21:44 +0000 Subject: [PATCH] Upload via GUI (452 Dateien) --- DeskHub-Webseite/includes/bootstrap.php | 2 + DeskHub-Webseite/includes/csat.php | 2 +- DeskHub-Webseite/includes/fcm.php | 1 + DeskHub-Webseite/includes/functions.php | 86 ++++------ DeskHub-Webseite/includes/login_guard.php | 78 ++++++++- DeskHub-Webseite/includes/mailer.php | 163 +++++++++++++------ DeskHub-Webseite/includes/manual_file.php | 31 ++++ DeskHub-Webseite/includes/permissions.php | 184 ++++++++++++++++++++++ DeskHub-Webseite/includes/plugins.php | 15 ++ DeskHub-Webseite/includes/signout.php | 76 +++++++++ 10 files changed, 529 insertions(+), 109 deletions(-) create mode 100644 DeskHub-Webseite/includes/manual_file.php create mode 100644 DeskHub-Webseite/includes/permissions.php create mode 100644 DeskHub-Webseite/includes/signout.php 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.

' . '' . '' @@ -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 = '' . $siteName . ''; + $brand = get_setting('logo_hide_name', '0') === '1' ? $img : $img . '' . $siteName . ''; + } + $home = e(base_url()); return << -
Zeit' . e(date('d.m.Y H:i')) . '
Gerät' . e($device) . ($channel === 'app' ? ' (App)' : '') . '
- - - - - - - - - -
- {$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 << + -
- {$label} + + + {$label}
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(); +}