Upload via GUI (360 Dateien)
This commit is contained in:
@@ -52,7 +52,9 @@ function staff_login(array $staff): void
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['staff_id'] = $staff['id'];
|
||||
$_SESSION['staff_last_activity'] = time();
|
||||
$_SESSION['staff_login_at'] = time();
|
||||
staff_touch_activity(get_db(), (int) $staff['id']);
|
||||
login_guard_success($staff, 'web');
|
||||
}
|
||||
|
||||
function staff_logout(): void
|
||||
@@ -77,6 +79,11 @@ function current_staff(): ?array
|
||||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
|
||||
$stmt->execute([$_SESSION['staff_id']]);
|
||||
$staff = $stmt->fetch();
|
||||
// „Das war ich nicht“ (Anmelde-Wächter): ältere Browser-Sitzungen dieses Kontos sind ungültig
|
||||
if ($staff && login_guard_session_revoked((int) $staff['id'])) {
|
||||
unset($_SESSION['staff_id']);
|
||||
return null;
|
||||
}
|
||||
return $cache = ($staff ?: null);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ require_once __DIR__ . '/pseudo_cron.php';
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/smtp_mailer.php';
|
||||
require_once __DIR__ . '/mail_queue.php';
|
||||
require_once __DIR__ . '/login_guard.php';
|
||||
require_once __DIR__ . '/vacation.php';
|
||||
require_once __DIR__ . '/mailer.php';
|
||||
require_once __DIR__ . '/email_addresses.php';
|
||||
require_once __DIR__ . '/health.php';
|
||||
|
||||
@@ -311,13 +311,16 @@ function fcm_notify_staff(array $staffIds, array $data, ?int $departmentId = nul
|
||||
if (!$staffIds || !fcm_configured()) return;
|
||||
fcm_after_response(function () use ($staffIds, $data, $departmentId, $extraFilter) {
|
||||
$pdo = get_db();
|
||||
// Mitarbeiter im Urlaub: stattdessen bekommt ihre Vertretung die Meldung
|
||||
$staffIds = vacation_expand_ids($pdo, $staffIds);
|
||||
if (!$staffIds) return;
|
||||
// Rechte: Nur wer Zugriff auf die Abteilung des Tickets hat (und für diese Art von Push berechtigt ist), bekommt etwas
|
||||
if ($departmentId !== null) {
|
||||
$staffIds = fcm_staff_allowed($pdo, $staffIds, $departmentId, fcm_event_permission((string) ($data['type'] ?? '')), $extraFilter);
|
||||
if (!$staffIds) return;
|
||||
}
|
||||
$in = implode(',', array_fill(0, count($staffIds), '?'));
|
||||
$stmt = $pdo->prepare("SELECT d.id, d.token, d.staff_id FROM push_devices d JOIN staff s ON s.id = d.staff_id WHERE s.is_active = 1 AND d.staff_id IN ($in)");
|
||||
$stmt = $pdo->prepare("SELECT d.id, d.token, d.staff_id FROM push_devices d JOIN staff s ON s.id = d.staff_id WHERE s.is_active = 1 AND s.is_on_vacation = 0 AND d.staff_id IN ($in)");
|
||||
$stmt->execute($staffIds);
|
||||
// Einstellungen je Mitarbeiter: abgewählte Ereignisse und stille Zeiten überspringen
|
||||
$devices = array_values(array_filter($stmt->fetchAll(), fn($d) => fcm_wants(fcm_prefs($pdo, (int) $d['staff_id']), (string) ($data['type'] ?? ''))));
|
||||
|
||||
@@ -608,6 +608,18 @@ function staff_can_access_department(array $staff, int $departmentId): bool
|
||||
return $ids === null || in_array($departmentId, $ids, true);
|
||||
}
|
||||
|
||||
/** Ist der Mitarbeiter gerade abwesend (Urlaubsmodus)? Im Urlaub gibt es keine Benachrichtigungen (Mail, Push, Android). */
|
||||
function staff_is_on_vacation(int $staffId): bool
|
||||
{
|
||||
try {
|
||||
$q = get_db()->prepare('SELECT is_on_vacation FROM staff WHERE id = ?');
|
||||
$q->execute([$staffId]);
|
||||
return (int) $q->fetchColumn() === 1;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function staff_department_ids(array $staff): ?array
|
||||
{
|
||||
if ($staff['role'] === 'admin') {
|
||||
@@ -622,9 +634,56 @@ function staff_department_ids(array $staff): ?array
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$ids[] = (int) $row['department_id'];
|
||||
}
|
||||
foreach (staff_substitution_department_ids($staff) as $d) $ids[] = $d;
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Abteilungen, auf die ein Mitarbeiter nur als Vertretung Zugriff hat: Ist ein Kollege im Urlaub (Profil → Abwesenheit oder Plugin
|
||||
* „Urlaub und Vertretung“) und hat diesen Mitarbeiter als Vertretung eingetragen, gelten dessen Abteilungen (Haupt- und zusätzliche) mit,
|
||||
* solange die Abwesenheit dauert. Ketten (A vertritt B, B ist selbst im Urlaub und von C vertreten) folgen bis zu drei Stufen.
|
||||
* Ist der Abwesende Administrator (sieht alle Abteilungen), kommen nur die Abteilungen der Tickets dazu, die der Vertretung gerade
|
||||
* zugewiesen sind. Ohne die Spalten (Datenbank noch nicht aktualisiert) ergibt sich eine leere Liste.
|
||||
* @return list<int>
|
||||
*/
|
||||
function staff_substitution_department_ids(array $staff): array
|
||||
{
|
||||
if (($staff['role'] ?? '') === 'admin' || empty($staff['id'])) return [];
|
||||
$pdo = get_db();
|
||||
$out = [];
|
||||
try {
|
||||
$frontier = [(int) $staff['id']];
|
||||
$seen = $frontier;
|
||||
for ($level = 0; $level < 3 && $frontier; $level++) {
|
||||
$in = implode(',', array_fill(0, count($frontier), '?'));
|
||||
$q = $pdo->prepare("SELECT id, role, department_id FROM staff WHERE is_active = 1 AND is_on_vacation = 1 AND vacation_substitute_id IN ($in)");
|
||||
$q->execute($frontier);
|
||||
$next = [];
|
||||
$adminAbsent = false;
|
||||
foreach ($q->fetchAll() as $abs) {
|
||||
$aid = (int) $abs['id'];
|
||||
if (in_array($aid, $seen, true)) continue;
|
||||
$seen[] = $aid;
|
||||
$next[] = $aid;
|
||||
if ($abs['role'] === 'admin') { $adminAbsent = true; continue; }
|
||||
if (!empty($abs['department_id'])) $out[] = (int) $abs['department_id'];
|
||||
$d = $pdo->prepare('SELECT department_id FROM staff_department WHERE staff_id = ?');
|
||||
$d->execute([$aid]);
|
||||
foreach ($d->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did;
|
||||
}
|
||||
if ($adminAbsent) {
|
||||
$t = $pdo->prepare("SELECT DISTINCT department_id FROM tickets WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed')");
|
||||
$t->execute([(int) $staff['id']]);
|
||||
foreach ($t->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did;
|
||||
}
|
||||
$frontier = $next;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_unique(array_filter($out)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim Öffnen eines noch niemandem zugewiesenen Tickets wird es dem öffnenden Mitarbeiter zugewiesen (Einstellung "Beim Öffnen
|
||||
* automatisch zuweisen", Standard an). Nicht bei geschlossenen, gelösten, zusammengeführten oder als Spam markierten Tickets und nicht,
|
||||
@@ -2162,8 +2221,10 @@ function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, s
|
||||
->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]);
|
||||
fcm_notify_mention($ticket, $mentionedId, $byStaffId); // Push auf die Android-App
|
||||
// E-Mail an erwähnten Mitarbeiter
|
||||
// Im Urlaub geht die Mail an die Vertretung
|
||||
$mailTo = vacation_expand_ids($pdo, [$mentionedId]);
|
||||
$stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?');
|
||||
$stmt->execute([$mentionedId]);
|
||||
$stmt->execute([$mailTo ? $mailTo[0] : 0]);
|
||||
$mentioned = $stmt->fetch();
|
||||
if ($mentioned) {
|
||||
$by = $pdo->prepare('SELECT name FROM staff WHERE id=?');
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
/**
|
||||
* Anmelde-Wächter: merkt sich die Geräte und Orte, von denen sich Mitarbeiter anmelden, und warnt per Mail, wenn ein neues Gerät
|
||||
* oder ein neuer Ort auftaucht. Fehlversuche und Sperren stehen im Protokoll (staff/login_log.php). Wer eine Warnung bekommt und die
|
||||
* Anmeldung nicht selbst war, sichert das Konto über den Link „Das war ich nicht“ in der Mail (staff/login_alert.php).
|
||||
*
|
||||
* Ein Gerät ist ein Browser (Kennung in einem Cookie) bzw. eine App (Gerätename). Ein Ort ist das Land (nur wenn Cloudflare es
|
||||
* mitschickt) oder sonst das Netz der IP-Adresse; echte Standortdaten gibt es ohne Fremddienst nicht.
|
||||
* Fehler im Wächter dürfen eine Anmeldung nie verhindern: alles ist in try/catch gefasst.
|
||||
*/
|
||||
|
||||
const LOGIN_GUARD_COOKIE = 'dh_device';
|
||||
|
||||
function login_guard_enabled(): bool
|
||||
{
|
||||
return get_setting('login_guard', '1') === '1';
|
||||
}
|
||||
|
||||
function login_guard_max_attempts(): int
|
||||
{
|
||||
return max(3, min(20, (int) get_setting('login_guard_max_attempts', '5')));
|
||||
}
|
||||
|
||||
function login_guard_lock_minutes(): int
|
||||
{
|
||||
return max(5, min(240, (int) get_setting('login_guard_lock_minutes', '15')));
|
||||
}
|
||||
|
||||
function login_guard_retention_days(): int
|
||||
{
|
||||
return max(7, min(1095, (int) get_setting('login_guard_retention_days', '180')));
|
||||
}
|
||||
|
||||
/** Anzeigename des Geräts aus dem User-Agent, z. B. „Chrome auf Windows“. */
|
||||
function login_guard_describe_device(string $ua): string
|
||||
{
|
||||
$ua = trim($ua);
|
||||
if ($ua === '') return 'Unbekanntes Gerät';
|
||||
if (stripos($ua, 'okhttp') !== false || stripos($ua, 'DeskHub') !== false) return 'DeskHub-App';
|
||||
$browser = 'Browser';
|
||||
foreach (['Edg' => 'Edge', 'OPR' => 'Opera', 'Firefox' => 'Firefox', 'Chrome' => 'Chrome', 'Safari' => 'Safari'] as $needle => $name) {
|
||||
if (stripos($ua, $needle) !== false) { $browser = $name; break; }
|
||||
}
|
||||
$os = 'unbekanntem System';
|
||||
foreach (['Windows' => 'Windows', 'Android' => 'Android', 'iPhone' => 'iOS', 'iPad' => 'iOS', 'Macintosh' => 'macOS', 'Mac OS' => 'macOS', 'Linux' => 'Linux'] as $needle => $name) {
|
||||
if (stripos($ua, $needle) !== false) { $os = $name; break; }
|
||||
}
|
||||
return $browser . ' auf ' . $os;
|
||||
}
|
||||
|
||||
/** Ort der Anmeldung: [Schlüssel, Anzeigetext]. Land nur, wenn Cloudflare es über einen vertrauten Proxy mitschickt. */
|
||||
function login_guard_place(string $ip): array
|
||||
{
|
||||
if (request_from_trusted_proxy()) {
|
||||
$country = strtoupper(trim((string) ($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '')));
|
||||
if (preg_match('/^[A-Z]{2}$/', $country) && !in_array($country, ['XX', 'T1'], true)) return ['C:' . $country, 'Land ' . $country];
|
||||
}
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP)) return ['unknown', 'Unbekannt'];
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return ['local', 'Lokales Netz'];
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$p = explode('.', $ip);
|
||||
return ['N:' . $p[0] . '.' . $p[1], 'Netz ' . $p[0] . '.' . $p[1] . '.x.x'];
|
||||
}
|
||||
$bin = inet_pton($ip);
|
||||
$h = $bin === false ? ['0', '0'] : array_map(fn($w) => dechex($w), array_values(unpack('n2', substr($bin, 0, 4))));
|
||||
return ['N6:' . $h[0] . ':' . $h[1], 'Netz ' . $h[0] . ':' . $h[1] . '::/32'];
|
||||
}
|
||||
|
||||
/** Schreibt einen Protokolleintrag; gibt die Eintrags-Nummer zurück. */
|
||||
function login_guard_record(PDO $pdo, string $event, ?int $staffId, string $email, string $channel, string $detail = '', ?string $device = null): int
|
||||
{
|
||||
$ip = client_ip();
|
||||
[, $placeLabel] = login_guard_place($ip);
|
||||
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
$pdo->prepare('INSERT INTO login_events (staff_id, email, event, channel, ip, place, device, detail, created_at) VALUES (?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$staffId, mb_substr($email, 0, 255), $event, $channel, mb_substr($ip, 0, 64), mb_substr($placeLabel, 0, 100),
|
||||
mb_substr($device ?? login_guard_describe_device($ua), 0, 160), mb_substr($detail, 0, 255), db_now()]);
|
||||
if (random_int(1, 40) === 1) login_guard_purge($pdo);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
function login_guard_purge(PDO $pdo): void
|
||||
{
|
||||
$cut = date('Y-m-d H:i:s', time() - login_guard_retention_days() * 86400);
|
||||
$pdo->prepare('DELETE FROM login_events WHERE created_at < ?')->execute([$cut]);
|
||||
}
|
||||
|
||||
/** Kennung dieses Browsers (Cookie); legt sie an, wenn sie fehlt. */
|
||||
function login_guard_device_id(string $channel, ?string $deviceName): string
|
||||
{
|
||||
if ($channel === 'app') return 'app:' . mb_strtolower(trim((string) $deviceName)) . '|' . login_guard_describe_device((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
$id = (string) ($_COOKIE[LOGIN_GUARD_COOKIE] ?? '');
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $id)) $id = bin2hex(random_bytes(16));
|
||||
if (!headers_sent() && PHP_SAPI !== 'cli') {
|
||||
setcookie(LOGIN_GUARD_COOKIE, $id, ['expires' => time() + 400 * 86400, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']), 'httponly' => true, 'samesite' => 'Lax']);
|
||||
}
|
||||
$_COOKIE[LOGIN_GUARD_COOKIE] = $id;
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach erfolgreicher Anmeldung aufrufen (Web: staff_login(); App: api/staff/v1/login.php).
|
||||
* Merkt Gerät und Ort, schreibt das Protokoll und warnt bei Neuem. Gibt das Ergebnis zurück: first | known | new_device | new_place | off.
|
||||
*/
|
||||
function login_guard_success(array $staff, string $channel = 'web', ?string $deviceName = null): string
|
||||
{
|
||||
if (!login_guard_enabled()) return 'off';
|
||||
try {
|
||||
$pdo = get_db();
|
||||
$sid = (int) $staff['id'];
|
||||
$now = db_now();
|
||||
$ip = client_ip();
|
||||
[$placeKey, $placeLabel] = login_guard_place($ip);
|
||||
$label = $channel === 'app' && trim((string) $deviceName) !== '' ? mb_substr(trim((string) $deviceName), 0, 120) : login_guard_describe_device((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
$hash = hash('sha256', $sid . '|' . login_guard_device_id($channel, $deviceName));
|
||||
|
||||
$st = $pdo->prepare('SELECT id FROM login_devices WHERE staff_id = ? AND device_hash = ?');
|
||||
$st->execute([$sid, $hash]);
|
||||
$devId = $st->fetchColumn();
|
||||
$st = $pdo->prepare('SELECT id FROM login_places WHERE staff_id = ? AND place_key = ?');
|
||||
$st->execute([$sid, $placeKey]);
|
||||
$plId = $st->fetchColumn();
|
||||
$st = $pdo->prepare('SELECT COUNT(*) FROM login_devices WHERE staff_id = ?');
|
||||
$st->execute([$sid]);
|
||||
$hadAny = (int) $st->fetchColumn() > 0;
|
||||
|
||||
if ($devId) {
|
||||
$pdo->prepare('UPDATE login_devices SET last_seen = ?, last_ip = ?, place = ?, label = ? WHERE id = ?')->execute([$now, $ip, $placeLabel, $label, $devId]);
|
||||
} else {
|
||||
$pdo->prepare('INSERT INTO login_devices (staff_id, device_hash, label, channel, first_ip, last_ip, place, first_seen, last_seen) VALUES (?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$sid, $hash, $label, $channel, $ip, $ip, $placeLabel, $now, $now]);
|
||||
}
|
||||
if ($plId) {
|
||||
$pdo->prepare('UPDATE login_places SET last_seen = ? WHERE id = ?')->execute([$now, $plId]);
|
||||
} else {
|
||||
$pdo->prepare('INSERT INTO login_places (staff_id, place_key, label, first_seen, last_seen) VALUES (?,?,?,?,?)')->execute([$sid, $placeKey, $placeLabel, $now, $now]);
|
||||
}
|
||||
|
||||
$email = (string) $staff['email'];
|
||||
if (!$hadAny) {
|
||||
login_guard_record($pdo, 'success', $sid, $email, $channel, 'Erste Anmeldung, Gerät und Ort gemerkt', $label);
|
||||
return 'first';
|
||||
}
|
||||
if (!$devId) {
|
||||
$eid = login_guard_record($pdo, 'new_device', $sid, $email, $channel, 'Neues Gerät' . (!$plId ? ' und neuer Ort' : ''), $label);
|
||||
login_guard_alert($pdo, $staff, $eid, 'Neues Gerät', $label, $placeLabel, $ip, $channel);
|
||||
return 'new_device';
|
||||
}
|
||||
if (!$plId && get_setting('login_guard_warn_place', '1') === '1') {
|
||||
$eid = login_guard_record($pdo, 'new_place', $sid, $email, $channel, 'Neuer Ort', $label);
|
||||
login_guard_alert($pdo, $staff, $eid, 'Neuer Ort', $label, $placeLabel, $ip, $channel);
|
||||
return 'new_place';
|
||||
}
|
||||
login_guard_record($pdo, 'success', $sid, $email, $channel, '', $label);
|
||||
return 'known';
|
||||
} catch (Throwable $e) {
|
||||
error_log('login_guard_success: ' . $e->getMessage());
|
||||
return 'off';
|
||||
}
|
||||
}
|
||||
|
||||
/** Warn-Mail an den Mitarbeiter mit einmaligem Link „Das war ich nicht“. */
|
||||
function login_guard_alert(PDO $pdo, array $staff, int $eventId, string $reason, string $device, string $place, string $ip, string $channel): void
|
||||
{
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$pdo->prepare('UPDATE login_events SET token_hash = ? WHERE id = ?')->execute([hash('sha256', $token), $eventId]);
|
||||
$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>'
|
||||
. '<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>'
|
||||
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">Ort</td><td>' . e($place) . '</td></tr>'
|
||||
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">IP-Adresse</td><td>' . e($ip) . '</td></tr></table>'
|
||||
. '<p style="margin:0 0 16px;color:#334155"><strong>Das waren Sie?</strong> Dann müssen Sie nichts tun.</p>'
|
||||
. '<p style="margin:0 0 16px;color:#334155"><strong>Das waren Sie nicht?</strong> Dann sichern Sie Ihr Konto sofort: Das Passwort wird ungültig, alle App-Anmeldungen werden beendet und die Administratoren erfahren davon.</p>'
|
||||
. '<p style="margin:0 0 8px"><a href="' . e($link) . '" style="display:inline-block;background:#dc2626;color:#ffffff;text-decoration:none;padding:11px 20px;border-radius:8px;font-weight:600">Das war ich nicht – Konto sichern</a></p>'
|
||||
. '<p style="margin:12px 0 0;font-size:12px;color:#64748b">Der Link gilt 7 Tage und kann einmal verwendet werden.</p>';
|
||||
send_mail((string) $staff['email'], 'Neue Anmeldung bei Ihrem Konto (' . $reason . ')', email_wrap($inner));
|
||||
}
|
||||
|
||||
/** Fehlversuch ins Protokoll. $staff ist null, wenn es die Adresse nicht gibt. */
|
||||
function login_guard_failed(?array $staff, string $email, string $channel, string $detail): void
|
||||
{
|
||||
if (!login_guard_enabled()) return;
|
||||
try {
|
||||
login_guard_record(get_db(), 'failed', $staff ? (int) $staff['id'] : null, $email, $channel, $detail);
|
||||
} catch (Throwable $e) {
|
||||
error_log('login_guard_failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Konto-Sperre hat gegriffen: einmal je Sperrzeit ins Protokoll, Mail an den Kontoinhaber und die Administratoren. */
|
||||
function login_guard_locked(string $email, string $channel): void
|
||||
{
|
||||
if (!login_guard_enabled()) return;
|
||||
try {
|
||||
$pdo = get_db();
|
||||
$cut = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60);
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM login_events WHERE event = 'locked' AND LOWER(email) = LOWER(?) AND created_at >= ?");
|
||||
$st->execute([$email, $cut]);
|
||||
if ((int) $st->fetchColumn() > 0) return;
|
||||
$st = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
|
||||
$st->execute([$email]);
|
||||
$staff = $st->fetch() ?: null;
|
||||
login_guard_record($pdo, 'locked', $staff ? (int) $staff['id'] : null, $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>'
|
||||
. '<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>';
|
||||
if ($staff) send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg));
|
||||
login_guard_notify_admins('Anmeldung gesperrt: ' . $email, $msg, $staff ? (string) $staff['email'] : null);
|
||||
} catch (Throwable $e) {
|
||||
error_log('login_guard_locked: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Mail an alle aktiven Administratoren (abschaltbar). $skipEmail: dieser Empfänger hat die Mail schon bekommen. */
|
||||
function login_guard_notify_admins(string $subject, string $innerHtml, ?string $skipEmail = null): void
|
||||
{
|
||||
if (get_setting('login_guard_notify_admins', '1') !== '1') return;
|
||||
foreach (get_db()->query("SELECT email FROM staff WHERE role = 'admin' AND is_active = 1")->fetchAll(PDO::FETCH_COLUMN) as $adminMail) {
|
||||
if ($skipEmail !== null && strcasecmp((string) $adminMail, $skipEmail) === 0) continue;
|
||||
if (filter_var($adminMail, FILTER_VALIDATE_EMAIL)) send_mail((string) $adminMail, $subject, email_wrap($innerHtml));
|
||||
}
|
||||
}
|
||||
|
||||
/** Alarm-Link prüfen: gibt die Protokollzeile zurück, wenn der Link gültig ist (7 Tage, einmal verwendbar). */
|
||||
function login_guard_find_alert(PDO $pdo, string $token): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{48}$/', $token)) return null;
|
||||
$st = $pdo->prepare('SELECT * FROM login_events WHERE token_hash = ? AND token_used = 0 AND created_at >= ?');
|
||||
$st->execute([hash('sha256', $token), date('Y-m-d H:i:s', time() - 7 * 86400)]);
|
||||
return $st->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Das war ich nicht“: Passwort ungültig machen, App-Anmeldungen beenden, alle gemerkten Geräte und Orte vergessen, laufende
|
||||
* Browser-Sitzungen beenden (current_staff() prüft den Zeitpunkt), Protokoll und Mail an die Administratoren.
|
||||
*/
|
||||
function login_guard_revoke(PDO $pdo, array $event): bool
|
||||
{
|
||||
$sid = (int) ($event['staff_id'] ?? 0);
|
||||
$st = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
|
||||
$st->execute([$sid]);
|
||||
$staff = $st->fetch();
|
||||
if (!$staff) return false;
|
||||
$pdo->prepare('UPDATE login_events SET token_used = 1 WHERE id = ?')->execute([(int) $event['id']]);
|
||||
$pdo->prepare('UPDATE staff SET password_hash = ? WHERE id = ?')->execute([password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), $sid]);
|
||||
$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]);
|
||||
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>'
|
||||
. '<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>'));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Nach „Das war ich nicht“ sind ältere Browser-Sitzungen ungültig. */
|
||||
function login_guard_session_revoked(int $staffId): bool
|
||||
{
|
||||
$revoked = (int) get_setting('login_guard_revoke_' . $staffId, '0');
|
||||
return $revoked > 0 && (int) ($_SESSION['staff_login_at'] ?? 0) <= $revoked;
|
||||
}
|
||||
|
||||
/** Namen der Ereignisarten für die Anzeige. */
|
||||
function login_guard_event_labels(): array
|
||||
{
|
||||
return [
|
||||
'success' => 'Angemeldet',
|
||||
'new_device' => 'Neues Gerät',
|
||||
'new_place' => 'Neuer Ort',
|
||||
'failed' => 'Fehlversuch',
|
||||
'locked' => 'Gesperrt',
|
||||
'revoked' => 'Konto gesichert',
|
||||
];
|
||||
}
|
||||
@@ -94,6 +94,7 @@ function send_mail_now(string $to, string $subject, string $htmlBody, ?string $f
|
||||
}
|
||||
|
||||
$headers = "MIME-Version: 1.0\r\n";
|
||||
$headers .= "X-DeskHub-Mail: 1\r\n"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket
|
||||
$headers .= "Content-type: text/html; charset=UTF-8\r\n";
|
||||
$headers .= "From: {$fromHeader}\r\n";
|
||||
|
||||
@@ -456,7 +457,7 @@ function notify_staff_new_ticket(array $ticket): void
|
||||
|
||||
if ($deptId) {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT DISTINCT email FROM staff WHERE is_active = 1 AND (
|
||||
"SELECT DISTINCT id FROM staff WHERE is_active = 1 AND (
|
||||
role = 'admin'
|
||||
OR department_id = ?
|
||||
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1)
|
||||
@@ -464,9 +465,10 @@ function notify_staff_new_ticket(array $ticket): void
|
||||
);
|
||||
$stmt->execute([$deptId, $deptId]);
|
||||
} else {
|
||||
$stmt = $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'");
|
||||
$stmt = $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'");
|
||||
}
|
||||
$recipients = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
// Mitarbeiter im Urlaub: stattdessen bekommt ihre Vertretung die Mail
|
||||
$recipients = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $stmt->fetchAll(PDO::FETCH_COLUMN)));
|
||||
if (!$recipients) return;
|
||||
|
||||
[$subject, $body] = render_email_template('new_ticket_department', [
|
||||
@@ -487,7 +489,8 @@ function notify_staff_new_ticket(array $ticket): void
|
||||
/** Benachrichtigt alle aktiven Admins per E-Mail, wenn ein Ticket neu als überfällig erkannt wurde */
|
||||
function notify_admins_overdue_ticket(array $ticket): void
|
||||
{
|
||||
$admins = get_db()->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$pdo = get_db();
|
||||
$admins = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN)));
|
||||
if (!$admins) return;
|
||||
|
||||
[$subject, $body] = render_email_template('ticket_overdue', [
|
||||
@@ -502,3 +505,92 @@ function notify_admins_overdue_ticket(array $ticket): void
|
||||
email_send_and_log($email, $subject, $body, null, 'Ticket-überfällig-Mail', $ticket['ticket_number']);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SLA-Überschreitung: nur an Betroffene ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wer bei einer SLA-Überschreitung eine Mail bekommt: der Bearbeiter des Tickets (ist er im Urlaub, seine Vertretung), sonst die
|
||||
* Mitarbeiter der Abteilung (Hauptabteilung oder zusätzliche Abteilung mit Benachrichtigung), ohne zugewiesenes Ticket und ohne Abteilungs-
|
||||
* 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
|
||||
*/
|
||||
function sla_overdue_recipients(PDO $pdo, array $ticket): array
|
||||
{
|
||||
$emails = [];
|
||||
$assignee = (int) ($ticket['assigned_staff_id'] ?? 0);
|
||||
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;
|
||||
if ($target !== null) {
|
||||
$q = $pdo->prepare('SELECT email FROM staff WHERE id = ? AND is_active = 1 AND is_on_vacation = 0');
|
||||
$q->execute([$target]);
|
||||
$e = $q->fetchColumn();
|
||||
if ($e) $emails[] = (string) $e;
|
||||
}
|
||||
}
|
||||
if (!$emails && !empty($ticket['department_id'])) {
|
||||
$q = $pdo->prepare(
|
||||
"SELECT DISTINCT id FROM staff WHERE is_active = 1 AND (department_id = ?
|
||||
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1))"
|
||||
);
|
||||
$q->execute([(int) $ticket['department_id'], (int) $ticket['department_id']]);
|
||||
$emails = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $q->fetchAll(PDO::FETCH_COLUMN)));
|
||||
}
|
||||
if (!$emails) {
|
||||
$emails = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN)));
|
||||
}
|
||||
$own = mail_own_system_addresses($pdo);
|
||||
$out = [];
|
||||
foreach ($emails as $e) {
|
||||
$e = trim($e);
|
||||
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL) && !in_array(strtolower($e), $own, true)) $out[strtolower($e)] = $e;
|
||||
}
|
||||
return array_values($out);
|
||||
}
|
||||
|
||||
/** Sendet die SLA-Mail (Vorlage „ticket_overdue“) an die Betroffenen. Liefert die Zahl der Empfänger. */
|
||||
function notify_overdue_ticket_staff(array $ticket): int
|
||||
{
|
||||
$pdo = get_db();
|
||||
$recipients = sla_overdue_recipients($pdo, $ticket);
|
||||
if (!$recipients) return 0;
|
||||
[$subject, $body] = render_email_template('ticket_overdue', [
|
||||
'ticket_number' => $ticket['ticket_number'],
|
||||
'subject' => e($ticket['subject']),
|
||||
'department_name' => e($ticket['department_name'] ?? ''),
|
||||
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
|
||||
], "SLA überschritten: Ticket #{ticket_number}", email_ticket_overdue_default_body());
|
||||
$body = email_wrap($body);
|
||||
foreach ($recipients as $email) email_send_and_log($email, $subject, $body, null, 'SLA-überschritten-Mail', $ticket['ticket_number']);
|
||||
return count($recipients);
|
||||
}
|
||||
|
||||
// ── Schutz vor Mail-Schleifen ────────────────────────────────────────────────
|
||||
|
||||
/** Adressen (klein geschrieben), von denen DeskHub selbst Mails verschickt: Standard-Absender und Absender-/Abteilungsadressen. */
|
||||
function mail_own_system_addresses(PDO $pdo): array
|
||||
{
|
||||
$out = [];
|
||||
$add = function ($a) use (&$out) {
|
||||
$a = strtolower(trim((string) $a));
|
||||
if ($a !== '' && str_contains($a, '@')) $out[$a] = true;
|
||||
};
|
||||
$add(get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''));
|
||||
$add(config()['mail']['from_email'] ?? '');
|
||||
try {
|
||||
foreach ($pdo->query('SELECT email FROM email_addresses')->fetchAll(PDO::FETCH_COLUMN) as $a) $add($a);
|
||||
foreach ($pdo->query('SELECT email FROM departments')->fetchAll(PDO::FETCH_COLUMN) as $a) $add($a);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
return array_keys($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist das eine Mail, die DeskHub selbst verschickt hat (und die über ein abgerufenes Postfach zurückkommt)? Daraus darf kein Ticket werden.
|
||||
* Erkennung: unsere Kopfzeile X-DeskHub-Mail oder ein Absender, der eine unserer eigenen Systemadressen ist.
|
||||
*/
|
||||
function mail_is_own_system_mail(PDO $pdo, string $fromEmail, string $rawHeader = ''): bool
|
||||
{
|
||||
if ($rawHeader !== '' && preg_match('/^X-DeskHub-Mail:/mi', $rawHeader)) return true;
|
||||
return in_array(strtolower(trim($fromEmail)), mail_own_system_addresses($pdo), true);
|
||||
}
|
||||
|
||||
@@ -119,13 +119,19 @@ function notify_team_assignment(PDO $pdo, array $ticket, int $teamId, ?int $acto
|
||||
$team = $stmt->fetch();
|
||||
if (!$team || !$team['is_active'] || $team['no_alerts']) return 0;
|
||||
|
||||
$members = $pdo->prepare('SELECT s.id, s.email FROM team_members tm JOIN staff s ON s.id = tm.staff_id WHERE tm.team_id = ? AND s.is_active = 1');
|
||||
$members = $pdo->prepare('SELECT s.id FROM team_members tm JOIN staff s ON s.id = tm.staff_id WHERE tm.team_id = ? AND s.is_active = 1');
|
||||
$members->execute([$teamId]);
|
||||
// Mitglieder im Urlaub: stattdessen ihre Vertretung
|
||||
$memberRows = [];
|
||||
foreach (vacation_expand_ids($pdo, $members->fetchAll(PDO::FETCH_COLUMN)) as $mid) {
|
||||
$em = staff_emails_by_ids($pdo, [$mid]);
|
||||
if ($em) $memberRows[] = ['id' => $mid, 'email' => $em[0]];
|
||||
}
|
||||
$sent = 0;
|
||||
$subject = 'Ticket #' . $ticket['ticket_number'] . ' wurde Team „' . $team['name'] . '“ zugewiesen';
|
||||
$body = '<p>Das Ticket <strong>#' . e($ticket['ticket_number']) . '</strong> („' . e($ticket['subject']) . '“) wurde dem Team <strong>'
|
||||
. e($team['name']) . '</strong> zugewiesen.</p>';
|
||||
foreach ($members->fetchAll() as $m) {
|
||||
foreach ($memberRows as $m) {
|
||||
if ($actorId !== null && (int) $m['id'] === $actorId) continue;
|
||||
if (send_mail($m['email'], $subject, $body)) $sent++;
|
||||
}
|
||||
|
||||
@@ -34,13 +34,15 @@ function plugin_known_permissions(): array
|
||||
'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API',
|
||||
'api.public' => 'Öffentliche Webhook-Adresse (zum Beispiel für Discord oder Telegram); das Plugin prüft die Echtheit der Anfragen selbst',
|
||||
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
|
||||
'auth.customer' => 'Kunden über einen externen Dienst anmelden und Kundenkonten anlegen (zum Beispiel authentik): Knopf auf der Anmeldeseite des Kundenportals. ACHTUNG: Das Plugin entscheidet selbst, wer sich anmelden darf und ob Konten entstehen.',
|
||||
'auth.login' => 'Mitarbeiter über einen externen Dienst anmelden (zum Beispiel authentik): Knopf auf der Anmeldeseite und eigene Anmelde-Adresse. ACHTUNG: Das Plugin entscheidet selbst, wer sich anmelden darf.',
|
||||
];
|
||||
}
|
||||
|
||||
/** Gemeinsamer Zustand (Hooks, Menüs, aktuell ausgeführtes Plugin). */
|
||||
function &plugin_state(): array
|
||||
{
|
||||
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
|
||||
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'logins' => [], 'login_loggedin' => [], 'customer_logins' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
|
||||
return $state;
|
||||
}
|
||||
|
||||
@@ -128,6 +130,8 @@ function plugin_filter_permission(string $hook): ?string
|
||||
{
|
||||
if ($hook === 'ticket.view.sidebar' || $hook === 'staff.page.top') return 'ui.sidebar';
|
||||
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
|
||||
if ($hook === 'staff.login.buttons') return 'auth.login'; // Knöpfe auf der Mitarbeiter-Anmeldeseite
|
||||
if ($hook === 'customer.login.buttons') return 'auth.customer'; // Knöpfe auf der Kunden-Anmeldeseite
|
||||
if ($hook === 'backup.list.rows') return 'ui.menu'; // wer einen Reiter unter „Backup“ anbieten darf, darf auch die Liste der Backups ergänzen
|
||||
return null;
|
||||
}
|
||||
@@ -182,6 +186,37 @@ function plugin_register_webhook(string $endpoint, callable $handler): void
|
||||
$state['webhooks'][$slug][$endpoint] = $handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert eine Anmelde-Adresse für Mitarbeiter: GET /staff/plugin_login.php?p=<slug>&e=<name>. Gedacht für externe Anmeldedienste (OpenID Connect, SAML …).
|
||||
* Der Handler erhält ['query' => $_GET, 'ip' => …] (die Sitzung $_SESSION steht zur Verfügung) und gibt eines davon zurück:
|
||||
* ['redirect' => 'https://…'] weiter zum Anmeldedienst (nur https, oder http zu localhost)
|
||||
* ['staff_id' => 12] dieser Mitarbeiter ist geprüft: DeskHub meldet ihn an (nur aktive Konten, mit Eintrag im Admin-Protokoll)
|
||||
* ['error' => 'Text'] zurück zur Anmeldeseite mit dieser Meldung
|
||||
* Den Link zum Starten baut plugin_login_url(). Wer anmelden darf, entscheidet allein das Plugin (Berechtigung auth.login).
|
||||
*
|
||||
* Mit $whenLoggedIn = true läuft die Adresse auch für bereits angemeldete Mitarbeiter (zum Beispiel, um ein Konto beim Anmeldedienst zu verknüpfen).
|
||||
* Der Handler bekommt dann zusätzlich ['staff' => <angemeldeter Mitarbeiter>] und darf nur zurückgeben:
|
||||
* ['redirect' => 'https://…'] weiter zum Anmeldedienst
|
||||
* ['local' => '/staff/…', 'notice' => 'Text'] zurück in den Mitarbeiterbereich, mit Hinweis
|
||||
* ['error' => 'Text', 'back' => '/staff/…'] zurück mit Fehlermeldung
|
||||
* Ein anderer Mitarbeiter kann so nie angemeldet werden (staff_id wird dann abgelehnt).
|
||||
*/
|
||||
function plugin_register_login(string $endpoint, callable $handler, bool $whenLoggedIn = false): void
|
||||
{
|
||||
$state = &plugin_state();
|
||||
$slug = $state['current'];
|
||||
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
|
||||
if (!plugin_permission_allowed('auth.login')) return;
|
||||
$state['logins'][$slug][$endpoint] = $handler;
|
||||
$state['login_loggedin'][$slug][$endpoint] = $whenLoggedIn;
|
||||
}
|
||||
|
||||
/** Adresse (ohne Domain) einer mit plugin_register_login() angemeldeten Anmelde-Adresse. */
|
||||
function plugin_login_url(string $slug, string $endpoint): string
|
||||
{
|
||||
return '/staff/plugin_login.php?p=' . rawurlencode($slug) . '&e=' . rawurlencode($endpoint);
|
||||
}
|
||||
|
||||
function plugin_register_api(string $endpoint, callable $handler, ?string $permission = null): void
|
||||
{
|
||||
if (!plugin_permission_allowed('api.endpoints')) return;
|
||||
@@ -983,3 +1018,79 @@ function catalog_thumb(string $kind, array $c): string
|
||||
}
|
||||
return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $c['name'], 0, 1))) . '</div>';
|
||||
}
|
||||
|
||||
// ── Anmeldung von Kunden über Plugins (Berechtigung auth.customer) ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registriert eine Anmelde-Adresse für Kunden: GET /plugin_login.php?p=<slug>&e=<name> (öffentlich, Kundenportal).
|
||||
* Der Handler erhält ['query' => $_GET, 'ip' => …] und gibt zurück:
|
||||
* ['redirect' => 'https://…'] weiter zum Anmeldedienst (nur https, oder http zu localhost)
|
||||
* ['customer' => ['email' => …, 'name' => …, 'create' => true|false, 'via' => 'Dienst']]
|
||||
* diese Person ist vom Dienst bestätigt: DeskHub meldet das Kundenkonto mit dieser
|
||||
* E-Mail-Adresse an; gibt es keines und 'create' ist true, wird es angelegt
|
||||
* ['error' => 'Text'] zurück zur Kunden-Anmeldeseite mit dieser Meldung
|
||||
* Die Rückkehr vom Dienst darf auch über eine mit plugin_register_login() angemeldete Adresse kommen, wenn das Plugin beide Berechtigungen hat.
|
||||
* Das Plugin trägt die Verantwortung, dass die E-Mail-Adresse wirklich bestätigt ist.
|
||||
*/
|
||||
function plugin_register_customer_login(string $endpoint, callable $handler): void
|
||||
{
|
||||
$state = &plugin_state();
|
||||
$slug = $state['current'];
|
||||
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
|
||||
if (!plugin_permission_allowed('auth.customer')) return;
|
||||
$state['customer_logins'][$slug][$endpoint] = $handler;
|
||||
}
|
||||
|
||||
/** Adresse (ohne Domain) einer mit plugin_register_customer_login() angemeldeten Kunden-Anmelde-Adresse. */
|
||||
function plugin_customer_login_url(string $slug, string $endpoint): string
|
||||
{
|
||||
return '/plugin_login.php?p=' . rawurlencode($slug) . '&e=' . rawurlencode($endpoint);
|
||||
}
|
||||
|
||||
/** Gilt die Weiterleitung zu einem Anmeldedienst? Nur https (oder http zu localhost zum Testen), keine Zeichen < Leerzeichen. */
|
||||
function plugin_login_external_url_ok(string $url): bool
|
||||
{
|
||||
if ($url === '' || strlen($url) > 4000 || preg_match('/[\x00-\x20]/', $url)) return false;
|
||||
$p = parse_url($url);
|
||||
if (!$p || empty($p['host'])) return false;
|
||||
$scheme = $p['scheme'] ?? '';
|
||||
return $scheme === 'https' || ($scheme === 'http' && in_array($p['host'], ['localhost', '127.0.0.1', '[::1]'], true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet einen vom Plugin bestätigten Kunden an (und legt das Konto an, wenn erlaubt). Endet immer mit einer Weiterleitung.
|
||||
* Ein Plugin ohne Berechtigung auth.customer kommt hier nicht durch.
|
||||
* @param array{email?:string,name?:string,create?:bool,via?:string} $c
|
||||
*/
|
||||
function plugin_customer_login_finish(string $slug, array $c): never
|
||||
{
|
||||
$fail = function (string $message): never {
|
||||
flash('error', $message);
|
||||
redirect('/login.php');
|
||||
};
|
||||
if (!in_array('auth.customer', plugin_state()['perms'][$slug] ?? [], true)) $fail('Diese Anmeldung ist nicht erlaubt.');
|
||||
$email = strtolower(trim((string) ($c['email'] ?? '')));
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 150) $fail('Die E-Mail-Adresse ist ungültig.');
|
||||
if (is_banned($email)) $fail('Mit dieser E-Mail-Adresse ist keine Anmeldung möglich.');
|
||||
$pdo = get_db();
|
||||
$st = $pdo->prepare('SELECT * FROM users WHERE LOWER(email) = ?');
|
||||
$st->execute([$email]);
|
||||
$user = $st->fetch();
|
||||
$created = false;
|
||||
$via = mb_substr((string) ($c['via'] ?? $slug), 0, 60);
|
||||
if (!$user) {
|
||||
if (empty($c['create'])) $fail('Für diese E-Mail-Adresse gibt es kein Kundenkonto.');
|
||||
$name = trim((string) preg_replace('/\s+/u', ' ', strip_tags((string) ($c['name'] ?? ''))));
|
||||
if ($name === '') $name = ucfirst(explode('@', $email)[0]);
|
||||
$name = mb_substr($name, 0, 120);
|
||||
// Ein Passwort gibt es nicht: Wer eines möchte, nutzt „Passwort vergessen“
|
||||
$pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)')
|
||||
->execute([$name, $email, password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), db_now()]);
|
||||
$user = ['id' => (int) $pdo->lastInsertId(), 'name' => $name, 'email' => $email];
|
||||
audit_log(null, 'Kundenkonto über ' . $via . ' angelegt: ' . mb_substr($email, 0, 120));
|
||||
$created = true;
|
||||
}
|
||||
customer_login($user);
|
||||
if ($created) flash('success', 'Willkommen! Ihr Konto wurde erstellt.');
|
||||
redirect('/home.php');
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ class SmtpMailer
|
||||
$headers[] = "From: {$from}";
|
||||
$headers[] = "Subject: {$encodedSubject}";
|
||||
$headers[] = "MIME-Version: 1.0";
|
||||
$headers[] = "X-DeskHub-Mail: 1"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket
|
||||
$headers[] = "Content-Type: text/html; charset=UTF-8";
|
||||
$headers[] = "Content-Transfer-Encoding: base64";
|
||||
return implode("\r\n", $headers) . "\r\n";
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
/**
|
||||
* Abwesenheit im Profil: Offene Tickets an die Vertretung weitergeben und nach dem Urlaub zurückgeben.
|
||||
* Das Plugin „Urlaub und Vertretung“ macht dasselbe nach Datum; ist dort eine Abwesenheit aktiv, übernimmt das Plugin und der Server hält sich heraus.
|
||||
* Welche Tickets weitergegeben wurden, steht in vacation_moves: Zurückgegeben wird nur, was noch bei der Vertretung liegt und offen ist
|
||||
* (hat jemand ein Ticket inzwischen umgehängt oder erledigt, bleibt es so).
|
||||
*/
|
||||
|
||||
/** Wer arbeitet an Stelle von $staffId? Folgt Vertretungsketten (höchstens drei Stufen, ohne Schleifen); null ohne nutzbare Vertretung. */
|
||||
function vacation_target(PDO $pdo, int $staffId): ?int
|
||||
{
|
||||
$get = $pdo->prepare('SELECT id, is_active, is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?');
|
||||
$get->execute([$staffId]);
|
||||
$row = $get->fetch();
|
||||
if (!$row || !(int) $row['is_on_vacation']) return null;
|
||||
$seen = [$staffId];
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$sub = (int) ($row['vacation_substitute_id'] ?? 0);
|
||||
if ($sub <= 0 || in_array($sub, $seen, true)) return null;
|
||||
$get->execute([$sub]);
|
||||
$row = $get->fetch();
|
||||
if (!$row || !(int) $row['is_active']) return null;
|
||||
if (!(int) $row['is_on_vacation']) return $sub;
|
||||
$seen[] = $sub;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Übernimmt das Plugin „Urlaub und Vertretung“ gerade diese Abwesenheit? */
|
||||
function vacation_plugin_owns(PDO $pdo, int $staffId): bool
|
||||
{
|
||||
try {
|
||||
$q = $pdo->prepare("SELECT COUNT(*) FROM plugin_vertretung_absences WHERE staff_id = ? AND status = 'active'");
|
||||
$q->execute([$staffId]);
|
||||
return (int) $q->fetchColumn() > 0;
|
||||
} catch (Throwable $e) {
|
||||
return false; // Plugin nicht installiert
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt die offenen Tickets von $staffId an die Vertretung weiter (die Abwesenheit muss schon eingetragen sein). Liefert die Zahl. */
|
||||
function vacation_hand_over(PDO $pdo, int $staffId): int
|
||||
{
|
||||
if (vacation_plugin_owns($pdo, $staffId)) return 0;
|
||||
$to = vacation_target($pdo, $staffId);
|
||||
if ($to === null || $to === $staffId) return 0;
|
||||
$name = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $staffId)->fetchColumn();
|
||||
$toName = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $to)->fetchColumn();
|
||||
$t = $pdo->prepare("SELECT id, ticket_number, subject FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed') ORDER BY id");
|
||||
$t->execute([$staffId]);
|
||||
$tickets = $t->fetchAll();
|
||||
foreach ($tickets as $row) {
|
||||
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$to, $row['id']]);
|
||||
$pdo->prepare('INSERT INTO vacation_moves (ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?)')->execute([$row['id'], $staffId, $to, db_now()]);
|
||||
log_activity($pdo, (int) $row['id'], $staffId, 'An die Vertretung ' . $toName . ' weitergegeben (' . $name . ' ist abwesend)');
|
||||
}
|
||||
if ($tickets) {
|
||||
$list = '';
|
||||
foreach (array_slice($tickets, 0, 15) as $row) $list .= '<li>#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '</li>';
|
||||
$mail = $pdo->prepare('SELECT email FROM staff WHERE id = ?');
|
||||
$mail->execute([$to]);
|
||||
$addr = (string) $mail->fetchColumn();
|
||||
if ($addr !== '') send_mail($addr, 'Vertretung für ' . $name . ': ' . count($tickets) . ' Tickets', email_wrap(
|
||||
'<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Sie vertreten ' . e($name) . '</h2>'
|
||||
. '<p style="margin:0 0 12px;color:#334155">' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen. Neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen, solange die Abwesenheit dauert.</p>'
|
||||
. '<ul style="color:#334155">' . $list . '</ul>'));
|
||||
}
|
||||
return count($tickets);
|
||||
}
|
||||
|
||||
/** Gibt weitergegebene Tickets zurück, die noch bei der Vertretung liegen und offen sind. Liefert die Zahl der zurückgegebenen Tickets. */
|
||||
function vacation_take_back(PDO $pdo, int $staffId): int
|
||||
{
|
||||
if (vacation_plugin_owns($pdo, $staffId)) return 0;
|
||||
$m = $pdo->prepare('SELECT m.id, m.ticket_id, m.to_staff_id, t.assigned_staff_id, t.status FROM vacation_moves m JOIN tickets t ON t.id = m.ticket_id WHERE m.from_staff_id = ? AND m.returned_at IS NULL ORDER BY m.id');
|
||||
$m->execute([$staffId]);
|
||||
$back = 0;
|
||||
$name = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $staffId)->fetchColumn();
|
||||
foreach ($m->fetchAll() as $row) {
|
||||
$stillThere = (int) $row['assigned_staff_id'] === (int) $row['to_staff_id'] && !in_array($row['status'], ['resolved', 'closed'], true);
|
||||
if ($stillThere) {
|
||||
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$staffId, $row['ticket_id']]);
|
||||
log_activity($pdo, (int) $row['ticket_id'], $staffId, 'Zurück an ' . $name . ' (Abwesenheit beendet)');
|
||||
$back++;
|
||||
}
|
||||
$pdo->prepare('UPDATE vacation_moves SET returned_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
|
||||
}
|
||||
return $back;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wird nach dem Speichern der Abwesenheit im Profil aufgerufen. $before/$after: [is_on_vacation, vacation_substitute_id].
|
||||
* Beim Speichern mit Haken: weitergeben (auch erneut, es zählen nur Tickets, die noch beim Abwesenden liegen); Ende → zurückgeben; neue Vertretung → erst zurück, dann an die neue.
|
||||
* Liefert [weitergegeben, zurückgegeben].
|
||||
*/
|
||||
function vacation_apply_change(PDO $pdo, int $staffId, array $before, array $after, bool $handOver): array
|
||||
{
|
||||
$handed = $back = 0;
|
||||
$wasOn = (int) $before[0] === 1;
|
||||
$isOn = (int) $after[0] === 1;
|
||||
$changedSub = (int) ($before[1] ?? 0) !== (int) ($after[1] ?? 0);
|
||||
if ($wasOn && (!$isOn || $changedSub)) $back = vacation_take_back($pdo, $staffId);
|
||||
if ($isOn && $handOver) $handed = vacation_hand_over($pdo, $staffId); // idempotent: nur Tickets, die noch beim Abwesenden liegen
|
||||
return [$handed, $back];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt Mitarbeiter im Urlaub durch ihre Vertretung (folgt Ketten), damit Benachrichtigungen dort ankommen, wo jemand arbeitet.
|
||||
* Ohne nutzbare Vertretung entfällt der Eintrag. Ergebnis: eindeutige Mitarbeiter-IDs, die aktiv und anwesend sind.
|
||||
* @param int[] $ids
|
||||
* @return int[]
|
||||
*/
|
||||
function vacation_expand_ids(PDO $pdo, array $ids): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (array_unique(array_map('intval', $ids)) as $id) {
|
||||
if ($id <= 0) continue;
|
||||
$target = staff_is_on_vacation($id) ? vacation_target($pdo, $id) : $id;
|
||||
if ($target !== null) $out[$target] = $target;
|
||||
}
|
||||
if (!$out) return [];
|
||||
$in = implode(',', array_fill(0, count($out), '?'));
|
||||
$q = $pdo->prepare("SELECT id FROM staff WHERE is_active = 1 AND is_on_vacation = 0 AND id IN ($in)");
|
||||
$q->execute(array_values($out));
|
||||
return array_map('intval', $q->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/** E-Mail-Adressen der Mitarbeiter-IDs (nur aktive, anwesende). @param int[] $ids @return string[] */
|
||||
function staff_emails_by_ids(PDO $pdo, array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
if (!$ids) return [];
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$q = $pdo->prepare("SELECT DISTINCT email FROM staff WHERE is_active = 1 AND is_on_vacation = 0 AND id IN ($in)");
|
||||
$q->execute($ids);
|
||||
return array_map('strval', $q->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
Reference in New Issue
Block a user