diff --git a/DeskHub-Webseite/tests/integrations_api.php b/DeskHub-Webseite/tests/integrations_api.php
index ade653dd..96d96336 100644
--- a/DeskHub-Webseite/tests/integrations_api.php
+++ b/DeskHub-Webseite/tests/integrations_api.php
@@ -116,6 +116,12 @@ $check($s === 422 && ($b['error']['code'] ?? '') === 'INVALID_EMAIL', 'ungültig
$check($s === 200 && ($b['ticket']['status'] ?? '') === 'open' && count($b['ticket']['messages'] ?? []) === 1, 'Status per API abgefragt');
[$s, $b] = $call('GET', '?number=' . urlencode($apiNumber) . '&email=fremd@example.test', null, $created['key']);
$check($s === 404, 'falsche E-Mail-Adresse: Ticket bleibt verborgen (404)');
+// Entwarnung (Minecraft-Plugin): Ticket wird gelöst, der Text steht unverändert (nicht doppelt maskiert) in der internen Notiz
+[$s, $b] = $call('POST', '', ['action' => 'resolve', 'number' => $apiNumber, 'email' => 'eva@example.test', 'message' => 'Der Server "Lobby" ist wieder erreichbar & ok.'], $created['key']);
+$check($s === 200 && ($b['status'] ?? '') === 'resolved', 'Entwarnung per API löst das Ticket');
+$noteRow = $pdo->prepare("SELECT message FROM ticket_threads WHERE ticket_id = (SELECT id FROM tickets WHERE ticket_number = ?) AND is_internal_note = 1 ORDER BY id DESC LIMIT 1");
+$noteRow->execute([$apiNumber]);
+$check($noteRow->fetchColumn() === 'Der Server "Lobby" ist wieder erreichbar & ok.', 'Notiz bei Entwarnung ist Klartext ohne HTML-Entities: ' . json_encode($noteRow->fetchColumn()));
$readOnly = api_key_create($pdo, 'Nur lesen', false, true, '', $staffId);
[$s] = $call('POST', '', ['name' => 'Eva', 'email' => 'eva@example.test', 'subject' => 'x', 'message' => 'y'], $readOnly['key']);
$check($s === 403, 'Schlüssel ohne Recht zum Anlegen: 403');
diff --git a/DeskHub-Webseite/tests/login_guard.php b/DeskHub-Webseite/tests/login_guard.php
new file mode 100644
index 00000000..5ec1913e
--- /dev/null
+++ b/DeskHub-Webseite/tests/login_guard.php
@@ -0,0 +1,136 @@
+prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,1,?)")
+ ->execute(['Test Person', 'person@firma.example', password_hash('geheim-123', PASSWORD_DEFAULT), 'agent', db_now()]);
+$sid = (int) $pdo->lastInsertId();
+$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,1,?)")
+ ->execute(['Chef', 'chef@firma.example', password_hash('x', PASSWORD_DEFAULT), 'admin', db_now()]);
+$staff = fn() => $pdo->query('SELECT * FROM staff WHERE id = ' . $sid)->fetch();
+$events = fn(string $ev = '') => (int) $pdo->query('SELECT COUNT(*) FROM login_events' . ($ev !== '' ? " WHERE event = '$ev'" : ''))->fetchColumn();
+$chrome = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
+$phone = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36';
+$as = function (string $ip, string $ua, ?string $cookie): void {
+ $_SERVER['REMOTE_ADDR'] = $ip;
+ $_SERVER['HTTP_USER_AGENT'] = $ua;
+ unset($_COOKIE[LOGIN_GUARD_COOKIE]);
+ if ($cookie !== null) $_COOKIE[LOGIN_GUARD_COOKIE] = $cookie;
+};
+$dev1 = str_repeat('a', 32);
+$dev2 = str_repeat('b', 32);
+
+// Beschreibung und Ort
+$check(login_guard_describe_device($chrome) === 'Chrome auf Windows', 'Gerätename: Chrome auf Windows');
+$check(login_guard_describe_device($phone) === 'Chrome auf Android', 'Gerätename: Chrome auf Android');
+$check(login_guard_place('203.0.113.9')[0] === 'N:203.0' && login_guard_place('10.0.0.5')[0] === 'local', 'Ort: Netz der IP bzw. lokales Netz');
+$check(str_starts_with(login_guard_place('2a02:810d:1::5')[0], 'N6:2a02:810d'), 'Ort: IPv6-Netz');
+
+// 1) Erste Anmeldung: still gemerkt, keine Warnung
+$as('203.0.113.9', $chrome, $dev1);
+$GLOBALS['login_guard_last_link'] = null;
+$check(login_guard_success($staff(), 'web') === 'first', 'Erste Anmeldung wird still gemerkt');
+$check($GLOBALS['login_guard_last_link'] === null && $events('success') === 1, 'Erste Anmeldung: keine Warnung, ein Protokolleintrag');
+
+// 2) Bekanntes Gerät, gleicher Ort
+$check(login_guard_success($staff(), 'web') === 'known' && $GLOBALS['login_guard_last_link'] === null, 'Bekanntes Gerät: keine Warnung');
+
+// 3) Neues Gerät: Warnung mit Link
+$as('203.0.113.9', $phone, $dev2);
+$check(login_guard_success($staff(), 'web') === 'new_device', 'Neues Gerät wird erkannt');
+$link = (string) $GLOBALS['login_guard_last_link'];
+$check(str_contains($link, '/staff/login_alert.php?t='), 'Warn-Link wurde erzeugt');
+$token1 = substr($link, strpos($link, 't=') + 2);
+$check($events('new_device') === 1, 'Protokoll: Neues Gerät');
+$check(login_guard_success($staff(), 'web') === 'known', 'Das neue Gerät ist danach bekannt');
+
+// 4) Bekanntes Gerät, neuer Ort
+$GLOBALS['login_guard_last_link'] = null;
+$as('198.51.100.7', $chrome, $dev1);
+$check(login_guard_success($staff(), 'web') === 'new_place' && $GLOBALS['login_guard_last_link'] !== null, 'Neuer Ort vom bekannten Gerät wird gemeldet');
+$token2 = substr((string) $GLOBALS['login_guard_last_link'], -48);
+$GLOBALS['login_guard_last_link'] = null;
+$as('192.0.2.77', $chrome, $dev1);
+set_setting('login_guard_warn_place', '0');
+$check(login_guard_success($staff(), 'web') === 'known' && $GLOBALS['login_guard_last_link'] === null, 'Ort-Warnung lässt sich ausschalten');
+set_setting('login_guard_warn_place', '1');
+
+// 5) App: Gerät = Gerätename
+$as('203.0.113.9', 'okhttp/4.12', null);
+$check(login_guard_success($staff(), 'app', 'Pixel 8') === 'new_device', 'App mit neuem Gerätenamen wird gemeldet');
+$check(login_guard_success($staff(), 'app', 'Pixel 8') === 'known', 'Gleiches App-Gerät ist bekannt');
+$check((int) $pdo->query("SELECT COUNT(*) FROM login_devices WHERE channel = 'app'")->fetchColumn() === 1, 'App-Gerät gemerkt');
+
+// 6) Fehlversuche und Sperre
+login_guard_failed(null, 'unbekannt@x.example', 'web', 'Unbekannte E-Mail-Adresse');
+login_guard_failed($staff(), 'person@firma.example', 'web', 'Falsches Passwort');
+$check($events('failed') === 2, 'Fehlversuche stehen im Protokoll');
+login_guard_locked('person@firma.example', 'web');
+login_guard_locked('person@firma.example', 'web');
+$check($events('locked') === 1, 'Sperre wird je Sperrzeit nur einmal protokolliert');
+$pdo->exec("UPDATE login_events SET created_at = '2000-01-01 00:00:00' WHERE event = 'locked'");
+login_guard_locked('person@firma.example', 'web');
+$check($events('locked') === 2, 'Nach der Sperrzeit wird eine neue Sperre wieder protokolliert');
+set_setting('login_guard_max_attempts', '999');
+$check(login_guard_max_attempts() === 20, 'Anzahl Fehlversuche ist auf 20 begrenzt');
+set_setting('login_guard_max_attempts', '5');
+
+// 7) Link prüfen, einmal verwenden
+$check(login_guard_find_alert($pdo, 'zzz') === null && login_guard_find_alert($pdo, str_repeat('0', 48)) === null, 'Ungültiger Link wird abgelehnt');
+$ev = login_guard_find_alert($pdo, $token1);
+$check($ev !== null && (int) $ev['staff_id'] === $sid, 'Gültiger Link findet den Eintrag');
+$pdo->prepare('UPDATE login_events SET created_at = ? WHERE id = ?')->execute(['2000-01-01 00:00:00', (int) $ev['id']]);
+$check(login_guard_find_alert($pdo, $token1) === null, 'Link ist nach 7 Tagen abgelaufen');
+
+// 8) Konto sichern
+$pdo->prepare('INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at, last_used_at) VALUES (?,?,?,?,?,?)')
+ ->execute([$sid, hash('sha256', 't'), 'Pixel 8', date('Y-m-d H:i:s', time() + 3600), db_now(), db_now()]);
+$ev2 = login_guard_find_alert($pdo, $token2);
+$check($ev2 !== null, 'Zweiter Link ist gültig');
+$_SESSION = ['staff_login_at' => time() - 100];
+$check(login_guard_session_revoked($sid) === false, 'Vor dem Sichern ist die Sitzung gültig');
+$check(login_guard_revoke($pdo, $ev2) === true, 'Konto sichern gelingt');
+$check(!password_verify('geheim-123', (string) $staff()['password_hash']), 'Passwort ist ungültig');
+$check((int) $pdo->query('SELECT COUNT(*) FROM mobile_api_tokens WHERE staff_id = ' . $sid)->fetchColumn() === 0, 'App-Anmeldungen sind beendet');
+$check((int) $pdo->query('SELECT COUNT(*) FROM login_devices WHERE staff_id = ' . $sid)->fetchColumn() === 0, 'Gemerkte Geräte sind vergessen');
+$check($events('revoked') === 1, 'Protokoll: Konto gesichert');
+$check(login_guard_session_revoked($sid) === true, 'Ältere Sitzungen sind ungültig');
+$_SESSION = ['staff_login_at' => time() + 5];
+$check(login_guard_session_revoked($sid) === false, 'Eine spätere Anmeldung bleibt gültig');
+$check(login_guard_find_alert($pdo, $token2) === null, 'Der Link ist nur einmal verwendbar');
+
+// 9) Ausschalter
+set_setting('login_guard', '0');
+$before = $events();
+$as('203.0.113.9', $chrome, str_repeat('c', 32));
+$check(login_guard_success($staff(), 'web') === 'off' && $events() === $before, 'Ausgeschaltet wird nichts gemerkt oder protokolliert');
+login_guard_failed(null, 'x@y.example', 'web', 'x');
+$check($events() === $before, 'Ausgeschaltet gibt es auch keine Fehlversuch-Einträge');
+set_setting('login_guard', '1');
+
+// 10) Aufbewahrung
+$pdo->exec("UPDATE login_events SET created_at = '2000-01-01 00:00:00' WHERE id IN (SELECT id FROM login_events ORDER BY id LIMIT 3)");
+$n0 = $events();
+$old = (int) $pdo->query("SELECT COUNT(*) FROM login_events WHERE created_at < '2001-01-01'")->fetchColumn();
+login_guard_purge($pdo);
+$check($old >= 3 && $events() === $n0 - $old, 'Alte Einträge werden nach der Aufbewahrungsfrist gelöscht');
+
+@unlink($db);
+if ($failures) {
+ fwrite(STDERR, "Anmelde-Wächter-Prüfung fehlgeschlagen:\n- " . implode("\n- ", $failures) . "\n");
+ exit(1);
+}
+echo "Anmelde-Wächter-Prüfungen bestanden.\n";
diff --git a/DeskHub-Webseite/tests/plugin_authentik.php b/DeskHub-Webseite/tests/plugin_authentik.php
new file mode 100644
index 00000000..383d89c0
--- /dev/null
+++ b/DeskHub-Webseite/tests/plugin_authentik.php
@@ -0,0 +1,458 @@
+open($zipPath, ZipArchive::CREATE);
+foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
+ $zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
+}
+$zip->close();
+$r = plugin_install_zip($pdo, $zipPath);
+$check($r['ok'] && $r['slug'] === 'authentik', 'Plugin lässt sich installieren: ' . json_encode($r));
+$act = plugin_set_active($pdo, 'authentik', true, ['ui.menu', 'auth.login', 'auth.customer']);
+$check($act['ok'], 'Plugin lässt sich mit den Berechtigungen aktivieren: ' . json_encode($act));
+$state = &plugin_state();
+$state['booted'] = false;
+plugins_boot();
+$check(isset(plugin_state()['loaded']['authentik']), 'Plugin wird geladen');
+$check(isset(plugin_state()['logins']['authentik']['start'], plugin_state()['logins']['authentik']['callback']), 'Start und Rückkehr sind als Anmelde-Adressen registriert');
+$menus = json_encode(plugin_state()['menus'] ?? []);
+$check(str_contains($menus, 'authentik') && str_contains($menus, 'einstellungen'), 'Menüpunkt für Administratoren ist registriert');
+
+// Mitarbeiter
+$ins = $pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,?,?)");
+$ins->execute(['Anna', 'Anna@Firma.Example', 'x', 'agent', 1, db_now()]);
+$anna = (int) $pdo->lastInsertId();
+$ins->execute(['Karl', 'karl@firma.example', 'x', 'agent', 0, db_now()]);
+
+// Ohne Einrichtung: kein Knopf, Anmeldung nicht möglich
+$check(!authentik_ready() && plugin_filter('staff.login.buttons', '', []) === '', 'Ohne Einrichtung gibt es keinen Knopf');
+$check(isset(authentik_login_start([])['error']), 'Ohne Einrichtung lässt sich die Anmeldung nicht starten');
+
+// Einrichten
+$issuer = 'https://auth.firma.example/application/o/deskhub';
+plugin_set_setting('authentik', 'issuer', $issuer . '/');
+plugin_set_setting('authentik', 'client_id', 'deskhub-client');
+plugin_set_setting('authentik', 'client_secret_enc', encrypt_secret('streng-geheim'));
+plugin_set_setting('authentik', 'enabled', '1');
+$cfg = authentik_config();
+$check($cfg['issuer'] === $issuer && $cfg['client_secret'] === 'streng-geheim' && authentik_ready(), 'Einstellungen werden gelesen (Geheimnis entschlüsselt, Schrägstrich am Ende entfernt)');
+$check(!str_contains((string) get_setting('plugin.authentik.client_secret_enc', ''), 'streng-geheim'), 'Das Geheimnis liegt nicht im Klartext in der Datenbank');
+$btn = plugin_filter('staff.login.buttons', '', []);
+$check(str_contains($btn, 'Mit authentik anmelden') && str_contains($btn, '/staff/plugin_login.php?p=authentik&e=start'), 'Eingerichtet erscheint der Knopf mit dem richtigen Ziel');
+plugin_set_setting('authentik', 'label', 'SSO Login');
+$check(str_contains(plugin_filter('staff.login.buttons', '', []), 'SSO <b>Login</b>'), 'Die Beschriftung wird maskiert');
+plugin_set_setting('authentik', 'label', '');
+
+// ── authentik als Funktion nachgebaut ──
+$idp = ['claims' => [], 'status' => 200, 'tokenStatus' => 200, 'userinfo' => null, 'challenge' => '', 'nonce' => '', 'calls' => []];
+$jwt = fn(array $c) => 'eyJhbGciOiJub25lIn0.' . authentik_b64url(json_encode($c)) . '.sig';
+$GLOBALS['authentik_http'] = function (string $method, string $url, array $opts) use (&$idp, $issuer, $jwt): array {
+ $idp['calls'][] = "$method $url";
+ if ($url === $issuer . '/.well-known/openid-configuration') {
+ return [$idp['status'], json_encode(['issuer' => $idp['issuerOverride'] ?? $issuer . '/', 'authorization_endpoint' => 'https://auth.firma.example/application/o/authorize/', 'token_endpoint' => 'https://auth.firma.example/application/o/token/', 'userinfo_endpoint' => 'https://auth.firma.example/application/o/userinfo/']), null];
+ }
+ if ($url === 'https://auth.firma.example/application/o/token/') {
+ $f = $opts['form'] ?? [];
+ // PKCE: der Prüfwert muss zur Herausforderung aus dem Start passen
+ $pkceOk = authentik_b64url(hash('sha256', (string) ($f['code_verifier'] ?? ''), true)) === $idp['challenge'];
+ if ($f['client_secret'] !== 'streng-geheim' || $f['client_id'] !== 'deskhub-client' || $f['grant_type'] !== 'authorization_code' || $f['code'] !== 'gueltiger-code' || !$pkceOk || !str_contains($f['redirect_uri'], '/staff/plugin_login.php?p=authentik&e=callback')) return [400, json_encode(['error' => 'invalid_grant']), null];
+ $claims = $idp['claims'] + ['iss' => $issuer . '/', 'aud' => 'deskhub-client', 'exp' => time() + 300, 'nonce' => $idp['nonce'], 'sub' => 'u1', 'email' => 'anna@firma.example', 'email_verified' => true];
+ return [$idp['tokenStatus'], json_encode(['id_token' => $jwt(array_filter($claims, fn($v) => $v !== '__weg__')), 'access_token' => 'at-1']), null];
+ }
+ if ($url === 'https://auth.firma.example/application/o/userinfo/') {
+ return [200, json_encode($idp['userinfo'] ?? []), null];
+ }
+ return [404, '', null];
+};
+$begin = function () use (&$idp): array {
+ $_SESSION = [];
+ $res = authentik_login_start([]);
+ if (empty($res['redirect'])) return [null, [], $res];
+ parse_str((string) parse_url($res['redirect'], PHP_URL_QUERY), $q);
+ $idp['challenge'] = (string) ($q['code_challenge'] ?? '');
+ $idp['nonce'] = (string) ($q['nonce'] ?? '');
+ return [$res['redirect'], $q, $res];
+};
+$back = fn(array $query) => authentik_login_callback(['query' => $query]);
+
+// Start
+[$redirect, $q] = $begin();
+$check($redirect !== null && str_starts_with($redirect, 'https://auth.firma.example/application/o/authorize/?'), 'Start leitet zu authentik');
+$check(($q['response_type'] ?? '') === 'code' && ($q['client_id'] ?? '') === 'deskhub-client' && ($q['scope'] ?? '') === 'openid email profile' && ($q['code_challenge_method'] ?? '') === 'S256', 'Anfrage enthält Code-Ablauf, Client-ID, Bereiche und PKCE');
+$check(strlen((string) ($q['state'] ?? '')) === 32 && strlen((string) ($q['nonce'] ?? '')) === 32 && strlen((string) ($q['code_challenge'] ?? '')) >= 43, 'Zustand, Nonce und Prüfwert sind zufällig und lang genug');
+$check(($q['redirect_uri'] ?? '') === authentik_redirect_uri() && str_ends_with(authentik_redirect_uri(), '/staff/plugin_login.php?p=authentik&e=callback'), 'Die Rückkehr-Adresse stimmt');
+[, $q2] = $begin();
+$check($q2['state'] !== $q['state'] && $q2['nonce'] !== $q['nonce'], 'Jeder Start erzeugt neue Werte');
+
+// Erfolgreiche Anmeldung
+[, $q] = $begin();
+$ok = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
+$check(($ok['staff_id'] ?? 0) === $anna && ($ok['via'] ?? '') === 'authentik', 'Erfolgreiche Rückkehr meldet den Mitarbeiter an (E-Mail ohne Beachtung der Groß-/Kleinschreibung): ' . json_encode($ok));
+$check(!isset($_SESSION['authentik']), 'Die Sitzungswerte sind danach gelöscht');
+$again = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
+$check(isset($again['error']) && !isset($again['staff_id']), 'Dieselbe Rückkehr funktioniert nicht ein zweites Mal');
+
+// Fehlerfälle
+$fail = function (string $what, array $claims, array $queryOverride = [], ?callable $tweak = null) use (&$idp, $begin, $back, $check) {
+ $idp['claims'] = $claims; $idp['userinfo'] = null;
+ if ($tweak) $tweak();
+ [, $q] = $begin();
+ $res = $back($queryOverride + ['code' => 'gueltiger-code', 'state' => $q['state']]);
+ $check(isset($res['error']) && !isset($res['staff_id']), $what . ': ' . json_encode($res, JSON_UNESCAPED_UNICODE));
+ $idp['claims'] = []; $idp['issuerOverride'] = null; $idp['tokenStatus'] = 200;
+ return $res;
+};
+$fail('falscher Zustand (state) wird abgelehnt', [], ['state' => 'falsch']);
+$fail('fehlender Code wird abgelehnt', [], ['code' => '']);
+$fail('ungültiger Code wird abgelehnt', [], ['code' => 'geklaut']);
+$fail('falsche Nonce wird abgelehnt', ['nonce' => 'fremde-nonce']);
+$fail('falscher Aussteller wird abgelehnt', ['iss' => 'https://boese.example/o/x/']);
+$fail('falsche Zielgruppe (aud) wird abgelehnt', ['aud' => 'andere-app']);
+$fail('Zielgruppe als Liste ohne uns wird abgelehnt', ['aud' => ['a', 'b']]);
+$fail('falscher Berechtigter (azp) wird abgelehnt', ['aud' => ['deskhub-client', 'x'], 'azp' => 'x']);
+$fail('abgelaufenes Token wird abgelehnt', ['exp' => time() - 600]);
+$fail('E-Mail nicht bestätigt wird bei fremder Kennung abgelehnt', ['email_verified' => false, 'sub' => 'u-unv']);
+$fail('fehlende E-Mail wird abgelehnt', ['email' => '__weg__']);
+$fail('ungültige E-Mail wird abgelehnt', ['email' => 'kein-mail']);
+$fail('Fehler von authentik (error=access_denied) wird abgelehnt', [], ['error' => 'access_denied