Upload via GUI (360 Dateien)

This commit is contained in:
2026-10-30 21:50:04 +00:00
parent 91ae4659b5
commit fa7cb047a9
6 changed files with 879 additions and 322 deletions
+66 -69
View File
@@ -125,17 +125,36 @@ $activePage = 'availability';
require __DIR__ . '/_header.php';
$statusLabels = [
'available' => ['Verfügbar', '#22c55e', '🟢'],
'busy' => ['Beschäftigt', '#f59e0b', '🟡'],
'break' => ['Pause', '#6366f1', '🟣'],
'offline' => ['Offline', '#94a3b8', '⚫'],
'available' => ['Verfügbar', '#22c55e'],
'busy' => ['Beschäftigt', '#f59e0b'],
'break' => ['Pause', '#6366f1'],
'offline' => ['Offline', '#94a3b8'],
];
$weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
?>
<style>
.av-dot { display:inline-block; width:10px; height:10px; border-radius:50%; background:var(--c, #94a3b8); flex:none }
.av-status { display:flex; gap:10px; flex-wrap:wrap; align-items:center }
.status-btn { display:inline-flex; align-items:center; gap:8px; font:inherit; font-size:.88rem; font-weight:600; padding:8px 16px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--ink-soft); cursor:pointer; transition:border-color .15s, background .15s, color .15s }
.status-btn:hover { border-color:var(--c) }
.status-btn.active { border-color:var(--c); background:color-mix(in srgb, var(--c) 12%, var(--surface)); color:var(--ink) }
.av-note { flex:1; min-width:200px; margin:0 }
.av-team { display:grid; grid-template-columns:repeat(auto-fill, minmax(250px, 1fr)); gap:12px }
.av-agent { display:flex; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--border); border-radius:10px; background:var(--surface) }
.av-avatar { position:relative; width:38px; height:38px; border-radius:50%; background:var(--primary); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; flex:none }
.av-avatar .status-dot { position:absolute; right:-2px; bottom:-2px; width:12px; height:12px; border-radius:50%; border:2px solid var(--surface); background:#94a3b8 }
.av-agent .av-name { font-weight:600; overflow-wrap:anywhere }
.av-agent .status-label { font-size:.8rem; font-weight:600 }
.av-agent .status-note, .av-agent .av-seen { font-size:.76rem; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.av-edit { margin-left:auto; flex:none; padding:4px 10px; font-size:.78rem }
.av-admin-row form { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:0 }
.av-admin-row select, .av-admin-row input[type=text] { margin:0; width:auto }
.av-auto { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:16px; padding-top:16px; border-top:1px solid var(--border-soft) }
.av-auto label, .av-auto input { margin:0 }
.av-auto input[type=number] { width:90px }
</style>
<div class="topbar">
<h1>Verfügbarkeit & Schichten</h1>
</div>
<h1 style="margin-top:0">Verfügbarkeit &amp; Schichten</h1>
<!-- Hidden CSRF form für AJAX -->
<form id="csrf-form" style="display:none"><?= csrf_field() ?></form>
@@ -147,53 +166,38 @@ foreach ($agents as $a) { if ($a['id'] === $staff['id']) { $myAvail = $a; break;
$myStatus = $myAvail['avail_status'] ?? 'available';
?>
<div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Mein Status</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
<?php foreach ($statusLabels as $sk => [$sl, $sc, $se]): ?>
<button type="button" onclick="setMyStatus('<?= $sk ?>')"
class="status-btn <?= $myStatus === $sk ? 'active' : '' ?>"
data-status="<?= $sk ?>"
style="display:flex;align-items:center;gap:7px;padding:8px 16px;border:2px solid <?= $myStatus === $sk ? $sc : 'var(--border)' ?>;border-radius:8px;background:<?= $myStatus === $sk ? $sc.'18' : 'transparent' ?>;cursor:pointer;font-size:.85rem;font-weight:<?= $myStatus === $sk ? '600' : '400' ?>;color:<?= $myStatus === $sk ? $sc : 'var(--ink-soft)' ?>;transition:all .15s">
<span><?= $se ?></span> <?= $sl ?>
<h3>Mein Status</h3>
<div class="av-status">
<?php foreach ($statusLabels as $sk => [$sl, $sc]): ?>
<button type="button" onclick="setMyStatus('<?= $sk ?>')" class="status-btn <?= $myStatus === $sk ? 'active' : '' ?>" data-status="<?= $sk ?>" style="--c:<?= $sc ?>">
<span class="av-dot" style="--c:<?= $sc ?>"></span><?= $sl ?>
</button>
<?php endforeach; ?>
<div style="flex:1;min-width:160px">
<input type="text" id="avail-note" value="<?= e($myAvail['avail_note'] ?? '') ?>"
placeholder="Notiz (optional, z.B. &laquo;In Besprechung bis 14:00&raquo;)"
style="width:100%;font-size:.83rem"
oninput="scheduleStatusUpdate()">
</div>
<input type="text" id="avail-note" class="av-note" value="<?= e($myAvail['avail_note'] ?? '') ?>" maxlength="120"
placeholder="Notiz (optional, z. B. „In Besprechung bis 14:00“)" oninput="scheduleStatusUpdate()">
</div>
</div>
<!-- ── Team-Übersicht ─────────────────────────────────────────────────────────── -->
<div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:14px">Team-Übersicht</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">
<h3>Team-Übersicht</h3>
<div class="av-team">
<?php foreach ($agents as $ag):
$as = $ag['avail_status'] ?? 'offline';
[$asl, $asc, $ase] = $statusLabels[$as] ?? ['Unbekannt','#94a3b8','⚫'];
$hasShift = isset($shiftsByStaff[$ag['id']]);
[$asl, $asc] = $statusLabels[$as] ?? ['Unbekannt', '#94a3b8'];
if (!empty($ag['is_on_vacation'])) { $asl = 'Urlaub'; $asc = '#94a3b8'; }
$__la = staff_last_active_label($ag['last_active_at'] ?? null);
?>
<div id="agent-status-<?= $ag['id'] ?>" style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">
<div style="width:36px;height:36px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:.9rem;font-weight:700;flex-shrink:0;position:relative">
<?= mb_strtoupper(mb_substr($ag['name'], 0, 1)) ?>
<span class="status-dot" style="position:absolute;bottom:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:<?= $asc ?>;border:2px solid var(--surface)"></span>
</div>
<div class="av-agent" id="agent-status-<?= $ag['id'] ?>">
<div class="av-avatar"><?= e(mb_strtoupper(mb_substr($ag['name'], 0, 1))) ?><span class="status-dot" style="background:<?= $asc ?>"></span></div>
<div style="min-width:0;flex:1">
<div style="font-size:.85rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<?= e($ag['name']) ?>
<?php if ($ag['is_on_vacation']): ?><span style="font-size:.7rem">🌴</span><?php endif; ?>
</div>
<div style="font-size:.75rem;font-weight:500"><span class="status-label" style="color:<?= $asc ?>"><?= $asl ?></span></div>
<div class="status-note" style="font-size:.72rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?= e($ag['avail_note'] ?? '') ?></div>
<div class="av-name"><?= e($ag['name']) ?></div>
<div><span class="status-label" style="color:<?= $asc ?>"><?= $asl ?></span></div>
<div class="status-note"><?= e($ag['avail_note'] ?? '') ?></div>
<?php if (!empty($ag['avail_auto'])): ?><div class="av-seen">automatisch offline</div>
<?php elseif ($__la !== ''): ?><div class="av-seen">aktiv <?= e($__la) ?></div><?php endif; ?>
</div>
<?php if ($admin): ?>
<a href="?edit=<?= $ag['id'] ?>" title="Schichtplan bearbeiten"
style="color:var(--muted);font-size:.78rem;text-decoration:none;flex-shrink:0">⚙️</a>
<?php endif; ?>
<?php $__la = staff_last_active_label($ag['last_active_at'] ?? null); ?>
<?php if ($__la !== '' || !empty($ag['avail_auto'])): ?><div style="font-size:.68rem;color:var(--muted);flex-shrink:0;text-align:right"><?= !empty($ag['avail_auto']) ? 'automatisch<br>offline' : '' ?><?= $__la !== '' && empty($ag['avail_auto']) ? 'aktiv ' . e($__la) : '' ?></div><?php endif; ?>
<?php if ($admin): ?><a href="?edit=<?= $ag['id'] ?>" class="btn secondary av-edit" title="Schichtplan bearbeiten">Schichtplan</a><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
@@ -202,34 +206,38 @@ $myStatus = $myAvail['avail_status'] ?? 'available';
<?php if ($admin): ?>
<!-- ── Admin: Status der Mitarbeiter ändern + Auto-Offline ───────────────────── -->
<div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:6px">Status der Mitarbeiter ändern (Admin)</div>
<p class="muted" style="font-size:.8rem;margin-bottom:12px">Ein von dir gesetzter Status bleibt, bis der Mitarbeiter ihn selbst ändert oder du ihn erneut änderst.</p>
<table style="width:100%;border-collapse:collapse;font-size:.85rem">
<h3>Status der Mitarbeiter ändern (Admin)</h3>
<p class="muted" style="font-size:.85rem;margin:0 0 12px">Ein von dir gesetzter Status bleibt, bis der Mitarbeiter ihn selbst ändert oder du ihn erneut änderst.</p>
<div style="overflow-x:auto"><table>
<thead><tr><th>Mitarbeiter</th><th>Status und Notiz</th></tr></thead>
<tbody>
<?php foreach ($agents as $ag): ?>
<tr style="border-top:1px solid var(--border)">
<td style="padding:6px 8px"><?= e($ag['name']) ?></td>
<td style="padding:6px 8px">
<form method="post" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<tr class="av-admin-row">
<td><strong><?= e($ag['name']) ?></strong></td>
<td>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="set_staff_status">
<input type="hidden" name="staff_id" value="<?= (int) $ag['id'] ?>">
<select name="status" style="width:auto">
<select name="status">
<?php foreach ($statusLabels as $sk => [$sl]): ?><option value="<?= $sk ?>" <?= ($ag['avail_status'] ?? 'offline') === $sk ? 'selected' : '' ?>><?= $sl ?></option><?php endforeach; ?>
</select>
<input type="text" name="note" value="<?= e($ag['avail_note'] ?? '') ?>" placeholder="Notiz (optional)" maxlength="120" style="width:180px">
<input type="text" name="note" value="<?= e($ag['avail_note'] ?? '') ?>" placeholder="Notiz (optional)" maxlength="120" style="width:220px">
<button type="submit" class="btn secondary">Setzen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<form method="post" style="margin-top:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
</tbody>
</table></div>
<form method="post" class="av-auto">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_auto_offline">
<label style="margin:0;font-weight:500">Automatisch offline nach</label>
<input type="number" name="auto_offline_minutes" min="0" max="1440" value="<?= (int) staff_auto_offline_minutes() ?>" style="width:90px"> Minuten ohne Aktivität
<label>Automatisch offline nach</label>
<input type="number" name="auto_offline_minutes" min="0" max="1440" value="<?= (int) staff_auto_offline_minutes() ?>">
<span>Minuten ohne Aktivität</span>
<button type="submit" class="btn secondary">Speichern</button>
<span class="muted" style="font-size:.78rem">0 = aus. Bei neuer Aktivität kommt der vorherige Status zurück.</span>
<span class="muted" style="font-size:.8rem">0 = aus. Bei neuer Aktivität kommt der vorherige Status zurück.</span>
</form>
</div>
<?php endif; ?>
@@ -309,18 +317,7 @@ var statusUpdateTimer;
function setMyStatus(s) {
currentStatus = s;
document.querySelectorAll('.status-btn').forEach(function(btn) {
var active = btn.dataset.status === s;
var colors = {
available: ['#22c55e','#22c55e18'],
busy: ['#f59e0b','#f59e0b18'],
break: ['#6366f1','#6366f118'],
offline: ['#94a3b8','#94a3b818'],
};
var c = colors[btn.dataset.status] || ['var(--border)','transparent'];
btn.style.borderColor = active ? c[0] : 'var(--border)';
btn.style.background = active ? c[1] : 'transparent';
btn.style.color = active ? c[0] : 'var(--ink-soft)';
btn.style.fontWeight = active ? '600' : '400';
btn.classList.toggle('active', btn.dataset.status === s);
});
sendStatusUpdate();
}
@@ -360,7 +357,7 @@ function refreshTeamStatus() {
var colors = { available:'#22c55e', busy:'#f59e0b', break:'#6366f1', offline:'#94a3b8' };
var labels = { available:'Verfügbar', busy:'Beschäftigt', break:'Pause', offline:'Offline' };
var s = ag.is_on_vacation ? 'offline' : (ag.status || 'offline');
var label = ag.is_on_vacation ? '🌴 Urlaub' : labels[s];
var label = ag.is_on_vacation ? 'Urlaub' : labels[s];
var color = colors[s] || '#94a3b8';
el.querySelector('.status-dot').style.background = color;
el.querySelector('.status-label').textContent = label;
+20 -4
View File
@@ -24,21 +24,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !csrf_valid()) {
$stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
$stmt->execute([$pendingId]);
$staff = $stmt->fetch();
if ($staff && totp_verify($staff['totp_secret'], $code)) {
if ($staff && !rate_limit_check('staff_totp_attempt', (string) $pendingId, login_guard_max_attempts(), login_guard_lock_minutes())) {
// Code-Raten bremsen: nach zu vielen Fehlversuchen muss das Passwort neu eingegeben werden
unset($_SESSION['totp_pending_staff_id']);
login_guard_locked((string) $staff['email'], 'web');
$errors[] = 'Zu viele falsche Codes. Bitte warten Sie einige Minuten und melden Sie sich neu an.';
} elseif ($staff && totp_verify($staff['totp_secret'], $code)) {
unset($_SESSION['totp_pending_staff_id']);
rate_limit_reset('staff_totp_attempt', (string) $pendingId);
staff_login($staff);
redirect('/staff/reports.php');
} else {
if ($staff) login_guard_failed($staff, (string) $staff['email'], 'web', 'Falscher 2FA-Code');
$errors[] = 'Ungültiger Code. Bitte erneut versuchen.';
$need2fa = true;
}
$errors[] = 'Ungültiger Code. Bitte erneut versuchen.';
$need2fa = true;
}
} else {
$email = trim($_POST['email'] ?? '');
$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), 5, 15)) {
} 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
login_guard_locked($email, 'web');
$errors[] = 'Zu viele Anmeldeversuche für dieses Konto. Bitte warten Sie einige Minuten.';
} else {
$stmt = get_db()->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
@@ -55,6 +64,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !csrf_valid()) {
redirect('/staff/reports.php');
}
} else {
login_guard_failed($staff ?: null, $email, 'web', $staff ? 'Falsches Passwort' : 'Unbekannte E-Mail-Adresse');
$errors[] = 'E-Mail-Adresse oder Passwort ist falsch.';
}
}
@@ -141,6 +151,12 @@ $displayName = get_setting('display_name', config()['site']['name']);
</div>
</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 endif; ?>
<?php endif; ?>
</div>
</div>
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* Ziel des Links „Das war ich nicht“ in der Warn-Mail des Anmelde-Wächters. Öffentlich erreichbar (der Link kommt per Mail),
* geschützt durch das einmalige, 7 Tage gültige Token. Ein Aufruf ändert nichts; erst der Knopf sichert das Konto.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
$pdo = get_db();
$token = (string) ($_GET['t'] ?? $_POST['t'] ?? '');
$event = $token !== '' ? login_guard_find_alert($pdo, $token) : null;
$done = false;
$errors = [];
if ($event && $_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
if (!rate_limit_check('login_alert_use', client_ip(), 10, 15)) {
$errors[] = 'Zu viele Versuche. Bitte warten Sie einige Minuten.';
} elseif (login_guard_revoke($pdo, $event)) {
$done = true;
} else {
$errors[] = 'Das Konto wurde nicht gefunden.';
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Konto sichern – Mitarbeiterbereich</title>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<?= site_icon_links() ?>
</head>
<body>
<?= i18n_switcher_html('floating') ?>
<div class="auth-wrap">
<div class="auth-card">
<?php require __DIR__ . '/../includes/brand_block.php'; ?>
<h1>Konto sichern</h1>
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
<?php if ($done): ?>
<div class="flash success">Ihr Konto ist gesichert. Das Passwort ist ungültig, alle App-Anmeldungen wurden beendet und die Administratoren wurden informiert.</div>
<p style="margin-top:14px;font-size:.9rem">Setzen Sie jetzt über „Passwort vergessen?“ ein neues Passwort.</p>
<div class="btn-row"><a class="btn" href="/staff/forgot_password.php" style="width:100%;text-align:center">Neues Passwort anfordern</a></div>
<?php elseif (!$event): ?>
<div class="flash error">Dieser Link ist ungültig, abgelaufen oder wurde schon verwendet.</div>
<p style="margin-top:18px;font-size:.85rem"><a href="/staff/login.php">← Zur Anmeldung</a></p>
<?php else: ?>
<div class="sub">Diese Anmeldung wurde von Ihrem Konto gemeldet:</div>
<table style="margin:14px 0;font-size:.9rem">
<tr><td style="padding:2px 14px 2px 0;color:var(--muted)">Zeit</td><td><?= e(date('d.m.Y H:i', strtotime((string) $event['created_at']))) ?></td></tr>
<tr><td style="padding:2px 14px 2px 0;color:var(--muted)">Gerät</td><td><?= e((string) $event['device']) ?></td></tr>
<tr><td style="padding:2px 14px 2px 0;color:var(--muted)">Ort</td><td><?= e((string) $event['place']) ?></td></tr>
<tr><td style="padding:2px 14px 2px 0;color:var(--muted)">IP-Adresse</td><td><?= e((string) $event['ip']) ?></td></tr>
</table>
<p style="font-size:.9rem">Waren Sie das nicht? Dann sichern Sie Ihr Konto: Das Passwort wird ungültig, alle App-Anmeldungen und gemerkten Geräte werden beendet und die Administratoren erfahren davon. Danach legen Sie ein neues Passwort fest.</p>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="t" value="<?= e($token) ?>">
<div class="btn-row"><button type="submit" class="btn danger" style="width:100%">Das war ich nicht – Konto sichern</button></div>
</form>
<p style="margin-top:18px;font-size:.85rem">Waren Sie es doch? Dann schließen Sie diese Seite einfach.</p>
<?php endif; ?>
</div>
</div>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$admin = require_admin();
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = (string) ($_POST['action'] ?? '');
if ($action === 'save') {
set_setting('login_guard', !empty($_POST['login_guard']) ? '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))));
set_setting('login_guard_lock_minutes', (string) max(5, min(240, (int) ($_POST['login_guard_lock_minutes'] ?? 15))));
set_setting('login_guard_retention_days', (string) max(7, min(1095, (int) ($_POST['login_guard_retention_days'] ?? 180))));
audit_log($admin['id'], 'Anmelde-Wächter: Einstellungen geändert');
flash('success', 'Einstellungen gespeichert.');
} elseif ($action === 'purge') {
$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 === 'forget_devices') {
$sid = (int) ($_POST['staff_id'] ?? 0);
if ($sid > 0) {
$pdo->prepare('DELETE FROM login_devices WHERE staff_id = ?')->execute([$sid]);
$pdo->prepare('DELETE FROM login_places WHERE staff_id = ?')->execute([$sid]);
audit_log($admin['id'], 'Anmelde-Wächter: gemerkte Geräte und Orte vergessen (Mitarbeiter ' . $sid . ')');
flash('success', 'Geräte und Orte vergessen. Die nächste Anmeldung zählt wieder als erste.');
}
}
redirect('/staff/login_log.php');
}
$labels = login_guard_event_labels();
$filter = (string) ($_GET['event'] ?? '');
if (!isset($labels[$filter])) $filter = '';
$q = trim((string) ($_GET['q'] ?? ''));
$where = ['1=1'];
$params = [];
if ($filter !== '') { $where[] = 'e.event = ?'; $params[] = $filter; }
if ($q !== '') { $where[] = '(e.email LIKE ? OR e.ip LIKE ? OR e.device LIKE ? OR e.place LIKE ?)'; $like = '%' . $q . '%'; array_push($params, $like, $like, $like, $like); }
$st = $pdo->prepare('SELECT e.*, s.name AS staff_name FROM login_events e LEFT JOIN staff s ON s.id = e.staff_id WHERE ' . implode(' AND ', $where) . ' ORDER BY e.id DESC LIMIT 300');
$st->execute($params);
$rows = $st->fetchAll();
$since = date('Y-m-d H:i:s', time() - 86400);
$cnt = $pdo->prepare('SELECT event, COUNT(*) AS n FROM login_events WHERE created_at >= ? GROUP BY event');
$cnt->execute([$since]);
$counts = array_fill_keys(array_keys($labels), 0);
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'];
require __DIR__ . '/_header.php';
?>
<h1>Anmelde-Wächter</h1>
<p class="muted" style="max-width:860px">Der Wächter merkt sich die Geräte und Orte, von denen sich Mitarbeiter anmelden (Web und App). Meldet sich jemand von einem <strong>neuen Gerät</strong> oder <strong>neuen Ort</strong> an, bekommt er eine Mail mit dem Link „Das war ich nicht“. Der Link macht das Passwort ungültig, beendet App-Anmeldungen und Browser-Sitzungen und informiert die Administratoren. Nach zu vielen <strong>Fehlversuchen</strong> wird die Anmeldung gesperrt; auch das steht im Protokoll und geht per Mail an Kontoinhaber und Administratoren. Als Ort gilt das Land (wenn Cloudflare es mitschickt), sonst das Netz der IP-Adresse; die erste Anmeldung eines Kontos wird still gemerkt.</p>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">
<?php foreach (['success', 'new_device', 'new_place', 'failed', 'locked', 'revoked'] as $k): ?>
<div class="card" style="margin:0;min-width:140px"><div class="muted" style="font-size:.8rem"><?= e($labels[$k]) ?> (24 Std.)</div><div style="font-size:1.6rem;font-weight:800;color:<?= $colors[$k] ?>"><?= (int) $counts[$k] ?></div></div>
<?php endforeach; ?>
</div>
<form method="post" class="card" style="max-width:860px;margin-bottom:16px"><?= csrf_field() ?><input type="hidden" name="action" value="save">
<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="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' : '' ?>>
<span>Administratoren per Mail informieren bei <strong>Sperren</strong> und bei „Konto gesichert“.</span></label>
<div style="display:flex;gap:18px;flex-wrap:wrap;margin-top:12px">
<div><label for="lg_max">Sperre nach … Fehlversuchen</label><input type="number" id="lg_max" name="login_guard_max_attempts" min="3" max="20" value="<?= login_guard_max_attempts() ?>" style="width:120px"></div>
<div><label for="lg_min">Sperre für … Minuten</label><input type="number" id="lg_min" name="login_guard_lock_minutes" min="5" max="240" value="<?= login_guard_lock_minutes() ?>" style="width:120px"></div>
<div><label for="lg_ret">Protokoll aufbewahren (Tage)</label><input type="number" id="lg_ret" name="login_guard_retention_days" min="7" max="1095" value="<?= login_guard_retention_days() ?>" style="width:120px"></div>
</div>
<div class="btn-row"><button class="btn" type="submit">Speichern</button></div>
</form>
<div class="card">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px">
<h3 style="margin:0;flex:1">Protokoll</h3>
<form method="get" style="display:flex;gap:8px;flex-wrap:wrap;margin:0">
<select name="event" style="width:auto"><option value="">Alle Ereignisse</option><?php foreach ($labels as $k => $l): ?><option value="<?= e($k) ?>" <?= $filter === $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?></select>
<input type="search" name="q" value="<?= e($q) ?>" placeholder="E-Mail, IP, Gerät …" style="width:200px">
<button class="btn secondary" type="submit">Filtern</button>
</form>
<form method="post" onsubmit="return confirm('Das ganze Protokoll löschen?')"><?= csrf_field() ?><input type="hidden" name="action" value="purge"><button class="btn secondary" type="submit" <?= $rows ? '' : 'disabled' ?>>Protokoll leeren</button></form>
</div>
<?php if (!$rows): ?>
<p class="muted">Keine Einträge<?= ($filter !== '' || $q !== '') ? ' zu diesem Filter' : '' ?>.</p>
<?php else: ?>
<div style="overflow-x:auto"><table>
<thead><tr><th>Zeit</th><th>Ereignis</th><th>Konto</th><th>Gerät</th><th>Ort / IP</th><th>Hinweis</th><th></th></tr></thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td style="white-space:nowrap"><?= e(date('d.m.Y H:i', strtotime((string) $r['created_at']))) ?></td>
<td><span style="color:<?= $colors[$r['event']] ?? '#475569' ?>;font-weight:700"><?= e($labels[$r['event']] ?? $r['event']) ?></span><div class="muted" style="font-size:.74rem"><?= $r['channel'] === 'app' ? 'App' : 'Web' ?></div></td>
<td><?= $r['staff_name'] ? e($r['staff_name']) . '<div class="muted" style="font-size:.76rem">' . e($r['email']) . '</div>' : e($r['email']) ?></td>
<td style="font-size:.85rem"><?= e($r['device']) ?></td>
<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'] && 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; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
<p class="muted" style="font-size:.78rem;margin:10px 0 0">Zeigt die letzten 300 Einträge. Einträge werden nach <?= login_guard_retention_days() ?> Tagen automatisch gelöscht.</p>
<?php endif; ?>
</div>
<?php require __DIR__ . '/_footer.php'; ?>
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* staff/plugin_login.php
*
* Gemeinsamer Einstieg für Anmeldedienste, die als Plugin kommen (zum Beispiel authentik über OpenID Connect): GET ?p=<Plugin>&e=<Name>.
* Das Plugin muss die Berechtigung "auth.login" haben und die Adresse mit plugin_register_login() anmelden. Hier steht nur, was für alle gleich ist:
* Ablauf der Antwort (Weiterleitung, Anmeldung, Fehler), Begrenzung der Aufrufe und der Eintrag im Admin-Protokoll. Wer sich anmelden darf,
* prüft das Plugin; DeskHub meldet nur aktive Mitarbeiterkonten an.
*
* Schon angemeldete Mitarbeiter kommen nur zu Adressen, die das Plugin ausdrücklich dafür angemeldet hat (zum Beispiel „Konto verknüpfen“);
* dabei kann nie ein anderer Mitarbeiter angemeldet werden.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
header('Cache-Control: no-store');
header('Referrer-Policy: no-referrer');
/** Nur Ziele im Mitarbeiterbereich: beginnt mit /staff/, keine Doppel-Schrägstriche, keine Zeilenumbrüche. */
function plugin_login_local_ok(string $path): bool
{
return $path !== '' && strlen($path) <= 300 && str_starts_with($path, '/staff/') && !str_contains($path, '//') && !preg_match('/[\x00-\x20\\\\]/', $path);
}
/** Zurück mit Fehlermeldung: angemeldet in den Mitarbeiterbereich, sonst zur Anmeldeseite. */
function plugin_login_back(string $message, ?string $back = null): never
{
flash('error', $message);
if (current_staff()) redirect($back !== null && plugin_login_local_ok($back) ? $back : '/staff/reports.php');
redirect('/staff/login.php');
}
$staffNow = current_staff();
$slug = (string) ($_GET['p'] ?? '');
$endpoint = (string) ($_GET['e'] ?? '');
$handler = plugin_state()['logins'][$slug][$endpoint] ?? null;
if (!is_callable($handler)) {
if ($staffNow) redirect('/staff/reports.php');
http_response_code(404);
echo 'Unbekannte Anmelde-Adresse.';
exit;
}
$allowedLoggedIn = !empty(plugin_state()['login_loggedin'][$slug][$endpoint]);
if ($staffNow && !$allowedLoggedIn) redirect('/staff/reports.php');
if (!rate_limit_check('plugin_login', client_ip(), 40, 15)) plugin_login_back('Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.');
$request = ['query' => $_GET, 'ip' => client_ip()];
if ($staffNow) $request['staff'] = $staffNow;
$result = plugin_guard($slug, fn() => $handler($request), null);
$back = is_array($result) && isset($result['back']) ? (string) $result['back'] : null;
if (!is_array($result)) plugin_login_back('Die Anmeldung ist gerade nicht möglich. Bitte versuchen Sie es später noch einmal oder melden Sie sich mit E-Mail und Passwort an.');
// Weiter zum Anmeldedienst: nur https (oder http zu localhost zum Testen), keine Zeilenumbrüche
if (!empty($result['redirect'])) {
$url = (string) $result['redirect'];
if (!plugin_login_external_url_ok($url)) plugin_login_back('Die Adresse des Anmeldedienstes ist ungültig.', $back);
header('Location: ' . $url, true, 302);
exit;
}
// Kunden: Die gemeinsame Rückkehr-Adresse darf auch eine Kunden-Anmeldung beenden (Berechtigung auth.customer, prüft plugin_customer_login_finish)
if (!$staffNow && (isset($result['customer']) || ($result['audience'] ?? '') === 'customer')) {
if (!empty($result['customer']) && is_array($result['customer'])) plugin_customer_login_finish($slug, $result['customer']);
flash('error', mb_substr((string) ($result['error'] ?? 'Die Anmeldung wurde abgelehnt.'), 0, 300));
redirect('/login.php');
}
// Angemeldete Mitarbeiter: zurück in den Mitarbeiterbereich (mit Hinweis), nie ein anderer Mitarbeiter
if ($staffNow) {
if (!empty($result['staff_id'])) plugin_login_back('Dieser Schritt darf keinen anderen Mitarbeiter anmelden.', $back);
if (!empty($result['local'])) {
$local = (string) $result['local'];
if (!plugin_login_local_ok($local)) $local = '/staff/reports.php';
if (!empty($result['notice'])) flash('success', mb_substr((string) $result['notice'], 0, 300));
redirect($local);
}
plugin_login_back(mb_substr((string) ($result['error'] ?? 'Der Vorgang wurde abgelehnt.'), 0, 300), $back);
}
// Geprüfter Mitarbeiter: anmelden
if (!empty($result['staff_id'])) {
$stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
$stmt->execute([(int) $result['staff_id']]);
$staff = $stmt->fetch();
if (!$staff) plugin_login_back('Dieses Konto ist in DeskHub nicht aktiv.');
$via = mb_substr((string) ($result['via'] ?? $slug), 0, 60);
staff_login($staff);
audit_log((int) $staff['id'], 'Anmeldung über ' . $via . ' (Plugin ' . $slug . ')');
redirect('/staff/reports.php');
}
plugin_login_back(mb_substr((string) ($result['error'] ?? 'Die Anmeldung wurde abgelehnt.'), 0, 300));
+516 -249
View File
@@ -1,249 +1,516 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/backup.php';
require_once __DIR__ . '/_ui.php';
$staff = require_admin();
$pdo = get_db();
$errors = [];
$permLabels = plugin_known_permissions();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
$slug = (string) ($_POST['slug'] ?? '');
if ($action === 'save_settings' && plugin_strict_mode()) {
$errors[] = 'Im geprüften Modus sind Katalog und Schlüssel fest vorgegeben.';
} elseif ($action === 'save_settings') {
$url = trim((string) ($_POST['catalog_url'] ?? ''));
$key = trim((string) ($_POST['publisher_key'] ?? ''));
if ($url !== '' && ($e = plugin_url_check($url))) $errors[] = 'Katalog-Adresse: ' . $e;
if ($key !== '' && !plugin_public_key_valid($key)) $errors[] = 'Der Herausgeber-Schlüssel ist kein gültiger öffentlicher RSA-Schlüssel (PEM, mindestens 2048 Bit).';
if (!empty($_POST['require_signature']) && $key === '') $errors[] = 'Für „Signatur erforderlich“ muss ein Herausgeber-Schlüssel hinterlegt sein.';
if (!$errors) {
set_setting('plugin_catalog_url', $url);
set_setting('plugin_publisher_key', $key);
set_setting('plugin_require_signature', !empty($_POST['require_signature']) ? '1' : '0');
audit_log($staff['id'], 'Plugin-Einstellungen geändert (Katalog/Herausgeber-Schlüssel)');
flash('success', 'Einstellungen gespeichert.');
redirect('/staff/plugins.php');
}
}
if ($action === 'install') {
$f = $_FILES['plugin_zip'] ?? [];
if (empty($_POST['trust'])) {
$errors[] = 'Bitte bestätigen, dass du der Quelle des Plugins vertraust.';
} elseif (($f['error'] ?? 99) !== UPLOAD_ERR_OK) {
$errors[] = 'Upload-Fehler (Code ' . ($f['error'] ?? 'unbekannt') . ').';
} elseif (strtolower(pathinfo((string) ($f['name'] ?? ''), PATHINFO_EXTENSION)) !== 'zip') {
$errors[] = 'Nur ZIP-Dateien sind erlaubt.';
} else {
$res = plugin_install_zip($pdo, $f['tmp_name'], trim((string) ($_POST['sha256'] ?? '')), trim((string) ($_POST['signature'] ?? '')), 'upload');
if (!$res['ok']) {
$errors[] = $res['error'];
} else {
audit_log($staff['id'], ($res['updated'] ? 'Plugin aktualisiert: ' : 'Plugin installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
flash('success', plugin_install_message($res, plugins_installed($pdo)[$res['slug']] ?? []));
redirect('/staff/plugins.php');
}
}
}
if ($action === 'catalog_install' && plugin_valid_slug($slug)) {
$res = plugin_catalog_install($pdo, $slug);
if ($res['ok']) {
audit_log($staff['id'], ($res['updated'] ? 'Plugin aus dem Katalog aktualisiert: ' : 'Plugin aus dem Katalog installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
flash('success', plugin_install_message($res, plugins_installed($pdo)[$res['slug']] ?? []));
redirect('/staff/plugins.php');
}
$errors[] = $res['error'];
}
if (in_array($action, ['activate', 'deactivate'], true) && plugin_valid_slug($slug)) {
if ($action === 'activate') {
$bk = create_backup(); // vor Datenbank-Änderungen des Plugins
if (!$bk['ok']) $errors[] = 'Backup fehlgeschlagen – das Plugin wurde nicht aktiviert: ' . ($bk['error'] ?? 'unbekannter Fehler');
}
if (!$errors) {
$manifest = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
// Signierte Plugins aus dem Katalog des Herausgebers brauchen keine zusätzliche Bestätigung; manuell hochgeladene schon
$row = plugins_installed($pdo)[$slug] ?? [];
$fromCatalog = ($row['source'] ?? '') === 'catalog' && !empty($row['signature_ok']);
$approve = (!empty($_POST['confirm_permissions']) || $fromCatalog) && $manifest ? $manifest['permissions'] : null;
$res = plugin_set_active($pdo, $slug, $action === 'activate', $approve);
if ($res['ok']) {
audit_log($staff['id'], ($action === 'activate' ? 'Plugin aktiviert: ' : 'Plugin deaktiviert: ') . $slug);
flash('success', $action === 'activate' ? 'Plugin aktiviert.' : 'Plugin deaktiviert.');
redirect('/staff/plugins.php');
}
$errors[] = $res['error'];
}
}
if ($action === 'uninstall' && plugin_valid_slug($slug)) {
$res = plugin_uninstall($pdo, $slug, !empty($_POST['remove_data']));
if ($res['ok']) {
audit_log($staff['id'], 'Plugin entfernt: ' . $slug . (!empty($_POST['remove_data']) ? ' (inkl. Daten)' : ''));
flash('success', 'Plugin entfernt.');
redirect('/staff/plugins.php');
}
$errors[] = $res['error'];
}
}
$installed = plugins_installed($pdo);
$manifests = [];
foreach ($installed as $slug => $row) $manifests[$slug] = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
$disabled = plugins_globally_disabled();
$strict = plugin_strict_mode();
$official = plugin_official_configured();
$catalog = plugin_catalog_url() !== '' ? plugin_catalog_cached(isset($_GET['refresh']), 60) : null;
// Katalogeinträge je Plugin (für die Vorschaubilder der installierten Plugins)
$catBySlug = [];
if ($catalog && !empty($catalog['ok'])) foreach ($catalog['plugins'] as $__c) $catBySlug[$__c['slug']] = $__c;
$hasKey = plugin_publisher_key() !== '';
$catalogHost = (string) (parse_url(plugin_catalog_url(), PHP_URL_HOST) ?: '');
$pageTitle = 'Plugins';
$activePage = 'plugins';
require __DIR__ . '/_header.php';
?>
<h1 style="margin-top:0">Plugins</h1>
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
<?php $updates = plugin_updates_available($pdo); if ($updates): ?>
<div class="flash warning"><strong>Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['installed'] . ' → ' . $u['available'], $updates))) ?>. Unten im Katalog mit „Aktualisieren“ installieren.</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>
<?php endif; ?>
<div class="card">
<table>
<thead><tr><th>Plugin</th><th>Version</th><th>Status</th><th>Herkunft</th><th></th></tr></thead>
<tbody>
<?php foreach ($installed as $slug => $p): $m = $manifests[$slug]; $perms = $m['permissions'] ?? []; ?>
<tr>
<td>
<div style="display:flex;gap:12px;align-items:flex-start"><?= catalog_thumb('plugin', $catBySlug[$slug] ?? ['slug' => $slug, 'name' => $p['name']]) ?><div style="min-width:0">
<strong><?= e($p['name']) ?></strong> <span class="muted" style="font-size:.78rem"><?= e($slug) ?></span>
<?php if ($m && $m['description'] !== ''): ?><div class="muted" style="font-size:.82rem"><?= e($m['description']) ?></div><?php endif; ?>
<?php if ($m && $m['author'] !== ''): ?><div class="muted" style="font-size:.78rem">von <?= e($m['author']) ?></div><?php endif; ?>
<?php if ($perms): ?>
<details style="font-size:.78rem;margin-top:4px"><summary>Berechtigungen (<?= count($perms) ?>)</summary>
<ul style="margin:4px 0 0;padding-left:18px"><?php foreach ($perms as $perm): ?><li><?= e($permLabels[$perm] ?? $perm) ?> <span class="muted">(<?= e($perm) ?>)</span></li><?php endforeach; ?></ul>
</details>
<?php elseif ($m): ?><div class="muted" style="font-size:.78rem">Fordert keine Berechtigungen an.</div><?php endif; ?>
<?php if (!$m): ?><div style="color:#dc2626;font-size:.82rem">plugin.json fehlt oder ist ungültig – das Plugin kann nicht geladen werden.</div><?php endif; ?>
</div></div>
</td>
<td><?= e($p['version']) ?></td>
<td>
<?php if ($p['is_active']): ?><span style="color:#15803d;font-weight:600">Aktiv</span>
<?php elseif ((int) $p['fail_count'] >= PLUGIN_FAIL_LIMIT): ?><span style="color:#dc2626;font-weight:600">Wegen Fehlern deaktiviert</span>
<?php else: ?><span class="muted">Inaktiv</span><?php endif; ?>
<?php if (!empty($p['last_error'])): ?><div style="color:#b45309;font-size:.74rem;margin-top:4px;max-width:260px;white-space:normal;word-break:break-word"><?= e(mb_strimwidth((string) $p['last_error'], 0, 300, '…')) ?></div><?php endif; ?>
</td>
<td style="font-size:.82rem">
<?php if (!empty($p['signature_ok'])): ?><span style="color:#15803d;font-weight:600">✓ Signiert</span>
<?php else: ?><span style="color:#b45309;font-weight:600">Eigenes Plugin</span><?php endif; ?><br>
<span class="muted"><?= ($p['source'] ?? '') === 'catalog' ? 'Aus dem Katalog' : (!empty($p['signature_ok']) ? 'Manuell hochgeladen' : 'Nicht vom Herausgeber geprüft') ?></span>
</td>
<td style="white-space:nowrap">
<?php if ($p['is_active']): ?>
<form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="deactivate"><button class="btn secondary" type="submit">Deaktivieren</button></form>
<?php else: ?>
<form method="post" style="display:inline-block;text-align:left" onsubmit="return confirm('Plugin aktivieren? Vorher wird ein Backup erstellt und evtl. Datenbank-Änderungen des Plugins eingespielt.')">
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="activate">
<?php if ($perms && !(($p['source'] ?? '') === 'catalog' && !empty($p['signature_ok']))): ?><label style="font-size:.76rem;display:block"><input type="checkbox" name="confirm_permissions" value="1" required> Berechtigungen bestätigen</label><?php endif; ?>
<button class="btn" type="submit" <?= $m ? '' : 'disabled' ?>>Aktivieren</button>
</form>
<?php endif; ?>
<form method="post" style="display:inline" onsubmit="return confirm('Plugin wirklich entfernen?')">
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="uninstall">
<label style="font-size:.78rem"><input type="checkbox" name="remove_data" value="1"> Daten löschen</label>
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer">Entfernen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$installed): ?><tr><td colspan="5" class="muted">Noch keine Plugins installiert.</td></tr><?php endif; ?>
</tbody>
</table>
</div>
<div class="card" style="margin-top:20px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<h3 style="margin:0"><?= $official ? 'Geprüfte Plugins' : 'Plugin-Katalog' ?></h3>
<?php if ($catalog): ?><a class="btn secondary" href="/staff/plugins.php?refresh=1">Katalog aktualisieren</a><?php endif; ?>
</div>
<?php if (!$catalog): ?>
<p class="muted" style="font-size:.85rem">Es ist noch keine Katalog-Adresse hinterlegt. Trage unten die Adresse der Plugin-Webseite ein, dann könnt ihr Plugins direkt von dort installieren.</p>
<?php elseif (!$catalog['ok']): ?>
<div class="flash error" style="margin-top:12px"><?= e($catalog['error']) ?></div>
<?php else: ?>
<?php if (!empty($catalog['cached_at'])): ?><div class="muted" style="font-size:.76rem;margin-top:6px">Stand: <?= e(date('d.m.Y H:i', (int) $catalog['cached_at'])) ?></div><?php endif; ?>
<table style="margin-top:12px">
<thead><tr><th>Plugin</th><th>Version</th><th>Signatur</th><th></th></tr></thead>
<tbody>
<?php foreach ($catalog['plugins'] as $c): $have = $installed[$c['slug']] ?? null; $newer = $have && version_compare($c['version'], $have['version'], '>'); ?>
<tr>
<td><div style="display:flex;gap:12px;align-items:flex-start"><?= catalog_thumb('plugin', $c) ?><div style="min-width:0">
<strong><?= e($c['name']) ?></strong> <?php if (!empty($c['language'])): ?><span title="Sprachpaket: nach der Aktivierung erscheint die Flagge zum Umschalten" style="background:#e8f1ff;color:#1d4ed8;border:1px solid #bcd3f7;border-radius:20px;padding:1px 9px;font-size:.72rem;font-weight:700">🌐 Sprache: <?= e($c['language']['name']) ?></span> <?php endif; ?><?php if (!empty($c['featured'])): ?><span title="Vom Herausgeber empfohlen" style="background:#fff4d6;color:#92600a;border:1px solid #f1d38a;border-radius:20px;padding:1px 9px;font-size:.72rem;font-weight:700">★ Empfohlen</span><?php endif; ?> <span class="muted" style="font-size:.78rem"><?= e($c['slug']) ?></span>
<div class="muted" style="font-size:.82rem"><?= e($c['description']) ?></div>
<?php if ($c['author'] !== ''): ?><div class="muted" style="font-size:.78rem">von <?= e($c['author']) ?></div><?php endif; ?>
<?php if ($c['permissions']): ?><div class="muted" style="font-size:.76rem">Fordert an: <?= e(implode(', ', array_map(fn($p) => $permLabels[$p] ?? $p, $c['permissions']))) ?></div><?php endif; ?>
</div></div></td>
<td><?= e($c['version']) ?><?= $have ? '<div class="muted" style="font-size:.76rem">installiert: ' . e($have['version']) . '</div>' : '' ?></td>
<td style="font-size:.82rem"><?= $c['signature'] !== '' ? ($hasKey ? '<span style="color:#15803d;font-weight:600">✓ Signiert</span>' : '<span style="color:#b45309">Signatur vorhanden, Schlüssel fehlt</span>') : '<span style="color:#b45309">Nicht signiert</span>' ?></td>
<td>
<?php if ($have && !$newer): ?><span class="muted">aktuell</span>
<?php elseif ($strict && $c['signature'] === ''): ?><span class="muted" style="font-size:.78rem">nicht signiert,<br>nicht installierbar</span>
<?php else: ?>
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="catalog_install"><input type="hidden" name="slug" value="<?= e($c['slug']) ?>">
<button class="btn" type="submit"><?= $newer ? 'Aktualisieren' : 'Installieren' ?></button></form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$catalog['plugins']): ?><tr><td colspan="4" class="muted">Der Katalog enthält keine Plugins.</td></tr><?php endif; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="card" style="margin-top:20px">
<h3 style="margin-top:0">Eigenes Plugin hochladen</h3>
<p class="muted" style="font-size:.85rem">Hier könnt ihr eigene Plugins als ZIP installieren oder aktualisieren, auch ohne Signatur. Diese Plugins sind <strong>nicht vom Herausgeber geprüft</strong> und laufen mit allen Rechten des Systems, auch mit Zugriff auf die gesamte Datenbank. Installiert nur Code, den ihr selbst geschrieben oder geprüft habt.</p>
<form method="post" enctype="multipart/form-data">
<?= csrf_field() ?><input type="hidden" name="action" value="install">
<label for="plugin_zip">ZIP-Datei</label>
<input type="file" id="plugin_zip" name="plugin_zip" accept=".zip" required>
<label for="signature">Signatur (optional, Base64)</label>
<input type="text" id="signature" name="signature" placeholder="Nur ausfüllen, wenn der Anbieter eine Signatur nennt" autocomplete="off">
<label for="sha256">Erwartete Prüfsumme (SHA-256, optional)</label>
<input type="text" id="sha256" name="sha256" placeholder="Nur ausfüllen, wenn der Anbieter eine Prüfsumme nennt" autocomplete="off">
<label style="margin-top:10px"><input type="checkbox" name="trust" value="1" required> Ich kenne den Inhalt dieses Plugins und vertraue ihm. Mir ist bewusst, dass es mit allen Rechten des Systems läuft.</label>
<div class="btn-row" style="margin-top:12px"><button type="submit" class="btn">Hochladen &amp; installieren</button></div>
</form>
<p class="muted" style="font-size:.82rem">Das Plugin wird nach der Installation nicht automatisch aktiviert. Vor dem Aktivieren bestätigt ihr die Berechtigungen, die es anfordert.</p>
</div>
<?php if (!$strict): ?>
<div class="card" style="margin-top:20px">
<h3 style="margin-top:0">Katalog und Herausgeber-Schlüssel</h3>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="action" value="save_settings">
<label for="catalog_url">Katalog-Adresse (https)</label>
<input type="text" id="catalog_url" name="catalog_url" value="<?= e(plugin_catalog_url()) ?>" placeholder="https://plugins.example.de/catalog.php?type=plugins">
<label for="publisher_key">Öffentlicher Herausgeber-Schlüssel (PEM)</label>
<textarea id="publisher_key" name="publisher_key" rows="6" style="font-family:monospace;font-size:.78rem" placeholder="-----BEGIN PUBLIC KEY-----"><?= e(plugin_publisher_key()) ?></textarea>
<label style="margin-top:8px"><input type="checkbox" name="require_signature" value="1" <?= plugin_signatures_required() ? 'checked' : '' ?>> Nur signierte Plugins installieren (empfohlen, sobald ein Schlüssel hinterlegt ist)</label>
<p class="muted" style="font-size:.82rem">Den Schlüssel bekommst du von der Plugin-Webseite. Ist er hinterlegt, prüft DeskHub bei jeder Installation, ob das Paket wirklich vom Herausgeber stammt und unverändert ist.</p>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
</div>
<?php endif; ?>
<?php require __DIR__ . '/_footer.php'; ?>
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/backup.php';
require_once __DIR__ . '/_ui.php';
$staff = require_admin();
$pdo = get_db();
$errors = [];
$permLabels = plugin_known_permissions();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
$slug = (string) ($_POST['slug'] ?? '');
if ($action === 'save_settings' && plugin_strict_mode()) {
$errors[] = 'Im geprüften Modus sind Katalog und Schlüssel fest vorgegeben.';
} elseif ($action === 'save_settings') {
$url = trim((string) ($_POST['catalog_url'] ?? ''));
$key = trim((string) ($_POST['publisher_key'] ?? ''));
if ($url !== '' && ($e = plugin_url_check($url))) $errors[] = 'Katalog-Adresse: ' . $e;
if ($key !== '' && !plugin_public_key_valid($key)) $errors[] = 'Der Herausgeber-Schlüssel ist kein gültiger öffentlicher RSA-Schlüssel (PEM, mindestens 2048 Bit).';
if (!empty($_POST['require_signature']) && $key === '') $errors[] = 'Für „Signatur erforderlich“ muss ein Herausgeber-Schlüssel hinterlegt sein.';
if (!$errors) {
set_setting('plugin_catalog_url', $url);
set_setting('plugin_publisher_key', $key);
set_setting('plugin_require_signature', !empty($_POST['require_signature']) ? '1' : '0');
audit_log($staff['id'], 'Plugin-Einstellungen geändert (Katalog/Herausgeber-Schlüssel)');
flash('success', 'Einstellungen gespeichert.');
redirect('/staff/plugins.php');
}
}
if ($action === 'install') {
$f = $_FILES['plugin_zip'] ?? [];
if (empty($_POST['trust'])) {
$errors[] = 'Bitte bestätigen, dass du der Quelle des Plugins vertraust.';
} elseif (($f['error'] ?? 99) !== UPLOAD_ERR_OK) {
$errors[] = 'Upload-Fehler (Code ' . ($f['error'] ?? 'unbekannt') . ').';
} elseif (strtolower(pathinfo((string) ($f['name'] ?? ''), PATHINFO_EXTENSION)) !== 'zip') {
$errors[] = 'Nur ZIP-Dateien sind erlaubt.';
} else {
$res = plugin_install_zip($pdo, $f['tmp_name'], trim((string) ($_POST['sha256'] ?? '')), trim((string) ($_POST['signature'] ?? '')), 'upload');
if (!$res['ok']) {
$errors[] = $res['error'];
} else {
audit_log($staff['id'], ($res['updated'] ? 'Plugin aktualisiert: ' : 'Plugin installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
flash('success', plugin_install_message($res, plugins_installed($pdo)[$res['slug']] ?? []));
redirect('/staff/plugins.php');
}
}
}
if ($action === 'catalog_install' && plugin_valid_slug($slug)) {
$res = plugin_catalog_install($pdo, $slug);
if ($res['ok']) {
audit_log($staff['id'], ($res['updated'] ? 'Plugin aus dem Katalog aktualisiert: ' : 'Plugin aus dem Katalog installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
flash('success', plugin_install_message($res, plugins_installed($pdo)[$res['slug']] ?? []));
redirect('/staff/plugins.php');
}
$errors[] = $res['error'];
}
if (in_array($action, ['activate', 'deactivate'], true) && plugin_valid_slug($slug)) {
if ($action === 'activate') {
$bk = create_backup(); // vor Datenbank-Änderungen des Plugins
if (!$bk['ok']) $errors[] = 'Backup fehlgeschlagen – das Plugin wurde nicht aktiviert: ' . ($bk['error'] ?? 'unbekannter Fehler');
}
if (!$errors) {
$manifest = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
// Signierte Plugins aus dem Katalog des Herausgebers brauchen keine zusätzliche Bestätigung; manuell hochgeladene schon
$row = plugins_installed($pdo)[$slug] ?? [];
$fromCatalog = ($row['source'] ?? '') === 'catalog' && !empty($row['signature_ok']);
$approve = (!empty($_POST['confirm_permissions']) || $fromCatalog) && $manifest ? $manifest['permissions'] : null;
$res = plugin_set_active($pdo, $slug, $action === 'activate', $approve);
if ($res['ok']) {
audit_log($staff['id'], ($action === 'activate' ? 'Plugin aktiviert: ' : 'Plugin deaktiviert: ') . $slug);
flash('success', $action === 'activate' ? 'Plugin aktiviert.' : 'Plugin deaktiviert.');
redirect('/staff/plugins.php');
}
$errors[] = $res['error'];
}
}
if ($action === 'uninstall' && plugin_valid_slug($slug)) {
$res = plugin_uninstall($pdo, $slug, !empty($_POST['remove_data']));
if ($res['ok']) {
audit_log($staff['id'], 'Plugin entfernt: ' . $slug . (!empty($_POST['remove_data']) ? ' (inkl. Daten)' : ''));
flash('success', 'Plugin entfernt.');
redirect('/staff/plugins.php');
}
$errors[] = $res['error'];
}
}
$installed = plugins_installed($pdo);
$manifests = [];
foreach ($installed as $slug => $row) $manifests[$slug] = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
$disabled = plugins_globally_disabled();
$strict = plugin_strict_mode();
$official = plugin_official_configured();
$catalog = plugin_catalog_url() !== '' ? plugin_catalog_cached(isset($_GET['refresh']), 60) : null;
// Katalogeinträge je Plugin (für die Vorschaubilder der installierten Plugins)
$catBySlug = [];
if ($catalog && !empty($catalog['ok'])) foreach ($catalog['plugins'] as $__c) $catBySlug[$__c['slug']] = $__c;
$hasKey = plugin_publisher_key() !== '';
$catalogHost = (string) (parse_url(plugin_catalog_url(), PHP_URL_HOST) ?: '');
$updates = plugin_updates_available($pdo);
$updBySlug = [];
foreach ($updates as $u) $updBySlug[$u['slug']] = $u;
// Zustand je Plugin: active | inactive | error; dazu Update-Kennzeichen
$stateOf = [];
$counts = ['all' => 0, 'active' => 0, 'inactive' => 0, 'error' => 0, 'update' => 0];
foreach ($installed as $slug => $p) {
$bad = !$manifests[$slug] || (!$p['is_active'] && (int) $p['fail_count'] >= PLUGIN_FAIL_LIMIT);
$st = $bad ? 'error' : ($p['is_active'] ? 'active' : 'inactive');
$stateOf[$slug] = $st;
$counts['all']++;
$counts[$st]++;
if (isset($updBySlug[$slug])) $counts['update']++;
}
// Erste Menüseite eines geladenen Plugins für den Link „Öffnen“
$openUrl = [];
foreach (plugin_state()['menus'] as $__m) {
if (!isset($openUrl[$__m['plugin']])) $openUrl[$__m['plugin']] = '/staff/plugin.php?p=' . rawurlencode($__m['plugin']) . '&page=' . rawurlencode($__m['page']);
}
$tab = in_array($_GET['tab'] ?? '', ['add', 'settings'], true) ? $_GET['tab'] : 'installed';
if ($errors) {
$failedAction = $_POST['action'] ?? '';
if (in_array($failedAction, ['install', 'catalog_install'], true)) $tab = 'add';
if ($failedAction === 'save_settings') $tab = 'settings';
}
$banner = function (array $c, string $name): string {
$inner = !empty($c['preview'])
? '<img src="/staff/catalog_preview.php?kind=plugin&amp;slug=' . e($c['slug']) . '" alt="">'
: '<span>' . e(mb_strtoupper(mb_substr($name, 0, 1))) . '</span>';
return '<div class="pl-hero">' . $inner . '</div>';
};
$pageTitle = 'Plugins';
$activePage = 'plugins';
require __DIR__ . '/_header.php';
?>
<style>
.pl-head { display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:6px }
.pl-head h1 { margin:0 }
.pl-tabs { display:flex; gap:4px; border-bottom:1px solid var(--border); margin:14px 0 16px; flex-wrap:wrap }
.pl-tabs button { background:none; border:none; border-bottom:3px solid transparent; padding:9px 14px; font:inherit; font-weight:600; color:var(--muted); cursor:pointer }
.pl-tabs button[aria-selected=true] { color:var(--primary); border-bottom-color:var(--primary) }
.pl-bar { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:12px }
.pl-filters { display:flex; gap:4px 14px; flex-wrap:wrap; font-size:.9rem }
.pl-filters button { background:none; border:none; padding:2px 0; font:inherit; color:var(--primary); cursor:pointer }
.pl-filters button[aria-pressed=true] { color:inherit; font-weight:700; border-bottom:2px solid var(--primary) }
.pl-filters .n { color:var(--muted); font-weight:400 }
.pl-search { max-width:260px; margin:0 }
.pl-card[hidden] { display:none }
.pl-title { display:flex; gap:12px; align-items:flex-start }
.pl-title strong { font-size:1.02rem }
.pl-actions { margin-top:8px; display:flex; flex-wrap:wrap; gap:6px 14px; align-items:center; font-size:.86rem }
.pl-actions form { display:inline; margin:0 }
.pl-link { background:none; border:none; padding:0; font:inherit; color:var(--primary); cursor:pointer; text-decoration:none }
.pl-link:hover { text-decoration:underline }
.pl-link.danger { color:#dc2626 }
.pl-meta { font-size:.82rem; color:var(--muted) }
.pl-meta p { margin:0 0 6px; color:inherit }
.pl-tag { display:inline-block; border-radius:20px; padding:1px 9px; font-size:.72rem; font-weight:700; border:1px solid var(--border); margin-right:4px }
.pl-tag.ok { background:#e8f6ee; color:#15803d; border-color:#b7e1c6 }
.pl-tag.warn { background:#fff4d6; color:#92600a; border-color:#f1d38a }
.pl-tag.bad { background:#fdeaea; color:#b91c1c; border-color:#f3b9b9 }
.pl-tag.info { background:#e8f1ff; color:#1d4ed8; border-color:#bcd3f7 }
.pl-err { color:#b45309; font-size:.8rem; margin-top:6px; word-break:break-word }
.pl-empty { padding:26px; text-align:center; color:var(--muted) }
.pl-empty[hidden] { display:none }
.pl-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(420px,1fr)); gap:16px }
.pl-card { background:var(--card,#fff); border:1px solid var(--border); border-radius:6px; display:flex; flex-direction:column; overflow:hidden }
.pl-card-top { display:grid; grid-template-columns:128px minmax(0,1fr) auto; gap:16px; padding:18px 18px 14px; align-items:start }
.pl-card.st-active { border-top:3px solid #15803d }
.pl-card.st-error { border-top:3px solid #dc2626 }
.pl-card-icon img, .pl-card-icon div { width:128px !important; height:128px !important; border-radius:14px !important; object-fit:cover; font-size:2.6rem !important }
.pl-card-main h4 { margin:0 0 8px; font-size:1.05rem; color:var(--primary) }
.pl-card-main p { margin:0 0 8px; font-size:.86rem; line-height:1.45 }
.pl-card-main p.by { font-style:italic; color:var(--muted); font-size:.82rem }
.pl-card-act { display:flex; flex-direction:column; align-items:flex-end; gap:12px }
.pl-card-act .btn { white-space:nowrap }
.pl-installed-badge { display:inline-block; padding:8px 14px; border-radius:8px; background:#e8f6ee; color:#15803d; border:1px solid #b7e1c6; font-weight:700; font-size:.88rem; white-space:nowrap }
.pl-card-act .pl-install { background:var(--primary); color:#fff; font-weight:700 }
.pl-more { font-size:.85rem; color:var(--primary) }
.pl-card-main p:not(.by) { display:-webkit-box; -webkit-line-clamp:4; line-clamp:4; -webkit-box-orient:vertical; overflow:hidden }
.pl-card-detail { display:none }
#pl-modal { margin:auto; border:none; border-radius:8px; padding:0; width:min(900px, 94vw); max-height:90vh; background:var(--card,#fff); color:inherit; overflow:hidden }
#pl-modal::backdrop { background:rgba(0,0,0,.65) }
#pl-modal .pl-modal-inner { display:flex; flex-direction:column; max-height:90vh }
#pl-modal .pl-card-detail { display:flex; flex-direction:column; min-height:0; flex:1; overflow:hidden; padding:0 }
#pl-modal .pl-modal-body { display:grid; grid-template-columns:minmax(0,1fr) 230px; overflow:auto; min-height:0 }
#pl-modal .pl-modal-text { padding:20px 24px; font-size:.92rem; line-height:1.55 }
#pl-modal .pl-modal-text h3 { margin:0 0 10px; font-size:1.25rem }
#pl-modal .pl-modal-text h4 { margin:16px 0 6px }
#pl-modal .pl-modal-side { padding:20px; background:var(--bg-soft,#f6f8fa); border-left:1px solid var(--border-soft); font-size:.84rem }
#pl-modal .pl-modal-side p { margin:0 0 10px }
#pl-modal .pl-modal-foot { padding:12px 20px; border-top:1px solid var(--border-soft); display:flex; flex-direction:row-reverse; justify-content:space-between; align-items:center; gap:14px; flex-wrap:wrap; background:var(--bg-soft,#f6f8fa) }
.pl-hero { min-height:110px; max-height:42vh; flex:none; background:#0b2a30; display:flex; align-items:center; justify-content:center; overflow:hidden }
.pl-hero img { display:block; width:100%; height:auto; max-height:42vh; object-fit:contain }
.pl-hero span { font-size:3.2rem; font-weight:700; color:#fff }
#pl-modal .pl-modal-foot form { margin:0; display:flex; align-items:center; gap:10px; flex-wrap:wrap }
#pl-modal .pl-modal-foot .pl-confirm { margin:0 }
#pl-modal .pl-modal-foot .btn.danger { padding:7px 12px; font-size:.85rem }
.pl-modal-close { position:absolute; top:10px; right:10px; z-index:2; width:34px; height:34px; border-radius:50%; border:none; background:rgba(0,0,0,.55); color:#fff; font-size:1.2rem; cursor:pointer; line-height:1 }
@media (max-width:640px) { #pl-modal .pl-modal-body { grid-template-columns:1fr } #pl-modal .pl-modal-side { border-left:none; border-top:1px solid var(--border-soft) } }
.pl-card .pl-foot { margin-top:auto; display:flex; justify-content:space-between; align-items:flex-end; gap:12px; flex-wrap:wrap; padding:14px 18px; background:var(--bg-soft,#f6f8fa); border-top:1px solid var(--border-soft); font-size:.8rem }
@media (max-width:560px) { .pl-grid { grid-template-columns:1fr } .pl-card-top { grid-template-columns:72px minmax(0,1fr) } .pl-card-icon img, .pl-card-icon div { width:72px !important; height:72px !important } .pl-card-act { grid-column:1 / -1; flex-direction:row; align-items:center } }
.pl-confirm { display:block; font-size:.76rem; margin-bottom:4px }
[data-pl-panel][hidden] { display:none }
@media (max-width:760px) { .pl-search { max-width:none; width:100% } }
</style>
<div class="pl-head">
<h1>Plugins</h1>
<button type="button" class="btn secondary" data-pl-tab="add">Neu hinzufügen</button>
</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>
<?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>
<?php endif; ?>
<div class="pl-tabs" role="tablist">
<button type="button" role="tab" data-pl-tab="installed" aria-selected="<?= $tab === 'installed' ? 'true' : 'false' ?>">Installiert (<?= $counts['all'] ?>)</button>
<button type="button" role="tab" data-pl-tab="add" aria-selected="<?= $tab === 'add' ? 'true' : 'false' ?>">Plugin hinzufügen</button>
<?php if (!$strict): ?><button type="button" role="tab" data-pl-tab="settings" aria-selected="<?= $tab === 'settings' ? 'true' : 'false' ?>">Katalog &amp; Schlüssel</button><?php endif; ?>
</div>
<!-- Installiert -->
<section data-pl-panel="installed" <?= $tab === 'installed' ? '' : 'hidden' ?>>
<div class="pl-bar">
<div class="pl-filters" role="group" aria-label="Filter">
<button type="button" data-pl-filter="all" aria-pressed="true">Alle <span class="n">(<?= $counts['all'] ?>)</span></button>
<button type="button" data-pl-filter="active" aria-pressed="false">Aktiv <span class="n">(<?= $counts['active'] ?>)</span></button>
<button type="button" data-pl-filter="inactive" aria-pressed="false">Inaktiv <span class="n">(<?= $counts['inactive'] ?>)</span></button>
<?php if ($counts['error']): ?><button type="button" data-pl-filter="error" aria-pressed="false">Mit Fehler <span class="n">(<?= $counts['error'] ?>)</span></button><?php endif; ?>
<?php if ($counts['update']): ?><button type="button" data-pl-filter="update" aria-pressed="false">Update verfügbar <span class="n">(<?= $counts['update'] ?>)</span></button><?php endif; ?>
</div>
<input type="search" class="pl-search" id="pl-search" placeholder="Installierte Plugins durchsuchen …" aria-label="Installierte Plugins durchsuchen">
</div>
<div class="pl-grid" id="pl-installed">
<?php foreach ($installed as $slug => $p): $m = $manifests[$slug]; $perms = $m['permissions'] ?? []; $st = $stateOf[$slug]; $upd = $updBySlug[$slug] ?? null;
$trusted = ($p['source'] ?? '') === 'catalog' && !empty($p['signature_ok']); ?>
<div class="pl-card st-<?= e($st) ?>" data-state="<?= e($st) ?>" data-update="<?= $upd ? '1' : '0' ?>" data-text="<?= e(mb_strtolower($p['name'] . ' ' . $slug . ' ' . ($m['description'] ?? '') . ' ' . ($m['author'] ?? ''))) ?>">
<div class="pl-card-top">
<div class="pl-card-icon"><?= catalog_thumb('plugin', $catBySlug[$slug] ?? ['slug' => $slug, 'name' => $p['name']]) ?></div>
<div class="pl-card-main">
<h4><?= e($p['name']) ?></h4>
<p><?= $m && $m['description'] !== '' ? e($m['description']) : (!$m ? '<span style="color:#dc2626">plugin.json fehlt oder ist ungültig – das Plugin kann nicht geladen werden.</span>' : '') ?></p>
<p class="by">Von <span><?= $m && $m['author'] !== '' ? e($m['author']) : 'unbekannt' ?></span></p>
<div>
<?php if ($p['is_active']): ?><span class="pl-tag ok">Aktiv</span>
<?php elseif ($st === 'error'): ?><span class="pl-tag bad"><?= $m ? 'Wegen Fehlern deaktiviert' : 'Ungültig' ?></span>
<?php else: ?><span class="pl-tag">Inaktiv</span><?php endif; ?>
<?php if ($upd): ?><span class="pl-tag warn">Update <span><?= e($upd['available']) ?></span></span><?php endif; ?>
</div>
</div>
<div class="pl-card-act">
<?php if ($p['is_active']): ?>
<form method="post" style="margin:0"><?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="deactivate"><button class="btn secondary" type="submit">Deaktivieren</button></form>
<?php if (isset($openUrl[$slug])): ?><a class="pl-more" href="<?= e($openUrl[$slug]) ?>">Öffnen</a><?php endif; ?>
<?php else: ?>
<form method="post" style="margin:0;text-align:right" onsubmit="return confirm('Plugin aktivieren? Vorher wird ein Backup erstellt und evtl. Datenbank-Änderungen des Plugins eingespielt.')">
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="activate">
<?php if ($perms && !$trusted): ?><label class="pl-confirm"><input type="checkbox" name="confirm_permissions" value="1" required> Berechtigungen bestätigen</label><?php endif; ?>
<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; ?>
<a href="#" class="pl-more">Weitere Details</a>
</div>
</div>
<div class="pl-card-detail" hidden>
<?= $banner($catBySlug[$slug] ?? ['slug' => $slug], $p['name']) ?>
<div class="pl-modal-body">
<div class="pl-modal-text">
<h3><?= e($p['name']) ?></h3>
<p><?= $m && $m['description'] !== '' ? nl2br(e($m['description'])) : '' ?></p>
<?php if (!$m): ?><p style="color:#dc2626">plugin.json fehlt oder ist ungültig – das Plugin kann nicht geladen werden.</p><?php endif; ?>
<?php if ($perms): ?>
<h4>Berechtigungen</h4>
<ul><?php foreach ($perms as $perm): ?><li><?= e($permLabels[$perm] ?? $perm) ?> <span style="opacity:.7">(<?= e($perm) ?>)</span></li><?php endforeach; ?></ul>
<?php elseif ($m): ?><p>Dieses Plugin fordert keine Berechtigungen an.</p><?php endif; ?>
<?php if (!empty($p['last_error'])): ?><h4>Letzte Fehlermeldung</h4><div class="pl-err"><?= e(mb_strimwidth((string) $p['last_error'], 0, 300, '…')) ?></div><?php endif; ?>
</div>
<aside class="pl-modal-side">
<p><strong>Version:</strong> <?= e($p['version']) ?></p>
<p><strong>Autor:</strong> <?= $m && $m['author'] !== '' ? e($m['author']) : 'unbekannt' ?></p>
<p><strong>Kennung:</strong> <code><?= e($slug) ?></code></p>
<p><strong>Zustand:</strong> <?= $p['is_active'] ? 'Aktiv' : ($st === 'error' ? 'Fehler' : 'Inaktiv') ?></p>
<p><strong>Herkunft:</strong> <?= ($p['source'] ?? '') === 'catalog' ? 'Aus dem Katalog' : (!empty($p['signature_ok']) ? 'Manuell hochgeladen' : 'Nicht vom Herausgeber geprüft') ?></p>
<p><strong>Signatur:</strong> <?= !empty($p['signature_ok']) ? '✓ vom Herausgeber signiert' : 'nicht signiert (eigenes Plugin)' ?></p>
<?php if ($upd): ?><p><strong>Update:</strong> <span><?= e($upd['available']) ?></span> verfügbar</p><?php endif; ?>
</aside>
</div>
<div class="pl-modal-foot">
<?php if ($p['is_active']): ?>
<form method="post"><?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="deactivate"><button class="btn secondary" type="submit">Deaktivieren</button></form>
<?php else: ?>
<form method="post" onsubmit="return confirm('Plugin aktivieren? Vorher wird ein Backup erstellt und evtl. Datenbank-Änderungen des Plugins eingespielt.')">
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="activate">
<?php if ($perms && !$trusted): ?><label class="pl-confirm"><input type="checkbox" name="confirm_permissions" value="1" required> Berechtigungen bestätigen</label><?php endif; ?>
<button class="btn" type="submit" <?= $m ? '' : 'disabled' ?>>Aktivieren</button>
</form>
<?php endif; ?>
<form method="post" onsubmit="return confirm('Plugin wirklich entfernen?')">
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="uninstall">
<label class="pl-confirm"><input type="checkbox" name="remove_data" value="1"> Auch die Daten des Plugins löschen</label>
<button type="submit" class="btn danger">Plugin entfernen</button>
</form>
</div>
</div>
<div class="pl-foot">
<div>
<div>Version <strong><?= e($p['version']) ?></strong></div>
<div class="muted"><?= $p['is_active'] ? 'Aktiv' : ($st === 'error' ? 'Fehler' : 'Inaktiv') ?></div>
</div>
<div style="text-align:right">
<div><?= !empty($p['signature_ok']) ? '<span style="color:#15803d;font-weight:600">✓ Signiert</span> vom Herausgeber' : '<span style="color:#b45309;font-weight:600">Eigenes Plugin</span>' ?></div>
<div class="muted"><?= $perms ? count($perms) . ' Berechtigung' . (count($perms) === 1 ? '' : 'en') : 'Keine Berechtigungen' ?></div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="pl-empty" id="pl-none" <?= $installed ? 'hidden' : '' ?>><?= $installed ? 'Kein Plugin passt zu Filter oder Suche.' : 'Noch keine Plugins installiert. Unter „Plugin hinzufügen“ findest du den Katalog und den ZIP-Upload.' ?></div>
</section>
<!-- Plugin hinzufügen -->
<section data-pl-panel="add" <?= $tab === 'add' ? '' : 'hidden' ?>>
<div class="card">
<div class="pl-bar" style="margin-bottom:0">
<h3 style="margin:0"><?= $official ? 'Geprüfte Plugins' : 'Plugin-Katalog' ?></h3>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<?php if ($catalog && !empty($catalog['ok'])): ?><input type="search" class="pl-search" id="pl-search-cat" placeholder="Katalog durchsuchen …" aria-label="Katalog durchsuchen"><?php endif; ?>
<?php if ($catalog): ?><a class="btn secondary" href="/staff/plugins.php?tab=add&amp;refresh=1">Katalog aktualisieren</a><?php endif; ?>
</div>
</div>
<?php if (!$catalog): ?>
<p class="muted" style="font-size:.85rem">Es ist noch keine Katalog-Adresse hinterlegt. Trage unter „Katalog &amp; Schlüssel“ die Adresse der Plugin-Webseite ein, dann könnt ihr Plugins direkt von dort installieren.</p>
<?php elseif (!$catalog['ok']): ?>
<div class="flash error" style="margin-top:12px"><?= e($catalog['error']) ?></div>
<?php else: ?>
<?php if (!empty($catalog['cached_at'])): ?><div class="muted" style="font-size:.76rem;margin:6px 0 12px">Stand: <span><?= e(date('d.m.Y H:i', (int) $catalog['cached_at'])) ?></span></div><?php endif; ?>
<div class="pl-grid" id="pl-catalog">
<?php foreach ($catalog['plugins'] as $c): $have = $installed[$c['slug']] ?? null; $newer = $have && version_compare($c['version'], $have['version'], '>'); ?>
<div class="pl-card" data-text="<?= e(mb_strtolower($c['name'] . ' ' . $c['slug'] . ' ' . $c['description'] . ' ' . $c['author'])) ?>">
<div class="pl-card-top">
<div class="pl-card-icon"><?= catalog_thumb('plugin', $c) ?></div>
<div class="pl-card-main">
<h4><?= e($c['name']) ?></h4>
<p><?= e($c['description']) ?></p>
<p class="by">Von <span><?= $c['author'] !== '' ? e($c['author']) : 'unbekannt' ?></span></p>
<div>
<?php if (!empty($c['language'])): ?><span class="pl-tag info" title="Sprachpaket: nach der Aktivierung erscheint die Flagge zum Umschalten">🌐 Sprache: <?= e($c['language']['name']) ?></span><?php endif; ?>
<?php if (!empty($c['featured'])): ?><span class="pl-tag warn" title="Vom Herausgeber empfohlen">★ Empfohlen</span><?php endif; ?>
</div>
</div>
<div class="pl-card-act">
<?php if ($have && !$newer): ?><span class="pl-installed-badge">✓ Installiert</span>
<?php elseif ($strict && $c['signature'] === ''): ?><span class="muted" style="font-size:.78rem;text-align:right">nicht signiert,<br>nicht installierbar</span>
<?php else: ?>
<form method="post" style="margin:0"><?= csrf_field() ?><input type="hidden" name="action" value="catalog_install"><input type="hidden" name="slug" value="<?= e($c['slug']) ?>">
<button class="btn pl-install" type="submit"><?= $newer ? 'Jetzt aktualisieren' : 'Jetzt installieren' ?></button></form>
<?php endif; ?>
<a href="#" class="pl-more">Weitere Details</a>
</div>
</div>
<div class="pl-card-detail" hidden>
<?= $banner($c, $c['name']) ?>
<div class="pl-modal-body">
<div class="pl-modal-text">
<h3><?= e($c['name']) ?></h3>
<p><?= nl2br(e($c['description'])) ?></p>
<?php if ($c['permissions']): ?>
<h4>Berechtigungen</h4>
<ul><?php foreach ($c['permissions'] as $perm): ?><li><?= e($permLabels[$perm] ?? $perm) ?> <span style="opacity:.7">(<?= e($perm) ?>)</span></li><?php endforeach; ?></ul>
<?php else: ?><p>Dieses Plugin fordert keine Berechtigungen an.</p><?php endif; ?>
</div>
<aside class="pl-modal-side">
<p><strong>Version:</strong> <?= e($c['version']) ?></p>
<p><strong>Autor:</strong> <?= $c['author'] !== '' ? e($c['author']) : 'unbekannt' ?></p>
<p><strong>Kennung:</strong> <code><?= e($c['slug']) ?></code></p>
<p><strong>Installiert:</strong> <?= $have ? e($have['version']) : 'nein' ?></p>
<p><strong>Signatur:</strong> <?= $c['signature'] !== '' ? ($hasKey ? '✓ vom Herausgeber signiert' : 'vorhanden, Schlüssel fehlt') : 'nicht signiert' ?></p>
</aside>
</div>
<div class="pl-modal-foot">
<?php if ($have && !$newer): ?><span class="pl-installed-badge">✓ Installiert</span>
<?php elseif ($strict && $c['signature'] === ''): ?><span class="muted">nicht signiert, nicht installierbar</span>
<?php else: ?>
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="catalog_install"><input type="hidden" name="slug" value="<?= e($c['slug']) ?>">
<button class="btn" type="submit"><?= $newer ? 'Jetzt aktualisieren' : 'Jetzt installieren' ?></button></form>
<?php endif; ?>
</div>
</div>
<div class="pl-foot">
<div>
<div>Version <strong><?= e($c['version']) ?></strong></div>
<div class="muted"><?= $have ? 'Installiert: <span>' . e($have['version']) . '</span>' : 'Nicht installiert' ?></div>
</div>
<div style="text-align:right">
<div><?= $c['signature'] !== '' ? ($hasKey ? '<span style="color:#15803d;font-weight:600">✓ Signiert</span> vom Herausgeber' : '<span style="color:#b45309;font-weight:600">Signatur vorhanden, Schlüssel fehlt</span>') : '<span style="color:#b45309;font-weight:600">Nicht signiert</span>' ?></div>
<div class="muted"><?= $c['permissions'] ? count($c['permissions']) . ' Berechtigung' . (count($c['permissions']) === 1 ? '' : 'en') : 'Keine Berechtigungen' ?></div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (!$catalog['plugins']): ?><div class="pl-empty">Der Katalog enthält keine Plugins.</div><?php else: ?><div class="pl-empty" id="pl-cat-none" hidden>Kein Plugin im Katalog passt zur Suche.</div><?php endif; ?>
<?php endif; ?>
</div>
<div class="card" style="margin-top:20px">
<h3 style="margin-top:0">Eigenes Plugin hochladen</h3>
<p class="muted" style="font-size:.85rem">Hier könnt ihr eigene Plugins als ZIP installieren oder aktualisieren, auch ohne Signatur. Diese Plugins sind <strong>nicht vom Herausgeber geprüft</strong> und laufen mit allen Rechten des Systems, auch mit Zugriff auf die gesamte Datenbank. Installiert nur Code, den ihr selbst geschrieben oder geprüft habt.</p>
<form method="post" enctype="multipart/form-data">
<?= csrf_field() ?><input type="hidden" name="action" value="install">
<label for="plugin_zip">ZIP-Datei</label>
<input type="file" id="plugin_zip" name="plugin_zip" accept=".zip" required>
<details style="margin-top:8px"><summary class="muted" style="cursor:pointer;font-size:.85rem">Signatur und Prüfsumme (optional)</summary>
<label for="signature">Signatur (optional, Base64)</label>
<input type="text" id="signature" name="signature" placeholder="Nur ausfüllen, wenn der Anbieter eine Signatur nennt" autocomplete="off">
<label for="sha256">Erwartete Prüfsumme (SHA-256, optional)</label>
<input type="text" id="sha256" name="sha256" placeholder="Nur ausfüllen, wenn der Anbieter eine Prüfsumme nennt" autocomplete="off">
</details>
<label style="margin-top:10px"><input type="checkbox" name="trust" value="1" required> Ich kenne den Inhalt dieses Plugins und vertraue ihm. Mir ist bewusst, dass es mit allen Rechten des Systems läuft.</label>
<div class="btn-row" style="margin-top:12px"><button type="submit" class="btn">Hochladen &amp; installieren</button></div>
</form>
<p class="muted" style="font-size:.82rem">Das Plugin wird nach der Installation nicht automatisch aktiviert. Vor dem Aktivieren bestätigt ihr die Berechtigungen, die es anfordert.</p>
</div>
</section>
<!-- Katalog und Schlüssel -->
<?php if (!$strict): ?>
<section data-pl-panel="settings" <?= $tab === 'settings' ? '' : 'hidden' ?>>
<div class="card">
<h3 style="margin-top:0">Katalog und Herausgeber-Schlüssel</h3>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="action" value="save_settings">
<label for="catalog_url">Katalog-Adresse (https)</label>
<input type="text" id="catalog_url" name="catalog_url" value="<?= e(plugin_catalog_url()) ?>" placeholder="https://plugins.example.de/catalog.php?type=plugins">
<label for="publisher_key">Öffentlicher Herausgeber-Schlüssel (PEM)</label>
<textarea id="publisher_key" name="publisher_key" rows="6" style="font-family:monospace;font-size:.78rem" placeholder="-----BEGIN PUBLIC KEY-----"><?= e(plugin_publisher_key()) ?></textarea>
<label style="margin-top:8px"><input type="checkbox" name="require_signature" value="1" <?= plugin_signatures_required() ? 'checked' : '' ?>> Nur signierte Plugins installieren (empfohlen, sobald ein Schlüssel hinterlegt ist)</label>
<p class="muted" style="font-size:.82rem">Den Schlüssel bekommst du von der Plugin-Webseite. Ist er hinterlegt, prüft DeskHub bei jeder Installation, ob das Paket wirklich vom Herausgeber stammt und unverändert ist.</p>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
</div>
</section>
<?php endif; ?>
<dialog id="pl-modal" aria-label="Plugin-Details"><button type="button" class="pl-modal-close" aria-label="Schließen">✕</button><div class="pl-modal-inner" id="pl-modal-content"></div></dialog>
<script>
(function () {
var panels = document.querySelectorAll('[data-pl-panel]');
var filter = 'all', q = '';
function show(name) {
panels.forEach(function (p) { p.hidden = p.getAttribute('data-pl-panel') !== name; });
document.querySelectorAll('.pl-tabs [data-pl-tab]').forEach(function (b) { b.setAttribute('aria-selected', b.getAttribute('data-pl-tab') === name ? 'true' : 'false'); });
try { history.replaceState(null, '', location.pathname + (name === 'installed' ? '' : '?tab=' + name)); } catch (e) {}
}
function apply() {
var rows = document.querySelectorAll('#pl-installed .pl-card'), shown = 0;
rows.forEach(function (r) {
var ok = (filter === 'all' || (filter === 'update' ? r.dataset.update === '1' : r.dataset.state === filter)) && (q === '' || r.dataset.text.indexOf(q) !== -1);
r.hidden = !ok; if (ok) shown++;
});
var none = document.getElementById('pl-none');
if (none && rows.length) none.hidden = shown > 0;
document.querySelectorAll('.pl-filters [data-pl-filter]').forEach(function (b) { b.setAttribute('aria-pressed', b.getAttribute('data-pl-filter') === filter ? 'true' : 'false'); });
}
document.querySelectorAll('[data-pl-tab]').forEach(function (b) {
b.addEventListener('click', function (ev) {
ev.preventDefault();
show(b.getAttribute('data-pl-tab'));
if (b.hasAttribute('data-pl-filter')) { filter = b.getAttribute('data-pl-filter'); apply(); }
});
});
document.querySelectorAll('.pl-filters [data-pl-filter]').forEach(function (b) {
b.addEventListener('click', function () { filter = b.getAttribute('data-pl-filter'); apply(); });
});
var s = document.getElementById('pl-search');
if (s) s.addEventListener('input', function () { q = s.value.trim().toLowerCase(); apply(); });
var modal = document.getElementById('pl-modal'), modalBody = document.getElementById('pl-modal-content');
document.querySelectorAll('.pl-more[href="#"]:not([data-pl-tab])').forEach(function (a) {
a.addEventListener('click', function (ev) {
ev.preventDefault();
var d = a.closest('.pl-card').querySelector('.pl-card-detail');
if (!d || !modal || !modal.showModal) return;
modalBody.innerHTML = d.outerHTML;
var inner = modalBody.firstElementChild; inner.removeAttribute('hidden');
modal.showModal();
});
});
if (modal) {
modal.querySelector('.pl-modal-close').addEventListener('click', function () { modal.close(); });
modal.addEventListener('click', function (ev) { if (ev.target === modal) modal.close(); });
}
var sc = document.getElementById('pl-search-cat');
if (sc) sc.addEventListener('input', function () {
var v = sc.value.trim().toLowerCase(), n = 0;
document.querySelectorAll('#pl-catalog .pl-card').forEach(function (c) { var ok = v === '' || c.dataset.text.indexOf(v) !== -1; c.hidden = !ok; if (ok) n++; });
var none = document.getElementById('pl-cat-none'); if (none) none.hidden = n > 0;
});
apply();
})();
</script>
<?php require __DIR__ . '/_footer.php'; ?>