Upload via GUI (65 Dateien)

This commit is contained in:
2026-11-05 21:23:51 +00:00
parent fba54f356c
commit 95faa40e98
50 changed files with 1551 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"_info": "English translations of this plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number. Further languages: lang/<code>.json, for example lang/fr.json.",
"Beispiel: Ticket-Protokoll": "Example: ticket log",
"Demonstriert Hooks, Menüpunkt, Seite, API-Endpunkt, Ticket-Seitenleiste, Einstellungen und Datenbank-Migration.": "Demonstrates hooks, menu item, page, API endpoint, ticket sidebar, settings and database migration."
}
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS plugin_beispiel_log (
id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id INT NOT NULL,
event VARCHAR(40) NOT NULL,
created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS plugin_beispiel_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INT NOT NULL,
event VARCHAR(40) NOT NULL,
created_at DATETIME NOT NULL
);
+13
View File
@@ -0,0 +1,13 @@
<?php
// Seite des Beispiel-Plugins. Verfügbar: $staff, $pdo.
$rows = $pdo->query('SELECT ticket_id, event, created_at FROM plugin_beispiel_log ORDER BY id DESC LIMIT 50')->fetchAll();
?>
<h1>Ticket-Protokoll</h1>
<p class="muted">Letzter Plugin-Cron: <?= e(plugin_setting('beispiel', 'last_cron', 'noch nie')) ?></p>
<div class="card"><table>
<thead><tr><th>Ticket</th><th>Ereignis</th><th>Zeit</th></tr></thead>
<tbody>
<?php foreach ($rows as $r): ?><tr><td>#<?= (int) $r['ticket_id'] ?></td><td><?= e($r['event']) ?></td><td><?= e($r['created_at']) ?></td></tr><?php endforeach; ?>
<?php if (!$rows): ?><tr><td colspan="3" class="muted">Noch keine Ereignisse.</td></tr><?php endif; ?>
</tbody>
</table></div>
+9
View File
@@ -0,0 +1,9 @@
{
"slug": "beispiel",
"name": "Beispiel: Ticket-Protokoll",
"version": "1.0.0",
"description": "Demonstriert Hooks, Menüpunkt, Seite, API-Endpunkt, Ticket-Seitenleiste, Einstellungen und Datenbank-Migration.",
"author": "DeskHub",
"requires": "1.0.0",
"permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "ui.sidebar", "api.endpoints", "db.migrations"]
}
+35
View File
@@ -0,0 +1,35 @@
<?php
// Beispiel-Plugin: protokolliert Ticket-Ereignisse in einer eigenen Tabelle.
// Wird beim Aktivieren geladen; hier werden nur Erweiterungen registriert.
plugin_add_hook('ticket.created', function (array $e) {
$pdo = get_db();
$pdo->prepare('INSERT INTO plugin_beispiel_log (ticket_id, event, created_at) VALUES (?,?,?)')->execute([$e['ticketId'], 'created', db_now()]);
});
plugin_add_hook('ticket.status_changed', function (array $e) {
$pdo = get_db();
$pdo->prepare('INSERT INTO plugin_beispiel_log (ticket_id, event, created_at) VALUES (?,?,?)')->execute([$e['ticketId'], 'status_changed', db_now()]);
});
// Baustein in der Ticketansicht (Plugins liefern fertiges HTML und müssen Ausgaben selbst escapen)
plugin_add_filter('ticket.view.sidebar', function (string $html, array $ctx) {
$stmt = get_db()->prepare('SELECT COUNT(*) FROM plugin_beispiel_log WHERE ticket_id = ?');
$stmt->execute([$ctx['ticketId']]);
return $html . '<div class="card" style="margin-top:16px;padding:14px 16px"><strong>Protokoll-Plugin</strong><br>'
. (int) $stmt->fetchColumn() . ' Ereignis(se) zu diesem Ticket</div>';
});
plugin_register_menu('Ticket-Protokoll', 'log');
plugin_register_api('count', function (array $staff, array $request): array {
return ['count' => (int) get_db()->query('SELECT COUNT(*) FROM plugin_beispiel_log')->fetchColumn()];
});
plugin_add_hook('cron.run', function () {
plugin_set_setting('beispiel', 'last_cron', db_now());
});
// Aufräumen beim Entfernen mit "Daten löschen"
plugin_add_hook('plugin.uninstall.beispiel', function () {
get_db()->exec('DROP TABLE IF EXISTS plugin_beispiel_log');
});
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

+42
View File
@@ -0,0 +1,42 @@
# authentik-Anmeldung für DeskHub
Mitarbeiter und Kunden melden sich mit **authentik** an (OpenID Connect, Authorization-Code-Ablauf mit PKCE). Auf der Anmeldeseite des Mitarbeiterbereichs und, wenn eingeschaltet, des Kundenportals erscheint zusätzlich der Knopf „Mit authentik anmelden“. E-Mail und Passwort bleiben als Zugang erhalten.
Voraussetzung: DeskHub **1.0.52 oder neuer** (Berechtigungen `auth.login` und `auth.customer`, Anmelde-Adressen `/staff/plugin_login.php` und `/plugin_login.php`).
## Einrichtung
1. **In authentik** unter *Anwendungen → Provider* einen Provider vom Typ **OAuth2/OpenID** anlegen, Client-Typ *vertraulich*.
2. **Umleitungs-URI** (Modus „Strict“): `https://<deine-DeskHub-Adresse>/staff/plugin_login.php?p=authentik&e=callback` (die genaue Adresse zeigt die Einstellungsseite).
3. **Bereiche:** `openid`, `email`, `profile`; für die Gruppenprüfung zusätzlich ein Bereich, der `groups` liefert.
4. Eine **Anwendung** mit diesem Provider anlegen und festlegen, wer sie nutzen darf.
5. In DeskHub: Plugin hochladen und aktivieren (Berechtigungen `ui.menu`, `auth.login` und `auth.customer` bestätigen), dann im Adminportal unter **authentik** die Issuer-Adresse (`https://auth.example.de/application/o/<Anwendungs-Kürzel>`), Client-ID und Client-Geheimnis eintragen, „Anmeldung einschalten“, speichern, **Verbindung testen**.
6. Mitarbeiter: Jeder hat sein DeskHub-Konto und verknüpft es einmal unter **Mein authentik-Konto** (oder meldet sich beim ersten Mal mit gleicher E-Mail-Adresse an). Kunden: unter „Kunden“ einschalten (siehe unten).
## Mitarbeiter
Angemeldet wird nur, wer in authentik bestätigt ist (E-Mail nicht als „nicht bestätigt“ markiert) **und** in DeskHub ein **aktives** Mitarbeiterkonto hat, das mit seinem authentik-Konto **verknüpft** ist. Die Verknüpfung macht jeder Mitarbeiter selbst unter **Mein authentik-Konto** (Menü) mit einem Klick; sie hängt an der festen Benutzerkennung von authentik, nicht an der E-Mail-Adresse. Ohne Verknüpfung klappt die erste Anmeldung auch, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist; dabei wird automatisch verknüpft. Mit der Einstellung **Nur verknüpfte Konten** geht das nicht mehr. Hat ein DeskHub-Konto schon eine Verknüpfung, kann ein fremdes authentik-Konto mit gleicher E-Mail-Adresse nicht hinein. Administratoren sehen alle Verknüpfungen in den Einstellungen und können sie lösen. Konten werden nicht automatisch angelegt. Optional zusätzlich nur Mitglieder einer authentik-Gruppe. Die zweite Stufe von DeskHub (Authenticator-Code) entfällt bei dieser Anmeldung, weil authentik sie übernimmt. Jede Anmeldung und jede Ablehnung steht im Admin-Protokoll.
## Kunden (Ticketersteller)
In den Einstellungen unter „Kunden“ einschalten. Dann erscheint der Knopf auch auf der Anmeldeseite des Kundenportals. Es zählt **nur die in authentik bestätigte E-Mail-Adresse**: Gibt es ein Kundenkonto mit dieser Adresse, wird es angemeldet; gibt es keines und „automatisch anlegen“ ist an, legt DeskHub es an (Name aus authentik, **ohne Passwort**; wer eines will, nutzt „Passwort vergessen“). Gesperrte Adressen (Sperrliste) kommen nicht hinein. Optional nur Kunden einer eigenen authentik-Gruppe. Die Rückkehr-Adresse in authentik ist dieselbe wie für Mitarbeiter, Sie brauchen keine zweite. Neue Kundenkonten stehen im Admin-Protokoll.
## Sicherheit
- PKCE (S256), zufälliger Zustandswert (`state`) und `nonce`, jeder Ablauf gilt nur einmal und höchstens 10 Minuten.
- Das ID-Token kommt direkt vom Token-Endpunkt über eine **geprüfte HTTPS-Verbindung** (Zertifikat und Name werden geprüft, keine Weiterleitungen); geprüft werden Aussteller (`iss`), Zielgruppe (`aud`/`azp`), Ablauf (`exp`) und `nonce`. Eine zusätzliche Signaturprüfung ist dafür nach OIDC Core 3.1.3.7 nicht nötig.
- **E-Mail nicht bestätigt:** Meldet authentik `email_verified = false`, ordnet DeskHub ein Konto nicht über die Adresse zu (nicht verknüpfte Mitarbeiter, Kunden). Das Verknüpfen unter „Mein authentik-Konto“ und die Anmeldung schon verknüpfter Konten gehen trotzdem. Ausschaltbar mit „Bestätigte E-Mail-Adresse verlangen“ (nur sinnvoll, wenn allein Sie in authentik Benutzer anlegen und Adressen ändern).
- Adressen: nur `https` (`http` nur zu `localhost` zum Testen), ohne Zugangsdaten, Parameter und Anker.
- Das Client-Geheimnis liegt verschlüsselt in der Datenbank und wird nie wieder angezeigt.
- Fällt authentik aus, melden sich Mitarbeiter weiter mit E-Mail und Passwort an.
## Grenzen
- Mitarbeiterkonten werden nicht automatisch angelegt und es gibt keine Rollen- oder Abteilungszuordnung aus Gruppen. Kundenkonten entstehen nur auf Wunsch.
- Kunden werden nur über die E-Mail-Adresse zugeordnet (keine feste Verknüpfung). Wer die E-Mail-Adresse in authentik ändern darf, könnte damit ein anderes Kundenkonto öffnen: Das in authentik absichern, wenn Kunden sich dort selbst registrieren dürfen.
- Abmelden in DeskHub meldet nicht bei authentik ab (kein „Single Logout“).
- Das Plugin schaltet die Anmeldung mit E-Mail und Passwort nicht ab.
## Tests
`tests/plugin_authentik.php` (OpenID-Ablauf mit nachgebautem authentik) und `tests/plugin_login.php` (Anmelde-Erweiterung des Servers) im DeskHub-Projekt.
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Anmeldung mit authentik">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0A5F95"/>
<stop offset="1" stop-color="#0E8C7C"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
<path d="M128 40 L200 66 V122 C200 166 170 196 128 214 C86 196 56 166 56 122 V66 Z" fill="#ffffff" fill-opacity=".16" stroke="#ffffff" stroke-width="8" stroke-linejoin="round"/>
<circle cx="128" cy="104" r="20" fill="#ffd79a"/>
<path d="M128 124 V176 M128 148 H148 M128 166 H142" fill="none" stroke="#ffd79a" stroke-width="12" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 725 B

