Compare commits

..
3 Commits
23 changed files with 2688 additions and 106 deletions
-9
View File
@@ -1,9 +0,0 @@
JWT_SECRET=change-me
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=SE3112
ADMIN_NAME=System Admin
MARIADB_ROOT_PASSWORD=SE3112
DB_PASSWORD_FALLBACK=root
CORS_ORIGIN=*
VITE_API_PORT=18080
+13
View File
@@ -0,0 +1,13 @@
# Copy this file to .env and replace all placeholders before production use.
JWT_SECRET=replace-with-a-long-random-secret
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-a-strong-admin-password
ADMIN_NAME=System Admin
# Existing controllers may require these legacy credentials.
MARIADB_ROOT_PASSWORD=SE3112
MARIADB_PASSWORD=3112
DB_PASSWORD_FALLBACK=root
REDIS_PASSWORD=replace-with-a-long-random-redis-password
CORS_ORIGIN=http://localhost:5173
VITE_API_PORT=18080
+2 -1
View File
@@ -1,7 +1,8 @@
node_modules
node_modules
dist
.DS_Store
npm-debug.log*
.scratch/runtime
data/
web/src
.env
+24 -1
View File
@@ -1,4 +1,4 @@
# SE Local Trenddata
# SE Local Trenddata
SE Local Trenddata ist eine webbasierte Trend-, Dashboard- und Quellenverwaltung fuer lokale Anlagen- und WAGO-Daten. Das Projekt besteht aus einem React-Frontend, einer Node.js/Express-API, MariaDB, Redis und einem Collector-Service fuer Protokollquellen wie Modbus TCP, OPC UA, BACnet IP und KNX IP.
@@ -671,3 +671,26 @@ Vor produktiver Nutzung:
- Fuer vollstaendige Protokollintegration muessen je nach Feldbus noch Details wie Adressierung, Datentyp, Byteorder und Authentifizierung sauber projektspezifisch gepflegt werden.
---
---
## Konfiguration und Sicherheit
Vor dem ersten Start `.env.example` nach `.env` kopieren und mindestens `JWT_SECRET`, `ADMIN_PASSWORD` und `REDIS_PASSWORD` durch eigene lange Zufallswerte ersetzen. Die lokale `.env` wird nicht versioniert.
Redis ist ausschließlich innerhalb des Docker-Netzwerks erreichbar und verlangt ein Passwort. Für Diagnosezwecke:
```powershell
docker compose exec redis redis-cli -a $env:REDIS_PASSWORD ping
```
Alle Dienste können vor einem Deployment statisch geprüft werden:
```powershell
npm --prefix api run check
npm --prefix collector run check
npm --prefix symcon-collector run check
npm --prefix bacnet-collector run check
npm --prefix runner run check
npm --prefix web run check
```
+2 -2
View File
@@ -1,9 +1,9 @@
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm ci
COPY src ./src
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js"
"start": "node src/index.js",
"check": "node --check src/index.js && node --check src/db.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
+28 -9
View File
@@ -1,4 +1,4 @@
import mysql from "mysql2/promise";
import mysql from "mysql2/promise";
import { config } from "./config.js";
function createPool(password) {
@@ -24,7 +24,15 @@ function isAccessDenied(error) {
return error?.code === "ER_ACCESS_DENIED_ERROR";
}
async function query(sql, values) {
function isTransientConnectionError(error) {
return ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "PROTOCOL_CONNECTION_LOST"].includes(error?.code);
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function queryWithCredentials(sql, values) {
try {
return await activePool.query(sql, values);
} catch (error) {
@@ -33,21 +41,18 @@ async function query(sql, values) {
}
for (const password of candidatePasswords) {
if (password === activePassword) {
continue;
}
if (password === activePassword) continue;
const fallbackPool = createPool(password);
try {
const result = await fallbackPool.query(sql, values);
const previousPool = activePool;
activePool = fallbackPool;
activePassword = password;
previousPool.end().catch(() => undefined);
return result;
} catch (fallbackError) {
await fallbackPool.end().catch(() => undefined);
if (!isAccessDenied(fallbackError)) {
throw fallbackError;
}
if (!isAccessDenied(fallbackError)) throw fallbackError;
}
}
@@ -55,4 +60,18 @@ async function query(sql, values) {
}
}
async function query(sql, values) {
let lastError;
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
return await queryWithCredentials(sql, values);
} catch (error) {
lastError = error;
if (!isTransientConnectionError(error) || attempt === 4) throw error;
await delay(250 * 2 ** attempt);
}
}
throw lastError;
}
export const pool = { query };
+26 -11
View File
@@ -202,18 +202,33 @@ async function ensureRolesSchema() {
}
function sanitizeApiSettings(body = {}) {
function updateSecret(body, existing, field, clearField) {
if (body[clearField] === true) return "";
const replacement = String(body[field] ?? "").trim();
return replacement || String(existing[field] || "").trim();
}
function sanitizeApiSettings(body = {}, existing = {}) {
return {
mailUrl: String(body.mailUrl || "https://mailapi.se-inno.de").trim() || "https://mailapi.se-inno.de",
mailApiKey: String(body.mailApiKey || "").trim(),
whatsappUrl: String(body.whatsappUrl || "https://whatsappapi.se-inno.de").trim() || "https://whatsappapi.se-inno.de",
whatsappApiKey: String(body.whatsappApiKey || "").trim(),
voipUrl: String(body.voipUrl || "https://voipapi.se-inno.de").trim() || "https://voipapi.se-inno.de",
voipApiKey: String(body.voipApiKey || "").trim(),
mailUrl: String(body.mailUrl ?? existing.mailUrl ?? "https://mailapi.se-inno.de").trim() || "https://mailapi.se-inno.de",
mailApiKey: updateSecret(body, existing, "mailApiKey", "clearMailApiKey"),
whatsappUrl: String(body.whatsappUrl ?? existing.whatsappUrl ?? "https://whatsappapi.se-inno.de").trim() || "https://whatsappapi.se-inno.de",
whatsappApiKey: updateSecret(body, existing, "whatsappApiKey", "clearWhatsappApiKey"),
voipUrl: String(body.voipUrl ?? existing.voipUrl ?? "https://voipapi.se-inno.de").trim() || "https://voipapi.se-inno.de",
voipApiKey: updateSecret(body, existing, "voipApiKey", "clearVoipApiKey"),
updatedAt: new Date().toISOString(),
};
}
function publicApiSettings(settings = {}) {
return {
mailUrl: settings.mailUrl || "https://mailapi.se-inno.de",
whatsappUrl: settings.whatsappUrl || "https://whatsappapi.se-inno.de",
voipUrl: settings.voipUrl || "https://voipapi.se-inno.de",
updatedAt: settings.updatedAt || null,
};
}
function normalizeRunnerTime(value) {
const raw = String(value ?? "").trim().replace(/\s*uhr$/i, "").replace(".", ":");
const match = raw.match(/^(\d{1,2}):(\d{2})$/);
@@ -834,9 +849,8 @@ app.post("/api/dashboard/data", authenticateTokenOptional, wrap(async (req, res)
app.get("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (_req, res) => {
const current = JSON.parse(await redis.get("integration:api") || "{}");
const settings = sanitizeApiSettings(current);
res.json({
settings,
settings: publicApiSettings(current),
mailConfigured: Boolean(current.mailApiKey),
whatsappConfigured: Boolean(current.whatsappApiKey),
voipConfigured: Boolean(current.voipApiKey),
@@ -844,10 +858,11 @@ app.get("/api/integrations", authenticateToken, requirePermission("manage_integr
}));
app.put("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (req, res) => {
const payload = sanitizeApiSettings(req.body || {});
const current = JSON.parse(await redis.get("integration:api") || "{}");
const payload = sanitizeApiSettings(req.body || {}, current);
await redis.set("integration:api", JSON.stringify(payload));
res.json({
settings: payload,
settings: publicApiSettings(payload),
mailConfigured: Boolean(payload.mailApiKey),
whatsappConfigured: Boolean(payload.whatsappApiKey),
voipConfigured: Boolean(payload.voipApiKey),
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
+2 -1
View File
@@ -4,7 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js"
"start": "node src/index.js",
"check": "node --check src/index.js"
},
"dependencies": {
"bacstack": "^0.0.1-beta.14"
+2 -2
View File
@@ -1,9 +1,9 @@
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm ci
COPY src ./src
RUN mkdir -p /app/data
+2433
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -4,12 +4,16 @@
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js"
"start": "node src/index.js",
"check": "node --check src/index.js"
},
"dependencies": {
"mysql2": "^3.12.0",
"node-opcua": "latest",
"node-bacnet": "latest",
"knx": "latest"
"node-opcua": "2.184.4",
"node-bacnet": "0.2.4",
"knx": "2.5.4"
},
"overrides": {
"binary-parser": "2.3.0"
}
}
+44 -12
View File
@@ -1,5 +1,5 @@
import { createServer } from "node:http";
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Buffer } from "node:buffer";
@@ -15,6 +15,7 @@ const bacnetCollectorUrl = String(process.env.BACNET_COLLECTOR_URL || "").replac
const LOCAL_POINT_LIMIT = 2000;
let transactionId = 1;
let polling = false;
let lastKnownGoodStore = null;
const db = mysql.createPool({
host: process.env.DB_HOST || "localhost",
@@ -38,13 +39,7 @@ function baseStore() {
};
}
function readStore() {
if (!existsSync(storeFile)) {
return baseStore();
}
try {
const parsed = JSON.parse(readFileSync(storeFile, "utf8"));
function normalizeStore(parsed) {
return {
...baseStore(),
...parsed,
@@ -52,13 +47,33 @@ function readStore() {
sources: Array.isArray(parsed.sources) ? parsed.sources : [],
datapoints: Array.isArray(parsed.datapoints) ? parsed.datapoints : [],
};
} catch {
return baseStore();
}
function readStore() {
if (!existsSync(storeFile)) {
return structuredClone(lastKnownGoodStore || baseStore());
}
try {
const store = normalizeStore(JSON.parse(readFileSync(storeFile, "utf8")));
lastKnownGoodStore = store;
return structuredClone(store);
} catch (error) {
console.warn("Quellenkonfiguration konnte nicht gelesen werden, letzter gültiger Stand wird verwendet:", error.message);
return structuredClone(lastKnownGoodStore || baseStore());
}
}
function writeStore(payload) {
writeFileSync(storeFile, JSON.stringify(payload, null, 2), "utf8");
const temporaryFile = `${storeFile}.${process.pid}.${Date.now()}.tmp`;
try {
writeFileSync(temporaryFile, JSON.stringify(payload, null, 2), "utf8");
renameSync(temporaryFile, storeFile);
lastKnownGoodStore = normalizeStore(payload);
} catch (error) {
try { unlinkSync(temporaryFile); } catch {}
throw error;
}
}
function touchStore(mutator) {
@@ -889,11 +904,28 @@ async function requestIpsymconApi(source, payload) {
}
}
function getIpsymconLinkedName(variable, variableId) {
const sources = Array.isArray(variable?.sources) ? variable.sources : [];
for (const source of sources) {
const links = Array.isArray(source?.link_chain) ? source.link_chain : [];
for (const link of [...links].reverse()) {
if (Number(link?.target_id) === variableId) {
const linkName = normalizeText(link?.name);
if (linkName) return linkName;
}
}
}
return "";
}
function mapIpsymconVariable(source, variable, categoryId, index) {
const variableId = Number(variable?.id);
const variableType = normalizeText(variable?.variable_type || variable?.type || "");
const kind = variableType === "boolean" || typeof variable?.value === "boolean" ? "digital" : "analog";
const name = normalizeText(variable?.full_name || variable?.name || `Variable ${variableId}`) || `Variable ${variableId}`;
// A named variable link is the operator-facing label and takes precedence over its target's name.
const name = getIpsymconLinkedName(variable, variableId)
|| normalizeText(variable?.full_name || variable?.name || `Variable ${variableId}`)
|| `Variable ${variableId}`;
return {
id: `${source.id}::ipsymcon::${variableId}`,
protocol: "ipsymcon",
+28 -10
View File
@@ -24,14 +24,14 @@ services:
image: redis:7.4-alpine
container_name: seltd-redis
restart: unless-stopped
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
ports:
- "6379:6379"
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}
command: ["sh", "-c", "exec redis-server /usr/local/etc/redis/redis.conf --requirepass \"$$REDIS_PASSWORD\""]
volumes:
- redis_data:/data
- ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 10
@@ -49,7 +49,7 @@ services:
DB_USER: root
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
DB_PASSWORD_FALLBACK: ${DB_PASSWORD_FALLBACK:-root}
REDIS_URL: redis://redis:6379
REDIS_URL: redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}@redis:6379/0
JWT_SECRET: ${JWT_SECRET:-change-me}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
@@ -63,6 +63,11 @@ services:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/health > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
web:
build:
@@ -74,7 +79,13 @@ services:
ports:
- "5173:5173"
depends_on:
- api
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5173/ > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
collector:
build:
@@ -101,6 +112,11 @@ services:
condition: service_healthy
bacnet-collector:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:18110/health > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
symcon-collector:
build:
@@ -136,6 +152,11 @@ services:
BACNET_BROADCAST_FALLBACKS: ${BACNET_BROADCAST_FALLBACKS:-}
BACNET_UNICAST_DELAY_MS: ${BACNET_UNICAST_DELAY_MS:-120}
network_mode: "host"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:18111/health > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
runner:
build:
@@ -143,7 +164,7 @@ services:
container_name: seltd-runner
restart: unless-stopped
environment:
REDIS_URL: redis://redis:6379
REDIS_URL: redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}@redis:6379/0
DB_HOST: mariadb
DB_PORT: 3306
DB_NAME: wago
@@ -197,6 +218,3 @@ volumes:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
+3 -2
View File
@@ -1,10 +1,11 @@
{
{
"name": "seltd-runner",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js"
"start": "node src/index.js",
"check": "node --check src/index.js"
},
"dependencies": {
"ioredis": "^5.6.1",
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
+2 -1
View File
@@ -4,7 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js"
"start": "node src/index.js",
"check": "node --check src/index.js"
},
"dependencies": {
"mysql2": "^3.12.0"
+6 -4
View File
@@ -7,6 +7,7 @@ const maxConcurrency = Math.max(1, Math.min(16, Number(process.env.SYMCON_MAX_CO
const sourcePollState = new Map();
const valueCache = new Map();
let polling = false;
let lastKnownGoodStore = { sources: [], datapoints: [] };
const db = mysql.createPool({
host: process.env.DB_HOST || "mariadb",
@@ -67,17 +68,18 @@ async function updateWorkerState(source, status, error = "") {
function readStore() {
if (!existsSync(sourcesFile)) {
return { sources: [], datapoints: [] };
return structuredClone(lastKnownGoodStore);
}
try {
const store = JSON.parse(readFileSync(sourcesFile, "utf8"));
return {
lastKnownGoodStore = {
sources: Array.isArray(store.sources) ? store.sources : [],
datapoints: Array.isArray(store.datapoints) ? store.datapoints : [],
};
return structuredClone(lastKnownGoodStore);
} catch (error) {
console.warn("Quellenkonfiguration konnte nicht gelesen werden:", error.message);
return { sources: [], datapoints: [] };
console.warn("Quellenkonfiguration konnte nicht gelesen werden, letzter gültiger Stand wird verwendet:", error.message);
return structuredClone(lastKnownGoodStore);
}
}
+2 -3
View File
@@ -1,13 +1,12 @@
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173"
"preview": "vite preview --host 0.0.0.0 --port 4173",
"check": "vite build"
},
"dependencies": {
"cors": "^2.8.6",
+38 -11
View File
@@ -741,7 +741,8 @@ export default function App() {
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null, ipsymcon: null });
const [debugSnapshot, setDebugSnapshot] = useState(null);
const [integrationSettings, setIntegrationSettings] = useState({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
const [integrationSettings, setIntegrationSettings] = useState({ mailUrl: "https://mailapi.se-inno.de", whatsappUrl: "https://whatsappapi.se-inno.de", voipUrl: "https://voipapi.se-inno.de", mailApiKey: "", whatsappApiKey: "", voipApiKey: "" });
const [integrationStatus, setIntegrationStatus] = useState({ mailConfigured: false, whatsappConfigured: false, voipConfigured: false });
const [runnerJobs, setRunnerJobs] = useState([]);
const [runnerQueue, setRunnerQueue] = useState([]);
const [runnerDraft, setRunnerDraft] = useState({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." });
@@ -1137,12 +1138,16 @@ export default function App() {
}, [canEditAliases, canManageSources, token]);
useEffect(() => {
if (!token || !canManageIntegrations) {
setIntegrationSettings({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
setIntegrationSettings({ mailUrl: "https://mailapi.se-inno.de", whatsappUrl: "https://whatsappapi.se-inno.de", voipUrl: "https://voipapi.se-inno.de", mailApiKey: "", whatsappApiKey: "", voipApiKey: "" });
setIntegrationStatus({ mailConfigured: false, whatsappConfigured: false, voipConfigured: false });
return;
}
api.getIntegrations(token)
.then((result) => setIntegrationSettings(result.settings || { mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" }))
.then((result) => {
setIntegrationSettings({ mailApiKey: "", whatsappApiKey: "", voipApiKey: "", ...(result.settings || {}) });
setIntegrationStatus({ mailConfigured: Boolean(result.mailConfigured), whatsappConfigured: Boolean(result.whatsappConfigured), voipConfigured: Boolean(result.voipConfigured) });
})
.catch((error) => setBootError(error.message));
}, [canManageIntegrations, token]);
@@ -2086,17 +2091,36 @@ export default function App() {
}
};
const applyIntegrationResult = (result) => {
setIntegrationSettings({ mailApiKey: "", whatsappApiKey: "", voipApiKey: "", ...(result.settings || {}) });
setIntegrationStatus({ mailConfigured: Boolean(result.mailConfigured), whatsappConfigured: Boolean(result.whatsappConfigured), voipConfigured: Boolean(result.voipConfigured) });
};
const saveIntegrationSettings = async () => {
resetFlash();
try {
const result = await api.saveIntegrations(token, integrationSettings);
setIntegrationSettings(result.settings || integrationSettings);
applyIntegrationResult(result);
setSuccessMessage("API-Verbindungen gespeichert.");
} catch (error) {
setBootError(error.message);
}
};
const clearIntegrationKey = async (channel) => {
const labels = { mail: "Mail", whatsapp: "WhatsApp", voip: "Telefon" };
if (!window.confirm(`${labels[channel]} API-Key wirklich entfernen?`)) return;
resetFlash();
try {
const clearField = channel === "mail" ? "clearMailApiKey" : channel === "whatsapp" ? "clearWhatsappApiKey" : "clearVoipApiKey";
const result = await api.saveIntegrations(token, { ...integrationSettings, [clearField]: true });
applyIntegrationResult(result);
setSuccessMessage(`${labels[channel]} API-Key entfernt.`);
} catch (error) {
setBootError(error.message);
}
};
const resetRunnerDraft = () => {
setRunnerDraft({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." });
};
@@ -3484,18 +3508,21 @@ export default function App() {
<div className="form-grid compact-forms">
<label>
<span>Mail API-Key</span>
<input type="password" value={integrationSettings.mailApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, mailApiKey: event.target.value }))} />
<small className="muted">{integrationSettings.mailApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
<input type="password" autoComplete="new-password" value={integrationSettings.mailApiKey || ""} placeholder={integrationStatus.mailConfigured ? "Key gespeichert - nur zum Ersetzen eingeben" : "API-Key eingeben"} onChange={(event) => setIntegrationSettings((current) => ({ ...current, mailApiKey: event.target.value }))} />
<small className="muted">{integrationStatus.mailConfigured ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
{integrationStatus.mailConfigured ? <button className="ghost-button compact-button danger" type="button" onClick={() => clearIntegrationKey("mail")}>Key entfernen</button> : null}
</label>
<label>
<span>WhatsApp API-Key</span>
<input type="password" value={integrationSettings.whatsappApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, whatsappApiKey: event.target.value }))} />
<small className="muted">{integrationSettings.whatsappApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
<input type="password" autoComplete="new-password" value={integrationSettings.whatsappApiKey || ""} placeholder={integrationStatus.whatsappConfigured ? "Key gespeichert - nur zum Ersetzen eingeben" : "API-Key eingeben"} onChange={(event) => setIntegrationSettings((current) => ({ ...current, whatsappApiKey: event.target.value }))} />
<small className="muted">{integrationStatus.whatsappConfigured ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
{integrationStatus.whatsappConfigured ? <button className="ghost-button compact-button danger" type="button" onClick={() => clearIntegrationKey("whatsapp")}>Key entfernen</button> : null}
</label>
<label>
<span>Telefon API-Key</span>
<input type="password" value={integrationSettings.phoneApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, phoneApiKey: event.target.value }))} />
<small className="muted">{integrationSettings.phoneApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
<input type="password" autoComplete="new-password" value={integrationSettings.voipApiKey || ""} placeholder={integrationStatus.voipConfigured ? "Key gespeichert - nur zum Ersetzen eingeben" : "API-Key eingeben"} onChange={(event) => setIntegrationSettings((current) => ({ ...current, voipApiKey: event.target.value }))} />
<small className="muted">{integrationStatus.voipConfigured ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
{integrationStatus.voipConfigured ? <button className="ghost-button compact-button danger" type="button" onClick={() => clearIntegrationKey("voip")}>Key entfernen</button> : null}
</label>
</div>
<button className="primary-button compact-button" type="button" onClick={saveIntegrationSettings}>API-Keys speichern</button>
@@ -3554,7 +3581,7 @@ export default function App() {
<span>aktiv</span>
</label>
<label className="checkbox-field compact-check">
<input type="checkbox" checked={runnerDraft.mailEnabled === true} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailEnabled: event.target.checked }))} disabled={!integrationSettings.mailEnabled && !integrationSettings.mailApiKey} />
<input type="checkbox" checked={runnerDraft.mailEnabled === true} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailEnabled: event.target.checked }))} disabled={!integrationStatus.mailConfigured && !integrationSettings.mailApiKey} />
<span>CSV per Mail verschicken</span>
</label>
<label>