Upload via GUI (452 Dateien)

This commit is contained in:
2026-11-05 21:21:52 +00:00
parent 10578475f0
commit 32f5e780d6
23 changed files with 1397 additions and 601 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$admin = require_admin();
$admin = require_admin_or_permission('manage_languages');
$pdo = get_db();
$langs = i18n_languages(true);
+30 -5
View File
@@ -1,6 +1,7 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/totp.php';
require_once __DIR__ . '/../includes/webauthn.php';
if (current_staff()) {
redirect('/staff/reports.php');
@@ -45,8 +46,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !csrf_valid()) {
$pass = $_POST['password'] ?? '';
if (!rate_limit_check('staff_login_attempt', client_ip(), 10, 15)) {
$errors[] = 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.';
} elseif ($email && !rate_limit_check('staff_login_email', strtolower($email), login_guard_max_attempts(), login_guard_lock_minutes())) {
// Konto-basierte Sperre – verhindert Brute-Force über wechselnde IPs
} elseif ($email && !rate_limit_check('staff_login_email', strtolower($email), login_guard_max_attempts(), login_guard_lock_minutes()) && !login_guard_known_device_for_email($email)) {
// Konto-basierte Sperre – verhindert Brute-Force über wechselnde IPs. Ein bekanntes Gerät des Kontos (Cookie von einer früheren Anmeldung)
// wird davon nicht ausgesperrt: Sonst könnte jeder, der die Adresse kennt, den echten Mitarbeiter mit Fehlversuchen dauerhaft aussperren.
// Die Sperre je IP-Adresse gilt auch für bekannte Geräte.
login_guard_locked($email, 'web');
$errors[] = 'Zu viele Anmeldeversuche für dieses Konto. Bitte warten Sie einige Minuten.';
} else {
@@ -152,9 +155,14 @@ $displayName = get_setting('display_name', config()['site']['name']);
</form>
<?php endif; ?>
<?php if (!$need2fa): ?>
<?php $loginButtons = (string) plugin_filter('staff.login.buttons', '', []); ?>
<?php if ($loginButtons !== ''): ?>
<div class="login-alt" style="margin-top:18px;padding-top:16px;border-top:1px solid #e2e8f0;display:grid;gap:10px"><?= $loginButtons ?></div>
<?php
$loginButtons = (string) plugin_filter('staff.login.buttons', '', []);
$passkeyOn = webauthn_enabled();
// Gleiche Gestaltung wie die Knöpfe der Anmelde-Plugins (zum Beispiel authentik): ein Block, gleiche Größe, Symbol vor der Beschriftung
$passkeyBtn = $passkeyOn ? '<button type="button" id="passkey-login" class="btn secondary" style="display:none;text-align:center;width:100%"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-5px;margin-right:8px"><circle cx="8" cy="15" r="4"/><path d="M10.85 12.15 19 4"/><path d="m18 5 3 3"/><path d="m15 8 2 2"/></svg>Mit Passkey anmelden</button><div id="passkey-msg" class="flash error" style="display:none;margin:0"></div>' : '';
?>
<?php if ($passkeyBtn . $loginButtons !== ''): ?>
<div class="login-alt" id="login-alt" style="margin-top:18px;padding-top:16px;border-top:1px solid #e2e8f0;display:grid;gap:10px"><?= $passkeyBtn . $loginButtons ?></div>
<?php endif; ?>
<?php endif; ?>
</div>
@@ -165,7 +173,24 @@ $displayName = get_setting('display_name', config()['site']['name']);
<span>Copyright © <?= date('Y') ?> <?= e($displayName) ?> – Alle Rechte vorbehalten.</span>
</footer>
<script src="/assets/js/passkey.js?v=<?= @filemtime(__DIR__ . '/../assets/js/passkey.js') ?: time() ?>"></script>
<script>
(function () {
var btn = document.getElementById('passkey-login'), msg = document.getElementById('passkey-msg'), alt = document.getElementById('login-alt');
if (!btn) return;
if (!window.DeskHubPasskey || !DeskHubPasskey.supported()) {
// Browser ohne Passkeys: Knopf bleibt weg, der Block auch, wenn sonst nichts darin steht
if (alt && alt.querySelectorAll('a, button').length <= 1) alt.style.display = 'none';
return;
}
btn.style.display = 'block';
btn.addEventListener('click', function () {
msg.style.display = 'none';
btn.disabled = true;
DeskHubPasskey.login(<?= json_encode(csrf_token()) ?>).then(function (r) { location.href = r.redirect || '/staff/reports.php'; })
.catch(function (e) { msg.textContent = e.message; msg.style.display = 'block'; btn.disabled = false; });
});
})();
// Hält die Sitzung der offenen Anmeldeseite am Leben: Der Server räumt ungenutzte Sitzungen nach etwa 24 Minuten auf, und dann wäre das
// Anmeldeformular ungültig. Ein kurzer Abruf alle 8 Minuten genügt.
setInterval(function () { fetch('/staff/session_ping.php?check=1', { credentials: 'same-origin', cache: 'no-store' }).catch(function () {}); }, 8 * 60 * 1000);
+20 -2
View File
@@ -1,13 +1,20 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$admin = require_admin();
require_once __DIR__ . '/../includes/webauthn.php';
$admin = require_admin_or_permission('view_login_guard');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = (string) ($_POST['action'] ?? '');
if (in_array($action, ['save', 'purge'], true) && $admin['role'] !== 'admin') {
// Einstellungen und Protokoll leeren bleiben echten Administratoren vorbehalten (Passkeys, Sperrzeiten)
flash('error', 'Einstellungen ändern und das Protokoll leeren dürfen nur Administratoren.');
redirect('/staff/login_log.php');
}
if ($action === 'save') {
set_setting('login_guard', !empty($_POST['login_guard']) ? '1' : '0');
set_setting('passkeys_enabled', !empty($_POST['passkeys_enabled']) ? '1' : '0');
set_setting('login_guard_warn_place', !empty($_POST['login_guard_warn_place']) ? '1' : '0');
set_setting('login_guard_notify_admins', !empty($_POST['login_guard_notify_admins']) ? '1' : '0');
set_setting('login_guard_max_attempts', (string) max(3, min(20, (int) ($_POST['login_guard_max_attempts'] ?? 5))));
@@ -19,6 +26,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$n = $pdo->exec('DELETE FROM login_events');
audit_log($admin['id'], 'Anmelde-Wächter: Protokoll geleert (' . (int) $n . ' Einträge)');
flash('success', 'Protokoll geleert.');
} elseif ($action === 'unlock') {
$sid = (int) ($_POST['staff_id'] ?? 0);
if ($sid > 0) {
login_guard_unlock($pdo, $sid, (int) $admin['id']);
flash('success', 'Die Anmeldesperre ist aufgehoben.');
}
} elseif ($action === 'forget_devices') {
$sid = (int) ($_POST['staff_id'] ?? 0);
if ($sid > 0) {
@@ -51,7 +64,7 @@ foreach ($cnt->fetchAll() as $r) $counts[$r['event']] = (int) $r['n'];
$pageTitle = 'Anmelde-Wächter';
$activePage = 'login_log';
$colors = ['success' => '#15803d', 'new_device' => '#b45309', 'new_place' => '#b45309', 'failed' => '#b91c1c', 'locked' => '#b91c1c', 'revoked' => '#7c3aed'];
$colors = ['success' => '#15803d', 'new_device' => '#b45309', 'new_place' => '#b45309', 'failed' => '#b91c1c', 'locked' => '#b91c1c', 'revoked' => '#7c3aed', 'signout' => '#7c3aed', 'unlocked' => '#15803d'];
require __DIR__ . '/_header.php';
?>
<h1>Anmelde-Wächter</h1>
@@ -67,6 +80,8 @@ require __DIR__ . '/_header.php';
<h3 style="margin-top:0">Einstellungen</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400"><input type="checkbox" name="login_guard" value="1" style="width:auto;margin-top:3px" <?= login_guard_enabled() ? 'checked' : '' ?>>
<span><strong>Anmelde-Wächter einschalten</strong> (Geräte merken, bei Neuem warnen, Protokoll führen)</span></label>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:8px"><input type="checkbox" name="passkeys_enabled" value="1" style="width:auto;margin-top:3px" <?= webauthn_enabled() ? 'checked' : '' ?>>
<span><strong>Passkeys erlauben</strong> (Anmeldung ohne Passwort per Fingerabdruck, Gesicht oder PIN). Ausgeschaltet verschwindet „Mit Passkey anmelden“ und vorhandene Passkeys gelten nicht mehr; sie bleiben gespeichert und gelten wieder, sobald ihr es einschaltet. Den Passkey eines einzelnen Mitarbeiters entfernt ihr unter <a href="/staff/staff_manage.php">Mitarbeiter</a>.</span></label>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:8px"><input type="checkbox" name="login_guard_warn_place" value="1" style="width:auto;margin-top:3px" <?= get_setting('login_guard_warn_place', '1') === '1' ? 'checked' : '' ?>>
<span>Auch bei einem <strong>neuen Ort</strong> warnen (vom bekannten Gerät, z. B. unterwegs mit dem Handy). Ausgeschaltet warnt nur ein neues Gerät.</span></label>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:8px"><input type="checkbox" name="login_guard_notify_admins" value="1" style="width:auto;margin-top:3px" <?= get_setting('login_guard_notify_admins', '1') === '1' ? 'checked' : '' ?>>
@@ -104,6 +119,9 @@ require __DIR__ . '/_header.php';
<td style="font-size:.85rem"><?= e($r['place']) ?><div class="muted" style="font-size:.76rem"><?= e($r['ip']) ?></div></td>
<td style="font-size:.82rem"><?= e($r['detail']) ?></td>
<td>
<?php if ($r['staff_id'] && $r['event'] === 'locked' && login_guard_is_locked($pdo, (string) $r['email'])): ?>
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="unlock"><input type="hidden" name="staff_id" value="<?= (int) $r['staff_id'] ?>"><button class="btn secondary" type="submit" style="padding:2px 9px;font-size:.76rem">Sperre aufheben</button></form>
<?php endif; ?>
<?php if ($r['staff_id'] && in_array($r['event'], ['new_device', 'new_place', 'revoked'], true)): ?>
<form method="post" onsubmit="return confirm('Alle gemerkten Geräte und Orte dieses Mitarbeiters vergessen?')"><?= csrf_field() ?><input type="hidden" name="action" value="forget_devices"><input type="hidden" name="staff_id" value="<?= (int) $r['staff_id'] ?>"><button class="btn secondary" type="submit" style="padding:3px 8px;font-size:.74rem">Geräte vergessen</button></form>
<?php endif; ?>
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$admin = require_admin();
$admin = require_admin_or_permission('view_mail_queue');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+73
View File
@@ -0,0 +1,73 @@
<?php
/**
* Schnittstelle für Passkeys im Browser (JSON). Aktionen:
* login_options / login_verify Anmeldung ohne Passwort (ohne vorherige Anmeldung)
* register_options / register_verify Registrierung im Profil (angemeldet, Passwort wird bestätigt)
*/
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/webauthn.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
function passkey_out(array $data, int $code = 200): void
{
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') passkey_out(['ok' => false, 'error' => 'Nur POST erlaubt.'], 405);
if (!csrf_valid()) passkey_out(['ok' => false, 'error' => 'Die Seite war zu lange geöffnet. Bitte neu laden und noch einmal versuchen.'], 403);
$pdo = get_db();
$action = (string) ($_POST['action'] ?? '');
$rp = webauthn_rp();
if (!webauthn_enabled()) {
passkey_out(['ok' => false, 'error' => 'Passkeys sind in dieser Installation ausgeschaltet.'], 403);
}
if (!$rp['secure']) {
passkey_out(['ok' => false, 'error' => 'Passkeys brauchen eine HTTPS-Adresse. Die Website-URL in den Einstellungen beginnt nicht mit https://.'], 400);
}
$cred = json_decode((string) ($_POST['credential'] ?? ''), true);
if ($action === 'login_options') {
if (!rate_limit_check('passkey_login_options', client_ip(), 60, 15)) passkey_out(['ok' => false, 'error' => 'Zu viele Versuche. Bitte warten Sie einige Minuten.'], 429);
passkey_out(['ok' => true, 'options' => webauthn_request_options()]);
}
if ($action === 'login_verify') {
if (!rate_limit_check('passkey_login_attempt', client_ip(), 15, 15)) passkey_out(['ok' => false, 'error' => 'Zu viele Versuche. Bitte warten Sie einige Minuten.'], 429);
if (!is_array($cred)) passkey_out(['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'], 400);
$r = webauthn_login_finish($pdo, $cred);
if (empty($r['ok'])) {
login_guard_failed(null, '', 'web', 'Passkey abgelehnt: ' . (string) ($r['error'] ?? ''));
passkey_out(['ok' => false, 'error' => (string) ($r['error'] ?? 'Anmeldung fehlgeschlagen.')], 401);
}
rate_limit_reset('passkey_login_attempt', client_ip());
unset($_SESSION['totp_pending_staff_id']);
staff_login($r['staff']);
passkey_out(['ok' => true, 'redirect' => '/staff/reports.php']);
}
// Ab hier: nur angemeldete Mitarbeiter
$staff = current_staff();
if (!$staff) passkey_out(['ok' => false, 'error' => 'Bitte melden Sie sich an.'], 401);
if ($action === 'register_options' || $action === 'register_verify') {
// Ein neuer Passkey bleibt dauerhaft gültig: darum zuerst das Passwort bestätigen (schützt vor einer fremden offenen Sitzung)
if ($action === 'register_options') {
if (!rate_limit_check('passkey_register', (string) $staff['id'], 10, 15)) passkey_out(['ok' => false, 'error' => 'Zu viele Versuche. Bitte warten Sie einige Minuten.'], 429);
if (!password_verify((string) ($_POST['password'] ?? ''), (string) $staff['password_hash'])) passkey_out(['ok' => false, 'error' => 'Das Passwort ist falsch.'], 403);
passkey_out(['ok' => true, 'options' => webauthn_creation_options($pdo, $staff)]);
}
if (!is_array($cred)) passkey_out(['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'], 400);
$r = webauthn_register_finish($pdo, $staff, $cred, (string) ($_POST['name'] ?? ''));
if (empty($r['ok'])) passkey_out(['ok' => false, 'error' => (string) ($r['error'] ?? 'Registrierung fehlgeschlagen.')], 400);
flash('success', 'Passkey hinzugefügt. Beim nächsten Anmelden können Sie „Mit Passkey anmelden“ wählen.');
audit_log((int) $staff['id'], 'Passkey hinzugefügt');
login_guard_record($pdo, 'passkey_added', (int) $staff['id'], (string) $staff['email'], 'web', 'Passkey hinzugefügt');
passkey_out(['ok' => true]);
}
passkey_out(['ok' => false, 'error' => 'Unbekannte Aktion.'], 400);
+15 -2
View File
@@ -50,6 +50,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
if ($action === 'catalog_update_all') {
$res = plugin_catalog_update_all($pdo);
if ($res['updated']) {
audit_log($staff['id'], 'Plugins aus dem Katalog aktualisiert: ' . implode(', ', $res['updated']));
flash('success', count($res['updated']) . ' Plugin(s) aktualisiert: ' . implode(', ', $res['updated']) . '.');
} elseif (!$res['failed']) {
flash('info', 'Alle Plugins sind aktuell.');
}
foreach ($res['failed'] as $failedSlug => $failedMsg) flash('error', $failedSlug . ': ' . $failedMsg);
redirect('/staff/plugins.php');
}
if ($action === 'catalog_install' && plugin_valid_slug($slug)) {
$res = plugin_catalog_install($pdo, $slug);
if ($res['ok']) {
@@ -219,7 +231,8 @@ require __DIR__ . '/_header.php';
</div>
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
<?php if ($updates): ?>
<div class="flash warning"><strong>Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['installed'] . ' → ' . $u['available'], $updates))) ?>. <a href="#" data-pl-tab="installed" data-pl-filter="update">Anzeigen</a> oder unter „Plugin hinzufügen“ mit „Aktualisieren“ installieren.</div>
<div class="flash warning"><strong>Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['installed'] . ' → ' . $u['available'], $updates))) ?>. <a href="#" data-pl-tab="installed" data-pl-filter="update">Anzeigen</a>
<form method="post" style="display:inline;margin:0 0 0 10px"><?= csrf_field() ?><input type="hidden" name="action" value="catalog_update_all"><button class="btn" type="submit" style="padding:3px 12px;font-size:.82rem" onclick="this.textContent='⏳ Wird aktualisiert …'"><?= count($updates) === 1 ? 'Jetzt aktualisieren' : 'Alle aktualisieren' ?></button></form></div>
<?php endif; ?>
<?php if ($disabled): ?>
<div class="flash error">Alle Plugins sind abgeschaltet (Datei <code>plugins/.disabled</code> oder Einstellung <code>plugins.enabled = false</code>). Bereits aktive Plugins werden nicht geladen.</div>
@@ -272,7 +285,7 @@ require __DIR__ . '/_header.php';
<button class="btn" type="submit" <?= $m ? '' : 'disabled' ?>>Aktivieren</button>
</form>
<?php endif; ?>
<?php if ($upd): ?><a class="pl-more" href="#" data-pl-tab="add">Jetzt aktualisieren</a><?php endif; ?>
<?php if ($upd): ?><form method="post" style="margin:0;text-align:right"><?= csrf_field() ?><input type="hidden" name="action" value="catalog_install"><input type="hidden" name="slug" value="<?= e($slug) ?>"><button class="pl-more" type="submit" style="background:none;border:0;padding:0;cursor:pointer;font:inherit;text-decoration:underline" onclick="this.textContent='⏳ Wird aktualisiert …'">Jetzt aktualisieren</button></form><?php endif; ?>
<a href="#" class="pl-more">Weitere Details</a>
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_admin();
$staff = require_admin_or_permission('manage_problems');
$pdo = get_db();
$errors = [];
$problemId = (int) ($_GET['id'] ?? $_POST['problem_id'] ?? 0);
+114
View File
@@ -1,6 +1,7 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/totp.php';
require_once __DIR__ . '/../includes/webauthn.php';
$staff = require_staff_login();
$pdo = get_db();
@@ -134,6 +135,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect('/staff/profile.php?tab=account');
}
// ── Von allen Geräten abmelden ─────────────────────────────────
if ($action === 'sign_out_everywhere') {
$keep = ($_POST['scope'] ?? 'others') !== 'all';
$r = staff_sign_out_everywhere($pdo, (int) $staff['id'], (int) $staff['id'], $keep);
if ($keep) {
flash('success', 'Alle anderen Geräte sind abgemeldet. Beendete App-Anmeldungen: ' . $r['apps'] . '. Diese Sitzung bleibt angemeldet.');
redirect('/staff/profile.php?tab=security');
}
staff_logout();
flash('success', 'Sie sind von allen Geräten abgemeldet. Bitte melden Sie sich neu an.');
redirect('/staff/login.php');
}
// ── Gemerktes Gerät entfernen (Anmelde-Wächter) ────────────────
if ($action === 'forget_device') {
$pdo->prepare('DELETE FROM login_devices WHERE id = ? AND staff_id = ?')->execute([(int) ($_POST['device_id'] ?? 0), $staff['id']]);
@@ -141,6 +155,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect('/staff/profile.php?tab=security');
}
// ── Passkey entfernen (das Passwort wird bestätigt) ───────────
if ($action === 'delete_passkey') {
$pass = $_POST['confirm_password'] ?? '';
$me = $pdo->prepare('SELECT password_hash FROM staff WHERE id=?');
$me->execute([$staff['id']]);
$me = $me->fetch();
if (!password_verify($pass, $me['password_hash'])) {
flash('error', 'Falsches Passwort.');
} else {
$del = $pdo->prepare('DELETE FROM staff_passkeys WHERE id = ? AND staff_id = ?');
$del->execute([(int) ($_POST['passkey_id'] ?? 0), $staff['id']]);
if ($del->rowCount() > 0) {
audit_log($staff['id'], 'Passkey entfernt');
login_guard_record($pdo, 'passkey_removed', (int) $staff['id'], (string) $staff['email'], 'web', 'Passkey vom Mitarbeiter selbst entfernt');
flash('success', 'Passkey entfernt.');
}
}
redirect('/staff/profile.php?tab=security');
}
// ── 2FA aktivieren ─────────────────────────────────────────────
if ($action === 'enable_2fa') {
$code = trim($_POST['totp_code'] ?? '');
@@ -452,6 +486,7 @@ document.getElementById('sig-clear').addEventListener('click', function () {
<?php elseif ($tab === 'security'): ?>
<?php
require_once __DIR__ . '/../includes/totp.php';
require_once __DIR__ . '/../includes/webauthn.php';
$me2 = get_db()->prepare('SELECT totp_enabled, totp_secret, email FROM staff WHERE id=?');
$me2->execute([$staff['id']]);
$me2 = $me2->fetch();
@@ -505,6 +540,85 @@ $myEvents->execute([$staff['id']]);
$myEvents = $myEvents->fetchAll();
$eventLabels = login_guard_event_labels();
?>
<?php $myPasskeys = webauthn_staff_passkeys($pdo, (int) $staff['id']); $pkRp = webauthn_rp(); ?>
<div class="card" style="margin-top:20px">
<h3>🔑 Passkeys</h3>
<p class="muted" style="font-size:.88rem">Mit einem Passkey melden Sie sich ohne Passwort an: per Fingerabdruck, Gesicht oder Geräte-PIN (Windows Hello, Handy, Sicherheitsschlüssel). Der Passkey ersetzt Passwort und 2FA-Code; Passwort und 2FA bleiben als Rückfall bestehen. Ein Administrator kann Ihre Passkeys bei Verlust entfernen.</p>
<p class="muted" style="font-size:.82rem;margin:0 0 8px">Passkeys gelten für die Domain <strong><?= e($pkRp['id']) ?></strong> (die Adresse, unter der Sie DeskHub aufrufen).</p>
<?php if (!webauthn_enabled()): ?>
<div class="flash info">Passkeys sind in dieser Installation zurzeit ausgeschaltet. Vorhandene Passkeys bleiben gespeichert, gelten aber erst wieder, wenn ein Administrator sie einschaltet.</div>
<?php endif; ?>
<?php if (!$pkRp['secure']): ?>
<div class="flash error">Passkeys brauchen eine HTTPS-Adresse. Die Website-URL in den Einstellungen beginnt noch nicht mit https://.</div>
<?php endif; ?>
<?php if ($myPasskeys): ?>
<table style="width:100%;font-size:.86rem;margin:10px 0">
<thead><tr style="text-align:left"><th>Name</th><th>Hinzugefügt</th><th>Zuletzt benutzt</th><th></th></tr></thead>
<tbody>
<?php foreach ($myPasskeys as $k): ?>
<tr>
<td><?= e($k['name']) ?></td>
<td><?= e(date('d.m.Y H:i', strtotime((string) $k['created_at']))) ?></td>
<td><?= $k['last_used_at'] ? e(date('d.m.Y H:i', strtotime((string) $k['last_used_at']))) : 'noch nie' ?></td>
<td>
<form method="post" style="display:flex;gap:6px;align-items:center;justify-content:flex-end" onsubmit="return confirm('Diesen Passkey entfernen?')"><?= csrf_field() ?>
<input type="hidden" name="action" value="delete_passkey"><input type="hidden" name="passkey_id" value="<?= (int) $k['id'] ?>">
<input type="password" name="confirm_password" placeholder="Passwort" required style="max-width:140px;padding:4px 8px">
<button class="btn secondary" type="submit" style="padding:3px 8px;font-size:.78rem">Entfernen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p class="muted" style="font-size:.88rem">Noch kein Passkey eingerichtet.</p>
<?php endif; ?>
<div id="passkey-add" style="display:none;margin-top:10px"<?= webauthn_enabled() ? '' : ' data-off="1"' ?>>
<h4 style="margin:0 0 6px">Neuen Passkey hinzufügen</h4>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
<div><label for="pk_name">Name (z. B. „Laptop“)</label><input type="text" id="pk_name" maxlength="80" placeholder="Mein Laptop" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" style="max-width:200px"></div>
<div><label for="pk_pass">Ihr Passwort</label><input type="password" id="pk_pass" autocomplete="current-password" style="max-width:200px"></div>
<button type="button" class="btn" id="pk_add">Passkey einrichten</button>
</div>
<div id="pk_msg" class="flash error" style="display:none;margin-top:10px"></div>
</div>
<p id="pk_unsupported" class="muted" style="font-size:.86rem;display:none">Dieser Browser oder diese Verbindung unterstützt keine Passkeys.</p>
<script src="/assets/js/passkey.js?v=<?= @filemtime(__DIR__ . '/../assets/js/passkey.js') ?: time() ?>"></script>
<script>
(function () {
var add = document.getElementById('passkey-add');
if (add.getAttribute('data-off')) return;
if (!window.DeskHubPasskey || !DeskHubPasskey.supported()) { document.getElementById('pk_unsupported').style.display = 'block'; return; }
add.style.display = 'block';
var msg = document.getElementById('pk_msg'), btn = document.getElementById('pk_add');
btn.addEventListener('click', function () {
msg.style.display = 'none';
var pass = document.getElementById('pk_pass').value;
if (!pass) { msg.textContent = 'Bitte geben Sie Ihr Passwort ein.'; msg.style.display = 'block'; return; }
btn.disabled = true;
DeskHubPasskey.register(<?= json_encode(csrf_token()) ?>, document.getElementById('pk_name').value, pass)
.then(function () { location.href = '/staff/profile.php?tab=security&pk=1'; })
.catch(function (e) { msg.textContent = e.message; msg.style.display = 'block'; btn.disabled = false; });
});
})();
</script>
</div>
<?php $myApps = staff_app_logins($pdo, (int) $staff['id']); ?>
<div class="card" style="margin-top:20px">
<h3>Von allen Geräten abmelden</h3>
<p class="muted" style="font-size:.88rem">Beendet alle Anmeldungen in Browsern und in den Apps (Android, Windows-Programm, Chrome-Erweiterung) und stoppt die Push-Benachrichtigungen auf diesen Geräten. Sinnvoll, wenn ein Gerät verloren ging oder Sie sich irgendwo angemeldet gelassen haben. Ihr Passwort bleibt unverändert.</p>
<?php if ($myApps): ?>
<div style="font-size:.84rem;margin:8px 0"><strong>Angemeldete Apps:</strong>
<?php foreach ($myApps as $app): ?><div class="muted"><?= e((string) ($app['device_name'] ?: 'Unbekanntes Gerät')) ?> · <?= e($app['last_used_at'] ? 'zuletzt ' . date('d.m.Y H:i', strtotime((string) $app['last_used_at'])) : 'noch nie benutzt') ?></div><?php endforeach; ?>
</div>
<?php endif; ?>
<div class="btn-row" style="display:flex;gap:10px;flex-wrap:wrap">
<form method="post" onsubmit="return confirm('Alle anderen Geräte abmelden? Dieses Gerät bleibt angemeldet.')"><?= csrf_field() ?><input type="hidden" name="action" value="sign_out_everywhere"><input type="hidden" name="scope" value="others"><button type="submit" class="btn secondary">Alle anderen Geräte abmelden</button></form>
<form method="post" onsubmit="return confirm('Von allen Geräten abmelden, auch von diesem?')"><?= csrf_field() ?><input type="hidden" name="action" value="sign_out_everywhere"><input type="hidden" name="scope" value="all"><button type="submit" class="btn secondary" style="border-color:var(--danger);color:var(--danger)">Von allen Geräten abmelden (auch hier)</button></form>
</div>
</div>
<div class="card" style="margin-top:20px">
<h3>🛡️ Meine Geräte und Anmeldungen</h3>
<p class="muted" style="font-size:.88rem">Von diesen Geräten haben Sie sich schon angemeldet. Meldet sich jemand von einem neuen Gerät oder Ort an, bekommen Sie eine Mail. Ein Gerät, das Sie nicht kennen, können Sie hier entfernen; ändern Sie dann auch Ihr Passwort.</p>
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_admin();
$staff = require_admin_or_permission('manage_automation');
$pdo = get_db();
$departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM recurring_tickets')->fetchAll(PDO::FETCH_COLUMN)));
+5 -3
View File
@@ -25,7 +25,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$cfg = report_clean_config($pdo, $cfgIn);
$now = db_now();
$pdo->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')
->execute([$staff['id'], mb_substr($name, 0, 120), json_encode($cfg, JSON_UNESCAPED_UNICODE), !empty($_POST['shared']) ? 1 : 0, $now, $now]);
->execute([$staff['id'], mb_substr($name, 0, 120), json_encode($cfg, JSON_UNESCAPED_UNICODE), (!empty($_POST['shared']) && has_permission($staff, 'share_views')) ? 1 : 0, $now, $now]);
flash('success', 'Bericht gespeichert.');
}
redirect('/staff/report_builder.php?' . ltrim((string) ($_POST['return_query'] ?? ''), '?'));
@@ -46,7 +46,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$own->execute([$sid]);
$sch = $own->fetch();
$mine = $sch && ((int) $sch['staff_id'] === (int) $staff['id'] || $staff['role'] === 'admin');
if ($action === 'schedule_add') {
if (in_array($action, ['schedule_add'], true) && !has_permission($staff, 'schedule_reports')) {
flash('error', 'Für Berichte per E-Mail fehlt die erforderliche Berechtigung.');
} elseif ($action === 'schedule_add') {
$err = report_schedule_create($pdo, $staff, $_POST);
$err ? flash('error', $err) : flash('success', 'Zeitplan angelegt. Der Bericht wird zum nächsten Termin per E-Mail gesendet.');
} elseif (!$mine) {
@@ -208,7 +210,7 @@ require __DIR__ . '/_header.php';
<input type="hidden" name="return_query" value="<?= e($queryOf($cfg)) ?>">
<label for="report_name" style="margin-top:0">Name</label>
<input type="text" id="report_name" name="report_name" maxlength="120" placeholder="z. B. Monatsbericht Support" required>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:10px"><input type="checkbox" name="shared" value="1" style="width:auto"> Für alle Mitarbeiter sichtbar</label>
<label style="display:<?= has_permission($staff, 'share_views') ? 'flex' : 'none' ?>;align-items:center;gap:8px;font-weight:400;margin-top:10px"><input type="checkbox" name="shared" value="1" style="width:auto"> Für alle Mitarbeiter sichtbar</label>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
</details>
+4 -1
View File
@@ -25,7 +25,10 @@ if (!$token || !$email) {
consume_password_reset('staff', $token);
$staffRow = $pdo->prepare('SELECT id FROM staff WHERE LOWER(email) = LOWER(?)');
$staffRow->execute([$email]);
audit_log((int) $staffRow->fetchColumn(), 'Passwort per Reset-Link zurückgesetzt');
$resetStaffId = (int) $staffRow->fetchColumn();
audit_log($resetStaffId, 'Passwort per Reset-Link zurückgesetzt');
// Wer den Link aus der Mail benutzt, hat sich als Besitzer des Postfachs ausgewiesen: Die Anmeldesperre endet damit
if ($resetStaffId > 0) login_guard_unlock($pdo, $resetStaffId, null, 'Sperre aufgehoben: Passwort per Reset-Link zurückgesetzt');
$done = true;
}
}
+102 -44
View File
@@ -4,23 +4,10 @@ require_once __DIR__ . '/_ui.php';
$staff = require_admin_or_permission('manage_roles');
$pdo = get_db();
$ticketPermLabels = [
'reply_ticket' => 'Tickets beantworten',
'close_ticket' => 'Tickets schließen/lösen',
'delete_ticket' => 'Tickets löschen',
'transfer_ticket' => 'Tickets übertragen (Abteilung ändern)',
'post_note' => 'Interne Notizen schreiben',
'assign_ticket' => 'Tickets zuweisen (Mitarbeiter/Team)',
'manage_canned' => 'Textbausteine verwalten',
'view_reports' => 'Berichte einsehen',
'manage_kb' => 'Wissensdatenbank verwalten',
'create_ticket' => 'Tickets im Namen von Kunden erstellen',
];
// Feinkörnige Adminportal-Rechte ("Co-Admin") – siehe admin_section_permissions() in functions.php.
// manage_kb steckt bewusst nur oben bei den Ticket-Rechten, nicht doppelt hier.
$adminPermLabels = admin_section_permissions();
unset($adminPermLabels['manage_kb']);
$permLabels = array_merge($ticketPermLabels, $adminPermLabels);
// Alle Rechte, Gruppen, Beschreibungen und Standardwerte: includes/permissions.php
$catalog = permission_catalog();
$permLabels = permission_labels();
$agentDefaults = default_permissions('agent');
$errors = [];
@@ -39,8 +26,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($dup->fetchColumn() > 0) $errors[] = 'Eine Rolle mit diesem Namen existiert bereits.';
if (!$errors) {
$perms = [];
// Wer kein echter Administrator ist, kann keine Rechte vergeben, die er selbst nicht hat (sonst könnte sich ein Co-Admin zum Administrator hochstufen)
$oldPerms = [];
if ($action === 'update') {
$op = $pdo->prepare('SELECT permissions FROM roles WHERE id = ?'); $op->execute([$id]);
$oldPerms = json_decode((string) $op->fetchColumn(), true) ?: [];
}
foreach (array_keys($permLabels) as $p) {
$perms[$p] = !empty($_POST['perm_' . $p]);
$want = !empty($_POST['perm_' . $p]);
if ($staff['role'] !== 'admin' && $want && !has_permission($staff, $p)) $want = !empty($oldPerms[$p]);
$perms[$p] = $want;
}
$json = json_encode($perms);
if ($action === 'create') {
@@ -58,6 +53,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
if ($action === 'seed_defaults') {
if ($staff['role'] !== 'admin') {
flash('error', 'Vordefinierte Rollen anlegen dürfen nur Administratoren.');
} else {
$made = permission_seed_default_roles($pdo);
if ($made) { audit_log($staff['id'], 'Vordefinierte Rollen angelegt: ' . implode(', ', $made)); flash('success', count($made) . ' Rolle(n) angelegt: ' . implode(', ', $made) . '.'); }
else flash('info', 'Alle vordefinierten Rollen sind schon vorhanden.');
}
redirect('/staff/roles.php');
}
if ($action === 'delete') {
$id = (int) $_POST['id'];
// Mitarbeiter und Abteilungszugriffe auf die Hauptrolle zurücksetzen
@@ -74,17 +80,19 @@ $isNew = isset($_GET['new']);
$editId = (int) ($_GET['edit'] ?? 0);
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
$presetNames = [];
foreach (permission_presets() as [$__pn, , , $__seed]) if ($__seed) $presetNames[] = mb_strtolower($__pn);
$roles = $pdo->query(
'SELECT r.*, (SELECT COUNT(*) FROM staff s WHERE s.role_id = r.id) + (SELECT COUNT(*) FROM staff_department sd WHERE sd.role_id = r.id) AS staff_count
FROM roles r ORDER BY r.name'
)->fetchAll();
$editRole = null;
$editPerms = array_fill_keys(array_keys($permLabels), false);
$editPerms = $agentDefaults; // neue Rolle: wie ein Standard-Mitarbeiter; fehlende Rechte älterer Rollen gelten mit dem Standardwert
if ($editId) {
$es = $pdo->prepare('SELECT * FROM roles WHERE id = ?'); $es->execute([$editId]);
$editRole = $es->fetch() ?: null;
if ($editRole) $editPerms = array_merge($editPerms, json_decode($editRole['permissions'] ?? '{}', true) ?? []);
if ($editRole) $editPerms = permission_effective(json_decode($editRole['permissions'] ?? '{}', true) ?? []);
}
if ($_POST && $errors) foreach (array_keys($permLabels) as $p) $editPerms[$p] = !empty($_POST['perm_' . $p]);
@@ -123,24 +131,52 @@ require __DIR__ . '/_header.php';
</div>
<div class="tab-pane" data-pane="perms" style="padding:16px 20px;display:none">
<p class="muted" style="margin-top:0">Administratoren haben immer alle Rechte. Die Rechte einer Rolle gelten für Mitarbeiter mit dieser Rolle – je Abteilung kann ein Mitarbeiter eine andere Rolle haben (Mitarbeiter → Zugang).</p>
<h4 style="border-bottom:1px dotted var(--border);padding-bottom:4px">Tickets</h4>
<div style="columns:2;column-gap:24px">
<?php foreach ($ticketPermLabels as $key => $label): ?>
<label style="display:flex;align-items:center;gap:8px;padding:4px 0;font-weight:400;cursor:pointer;break-inside:avoid">
<input type="checkbox" name="perm_<?= $key ?>" style="width:auto" <?= ($editPerms[$key] ?? false) ? 'checked' : '' ?>> <?= e($label) ?>
</label>
<?php endforeach; ?>
</div>
<h4 style="border-bottom:1px dotted var(--border);padding-bottom:4px;margin-top:20px">Adminportal-Bereiche („Co-Admin“)</h4>
<div style="columns:2;column-gap:24px">
<?php foreach ($adminPermLabels as $key => $label): ?>
<label style="display:flex;align-items:center;gap:8px;padding:4px 0;font-weight:400;cursor:pointer;break-inside:avoid">
<input type="checkbox" name="perm_<?= $key ?>" style="width:auto" <?= ($editPerms[$key] ?? false) ? 'checked' : '' ?>> <?= e($label) ?>
</label>
<p class="muted" style="margin-top:0">Administratoren haben immer alle Rechte. Die Rechte einer Rolle gelten für Mitarbeiter mit dieser Rolle – je Abteilung kann ein Mitarbeiter eine andere Rolle haben (Mitarbeiter → Zugang). Ein Recht für einen Adminportal-Bereich macht die Person zum „Co-Admin“ für genau diesen Bereich. <strong>Immer nur echten Administratoren vorbehalten:</strong> Einstellungen, Backup, System-Update, Plugins, API-Schlüssel, Webhooks, osTicket-Import, Sicherheitsprüfung, Passkeys ein-/ausschalten und die Datenschutz-Löschfunktionen.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0 4px">
<strong style="font-size:.86rem">Vorlage laden:</strong>
<?php foreach (permission_presets() as $pk => [$pname, $pdesc, $pperms]): ?>
<button type="button" class="btn secondary" style="padding:4px 12px;font-size:.82rem" data-preset="<?= e($pk) ?>" title="<?= e($pdesc) ?>"><?= e($pname) ?></button>
<?php endforeach; ?>
<span class="muted" style="margin-left:auto;font-size:.82rem"><span id="perm-count"></span> Rechte</span>
</div>
<?php foreach ($catalog as $gk => $group): ?>
<fieldset style="border:1px solid var(--border);border-radius:8px;margin:14px 0;padding:6px 14px 10px" data-group="<?= e($gk) ?>">
<legend style="font-weight:700;font-size:.9rem;padding:0 6px"><?= e($group['title']) ?></legend>
<div style="text-align:right;font-size:.78rem;margin-bottom:2px"><a href="#" data-all="<?= e($gk) ?>">Alle</a> · <a href="#" data-none="<?= e($gk) ?>">Keine</a></div>
<?php foreach ($group['items'] as $key => $item): ?>
<label style="display:flex;align-items:flex-start;gap:8px;padding:5px 0;font-weight:400;cursor:pointer">
<input type="checkbox" name="perm_<?= e($key) ?>" style="width:auto;margin-top:3px" <?= ($editPerms[$key] ?? false) ? 'checked' : '' ?>>
<span><span style="font-weight:600"><?= e($item['label']) ?></span><?php if ($item['area']): ?> <span class="muted" style="font-size:.7rem;border:1px solid var(--border);border-radius:10px;padding:0 6px">Adminportal</span><?php endif; ?><br><span class="muted" style="font-size:.78rem"><?= e($item['hint']) ?></span></span>
</label>
<?php endforeach; ?>
</fieldset>
<?php endforeach; ?>
</div>
<script>
(function () {
var presets = <?= json_encode(array_map(fn($x) => $x[2], permission_presets()), JSON_UNESCAPED_UNICODE) ?>;
var groups = <?= json_encode(array_map(fn($g) => array_keys($g['items']), $catalog), JSON_UNESCAPED_UNICODE) ?>;
var box = function (k) { return document.querySelector('input[name="perm_' + k + '"]'); };
function count() {
var n = 0, t = 0;
Object.keys(groups).forEach(function (g) { groups[g].forEach(function (k) { t++; if (box(k) && box(k).checked) n++; }); });
document.getElementById('perm-count').textContent = n + ' / ' + t;
}
document.querySelectorAll('[data-preset]').forEach(function (b) { b.addEventListener('click', function () {
var p = presets[b.getAttribute('data-preset')];
Object.keys(p).forEach(function (k) { if (box(k)) box(k).checked = !!p[k]; });
count();
}); });
document.querySelectorAll('[data-all],[data-none]').forEach(function (a) { a.addEventListener('click', function (e) {
e.preventDefault();
var g = a.getAttribute('data-all') || a.getAttribute('data-none'), on = a.hasAttribute('data-all');
groups[g].forEach(function (k) { if (box(k)) box(k).checked = on; });
count();
}); });
document.querySelectorAll('input[name^="perm_"]').forEach(function (c) { c.addEventListener('change', count); });
count();
})();
</script>
<div class="btn-row" style="padding:0 20px 18px">
<button type="submit" class="btn"><?= $editRole ? 'Änderungen speichern' : 'Rolle erstellen' ?></button>
@@ -154,28 +190,50 @@ require __DIR__ . '/_header.php';
<h1 style="margin:0">Rollen</h1>
<a href="/staff/roles.php?new=1" class="btn">Neue Rolle hinzufügen</a>
</div>
<p class="muted">Benutzerdefinierte Berechtigungssets für Mitarbeiter. Ohne Rolle gelten die Standard-Mitarbeiterrechte.</p>
<p class="muted">Berechtigungssets für Mitarbeiter. Ohne Rolle gelten die Standard-Mitarbeiterrechte.</p>
<p class="muted">Rollen mit dem Kennzeichen „vordefiniert“ (Co-Administrator, Support-Manager, IT / Technik, Teamleitung, Support 1st und 2nd Level, Redaktion, Auswertung, Teamverwaltung, Aushilfe, Nur lesen) wurden mit dem Update angelegt und lassen sich wie jede andere Rolle ändern oder löschen.</p>
<?php $missingRoles = permission_missing_default_roles($pdo); ?>
<?php if ($missingRoles && $staff['role'] === 'admin'): ?>
<div class="card" style="display:flex;gap:14px;align-items:center;flex-wrap:wrap">
<div style="flex:1;min-width:260px"><strong><?= count($missingRoles) ?> vordefinierte Rolle(n) fehlen</strong><div class="muted" style="font-size:.84rem"><?= e(implode(', ', $missingRoles)) ?></div></div>
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="seed_defaults"><button class="btn secondary" type="submit">Fehlende Rollen anlegen</button></form>
</div>
<?php endif; ?>
<div class="card">
<?php $defaults = default_permissions('agent'); ?>
<?php $defaults = $agentDefaults; $roleRights = []; foreach ($roles as $r) $roleRights[(int) $r['id']] = permission_effective(json_decode($r['permissions'] ?? '{}', true) ?? []); ?>
<details>
<summary style="cursor:pointer;font-weight:600">Standard-Mitarbeiterrechte (ohne eigene Rolle)</summary>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-top:10px">
<?php foreach ($permLabels as $key => $label): ?>
<div style="font-size:.82rem;color:<?= ($defaults[$key] ?? false) ? 'var(--success)' : 'var(--muted)' ?>"><?= ($defaults[$key] ?? false) ? '✓' : '✕' ?> <?= e($label) ?></div>
<summary style="cursor:pointer;font-weight:600">Rechte-Übersicht: Wer darf was?</summary>
<p class="muted" style="font-size:.82rem;margin:8px 0">Standard = Mitarbeiter ohne eigene Rolle. Administratoren dürfen immer alles. Eine deaktivierte Rolle zählt wie Standard.</p>
<div style="overflow-x:auto">
<table style="font-size:.82rem;min-width:520px">
<thead><tr><th>Recht</th><th style="text-align:center">Standard</th><?php foreach ($roles as $r): ?><th style="text-align:center"><?= e($r['name']) ?><?= ($r['is_active'] ?? 1) ? '' : ' (aus)' ?></th><?php endforeach; ?></tr></thead>
<tbody>
<?php foreach ($catalog as $group): ?>
<tr><td colspan="<?= 2 + count($roles) ?>" style="background:var(--bg-soft);font-weight:700"><?= e($group['title']) ?></td></tr>
<?php foreach ($group['items'] as $key => $item): ?>
<tr><td><?= e($item['label']) ?></td>
<td style="text-align:center;color:<?= !empty($defaults[$key]) ? 'var(--success)' : 'var(--muted)' ?>"><?= !empty($defaults[$key]) ? '✓' : '–' ?></td>
<?php foreach ($roles as $r): $on = ($r['is_active'] ?? 1) ? !empty($roleRights[(int) $r['id']][$key]) : !empty($defaults[$key]); ?>
<td style="text-align:center;color:<?= $on ? 'var(--success)' : 'var(--muted)' ?>"><?= $on ? '✓' : '–' ?></td>
<?php endforeach; ?></tr>
<?php endforeach; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
</details>
</div>
<div class="card">
<table>
<thead><tr><th>Rollenname</th><th>Status</th><th style="text-align:center">Zugewiesen</th><th>Erstellt</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
<thead><tr><th>Rollenname</th><th>Status</th><th style="text-align:center">Rechte</th><th style="text-align:center">Zugewiesen</th><th>Erstellt</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
<tbody>
<?php foreach ($roles as $r): ?>
<tr>
<td><a href="/staff/roles.php?edit=<?= (int) $r['id'] ?>"><?= e($r['name']) ?></a></td>
<td><a href="/staff/roles.php?edit=<?= (int) $r['id'] ?>"><?= e($r['name']) ?></a><?php if (in_array(mb_strtolower((string) $r['name']), $presetNames, true)): ?> <span class="badge" style="background:#e6f4f3;color:#0f6f6a;font-weight:600;font-size:.68rem">vordefiniert</span><?php endif; ?></td>
<td><strong><?= ($r['is_active'] ?? 1) ? 'Aktiv' : 'Deaktiviert' ?></strong></td>
<td style="text-align:center"><?= count(array_filter($roleRights[(int) $r['id']])) ?> / <?= count($permLabels) ?></td>
<td style="text-align:center"><?= (int) $r['staff_count'] ?></td>
<td class="muted" style="font-size:.82rem"><?= e(ui_date($r['created_at'] ?? null)) ?></td>
<td class="muted" style="font-size:.82rem"><?= e(ui_date($r['updated_at'] ?? null)) ?></td>
@@ -190,7 +248,7 @@ require __DIR__ . '/_header.php';
</td>
</tr>
<?php endforeach; ?>
<?php if (!$roles): ?><tr><td colspan="6" class="muted">Noch keine benutzerdefinierten Rollen angelegt.</td></tr><?php endif; ?>
<?php if (!$roles): ?><tr><td colspan="7" class="muted">Noch keine benutzerdefinierten Rollen angelegt.</td></tr><?php endif; ?>
</tbody>
</table>
</div>
+6
View File
@@ -55,6 +55,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
set_setting('auto_close_enabled', !empty($_POST['auto_close_enabled']) ? '1' : '0');
$autoCloseDays = max(1, min(90, (int) ($_POST['auto_close_days'] ?? 7)));
set_setting('auto_close_days', (string) $autoCloseDays);
set_setting('sla_notify_mode', ($_POST['sla_notify_mode'] ?? 'admins') === 'assignee' ? 'assignee' : 'admins');
set_setting('wait_reminder_enabled', !empty($_POST['wait_reminder_enabled']) ? '1' : '0');
set_setting('wait_reminder_hours', (string) max(1, min(72, (int) ($_POST['wait_reminder_hours'] ?? 4))));
set_setting('wait_reminder_email', !empty($_POST['wait_reminder_email']) ? '1' : '0');
@@ -582,6 +583,11 @@ require __DIR__ . '/_header.php';
<input type="number" name="auto_close_days" value="<?= (int) get_setting('auto_close_days','7') ?>" min="1" max="90" style="width:90px">
<span class="muted" style="font-size:.82rem">Tage</span>
</div>
<label for="sla_notify_mode" style="margin-top:12px">Bei überschrittener SLA-Frist benachrichtigen</label>
<select id="sla_notify_mode" name="sla_notify_mode" style="max-width:420px">
<option value="admins" <?= sla_notify_mode() === 'admins' ? 'selected' : '' ?>>Nur die Administratoren (per E-Mail)</option>
<option value="assignee" <?= sla_notify_mode() === 'assignee' ? 'selected' : '' ?>>Bearbeiter bzw. Abteilung (E-Mail und Push in der App)</option>
</select>
<?php $waitOn = wait_reminder_enabled(); ?>
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
<input type="checkbox" name="wait_reminder_enabled" value="1" style="width:auto" id="wait_reminder_enabled" onchange="document.getElementById('wait-reminder-row').style.display=this.checked?'block':'none'" <?= $waitOn ? 'checked' : '' ?>>
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/_ui.php';
$staff = require_admin();
$staff = require_admin_or_permission('manage_site_pages');
$pdo = get_db();
$errors = [];
+304 -37
View File
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/webauthn.php';
$me = require_admin_or_permission('manage_staff');
$isFullAdmin = $me['role'] === 'admin';
$canManageTeams = has_permission($me, 'manage_teams');
@@ -10,18 +11,7 @@ $departments = $pdo->query('SELECT * FROM departments ORDER BY name')->fetchAll(
$customRoles = $pdo->query('SELECT * FROM roles ORDER BY name')->fetchAll();
$teams = $pdo->query('SELECT id, name, is_active FROM teams ORDER BY name')->fetchAll();
$permLabels = [
'reply_ticket' => 'Tickets beantworten',
'close_ticket' => 'Tickets schließen/lösen',
'delete_ticket' => 'Tickets löschen',
'transfer_ticket' => 'Tickets übertragen',
'post_note' => 'Interne Notizen schreiben',
'assign_ticket' => 'Tickets zuweisen',
'manage_canned' => 'Textbausteine verwalten',
'view_reports' => 'Berichte einsehen',
'manage_kb' => 'Wissensdatenbank verwalten',
'create_ticket' => 'Tickets im Namen von Kunden erstellen',
] + array_diff_key(admin_section_permissions(), ['manage_kb' => 1]);
$permLabels = permission_labels();
/** Setzt die zusätzlichen Abteilungen (Erweiterter Zugriff) neu. */
function save_staff_access(PDO $pdo, int $id, ?int $primaryDeptId, array $post): void
@@ -116,12 +106,84 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect('/staff/staff_manage.php?edit=' . $id . '&tab=access');
}
if ($action === 'unlock_login') {
$id = (int) $_POST['id'];
$exists = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ?');
$exists->execute([$id]);
if (!(int) $exists->fetchColumn()) {
flash('error', 'Mitarbeiter nicht gefunden.');
} else {
login_guard_unlock($pdo, $id, (int) $me['id']);
flash('success', 'Die Anmeldesperre ist aufgehoben. Der Mitarbeiter kann sich sofort wieder anmelden.');
}
redirect('/staff/staff_manage.php');
}
if ($action === 'sign_out_all') {
$id = (int) $_POST['id'];
$exists = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ?');
$exists->execute([$id]);
if (!(int) $exists->fetchColumn()) {
flash('error', 'Mitarbeiter nicht gefunden.');
} else {
$r = staff_sign_out_everywhere($pdo, $id, (int) $me['id'], $id === (int) $me['id']);
flash('success', 'Mitarbeiter von allen Geräten abgemeldet. Beendet: App-Anmeldungen ' . $r['apps'] . ', Browser-Push ' . $r['browsers'] . ', App-Geräte ' . $r['devices'] . '.');
}
redirect('/staff/staff_manage.php');
}
if ($action === 'remove_passkey') {
$id = (int) $_POST['id'];
$q = $pdo->prepare('SELECT id, name, email FROM staff WHERE id = ?');
$q->execute([$id]);
$target = $q->fetch();
$del = $pdo->prepare('DELETE FROM staff_passkeys WHERE id = ? AND staff_id = ?');
if ($target) $del->execute([(int) ($_POST['passkey_id'] ?? 0), $id]);
if ($target && $del->rowCount() > 0) {
audit_log((int) $me['id'], "Passkey von Mitarbeiter #{$id} entfernt");
login_guard_record($pdo, 'passkey_removed', $id, (string) $target['email'], 'web', '1 Passkey durch Administrator #' . (int) $me['id'] . ' entfernt');
if ($id !== (int) $me['id'] && trim((string) $target['email']) !== '') {
send_mail((string) $target['email'], 'Ein Passkey wurde entfernt',
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;">Passkey entfernt</h2><p style="margin:0;color:#334155">Ein Administrator hat einen Ihrer Passkeys in DeskHub entfernt. Sie können sich weiter mit Passwort (und 2FA) anmelden. Haben Sie das nicht erwartet, wenden Sie sich an Ihre Administration.</p>'));
}
flash('success', 'Passkey entfernt.');
} else {
flash('error', 'Passkey nicht gefunden.');
}
redirect('/staff/staff_manage.php?edit=' . $id);
}
if ($action === 'remove_passkeys') {
$id = (int) $_POST['id'];
$q = $pdo->prepare('SELECT id, name, email FROM staff WHERE id = ?');
$q->execute([$id]);
$target = $q->fetch();
if (!$target) {
flash('error', 'Mitarbeiter nicht gefunden.');
} else {
$n = webauthn_remove_all($pdo, $id);
if ($n > 0) {
audit_log((int) $me['id'], "Passkeys von Mitarbeiter #{$id} entfernt ({$n})");
login_guard_record($pdo, 'passkey_removed', $id, (string) $target['email'], 'web', $n . ' Passkey(s) durch Administrator #' . (int) $me['id'] . ' entfernt');
if ($id !== (int) $me['id'] && trim((string) $target['email']) !== '') {
send_mail((string) $target['email'], 'Ihre Passkeys wurden entfernt',
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;">Passkeys entfernt</h2><p style="margin:0;color:#334155">Ein Administrator hat Ihre Passkeys in DeskHub entfernt. Sie können sich weiter mit Passwort (und 2FA) anmelden und im Profil unter „Sicherheit“ neue Passkeys einrichten. Haben Sie das nicht erwartet, wenden Sie sich an Ihre Administration.</p>'));
}
flash('success', $n . ' Passkey' . ($n === 1 ? '' : 's') . ' von ' . $target['name'] . ' entfernt. Die Anmeldung mit Passwort bleibt möglich.');
} else {
flash('info', 'Dieser Mitarbeiter hat keine Passkeys.');
}
}
redirect(($_POST['back'] ?? '') === 'edit' ? '/staff/staff_manage.php?edit=' . $id : '/staff/staff_manage.php');
}
if ($action === 'delete') {
$id = (int) $_POST['id'];
if ($id === (int) $me['id']) {
flash('error', 'Sie können Ihr eigenes Konto nicht löschen.');
} else {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = NULL WHERE assigned_staff_id = ?')->execute([$id]);
webauthn_remove_all($pdo, $id);
$pdo->prepare('DELETE FROM staff WHERE id = ?')->execute([$id]);
audit_log($me['id'], "Mitarbeiter gelöscht: #{$id}");
flash('success', 'Mitarbeiter gelöscht.');
@@ -159,10 +221,17 @@ if ($_POST && $errors) { // eingegebene Auswahl bei Fehlern erhalten
}
$allStaff = $pdo->query(
'SELECT s.*, d.name AS department_name,
(SELECT COUNT(*) FROM team_members tm WHERE tm.staff_id = s.id) AS team_count
FROM staff s LEFT JOIN departments d ON d.id = s.department_id ORDER BY s.name'
"SELECT s.*, d.name AS department_name,
(SELECT r.name FROM roles r WHERE r.id = s.role_id) AS role_name,
(SELECT COUNT(*) FROM team_members tm WHERE tm.staff_id = s.id) AS team_count,
(SELECT COUNT(*) FROM tickets t WHERE t.assigned_staff_id = s.id AND t.is_spam = 0 AND t.status NOT IN ('resolved','closed')) AS open_tickets
FROM staff s LEFT JOIN departments d ON d.id = s.department_id ORDER BY s.name"
)->fetchAll();
// Weitere Abteilungen je Mitarbeiter (zusätzlich zur Hauptabteilung)
$extraDepts = [];
foreach ($pdo->query('SELECT sd.staff_id, d.name FROM staff_department sd JOIN departments d ON d.id = sd.department_id ORDER BY d.name')->fetchAll() as $r) {
$extraDepts[(int) $r['staff_id']][] = (string) $r['name'];
}
$fmt = fn(?string $dt) => $dt ? date('d.m.Y', strtotime($dt)) : '–';
$pageTitle = 'Mitarbeiter';
@@ -294,6 +363,32 @@ require __DIR__ . '/_header.php';
<a href="/staff/staff_manage.php" class="btn secondary">Abbrechen</a>
</div>
</form>
<?php if ($editStaff): ?>
<?php $editPasskeys = webauthn_staff_passkeys($pdo, (int) $editStaff['id']); ?>
<div class="card" style="max-width:960px;margin-top:18px">
<h3 style="margin-top:0">🔑 Passkeys von <?= e($editStaff['name']) ?></h3>
<?php if (!webauthn_enabled()): ?><div class="flash info">Passkeys sind zurzeit ausgeschaltet (Adminportal → Anmelde-Wächter). Vorhandene Passkeys gelten nicht, bleiben aber gespeichert.</div><?php endif; ?>
<?php if (!$editPasskeys): ?>
<p class="muted" style="margin:0">Dieser Mitarbeiter hat keinen Passkey. Einrichten kann er ihn selbst unter Mein Profil → Sicherheit.</p>
<?php else: ?>
<table style="width:100%;font-size:.88rem">
<thead><tr style="text-align:left"><th>Name</th><th>Hinzugefügt</th><th>Zuletzt benutzt</th><th></th></tr></thead>
<tbody>
<?php foreach ($editPasskeys as $pk): ?>
<tr>
<td><?= e($pk['name']) ?></td>
<td><?= e(date('d.m.Y H:i', strtotime((string) $pk['created_at']))) ?></td>
<td><?= $pk['last_used_at'] ? e(date('d.m.Y H:i', strtotime((string) $pk['last_used_at']))) : 'noch nie' ?></td>
<td style="text-align:right"><form method="post" onsubmit="return confirm('Diesen Passkey entfernen?')"><?= csrf_field() ?><input type="hidden" name="action" value="remove_passkey"><input type="hidden" name="id" value="<?= (int) $editStaff['id'] ?>"><input type="hidden" name="passkey_id" value="<?= (int) $pk['id'] ?>"><button class="btn secondary" type="submit" style="padding:3px 10px;font-size:.8rem">Entfernen</button></form></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<form method="post" style="margin-top:12px" onsubmit="return confirm('Alle <?= count($editPasskeys) ?> Passkey(s) dieses Mitarbeiters entfernen? Er kann sich danach nur noch mit Passwort (und 2FA) anmelden.')"><?= csrf_field() ?><input type="hidden" name="action" value="remove_passkeys"><input type="hidden" name="id" value="<?= (int) $editStaff['id'] ?>"><input type="hidden" name="back" value="edit"><button class="btn secondary" type="submit" style="border-color:var(--danger);color:var(--danger)">Alle Passkeys entfernen</button></form>
<p class="muted" style="font-size:.8rem;margin-bottom:0">Der Mitarbeiter bekommt eine Mail. Anmelden mit Passwort (und 2FA) bleibt möglich.</p>
<?php endif; ?>
</div>
<?php endif; ?>
<style>
.tab-btn{background:none;border:1px solid transparent;border-bottom:none;padding:8px 16px;cursor:pointer;border-radius:6px 6px 0 0;color:var(--muted)}
@@ -368,38 +463,210 @@ require __DIR__ . '/_header.php';
</script>
<?php else: ?>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
<h1 style="margin:0">Mitarbeiter</h1>
<?php
$ago = function (?string $dt): string {
if (!$dt) return 'noch nie';
$d = time() - strtotime($dt);
if ($d < 300) return 'online';
if ($d < 3600) return 'vor ' . floor($d / 60) . ' Min';
if ($d < 86400) return 'vor ' . floor($d / 3600) . ' Std';
if ($d < 86400 * 60) return 'vor ' . floor($d / 86400) . ' Tagen';
return date('d.m.Y', strtotime($dt));
};
$staffRows = [];
$nAdmin = $nAgent = $nOff = $nLocked = 0;
foreach ($allStaff as $s) {
$locked = $s['is_active'] && login_guard_is_locked($pdo, (string) $s['email']);
if (!$s['is_active']) $nOff++;
elseif ($s['role'] === 'admin') $nAdmin++;
else $nAgent++;
if ($locked) $nLocked++;
$staffRows[] = $s + ['_locked' => $locked, '_passkeys' => webauthn_count_passkeys($pdo, (int) $s['id'])];
}
?>
<style>
.sm-bar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin:16px 0 12px; }
.sm-chip { border:1px solid var(--border,#e2e8f0); background:var(--surface,#fff); color:var(--muted,#64748b); border-radius:999px; padding:5px 14px; font:inherit; font-size:.82rem; font-weight:600; cursor:pointer; }
.sm-chip.on { background:var(--primary,#0f6f6a); border-color:var(--primary,#0f6f6a); color:#fff; }
.sm-chip small { opacity:.8; margin-left:4px; font-weight:700; }
.sm-search { margin-left:auto; max-width:260px; flex:1 1 180px; }
.sm-table { width:100%; border-collapse:collapse; }
.sm-table th { text-align:left; font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted,#64748b); padding:10px 12px; border-bottom:1px solid var(--border,#e2e8f0); white-space:nowrap; }
.sm-table td { padding:12px; border-bottom:1px solid var(--border-soft,#eef2f6); vertical-align:middle; }
.sm-table tr:last-child td { border-bottom:0; }
.sm-table tbody tr:hover { background:var(--bg-soft,#f8fafc); }
.sm-person { display:flex; align-items:center; gap:12px; min-width:220px; }
.sm-avatar { width:40px; height:40px; border-radius:50%; flex-shrink:0; object-fit:cover; display:flex; align-items:center; justify-content:center; background:var(--primary,#0f6f6a); color:#fff; font-weight:700; font-size:.95rem; position:relative; }
.sm-person a.sm-name { font-weight:700; color:var(--ink,#0f172a); text-decoration:none; }
.sm-person a.sm-name:hover { color:var(--primary,#0f6f6a); }
.sm-mail { font-size:.8rem; color:var(--muted,#64748b); word-break:break-all; }
.sm-badge { display:inline-block; font-size:.72rem; font-weight:700; border-radius:999px; padding:2px 10px; white-space:nowrap; }
.sm-b-admin { background:#ede9fe; color:#6d28d9; }
.sm-b-role { background:#e0f2fe; color:#0369a1; }
.sm-b-agent { background:#f1f5f9; color:#475569; }
.sm-b-ok { background:#dcfce7; color:#15803d; }
.sm-b-off { background:#fee2e2; color:#b91c1c; }
.sm-b-warn { background:#fef3c7; color:#b45309; }
.sm-b-vac { background:#e0e7ff; color:#4338ca; }
.sm-dept { font-size:.86rem; }
.sm-dept span { display:inline-block; font-size:.74rem; color:var(--muted,#64748b); background:var(--bg-soft,#f1f5f9); border-radius:6px; padding:1px 7px; margin:2px 3px 0 0; }
.sm-num { font-weight:700; font-size:1rem; }
.sm-dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#94a3b8; margin-right:6px; }
.sm-dot.on { background:#22c55e; }
.sm-actions { display:flex; align-items:center; gap:8px; justify-content:flex-end; white-space:nowrap; }
.sm-menu { position:relative; }
.sm-menu > summary { list-style:none; cursor:pointer; border:1px solid var(--border,#e2e8f0); border-radius:8px; width:34px; height:34px; display:flex; align-items:center; justify-content:center; font-size:1.1rem; color:var(--muted,#64748b); background:var(--surface,#fff); }
.sm-menu > summary::-webkit-details-marker { display:none; }
.sm-menu[open] > summary { background:var(--bg-soft,#f1f5f9); }
/* fixed statt absolute: Der Rahmen der Tabelle (overflow) würde das Menü sonst abschneiden; die Lage setzt das Skript unten */
.sm-menu-list { position:fixed; left:auto; top:0; right:0; z-index:1000; min-width:210px; background:var(--surface,#fff); border:1px solid var(--border,#e2e8f0); border-radius:10px; box-shadow:0 10px 30px rgba(15,23,42,.16); padding:6px; }
.sm-menu-list form { margin:0; }
.sm-menu-list button { display:block; width:100%; text-align:left; background:none; border:0; border-radius:6px; padding:8px 10px; font:inherit; font-size:.88rem; cursor:pointer; color:var(--ink,#0f172a); }
.sm-menu-list button:hover { background:var(--bg-soft,#f1f5f9); }
.sm-menu-list button.danger { color:#dc2626; }
.sm-empty { padding:26px; text-align:center; color:var(--muted,#64748b); display:none; }
@media (max-width:1100px) { .sm-hide-m { display:none; } }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<div>
<h1 style="margin:0">Mitarbeiter</h1>
<div class="muted" style="font-size:.86rem;margin-top:2px"><?= count($staffRows) ?> Konten<?= $nLocked ? ' · ' . $nLocked . ' mit zeitweise gesperrter Anmeldung' : '' ?></div>
</div>
<a href="/staff/staff_manage.php?new=1" class="btn">Neuen Agenten hinzufügen</a>
</div>
<div class="card" style="margin-top:14px">
<table>
<thead><tr><th>Name</th><th>E-Mail</th><th>Typ</th><th>Status</th><th>Hauptabteilung</th><th style="text-align:center">Teams</th><th>Erstellt</th><th></th></tr></thead>
<div class="sm-bar" id="sm-bar">
<button type="button" class="sm-chip on" data-f="all">Alle<small><?= count($staffRows) ?></small></button>
<button type="button" class="sm-chip" data-f="admin">Administratoren<small><?= $nAdmin ?></small></button>
<button type="button" class="sm-chip" data-f="agent">Mitarbeiter<small><?= $nAgent ?></small></button>
<?php if ($nOff): ?><button type="button" class="sm-chip" data-f="off">Deaktiviert<small><?= $nOff ?></small></button><?php endif; ?>
<?php if ($nLocked): ?><button type="button" class="sm-chip" data-f="locked">Gesperrt<small><?= $nLocked ?></small></button><?php endif; ?>
<input type="search" id="sm-search" class="sm-search" placeholder="Mitarbeiter suchen …" aria-label="Mitarbeiter suchen">
</div>
<div class="card" style="padding:0;overflow:visible">
<div style="overflow-x:auto;overflow-y:visible">
<table class="sm-table" id="sm-table">
<thead><tr>
<th>Mitarbeiter</th><th>Rolle</th><th>Abteilung</th><th class="sm-hide-m" style="text-align:center">Offene Tickets</th><th class="sm-hide-m">Zuletzt aktiv</th><th>Status</th><th></th>
</tr></thead>
<tbody>
<?php foreach ($allStaff as $s): ?>
<tr>
<td><a href="/staff/staff_manage.php?edit=<?= (int) $s['id'] ?>"><?= e($s['name']) ?></a></td>
<td><?= e($s['email']) ?></td>
<td><?= $s['role'] === 'admin' ? 'Administrator' : 'Mitarbeiter' ?></td>
<td><strong><?= $s['is_active'] ? 'Aktiv' : 'Gesperrt' ?></strong></td>
<td><?= e($s['department_name'] ?? '–') ?></td>
<td style="text-align:center"><?= (int) $s['team_count'] ?></td>
<td class="muted" style="font-size:.82rem"><?= e($fmt($s['created_at'] ?? null)) ?></td>
<td style="white-space:nowrap">
<a href="/staff/staff_manage.php?edit=<?= (int) $s['id'] ?>">Bearbeiten</a> ·
<form method="post" style="display:inline" onsubmit="return confirm('Mitarbeiter wirklich löschen?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= (int) $s['id'] ?>">
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
</form>
<?php foreach ($staffRows as $s):
$sid = (int) $s['id'];
$isMe = $sid === (int) $me['id'];
$isAdmin = $s['role'] === 'admin';
$initials = mb_strtoupper(mb_substr(trim((string) $s['name']), 0, 1)) . (preg_match('/\s(\S)/u', trim((string) $s['name']), $mm) ? mb_strtoupper($mm[1]) : '');
$online = !empty($s['last_active_at']) && (time() - strtotime((string) $s['last_active_at'])) < 300;
$group = !$s['is_active'] ? 'off' : ($isAdmin ? 'admin' : 'agent');
$depts = $extraDepts[$sid] ?? [];
$depts = array_values(array_filter($depts, fn($n) => $n !== (string) ($s['department_name'] ?? '')));
$haystack = mb_strtolower($s['name'] . ' ' . $s['email'] . ' ' . ($s['department_name'] ?? '') . ' ' . implode(' ', $depts) . ' ' . ($s['role_name'] ?? ''));
?>
<tr data-group="<?= $group ?>" data-locked="<?= $s['_locked'] ? '1' : '0' ?>" data-text="<?= e($haystack) ?>">
<td>
<div class="sm-person">
<?php if (!empty($s['avatar_path'])): ?><img class="sm-avatar" src="<?= e($s['avatar_path']) ?>" alt="">
<?php else: ?><span class="sm-avatar" aria-hidden="true"><?= e($initials ?: '?') ?></span><?php endif; ?>
<div>
<a class="sm-name" href="/staff/staff_manage.php?edit=<?= $sid ?>"><?= e($s['name']) ?></a><?php if ($isMe): ?> <span class="muted" style="font-size:.74rem">(Sie)</span><?php endif; ?>
<div class="sm-mail"><?= e($s['email']) ?></div>
</div>
</div>
</td>
<td>
<?php if ($isAdmin): ?><span class="sm-badge sm-b-admin">Administrator</span>
<?php elseif (!empty($s['role_name'])): ?><span class="sm-badge sm-b-role"><?= e($s['role_name']) ?></span>
<?php else: ?><span class="sm-badge sm-b-agent">Mitarbeiter</span><?php endif; ?>
<?php if (!empty($s['totp_enabled'])): ?><div class="muted" style="font-size:.72rem;margin-top:4px" title="Zwei-Faktor-Anmeldung ist eingeschaltet">🔒 2FA an</div><?php endif; ?>
<?php if (!empty($s['_passkeys'])): ?><div class="muted" style="font-size:.72rem;margin-top:2px" title="Der Mitarbeiter kann sich mit Passkey anmelden">🔑 <?= (int) $s['_passkeys'] ?> Passkey<?= (int) $s['_passkeys'] === 1 ? '' : 's' ?></div><?php endif; ?>
</td>
<td class="sm-dept">
<?= e($s['department_name'] ?? '–') ?>
<?php if ($depts || (int) $s['team_count']): ?>
<div>
<?php foreach ($depts as $dn): ?><span><?= e($dn) ?></span><?php endforeach; ?>
<?php if ((int) $s['team_count']): ?><span><?= (int) $s['team_count'] ?> Team<?= (int) $s['team_count'] === 1 ? '' : 's' ?></span><?php endif; ?>
</div>
<?php endif; ?>
</td>
<td class="sm-hide-m" style="text-align:center"><span class="sm-num"><?= (int) $s['open_tickets'] ?></span></td>
<td class="sm-hide-m" style="font-size:.86rem;white-space:nowrap"><span class="sm-dot <?= $online ? 'on' : '' ?>"></span><?= e($ago($s['last_active_at'] ?? null)) ?></td>
<td>
<?php if (!$s['is_active']): ?><span class="sm-badge sm-b-off">Deaktiviert</span>
<?php else: ?><span class="sm-badge sm-b-ok">Aktiv</span><?php endif; ?>
<?php if ($s['is_active'] && !empty($s['is_on_vacation'])): ?><span class="sm-badge sm-b-vac">Im Urlaub</span><?php endif; ?>
<?php if ($s['_locked']): ?><div style="margin-top:4px"><span class="sm-badge sm-b-warn" title="Zu viele falsche Anmeldeversuche">Anmeldung zeitweise gesperrt</span></div><?php endif; ?>
</td>
<td>
<div class="sm-actions">
<a class="btn secondary" style="padding:6px 14px;font-size:.84rem" href="/staff/staff_manage.php?edit=<?= $sid ?>">Bearbeiten</a>
<details class="sm-menu">
<summary title="Weitere Aktionen" aria-label="Weitere Aktionen für <?= e($s['name']) ?>">⋯</summary>
<div class="sm-menu-list">
<?php if ($s['_locked']): ?>
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="unlock_login"><input type="hidden" name="id" value="<?= $sid ?>"><button type="submit">Sperre aufheben</button></form>
<?php endif; ?>
<?php if (!empty($s['_passkeys'])): ?>
<form method="post" onsubmit="return confirm('Alle <?= (int) $s['_passkeys'] ?> Passkey(s) dieses Mitarbeiters entfernen? Er kann sich danach nur noch mit Passwort (und 2FA) anmelden.');"><?= csrf_field() ?><input type="hidden" name="action" value="remove_passkeys"><input type="hidden" name="id" value="<?= $sid ?>"><button type="submit">Passkeys entfernen (<?= (int) $s['_passkeys'] ?>)</button></form>
<?php endif; ?>
<form method="post" onsubmit="return confirm('Diesen Mitarbeiter von allen Geräten abmelden? Er muss sich danach überall neu anmelden.');"><?= csrf_field() ?><input type="hidden" name="action" value="sign_out_all"><input type="hidden" name="id" value="<?= $sid ?>"><button type="submit">Überall abmelden</button></form>
<?php if (!$isMe): ?>
<form method="post" onsubmit="return confirm('Mitarbeiter wirklich löschen?');"><?= csrf_field() ?><input type="hidden" name="action" value="delete"><input type="hidden" name="id" value="<?= $sid ?>"><button type="submit" class="danger">Löschen</button></form>
<?php endif; ?>
</div>
</details>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="sm-empty" id="sm-empty">Keine Mitarbeiter gefunden.</div>
</div>
<script>
(function () {
var rows = Array.prototype.slice.call(document.querySelectorAll('#sm-table tbody tr'));
var chips = Array.prototype.slice.call(document.querySelectorAll('#sm-bar .sm-chip'));
var search = document.getElementById('sm-search');
var empty = document.getElementById('sm-empty');
var filter = 'all';
function apply() {
var q = (search.value || '').trim().toLowerCase();
var shown = 0;
rows.forEach(function (tr) {
var ok = filter === 'all' || (filter === 'locked' ? tr.dataset.locked === '1' : tr.dataset.group === filter);
if (ok && q && tr.dataset.text.indexOf(q) === -1) ok = false;
tr.style.display = ok ? '' : 'none';
if (ok) shown++;
});
empty.style.display = shown ? 'none' : 'block';
}
chips.forEach(function (c) { c.addEventListener('click', function () { filter = c.dataset.f; chips.forEach(function (x) { x.classList.toggle('on', x === c); }); apply(); }); });
search.addEventListener('input', apply);
// Aktionsmenüs: nur eines offen, Klick daneben oder Esc schließt
var menus = Array.prototype.slice.call(document.querySelectorAll('.sm-menu'));
// Menü direkt unter dem Knopf anzeigen, bei zu wenig Platz darüber; rechtsbündig mit dem Knopf
function place(m) {
var list = m.querySelector('.sm-menu-list');
var r = m.querySelector('summary').getBoundingClientRect();
list.style.visibility = 'hidden';
var h = list.offsetHeight;
var top = r.bottom + 6;
if (top + h > window.innerHeight - 8) top = Math.max(8, r.top - h - 6);
list.style.top = top + 'px';
list.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
list.style.visibility = 'visible';
}
menus.forEach(function (m) { m.addEventListener('toggle', function () { if (m.open) { menus.forEach(function (o) { if (o !== m) o.open = false; }); place(m); } }); });
function closeAll() { menus.forEach(function (m) { m.open = false; }); }
window.addEventListener('resize', closeAll);
window.addEventListener('scroll', closeAll, true);
document.addEventListener('click', function (e) { menus.forEach(function (m) { if (m.open && !m.contains(e.target)) m.open = false; }); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') menus.forEach(function (m) { m.open = false; }); });
})();
</script>
<?php endif; ?>
<?php require __DIR__ . '/_footer.php'; ?>
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$admin = require_admin();
$admin = require_admin_or_permission('manage_status_page');
$pdo = get_db();
$errors = [];
@@ -5,7 +5,7 @@
* Gibt max. 8 Tickets zurück die zur Suchanfrage passen.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
$staff = require_admin_or_permission('merge_split_ticket');
$pdo = get_db();
header('Content-Type: application/json');
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/_ui.php';
$staff = require_admin();
$staff = require_admin_or_permission('manage_automation');
$pdo = get_db();
$departments = departments_for_select($pdo, array_map('intval', $pdo->query("SELECT action_value FROM ticket_rule_actions WHERE action_type = 'set_department'")->fetchAll(PDO::FETCH_COLUMN)));
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
$staff = require_admin_or_permission('merge_split_ticket');
$pdo = get_db();
$ticketId = (int)($_GET['id'] ?? 0);
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$admin = require_admin();
$admin = require_admin_or_permission('manage_ticket_templates');
$pdo = get_db();
$errors = [];
+19 -12
View File
@@ -163,6 +163,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Kunden sperren (eigener Block ohne globale verify_csrf)
if (($_POST['action'] ?? '') === 'ban_user') {
verify_csrf();
if (!has_permission($staff, 'manage_banlist')) {
flash('error', 'Für die Sperrliste fehlt die erforderliche Berechtigung.');
redirect('/staff/ticket_view.php?n=' . urlencode((string) ($_GET['n'] ?? '')));
}
$banEmail = trim($_POST['ban_email'] ?? '');
$banDomain = trim($_POST['ban_domain'] ?? '');
$banReason = trim($_POST['ban_reason'] ?? '');
@@ -216,6 +220,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'delete_ticket') $requirePerm('delete_ticket');
if ($action === 'mark_spam' || $action === 'restore_spam') $requirePerm('close_ticket');
if ($action === 'reload_mail_attachments') $requirePerm('reply_ticket');
if ($action === 'merge_ticket') $requirePerm('merge_split_ticket');
if ($action === 'link_add' || $action === 'link_remove') $requirePerm('link_tickets');
if ($action === 'add_comment') {
$threadId = (int)($_POST['thread_id'] ?? 0);
@@ -583,7 +589,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($action === 'link_add' || $action === 'link_remove') {
$requirePerm('reply_ticket');
// Berechtigung „Tickets verknüpfen“ wurde oben geprüft
if ($action === 'link_add') {
$err = ticket_link_add($pdo, (int) $ticket['id'], (string) ($_POST['link_number'] ?? ''), (string) ($_POST['link_type'] ?? 'related'), (int) $staff['id'], staff_department_ids($staff));
if ($err) flash('error', $err);
@@ -599,7 +605,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($action === 'problem_link') {
if ($staff['role'] !== 'admin') { flash('error', 'Nur Administratoren dürfen Tickets mit Problemen verknüpfen.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); }
if (!has_permission($staff, 'manage_problems')) { flash('error', 'Für das Problemmanagement fehlt die erforderliche Berechtigung.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); }
$problemIdToLink = (int) ($_POST['problem_id'] ?? 0);
if (!ticket_problem_link($pdo, $problemIdToLink, (int) $ticket['id'], (int) $staff['id'])) flash('error', 'Problem konnte nicht gefunden werden.');
else { log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Mit ' . ticket_problem_number($problemIdToLink) . ' verknüpft'); flash('success', 'Ticket mit Problem verknüpft.'); }
@@ -607,7 +613,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($action === 'problem_unlink') {
if ($staff['role'] !== 'admin') { flash('error', 'Nur Administratoren dürfen Problem-Verknüpfungen ändern.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); }
if (!has_permission($staff, 'manage_problems')) { flash('error', 'Für das Problemmanagement fehlt die erforderliche Berechtigung.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); }
$problemIdToUnlink = (int) ($_POST['problem_id'] ?? 0);
ticket_problem_unlink($pdo, $problemIdToUnlink, (int) $ticket['id']);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Problemverknüpfung ' . ticket_problem_number($problemIdToUnlink) . ' entfernt');
@@ -770,7 +776,8 @@ require __DIR__ . '/_header.php';
[$statusLabel, $statusColor] = status_label($ticket['status']);
$slaPaused = !empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']);
$slaHistory = ticket_sla_history_for_ticket($pdo, $ticket);
$isOverdue = !empty($ticket['is_overdue']) || (!$slaPaused && $ticket['resolution_due_at'] && strtotime($ticket['resolution_due_at']) < time() && !in_array($ticket['status'], ['resolved','closed']));
$ticketDone = in_array($ticket['status'], ['resolved','closed'], true); // erledigt: gelöst und geschlossen gelten gleich, die SLA-Uhr zählt nicht mehr
$isOverdue = !$ticketDone && (!empty($ticket['is_overdue']) || (!$slaPaused && $ticket['resolution_due_at'] && strtotime($ticket['resolution_due_at']) < time()));
// Bewertung laden
$ticketRating = null;
if (in_array($ticket['status'], ['resolved','closed'])) {
@@ -1457,7 +1464,7 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<?php endforeach; ?>
<?php if (!$ticketLinks): ?><p class="muted" style="font-size:.84rem">Keine verknüpften Tickets.</p><?php endif; ?>
<?php if ($blockers = ticket_open_blockers($pdo, (int) $ticket['id'])): ?><div style="padding:8px 10px;margin-top:8px;border-radius:7px;background:#fef3c7;color:#92400e;font-size:.82rem">Wird noch blockiert durch <?= e(implode(', ', array_map(fn($b) => '#' . $b['number'], $blockers))) ?>.</div><?php endif; ?>
<form method="post" style="display:flex;gap:6px;align-items:end;margin-top:8px;flex-wrap:wrap"><?= csrf_field() ?><input type="hidden" name="action" value="link_add">
<form method="post" style="display:flex;gap:6px;align-items:end;margin-top:8px;flex-wrap:wrap"<?= has_permission($staff, 'link_tickets', (int) $ticket['department_id']) ? '' : ' hidden' ?>><?= csrf_field() ?><input type="hidden" name="action" value="link_add">
<div style="flex:1;min-width:110px"><label style="font-size:.75rem" for="link_number">Ticketnummer</label><input type="text" id="link_number" name="link_number" required maxlength="40" placeholder="z. B. 923425"></div>
<div style="min-width:130px"><label style="font-size:.75rem" for="link_type">Art</label><select id="link_type" name="link_type"><?php foreach (ticket_link_types() as $lt => $ll): ?><option value="<?= e($lt) ?>"><?= e($ll[0]) ?></option><?php endforeach; ?></select></div>
<button class="btn secondary" type="submit">Verknüpfen</button>
@@ -1469,7 +1476,7 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<?php if ($ticketProblems): foreach ($ticketProblems as $problemItem): ?>
<div style="padding:8px 0;border-top:1px solid var(--border-soft);margin-top:8px"><a href="/staff/problems.php?id=<?= (int) $problemItem['id'] ?>"><strong><?= e(ticket_problem_number((int) $problemItem['id'])) ?> · <?= e($problemItem['title']) ?></strong></a><div class="muted" style="font-size:.78rem;margin-top:3px"><?= e(ticket_problem_statuses()[$problemItem['status']] ?? $problemItem['status']) ?></div><?php if (!empty($problemItem['workaround'])): ?><div style="font-size:.82rem;margin-top:5px"><strong>Workaround:</strong> <?= nl2br(e((string) $problemItem['workaround'])) ?></div><?php endif; ?><?php if ($staff['role'] === 'admin'): ?><form method="post" style="margin-top:6px"><?= csrf_field() ?><input type="hidden" name="action" value="problem_unlink"><input type="hidden" name="problem_id" value="<?= (int) $problemItem['id'] ?>"><button class="btn secondary" type="submit" style="padding:2px 8px;font-size:.75rem">Trennen</button></form><?php endif; ?></div>
<?php endforeach; else: ?><p class="muted" style="font-size:.84rem">Dieses Ticket ist keinem bekannten Problem zugeordnet.</p><?php endif; ?>
<?php if ($staff['role'] === 'admin' && $openProblems): ?><form method="post" style="display:flex;gap:6px;align-items:end;margin-top:8px"><?= csrf_field() ?><input type="hidden" name="action" value="problem_link"><div style="flex:1;min-width:150px"><label style="font-size:.75rem">Mit Problem verknüpfen</label><select name="problem_id" required><?php foreach ($openProblems as $problemOption): ?><option value="<?= (int) $problemOption['id'] ?>"><?= e(ticket_problem_number((int) $problemOption['id']) . ' · ' . $problemOption['title']) ?></option><?php endforeach; ?></select></div><button class="btn secondary" type="submit">Verknüpfen</button></form><?php endif; ?>
<?php if (has_permission($staff, 'manage_problems') && $openProblems): ?><form method="post" style="display:flex;gap:6px;align-items:end;margin-top:8px"><?= csrf_field() ?><input type="hidden" name="action" value="problem_link"><div style="flex:1;min-width:150px"><label style="font-size:.75rem">Mit Problem verknüpfen</label><select name="problem_id" required><?php foreach ($openProblems as $problemOption): ?><option value="<?= (int) $problemOption['id'] ?>"><?= e(ticket_problem_number((int) $problemOption['id']) . ' · ' . $problemOption['title']) ?></option><?php endforeach; ?></select></div><button class="btn secondary" type="submit">Verknüpfen</button></form><?php endif; ?>
</div>
<!-- SLA + Individuelle Felder -->
@@ -1479,15 +1486,15 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px">
<div>
<div style="font-size:.7rem; color:var(--muted-lt); margin-bottom:2px">Erstreaktion</div>
<?php $responseOverdue = !$slaPaused && $ticket['first_response_due_at'] && !$ticket['first_responded_at'] && strtotime($ticket['first_response_due_at']) < time(); ?>
<?php $responseOverdue = !$ticketDone && !$slaPaused && $ticket['first_response_due_at'] && !$ticket['first_responded_at'] && strtotime($ticket['first_response_due_at']) < time(); ?>
<div style="font-size:.8rem; font-weight:700; color:<?= $responseOverdue ? 'var(--danger)' : 'var(--ink)' ?>"><?= format_dt($ticket['first_response_due_at']) ?></div>
<div style="font-size:.74rem; color:<?= $responseOverdue ? 'var(--danger)' : 'var(--muted)' ?>"><?= $ticket['first_responded_at'] ? '✓ beantwortet' : ($slaPaused ? 'Pausiert' : time_until($ticket['first_response_due_at'])) ?></div>
<div style="font-size:.74rem; color:<?= $responseOverdue ? 'var(--danger)' : 'var(--muted)' ?>"><?= $ticket['first_responded_at'] ? '✓ beantwortet' : ($ticketDone ? '–' : ($slaPaused ? 'Pausiert' : time_until($ticket['first_response_due_at']))) ?></div>
</div>
<div>
<div style="font-size:.7rem; color:var(--muted-lt); margin-bottom:2px">Lösung</div>
<?php $resolutionOverdue = !$slaPaused && $ticket['resolution_due_at'] && !$ticket['resolved_at'] && strtotime($ticket['resolution_due_at']) < time(); ?>
<?php $resolutionOverdue = !$ticketDone && !$slaPaused && $ticket['resolution_due_at'] && !$ticket['resolved_at'] && strtotime($ticket['resolution_due_at']) < time(); ?>
<div style="font-size:.8rem; font-weight:700; color:<?= $resolutionOverdue ? 'var(--danger)' : 'var(--ink)' ?>"><?= format_dt($ticket['resolution_due_at']) ?></div>
<div style="font-size:.74rem; color:<?= $resolutionOverdue ? 'var(--danger)' : 'var(--muted)' ?>"><?= $ticket['resolved_at'] ? '✓ gelöst' : ($slaPaused ? 'Pausiert' : time_until($ticket['resolution_due_at'])) ?></div>
<div style="font-size:.74rem; color:<?= $resolutionOverdue ? 'var(--danger)' : 'var(--muted)' ?>"><?= $ticket['resolved_at'] ? '✓ gelöst' : ($ticketDone ? '✓ erledigt' : ($slaPaused ? 'Pausiert' : time_until($ticket['resolution_due_at']))) ?></div>
</div>
</div>
<details style="margin-top:12px;border-top:1px solid var(--border-soft);padding-top:9px">
@@ -1616,7 +1623,7 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<span>Weitere Aktionen</span><span style="font-size:.68rem; transition:transform .2s">▾</span>
</summary>
<div style="border-top:1px solid var(--border-soft); padding:14px 16px; display:flex; flex-direction:column; gap:12px">
<div>
<div<?= has_permission($staff, 'merge_split_ticket', (int) $ticket['department_id']) ? '' : ' hidden' ?>>
<div style="font-size:.79rem; font-weight:600; color:var(--ink-soft); margin-bottom:6px">Tickets zusammenführen</div>
<p style="font-size:.75rem; color:var(--muted); margin:0 0 10px">Verlauf, Tags und Collaborators werden ins Ziel-Ticket verschoben. Dieses Ticket wird geschlossen.</p>
@@ -1726,7 +1733,7 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<?php if ($alreadyBanned): ?>
<span style="background:#fee2e2;color:#dc2626;font-size:.75rem;padding:3px 10px;border-radius:20px;font-weight:600">⛔ Gesperrt</span>
<?php else: ?>
<details style="margin-top:6px">
<details style="margin-top:6px"<?= has_permission($staff, 'manage_banlist') ? '' : ' hidden' ?>>
<summary style="cursor:pointer;font-size:.78rem;color:var(--danger);font-weight:600">Absender sperren…</summary>
<form method="post" style="margin-top:10px" onsubmit="return confirm('Wirklich sperren?')">
<?= csrf_field() ?>
+21 -5
View File
@@ -18,12 +18,16 @@ $resolutionReasons = ticket_resolution_reasons();
require_once __DIR__ . '/../includes/mail_fetch_manual.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'fetch_mail_now') {
verify_csrf();
if (!has_permission($staff, 'fetch_mail')) {
flash('error', 'Für den E-Mail-Abruf fehlt die erforderliche Berechtigung.');
redirect('/staff/tickets.php');
}
$fetch = mail_fetch_manual($pdo);
flash($fetch['level'], $fetch['message']);
audit_log((int) $staff['id'], 'E-Mail-Abruf von Hand gestartet (' . $fetch['processed'] . ' E-Mail(s))');
redirect('/staff/tickets.php' . (($_POST['return_query'] ?? '') !== '' ? '?' . ltrim((string) $_POST['return_query'], '?') : ''));
}
$fetchAvailable = mail_fetch_available($pdo);
$fetchAvailable = mail_fetch_available($pdo) && has_permission($staff, 'fetch_mail');
$fetchLastRun = (string) get_setting('cron_fetch_emails_last_run', '');
// ---------------------------------------------------------------
@@ -35,7 +39,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($_POST['queue_action'] ??
try {
if ($_POST['queue_action'] === 'save') {
parse_str((string) ($_POST['return_query'] ?? ''), $currentGet);
queue_create($pdo, (int) $staff['id'], (string) ($_POST['queue_name'] ?? ''), queue_filters_from_web($currentGet), !empty($_POST['queue_shared']));
queue_create($pdo, (int) $staff['id'], (string) ($_POST['queue_name'] ?? ''), queue_filters_from_web($currentGet), !empty($_POST['queue_shared']) && has_permission($staff, 'share_views'));
flash('success', 'Ansicht gespeichert.');
} else {
$qid = (int) ($_POST['queue_id'] ?? 0);
@@ -123,6 +127,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== '
$movedCount++;
continue;
}
// Rechte je Ticket (in der Abteilung des Tickets): Sammelaktionen dürfen nicht mehr als die Einzelaktionen im Ticket
$needPerm = ['assign' => 'assign_ticket', 'status' => in_array((string) ($_POST['bulk_status'] ?? ''), ['resolved', 'closed'], true) ? 'close_ticket' : 'reply_ticket',
'delete' => 'delete_ticket', 'ban' => 'manage_banlist'][$bulkAction] ?? null;
if ($bulkAction === 'priority') $needPerm = 'assign_ticket';
if ($needPerm !== null && !has_permission($staff, $needPerm, (int) $t['department_id'])) { $bulkDenied = ($bulkDenied ?? 0) + 1; continue; }
if ($bulkAction === 'ban' && !has_permission($staff, 'close_ticket', (int) $t['department_id'])) { $bulkDenied = ($bulkDenied ?? 0) + 1; continue; }
if ($bulkAction === 'assign') {
$newAgent = $_POST['bulk_assigned_staff_id'] !== '' ? (int) $_POST['bulk_assigned_staff_id'] : null;
$pdo->prepare('UPDATE tickets SET assigned_staff_id=?, updated_at=? WHERE id=?')->execute([$newAgent, $now, $t['id']]);
@@ -179,7 +189,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== '
flash($movedCount > 0 ? 'success' : 'error', $msg);
} elseif (!$bulkErrors) {
$verb = ['assign' => 'zugewiesen', 'priority' => 'Priorität geändert', 'status' => 'Status geändert', 'delete' => 'gelöscht'][$bulkAction] ?? 'aktualisiert';
flash('success', count($allowedTickets) . ' Ticket(s) ' . $verb . '.');
$okCount = count($allowedTickets) - (int) ($bulkDenied ?? 0);
if ($okCount > 0) flash('success', $okCount . ' Ticket(s) ' . $verb . '.');
if (!empty($bulkDenied)) flash('error', (int) $bulkDenied . ' Ticket(s) übersprungen: Dafür fehlt die Berechtigung.');
} else {
foreach ($bulkErrors as $e) flash('error', $e);
}
@@ -513,7 +525,7 @@ document.getElementById('qf-panel')?.addEventListener('click', function(e){ e.st
<input type="hidden" name="return_query" value="<?= e($__currentQuery) ?>">
<label for="queue_name" style="margin-top:0">Name der Ansicht</label>
<input type="text" id="queue_name" name="queue_name" maxlength="80" placeholder="z. B. Meine überfälligen" required>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:10px"><input type="checkbox" name="queue_shared" value="1" style="width:auto"> Für alle Mitarbeiter sichtbar</label>
<label style="display:<?= has_permission($staff, 'share_views') ? 'flex' : 'none' ?>;align-items:center;gap:8px;font-weight:400;margin-top:10px"><input type="checkbox" name="queue_shared" value="1" style="width:auto"> Für alle Mitarbeiter sichtbar</label>
<p class="muted" style="font-size:.76rem;margin:8px 0 0">Gespeichert werden: <?= e($__currentFilters ? implode(', ', array_keys($__currentFilters)) : 'keine Filter (alle Tickets)') ?></p>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
@@ -607,7 +619,7 @@ document.getElementById('qf-panel')?.addEventListener('click', function(e){ e.st
Auch Nachrichten durchsuchen
</label>
<div style="flex:1"></div>
<?php if ($staff['role'] === 'admin'): ?>
<?php if (has_permission($staff, 'export_tickets')): ?>
<a href="/staff/export.php?type=tickets&<?= http_build_query($_GET) ?>" class="btn secondary" style="font-size:.8rem;padding:5px 12px">↓ CSV exportieren</a>
<?php endif; ?>
<?php if ($search || $filterStatus || $filterDept || $filterPrio || $filterAssign || $filterTag || $filterSpam): ?>
@@ -660,10 +672,14 @@ document.getElementById('qf-panel')?.addEventListener('click', function(e){ e.st
</div>
<div style="margin-left:auto;display:flex;gap:8px">
<?php if (has_permission($staff, 'manage_banlist') && has_permission($staff, 'close_ticket')): ?>
<button type="button" class="btn secondary" style="border-color:#f59e0b;color:#92400e;white-space:nowrap"
onclick="if(confirm('Alle Absender der markierten Tickets sperren und Tickets schließen?')) submitBulk('ban')">⛔ Absender sperren</button>
<?php endif; ?>
<?php if (has_permission($staff, 'delete_ticket')): ?>
<button type="button" class="btn danger" style="white-space:nowrap"
onclick="if(confirm('Markierte Tickets wirklich unwiderruflich löschen?')) submitBulk('delete')">Löschen</button>
<?php endif; ?>
</div>
</div>
</form>
File diff suppressed because it is too large Load Diff