+143
View File
@@ -0,0 +1,143 @@
{
"_info": "English translations of the authentik plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number. Further languages: lang/<code>.json, for example lang/fr.json.",
"(seit {}).": "(since {}).",
". Alternativ klappt die erste Anmeldung auch, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist (dann wird automatisch verknüpft).": ". Alternatively the first sign-in also works if the email address is the same in authentik and DeskHub (it is then linked automatically).",
". Für die Gruppenprüfung zusätzlich den Bereich, der": ". For the group check, additionally add the scope that",
"Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein": "Only someone who is confirmed in authentik and has",
"Anmeldung mit authentik einschalten": "Switch on sign-in with authentik",
"Anwendung": "application",
"Anwendungen → Provider": "Applications → Providers",
"Auch Kunden mit authentik anmelden.": "Also sign in customers with authentik.",
"Auf der Anmeldeseite des Mitarbeiterbereichs erscheint der Knopf „{}“.": "The button “{}” appears on the sign-in page of the staff area.",
"Auf diesem Server fehlt die PHP-Erweiterung cURL.": "The PHP extension cURL is missing on this server.",
"Ausgeschaltet.": "Switched off.",
"Bereiche (Scopes)": "Scopes",
"Bereiche:": "Scopes:",
"Beschriftung": "Label",
"Beschriftung des Knopfes": "Button label",
"Bestätigte E-Mail-Adresse verlangen (empfohlen).": "Require a verified email address (recommended).",
"Bitte die Issuer-Adresse mit https:// angeben, ohne Parameter (zum Beispiel https://auth.example.de/application/o/deskhub).": "Please enter the issuer address with https://, without parameters (for example https://auth.example.com/application/o/deskhub).",
"Bitte zuerst anmelden.": "Please sign in first.",
"Bitte zuerst eine gültige Issuer-Adresse speichern.": "Please save a valid issuer address first.",
"Client-Geheimnis (Client Secret)": "Client secret",
"Client-ID": "Client ID",
"Client-ID und Client-Geheimnis hier eintragen. Die": "Enter the client ID and client secret here. The",
"Client-Typ:": "Client type:",
"Danach führt „{}“ auf der Anmeldeseite genau in dieses Konto. Auch ohne Verknüpfung klappt die Anmeldung, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist; dabei wird sie dann automatisch verknüpft.": "Afterwards “{}” on the sign-in page leads exactly into this account. Even without a link, signing in works if the email address is the same in authentik and DeskHub; it is then linked automatically.",
"Danach führt „{}“ auf der Anmeldeseite genau in dieses Konto. Ohne Verknüpfung ist die Anmeldung mit authentik hier nicht möglich.": "Afterwards “{}” on the sign-in page leads exactly into this account. Without a link, signing in with authentik is not possible here.",
"Das mit Ihrem authentik-Konto verknüpfte DeskHub-Konto ist deaktiviert.": "The DeskHub account linked to your authentik account is deactivated.",
"Der Ablauf nutzt PKCE, einen Zustandswert und eine Nonce; Aussteller, Zielgruppe und Ablauf des ID-Tokens werden geprüft. Das Token kommt direkt von authentik über eine geprüfte HTTPS-Verbindung. Das Geheimnis liegt verschlüsselt in der Datenbank. Fällt authentik aus, melden sich Mitarbeiter weiter mit E-Mail und Passwort an. Jede Anmeldung steht im Admin-Protokoll.": "The flow uses PKCE, a state value and a nonce; issuer, audience and expiry of the ID token are checked. The token comes directly from authentik over a verified HTTPS connection. The secret is stored encrypted in the database. If authentik fails, staff keep signing in with email and password. Every sign-in is recorded in the admin log.",
"Der DeskHub-Server kennt die Kunden-Anmeldung (Version 1.0.52 oder neuer).": "The DeskHub server supports customer sign-in (version 1.0.52 or newer).",
"Der Issuer in der Antwort („{}“) passt nicht zur eingestellten Adresse.": "The issuer in the response (“{}”) does not match the configured address.",
"Der Knopf erscheint auf der Anmeldeseite des Kundenportals. Es zählt nur die in authentik bestätigte E-Mail-Adresse: Hat ein Kundenkonto dieselbe Adresse, meldet DeskHub es an.": "The button appears on the sign-in page of the customer portal. Only the email address confirmed in authentik counts: if a customer account has the same address, DeskHub signs it in.",
"Der Knopf erscheint dort noch nicht.": "The button does not appear there yet.",
"Der Knopf erscheint erst nach dem Einschalten.": "The button only appears after switching on.",
"Der Server ist zu alt: Bitte DeskHub 1.0.52 oder neuer einspielen (auch login.php, plugin_login.php und includes/plugins.php).": "The server is too old: please install DeskHub 1.0.52 or newer (including login.php, plugin_login.php and includes/plugins.php).",
"Die Anmeldeseite des Kundenportals (login.php) ist auf dem neuesten Stand.": "The sign-in page of the customer portal (login.php) is up to date.",
"Die Anmeldung ist abgelaufen oder wurde nicht hier gestartet. Bitte noch einmal auf „Mit authentik verknüpfen“ klicken.": "The sign-in has expired or was not started here. Please click “Link with authentik” again.",
"Die Anmeldung ist abgelaufen oder wurde nicht hier gestartet. Bitte noch einmal auf „{}“ klicken.": "The sign-in has expired or was not started here. Please click “{}” again.",
"Die Anmeldung mit authentik ist für Kunden nicht eingeschaltet.": "Sign-in with authentik is not switched on for customers.",
"Die Anmeldung mit authentik ist nicht eingerichtet.": "Sign-in with authentik is not set up.",
"Die Anmeldung mit authentik ist nicht eingerichtet. Ein Administrator kann sie unter „authentik“ einschalten.": "Sign-in with authentik is not set up. An administrator can switch it on under “authentik”.",
"Die Antwort stammt nicht vom eingestellten authentik (Aussteller).": "The response does not come from the configured authentik (issuer).",
"Die Antwort von authentik enthält keine Benutzerkennung.": "The response from authentik contains no user identifier.",
"Die Antwort von authentik gehört nicht zu dieser Anmeldung (Nonce).": "The response from authentik does not belong to this sign-in (nonce).",
"Die Antwort von authentik gilt für eine andere Anwendung.": "The response from authentik is for a different application.",
"Die Antwort von authentik ist unlesbar.": "The response from authentik is unreadable.",
"Die Antwort von authentik passt nicht zur gestarteten Anmeldung. Bitte noch einmal versuchen.": "The response from authentik does not match the sign-in that was started. Please try again.",
"Die Berechtigung „auth.customer“ fehlt: Unter Plugins das Plugin deaktivieren und wieder aktivieren und die Berechtigungen bestätigen.": "The permission “auth.customer” is missing: under Plugins, deactivate and reactivate the plugin and confirm the permissions.",
"Die Berechtigung „auth.customer“ ist freigegeben.": "The permission “auth.customer” is approved.",
"Die Bereiche (Scopes) enthalten ungültige Zeichen.": "The scopes contain invalid characters.",
"Die Bestätigung von authentik ist abgelaufen.": "The confirmation from authentik has expired.",
"Die Client-ID ist ungültig.": "The client ID is invalid.",
"Die Datei login.php im DeskHub-Hauptordner ist alt und zeigt keine Plugin-Knöpfe: Bitte die aktuelle login.php hochladen.": "The file login.php in the main DeskHub folder is outdated and shows no plugin buttons: please upload the current login.php.",
"Die E-Mail-Adresse ist in authentik nicht bestätigt, daher ordnet DeskHub das Konto nicht über die Adresse zu. Verknüpfen Sie Ihr Konto einmal unter „Mein authentik-Konto“ (nach der Anmeldung mit E-Mail und Passwort), danach geht die Anmeldung mit authentik.": "The email address is not verified in authentik, so DeskHub does not assign the account by address. Link your account once under “My authentik account” (after signing in with email and password); after that signing in with authentik works.",
"Die E-Mail-Adresse ist in authentik nicht bestätigt. Bitte erst in authentik bestätigen.": "The email address is not verified in authentik. Please verify it in authentik first.",
"Die Konfiguration von authentik enthält keinen gültigen Eintrag „{}“.": "The configuration from authentik has no valid entry “{}”.",
"Die Verbindung ist nicht vollständig (Issuer, Client-ID, Geheimnis, eingeschaltet).": "The connection is incomplete (issuer, client ID, secret, switched on).",
"Die Verbindung zu authentik ist eingerichtet und eingeschaltet.": "The connection to authentik is set up and switched on.",
"Die Verknüpfung ist gelöst.": "The link has been removed.",
"Die Verknüpfung ist gelöst. Mit authentik melden Sie sich erst wieder an, wenn Sie es neu verknüpfen (oder, falls erlaubt, über die E-Mail-Adresse).": "The link has been removed. You can only sign in with authentik again after linking it anew (or, if allowed, via the email address).",
"Die Verknüpfung wurde von einem anderen Konto gestartet. Bitte noch einmal starten.": "The link was started by a different account. Please start it again.",
"Dieses DeskHub-Konto ist mit einem anderen authentik-Konto verknüpft.": "This DeskHub account is linked to a different authentik account.",
"Dieses authentik-Konto ist bereits mit einem anderen DeskHub-Konto verknüpft.": "This authentik account is already linked to a different DeskHub account.",
"E-Mail in DeskHub": "Email in DeskHub",
"E-Mail in authentik": "Email in authentik",
"Eine": "Create an",
"Eingeschaltet, aber unvollständig.": "Switched on, but incomplete.",
"Einrichtung in authentik": "Setup in authentik",
"Für jeden Mitarbeiter in DeskHub ein Konto mit derselben E-Mail-Adresse wie in authentik haben.": "Every staff member needs an account in DeskHub with the same email address as in authentik.",
"Für {} gibt es in DeskHub kein aktives Mitarbeiterkonto. Bitte einen Administrator bitten, es mit derselben E-Mail-Adresse anzulegen, oder ein bestehendes Konto unter „Mein authentik-Konto“ verknüpfen.": "There is no active staff account in DeskHub for {}. Please ask an administrator to create it with the same email address, or link an existing account under “My authentik account”.",
"Für {} gibt es in DeskHub kein aktives Mitarbeiterkonto. Bitte einen Administrator bitten, es mit derselben E-Mail-Adresse anzulegen.": "There is no active staff account in DeskHub for {}. Please ask an administrator to create it with the same email address.",
"Gilt, wo die Adresse über das Konto entscheidet: bei Kunden und bei Mitarbeitern, die noch nicht verknüpft sind. Meldet authentik die Adresse als „nicht bestätigt“, kommen sie sonst nicht hinein. Wer schon verknüpft ist, meldet sich immer an. Nur ausschalten, wenn nur du in authentik Benutzer anlegst und Adressen ändern darfst.": "Applies where the address decides about the account: for customers and for staff who are not linked yet. If authentik reports the address as “not verified”, they cannot get in otherwise. Anyone who is already linked always signs in. Only switch off if you are the only one who creates users and changes addresses in authentik.",
"Ihr DeskHub-Konto gehört zum authentik-Konto": "Your DeskHub account belongs to the authentik account",
"Ihr authentik-Konto ({}) ist jetzt verknüpft. Die Anmeldung mit authentik führt künftig genau in dieses Konto.": "Your authentik account ({}) is now linked. Signing in with authentik now leads exactly into this account.",
"Ihr authentik-Konto gehört nicht zur Gruppe „{}“, die für DeskHub nötig ist.": "Your authentik account does not belong to the group “{}” that DeskHub requires.",
"Ihr authentik-Konto ist noch keinem DeskHub-Konto zugeordnet. Melden Sie sich mit E-Mail und Passwort an und verknüpfen Sie es dort unter „Mein authentik-Konto“.": "Your authentik account is not assigned to a DeskHub account yet. Sign in with email and password and link it there under “My authentik account”.",
"In authentik unter": "In authentik, under",
"Issuer-Adresse": "issuer address",
"Issuer-Adresse (OpenID-Konfiguration)": "Issuer address (OpenID configuration)",
"Issuer-Adresse, Client-ID und Geheimnis müssen eingetragen sein.": "The issuer address, client ID and secret must be entered.",
"Jeder Mitarbeiter hat sein DeskHub-Konto und verknüpft es einmal selbst unter": "Every staff member has their DeskHub account and links it once themselves under",
"Keine Berechtigung.": "No permission.",
"Kunden (Ticketersteller)": "Customers (ticket creators)",
"Kunden-Anmeldung": "Customer sign-in",
"Kunden-Anmeldung ist ausgeschaltet: Unten unter „Kunden“ den Haken setzen und speichern.": "Customer sign-in is switched off: tick the box under “Customers” below and save.",
"Kunden-Anmeldung ist eingeschaltet („Auch Kunden mit authentik anmelden“).": "Customer sign-in is switched on (“Also sign in customers with authentik”).",
"Kundenkonto automatisch anlegen,": "Create the customer account automatically",
"Lösen": "Remove",
"Mein authentik-Konto": "My authentik account",
"Mit authentik anmelden": "Sign in with authentik",
"Mit authentik verknüpfen": "Link with authentik",
"Mit einem anderen authentik-Konto neu verknüpfen": "Link again with a different authentik account",
"Mit „{}“ auf der Anmeldeseite kommen Sie damit direkt in dieses Konto, auch wenn sich die E-Mail-Adresse in authentik oder DeskHub später ändert. Ein anderes authentik-Konto kann dieses DeskHub-Konto nicht öffnen.": "With “{}” on the sign-in page you now go straight into this account, even if the email address in authentik or DeskHub changes later. No other authentik account can open this DeskHub account.",
"Mitarbeiter": "Staff member",
"Mitarbeiter und Kunden melden sich mit authentik an (OpenID Connect, mit PKCE): Knopf „Mit authentik anmelden“ auf der Anmeldeseite des Mitarbeiterbereichs und, wenn eingeschaltet, des Kundenportals. Mitarbeiter: Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein aktives, verknüpftes Mitarbeiterkonto (oder eines mit derselben E-Mail-Adresse) hat; sie verknüpfen ihr Konto selbst unter „Mein authentik-Konto“. Kunden (Ticketersteller): Die bestätigte E-Mail-Adresse muss passen, das Konto wird bei Bedarf automatisch angelegt. Optional nur Mitglieder bestimmter authentik-Gruppen. E-Mail und Passwort bleiben als Zugang erhalten.": "Staff and customers sign in with authentik (OpenID Connect, with PKCE): a “Sign in with authentik” button on the sign-in page of the staff area and, if switched on, of the customer portal. Staff: only someone who is confirmed in authentik and has an active, linked staff account (or one with the same email address) in DeskHub is signed in; they link their account themselves under “My authentik account”. Customers (ticket creators): the confirmed email address must match, and the account is created automatically if wanted. Optionally restricted to authentik groups. Email and password remain available as a way in.",
"Noch kein Mitarbeiter hat sein Konto verknüpft. Mitarbeiter tun das selbst unter „Mein authentik-Konto“; bei der ersten Anmeldung über die E-Mail-Adresse geschieht es automatisch.": "No staff member has linked their account yet. Staff do this themselves under “My authentik account”; it happens automatically on the first sign-in via the email address.",
"Noch nicht verknüpft.": "Not linked yet.",
"Nur Kunden aus dieser authentik-Gruppe (optional)": "Only customers from this authentik group (optional)",
"Nur Mitglieder dieser authentik-Gruppe (optional)": "Only members of this authentik group (optional)",
"Nur verknüpfte Konten anmelden.": "Only sign in linked accounts.",
"Ohne Haken klappt die Anmeldung auch über die E-Mail-Adresse (das Konto wird dabei gleich verknüpft). Mit Haken muss jeder Mitarbeiter sein Konto erst unter „Mein authentik-Konto“ verknüpfen.": "Without the tick, signing in also works via the email address (the account is linked at the same time). With the tick, every staff member must first link their account under “My authentik account”.",
"Ruft die Konfiguration von authentik ab (ohne Anmeldung). Vorher speichern.": "Fetches the configuration from authentik (without signing in). Save first.",
"Seit": "Since",
"Sicherheit:": "Security:",
"Umleitungs-URI (Redirect URI)": "Redirect URI",
"Ungültige Adresse.": "Invalid address.",
"Verbindung in Ordnung: authentik meldet den Aussteller „{}“ und die Anmelde-Adresse {}.": "Connection OK: authentik reports the issuer “{}” and the sign-in address {}.",
"Verbindung testen": "Test connection",
"Verbindung zu authentik": "Connection to authentik",
"Verknüpfen Sie Ihr DeskHub-Konto einmal mit Ihrem authentik-Konto. Sie melden sich dazu kurz bei authentik an und kommen hierher zurück.": "Link your DeskHub account once with your authentik account. You briefly sign in at authentik and come back here.",
"Verknüpfte Konten ({#})": "Linked accounts ({})",
"Verknüpfung dieses Mitarbeiters lösen?": "Remove this staff member's link?",
"Verknüpfung lösen": "Remove link",
"Verknüpfung wirklich lösen?": "Really remove the link?",
"Zustand": "Status",
"aktives Mitarbeiterkonto": "an active staff account",
"aktives Mitarbeiterkonto mit derselben E-Mail-Adresse": "an active staff account with the same email address",
"anlegen.": "create.",
"authentik antwortet nicht mit einer gültigen Konfiguration (HTTP {}). Stimmt die Issuer-Adresse?": "authentik does not respond with a valid configuration (HTTP {}). Is the issuer address correct?",
"authentik hat den Anmelde-Code nicht angenommen (HTTP {}). Stimmen Client-ID und Geheimnis?": "authentik did not accept the sign-in code (HTTP {}). Are the client ID and secret correct?",
"authentik hat die Anmeldung nicht bestätigt ({}).": "authentik did not confirm the sign-in ({}).",
"authentik hat keine E-Mail-Adresse geliefert. Im Provider den Bereich „email“ aktivieren.": "authentik did not provide an email address. Enable the scope “email” on the provider.",
"authentik ist nicht erreichbar: {}": "authentik is not reachable: {}",
"authentik-Anmeldung": "authentik sign-in",
"authentik-Anmeldung (OpenID Connect)": "authentik sign-in (OpenID Connect)",
"einen Provider vom Typ": "create a provider of type",
"genau so eintragen (Modus „Strict“):": "enter it exactly like this (mode “Strict”):",
"hat, das mit seinem authentik-Konto": "in DeskHub that is",
"hat. Konten werden nicht automatisch angelegt. E-Mail und Passwort funktionieren weiter. Die zweite Stufe (Authenticator-Code) von DeskHub entfällt bei dieser Anmeldung: Dafür sorgt authentik.": "in DeskHub is signed in. Accounts are not created automatically. Email and password keep working. DeskHub's second step (authenticator code) is skipped for this sign-in: authentik takes care of that.",
"ist (oder dieselbe E-Mail-Adresse hat). Konten werden nicht automatisch angelegt. E-Mail und Passwort funktionieren weiter. Die zweite Stufe (Authenticator-Code) von DeskHub entfällt bei dieser Anmeldung: Dafür sorgt authentik.": "to their authentik account (or has the same email address) is signed in. Accounts are not created automatically. Email and password keep working. DeskHub's second step (authenticator code) is skipped for this sign-in: authentik takes care of that.",
"liefert (in authentik: Bereichs-Zuordnung „groups“).": "provides (in authentik: scope mapping “groups”).",
"mindestens": "at least",
"mit diesem Provider anlegen und bestimmen, wer sie nutzen darf (authentik prüft das selbst).": "with this provider and decide who may use it (authentik checks this itself).",
"steht beim Provider unter „OpenID-Konfigurations-Aussteller“, sie sieht so aus:": "is shown on the provider under “OpenID Configuration Issuer”; it looks like this:",
"verknüpft": "linked",
"vertraulich (Confidential).": "confidential.",
"wenn es zu der E-Mail-Adresse noch keines gibt (ohne Passwort; wer eines möchte, nutzt „Passwort vergessen“). Ohne Haken kommen nur Kunden hinein, die schon ein Konto haben.": "if there is none for the email address yet (without a password; anyone who wants one uses “Forgot password”). Without the tick only customers who already have an account get in.",
"zum Beispiel deskhub-kunden": "for example deskhub-customers",
"zum Beispiel deskhub-support": "for example deskhub-support",
"✓ Der Knopf erscheint auf der Anmeldeseite des Kundenportals.": "✓ The button appears on the sign-in page of the customer portal.",
"✓ Eingeschaltet.": "✓ Switched on.",
"✓ Verknüpft.": "✓ Linked."
}
+374
View File
@@ -0,0 +1,374 @@
<?php
declare(strict_types=1);
// authentik-Anmeldung über OpenID Connect (Authorization-Code-Ablauf mit PKCE). Alle Funktionen beginnen mit authentik_.
const AUTHENTIK = 'authentik';
/** Einstellungen des Plugins (Geheimnis entschlüsselt). */
function authentik_config(): array
{
$secret = (string) plugin_setting(AUTHENTIK, 'client_secret_enc', '');
$label = trim((string) plugin_setting(AUTHENTIK, 'label', 'Mit authentik anmelden'));
return [
'enabled' => plugin_setting(AUTHENTIK, 'enabled', '0') === '1',
'issuer' => rtrim(trim((string) plugin_setting(AUTHENTIK, 'issuer', '')), '/'),
'client_id' => trim((string) plugin_setting(AUTHENTIK, 'client_id', '')),
'client_secret' => $secret !== '' ? decrypt_secret($secret) : '',
'scopes' => trim((string) plugin_setting(AUTHENTIK, 'scopes', 'openid email profile')) ?: 'openid email profile',
'group' => trim((string) plugin_setting(AUTHENTIK, 'group', '')),
'customers' => plugin_setting(AUTHENTIK, 'customers', '0') === '1',
'customer_create' => plugin_setting(AUTHENTIK, 'customer_create', '1') === '1',
'customer_group' => trim((string) plugin_setting(AUTHENTIK, 'customer_group', '')),
'require_verified' => plugin_setting(AUTHENTIK, 'require_verified', '1') === '1',
'label' => $label !== '' ? mb_substr($label, 0, 60) : 'Mit authentik anmelden',
];
}
/** Rückkehr-Adresse, die in authentik als „Redirect URI“ eingetragen wird. */
function authentik_redirect_uri(): string
{
return rtrim(base_url(), '/') . plugin_login_url(AUTHENTIK, 'callback');
}
/** Prüft eine Adresse des Anmeldedienstes: https (http nur für localhost zum Testen), ohne Zugangsdaten, Parameter und Anker. */
function authentik_url_ok(string $url, bool $allowQuery = false): bool
{
if ($url === '' || strlen($url) > 300 || preg_match('/[\x00-\x20]/', $url)) return false;
$p = parse_url($url);
if (!$p || empty($p['host']) || isset($p['user']) || isset($p['pass']) || isset($p['fragment'])) return false;
if (!$allowQuery && isset($p['query'])) return false;
$scheme = $p['scheme'] ?? '';
return $scheme === 'https' || ($scheme === 'http' && in_array($p['host'], ['localhost', '127.0.0.1', '[::1]'], true));
}
function authentik_ready(): bool
{
$c = authentik_config();
return $c['enabled'] && authentik_url_ok($c['issuer']) && $c['client_id'] !== '' && $c['client_secret'] !== '';
}
/** Ist die Anmeldung für Kunden (Ticketersteller) eingeschaltet und eingerichtet? */
function authentik_customer_ready(): bool
{
return authentik_ready() && authentik_config()['customers'];
}
/**
* HTTP-Aufruf zum Anmeldedienst mit cURL: Zertifikat wird geprüft, keine Weiterleitungen, begrenzte Antwortgröße und Zeit.
* @return array{0:int,1:?string,2:?string} [Status, Antworttext, Fehler]
*/
function authentik_http(string $method, string $url, array $opts = []): array
{
if (!function_exists('curl_init')) return [0, null, 'Auf diesem Server fehlt die PHP-Erweiterung cURL.'];
if (!authentik_url_ok($url, true)) return [0, null, 'Ungültige Adresse.'];
if (isset($GLOBALS['authentik_http']) && is_callable($GLOBALS['authentik_http'])) return ($GLOBALS['authentik_http'])($method, $url, $opts); // für Tests
$body = '';
$headers = array_merge(['Accept: application/json', 'User-Agent: DeskHub-authentik'], (array) ($opts['headers'] ?? []));
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 12, CURLOPT_CONNECTTIMEOUT => 6,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => $headers,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
CURLOPT_WRITEFUNCTION => function ($c, string $data) use (&$body): int {
$body .= $data;
return strlen($body) > 262144 ? 0 : strlen($data);
},
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query((array) ($opts['form'] ?? [])));
}
$ok = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = $ok === false ? curl_error($ch) : null;
curl_close($ch);
return [$status, $err === null ? $body : null, $err];
}
/** Konfiguration des Anmeldedienstes (/.well-known/openid-configuration), eine Stunde lang gemerkt. @return array{ok:bool,error?:string,doc?:array} */
function authentik_discovery(string $issuer, bool $refresh = false): array
{
if (!$refresh) {
$cached = json_decode((string) plugin_setting(AUTHENTIK, 'discovery', ''), true);
if (is_array($cached) && ($cached['issuer'] ?? '') === $issuer && (int) ($cached['at'] ?? 0) > time() - 3600 && is_array($cached['doc'] ?? null)) return ['ok' => true, 'doc' => $cached['doc']];
}
[$status, $body, $err] = authentik_http('GET', $issuer . '/.well-known/openid-configuration');
if ($err !== null || $body === null) return ['ok' => false, 'error' => 'authentik ist nicht erreichbar: ' . ($err ?? 'keine Antwort')];
$doc = json_decode($body, true);
if ($status !== 200 || !is_array($doc)) return ['ok' => false, 'error' => 'authentik antwortet nicht mit einer gültigen Konfiguration (HTTP ' . $status . '). Stimmt die Issuer-Adresse?'];
if (rtrim((string) ($doc['issuer'] ?? ''), '/') !== $issuer) return ['ok' => false, 'error' => 'Der Issuer in der Antwort („' . mb_substr((string) ($doc['issuer'] ?? ''), 0, 120) . '“) passt nicht zur eingestellten Adresse.'];
foreach (['authorization_endpoint', 'token_endpoint'] as $k) {
if (!authentik_url_ok((string) ($doc[$k] ?? ''), true)) return ['ok' => false, 'error' => 'Die Konfiguration von authentik enthält keinen gültigen Eintrag „' . $k . '“.'];
}
$keep = array_intersect_key($doc, array_flip(['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint']));
plugin_set_setting(AUTHENTIK, 'discovery', json_encode(['issuer' => $issuer, 'at' => time(), 'doc' => $keep], JSON_UNESCAPED_SLASHES));
return ['ok' => true, 'doc' => $keep];
}
function authentik_b64url(string $bin): string
{
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}
/** Ablauf starten: Zustand, Nonce und PKCE-Prüfwert in der Sitzung merken, dann weiter zu authentik. $mode: 'login' oder 'link' (Konto verknüpfen). */
function authentik_begin(string $mode, ?int $staffId = null): array
{
$back = $mode === 'link' ? ['back' => '/staff/plugin.php?p=authentik&page=konto'] : [];
if ($mode === 'customer' && !authentik_customer_ready()) return ['error' => 'Die Anmeldung mit authentik ist für Kunden nicht eingeschaltet.'];
if (!authentik_ready()) return ['error' => 'Die Anmeldung mit authentik ist nicht eingerichtet.'] + $back;
$cfg = authentik_config();
$d = authentik_discovery($cfg['issuer']);
if (!$d['ok']) return ['error' => $d['error']] + $back;
$verifier = authentik_b64url(random_bytes(48));
$state = bin2hex(random_bytes(16));
$nonce = bin2hex(random_bytes(16));
$_SESSION['authentik'] = ['state' => $state, 'nonce' => $nonce, 'verifier' => $verifier, 'at' => time(), 'mode' => $mode, 'staff_id' => $staffId];
$url = $d['doc']['authorization_endpoint'];
$url .= (str_contains($url, '?') ? '&' : '?') . http_build_query([
'response_type' => 'code', 'client_id' => $cfg['client_id'], 'redirect_uri' => authentik_redirect_uri(), 'scope' => $cfg['scopes'],
'state' => $state, 'nonce' => $nonce, 'code_challenge' => authentik_b64url(hash('sha256', $verifier, true)), 'code_challenge_method' => 'S256',
], '', '&', PHP_QUERY_RFC3986);
return ['redirect' => $url];
}
/** Start der Anmeldung (nicht angemeldet). */
function authentik_login_start(array $req): array
{
return authentik_begin('login');
}
/** Start der Anmeldung für Kunden (Kundenportal). */
function authentik_customer_start(array $req): array
{
return authentik_begin('customer');
}
/** Start der Verknüpfung (angemeldeter Mitarbeiter). */
function authentik_link_start(array $req): array
{
$staff = $req['staff'] ?? null;
if (!is_array($staff) || empty($staff['id'])) return ['error' => 'Bitte zuerst anmelden.'];
return authentik_begin('link', (int) $staff['id']);
}
/** Liest die Aussagen (Claims) eines ID-Tokens, das direkt vom Token-Endpunkt über TLS kam (dann ist keine Signaturprüfung nötig, OIDC Core 3.1.3.7). */
function authentik_claims(string $jwt): ?array
{
$parts = explode('.', $jwt);
if (count($parts) !== 3) return null;
$json = base64_decode(strtr($parts[1], '-_', '+/') . str_repeat('=', (4 - strlen($parts[1]) % 4) % 4), true);
$claims = $json === false ? null : json_decode($json, true);
return is_array($claims) ? $claims : null;
}
/**
* Rückkehr von authentik: Code gegen Token tauschen und alles prüfen (Zustand, Aussteller, Zielgruppe, Ablauf, Nonce, E-Mail, Gruppe).
* @return array{error:string}|array{claims:array,email:string,sub:string}
*/
function authentik_exchange(array $q, ?array $sess, string $mode): array
{
$cfg = authentik_config();
if (!$sess || time() - (int) ($sess['at'] ?? 0) > 600 || ($sess['mode'] ?? 'login') !== $mode) return ['error' => 'Die Anmeldung ist abgelaufen oder wurde nicht hier gestartet. Bitte noch einmal auf „' . ($mode === 'link' ? 'Mit authentik verknüpfen' : $cfg['label']) . '“ klicken.'];
if (isset($q['error'])) return ['error' => 'authentik hat die Anmeldung nicht bestätigt (' . mb_substr(preg_replace('/[^A-Za-z0-9 _.\-]/', '', (string) $q['error']), 0, 60) . ').'];
$state = (string) ($q['state'] ?? '');
$code = (string) ($q['code'] ?? '');
if ($state === '' || !hash_equals((string) $sess['state'], $state) || $code === '' || strlen($code) > 2000) return ['error' => 'Die Antwort von authentik passt nicht zur gestarteten Anmeldung. Bitte noch einmal versuchen.'];
$d = authentik_discovery($cfg['issuer']);
if (!$d['ok']) return ['error' => $d['error']];
[$status, $body, $err] = authentik_http('POST', $d['doc']['token_endpoint'], ['form' => [
'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => authentik_redirect_uri(),
'client_id' => $cfg['client_id'], 'client_secret' => $cfg['client_secret'], 'code_verifier' => (string) $sess['verifier'],
]]);
if ($err !== null || $body === null) return ['error' => 'authentik ist nicht erreichbar: ' . ($err ?? 'keine Antwort')];
$tok = json_decode($body, true);
if ($status !== 200 || !is_array($tok) || empty($tok['id_token'])) return ['error' => 'authentik hat den Anmelde-Code nicht angenommen (HTTP ' . $status . '). Stimmen Client-ID und Geheimnis?'];
$claims = authentik_claims((string) $tok['id_token']);
if (!$claims) return ['error' => 'Die Antwort von authentik ist unlesbar.'];
// Prüfung nach OIDC Core: Aussteller, Zielgruppe, Ablauf, Nonce
if (rtrim((string) ($claims['iss'] ?? ''), '/') !== $cfg['issuer']) return ['error' => 'Die Antwort stammt nicht vom eingestellten authentik (Aussteller).'];
$aud = $claims['aud'] ?? [];
$aud = is_array($aud) ? $aud : [$aud];
if (!in_array($cfg['client_id'], array_map('strval', $aud), true) || (isset($claims['azp']) && (string) $claims['azp'] !== $cfg['client_id'])) return ['error' => 'Die Antwort von authentik gilt für eine andere Anwendung.'];
if ((int) ($claims['exp'] ?? 0) < time() - 60) return ['error' => 'Die Bestätigung von authentik ist abgelaufen.'];
if (!hash_equals((string) $sess['nonce'], (string) ($claims['nonce'] ?? ''))) return ['error' => 'Die Antwort von authentik gehört nicht zu dieser Anmeldung (Nonce).'];
$sub = (string) ($claims['sub'] ?? '');
if ($sub === '' || strlen($sub) > 255) return ['error' => 'Die Antwort von authentik enthält keine Benutzerkennung.'];
$userinfo = null;
$loadUserinfo = function () use (&$userinfo, $tok, $d, $sub) {
if ($userinfo !== null) return $userinfo;
$userinfo = [];
if (!empty($tok['access_token']) && !empty($d['doc']['userinfo_endpoint'])) {
[$s2, $b2, $e2] = authentik_http('GET', $d['doc']['userinfo_endpoint'], ['headers' => ['Authorization: Bearer ' . $tok['access_token']]]);
$ui = $e2 === null && $b2 !== null && $s2 === 200 ? json_decode($b2, true) : null;
if (is_array($ui) && (string) ($ui['sub'] ?? '') === $sub) $userinfo = $ui; // nur der Benutzer dieser Anmeldung zählt
}
return $userinfo;
};
$email = strtolower(trim((string) ($claims['email'] ?? '')));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$claims = $loadUserinfo() + $claims; // manche Einstellungen liefern die Adresse nur über „userinfo“
$email = strtolower(trim((string) ($claims['email'] ?? '')));
}
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) return ['error' => 'authentik hat keine E-Mail-Adresse geliefert. Im Provider den Bereich „email“ aktivieren.'];
// Ob authentik die Adresse als bestätigt meldet. Verlangt wird das nur dort, wo die Adresse über das Konto entscheidet (siehe authentik_unverified_error()).
$verified = !array_key_exists('email_verified', $claims) || $claims['email_verified'] !== false;
// Optional: nur Mitglieder einer Gruppe (für Kunden eine eigene)
$needGroup = $mode === 'customer' ? $cfg['customer_group'] : $cfg['group'];
if ($needGroup !== '') {
$groups = $claims['groups'] ?? null;
if (!is_array($groups)) $groups = $loadUserinfo()['groups'] ?? null;
$names = array_map(fn($g) => is_array($g) ? (string) ($g['name'] ?? '') : (string) $g, is_array($groups) ? $groups : []);
if (!in_array($needGroup, $names, true)) {
audit_log(null, 'authentik: ' . mb_substr($email, 0, 120) . ' abgelehnt (nicht in der Gruppe ' . mb_substr($needGroup, 0, 60) . ')');
return ['error' => 'Ihr authentik-Konto gehört nicht zur Gruppe „' . $needGroup . '“, die für DeskHub nötig ist.'];
}
}
return ['claims' => $claims, 'email' => $email, 'sub' => $sub, 'verified' => $verified];
}
/**
* Fehlermeldung, wenn authentik die E-Mail-Adresse als „nicht bestätigt“ meldet und die Adresse über das Konto entscheiden würde
* (Mitarbeiter ohne Verknüpfung, Kunden). Null = in Ordnung (bestätigt oder Prüfung ausgeschaltet).
*/
function authentik_unverified_error(array $x, string $who): ?string
{
if ($x['verified'] || !authentik_config()['require_verified']) return null;
return $who === 'customer'
? 'Die E-Mail-Adresse ist in authentik nicht bestätigt. Bitte erst in authentik bestätigen.'
: 'Die E-Mail-Adresse ist in authentik nicht bestätigt, daher ordnet DeskHub das Konto nicht über die Adresse zu. Verknüpfen Sie Ihr Konto einmal unter „Mein authentik-Konto“ (nach der Anmeldung mit E-Mail und Passwort), danach geht die Anmeldung mit authentik.';
}
// ── Verknüpfung DeskHub-Konto ⇄ authentik-Konto (in den Einstellungen gespeichert) ──
/** Kurzer Schlüssel für eine authentik-Benutzerkennung (gilt nur für diesen Issuer). */
function authentik_link_hash(string $sub): string
{
return substr(hash('sha256', authentik_config()['issuer'] . '|' . $sub), 0, 40);
}
/** Zu dieser authentik-Kennung verknüpfter Mitarbeiter (ID) oder null. */
function authentik_link_staff_for(string $sub): ?int
{
$id = (int) plugin_setting(AUTHENTIK, 'link_s_' . authentik_link_hash($sub), '0');
return $id > 0 ? $id : null;
}
/** Verknüpfung eines Mitarbeiters: ['h' => Schlüssel, 'email' => …, 'at' => …] oder null. */
function authentik_link_of(int $staffId): ?array
{
$d = json_decode((string) plugin_setting(AUTHENTIK, 'link_u_' . $staffId, ''), true);
return is_array($d) && !empty($d['h']) ? $d : null;
}
function authentik_link_set(int $staffId, string $sub, string $email): void
{
authentik_link_remove($staffId);
$h = authentik_link_hash($sub);
plugin_set_setting(AUTHENTIK, 'link_s_' . $h, (string) $staffId);
plugin_set_setting(AUTHENTIK, 'link_u_' . $staffId, json_encode(['h' => $h, 'email' => mb_substr($email, 0, 190), 'at' => date('Y-m-d H:i:s')], JSON_UNESCAPED_UNICODE));
}
function authentik_link_remove(int $staffId): void
{
$old = authentik_link_of($staffId);
$keys = ['link_u_' . $staffId];
if ($old) $keys[] = 'link_s_' . $old['h'];
foreach ($keys as $k) {
plugin_set_setting(AUTHENTIK, $k, ''); // erst leeren (der Einstellungs-Zwischenspeicher der Anfrage), dann die Zeile löschen
get_db()->prepare('DELETE FROM settings WHERE setting_key = ?')->execute(['plugin.' . AUTHENTIK . '.' . $k]);
}
}
/** Alle Verknüpfungen mit Name und E-Mail des Mitarbeiters (für die Verwaltung). @return list<array{staff_id:int,name:string,staff_email:string,email:string,at:string}> */
function authentik_links_all(): array
{
$out = [];
$rows = get_db()->query("SELECT setting_key, setting_value FROM settings WHERE setting_key LIKE 'plugin.authentik.link_u_%'")->fetchAll();
$st = get_db()->prepare('SELECT name, email FROM staff WHERE id = ?');
foreach ($rows as $r) {
if (!str_starts_with((string) $r['setting_key'], 'plugin.authentik.link_u_')) continue;
$id = (int) substr((string) $r['setting_key'], strlen('plugin.authentik.link_u_'));
$d = json_decode((string) $r['setting_value'], true);
if ($id <= 0 || !is_array($d)) continue;
$st->execute([$id]);
$s = $st->fetch();
$out[] = ['staff_id' => $id, 'name' => (string) ($s['name'] ?? '(gelöscht)'), 'staff_email' => (string) ($s['email'] ?? ''), 'email' => (string) ($d['email'] ?? ''), 'at' => (string) ($d['at'] ?? '')];
}
usort($out, fn($a, $b) => strcasecmp($a['name'], $b['name']));
return $out;
}
/** Rückkehr von authentik: angemeldet = Konto verknüpfen, sonst anmelden. */
function authentik_login_callback(array $req): array
{
$loggedIn = isset($req['staff']) && is_array($req['staff']);
$sess = is_array($_SESSION['authentik'] ?? null) ? $_SESSION['authentik'] : null;
unset($_SESSION['authentik']); // jeder Ablauf gilt nur einmal
// Betriebsart: angemeldet = Verknüpfen; sonst steht in der Sitzung, ob ein Kunde oder ein Mitarbeiter anmelden wollte
$mode = $loggedIn ? 'link' : (($sess['mode'] ?? 'login') === 'customer' ? 'customer' : 'login');
$back = $loggedIn ? ['back' => '/staff/plugin.php?p=authentik&page=konto'] : ($mode === 'customer' ? ['audience' => 'customer'] : []);
if (!authentik_ready()) return ['error' => 'Die Anmeldung mit authentik ist nicht eingerichtet.'] + $back;
if ($mode === 'customer' && !authentik_customer_ready()) return ['error' => 'Die Anmeldung mit authentik ist für Kunden nicht eingeschaltet.'] + $back;
$x = authentik_exchange((array) ($req['query'] ?? []), $sess, $mode);
if (isset($x['error'])) return $x + $back;
$email = $x['email'];
$sub = $x['sub'];
// Kunde (Ticketersteller): nur die bestätigte E-Mail-Adresse zählt; das Konto wird bei Bedarf angelegt
if ($mode === 'customer') {
if ($e = authentik_unverified_error($x, 'customer')) return ['error' => $e] + $back;
$c = $x['claims'];
$name = trim((string) ($c['name'] ?? ''));
if ($name === '') $name = trim(trim((string) ($c['given_name'] ?? '')) . ' ' . trim((string) ($c['family_name'] ?? '')));
if ($name === '') $name = trim((string) ($c['preferred_username'] ?? ''));
return ['customer' => ['email' => $email, 'name' => mb_substr($name, 0, 120), 'create' => authentik_config()['customer_create'], 'via' => 'authentik']];
}
// Verknüpfen (angemeldeter Mitarbeiter)
if ($loggedIn) {
$staffId = (int) $req['staff']['id'];
if ((int) ($sess['staff_id'] ?? 0) !== $staffId) return ['error' => 'Die Verknüpfung wurde von einem anderen Konto gestartet. Bitte noch einmal starten.'] + $back;
$other = authentik_link_staff_for($sub);
if ($other !== null && $other !== $staffId) return ['error' => 'Dieses authentik-Konto ist bereits mit einem anderen DeskHub-Konto verknüpft.'] + $back;
authentik_link_set($staffId, $sub, $email);
audit_log($staffId, 'authentik: Konto verknüpft (' . mb_substr($email, 0, 120) . ')');
return ['local' => '/staff/plugin.php?p=authentik&page=konto', 'notice' => 'Ihr authentik-Konto (' . $email . ') ist jetzt verknüpft. Die Anmeldung mit authentik führt künftig genau in dieses Konto.'];
}
// Anmelden: zuerst über die Verknüpfung, sonst (wenn erlaubt) über die E-Mail-Adresse
$st = get_db()->prepare('SELECT id, is_active FROM staff WHERE id = ?');
$linked = authentik_link_staff_for($sub);
if ($linked !== null) {
$st->execute([$linked]);
$s = $st->fetch();
if (!$s) { authentik_link_remove($linked); }
elseif (!(int) $s['is_active']) return ['error' => 'Das mit Ihrem authentik-Konto verknüpfte DeskHub-Konto ist deaktiviert.'];
else return ['staff_id' => (int) $s['id'], 'via' => 'authentik'];
}
if (plugin_setting(AUTHENTIK, 'only_linked', '0') === '1') {
audit_log(null, 'authentik: ' . mb_substr($email, 0, 120) . ' abgelehnt (nicht verknüpft)');
return ['error' => 'Ihr authentik-Konto ist noch keinem DeskHub-Konto zugeordnet. Melden Sie sich mit E-Mail und Passwort an und verknüpfen Sie es dort unter „Mein authentik-Konto“.'];
}
// Zuordnung über die E-Mail-Adresse nur, wenn authentik sie als bestätigt meldet (sonst über „Mein authentik-Konto“ verknüpfen)
if ($e = authentik_unverified_error($x, 'staff')) return ['error' => $e];
$q = get_db()->prepare('SELECT id FROM staff WHERE LOWER(email) = ? AND is_active = 1');
$q->execute([$email]);
$id = $q->fetchColumn();
if (!$id) {
audit_log(null, 'authentik: kein Mitarbeiterkonto für ' . mb_substr($email, 0, 120));
return ['error' => 'Für ' . $email . ' gibt es in DeskHub kein aktives Mitarbeiterkonto. Bitte einen Administrator bitten, es mit derselben E-Mail-Adresse anzulegen, oder ein bestehendes Konto unter „Mein authentik-Konto“ verknüpfen.'];
}
$id = (int) $id;
// Hat dieses DeskHub-Konto schon ein anderes authentik-Konto, darf ein fremdes mit gleicher E-Mail-Adresse nicht hinein
if (authentik_link_of($id) !== null) return ['error' => 'Dieses DeskHub-Konto ist mit einem anderen authentik-Konto verknüpft.'];
// Erste Anmeldung über die E-Mail-Adresse: gleich fest verknüpfen (spätere Änderungen der Adresse stören dann nicht)
authentik_link_set($id, $sub, $email);
audit_log($id, 'authentik: Konto automatisch verknüpft (' . mb_substr($email, 0, 120) . ')');
return ['staff_id' => $id, 'via' => 'authentik'];
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
if (($staff['role'] ?? '') !== 'admin') {
http_response_code(403);
echo '<p>Keine Berechtigung.</p>';
return;
}
$message = '';
$messageType = 'success';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$action = (string) ($_POST['action'] ?? '');
if ($action === 'save') {
$issuer = rtrim(trim((string) ($_POST['issuer'] ?? '')), '/');
$clientId = trim((string) ($_POST['client_id'] ?? ''));
$secretIn = (string) ($_POST['client_secret'] ?? '');
$scopes = trim(preg_replace('/\s+/', ' ', (string) ($_POST['scopes'] ?? '')));
if ($issuer !== '' && !authentik_url_ok($issuer)) {
$message = 'Bitte die Issuer-Adresse mit https:// angeben, ohne Parameter (zum Beispiel https://auth.example.de/application/o/deskhub).';
$messageType = 'error';
} elseif (strlen($clientId) > 200 || preg_match('/[\x00-\x20]/', $clientId)) {
$message = 'Die Client-ID ist ungültig.';
$messageType = 'error';
} elseif ($scopes !== '' && !preg_match('/^[A-Za-z0-9_.:\- ]{1,200}$/', $scopes)) {
$message = 'Die Bereiche (Scopes) enthalten ungültige Zeichen.';
$messageType = 'error';
} else {
plugin_set_setting(AUTHENTIK, 'issuer', $issuer);
plugin_set_setting(AUTHENTIK, 'client_id', $clientId);
if ($secretIn !== '') plugin_set_setting(AUTHENTIK, 'client_secret_enc', encrypt_secret($secretIn));
plugin_set_setting(AUTHENTIK, 'scopes', $scopes !== '' ? $scopes : 'openid email profile');
plugin_set_setting(AUTHENTIK, 'group', mb_substr(trim((string) ($_POST['group'] ?? '')), 0, 100));
plugin_set_setting(AUTHENTIK, 'label', mb_substr(trim((string) ($_POST['label'] ?? '')), 0, 60));
plugin_set_setting(AUTHENTIK, 'enabled', !empty($_POST['enabled']) ? '1' : '0');
plugin_set_setting(AUTHENTIK, 'only_linked', !empty($_POST['only_linked']) ? '1' : '0');
plugin_set_setting(AUTHENTIK, 'require_verified', !empty($_POST['require_verified']) ? '1' : '0');
plugin_set_setting(AUTHENTIK, 'customers', !empty($_POST['customers']) ? '1' : '0');
plugin_set_setting(AUTHENTIK, 'customer_create', !empty($_POST['customer_create']) ? '1' : '0');
plugin_set_setting(AUTHENTIK, 'customer_group', mb_substr(trim((string) ($_POST['customer_group'] ?? '')), 0, 100));
plugin_set_setting(AUTHENTIK, 'discovery', ''); // neu laden
audit_log((int) $staff['id'], 'authentik-Anmeldung: Einstellungen geändert' . (!empty($_POST['enabled']) ? ' (eingeschaltet)' : ' (ausgeschaltet)'));
$message = 'Gespeichert.';
}
} elseif ($action === 'unlink_staff') {
$sid = (int) ($_POST['staff_id'] ?? 0);
if ($sid > 0 && authentik_link_of($sid) !== null) {
authentik_link_remove($sid);
audit_log((int) $staff['id'], 'authentik: Verknüpfung von Mitarbeiter #' . $sid . ' gelöst');
$message = 'Die Verknüpfung ist gelöst.';
}
} elseif ($action === 'test') {
$cfg = authentik_config();
if (!authentik_url_ok($cfg['issuer'])) {
$message = 'Bitte zuerst eine gültige Issuer-Adresse speichern.';
$messageType = 'error';
} else {
$d = authentik_discovery($cfg['issuer'], true);
if ($d['ok']) {
$message = 'Verbindung in Ordnung: authentik meldet den Aussteller „' . $d['doc']['issuer'] . '“ und die Anmelde-Adresse ' . $d['doc']['authorization_endpoint'] . '.';
} else {
$message = $d['error'];
$messageType = 'error';
}
}
}
}
$cfg = authentik_config();
$hasSecret = $cfg['client_secret'] !== '';
$ready = authentik_ready();
$redirect = authentik_redirect_uri();
?>
<h1>authentik-Anmeldung</h1>
<?php if ($message !== ''): ?><div class="flash <?= $messageType === 'error' ? 'error' : 'success' ?>" role="status"><?= e($message) ?></div><?php endif; ?>
<section class="card" style="max-width:900px">
<h3>Zustand</h3>
<p>
<?php if ($ready): ?><strong style="color:#157a45">✓ Eingeschaltet.</strong> Auf der Anmeldeseite des Mitarbeiterbereichs erscheint der Knopf „<?= e($cfg['label']) ?>“.
<?php elseif ($cfg['enabled']): ?><strong style="color:#b45309">Eingeschaltet, aber unvollständig.</strong> Issuer-Adresse, Client-ID und Geheimnis müssen eingetragen sein.
<?php else: ?><strong style="color:#b45309">Ausgeschaltet.</strong> Der Knopf erscheint erst nach dem Einschalten.<?php endif; ?>
</p>
<p class="muted" style="font-size:.88rem">Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein <strong>aktives Mitarbeiterkonto</strong> hat, das mit seinem authentik-Konto <strong>verknüpft</strong> ist (oder dieselbe E-Mail-Adresse hat). Konten werden nicht automatisch angelegt. E-Mail und Passwort funktionieren weiter. Die zweite Stufe (Authenticator-Code) von DeskHub entfällt bei dieser Anmeldung: Dafür sorgt authentik.</p>
<?php
// Warum erscheint (oder fehlt) der Knopf auf der Anmeldeseite des Kundenportals?
$serverOk = function_exists('plugin_register_customer_login');
$permOk = in_array('auth.customer', plugin_state()['perms'][AUTHENTIK] ?? [], true);
$custRegistered = isset(plugin_state()['customer_logins'][AUTHENTIK]['start']);
$checks = [
[$serverOk, 'Der DeskHub-Server kennt die Kunden-Anmeldung (Version 1.0.52 oder neuer).', 'Der Server ist zu alt: Bitte DeskHub 1.0.52 oder neuer einspielen (auch login.php, plugin_login.php und includes/plugins.php).'],
[$permOk, 'Die Berechtigung „auth.customer“ ist freigegeben.', 'Die Berechtigung „auth.customer“ fehlt: Unter Plugins das Plugin deaktivieren und wieder aktivieren und die Berechtigungen bestätigen.'],
[$cfg['customers'], 'Kunden-Anmeldung ist eingeschaltet („Auch Kunden mit authentik anmelden“).', 'Kunden-Anmeldung ist ausgeschaltet: Unten unter „Kunden“ den Haken setzen und speichern.'],
[$ready, 'Die Verbindung zu authentik ist eingerichtet und eingeschaltet.', 'Die Verbindung ist nicht vollständig (Issuer, Client-ID, Geheimnis, eingeschaltet).'],
];
// Zeichnet die Kunden-Anmeldeseite (login.php im DeskHub-Hauptordner) die Knöpfe der Plugins überhaupt? (Datei fehlt oder ist alt: Knopf bleibt weg)
$loginFile = dirname(plugins_dir()) . '/login.php';
$loginPhp = is_file($loginFile) ? (string) @file_get_contents($loginFile) : null;
$loginOk = $loginPhp === null ? true : str_contains($loginPhp, 'customer.login.buttons');
$checks[] = [$loginOk, 'Die Anmeldeseite des Kundenportals (login.php) ist auf dem neuesten Stand.', 'Die Datei login.php im DeskHub-Hauptordner ist alt und zeigt keine Plugin-Knöpfe: Bitte die aktuelle login.php hochladen.'];
$allOk = $serverOk && $permOk && $cfg['customers'] && $ready && $custRegistered && $loginOk;
?>
<h4 style="margin:14px 0 6px">Kunden-Anmeldung</h4>
<p style="margin:0 0 6px"><?php if ($allOk): ?><strong style="color:#157a45">✓ Der Knopf erscheint auf der Anmeldeseite des Kundenportals.</strong><?php else: ?><strong style="color:#b45309">Der Knopf erscheint dort noch nicht.</strong><?php endif; ?></p>
<ul style="list-style:none;padding:0;margin:0;font-size:.88rem">
<?php foreach ($checks as [$ok, $yes, $no]): ?><li><?= $ok ? '<span style="color:#157a45">✓</span> ' . e($yes) : '<span style="color:#b91c1c">✗</span> ' . e($no) ?></li><?php endforeach; ?>
</ul>
</section>
<form method="post" class="card" style="max-width:900px;margin-top:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<h3>Verbindung zu authentik</h3>
<label style="display:flex;gap:8px;align-items:center;font-weight:400"><input type="checkbox" name="enabled" value="1" style="width:auto" <?= $cfg['enabled'] ? 'checked' : '' ?>> Anmeldung mit authentik einschalten</label>
<label for="issuer">Issuer-Adresse (OpenID-Konfiguration)</label>
<input type="text" inputmode="url" id="issuer" name="issuer" value="<?= e($cfg['issuer']) ?>" placeholder="https://auth.example.de/application/o/deskhub" autocomplete="off">
<label for="client_id">Client-ID</label>
<input type="text" id="client_id" name="client_id" value="<?= e($cfg['client_id']) ?>" autocomplete="off">
<label for="client_secret">Client-Geheimnis (Client Secret)</label>
<input type="password" id="client_secret" name="client_secret" value="" placeholder="<?= $hasSecret ? '(unverändert)' : '' ?>" autocomplete="new-password">
<label for="scopes">Bereiche (Scopes)</label>
<input type="text" id="scopes" name="scopes" value="<?= e($cfg['scopes']) ?>">
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:10px"><input type="checkbox" name="only_linked" value="1" style="width:auto;margin-top:3px" <?= plugin_setting(AUTHENTIK, 'only_linked', '0') === '1' ? 'checked' : '' ?>> <span><strong>Nur verknüpfte Konten anmelden.</strong> Ohne Haken klappt die Anmeldung auch über die E-Mail-Adresse (das Konto wird dabei gleich verknüpft). Mit Haken muss jeder Mitarbeiter sein Konto erst unter „Mein authentik-Konto“ verknüpfen.</span></label>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:10px"><input type="checkbox" name="require_verified" value="1" style="width:auto;margin-top:3px" <?= $cfg['require_verified'] ? 'checked' : '' ?>> <span><strong>Bestätigte E-Mail-Adresse verlangen (empfohlen).</strong> Gilt, wo die Adresse über das Konto entscheidet: bei Kunden und bei Mitarbeitern, die noch nicht verknüpft sind. Meldet authentik die Adresse als „nicht bestätigt“, kommen sie sonst nicht hinein. Wer schon verknüpft ist, meldet sich immer an. Nur ausschalten, wenn nur du in authentik Benutzer anlegst und Adressen ändern darfst.</span></label>
<label for="group">Nur Mitglieder dieser authentik-Gruppe (optional)</label>
<input type="text" id="group" name="group" value="<?= e($cfg['group']) ?>" placeholder="zum Beispiel deskhub-support">
<h3 style="margin-top:22px">Kunden (Ticketersteller)</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400"><input type="checkbox" name="customers" value="1" style="width:auto;margin-top:3px" <?= $cfg['customers'] ? 'checked' : '' ?>> <span><strong>Auch Kunden mit authentik anmelden.</strong> Der Knopf erscheint auf der Anmeldeseite des Kundenportals. Es zählt nur die in authentik bestätigte E-Mail-Adresse: Hat ein Kundenkonto dieselbe Adresse, meldet DeskHub es an.</span></label>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:10px"><input type="checkbox" name="customer_create" value="1" style="width:auto;margin-top:3px" <?= $cfg['customer_create'] ? 'checked' : '' ?>> <span><strong>Kundenkonto automatisch anlegen,</strong> wenn es zu der E-Mail-Adresse noch keines gibt (ohne Passwort; wer eines möchte, nutzt „Passwort vergessen“). Ohne Haken kommen nur Kunden hinein, die schon ein Konto haben.</span></label>
<label for="customer_group">Nur Kunden aus dieser authentik-Gruppe (optional)</label>
<input type="text" id="customer_group" name="customer_group" value="<?= e($cfg['customer_group']) ?>" placeholder="zum Beispiel deskhub-kunden">
<h3 style="margin-top:22px">Beschriftung</h3>
<label for="label">Beschriftung des Knopfes</label>
<input type="text" id="label" name="label" value="<?= e($cfg['label']) ?>">
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
<form method="post" class="card" style="max-width:900px;margin-top:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="test">
<h3>Verbindung testen</h3>
<p class="muted" style="font-size:.88rem">Ruft die Konfiguration von authentik ab (ohne Anmeldung). Vorher speichern.</p>
<button type="submit" class="btn secondary">Verbindung testen</button>
</form>
<?php $links = authentik_links_all(); ?>
<section class="card" style="max-width:900px;margin-top:16px">
<h3>Verknüpfte Konten (<?= count($links) ?>)</h3>
<?php if (!$links): ?>
<p class="muted">Noch kein Mitarbeiter hat sein Konto verknüpft. Mitarbeiter tun das selbst unter „Mein authentik-Konto“; bei der ersten Anmeldung über die E-Mail-Adresse geschieht es automatisch.</p>
<?php else: ?>
<div style="overflow-x:auto"><table style="width:100%">
<thead><tr><th>Mitarbeiter</th><th>E-Mail in DeskHub</th><th>E-Mail in authentik</th><th>Seit</th><th></th></tr></thead>
<tbody>
<?php foreach ($links as $l): ?>
<tr><td><?= e($l['name']) ?></td><td><?= e($l['staff_email']) ?></td><td><?= e($l['email']) ?></td><td><?= e(format_dt($l['at'])) ?></td>
<td><form method="post" data-confirm="Verknüpfung dieses Mitarbeiters lösen?" onsubmit="return confirm(this.dataset.confirm)"><?= csrf_field() ?><input type="hidden" name="action" value="unlink_staff"><input type="hidden" name="staff_id" value="<?= (int) $l['staff_id'] ?>"><button class="btn secondary" style="padding:3px 10px;font-size:.8rem" type="submit">Lösen</button></form></td></tr>
<?php endforeach; ?>
</tbody>
</table></div>
<?php endif; ?>
</section>
<section class="card" style="max-width:900px;margin-top:16px">
<h3>Einrichtung in authentik</h3>
<ol style="line-height:1.7;padding-left:20px;font-size:.92rem">
<li>In authentik unter <strong>Anwendungen → Provider</strong> einen Provider vom Typ <strong>OAuth2/OpenID</strong> anlegen. <strong>Client-Typ:</strong> vertraulich (Confidential).</li>
<li><strong>Umleitungs-URI (Redirect URI)</strong> genau so eintragen (Modus „Strict“): <code><?= e($redirect) ?></code></li>
<li><strong>Bereiche:</strong> mindestens <code>openid</code>, <code>email</code> und <code>profile</code>. Für die Gruppenprüfung zusätzlich den Bereich, der <code>groups</code> liefert (in authentik: Bereichs-Zuordnung „groups“).</li>
<li>Eine <strong>Anwendung</strong> mit diesem Provider anlegen und bestimmen, wer sie nutzen darf (authentik prüft das selbst).</li>
<li>Client-ID und Client-Geheimnis hier eintragen. Die <strong>Issuer-Adresse</strong> steht beim Provider unter „OpenID-Konfigurations-Aussteller“, sie sieht so aus: <code>https://auth.example.de/application/o/&lt;Anwendungs-Kürzel&gt;/</code>.</li>
<li>Jeder Mitarbeiter hat sein DeskHub-Konto und verknüpft es einmal selbst unter <strong>Mein authentik-Konto</strong>. Alternativ klappt die erste Anmeldung auch, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist (dann wird automatisch verknüpft).</li>
</ol>
<p class="muted" style="font-size:.85rem"><strong>Sicherheit:</strong> Der Ablauf nutzt PKCE, einen Zustandswert und eine Nonce; Aussteller, Zielgruppe und Ablauf des ID-Tokens werden geprüft. Das Token kommt direkt von authentik über eine geprüfte HTTPS-Verbindung. Das Geheimnis liegt verschlüsselt in der Datenbank. Fällt authentik aus, melden sich Mitarbeiter weiter mit E-Mail und Passwort an. Jede Anmeldung steht im Admin-Protokoll.</p>
</section>
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
// „Mein authentik-Konto“: jeder Mitarbeiter verknüpft sein DeskHub-Konto selbst mit seinem authentik-Konto.
$staffId = (int) ($staff['id'] ?? 0);
$message = '';
$messageType = 'success';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
if (($_POST['action'] ?? '') === 'unlink' && authentik_link_of($staffId) !== null) {
authentik_link_remove($staffId);
audit_log($staffId, 'authentik: Verknüpfung gelöst');
$message = 'Die Verknüpfung ist gelöst. Mit authentik melden Sie sich erst wieder an, wenn Sie es neu verknüpfen (oder, falls erlaubt, über die E-Mail-Adresse).';
}
}
$ready = authentik_ready();
$link = authentik_link_of($staffId);
$onlyLinked = plugin_setting(AUTHENTIK, 'only_linked', '0') === '1';
?>
<h1>Mein authentik-Konto</h1>
<?php if ($message !== ''): ?><div class="flash <?= $messageType === 'error' ? 'error' : 'success' ?>" role="status"><?= e($message) ?></div><?php endif; ?>
<section class="card" style="max-width:760px">
<?php if (!$ready): ?>
<p>Die Anmeldung mit authentik ist nicht eingerichtet. Ein Administrator kann sie unter „authentik“ einschalten.</p>
<?php elseif ($link): ?>
<p><strong style="color:#157a45">✓ Verknüpft.</strong> Ihr DeskHub-Konto gehört zum authentik-Konto <strong><?= e((string) ($link['email'] ?? '')) ?></strong> (seit <?= e(format_dt((string) ($link['at'] ?? ''))) ?>).</p>
<p class="muted" style="font-size:.9rem">Mit „<?= e(authentik_config()['label']) ?>“ auf der Anmeldeseite kommen Sie damit direkt in dieses Konto, auch wenn sich die E-Mail-Adresse in authentik oder DeskHub später ändert. Ein anderes authentik-Konto kann dieses DeskHub-Konto nicht öffnen.</p>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<a class="btn secondary" href="<?= e(plugin_login_url(AUTHENTIK, 'link')) ?>">Mit einem anderen authentik-Konto neu verknüpfen</a>
<form method="post" data-confirm="Verknüpfung wirklich lösen?" onsubmit="return confirm(this.dataset.confirm)"><?= csrf_field() ?><input type="hidden" name="action" value="unlink"><button class="btn secondary" type="submit">Verknüpfung lösen</button></form>
</div>
<?php else: ?>
<p><strong style="color:#b45309">Noch nicht verknüpft.</strong> Verknüpfen Sie Ihr DeskHub-Konto einmal mit Ihrem authentik-Konto. Sie melden sich dazu kurz bei authentik an und kommen hierher zurück.</p>
<p class="muted" style="font-size:.9rem">Danach führt „<?= e(authentik_config()['label']) ?>“ auf der Anmeldeseite genau in dieses Konto. <?= $onlyLinked ? 'Ohne Verknüpfung ist die Anmeldung mit authentik hier nicht möglich.' : 'Auch ohne Verknüpfung klappt die Anmeldung, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist; dabei wird sie dann automatisch verknüpft.' ?></p>
<a class="btn" href="<?= e(plugin_login_url(AUTHENTIK, 'link')) ?>">Mit authentik verknüpfen</a>
<?php endif; ?>
</section>
+14
View File
@@ -0,0 +1,14 @@
{
"slug": "authentik",
"name": "authentik-Anmeldung (OpenID Connect)",
"version": "1.2.4",
"description": "Mitarbeiter und Kunden melden sich mit authentik an (OpenID Connect, mit PKCE): Knopf „Mit authentik anmelden“ auf der Anmeldeseite des Mitarbeiterbereichs und, wenn eingeschaltet, des Kundenportals. Mitarbeiter: Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein aktives, verknüpftes Mitarbeiterkonto (oder eines mit derselben E-Mail-Adresse) hat; sie verknüpfen ihr Konto selbst unter „Mein authentik-Konto“. Kunden (Ticketersteller): Die bestätigte E-Mail-Adresse muss passen, das Konto wird bei Bedarf automatisch angelegt. Optional nur Mitglieder bestimmter authentik-Gruppen. E-Mail und Passwort bleiben als Zugang erhalten.",
"author": "DeskHub",
"entry": "plugin.php",
"requires": "1.0.52",
"permissions": [
"ui.menu",
"auth.login",
"auth.customer"
]
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
// authentik-Anmeldung: Knopf auf der Anmeldeseite, Start und Rückkehr des OpenID-Connect-Ablaufs, Einstellungsseite für Administratoren.
if (!function_exists('plugin_register_login')) {
return; // älterer DeskHub-Server ohne Anmelde-Erweiterung (Server 1.0.51 oder neuer nötig)
}
require_once __DIR__ . '/lib.php';
/** Kleines Symbol vor der Beschriftung des Anmelde-Knopfes (icon.svg, als Daten-Adresse: braucht keine eigene Datei-Adresse). */
function authentik_button_icon(): string
{
static $html = null;
if ($html === null) {
$svg = @file_get_contents(__DIR__ . '/icon.svg');
$html = $svg ? '<img src="data:image/svg+xml;base64,' . base64_encode($svg) . '" alt="" width="20" height="20" style="vertical-align:-5px;margin-right:8px;border-radius:5px">' : '';
}
return $html;
}
plugin_register_menu('authentik', 'einstellungen', null, 'admin');
plugin_register_menu('Mein authentik-Konto', 'konto'); // für alle Mitarbeiter: das eigene Konto verknüpfen
plugin_register_login('start', 'authentik_login_start');
plugin_register_login('link', 'authentik_link_start', true);
plugin_register_login('callback', 'authentik_login_callback', true); // gleiche Rückkehr-Adresse für Anmelden und Verknüpfen
if (function_exists('plugin_register_customer_login')) { // Server 1.0.52 oder neuer
plugin_register_customer_login('start', 'authentik_customer_start');
plugin_add_filter('customer.login.buttons', function ($html) {
if (!authentik_customer_ready()) return $html;
$cfg = authentik_config();
return $html . '<a class="btn secondary" style="display:block;text-align:center" href="' . e(plugin_customer_login_url(AUTHENTIK, 'start')) . '">' . authentik_button_icon() . e($cfg['label']) . '</a>';
});
}
plugin_add_filter('staff.login.buttons', function ($html) {
if (!authentik_ready()) return $html;
$cfg = authentik_config();
return $html . '<a class="btn secondary" style="display:block;text-align:center" href="' . e(plugin_login_url(AUTHENTIK, 'start')) . '">' . authentik_button_icon() . e($cfg['label']) . '</a>';
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
{
"_info": "English translations for this plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number.",
"Ab Uhrzeit (Serverzeit)": "From time (server time)",
"Kein Bericht gesendet: Entweder gibt es nichts zu berichten oder der Mailversand ist nicht eingerichtet.": "No report sent: either there is nothing to report or mail sending is not set up.",
"Nur an Werktagen (Mo–Fr)": "Weekdays only (Mon–Fri)",
"Ohne Auswahl erhalten alle aktiven Mitarbeiter den Bericht. Jeder sieht nur Tickets seiner Abteilungen.": "Without a selection all active staff receive the report. Everyone sees only tickets of their own departments.",
"Schickt dir den Bericht sofort zum Ausprobieren (an": "Sends you the report right away to try it out (to",
"Schickt dir den Bericht sofort zum Ausprobieren (an {}).": "Sends you the report right away to try it out (to {}).",
"Sendet Mitarbeitern täglich eine Zusammenfassung: offene, unzugewiesene und überfällige Tickets, Zugänge und Erledigungen der letzten 24 Stunden sowie die ältesten offenen Tickets. Jeder sieht nur seine Abteilungen.": "Sends staff a daily summary: open, unassigned and overdue tickets, new arrivals and completions of the last 24 hours, and the oldest open tickets. Everyone sees only their own departments.",
"Tagesbericht": "Daily report",
"Tagesbericht per E-Mail": "Daily report by email",
"Testbericht an mich senden": "Send test report to me",
"Täglichen Bericht per E-Mail senden": "Send daily report by email",
"Zuletzt gesendet: noch nie": "Last sent: never",
"Täglich": "Daily",
"Einmal pro Woche": "Once a week",
"Wie eingestellt (Standard)": "As configured (default)",
"Nur wenn Tickets überfällig sind": "Only when tickets are overdue",
"Nur einmal pro Woche (montags)": "Only once a week (Mondays)",
"Gar keinen Tagesbericht mehr": "No daily report at all any more",
"Bericht per E-Mail senden": "Send report by email",
"Rhythmus": "Frequency",
"Wochentag (bei „Einmal pro Woche“)": "Weekday (for “Once a week”)",
"Nur senden, wenn es etwas zu tun gibt (unzugewiesene oder überfällige Tickets)": "Only send if there is something to do (unassigned or overdue tickets)",
"Ohne Haken kommt der Bericht auch, wenn nur normale offene Tickets da sind.": "Without the tick the report is also sent when there are only ordinary open tickets.",
"Ohne Auswahl erhalten alle aktiven Mitarbeiter den Bericht. Jeder sieht nur Tickets seiner Abteilungen. Jeder Mitarbeiter kann unter „Mein Tagesbericht“ selbst wählen, wie oft er ihn bekommt (auch gar nicht).": "Without a selection all active staff receive the report. Everyone sees only tickets of their own departments. Each staff member can choose under “My daily report” how often they get it (including never).",
"{} Mitarbeiter haben den Bericht persönlich abgestellt.": "{} staff members have switched the report off for themselves.",
"Mein Tagesbericht": "My daily report",
"Gespeichert.": "Saved.",
"Bitte eine Auswahl treffen.": "Please make a selection.",
"Der Tagesbericht ist zurzeit für niemanden eingeschaltet. Deine Wahl gilt, sobald ein Administrator ihn aktiviert.": "The daily report is currently switched off for everyone. Your choice applies as soon as an administrator activates it.",
"Hier legst du fest, wie oft du den Bericht bekommst.": "Here you decide how often you get the report.",
"Sendet Mitarbeitern regelmäßig eine Zusammenfassung: offene, unzugewiesene und überfällige Tickets, Zugänge und Erledigungen der letzten 24 Stunden sowie die ältesten offenen Tickets. Rhythmus einstellbar (täglich, Werktage, wöchentlich), auf Wunsch nur bei Handlungsbedarf; jeder Mitarbeiter kann seine Mails selbst reduzieren oder abstellen. Jeder sieht nur seine Abteilungen.": "Sends staff a regular summary: open, unassigned and overdue tickets, new arrivals and completions of the last 24 hours, and the oldest open tickets. Frequency can be set (daily, weekdays, weekly), optionally only when action is needed; every staff member can reduce or switch off their own emails. Everyone sees only their own departments."
}
+189
View File
@@ -0,0 +1,189 @@
<?php
/** Logik des Tagesbericht-Plugins (ohne Seiten-/Hook-Code, damit sie einzeln testbar ist). */
const TAGESBERICHT = 'tagesbericht';
/** Rhythmen des Berichts: täglich, nur Werktage, einmal pro Woche. */
function tagesbericht_rhythms(): array
{
return ['daily' => 'Täglich', 'workdays' => 'Nur an Werktagen (Mo–Fr)', 'weekly' => 'Einmal pro Woche'];
}
/** Persönliche Wahl jedes Mitarbeiters; „default“ = so, wie der Administrator es eingestellt hat. */
function tagesbericht_pref_labels(): array
{
return [
'default' => 'Wie eingestellt (Standard)',
'urgent' => 'Nur wenn Tickets überfällig sind',
'weekly' => 'Nur einmal pro Woche (montags)',
'off' => 'Gar keinen Tagesbericht mehr',
];
}
function tagesbericht_config(): array
{
// Ältere Installationen kennen nur „Werktage ja/nein“: daraus wird der Rhythmus abgeleitet
$legacyWorkdays = plugin_setting(TAGESBERICHT, 'workdays', '1') === '1';
$rhythm = (string) plugin_setting(TAGESBERICHT, 'rhythm', null);
if ($rhythm === '') $rhythm = $legacyWorkdays ? 'workdays' : 'daily'; // noch nie gewählt: aus der älteren Einstellung ableiten
if (!isset(tagesbericht_rhythms()[$rhythm])) $rhythm = 'workdays';
return [
'enabled' => plugin_setting(TAGESBERICHT, 'enabled', '0') === '1',
'hour' => max(0, min(23, (int) plugin_setting(TAGESBERICHT, 'hour', '7'))),
'rhythm' => $rhythm,
'weekday' => max(1, min(7, (int) plugin_setting(TAGESBERICHT, 'weekday', '1'))),
'workdays' => $rhythm === 'workdays',
// Nur senden, wenn es etwas zu tun gibt (unzugewiesene oder überfällige Tickets); sonst bleibt das Postfach leer
'only_action' => plugin_setting(TAGESBERICHT, 'only_action', '1') === '1',
// Leer = alle aktiven Mitarbeiter, sonst Liste von Mitarbeiter-IDs
'recipients' => array_values(array_filter(array_map('intval', explode(',', (string) plugin_setting(TAGESBERICHT, 'recipients', ''))))),
'last_sent' => plugin_setting(TAGESBERICHT, 'last_sent', ''),
];
}
/** Persönliche Wahl eines Mitarbeiters (default, urgent, weekly, off). */
function tagesbericht_pref(int $staffId): string
{
$v = (string) plugin_setting(TAGESBERICHT, 'pref.' . $staffId, 'default');
return isset(tagesbericht_pref_labels()[$v]) ? $v : 'default';
}
function tagesbericht_set_pref(int $staffId, string $value): bool
{
if (!isset(tagesbericht_pref_labels()[$value])) return false;
plugin_set_setting(TAGESBERICHT, 'pref.' . $staffId, $value === 'default' ? null : $value);
return true;
}
/** Empfänger laut Einstellung (nur aktive Mitarbeiter mit E-Mail-Adresse). */
function tagesbericht_recipients(PDO $pdo, array $ids): array
{
$rows = $pdo->query("SELECT * FROM staff WHERE is_active = 1 AND email <> ''")->fetchAll();
return array_values(array_filter($rows, fn($s) => !$ids || in_array((int) $s['id'], $ids, true)));
}
/** Zahlen und Listen für einen Mitarbeiter; berücksichtigt dessen Abteilungen. */
function tagesbericht_data(PDO $pdo, array $staff, ?string $now = null): array
{
$now = $now ?? db_now();
$since = date('Y-m-d H:i:s', strtotime($now) - 86400);
[$dept, $deptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
$open = "t.status NOT IN ('resolved','closed') AND t.is_spam = 0";
$count = function (string $where, array $params = []) use ($pdo, $dept, $deptParams): int {
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets t WHERE {$where}{$dept}");
$stmt->execute(array_merge($params, $deptParams));
return (int) $stmt->fetchColumn();
};
$oldest = $pdo->prepare("SELECT t.ticket_number, t.subject, t.created_at, t.resolution_due_at, d.name AS department
FROM tickets t LEFT JOIN departments d ON d.id = t.department_id
WHERE {$open}{$dept} ORDER BY t.created_at ASC LIMIT 5");
$oldest->execute($deptParams);
return [
'open' => $count($open),
'unassigned' => $count($open . ' AND t.assigned_staff_id IS NULL'),
'mine' => $count($open . ' AND t.assigned_staff_id = ?', [(int) $staff['id']]),
'overdue' => $count($open . ' AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ?', [$now]),
'created' => $count('t.is_spam = 0 AND t.created_at >= ?', [$since]),
'resolved' => $count('t.is_spam = 0 AND t.resolved_at IS NOT NULL AND t.resolved_at >= ?', [$since]),
'oldest' => $oldest->fetchAll(),
];
}
function tagesbericht_html(array $staff, array $d): string
{
$card = function (string $label, int $value, string $color) {
return '<td style="padding:6px"><div style="background:#f5f7f9;border-radius:8px;padding:12px;text-align:center">'
. '<div style="font-size:24px;font-weight:700;color:' . $color . '">' . $value . '</div>'
. '<div style="font-size:12px;color:#64748b">' . e($label) . '</div></div></td>';
};
$rows = '';
foreach ($d['oldest'] as $t) {
$late = $t['resolution_due_at'] && $t['resolution_due_at'] < db_now();
$rows .= '<tr><td style="padding:6px 0;border-top:1px solid #e2e8f0;font-size:13px"><strong>' . e($t['ticket_number']) . '</strong> '
. e(mb_strimwidth((string) $t['subject'], 0, 60, '…')) . '<br><span style="color:#64748b;font-size:12px">'
. e((string) $t['department']) . ' · seit ' . e(date('d.m.Y', strtotime($t['created_at'])))
. ($late ? ' · <span style="color:#dc2626">überfällig</span>' : '') . '</span></td></tr>';
}
$inner = email_p('Guten Morgen ' . e(explode(' ', trim((string) $staff['name']))[0]) . ', hier ist deine Ticket-Übersicht:')
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>'
. $card('Offen', $d['open'], '#0f6f6a') . $card('Unzugewiesen', $d['unassigned'], '#d97706') . $card('Überfällig', $d['overdue'], '#dc2626')
. '</tr><tr>'
. $card('Mir zugewiesen', $d['mine'], '#2563eb') . $card('Neu (24 h)', $d['created'], '#475569') . $card('Erledigt (24 h)', $d['resolved'], '#15803d')
. '</tr></table>'
. ($rows !== '' ? '<p style="margin:20px 0 6px;font-weight:700;font-size:14px">Älteste offene Tickets</p><table role="presentation" width="100%" cellpadding="0" cellspacing="0">' . $rows . '</table>' : '')
. email_button('Zum Ticketsystem', base_url() . '/staff/tickets.php')
. '<p style="margin:18px 0 0;font-size:12px;color:#94a3b8">Zu viele Mails? <a href="' . e(base_url() . '/staff/plugin.php?p=' . TAGESBERICHT . '&page=persoenlich') . '" style="color:#64748b">Hier einstellen, wie oft du diesen Bericht bekommst</a> (auch „gar nicht“).</p>';
return email_wrap($inner);
}
/** Ob der Bericht an diesem Tag laut Rhythmus des Administrators fällig ist. */
function tagesbericht_day_ok(array $cfg, int $time): bool
{
$dow = (int) date('N', $time);
return match ($cfg['rhythm']) {
'weekly' => $dow === $cfg['weekday'],
'workdays' => $dow <= 5,
default => true,
};
}
/**
* Ob dieser Mitarbeiter heute einen Bericht bekommt (ohne Blick auf die Zahlen): persönliche Wahl „gar nicht“ und „nur montags“.
*/
function tagesbericht_pref_allows_today(string $pref, array $cfg, int $time): bool
{
if ($pref === 'off') return false;
if ($pref === 'weekly' && $cfg['rhythm'] !== 'weekly' && (int) date('N', $time) !== 1) return false;
return true;
}
/**
* Ob dieser Bericht gesendet wird, wenn der Tag und die persönliche Wahl es erlauben: nicht bei „nichts zu berichten“, bei
* „nur bei Handlungsbedarf“ nur mit unzugewiesenen oder überfälligen Tickets, bei persönlich „nur Überfälliges“ nur mit überfälligen.
* Ein Testbericht ($force) prüft nur, ob es überhaupt etwas zu berichten gibt.
*/
function tagesbericht_should_send(array $cfg, string $pref, array $d, bool $force = false): bool
{
if ($d['open'] === 0 && $d['created'] === 0 && $d['resolved'] === 0) return false;
if ($force) return true;
if ($cfg['only_action'] && $d['unassigned'] === 0 && $d['overdue'] === 0) return false;
if ($pref === 'urgent' && $d['overdue'] === 0) return false;
return true;
}
/**
* Sendet den Bericht an die Empfänger; liefert die Anzahl erfolgreich versendeter Mails.
* $only = feste Empfänger (Testbericht): Dann gelten weder persönliche Wahl noch „nur bei Handlungsbedarf“.
*/
function tagesbericht_send(PDO $pdo, ?array $only = null, ?int $time = null): int
{
$cfg = tagesbericht_config();
$time = $time ?? time();
$force = $only !== null;
$sent = 0;
foreach ($only ?? tagesbericht_recipients($pdo, $cfg['recipients']) as $staff) {
$pref = $force ? 'default' : tagesbericht_pref((int) $staff['id']);
if (!tagesbericht_pref_allows_today($pref, $cfg, $time)) continue;
$d = tagesbericht_data($pdo, $staff);
if (!tagesbericht_should_send($cfg, $pref, $d, $force)) continue;
$subject = 'Tagesbericht: ' . $d['open'] . ' offen, ' . $d['overdue'] . ' überfällig';
if (send_mail($staff['email'], $subject, tagesbericht_html($staff, $d))) $sent++;
}
return $sent;
}
/** Wird alle paar Minuten aufgerufen: sendet einmal pro Tag ab der eingestellten Uhrzeit (soweit der Rhythmus es erlaubt). */
function tagesbericht_tick(PDO $pdo, ?int $time = null): int
{
$cfg = tagesbericht_config();
$time = $time ?? time();
if (!$cfg['enabled']) return 0;
if ((int) date('G', $time) < $cfg['hour']) return 0;
if (!tagesbericht_day_ok($cfg, $time)) return 0;
$today = date('Y-m-d', $time);
if ($cfg['last_sent'] === $today) return 0;
plugin_set_setting(TAGESBERICHT, 'last_sent', $today); // zuerst merken, damit parallele Aufrufe nicht doppelt senden
return tagesbericht_send($pdo, null, $time);
}
@@ -0,0 +1,70 @@
<?php
// Einstellungsseite des Tagesbericht-Plugins. Verfügbar: $staff, $pdo.
$message = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'save') {
plugin_set_setting(TAGESBERICHT, 'enabled', !empty($_POST['enabled']) ? '1' : '0');
plugin_set_setting(TAGESBERICHT, 'hour', (string) max(0, min(23, (int) ($_POST['hour'] ?? 7))));
$rhythm = isset(tagesbericht_rhythms()[$_POST['rhythm'] ?? '']) ? (string) $_POST['rhythm'] : 'workdays';
plugin_set_setting(TAGESBERICHT, 'rhythm', $rhythm);
plugin_set_setting(TAGESBERICHT, 'workdays', $rhythm === 'workdays' ? '1' : '0');
plugin_set_setting(TAGESBERICHT, 'weekday', (string) max(1, min(7, (int) ($_POST['weekday'] ?? 1))));
plugin_set_setting(TAGESBERICHT, 'only_action', !empty($_POST['only_action']) ? '1' : '0');
$ids = array_map('intval', (array) ($_POST['recipients'] ?? []));
plugin_set_setting(TAGESBERICHT, 'recipients', implode(',', array_filter($ids)));
$message = 'Einstellungen gespeichert.';
} elseif ($action === 'test') {
$n = tagesbericht_send($pdo, [$staff]);
if ($n > 0) $message = 'Testbericht an ' . $staff['email'] . ' gesendet.';
else $error = 'Kein Bericht gesendet: Entweder gibt es nichts zu berichten oder der Mailversand ist nicht eingerichtet.';
}
}
$cfg = tagesbericht_config();
$allStaff = $pdo->query("SELECT id, name, email FROM staff WHERE is_active = 1 ORDER BY name")->fetchAll();
?>
<h1>Tagesbericht</h1>
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
<form method="post" class="card" style="max-width:640px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<label><input type="checkbox" name="enabled" value="1" <?= $cfg['enabled'] ? 'checked' : '' ?>> Bericht per E-Mail senden</label>
<label for="rhythm">Rhythmus</label>
<select id="rhythm" name="rhythm" style="max-width:260px">
<?php foreach (tagesbericht_rhythms() as $k => $l): ?><option value="<?= e($k) ?>" <?= $cfg['rhythm'] === $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?>
</select>
<label for="weekday">Wochentag (bei „Einmal pro Woche“)</label>
<select id="weekday" name="weekday" style="max-width:200px">
<?php foreach ([1 => 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag', 5 => 'Freitag', 6 => 'Samstag', 7 => 'Sonntag'] as $k => $l): ?><option value="<?= $k ?>" <?= $cfg['weekday'] === $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?>
</select>
<label for="hour">Ab Uhrzeit (Serverzeit)</label>
<select id="hour" name="hour" style="max-width:140px">
<?php for ($h = 0; $h < 24; $h++): ?><option value="<?= $h ?>" <?= $cfg['hour'] === $h ? 'selected' : '' ?>><?= sprintf('%02d:00', $h) ?></option><?php endfor; ?>
</select>
<label style="margin-top:10px"><input type="checkbox" name="only_action" value="1" <?= $cfg['only_action'] ? 'checked' : '' ?>> Nur senden, wenn es etwas zu tun gibt (unzugewiesene oder überfällige Tickets)</label>
<p class="muted" style="font-size:.82rem;margin:2px 0 0 24px">Ohne Haken kommt der Bericht auch, wenn nur normale offene Tickets da sind.</p>
<h3>Empfänger</h3>
<p class="muted" style="font-size:.85rem">Ohne Auswahl erhalten alle aktiven Mitarbeiter den Bericht. Jeder sieht nur Tickets seiner Abteilungen. Jeder Mitarbeiter kann unter „Mein Tagesbericht“ selbst wählen, wie oft er ihn bekommt (auch gar nicht).</p>
<?php foreach ($allStaff as $s): ?>
<label style="display:block"><input type="checkbox" name="recipients[]" value="<?= (int) $s['id'] ?>" <?= in_array((int) $s['id'], $cfg['recipients'], true) ? 'checked' : '' ?>> <?= e($s['name']) ?> <span class="muted">(<?= e($s['email']) ?>)</span></label>
<?php endforeach; ?>
<?php $offCount = 0; foreach ($allStaff as $s) if (tagesbericht_pref((int) $s['id']) === 'off') $offCount++; ?>
<?php if ($offCount): ?><p class="muted" style="font-size:.82rem;margin-top:10px"><?= $offCount ?> Mitarbeiter haben den Bericht persönlich abgestellt.</p><?php endif; ?>
<p class="muted" style="font-size:.82rem;margin-top:14px">Zuletzt gesendet: <?= e($cfg['last_sent'] !== '' ? $cfg['last_sent'] : 'noch nie') ?></p>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
<form method="post" class="card" style="max-width:640px;margin-top:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="test">
<p style="margin-top:0">Schickt dir den Bericht sofort zum Ausprobieren (an <?= e($staff['email']) ?>).</p>
<button type="submit" class="btn secondary">Testbericht an mich senden</button>
</form>
@@ -0,0 +1,52 @@
<?php
// Persönliche Einstellung des Tagesberichts. Verfügbar: $staff, $pdo. Jeder Mitarbeiter darf nur seine eigene Wahl ändern.
$message = null;
$error = null;
$myId = (int) $staff['id'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'save') {
if (tagesbericht_set_pref($myId, (string) ($_POST['pref'] ?? ''))) $message = 'Gespeichert.';
else $error = 'Bitte eine Auswahl treffen.';
} elseif ($action === 'test') {
$n = tagesbericht_send($pdo, [$staff]);
if ($n > 0) $message = 'Testbericht an ' . $staff['email'] . ' gesendet.';
else $error = 'Kein Bericht gesendet: Entweder gibt es nichts zu berichten oder der Mailversand ist nicht eingerichtet.';
}
}
$cfg = tagesbericht_config();
$current = tagesbericht_pref($myId);
$rhythms = tagesbericht_rhythms();
$days = [1 => 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag', 5 => 'Freitag', 6 => 'Samstag', 7 => 'Sonntag'];
?>
<h1>Mein Tagesbericht</h1>
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
<?php if (!$cfg['enabled']): ?>
<div class="flash info">Der Tagesbericht ist zurzeit für niemanden eingeschaltet. Deine Wahl gilt, sobald ein Administrator ihn aktiviert.</div>
<?php endif; ?>
<form method="post" class="card" style="max-width:640px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<p class="muted" style="margin-top:0;font-size:.88rem">
Der Administrator hat eingestellt: <strong><?= e($rhythms[$cfg['rhythm']]) ?><?= $cfg['rhythm'] === 'weekly' ? ' (' . e($days[$cfg['weekday']]) . ')' : '' ?></strong>
ab <?= sprintf('%02d:00', $cfg['hour']) ?> Uhr (Serverzeit)<?= $cfg['only_action'] ? ', nur wenn es unzugewiesene oder überfällige Tickets gibt' : '' ?>.
Hier legst du fest, wie oft <em>du</em> den Bericht bekommst.
</p>
<?php foreach (tagesbericht_pref_labels() as $value => $label): ?>
<label style="display:block;font-weight:400;margin:6px 0"><input type="radio" name="pref" value="<?= e($value) ?>" style="width:auto" <?= $current === $value ? 'checked' : '' ?>> <?= e($label) ?></label>
<?php endforeach; ?>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
<form method="post" class="card" style="max-width:640px;margin-top:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="test">
<p style="margin-top:0">Schickt dir den Bericht sofort zum Ausprobieren (an <?= e($staff['email']) ?>).</p>
<button type="submit" class="btn secondary">Testbericht an mich senden</button>
</form>
+9
View File
@@ -0,0 +1,9 @@
{
"slug": "tagesbericht",
"name": "Tagesbericht per E-Mail",
"version": "1.1.0",
"description": "Sendet Mitarbeitern regelmäßig eine Zusammenfassung: offene, unzugewiesene und überfällige Tickets, Zugänge und Erledigungen der letzten 24 Stunden sowie die ältesten offenen Tickets. Rhythmus einstellbar (täglich, Werktage, wöchentlich), auf Wunsch nur bei Handlungsbedarf; jeder Mitarbeiter kann seine Mails selbst reduzieren oder abstellen. Jeder sieht nur seine Abteilungen.",
"author": "DeskHub",
"requires": "1.0.0",
"permissions": ["hooks.cron", "ui.menu"]
}
+12
View File
@@ -0,0 +1,12 @@
<?php
// Tagesbericht-Plugin: sendet Mitarbeitern regelmäßig eine Ticket-Zusammenfassung per E-Mail.
require_once __DIR__ . '/lib.php';
plugin_add_hook('cron.run', function () {
tagesbericht_tick(get_db());
});
// Einstellungen: nur für Admins sichtbar (siehe pages/einstellungen.php)
plugin_register_menu('Tagesbericht', 'einstellungen', 'manage_email_addresses');
// Persönliche Einstellung: jeder Mitarbeiter wählt selbst, wie oft er den Bericht bekommt (siehe pages/persoenlich.php)
plugin_register_menu('Mein Tagesbericht', 'persoenlich');
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
{
"_info": "English translations for this plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number.",
"Abwesenheit": "Absence",
"Abwesenheit eintragen": "Enter absence",
"Abwesenheit und Vertretung": "Absence and substitute",
"An die Vertretung": "To the substitute",
"Bis (einschließlich)": "Until (inclusive)",
"Bitte Beginn und Ende als Datum angeben.": "Please enter the start and end as dates.",
"Bitte eine aktive Vertretung wählen.": "Please choose an active substitute.",
"Das Ende darf nicht vor dem Beginn liegen.": "The end must not be before the start.",
"Der Zeitraum liegt schon in der Vergangenheit.": "The period is already in the past.",
"Die Abwesenheit ist eingetragen.": "The absence has been entered.",
"Die Abwesenheit wurde beendet, offene Tickets sind wieder bei der ursprünglichen Person.": "The absence has ended; open tickets are back with the original person.",
"Die Vertretung muss eine andere Person sein.": "The substitute must be a different person.",
"Diese Abwesenheit kann nicht beendet werden.": "This absence cannot be ended.",
"Dieser Mitarbeiter ist nicht aktiv.": "This staff member is not active.",
"Eintragen": "Enter",
"In diesem Zeitraum ist für diese Person schon eine Abwesenheit eingetragen.": "An absence has already been entered for this person in this period.",
"Keine Abwesenheiten eingetragen.": "No absences entered.",
"Laufend und geplant": "Ongoing and planned",
"Mitarbeiter tragen Abwesenheiten mit Vertretung ein. Zum Beginn werden ihre offenen Tickets an die Vertretung verteilt, neue Tickets gehen automatisch dorthin, und am Ende bekommt der Mitarbeiter seine noch offenen Tickets zurück. Baut auf dem Urlaubsstatus im Profil und auf der automatischen Zuweisung der Abteilungen auf.": "Staff enter absences with a substitute. At the start their open tickets are distributed to the substitute, new tickets go there automatically, and at the end the staff member gets back their still-open tickets. Builds on the vacation status in the profile and on the automatic assignment of the departments.",
"Nicht angetreten": "Not started",
"Urlaub und Vertretung": "Vacation and substitute",
"Vertretung": "Substitute",
"Vertretung für": "Substitute for",
"Wer ist abwesend?": "Who is absent?",
"zum Beispiel Sommerurlaub": "for example summer vacation",
"– wählen –": "– choose –",
"Die Vertretung sieht": "The substitute sees",
"Abteilungen der übernommenen Tickets": "Departments of the tickets taken over",
"Dieser Server ist älter als 1.0.55: Die Vertretung bekommt die Tickets, sieht sie aber nur, wenn sie auch Zugang zur Abteilung hat. Bitte den Server aktualisieren.": "This server is older than 1.0.55: the substitute receives the tickets but only sees them with access to the department. Please update the server.",
"Zum Beginn gehen Ihre offenen Tickets an die Vertretung, neue Tickets ebenfalls (auch bei automatischer Zuweisung). Am Ende bekommen Sie die Tickets zurück, die dann noch bei der Vertretung liegen. Der Urlaubsstatus im Profil wird dafür automatisch gesetzt. Solange die Abwesenheit dauert, sieht und bearbeitet die Vertretung auch die Abteilungen der abwesenden Person, selbst wenn sie dort sonst keinen Zugang hat (Server ab 1.0.55).": "At the start your open tickets go to the substitute, as do new tickets (also with automatic assignment). At the end you get back the tickets that are still with the substitute. The vacation status in the profile is set automatically for this. While the absence lasts, the substitute also sees and handles the absent person’s departments, even without their own access (server 1.0.55 or later)."
}
+219
View File
@@ -0,0 +1,219 @@
<?php
// Urlaub und Vertretung. Alle Funktionen beginnen mit vertretung_.
// Baut auf dem Urlaubsstatus im Profil auf (staff.is_on_vacation / vacation_substitute_id): Die automatische Zuweisung der Abteilungen
// und der E-Mail-Abruf beachten ihn bereits. Dieses Plugin setzt ihn zum Beginn und nimmt ihn zum Ende zurück.
const VERTRETUNG = 'vertretung';
function vertretung_today(): string
{
return (string) ($GLOBALS['vertretung_today'] ?? date('Y-m-d'));
}
function vertretung_status_labels(): array
{
return ['planned' => 'Geplant', 'active' => 'Läuft', 'done' => 'Beendet', 'cancelled' => 'Abgebrochen', 'missed' => 'Nicht angetreten'];
}
function vertretung_staff(PDO $pdo, int $id): ?array
{
$s = $pdo->prepare('SELECT id, name, email, is_active, is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?');
$s->execute([$id]);
return $s->fetch() ?: null;
}
function vertretung_name(PDO $pdo, int $id): string
{
return (string) (vertretung_staff($pdo, $id)['name'] ?? ('#' . $id));
}
function vertretung_can_manage(array $actor, int $staffId): bool
{
return ($actor['role'] ?? '') === 'admin' || (int) ($actor['id'] ?? 0) === $staffId;
}
function vertretung_mail(PDO $pdo, int $staffId, string $subject, string $html): void
{
$st = vertretung_staff($pdo, $staffId);
if (!$st || !(int) $st['is_active'] || trim((string) $st['email']) === '') return;
try {
send_mail((string) $st['email'], $subject, $html);
} catch (Throwable $e) {
error_log('vertretung mail: ' . $e->getMessage());
}
}
/**
* Wer arbeitet an Stelle von $staffId? Folgt Vertretungsketten (höchstens drei Stufen, ohne Schleifen). null, wenn die Person
* anwesend ist oder keine nutzbare Vertretung hat.
*/
function vertretung_target(PDO $pdo, int $staffId): ?int
{
$row = vertretung_staff($pdo, $staffId);
if (!$row || !(int) $row['is_on_vacation']) return null;
$seen = [$staffId];
for ($i = 0; $i < 3; $i++) {
$sub = (int) ($row['vacation_substitute_id'] ?? 0);
if ($sub <= 0 || in_array($sub, $seen, true)) return null;
$row = vertretung_staff($pdo, $sub);
if (!$row || !(int) $row['is_active']) return null;
if (!(int) $row['is_on_vacation']) return $sub;
$seen[] = $sub;
}
return null;
}
/** Gibt ein Ticket an $to weiter und merkt es für die Rückgabe. */
function vertretung_move(PDO $pdo, int $ticketId, int $from, int $to, int $absenceId): void
{
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$to, $ticketId]);
log_activity($pdo, $ticketId, null, 'An die Vertretung ' . vertretung_name($pdo, $to) . ' weitergegeben (' . vertretung_name($pdo, $from) . ' ist abwesend)');
$pdo->prepare('INSERT INTO plugin_vertretung_moves (absence_id, ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?,?)')
->execute([$absenceId, $ticketId, $from, $to, db_now()]);
}
/** Hook: Ticket gehört einer abwesenden Person → an die Vertretung. */
function vertretung_redirect(PDO $pdo, int $ticketId): void
{
$t = $pdo->prepare('SELECT assigned_staff_id, status, is_spam FROM tickets WHERE id = ?');
$t->execute([$ticketId]);
$ticket = $t->fetch();
if (!$ticket || !$ticket['assigned_staff_id'] || !empty($ticket['is_spam']) || in_array($ticket['status'], ['resolved', 'closed'], true)) return;
$from = (int) $ticket['assigned_staff_id'];
$to = vertretung_target($pdo, $from);
if ($to === null || $to === $from) return;
$a = $pdo->prepare("SELECT id FROM plugin_vertretung_absences WHERE staff_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1");
$a->execute([$from]);
vertretung_move($pdo, $ticketId, $from, $to, (int) $a->fetchColumn());
}
/** Prüft Eingaben für eine neue Abwesenheit. Liefert eine Fehlermeldung oder null. */
function vertretung_validate(PDO $pdo, int $staffId, int $subId, string $start, string $end): ?string
{
$okDate = static fn(string $d) => preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $d, $m) === 1 && checkdate((int) $m[2], (int) $m[3], (int) $m[1]);
if (!$okDate($start) || !$okDate($end)) return 'Bitte Beginn und Ende als Datum angeben.';
if ($end < $start) return 'Das Ende darf nicht vor dem Beginn liegen.';
if ($end < vertretung_today()) return 'Der Zeitraum liegt schon in der Vergangenheit.';
$st = vertretung_staff($pdo, $staffId);
if (!$st || !(int) $st['is_active']) return 'Dieser Mitarbeiter ist nicht aktiv.';
$sub = vertretung_staff($pdo, $subId);
if (!$sub || !(int) $sub['is_active']) return 'Bitte eine aktive Vertretung wählen.';
if ($subId === $staffId) return 'Die Vertretung muss eine andere Person sein.';
$o = $pdo->prepare("SELECT COUNT(*) FROM plugin_vertretung_absences WHERE staff_id = ? AND status IN ('planned','active') AND start_date <= ? AND end_date >= ?");
$o->execute([$staffId, $end, $start]);
if ((int) $o->fetchColumn() > 0) return 'In diesem Zeitraum ist für diese Person schon eine Abwesenheit eingetragen.';
return null;
}
/** @return array{ok:bool,error?:string,id?:int} */
function vertretung_create(PDO $pdo, array $actor, int $staffId, int $subId, string $start, string $end, string $note): array
{
if (!vertretung_can_manage($actor, $staffId)) return ['ok' => false, 'error' => 'Sie dürfen nur Ihre eigene Abwesenheit eintragen.'];
$err = vertretung_validate($pdo, $staffId, $subId, $start, $end);
if ($err !== null) return ['ok' => false, 'error' => $err];
$pdo->prepare("INSERT INTO plugin_vertretung_absences (staff_id, substitute_id, start_date, end_date, note, status, created_by, created_at) VALUES (?,?,?,?,?,'planned',?,?)")
->execute([$staffId, $subId, $start, $end, mb_substr(trim($note), 0, 250), (int) ($actor['id'] ?? 0) ?: null, db_now()]);
$id = (int) $pdo->lastInsertId();
if ($start <= vertretung_today()) vertretung_tick($pdo); // beginnt sofort
return ['ok' => true, 'id' => $id];
}
function vertretung_cancel(PDO $pdo, array $actor, int $id): bool
{
$s = $pdo->prepare("SELECT * FROM plugin_vertretung_absences WHERE id = ? AND status IN ('planned','active')");
$s->execute([$id]);
$abs = $s->fetch();
if (!$abs || !vertretung_can_manage($actor, (int) $abs['staff_id'])) return false;
if ($abs['status'] === 'active') vertretung_deactivate($pdo, $abs, 'cancelled');
else $pdo->prepare("UPDATE plugin_vertretung_absences SET status = 'cancelled' WHERE id = ?")->execute([$id]);
return true;
}
/** Beginn: Urlaubsstatus setzen und offene Tickets an die Vertretung verteilen. Liefert die Zahl der weitergegebenen Tickets. */
function vertretung_activate(PDO $pdo, array $abs): int
{
$staffId = (int) $abs['staff_id'];
$pdo->prepare("UPDATE plugin_vertretung_absences SET status = 'active' WHERE id = ?")->execute([$abs['id']]);
$pdo->prepare('UPDATE staff SET is_on_vacation = 1, vacation_substitute_id = ? WHERE id = ?')->execute([$abs['substitute_id'], $staffId]);
$to = vertretung_target($pdo, $staffId);
if ($to === null) return 0;
$t = $pdo->prepare("SELECT id, ticket_number, subject FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed') ORDER BY id");
$t->execute([$staffId]);
$tickets = $t->fetchAll();
foreach ($tickets as $row) vertretung_move($pdo, (int) $row['id'], $staffId, $to, (int) $abs['id']);
$name = vertretung_name($pdo, $staffId);
$list = '';
foreach (array_slice($tickets, 0, 15) as $row) $list .= '<li>#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '</li>';
vertretung_mail($pdo, $to, 'Vertretung für ' . $name . ': ' . count($tickets) . ' Tickets', '<p>Sie vertreten <strong>' . e($name) . '</strong> vom ' . e(vertretung_date((string) $abs['start_date'])) . ' bis ' . e(vertretung_date((string) $abs['end_date'])) . '.</p>'
. '<p>' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen, neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen.</p>' . ($list ? '<ul>' . $list . '</ul>' : ''));
return count($tickets);
}
/** Ende oder Abbruch: Urlaubsstatus zurücknehmen und noch offene Tickets zurückgeben. Liefert die Zahl der zurückgegebenen Tickets. */
function vertretung_deactivate(PDO $pdo, array $abs, string $newStatus): int
{
$staffId = (int) $abs['staff_id'];
$pdo->prepare("UPDATE plugin_vertretung_absences SET status = ? WHERE id = ?")->execute([$newStatus, $abs['id']]);
// nur zurücknehmen, was dieses Plugin gesetzt hat (hat jemand die Vertretung im Profil geändert, bleibt es so)
$pdo->prepare('UPDATE staff SET is_on_vacation = 0, vacation_substitute_id = NULL WHERE id = ? AND is_on_vacation = 1 AND vacation_substitute_id = ?')->execute([$staffId, $abs['substitute_id']]);
$m = $pdo->prepare('SELECT m.id, m.ticket_id, m.to_staff_id, t.assigned_staff_id, t.status, t.ticket_number, t.subject FROM plugin_vertretung_moves m JOIN tickets t ON t.id = m.ticket_id WHERE m.absence_id = ? AND m.returned_at IS NULL ORDER BY m.id');
$m->execute([$abs['id']]);
$back = [];
foreach ($m->fetchAll() as $row) {
$stillThere = (int) $row['assigned_staff_id'] === (int) $row['to_staff_id'] && !in_array($row['status'], ['resolved', 'closed'], true);
if ($stillThere) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$staffId, $row['ticket_id']]);
log_activity($pdo, (int) $row['ticket_id'], null, 'Zurück an ' . vertretung_name($pdo, $staffId) . ' (Abwesenheit beendet)');
$back[] = $row;
}
$pdo->prepare('UPDATE plugin_vertretung_moves SET returned_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
}
if ($back) {
$list = '';
foreach (array_slice($back, 0, 15) as $row) $list .= '<li>#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '</li>';
vertretung_mail($pdo, $staffId, 'Willkommen zurück: ' . count($back) . ' Tickets', '<p>Ihre Abwesenheit ist beendet. Diese Tickets sind wieder bei Ihnen:</p><ul>' . $list . '</ul>');
}
return count($back);
}
/** Cron: abgelaufene Abwesenheiten beenden, fällige beginnen. */
function vertretung_tick(PDO $pdo): void
{
$today = vertretung_today();
$q = $pdo->prepare("SELECT * FROM plugin_vertretung_absences WHERE status = 'active' AND end_date < ?");
$q->execute([$today]);
foreach ($q->fetchAll() as $abs) vertretung_deactivate($pdo, $abs, 'done');
$pdo->prepare("UPDATE plugin_vertretung_absences SET status = 'missed' WHERE status = 'planned' AND end_date < ?")->execute([$today]);
$q = $pdo->prepare("SELECT * FROM plugin_vertretung_absences WHERE status = 'planned' AND start_date <= ? AND end_date >= ? ORDER BY start_date, id");
$q->execute([$today, $today]);
foreach ($q->fetchAll() as $abs) vertretung_activate($pdo, $abs);
vertretung_rebalance($pdo);
}
/**
* Ist die erste Vertretung zurück (oder hat sich die Kette geändert), wandern die für die Abwesende weitergegebenen Tickets,
* die noch unberührt bei der bisherigen Vertretung liegen, zur jetzt zuständigen Person.
*/
function vertretung_rebalance(PDO $pdo): void
{
foreach ($pdo->query("SELECT id, staff_id FROM plugin_vertretung_absences WHERE status = 'active'")->fetchAll() as $abs) {
$target = vertretung_target($pdo, (int) $abs['staff_id']);
if ($target === null) continue;
$m = $pdo->prepare('SELECT m.id, m.ticket_id, m.to_staff_id FROM plugin_vertretung_moves m JOIN tickets t ON t.id = m.ticket_id WHERE m.absence_id = ? AND m.returned_at IS NULL AND m.to_staff_id <> ? AND t.assigned_staff_id = m.to_staff_id');
$m->execute([$abs['id'], $target]);
foreach ($m->fetchAll() as $row) {
$t = $pdo->prepare('SELECT status FROM tickets WHERE id = ?');
$t->execute([$row['ticket_id']]);
if (in_array($t->fetchColumn(), ['resolved', 'closed'], true)) continue;
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$target, $row['ticket_id']]);
$pdo->prepare('UPDATE plugin_vertretung_moves SET to_staff_id = ? WHERE id = ?')->execute([$target, $row['id']]);
log_activity($pdo, (int) $row['ticket_id'], null, 'An die Vertretung ' . vertretung_name($pdo, $target) . ' weitergegeben (Vertretung ist zurück)');
}
}
}
function vertretung_date(string $ymd): string
{
$t = strtotime($ymd . ' 12:00:00');
return $t ? date('d.m.Y', $t) : $ymd;
}
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS plugin_vertretung_absences (
id INT AUTO_INCREMENT PRIMARY KEY,
staff_id INT NOT NULL,
substitute_id INT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
note VARCHAR(255) NOT NULL DEFAULT '',
status VARCHAR(12) NOT NULL DEFAULT 'planned',
created_by INT NULL,
created_at DATETIME NOT NULL,
KEY idx_plugin_vertretung_status (status, start_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS plugin_vertretung_moves (
id INT AUTO_INCREMENT PRIMARY KEY,
absence_id INT NOT NULL,
ticket_id INT NOT NULL,
from_staff_id INT NOT NULL,
to_staff_id INT NOT NULL,
created_at DATETIME NOT NULL,
returned_at DATETIME NULL,
KEY idx_plugin_vertretung_moves_absence (absence_id, returned_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4