Upload via GUI (452 Dateien)

This commit is contained in:
2026-11-05 21:21:44 +00:00
parent 0cd88ac2f9
commit e90c1a4563
10 changed files with 529 additions and 109 deletions
+2
View File
@@ -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';
+1 -1
View File
@@ -76,7 +76,7 @@ function csat_mail_html(array $ticket, string $token): string
for ($i = 1; $i <= 5; $i++) {
$stars .= '<a href="' . e(csat_url($token, $i)) . '" style="display:inline-block;text-decoration:none;font-size:34px;line-height:1;color:#f5b301;padding:0 3px" title="' . $i . ' von 5">★</a>';
}
return '<h2 style="margin:0 0 12px;font-size:20px;color:#16202a">Wie zufrieden waren Sie?</h2>'
return '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Wie zufrieden waren Sie?</h2>'
. '<p style="margin:0 0 16px;color:#475569;font-size:15px;line-height:1.55">' . ($name !== '' ? 'Hallo ' . e($name) . ', ' : '')
. 'Ihr Ticket <strong>[#' . e((string) $ticket['ticket_number']) . '] ' . e((string) $ticket['subject']) . '</strong> ist abgeschlossen. '
. 'Eine kurze Bewertung hilft uns, besser zu werden. Es dauert nur einen Klick.</p>'
+1
View File
@@ -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);
+31 -55
View File
@@ -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');
+74 -4
View File
@@ -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 = '<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Neue Anmeldung bei Ihrem Konto</h2>'
$inner = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Neue Anmeldung bei Ihrem Konto</h2>'
. '<p style="margin:0 0 12px;color:#334155">Hallo ' . $name . ', Ihr Mitarbeiter-Konto wurde gerade von einem bisher unbekannten ' . ($reason === 'Neuer Ort' ? 'Ort' : 'Gerät') . ' aus genutzt.</p>'
. '<table style="margin:0 0 16px;font-size:14px;color:#334155"><tr><td style="padding:2px 14px 2px 0;color:#64748b">Zeit</td><td>' . e(date('d.m.Y H:i')) . '</td></tr>'
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">Gerät</td><td>' . e($device) . ($channel === 'app' ? ' (App)' : '') . '</td></tr>'
@@ -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 = '<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Anmeldung gesperrt</h2>'
$msg = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Anmeldung gesperrt</h2>'
. '<p style="margin:0 0 12px;color:#334155">Für das Konto <strong>' . e($email) . '</strong> gab es zu viele falsche Anmeldeversuche. Die Anmeldung ist für ' . login_guard_lock_minutes() . ' Minuten gesperrt.</p>'
. '<p style="margin:0 0 12px;color:#334155">Letzte IP-Adresse: ' . e($ip) . '. Waren Sie das nicht, ändern Sie bitte Ihr Passwort, sobald die Sperre vorbei ist.</p>';
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 = '<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Konto gesichert</h2>'
$msg = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Konto gesichert</h2>'
. '<p style="margin:0 0 12px;color:#334155">' . 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.</p>'
. '<p style="margin:0 0 12px;color:#334155">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“.</p>';
login_guard_notify_admins('Konto gesichert: ' . $staff['email'], $msg);
send_mail((string) $staff['email'], 'Ihr Konto wurde gesichert', email_wrap('<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Ihr Konto wurde gesichert</h2><p style="margin:0;color:#334155">Das Passwort ist ungültig. Setzen Sie über „Passwort vergessen?“ auf der Anmeldeseite ein neues Passwort.</p>'));
send_mail((string) $staff['email'], 'Ihr Konto wurde gesichert', email_wrap('<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Ihr Konto wurde gesichert</h2><p style="margin:0;color:#334155">Das Passwort ist ungültig. Setzen Sie über „Passwort vergessen?“ auf der Anmeldeseite ein neues Passwort.</p>'));
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',
];
}
+103 -38
View File
@@ -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 = '<span style="font-family:' . $font . ';color:#ffffff;font-size:20px;font-weight:700;letter-spacing:-.01em;">' . $siteName . '</span>';
if ($logo !== '' && (str_starts_with($logo, '/') || preg_match('#^https://#i', $logo))) {
$src = e(str_starts_with($logo, '/') ? base_url() . $logo : $logo);
$img = '<img src="' . $src . '" alt="' . $siteName . '" height="34" style="display:inline-block;height:34px;width:auto;border:0;vertical-align:middle;">';
$brand = get_setting('logo_hide_name', '0') === '1' ? $img : $img . '<span style="font-family:' . $font . ';color:#ffffff;font-size:20px;font-weight:700;vertical-align:middle;padding-left:12px;">' . $siteName . '</span>';
}
$home = e(base_url());
return <<<HTML
<div style="background:#f5f7f9;padding:32px 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e2e8f0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#eef2f6" style="background:#eef2f6;">
<tr>
<td style="background:#0f6f6a;padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;letter-spacing:-.01em;">{$siteName}</span>
<td align="center" style="padding:28px 12px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;">
<tr>
<td bgcolor="#0f6f6a" style="background:#0f6f6a;border-radius:14px 14px 0 0;padding:24px 36px;">
{$brand}
</td>
</tr>
<tr>
<td style="padding:32px 28px;">
<td height="4" bgcolor="#14958d" style="background:#14958d;font-size:0;line-height:0;height:4px;">&nbsp;</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="background:#ffffff;padding:36px 36px 34px;font-family:{$font};color:#1f2937;font-size:15px;line-height:1.6;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;">
{$innerHtml}
</td>
</tr>
<tr>
<td style="padding:18px 28px;background:#f5f7f9;border-top:1px solid #e2e8f0;">
<p style="margin:0;color:#94a3b8;font-size:12px;">Diese E-Mail wurde automatisch von {$siteName} versendet.</p>
<td bgcolor="#ffffff" style="background:#ffffff;border:1px solid #e2e8f0;border-top:1px solid #edf1f5;border-radius:0 0 14px 14px;padding:18px 36px;font-family:{$font};">
<span style="color:#94a3b8;font-size:12px;line-height:1.5;">Diese E-Mail wurde automatisch von <a href="{$home}" style="color:#64748b;text-decoration:none;">{$siteName}</a> versendet. Bitte antworten Sie nur, wenn es um Ihr Ticket geht.</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
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 <<<HTML
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 22px;">
<tr>
<td style="background:{$bg};border-radius:8px;padding:14px 18px;">
<div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{$color};margin-bottom:2px;">{$label}</div>
<div style="font-size:20px;font-weight:700;color:{$color};font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">{$value}</div>
<td width="5" bgcolor="{$color}" style="background:{$color};width:5px;font-size:0;line-height:0;">&nbsp;</td>
<td bgcolor="{$bg}" style="background:{$bg};padding:16px 20px;font-family:{$font};">
<div style="font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:{$color};line-height:1.4;">{$label}</div>
<div style="font-size:24px;font-weight:700;color:{$color};line-height:1.3;font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">{$value}</div>
</td>
</tr>
</table>
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
<table role="presentation" cellpadding="0" cellspacing="0">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:6px 0 4px;">
<tr>
<td style="background:{$color};border-radius:8px;">
<a href="{$url}" style="display:inline-block;padding:12px 24px;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;">{$label}</a>
<td align="center" bgcolor="{$color}" style="background:{$color};border-radius:10px;">
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{$url}" style="height:48px;v-text-anchor:middle;width:{$w}px;" arcsize="20%" stroke="f" fillcolor="{$color}"><w:anchorlock/><center style="color:#ffffff;font-family:'Segoe UI',Arial,sans-serif;font-size:15px;font-weight:bold;">{$label}</center></v:roundrect><![endif]-->
<!--[if !mso]><!-- --><a href="{$url}" style="display:inline-block;padding:14px 30px;font-family:{$font};color:#ffffff;font-size:15px;font-weight:600;line-height:1.2;text-decoration:none;border-radius:10px;background:{$color};">{$label}</a><!--<![endif]-->
</td>
</tr>
</table>
HTML;
}
/** Wiederverwendeter Inline-Style für normale Absätze in Mail-Vorlagen. */
/** Normaler Absatz. */
function email_p(string $html, string $marginBottom = '16px'): string
{
return "<p style=\"margin:0 0 {$marginBottom};color:#16202a;font-size:15px;line-height:1.5;\">{$html}</p>";
return "<p style=\"margin:0 0 {$marginBottom};color:#1f2937;font-size:15px;line-height:1.6;font-family:" . EMAIL_FONT . ";\">{$html}</p>";
}
/** Zitat-/Textkasten für die eigentliche Nachricht (zum Beispiel die Antwort des Supports). */
function email_quote(string $html): string
{
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px;"><tr>'
. '<td width="4" bgcolor="#0f6f6a" style="background:#0f6f6a;width:4px;font-size:0;line-height:0;">&nbsp;</td>'
. '<td bgcolor="#f6f9fb" style="background:#f6f9fb;padding:16px 20px;color:#1f2937;font-size:15px;line-height:1.65;font-family:' . EMAIL_FONT . ';">' . $html . '</td>'
. '</tr></table>';
}
/** Überschrift im Mailtext. */
function email_h(string $text): string
{
return '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:' . EMAIL_FONT . ';">' . $text . '</h2>';
}
/** Kleingedruckter Hinweis unter dem Knopf. */
function email_small(string $html): string
{
return '<p style="margin:22px 0 0;color:#64748b;font-size:13px;line-height:1.55;font-family:' . EMAIL_FONT . ';">' . $html . '</p>';
}
/**
@@ -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('<strong>Betreff:</strong> {subject}', '24px')
. email_button('Ticket ansehen', '{ticket_url}')
. '<p style="margin:24px 0 0;color:#64748b;font-size:13px;line-height:1.5;">Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter <a href="{lookup_url}" style="color:#0f6f6a;">{lookup_url}</a> prüfen.</p>';
. email_small('Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter <a href="{lookup_url}" style="color:#0f6f6a;">{lookup_url}</a> 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 <strong>#{ticket_number}</strong>:')
. '<div style="background:#f8fafc;border-left:3px solid #0f6f6a;border-radius:0 6px 6px 0;padding:14px 18px;margin:0 0 24px;color:#16202a;font-size:14px;line-height:1.6;">{message}</div>'
return email_h('Neue Antwort zu Ihrem Ticket')
. email_p('Hallo {customer_name},')
. email_p('es gibt eine neue Antwort zu Ihrem Ticket <strong>#{ticket_number}</strong>:', '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('<strong>Von:</strong> {customer_name} ({customer_email})', '6px')
. email_p('<strong>Abteilung:</strong> {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('<strong>Betreff:</strong> {subject}', '6px')
. email_p('<strong>Abteilung:</strong> {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 <strong>1 Stunde</strong> 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 <strong>1 Stunde</strong> lang gültig.', '22px')
. email_button('Passwort zurücksetzen', e($link))
. '<p style="margin:20px 0 0;color:#64748b;font-size:13px;line-height:1.5;">Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail einfach – es wird nichts verändert.</p>';
. 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 '<h2 style="margin:0 0 12px;font-size:20px;color:#0f172a;">Brauchen Sie noch Hilfe?</h2>'
. '<p style="margin:0 0 12px;color:#334155;font-size:15px;line-height:1.6;">Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket <strong>#{ticket_number}</strong> („{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.</p>'
return email_h('Brauchen Sie noch Hilfe?')
. email_p('Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket <strong>#{ticket_number}</strong> („{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}'
. '<p style="margin:20px 0 0;"><a href="{ticket_url}" style="background:#0f6f6a;color:#ffffff;text-decoration:none;padding:11px 20px;border-radius:8px;font-weight:600;display:inline-block;">Ticket ansehen</a></p>';
. '<div style="height:8px;line-height:8px;font-size:0;">&nbsp;</div>'
. 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<string> 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;
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* Das Handbuch (PDF) für Administratoren: Adminportal → Hilfe (staff/help.php).
* Die Datei kommt mit jedem Update im Programmpaket mit (docs/DeskHub-Handbuch.pdf). Der Ordner docs/ ist für den Webserver gesperrt
* (.htaccess bzw. die nginx-Regeln); ausgeliefert wird die Datei nur über die Hilfe-Seite an echte Administratoren.
*/
const MANUAL_FILE = 'DeskHub-Handbuch.pdf';
function manual_path(): string
{
return (string) (getenv('DESKHUB_MANUAL_PATH') ?: dirname(__DIR__) . '/docs/' . MANUAL_FILE);
}
/** @return array{size:int,modified_at:string,sha256:string}|null */
function manual_info(): ?array
{
$path = manual_path();
if (!is_file($path)) return null;
$size = (int) filesize($path);
$mtime = (int) filemtime($path);
// Die Prüfsumme wird nur neu berechnet, wenn sich Größe oder Änderungsdatum ändern
$cache = json_decode((string) get_setting('manual_file_cache', ''), true);
if (!is_array($cache) || (int) ($cache['size'] ?? -1) !== $size || (int) ($cache['mtime'] ?? -1) !== $mtime || empty($cache['sha256'])) {
$cache = ['size' => $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']];
}
+184
View File
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
/**
* Rechte-Katalog: Alle Berechtigungen an einer Stelle, gruppiert, mit Beschreibung und Standardwert.
*
* - 'agent' : Standardwert für Mitarbeiter ohne eigene Rolle (und für Rechte, die in einer älteren Rolle noch fehlen). Rechte, die bis zur
* Einführung des Katalogs jeder Mitarbeiter hatte, stehen auf true; neue Adminportal-Bereiche auf false.
* - 'area' : Bereich des Adminportals („Co-Admin“): Wer so ein Recht hat, sieht das Adminportal im Menü.
* - Echte Administratoren haben immer alle Rechte (has_permission()).
* Bewusst nicht delegierbar (immer nur echte Administratoren): Einstellungen, Backup, System-Update, Plugins, API-Schlüssel, Webhooks, osTicket-Import,
* Sicherheitsprüfung, Passkeys ein-/ausschalten und die Datenschutz-Löschfunktionen.
*/
function permission_catalog(): array
{
$p = fn(string $label, string $hint, bool $agent, bool $area = false) => ['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<string> 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<string> */
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;
}
+15
View File
@@ -630,6 +630,21 @@ function catalog_refresh_background(): void
*
* @return array<int, array{slug:string, name:string, installed:string, available:string}>
*/
/**
* Installiert alle Plugin-Updates aus dem Katalog (jedes einzeln, mit Prüfsumme und Signatur; ein Fehler stoppt die anderen nicht).
* @return array{updated:list<string>,failed:array<string,string>} 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);
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* „Von allen Geräten abmelden“: beendet alle Anmeldungen eines Mitarbeiters auf einmal.
* - Browser-Sitzungen (current_staff() prüft den Zeitpunkt „login_guard_revoke_<id>“, 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('<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Von allen Geräten abgemeldet</h2><p style="margin:0;color:#334155">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.</p>'));
}
return $res;
}
/** Aktive App-Anmeldungen eines Mitarbeiters (Android, Windows, Chrome) für die Anzeige. @return list<array<string,mixed>> */
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();
}