diff --git a/.env b/.env index 0961510..127b150 100644 --- a/.env +++ b/.env @@ -7,4 +7,3 @@ MARIADB_ROOT_PASSWORD=SE3112 DB_PASSWORD_FALLBACK=root CORS_ORIGIN=* VITE_API_PORT=18080 -CORS_ORIGIN=http://localhost:5173 diff --git a/.env.example b/.env.example deleted file mode 100644 index 0961510..0000000 --- a/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -JWT_SECRET=change-me -ADMIN_USERNAME=admin -ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=ChangeMe123! -ADMIN_NAME=System Admin -MARIADB_ROOT_PASSWORD=SE3112 -DB_PASSWORD_FALLBACK=root -CORS_ORIGIN=* -VITE_API_PORT=18080 -CORS_ORIGIN=http://localhost:5173 diff --git a/api/src/auth.js b/api/src/auth.js index e07c918..e969255 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -32,6 +32,25 @@ export function authenticateToken(req, res, next) { } } +export function authenticateTokenOptional(req, _res, next) { + const header = req.headers.authorization || ""; + const token = header.startsWith("Bearer ") ? header.slice(7) : ""; + + if (!token) { + req.user = null; + next(); + return; + } + + try { + req.user = jwt.verify(token, config.jwtSecret); + } catch { + req.user = null; + } + + next(); +} + export function requireRoles(...roles) { return (req, res, next) => { if (!req.user || !roles.includes(req.user.role)) { diff --git a/api/src/index.js b/api/src/index.js index 101fdb3..7368eab 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -1,7 +1,7 @@ import bcrypt from "bcryptjs"; import cors from "cors"; import express from "express"; -import { authenticateToken, createToken, requireRoles } from "./auth.js"; +import { authenticateToken, authenticateTokenOptional, createToken, requireRoles } from "./auth.js"; import { config } from "./config.js"; import { pool } from "./db.js"; import { @@ -88,7 +88,7 @@ function assertPoints(points) { } function canUseSelection(selection, user) { - return Boolean(selection) && (selection.shared || selection.ownerId === user.sub || user.role === "admin"); + return Boolean(selection) && (selection.shared || (Boolean(user) && (selection.ownerId === user.sub || user.role === "admin"))); } function slugifyUsername(value) { @@ -147,6 +147,16 @@ async function serializeUserWithPermissions(row) { }; } +const GUEST_VIEWER = { + sub: "public", + role: "viewer", + name: "Gast", +}; + +function resolveViewerUser(req) { + return req.user || GUEST_VIEWER; +} + function requirePermission(...permissions) { return async (req, res, next) => { try { @@ -218,6 +228,12 @@ async function ensureUtf8Database() { } } +async function ensureWagoApplicationUser() { + await pool.query("CREATE USER IF NOT EXISTS 'wago'@'%' IDENTIFIED BY '3112'"); + await pool.query(`GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON \`${config.dbName}\`.* TO 'wago'@'%'`); + await pool.query("FLUSH PRIVILEGES"); +} + async function ensureUserSchema() { await pool.query( ` @@ -384,7 +400,7 @@ async function resolveIspPointCount(isp) { return await resolveTrendTablePointCount(pool, config.dbName, isp); } -app.get("/api/isps", authenticateToken, wrap(async (_req, res) => { +app.get("/api/isps", authenticateTokenOptional, wrap(async (_req, res) => { const isps = await getAvailableIsps(pool, config.dbName); const response = await Promise.all( isps.map(async (isp) => { @@ -400,7 +416,7 @@ app.get("/api/isps", authenticateToken, wrap(async (_req, res) => { res.json({ isps: response }); })); -app.get("/api/isps/:isp/aliases", authenticateToken, wrap(async (req, res) => { +app.get("/api/isps/:isp/aliases", authenticateTokenOptional, wrap(async (req, res) => { const pointCount = await resolveIspPointCount(req.params.isp); const metadata = await ensureIspMetadata(pool, redis, req.params.isp, pointCount); res.json(metadata); @@ -694,20 +710,20 @@ app.delete("/api/selections/:selectionId", authenticateToken, requirePermission( res.status(204).send(); })); -app.post("/api/trends/query", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { - const points = await resolveSelectionPoints(req.body, req.user); +app.post("/api/trends/query", authenticateTokenOptional, wrap(async (req, res) => { + const points = await resolveSelectionPoints(req.body, resolveViewerUser(req)); const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(req.body.range)); res.json(trendData); })); -app.post("/api/points/latest", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { - const points = await resolveSelectionPoints(req.body, req.user); +app.post("/api/points/latest", authenticateTokenOptional, wrap(async (req, res) => { + const points = await resolveSelectionPoints(req.body, resolveViewerUser(req)); const values = await fetchLatestValues(pool, redis, points); res.json({ values }); })); -app.get("/api/dashboard", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => { - const dashboard = await getDashboard(redis, req.user.sub); +app.get("/api/dashboard", authenticateTokenOptional, wrap(async (req, res) => { + const dashboard = await getDashboard(redis, req.user?.sub || "public"); res.json(dashboard); })); @@ -716,12 +732,13 @@ app.put("/api/dashboard", authenticateToken, requirePermission("view_dashboard") res.json(dashboard); })); -app.post("/api/dashboard/data", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => { +app.post("/api/dashboard/data", authenticateTokenOptional, wrap(async (req, res) => { const widgets = Array.isArray(req.body.widgets) ? req.body.widgets : []; const results = []; + const viewer = resolveViewerUser(req); for (const widget of widgets) { - const points = widget.selectionId ? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user) : assertPoints(widget.points || []); + const points = widget.selectionId ? await resolveSelectionPoints({ selectionId: widget.selectionId }, viewer) : assertPoints(widget.points || []); if (widget.type === "chart") { const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(widget.range)); diff --git a/api/src/lib/isp.js b/api/src/lib/isp.js index 3044291..88cc632 100644 --- a/api/src/lib/isp.js +++ b/api/src/lib/isp.js @@ -113,14 +113,19 @@ function createRandomPointColor(seed) { return hslToHex(hue, saturation, lightness); } -export function normalizeEngineeringUnit(value) { +export function normalizeEngineeringUnit(value, kind = "analog") { + const normalizedKind = kind === "digital" ? "digital" : "analog"; const raw = String(value ?? "").trim(); if (!raw) { - return "\u00B0C"; + return normalizedKind === "digital" ? "bool" : "\u00B0C"; + } + + if (normalizedKind === "digital" && /^(?:bool|boolean)$/i.test(raw)) { + return "bool"; } if (/^\?C$/i.test(raw) || /^\u00B0\s*C$/i.test(raw) || (/[^\x00-\x7F]/.test(raw) && /C/i.test(raw))) { - return "\u00B0C"; + return normalizedKind === "digital" ? "bool" : "\u00B0C"; } return raw; @@ -388,7 +393,7 @@ export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_PO parsed.points[pointKey] = { ...defaultPoint, alias: initialAlias, - unit: normalizeEngineeringUnit(sourceSeed?.unit || defaultPoint.unit), + unit: normalizeEngineeringUnit(sourceSeed?.unit || defaultPoint.unit, sourceSeed?.kind || defaultPoint.kind), kind: sourceSeed?.kind || defaultPoint.kind, }; changed = true; @@ -407,13 +412,13 @@ export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_PO point.kind = sourceSeed.kind; changed = true; } - const normalizedUnit = normalizeEngineeringUnit(point.unit); + const normalizedUnit = normalizeEngineeringUnit(point.unit, point.kind || sourceSeed?.kind || defaultPoint.kind); if (normalizedUnit !== point.unit) { point.unit = normalizedUnit; changed = true; } - if ((!point.unit || point.unit === normalizeEngineeringUnit(defaultPoint.unit)) && sourceSeed?.unit) { - const seededUnit = normalizeEngineeringUnit(sourceSeed.unit); + if ((!point.unit || point.unit === normalizeEngineeringUnit(defaultPoint.unit, defaultPoint.kind)) && sourceSeed?.unit) { + const seededUnit = normalizeEngineeringUnit(sourceSeed.unit, sourceSeed.kind || point.kind || defaultPoint.kind); if (point.unit !== seededUnit) { point.unit = seededUnit; changed = true; @@ -463,7 +468,7 @@ export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_PO metadata.points[pointKey] = { ...metadata.points[pointKey], alias: sourceSeed.alias || sourceSeed.name || metadata.points[pointKey].alias, - unit: normalizeEngineeringUnit(sourceSeed.unit || metadata.points[pointKey].unit), + unit: normalizeEngineeringUnit(sourceSeed.unit || metadata.points[pointKey].unit, sourceSeed.kind || metadata.points[pointKey].kind), kind: sourceSeed.kind || metadata.points[pointKey].kind, }; }); @@ -506,7 +511,7 @@ export async function getPointDescriptors(pool, redis, points) { key: buildPointKey(isp, pointIndex), displayName: metadata.displayName, alias: pointMeta.alias, - unit: normalizeEngineeringUnit(pointMeta.unit), + unit: normalizeEngineeringUnit(pointMeta.unit, pointMeta.kind), factor: pointMeta.factor, kind: pointMeta.kind, min: pointMeta.min, diff --git a/collector/src/index.js b/collector/src/index.js index b01965e..2a5f35f 100644 --- a/collector/src/index.js +++ b/collector/src/index.js @@ -156,16 +156,25 @@ function normalizeUnit(value) { const raw = normalizeText(value); const collapsed = raw.replace(/\s+/g, ""); if (!collapsed) { - return "\u00B0C"; + return "°C"; } - if (/^\?C$/i.test(collapsed) || /^\u00B0C$/i.test(collapsed)) { - return "\u00B0C"; + if (/^(?:\?C|\u00B0C)$/i.test(collapsed)) { + return "°C"; } return raw; } +function normalizePointUnit(value, kind = "analog") { + const normalizedKind = normalizePointKind(kind); + const raw = normalizeText(value); + if (!raw) { + return normalizedKind === "digital" ? "bool" : "°C"; + } + return normalizeUnit(raw); +} + function normalizePointIndex(value, fallback) { const numeric = Number(value); return Number.isInteger(numeric) && numeric >= 1 && numeric <= LOCAL_POINT_LIMIT ? numeric : fallback; @@ -244,7 +253,7 @@ function parseModbusCsv(payload) { pointIndex, name: normalizeText(get("name", `Register ${index + 1}`)) || `Register ${index + 1}`, alias: normalizeText(get("alias", get("name", `Register ${index + 1}`))) || normalizeText(get("name", `Register ${index + 1}`)) || `Register ${index + 1}`, - unit: normalizeUnit(get("unit")), + unit: normalizePointUnit(get("unit"), get("kind", get("type", "analog"))), dataType: get("datatype", "holding-register"), kind: normalizePointKind(get("kind", get("type", "analog"))), enabled: payload.enabled === true, @@ -294,7 +303,7 @@ function parseKnxXml(payload) { pointIndex: index + 1, name, alias: name, - unit: normalizeUnit(""), + unit: normalizePointUnit("", dpt.startsWith("1.") ? "digital" : "analog"), dataType: dpt || "group-address", kind: dpt.startsWith("1.") ? "digital" : "analog", enabled: payload.enabled === true, @@ -331,7 +340,7 @@ function createManualDatapoint(store, payload) { pointIndex, name, alias, - unit: normalizeUnit(payload.unit), + unit: normalizePointUnit(payload.unit, payload.kind), kind: normalizePointKind(payload.kind), enabled: payload.enabled !== false, writeMode: normalizeWriteMode(payload.writeMode || source.writeMode), @@ -365,7 +374,7 @@ function createScanDatapoint(source, payload, index) { pointIndex, name: normalizeText(payload.name || alias) || alias, alias, - unit: normalizeUnit(payload.unit), + unit: normalizePointUnit(payload.unit, payload.kind), kind: normalizePointKind(payload.kind), enabled: payload.enabled === true, writeMode: normalizeWriteMode(payload.writeMode || source.writeMode), @@ -413,7 +422,7 @@ function buildScannedDatapoints(store, source, rawPoints, toPayload) { pointIndex: existing.pointIndex, alias: existing.alias || scanned.alias, name: existing.name || scanned.name, - unit: normalizeUnit(existing.unit || scanned.unit), + unit: normalizePointUnit(existing.unit || scanned.unit, existing.kind || scanned.kind), kind: existing.kind || scanned.kind, enabled: existing.enabled === true, writeMode: normalizeWriteMode(existing.writeMode || scanned.writeMode), @@ -1707,7 +1716,7 @@ const server = createServer(async (req, res) => { address: typeof body.address === "string" ? normalizeText(body.address) : store.datapoints[index].address, nodeId: typeof body.nodeId === "string" ? normalizeText(body.nodeId) : store.datapoints[index].nodeId, dataType: typeof body.dataType === "string" && body.dataType.trim() ? normalizeText(body.dataType) : store.datapoints[index].dataType, - unit: body.unit ? normalizeUnit(body.unit) : store.datapoints[index].unit, + unit: body.unit ? normalizePointUnit(body.unit, body.kind || store.datapoints[index].kind) : store.datapoints[index].unit, kind: body.kind ? normalizePointKind(body.kind) : store.datapoints[index].kind, scale: Number.isFinite(Number(body.scale)) ? Number(body.scale) : store.datapoints[index].scale, }; diff --git a/docker-compose.yml b/docker-compose.yml index 1521293..8938fd0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: MARIADB_ROOT_HOST: mariadb MARIADB_DATABASE: wago MARIADB_USER: wago - MARIADB_PASSWORD: ${MARIADB_PASSWORD:-SE3112} + MARIADB_PASSWORD: ${MARIADB_PASSWORD:-3112} ports: - "3306:3306" volumes: diff --git a/docker/mariadb/init/01-init.sql b/docker/mariadb/init/01-init.sql index 0f48cf0..768ce7c 100644 --- a/docker/mariadb/init/01-init.sql +++ b/docker/mariadb/init/01-init.sql @@ -61,3 +61,8 @@ CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY 'SE3112'; GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES; + + +CREATE USER IF NOT EXISTS 'wago'@'%' IDENTIFIED BY '3112'; +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON `wago`.* TO 'wago'@'%'; +FLUSH PRIVILEGES; diff --git a/web/src/App.jsx b/web/src/App.jsx index bf4832e..e5b52c2 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -102,12 +102,13 @@ const pointColors = [ ]; const DEFAULT_UNIT_OPTIONS = [ - { symbol: "\u00B0C", label: "Grad Celsius", isSystem: true }, + { symbol: "°C", label: "Grad Celsius", isSystem: true }, { symbol: "%", label: "Prozent", isSystem: true }, { symbol: "K", label: "Kelvin", isSystem: true }, { symbol: "V", label: "Volt", isSystem: true }, { symbol: "A", label: "Ampere", isSystem: true }, { symbol: "Pa", label: "Pascal", isSystem: true }, + { symbol: "bool", label: "Digital / Bool", isSystem: true }, ]; const UNIT_PRESETS = DEFAULT_UNIT_OPTIONS.map((unit) => unit.symbol); const CUSTOM_UNIT_VALUE = "__custom__"; @@ -164,6 +165,10 @@ function getDefaultPointDataType(protocol) { return "holding-register"; } +function getDefaultUnitForKind(kind) { + return kind === "digital" ? "bool" : "°C"; +} + function getPointDataTypeOptions(protocol) { if (protocol === "modbus-tcp") { return MODBUS_DATA_TYPE_OPTIONS; @@ -543,7 +548,7 @@ function IconButton({ title, onClick, children, danger = false }) { ); } -function LoginScreen({ onLogin, error, busy }) { +function LoginScreen({ onLogin, error, busy, inline = false }) { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -553,7 +558,7 @@ function LoginScreen({ onLogin, error, busy }) { }; return ( -