703 lines
38 KiB
PHP
703 lines
38 KiB
PHP
<?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();
|
||
|
||
$errors = [];
|
||
$tab = $_GET['tab'] ?? 'account';
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
verify_csrf();
|
||
$action = $_POST['action'] ?? '';
|
||
|
||
// ── Konto-Daten ─────────────────────────────────────────────
|
||
if ($action === 'save_account') {
|
||
$name = trim($_POST['name'] ?? '');
|
||
$email = trim($_POST['email'] ?? '');
|
||
$phone = trim($_POST['phone'] ?? '');
|
||
|
||
if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
|
||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte eine gültige E-Mail-Adresse angeben.';
|
||
|
||
// E-Mail-Duplikat prüfen (andere Mitarbeiter)
|
||
if (!$errors) {
|
||
$dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE LOWER(email) = LOWER(?) AND id != ?');
|
||
$dup->execute([$email, $staff['id']]);
|
||
if ((int)$dup->fetchColumn()) $errors[] = 'Diese E-Mail-Adresse wird bereits von einem anderen Mitarbeiter verwendet.';
|
||
}
|
||
|
||
if (!$errors) {
|
||
$pdo->prepare('UPDATE staff SET name=?, email=?, phone=? WHERE id=?')
|
||
->execute([$name, $email, $phone ?: null, $staff['id']]);
|
||
audit_log($staff['id'], 'Profil aktualisiert: Name/E-Mail/Telefon');
|
||
flash('success', 'Kontodaten gespeichert.');
|
||
redirect('/staff/profile.php?tab=account');
|
||
}
|
||
$tab = 'account';
|
||
}
|
||
|
||
// ── Passwort ─────────────────────────────────────────────────
|
||
if ($action === 'save_password') {
|
||
$current = $_POST['current_password'] ?? '';
|
||
$new = $_POST['new_password'] ?? '';
|
||
$confirm = $_POST['confirm_password'] ?? '';
|
||
|
||
if (!password_verify($current, $staff['password_hash'])) {
|
||
$errors[] = 'Das aktuelle Passwort ist falsch.';
|
||
}
|
||
$pwError = validate_password_strength($new);
|
||
if ($pwError) {
|
||
$errors[] = $pwError;
|
||
}
|
||
if ($new !== $confirm) {
|
||
$errors[] = 'Die Passwörter stimmen nicht überein.';
|
||
}
|
||
|
||
if (!$errors) {
|
||
$pdo->prepare('UPDATE staff SET password_hash=? WHERE id=?')
|
||
->execute([password_hash($new, PASSWORD_DEFAULT), $staff['id']]);
|
||
audit_log($staff['id'], 'Passwort geändert');
|
||
flash('success', 'Passwort geändert.');
|
||
redirect('/staff/profile.php?tab=account');
|
||
}
|
||
$tab = 'account';
|
||
}
|
||
|
||
// ── Avatar ────────────────────────────────────────────────────
|
||
if ($action === 'save_avatar') {
|
||
if (!empty($_FILES['avatar']['name'])) {
|
||
$allowed = ['jpg','jpeg','png','gif','webp'];
|
||
$ext = strtolower(pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION));
|
||
if (!in_array($ext, $allowed)) {
|
||
$errors[] = 'Erlaubte Bildformate: JPG, PNG, GIF, WEBP.';
|
||
} elseif ($_FILES['avatar']['size'] > 2 * 1024 * 1024) {
|
||
$errors[] = 'Maximale Dateigröße: 2 MB.';
|
||
} elseif (($imgErr = favicon_validate_file($_FILES['avatar']['tmp_name'], $ext)) !== null) {
|
||
$errors[] = $imgErr;
|
||
} else {
|
||
$dir = __DIR__ . '/../assets/avatars/';
|
||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||
// Alten Avatar löschen
|
||
if ($staff['avatar_path']) {
|
||
$old = __DIR__ . '/..' . $staff['avatar_path'];
|
||
if (file_exists($old)) unlink($old);
|
||
}
|
||
$filename = 'avatar_' . $staff['id'] . '_' . time() . '.' . $ext;
|
||
move_uploaded_file($_FILES['avatar']['tmp_name'], $dir . $filename);
|
||
$path = '/assets/avatars/' . $filename;
|
||
$pdo->prepare('UPDATE staff SET avatar_path=? WHERE id=?')->execute([$path, $staff['id']]);
|
||
flash('success', 'Avatar aktualisiert.');
|
||
redirect('/staff/profile.php?tab=account');
|
||
}
|
||
}
|
||
$tab = 'account';
|
||
}
|
||
|
||
if ($action === 'delete_avatar') {
|
||
if ($staff['avatar_path']) {
|
||
$old = __DIR__ . '/..' . $staff['avatar_path'];
|
||
if (file_exists($old)) unlink($old);
|
||
}
|
||
$pdo->prepare('UPDATE staff SET avatar_path=NULL WHERE id=?')->execute([$staff['id']]);
|
||
flash('success', 'Avatar entfernt.');
|
||
redirect('/staff/profile.php?tab=account');
|
||
}
|
||
|
||
// ── Urlaub/Abwesenheit ─────────────────────────────────────
|
||
if ($action === 'save_settings') {
|
||
$onVacation = !empty($_POST['is_on_vacation']) ? 1 : 0;
|
||
$substituteId = ($_POST['vacation_substitute_id'] ?? '') !== '' ? (int)$_POST['vacation_substitute_id'] : null;
|
||
$beforeRow = $pdo->prepare('SELECT is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?');
|
||
$beforeRow->execute([$staff['id']]);
|
||
$beforeRow = $beforeRow->fetch() ?: ['is_on_vacation' => 0, 'vacation_substitute_id' => null];
|
||
$pdo->prepare('UPDATE staff SET is_on_vacation=?, vacation_substitute_id=? WHERE id=?')
|
||
->execute([$onVacation, $substituteId, $staff['id']]);
|
||
audit_log($staff['id'], $onVacation ? 'Abwesenheit aktiviert' : 'Abwesenheit deaktiviert');
|
||
// Offene Tickets an die Vertretung weitergeben bzw. nach dem Urlaub zurückholen
|
||
[$handed, $back] = vacation_apply_change($pdo, (int) $staff['id'], [$beforeRow['is_on_vacation'], $beforeRow['vacation_substitute_id']], [$onVacation, $substituteId], !empty($_POST['hand_over']));
|
||
flash('success', 'Einstellungen gespeichert.' . ($handed ? ' ' . $handed . ' offene Tickets wurden an die Vertretung weitergegeben.' : '') . ($back ? ' ' . $back . ' Tickets sind wieder bei Ihnen.' : ''));
|
||
redirect('/staff/profile.php?tab=settings');
|
||
}
|
||
|
||
// ── Android-Push: welche Popups, stille Zeiten ─────────────────
|
||
if ($action === 'save_push_prefs') {
|
||
$ev = array_map('strval', (array) ($_POST['push_events'] ?? []));
|
||
$qf = trim((string) ($_POST['quiet_from'] ?? ''));
|
||
$qt = trim((string) ($_POST['quiet_to'] ?? ''));
|
||
if (($qf !== '' || $qt !== '') && (!fcm_time_valid($qf) || !fcm_time_valid($qt))) {
|
||
flash('error', 'Stille Zeiten bitte als Uhrzeit von/bis angeben (z. B. 22:00 und 06:00).');
|
||
redirect('/staff/profile.php?tab=account');
|
||
}
|
||
fcm_save_prefs($pdo, (int) $staff['id'], $ev, $qf, $qt, array_keys(fcm_event_types_for($staff)));
|
||
flash('success', 'Push-Einstellungen gespeichert.');
|
||
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']]);
|
||
flash('success', 'Gerät entfernt.');
|
||
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'] ?? '');
|
||
$secret = $_SESSION['totp_setup_secret'] ?? '';
|
||
if (!$secret) {
|
||
flash('error', 'Sitzungsfehler. Bitte nochmals versuchen.');
|
||
} elseif (!totp_verify($secret, $code)) {
|
||
flash('error', 'Code ungültig. Bitte erneut versuchen.');
|
||
} else {
|
||
$pdo->prepare('UPDATE staff SET totp_secret=?, totp_enabled=1 WHERE id=?')->execute([$secret, $staff['id']]);
|
||
unset($_SESSION['totp_setup_secret']);
|
||
audit_log($staff['id'], 'Zwei-Faktor-Authentifizierung aktiviert');
|
||
flash('success', '2FA wurde erfolgreich aktiviert.');
|
||
}
|
||
redirect('/staff/profile.php?tab=security');
|
||
}
|
||
|
||
// ── 2FA deaktivieren ───────────────────────────────────────────
|
||
if ($action === 'disable_2fa') {
|
||
$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 {
|
||
$pdo->prepare('UPDATE staff SET totp_secret=NULL, totp_enabled=0 WHERE id=?')->execute([$staff['id']]);
|
||
audit_log($staff['id'], 'Zwei-Faktor-Authentifizierung deaktiviert');
|
||
flash('success', '2FA wurde deaktiviert.');
|
||
}
|
||
redirect('/staff/profile.php?tab=security');
|
||
}
|
||
|
||
// ── Signatur ──────────────────────────────────────────────────
|
||
if ($action === 'save_signature') {
|
||
$sig = sanitize_staff_html(trim($_POST['signature'] ?? ''));
|
||
$pdo->prepare('UPDATE staff SET signature=? WHERE id=?')->execute([$sig ?: null, $staff['id']]);
|
||
flash('success', 'Signatur gespeichert.');
|
||
redirect('/staff/profile.php?tab=signature');
|
||
}
|
||
}
|
||
|
||
// Aktuellen Stand neu laden (nach Redirect relevant wenn $_SESSION cached)
|
||
$staffRow = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
|
||
$staffRow->execute([$staff['id']]);
|
||
$me = $staffRow->fetch();
|
||
|
||
$pageTitle = 'Mein Profil';
|
||
$activePage = 'profile';
|
||
require __DIR__ . '/_header.php';
|
||
?>
|
||
|
||
<div style="max-width:740px">
|
||
|
||
<h1>Mein Profil</h1>
|
||
|
||
<?php foreach ($errors as $err): ?>
|
||
<div class="flash error"><?= e($err) ?></div>
|
||
<?php endforeach; ?>
|
||
|
||
<!-- Tab-Navigation -->
|
||
<div style="display:flex; border-bottom:2px solid var(--border); margin-bottom:24px; gap:0">
|
||
<?php foreach (['account' => '👤 Konto', 'settings' => '⚙ Einstellungen', 'signature' => '✍ Signatur', 'security' => '🔒 Sicherheit'] as $t => $label): ?>
|
||
<a href="/staff/profile.php?tab=<?= $t ?>"
|
||
style="padding:10px 22px; font-size:.9rem; font-weight:600; text-decoration:none; border-bottom:3px solid <?= $tab===$t ? 'var(--primary)' : 'transparent' ?>; margin-bottom:-2px; color:<?= $tab===$t ? 'var(--primary)' : 'var(--muted)' ?>">
|
||
<?= $label ?>
|
||
</a>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<!-- ══ KONTO ══════════════════════════════════════════════════ -->
|
||
<?php if ($tab === 'account'): ?>
|
||
|
||
<div class="card">
|
||
<h3>Profilbild</h3>
|
||
<div style="display:flex; gap:20px; align-items:flex-start">
|
||
<div style="flex-shrink:0">
|
||
<?php if ($me['avatar_path']): ?>
|
||
<img src="<?= e($me['avatar_path']) ?>" alt="Avatar"
|
||
style="width:80px; height:80px; border-radius:50%; object-fit:cover; border:2px solid var(--border)">
|
||
<?php else: ?>
|
||
<div style="width:80px; height:80px; border-radius:50%; background:var(--primary-lt); display:flex; align-items:center; justify-content:center; font-size:2rem; border:2px solid var(--border)">
|
||
<?= mb_strtoupper(mb_substr($me['name'], 0, 1)) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div style="flex:1">
|
||
<form method="post" enctype="multipart/form-data">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="save_avatar">
|
||
<label for="avatar">Neues Bild hochladen (JPG, PNG, WEBP · max. 2 MB)</label>
|
||
<div style="display:flex; gap:8px; align-items:center; margin-top:6px">
|
||
<input type="file" id="avatar" name="avatar" accept="image/*" style="flex:1">
|
||
<button type="submit" class="btn secondary" style="white-space:nowrap">Hochladen</button>
|
||
</div>
|
||
</form>
|
||
<?php if ($me['avatar_path']): ?>
|
||
<form method="post" style="margin-top:8px">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="delete_avatar">
|
||
<button type="submit" style="background:none;border:none;color:var(--danger);font-size:.82rem;cursor:pointer;padding:0">Bild entfernen</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Kontodaten</h3>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="save_account">
|
||
<label for="name">Name</label>
|
||
<input type="text" id="name" name="name" value="<?= e($me['name']) ?>" required>
|
||
<label for="email">E-Mail-Adresse</label>
|
||
<input type="email" id="email" name="email" value="<?= e($me['email']) ?>" required>
|
||
<label for="phone">Telefonnummer (optional)</label>
|
||
<input type="text" id="phone" name="phone" value="<?= e($me['phone'] ?? '') ?>" placeholder="z.B. +49 89 123456">
|
||
<div class="btn-row">
|
||
<button type="submit" class="btn">Speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Passwort ändern</h3>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="save_password">
|
||
<label for="current_password">Aktuelles Passwort</label>
|
||
<input type="password" id="current_password" name="current_password" required autocomplete="current-password">
|
||
<div class="field-row">
|
||
<div>
|
||
<label for="new_password">Neues Passwort</label>
|
||
<input type="password" id="new_password" name="new_password" required autocomplete="new-password" minlength="10" placeholder="mind. 10 Zeichen, Groß-/Klein, Ziffer, Sonderzeichen">
|
||
</div>
|
||
<div>
|
||
<label for="confirm_password">Passwort wiederholen</label>
|
||
<input type="password" id="confirm_password" name="confirm_password" required autocomplete="new-password">
|
||
</div>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button type="submit" class="btn">Passwort ändern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Push-Benachrichtigungen -->
|
||
<?php
|
||
$pushVapidKey = '';
|
||
if (file_exists(__DIR__ . '/../includes/push.php')) {
|
||
require_once __DIR__ . '/../includes/push.php';
|
||
$__keys = get_or_create_vapid_keys();
|
||
$pushVapidKey = $__keys['public_key'] ?? '';
|
||
}
|
||
?>
|
||
<div class="card">
|
||
<h3>Browser-Push-Benachrichtigungen</h3>
|
||
<?php if ($pushVapidKey): ?>
|
||
<p class="muted" style="font-size:.84rem;margin-bottom:16px">
|
||
Erhalte Desktop-Benachrichtigungen bei neuen Tickets und Antworten — auch wenn der Browser-Tab nicht aktiv ist.
|
||
Funktioniert in Chrome, Firefox und Edge. Erfordert HTTPS.
|
||
</p>
|
||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px">
|
||
<span id="push-status-icon" style="font-size:1.4rem">🔕</span>
|
||
<div>
|
||
<div id="push-status-text" style="font-size:.85rem;font-weight:500">Deaktiviert</div>
|
||
<div id="push-status-sub" style="font-size:.76rem;color:var(--muted)">Klicken Sie auf Aktivieren um Benachrichtigungen zu erhalten</div>
|
||
</div>
|
||
<button type="button" id="push-toggle-btn" onclick="togglePushNotifications()"
|
||
class="btn secondary" style="margin-left:auto">
|
||
Aktivieren
|
||
</button>
|
||
</div>
|
||
<div style="padding-top:14px;border-top:1px solid var(--border-soft)">
|
||
<div style="font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin-bottom:8px">Benachrichtigungen erhalten bei:</div>
|
||
<div style="display:flex;flex-direction:column;gap:5px;font-size:.83rem;color:var(--ink-soft)">
|
||
<div>✅ Neues Ticket in meiner Abteilung</div>
|
||
<div>✅ Neue Antwort auf mir zugewiesenes Ticket</div>
|
||
</div>
|
||
</div>
|
||
<input type="hidden" id="push-vapid-key" value="<?= e($pushVapidKey) ?>">
|
||
<?php else: ?>
|
||
<p class="muted" style="font-size:.84rem">
|
||
Push-Benachrichtigungen sind auf diesem Server nicht verfügbar (OpenSSL-Erweiterung fehlt oder PHP zu alt).
|
||
</p>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php $__pp = fcm_prefs($pdo, (int) $staff['id']); ?>
|
||
<div class="card">
|
||
<h3>📱 Android-App: Welche Popups?</h3>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="save_push_prefs">
|
||
<p class="muted" style="font-size:.84rem;margin-bottom:10px">Wähle, wofür die App ein Popup zeigen soll.</p>
|
||
<?php foreach (fcm_event_types_for($staff) as $__k => $__label): ?>
|
||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0;font-weight:400"><input type="checkbox" name="push_events[]" value="<?= e($__k) ?>" style="width:auto" <?= in_array($__k, $__pp['events'], true) ? 'checked' : '' ?>> <?= e($__label) ?></label>
|
||
<?php endforeach; ?>
|
||
<div style="margin-top:12px;font-size:.84rem;font-weight:600">Stille Zeiten (keine Popups)</div>
|
||
<div style="display:flex;align-items:center;gap:8px;margin-top:6px">
|
||
von <input type="time" name="quiet_from" value="<?= e($__pp['quiet_from']) ?>" style="width:auto"> bis <input type="time" name="quiet_to" value="<?= e($__pp['quiet_to']) ?>" style="width:auto">
|
||
</div>
|
||
<p class="muted" style="font-size:.78rem;margin-top:4px">Beide Felder leer = immer Popups. Die Zeit gilt nach der Serverzeit (<?= e(date_default_timezone_get()) ?>), auch über Mitternacht (z. B. 22:00 bis 06:00).</p>
|
||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- ══ EINSTELLUNGEN ══════════════════════════════════════════ -->
|
||
<?php elseif ($tab === 'settings'): ?>
|
||
|
||
<div class="card">
|
||
<h3>Abwesenheit / Urlaub</h3>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="save_settings">
|
||
<?php
|
||
$allAgents = get_db()->query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll();
|
||
?>
|
||
<label style="display:flex; align-items:flex-start; gap:12px; cursor:pointer; font-weight:400">
|
||
<input type="checkbox" name="is_on_vacation" value="1" style="width:auto; margin-top:3px; flex-shrink:0" <?= $me['is_on_vacation'] ? 'checked' : '' ?>>
|
||
<span>
|
||
<strong>Urlaubsmodus aktiv</strong><br>
|
||
<span class="muted" style="font-size:.84rem">Neue Tickets werden nicht automatisch diesem Konto zugewiesen, während der Urlaubsmodus aktiv ist.</span>
|
||
</span>
|
||
</label>
|
||
<label style="margin-top:14px">Vertretung (optional)</label>
|
||
<select name="vacation_substitute_id">
|
||
<option value="">– keine Vertretung –</option>
|
||
<?php foreach ($allAgents as $a): if ($a['id'] == $me['id']) continue; ?>
|
||
<option value="<?= $a['id'] ?>" <?= ($me['vacation_substitute_id'] ?? '') == $a['id'] ? 'selected' : '' ?>><?= e($a['name']) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<p class="muted" style="font-size:.8rem">Neue Tickets werden während Ihrer Abwesenheit automatisch dieser Person zugewiesen. Sie sieht dafür auch Ihre Abteilungen.</p>
|
||
<label style="display:flex; align-items:flex-start; gap:12px; cursor:pointer; font-weight:400; margin-top:10px">
|
||
<input type="checkbox" name="hand_over" value="1" style="width:auto; margin-top:3px; flex-shrink:0" checked>
|
||
<span><strong>Meine offenen Tickets an die Vertretung weitergeben</strong><br><span class="muted" style="font-size:.84rem">Beim Speichern gehen Ihre offenen Tickets an die Vertretung. Wenn Sie den Urlaubsmodus beenden, kommen die Tickets zurück, die dann noch offen bei ihr liegen. Ohne Vertretung passiert nichts.</span></span>
|
||
</label>
|
||
<?php if ($me['is_on_vacation']): ?>
|
||
<div class="flash info" style="margin-top:8px">Urlaubsmodus ist aktuell <strong>aktiv</strong>. Sie erhalten keine automatischen Ticket-Zuweisungen.</div>
|
||
<?php endif; ?>
|
||
<div class="btn-row">
|
||
<button type="submit" class="btn">Speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Übersicht</h3>
|
||
<div style="display:flex; flex-direction:column; gap:12px; font-size:.9rem">
|
||
<div style="display:flex; gap:16px">
|
||
<span style="color:var(--muted); width:130px; flex-shrink:0">Rolle</span>
|
||
<span><?= $me['role'] === 'admin' ? '<span class="badge" style="background:var(--accent)">Administrator</span>' : '<span class="badge" style="background:var(--primary)">Mitarbeiter</span>' ?></span>
|
||
</div>
|
||
<div style="display:flex; gap:16px">
|
||
<span style="color:var(--muted); width:130px; flex-shrink:0">Mitglied seit</span>
|
||
<span><?= format_dt($me['created_at']) ?></span>
|
||
</div>
|
||
<?php
|
||
$ticketCount = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ?');
|
||
$ticketCount->execute([$me['id']]);
|
||
$myCount = $ticketCount->fetchColumn();
|
||
?>
|
||
<div style="display:flex; gap:16px">
|
||
<span style="color:var(--muted); width:130px; flex-shrink:0">Zugewiesene Tickets</span>
|
||
<span><a href="/staff/tickets.php?assigned=me"><?= (int)$myCount ?></a></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ SIGNATUR ════════════════════════════════════════════════ -->
|
||
<?php elseif ($tab === 'signature'): ?>
|
||
|
||
<div class="card">
|
||
<h3>E-Mail-Signatur</h3>
|
||
<p class="muted" style="margin-bottom:14px; font-size:.875rem">
|
||
Wird automatisch an Ihre ausgehenden Ticket-Antworten angehängt.
|
||
Ist keine Mitarbeiter-Signatur gesetzt, greift die Abteilungs-Signatur.
|
||
</p>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="save_signature">
|
||
<textarea id="signature" name="signature" data-richtext data-image-upload-url="/staff/upload_signature_image.php" style="min-height:180px"><?= e($me['signature'] ?? '') ?></textarea>
|
||
<p class="muted" style="font-size:.8rem;margin-top:8px">Bilder einfügen mit 🖼️. Zum Verkleinern oder Vergrößern das Bild anklicken: Mit dem runden Anfasser ziehen oder 25 / 50 / 75 % / Original wählen.</p>
|
||
<div class="btn-row">
|
||
<button type="submit" class="btn">Speichern</button>
|
||
<button type="button" class="btn secondary" id="sig-clear">Löschen</button>
|
||
</div>
|
||
</form>
|
||
<?php if ($me['signature']): ?>
|
||
<div style="margin-top:20px; padding-top:16px; border-top:1px solid var(--border-soft)">
|
||
<p class="muted" style="font-size:.78rem; margin-bottom:8px">Vorschau:</p>
|
||
<div style="border:1px solid var(--border); border-radius:8px; padding:14px 16px; font-size:.9rem; line-height:1.65">
|
||
<?= $me['signature'] ?>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<script>
|
||
document.getElementById('sig-clear').addEventListener('click', function () {
|
||
var ta = document.getElementById('signature');
|
||
var ed = ta.previousElementSibling;
|
||
if (ed && ed.classList.contains('richtext-editable')) ed.innerHTML = '';
|
||
ta.value = '';
|
||
});
|
||
</script>
|
||
|
||
<?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();
|
||
if (!$me2['totp_enabled'] && empty($_SESSION['totp_setup_secret'])) {
|
||
$_SESSION['totp_setup_secret'] = totp_generate_secret();
|
||
}
|
||
$setupSecret = $_SESSION['totp_setup_secret'] ?? '';
|
||
?>
|
||
<div class="card">
|
||
<h3>🔒 Zwei-Faktor-Authentifizierung (2FA)</h3>
|
||
<?php if ($me2['totp_enabled']): ?>
|
||
<div class="flash info">2FA ist aktuell <strong>aktiv</strong>.</div>
|
||
<p class="muted" style="margin-top:14px">Um 2FA zu deaktivieren, bestätigen Sie mit Ihrem Passwort:</p>
|
||
<form method="post" style="margin-top:10px">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="disable_2fa">
|
||
<input type="password" name="confirm_password" placeholder="Aktuelles Passwort" required>
|
||
<div class="btn-row">
|
||
<button type="submit" class="btn secondary" style="border-color:var(--danger);color:var(--danger)" onclick="return confirm('2FA wirklich deaktivieren?')">2FA deaktivieren</button>
|
||
</div>
|
||
</form>
|
||
<?php else: ?>
|
||
<p class="muted">Schützen Sie Ihr Konto mit einem zeitbasierten Einmalcode (TOTP – kompatibel mit Google Authenticator, Authy, etc.).</p>
|
||
<ol style="margin:16px 0; padding-left:20px; line-height:2">
|
||
<li>Installieren Sie <strong>Google Authenticator</strong> oder <strong>Authy</strong></li>
|
||
<li>Scannen Sie den QR-Code oder geben Sie den Schlüssel manuell ein</li>
|
||
<li>Tragen Sie den 6-stelligen Code ein und klicken Sie „2FA aktivieren"</li>
|
||
</ol>
|
||
<div style="display:flex; gap:24px; align-items:flex-start; flex-wrap:wrap; margin:20px 0">
|
||
<img src="<?= e(totp_qr_url($setupSecret, $me2['email'])) ?>" alt="QR-Code" style="width:160px;height:160px;border:4px solid var(--border);border-radius:8px">
|
||
<div>
|
||
<div style="font-size:.78rem; color:var(--muted); margin-bottom:6px">Manueller Schlüssel:</div>
|
||
<code style="font-size:.95rem; letter-spacing:.1em; background:var(--bg-soft); padding:8px 12px; border-radius:6px; display:block"><?= e(chunk_split($setupSecret, 4, ' ')) ?></code>
|
||
</div>
|
||
</div>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="enable_2fa">
|
||
<label for="totp_code">Code aus der App</label>
|
||
<input type="text" id="totp_code" name="totp_code" inputmode="numeric" pattern="\d{6}" maxlength="6" required placeholder="000000" style="letter-spacing:.2em;font-size:1.2rem;max-width:160px">
|
||
<div class="btn-row"><button type="submit" class="btn">2FA aktivieren</button></div>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php
|
||
$myDevices = $pdo->prepare('SELECT * FROM login_devices WHERE staff_id = ? ORDER BY last_seen DESC');
|
||
$myDevices->execute([$staff['id']]);
|
||
$myDevices = $myDevices->fetchAll();
|
||
$myEvents = $pdo->prepare('SELECT * FROM login_events WHERE staff_id = ? ORDER BY id DESC LIMIT 10');
|
||
$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>
|
||
<?php if (!$myDevices): ?><p class="muted">Noch keine Geräte gemerkt.</p><?php else: ?>
|
||
<div style="overflow-x:auto"><table>
|
||
<thead><tr><th>Gerät</th><th>Letzter Ort</th><th>Zuletzt gesehen</th><th></th></tr></thead>
|
||
<tbody>
|
||
<?php foreach ($myDevices as $d): ?>
|
||
<tr>
|
||
<td><?= e($d['label']) ?><div class="muted" style="font-size:.76rem"><?= $d['channel'] === 'app' ? 'App' : 'Browser' ?>, seit <span><?= e(date('d.m.Y', strtotime((string) $d['first_seen']))) ?></span></div></td>
|
||
<td><?= e($d['place']) ?><div class="muted" style="font-size:.76rem"><?= e($d['last_ip']) ?></div></td>
|
||
<td><?= e(date('d.m.Y H:i', strtotime((string) $d['last_seen']))) ?></td>
|
||
<td><form method="post" onsubmit="return confirm('Dieses Gerät entfernen? Die nächste Anmeldung davon löst wieder eine Warnung aus.')"><?= csrf_field() ?><input type="hidden" name="action" value="forget_device"><input type="hidden" name="device_id" value="<?= (int) $d['id'] ?>"><button class="btn secondary" type="submit" style="padding:3px 8px;font-size:.78rem">Entfernen</button></form></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table></div>
|
||
<?php endif; ?>
|
||
<?php if ($myEvents): ?>
|
||
<h4 style="margin:18px 0 6px">Letzte Anmeldungen</h4>
|
||
<div style="overflow-x:auto"><table>
|
||
<tbody>
|
||
<?php foreach ($myEvents as $ev): ?>
|
||
<tr><td style="white-space:nowrap"><?= e(date('d.m.Y H:i', strtotime((string) $ev['created_at']))) ?></td><td><?= e($eventLabels[$ev['event']] ?? $ev['event']) ?></td><td><?= e($ev['device']) ?></td><td><?= e($ev['place']) ?> <span class="muted">(<?= e($ev['ip']) ?>)</span></td></tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table></div>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// Push-Status auf Profilseite prüfen und Button aktualisieren
|
||
(function() {
|
||
var keyInput = document.getElementById('push-vapid-key');
|
||
var btn = document.getElementById('push-toggle-btn');
|
||
var statusText = document.getElementById('push-status-text');
|
||
var statusSub = document.getElementById('push-status-sub');
|
||
var statusIcon = document.getElementById('push-status-icon');
|
||
|
||
if (!keyInput) return; // Push-Karte nicht sichtbar
|
||
|
||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||
statusText.textContent = 'Nicht unterstützt';
|
||
statusSub.textContent = 'Ihr Browser unterstützt keine Push-Benachrichtigungen.';
|
||
statusIcon.textContent = '⚠️';
|
||
btn.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
function setActive(active) {
|
||
if (active) {
|
||
statusIcon.textContent = '🔔';
|
||
statusText.textContent = 'Aktiv';
|
||
statusText.style.color = '#22c55e';
|
||
statusSub.textContent = 'Sie erhalten Desktop-Benachrichtigungen';
|
||
btn.textContent = 'Deaktivieren';
|
||
btn.style.borderColor = '#ef4444';
|
||
btn.style.color = '#ef4444';
|
||
} else {
|
||
statusIcon.textContent = '🔕';
|
||
statusText.textContent = 'Deaktiviert';
|
||
statusText.style.color = '';
|
||
statusSub.textContent = 'Klicken Sie auf Aktivieren um Benachrichtigungen zu erhalten';
|
||
btn.textContent = 'Aktivieren';
|
||
btn.style.borderColor = '';
|
||
btn.style.color = '';
|
||
}
|
||
}
|
||
|
||
navigator.serviceWorker.ready.then(function(reg) {
|
||
reg.pushManager.getSubscription().then(function(sub) {
|
||
setActive(!!(sub && Notification.permission === 'granted'));
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
<?php require __DIR__ . '/_footer.php'; ?>
|