diff --git a/admin/setup.php b/admin/setup.php
index 9532c4b..1ae1527 100644
--- a/admin/setup.php
+++ b/admin/setup.php
@@ -15,6 +15,7 @@ if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
dol_include_once('/eplan/lib/eplan.lib.php');
+dol_include_once('/eplan/lib/eplan_token.lib.php');
$langs->loadLangs(array('admin', 'eplan@eplan'));
@@ -33,6 +34,16 @@ if ($action == 'save' && $user->admin) {
exit;
}
+if ($action == 'token_rotieren' && $user->admin) {
+ eplanTokenRotieren();
+ setEventMessages($langs->trans("EplanTokenRotiert"), null, 'mesgs');
+ header("Location: ".$_SERVER['PHP_SELF']);
+ exit;
+}
+
+// Token beim ersten Aufruf automatisch erzeugen
+$pwa_token = eplanTokenHolen();
+
$page_name = "EplanSetup";
llxHeader('', $langs->trans($page_name), '', '', 0, 0, '', '', '', 'mod-eplan page-admin-setup');
@@ -83,6 +94,29 @@ print '';
print '';
print '';
+// --- PWA-Token ---
+print '
';
+print '
';
+print '
';
+print '
';
+
print '
';
print '
';
print '| '.$langs->trans("EplanAbout").' |
';
diff --git a/ajax/pwa_api.php b/ajax/pwa_api.php
new file mode 100644
index 0000000..b71e7bb
--- /dev/null
+++ b/ajax/pwa_api.php
@@ -0,0 +1,175 @@
+
+ * GPL v3+
+ *
+ * NOLOGIN-Endpoint für die ElektroPlan-PWA / FastAPI-Backend.
+ * Auth: Header "X-Eplan-Token: " gegen Const EPLAN_PWA_SECRET (hash_equals).
+ *
+ * Actions (?action=...):
+ * - ping → {aktiv: true, version: "1.1.0"}
+ * - auftraege_listen → Liste offener Aufträge (Commande)
+ * - auftrag_details&id= → Einzelner Auftrag mit Positionen
+ * - kunden_suchen&such= → Third-Party-Suche
+ * - dokument_upload (POST) → Plan-PDF an Auftrag/Kunde anhängen
+ */
+
+if (!defined('NOCSRFCHECK')) define('NOCSRFCHECK', '1');
+if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
+if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
+if (!defined('NOREQUIREHTML')) define('NOREQUIREHTML', '1');
+if (!defined('NOREQUIREAJAX')) define('NOREQUIREAJAX', '1');
+if (!defined('NOLOGIN')) define('NOLOGIN', '1');
+
+$res = 0;
+if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
+$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; $tmp2 = realpath(__FILE__); $i = strlen($tmp) - 1; $j = strlen($tmp2) - 1;
+while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { $i--; $j--; }
+if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
+if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
+if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
+if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
+if (!$res) { http_response_code(500); echo json_encode(['error' => 'main.inc.php not found']); exit; }
+
+dol_include_once('/eplan/lib/eplan_token.lib.php');
+
+header('Content-Type: application/json; charset=utf-8');
+header('Cache-Control: no-store');
+
+// --- Auth ---
+$token = eplanTokenAusRequest();
+if (!eplanTokenPruefen($token)) {
+ http_response_code(401);
+ echo json_encode(['error' => 'Unauthorized', 'detail' => 'Ungültiger oder fehlender Token']);
+ exit;
+}
+
+if (!isModEnabled('eplan')) {
+ http_response_code(503);
+ echo json_encode(['error' => 'Eplan-Modul deaktiviert']);
+ exit;
+}
+
+$action = GETPOST('action', 'aZ09');
+
+try {
+ switch ($action) {
+ case 'ping':
+ echo json_encode([
+ 'aktiv' => true,
+ 'modul' => 'eplan',
+ 'version' => '1.2.0',
+ 'dolibarr' => DOL_VERSION,
+ ]);
+ break;
+
+ case 'auftraege_listen':
+ $such = GETPOST('such', 'alphanohtml');
+ $limit = max(1, min(200, (int) GETPOST('limit', 'int') ?: 50));
+ $sql = "SELECT c.rowid as id, c.ref, c.ref_client, c.date_commande, c.total_ttc, c.fk_statut,
+ t.nom as kunde
+ FROM ".MAIN_DB_PREFIX."commande c
+ LEFT JOIN ".MAIN_DB_PREFIX."societe t ON t.rowid = c.fk_soc
+ WHERE c.entity IN (".getEntity('commande').")";
+ if (!empty($such)) {
+ $such_esc = $db->escape($such);
+ $sql .= " AND (c.ref LIKE '%".$such_esc."%'
+ OR c.ref_client LIKE '%".$such_esc."%'
+ OR t.nom LIKE '%".$such_esc."%')";
+ }
+ $sql .= " ORDER BY c.date_commande DESC LIMIT ".((int) $limit);
+ $resql = $db->query($sql);
+ $liste = [];
+ if ($resql) {
+ while ($obj = $db->fetch_object($resql)) {
+ $liste[] = [
+ 'id' => (int) $obj->id,
+ 'ref' => $obj->ref,
+ 'ref_client' => $obj->ref_client,
+ 'kunde' => $obj->kunde,
+ 'status' => (int) $obj->fk_statut,
+ 'datum' => $obj->date_commande,
+ 'total_ttc' => $obj->total_ttc !== null ? (float) $obj->total_ttc : null,
+ ];
+ }
+ $db->free($resql);
+ }
+ echo json_encode($liste);
+ break;
+
+ case 'auftrag_details':
+ $id = (int) GETPOST('id', 'int');
+ if ($id <= 0) { http_response_code(400); echo json_encode(['error' => 'id fehlt']); break; }
+ require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
+ $c = new Commande($db);
+ if ($c->fetch($id) <= 0) { http_response_code(404); echo json_encode(['error' => 'nicht gefunden']); break; }
+ $c->fetch_thirdparty();
+ echo json_encode([
+ 'id' => (int) $c->id,
+ 'ref' => $c->ref,
+ 'ref_client' => $c->ref_client,
+ 'kunde' => $c->thirdparty ? $c->thirdparty->name : null,
+ 'kunde_id' => $c->thirdparty ? (int) $c->thirdparty->id : null,
+ 'datum' => $c->date_commande,
+ 'status' => (int) $c->statut,
+ 'total_ttc' => (float) $c->total_ttc,
+ ]);
+ break;
+
+ case 'kunden_suchen':
+ $such = GETPOST('such', 'alphanohtml');
+ if (empty($such) || strlen($such) < 2) { echo json_encode([]); break; }
+ $such_esc = $db->escape($such);
+ $sql = "SELECT rowid as id, nom, code_client FROM ".MAIN_DB_PREFIX."societe
+ WHERE entity IN (".getEntity('societe').")
+ AND (nom LIKE '%".$such_esc."%' OR code_client LIKE '%".$such_esc."%')
+ ORDER BY nom LIMIT 30";
+ $resql = $db->query($sql);
+ $liste = [];
+ if ($resql) {
+ while ($obj = $db->fetch_object($resql)) {
+ $liste[] = ['id' => (int) $obj->id, 'name' => $obj->nom, 'code' => $obj->code_client];
+ }
+ $db->free($resql);
+ }
+ echo json_encode($liste);
+ break;
+
+ case 'dokument_upload':
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(405); echo json_encode(['error' => 'POST benötigt']); break;
+ }
+ $auftrag_id = (int) GETPOST('auftrag_id', 'int');
+ if ($auftrag_id <= 0) { http_response_code(400); echo json_encode(['error' => 'auftrag_id fehlt']); break; }
+ if (empty($_FILES['datei'])) { http_response_code(400); echo json_encode(['error' => 'Keine Datei']); break; }
+
+ require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
+ require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
+ $c = new Commande($db);
+ if ($c->fetch($auftrag_id) <= 0) { http_response_code(404); echo json_encode(['error' => 'Auftrag nicht gefunden']); break; }
+
+ $ref_sanitiert = dol_sanitizeFileName($c->ref);
+ $ziel_dir = $conf->commande->dir_output.'/'.$ref_sanitiert;
+ dol_mkdir($ziel_dir);
+
+ $dateiname = dol_sanitizeFileName($_FILES['datei']['name']);
+ $ziel = $ziel_dir.'/'.$dateiname;
+ $erfolg = dol_move_uploaded_file($_FILES['datei']['tmp_name'], $ziel, 1, 0, 0);
+ if ($erfolg > 0) {
+ echo json_encode(['erfolg' => true, 'pfad' => $ref_sanitiert.'/'.$dateiname]);
+ } else {
+ http_response_code(500);
+ echo json_encode(['error' => 'Upload fehlgeschlagen', 'code' => $erfolg]);
+ }
+ break;
+
+ default:
+ http_response_code(400);
+ echo json_encode(['error' => 'Unbekannte Action', 'action' => $action]);
+ }
+} catch (Throwable $e) {
+ dol_syslog('eplan/pwa_api: '.$e->getMessage(), LOG_ERR);
+ http_response_code(500);
+ echo json_encode(['error' => 'Server-Fehler', 'detail' => $e->getMessage()]);
+}
+
+$db->close();
diff --git a/langs/de_DE/eplan.lang b/langs/de_DE/eplan.lang
index 98ea193..ef31d83 100644
--- a/langs/de_DE/eplan.lang
+++ b/langs/de_DE/eplan.lang
@@ -8,6 +8,16 @@ EplanRightRead = Aufmaß einsehen
EplanRightWrite = Aufmaß bearbeiten
EplanRightAdmin = Eplan-Einstellungen verwalten
+# PWA-Token
+EplanApiToken = PWA-Anbindung (Token)
+EplanApiTokenDesc = Der Token authentifiziert die ElektroPlan-PWA gegen diesen Dolibarr. Trage URL + Token in den PWA-Einstellungen (⚙) ein.
+EplanApiTokenValue = Token
+EplanApiTokenHint = Einmal anklicken zum Markieren, dann Strg+C zum Kopieren.
+EplanApiEndpoint = API-Endpoint
+EplanTokenRotieren = Neuen Token erzeugen
+EplanTokenRotierenConfirm = Neuen Token erzeugen? Der alte wird sofort ungültig und die PWA muss neu konfiguriert werden.
+EplanTokenRotiert = Neuer Token erzeugt. PWA mit dem neuen Token aktualisieren.
+
EplanSetup = Eplan Einstellungen
EplanPwaUrl = PWA-URL
EplanPwaUrlDesc = Vollständige URL zur ElektroPlan PWA (z.B. https://elektroplan.example.de)
diff --git a/langs/en_US/eplan.lang b/langs/en_US/eplan.lang
index cd35282..cd78687 100644
--- a/langs/en_US/eplan.lang
+++ b/langs/en_US/eplan.lang
@@ -8,6 +8,16 @@ EplanRightRead = View measurement
EplanRightWrite = Edit measurement
EplanRightAdmin = Manage Eplan settings
+# PWA token
+EplanApiToken = PWA connection (token)
+EplanApiTokenDesc = The token authenticates the ElektroPlan PWA against this Dolibarr. Enter URL + token in the PWA settings (gear icon).
+EplanApiTokenValue = Token
+EplanApiTokenHint = Click once to select, then Ctrl+C to copy.
+EplanApiEndpoint = API endpoint
+EplanTokenRotieren = Generate new token
+EplanTokenRotierenConfirm = Generate new token? The old one is immediately invalid and the PWA must be reconfigured.
+EplanTokenRotiert = New token generated. Update the PWA with the new token.
+
EplanSetup = Eplan Settings
EplanPwaUrl = PWA URL
EplanPwaUrlDesc = Full URL to the ElektroPlan PWA (e.g. https://elektroplan.example.com)
diff --git a/lib/eplan_token.lib.php b/lib/eplan_token.lib.php
new file mode 100644
index 0000000..7e5e7f7
--- /dev/null
+++ b/lib/eplan_token.lib.php
@@ -0,0 +1,63 @@
+
+ * GPL v3+
+ *
+ * Token-Handling für die Eplan-PWA-Anbindung.
+ * Kein JWT — ein simpler Shared-Secret-Token reicht, weil nur der
+ * ElektroPlan-Backend-Container den Endpoint anspricht (Server-zu-Server
+ * über HTTPS). Kein User-Kontext, keine Rotation per Request.
+ */
+
+/**
+ * Lazy-init: Token aus llx_const laden, falls leer frisch erzeugen.
+ * @return string 64-Zeichen hex Token
+ */
+function eplanTokenHolen()
+{
+ global $db, $conf;
+
+ $token = getDolGlobalString('EPLAN_PWA_SECRET');
+ if (!empty($token)) {
+ return $token;
+ }
+
+ $token = bin2hex(random_bytes(32));
+ dolibarr_set_const($db, 'EPLAN_PWA_SECRET', $token, 'chaine', 0, 'PWA-Token', $conf->entity);
+ return $token;
+}
+
+/**
+ * Neuen Token erzeugen (überschreibt den alten).
+ * @return string neuer Token
+ */
+function eplanTokenRotieren()
+{
+ global $db, $conf;
+ $token = bin2hex(random_bytes(32));
+ dolibarr_set_const($db, 'EPLAN_PWA_SECRET', $token, 'chaine', 0, 'PWA-Token', $conf->entity);
+ return $token;
+}
+
+/**
+ * Vergleicht einen übergebenen Token mit dem gespeicherten (timing-safe).
+ * @param string $eingang Token aus dem Request
+ * @return bool
+ */
+function eplanTokenPruefen($eingang)
+{
+ if (empty($eingang)) return false;
+ $gespeichert = getDolGlobalString('EPLAN_PWA_SECRET');
+ if (empty($gespeichert)) return false;
+ return hash_equals($gespeichert, (string) $eingang);
+}
+
+/**
+ * Token aus Request holen: bevorzugt Header X-Eplan-Token, sonst Query-Param `token`.
+ * @return string|null
+ */
+function eplanTokenAusRequest()
+{
+ $header = $_SERVER['HTTP_X_EPLAN_TOKEN'] ?? '';
+ if (!empty($header)) return $header;
+ return $_GET['token'] ?? $_POST['token'] ?? null;
+}