diff --git a/package-lock.json b/package-lock.json
index 69db587..46a563b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.3",
"style-loader": "^4.0.0",
+ "telegraf": "^4.16.3",
"webpack": "^5.104.1",
"webpack-cli": "^6.0.1",
"webpack-dev-middleware": "^7.4.5"
@@ -2521,6 +2522,12 @@
"url": "https://opencollective.com/popperjs"
}
},
+ "node_modules/@telegraf/types": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz",
+ "integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==",
+ "license": "MIT"
+ },
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -2801,6 +2808,18 @@
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"license": "Apache-2.0"
},
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -3174,12 +3193,34 @@
"ieee754": "^1.1.13"
}
},
+ "node_modules/buffer-alloc": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
+ "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-alloc-unsafe": "^1.1.0",
+ "buffer-fill": "^1.0.0"
+ }
+ },
+ "node_modules/buffer-alloc-unsafe": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
+ "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
+ "license": "MIT"
+ },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
+ "node_modules/buffer-fill": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
+ "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==",
+ "license": "MIT"
+ },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4012,6 +4053,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -5071,6 +5121,15 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5156,6 +5215,26 @@
"node": "^18 || ^20 || >= 21"
}
},
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@@ -5272,6 +5351,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-timeout": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz",
+ "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -5987,12 +6075,30 @@
],
"license": "MIT"
},
+ "node_modules/safe-compare": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz",
+ "integrity": "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-alloc": "^1.2.0"
+ }
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/sandwich-stream": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz",
+ "integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6492,6 +6598,28 @@
"node": ">=6"
}
},
+ "node_modules/telegraf": {
+ "version": "4.16.3",
+ "resolved": "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz",
+ "integrity": "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==",
+ "license": "MIT",
+ "dependencies": {
+ "@telegraf/types": "^7.1.0",
+ "abort-controller": "^3.0.0",
+ "debug": "^4.3.4",
+ "mri": "^1.2.0",
+ "node-fetch": "^2.7.0",
+ "p-timeout": "^4.1.0",
+ "safe-compare": "^1.1.4",
+ "sandwich-stream": "^2.0.2"
+ },
+ "bin": {
+ "telegraf": "lib/cli.mjs"
+ },
+ "engines": {
+ "node": "^12.20.0 || >=14.13.1"
+ }
+ },
"node_modules/terser": {
"version": "5.44.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
@@ -6575,6 +6703,12 @@
"node": ">=0.6"
}
},
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
"node_modules/tree-dump": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz",
@@ -6754,6 +6888,12 @@
"node": ">=10.13.0"
}
},
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/webpack": {
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
@@ -6926,6 +7066,16 @@
"node": ">= 0.6"
}
},
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/package.json b/package.json
index f7194b2..a33d725 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.3",
"style-loader": "^4.0.0",
+ "telegraf": "^4.16.3",
"webpack": "^5.104.1",
"webpack-cli": "^6.0.1",
"webpack-dev-middleware": "^7.4.5"
diff --git a/server.js b/server.js
index cb10653..fa5c438 100644
--- a/server.js
+++ b/server.js
@@ -8,6 +8,7 @@ import webpackDevMiddleware from 'webpack-dev-middleware';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import config from './webpack.config.js';
+import { Telegraf } from 'telegraf';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -20,6 +21,7 @@ const POLL_INTERVAL_MS = 60000; // 60 seconds
const DB_FILE = 'ac_data.db';
const PORT = 3905;
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
+const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
// Device Type Mapping
const DEVICE_TYPES = {
@@ -81,13 +83,28 @@ db.exec(`
)
`);
+db.exec(`
+ CREATE TABLE IF NOT EXISTS alarms (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ enabled INTEGER DEFAULT 1,
+ trigger_type TEXT NOT NULL,
+ trigger_data TEXT NOT NULL,
+ action_type TEXT NOT NULL DEFAULT 'telegram',
+ action_data TEXT NOT NULL,
+ sort_order INTEGER DEFAULT 0,
+ color_tag TEXT DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+`);
+
// Migration: Add sort_order and color_tag if they don't exist
-try {
- db.exec('ALTER TABLE rules ADD COLUMN sort_order INTEGER DEFAULT 0');
-} catch (e) { /* column already exists */ }
-try {
- db.exec('ALTER TABLE rules ADD COLUMN color_tag TEXT DEFAULT NULL');
-} catch (e) { /* column already exists */ }
+try { db.exec('ALTER TABLE rules ADD COLUMN sort_order INTEGER DEFAULT 0'); } catch (e) { }
+try { db.exec('ALTER TABLE rules ADD COLUMN color_tag TEXT DEFAULT NULL'); } catch (e) { }
+try { db.exec('ALTER TABLE users ADD COLUMN telegram_id TEXT DEFAULT NULL'); } catch (e) { }
+try { db.exec('ALTER TABLE alarms ADD COLUMN sort_order INTEGER DEFAULT 0'); } catch (e) { }
+try { db.exec('ALTER TABLE alarms ADD COLUMN color_tag TEXT DEFAULT NULL'); } catch (e) { }
const insertStmt = db.prepare(`
INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed)
@@ -111,6 +128,147 @@ const updateRuleOrder = db.prepare('UPDATE rules SET sort_order = ? WHERE id = ?
const deleteRule = db.prepare('DELETE FROM rules WHERE id = ?');
const getMaxSortOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as max_order FROM rules');
+// Alarms prepared statements
+const getAllAlarms = db.prepare('SELECT * FROM alarms ORDER BY sort_order, id');
+const getAlarmById = db.prepare('SELECT * FROM alarms WHERE id = ?');
+const insertAlarm = db.prepare(`
+ INSERT INTO alarms (name, enabled, trigger_type, trigger_data, action_type, action_data, sort_order, color_tag)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+`);
+const updateAlarm = db.prepare(`
+ UPDATE alarms SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, color_tag = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+`);
+const updateAlarmOrder = db.prepare('UPDATE alarms SET sort_order = ? WHERE id = ?');
+const deleteAlarm = db.prepare('DELETE FROM alarms WHERE id = ?');
+const getMaxAlarmSortOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as max_order FROM alarms');
+
+// User Profile statements
+const updateUserTelegramId = db.prepare('UPDATE users SET telegram_id = ? WHERE id = ?');
+const getAllTelegramUsers = db.prepare("SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL AND telegram_id != ''");
+
+// --- TELEGRAM BOT ---
+let bot = null;
+if (TELEGRAM_BOT_TOKEN) {
+ bot = new Telegraf(TELEGRAM_BOT_TOKEN);
+
+ // Simple start handler to give user their ID
+ bot.start((ctx) => {
+ const userId = ctx.from.id;
+ ctx.reply(`Hello! Your Telegram ID is: ${userId}\n\nPlease copy this ID and paste it into your profile on the Dashboard.`);
+ });
+
+ bot.launch().catch(err => console.error('Telegram Bot Launch Error:', err));
+ console.log('Telegram Bot initialized.');
+
+ // Graceful stop
+ process.once('SIGINT', () => bot.stop('SIGINT'));
+ process.once('SIGTERM', () => bot.stop('SIGTERM'));
+} else {
+ console.warn('TELEGRAM_BOT_TOKEN not set. Alarms will not send notifications.');
+}
+
+async function sendTelegramNotification(message) {
+ if (!bot) return;
+ try {
+ const users = getAllTelegramUsers.all();
+ for (const u of users) {
+ await bot.telegram.sendMessage(u.telegram_id, message);
+ }
+ } catch (err) {
+ console.error('Failed to send Telegram notification:', err);
+ }
+}
+
+// --- ALARM LOGIC ---
+// Simple state tracking to avoid spamming: alarmId -> lastTriggeredTime
+const alarmStates = new Map();
+
+function evaluateAlarms(readings) {
+ const alarms = getAllAlarms.all();
+
+ alarms.forEach(alarm => {
+ if (!alarm.enabled) return;
+
+ // Skip if recently triggered (debounce 5 mins)
+ const lastTrigger = alarmStates.get(alarm.id);
+ if (lastTrigger && (Date.now() - lastTrigger) < 5 * 60 * 1000) {
+ return;
+ }
+
+ try {
+ const trigger = JSON.parse(alarm.trigger_data);
+ let triggered = false;
+
+ // Check Sensors
+ if (trigger.sensors && trigger.sensors.length > 0) {
+ const results = trigger.sensors.map(cond => {
+ const sensorId = cond.sensor; // e.g., "Wall Display:temp"
+ const operator = cond.operator;
+ const threshold = cond.value;
+
+ // Find matching reading
+ // sensorId format: "DevName:temp" or "DevName:humid" or "DevName:Port:level"
+ const parts = sensorId.split(':');
+ const devName = parts[0];
+ const type = parts.length === 2 ? parts[1] : parts[2]; // temp/humid OR level
+
+ // Find the latest reading for this device+type
+ // readings is an array of objects passed from poll()
+ // But poll inserts individually. We need to aggregate or pass the full context.
+ // Implementation Detail: poll() inserts one by one.
+ // Better approach: poll() collects all readings, THEN calls evaluateAlarms(allReadings).
+
+ const reading = readings.find(r => r.devName === devName);
+ if (!reading) return false;
+
+ let value = null;
+ if (type === 'temp') value = reading.temp_c;
+ if (type === 'humidity') value = reading.humidity;
+ if (type === 'level') {
+ // reading.ports is array of {port, temp, hum, speak...}
+ const portNum = parseInt(parts[1]);
+ const p = reading.ports.find(rp => rp.port === portNum);
+ if (p) value = p.speak;
+ }
+
+ if (value === null || value === undefined) return false;
+
+ // Numeric comparison
+ switch (operator) {
+ case '>': return value > threshold;
+ case '<': return value < threshold;
+ case '>=': return value >= threshold;
+ case '<=': return value <= threshold;
+ case '==': return value == threshold;
+ default: return false;
+ }
+ });
+
+ if (trigger.sensorLogic === 'or') {
+ triggered = results.some(r => r);
+ } else {
+ triggered = results.every(r => r);
+ }
+ }
+
+ // Time triggers are handled differently (cron-like), skipping for now as per "sensor trigger" focus in plan,
+ // but structure supports it. If time trigger is ONLY trigger, we'd need a separate loop.
+ // Assuming sensor triggers for now based on context of "readings".
+
+ if (triggered) {
+ console.log(`ALARM TRIGGERED: ${alarm.name}`);
+ alarmStates.set(alarm.id, Date.now());
+ const action = JSON.parse(alarm.action_data);
+ const msg = `đ¨ ALARM: ${alarm.name}\n\n${action.message || 'No message'}\n\nSeverity: ${action.severity || 'Info'}`;
+ sendTelegramNotification(msg);
+ }
+ } catch (err) {
+ console.error(`Error evaluating alarm ${alarm.id}:`, err);
+ }
+ });
+}
+
// --- AC INFINITY API LOGIC ---
let token = null;
@@ -197,6 +355,8 @@ async function poll() {
const devices = await getDeviceList(token);
console.log(`[${new Date().toISOString()}] Data Fetch: Found ${devices.length} controllers.`);
+ const currentReadings = []; // Collect for alarm evaluation
+
for (const device of devices) {
const ports = device.deviceInfo && device.deviceInfo.ports ? device.deviceInfo.ports : [];
@@ -205,6 +365,14 @@ async function poll() {
continue;
}
+ const deviceReadings = {
+ devName: device.devName,
+ devId: device.devId,
+ temp_c: null, // Device level temp (some controllers have it)
+ humidity: null,
+ ports: []
+ };
+
for (const portInfo of ports) {
// Filter by online status
if (portInfo.online === 1) {
@@ -216,6 +384,11 @@ async function poll() {
const hum = settings.humidity ? settings.humidity / 100 : null;
const vpd = settings.vpdnums ? settings.vpdnums / 100 : null;
+ // Some devices report environment at "device" level, others at port.
+ // We'll capture first valid env reading as device reading for simplicity
+ if (tempC !== null && deviceReadings.temp_c === null) deviceReadings.temp_c = tempC;
+ if (hum !== null && deviceReadings.humidity === null) deviceReadings.humidity = hum;
+
// Determine Port Name
let portName = portInfo.portName;
if (!portName || portName.startsWith('Port ')) {
@@ -242,11 +415,24 @@ async function poll() {
if (portName === 'Fan') label = 'Fan Speed';
if (portName === 'Light') label = 'Brightness';
+ deviceReadings.ports.push({
+ port,
+ portName,
+ speak: settings.speak,
+ temp: tempC,
+ hum
+ });
+
console.log(`Saved reading for ${device.devName} (${portName}): ${tempC}°C, ${hum}%, ${label}: ${settings.speak}/10`);
}
}
}
+ currentReadings.push(deviceReadings);
}
+
+ // Evaluate Alarms
+ evaluateAlarms(currentReadings);
+
} catch (error) {
console.error('Polling error:', error.message);
token = null; // Reset token to force re-login
@@ -281,7 +467,7 @@ app.post('/api/auth/login', async (req, res) => {
{ expiresIn: '7d' }
);
- res.json({ token, user: { username: user.username, role: user.role } });
+ res.json({ token, user: { username: user.username, role: user.role, telegramId: user.telegram_id } });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
@@ -299,7 +485,12 @@ app.get('/api/auth/me', (req, res) => {
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET);
- res.json({ user: { username: decoded.username, role: decoded.role } });
+ // Fetch fresh user data to include Telegram ID
+ const user = db.prepare('SELECT username, role, telegram_id FROM users WHERE id = ?').get(decoded.id);
+
+ if (!user) return res.status(404).json({ error: 'User not found' });
+
+ res.json({ user: { username: user.username, role: user.role, telegramId: user.telegram_id } });
} catch (error) {
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Invalid or expired token' });
@@ -308,6 +499,21 @@ app.get('/api/auth/me', (req, res) => {
}
});
+// PUT /api/auth/profile - user updates their own profile
+app.put('/api/auth/profile', requireAuth, (req, res) => {
+ try {
+ const { telegramId } = req.body;
+ if (telegramId === undefined) {
+ return res.status(400).json({ error: 'telegramId is required' });
+ }
+ updateUserTelegramId.run(telegramId, req.user.id);
+ res.json({ success: true, telegramId });
+ } catch (err) {
+ console.error('Profile update error:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
// --- AUTH MIDDLEWARE ---
function optionalAuth(req, res, next) {
const authHeader = req.headers.authorization;
@@ -478,6 +684,103 @@ app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
}
});
+// --- ALARMS API ---
+// GET /api/alarms - public (guests can view)
+app.get('/api/alarms', (req, res) => {
+ try {
+ const rows = getAllAlarms.all();
+ res.json(rows.map(formatRule)); // formatRule works for alarms too as schema is compatible
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// POST /api/alarms - admin only
+app.post('/api/alarms', requireAuth, requireAdmin, (req, res) => {
+ try {
+ const { name, enabled, trigger, action, colorTags } = req.body;
+ if (!name || !trigger || !action) {
+ return res.status(400).json({ error: 'name, trigger, and action required' });
+ }
+
+ // Trigger Type Logic
+ let triggerType = 'combined';
+ if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
+ if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
+
+ const maxOrder = getMaxAlarmSortOrder.get().max_order;
+ const sortOrder = maxOrder + 1;
+
+ const triggerData = JSON.stringify(trigger);
+ const actionData = JSON.stringify(action); // action is { message: "...", severity: "..." }
+ const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
+
+ // action_type fixed to 'telegram'
+ const result = insertAlarm.run(name, enabled ? 1 : 0, triggerType, triggerData, 'telegram', actionData, sortOrder, colorTagsData);
+
+ const newAlarm = getAlarmById.get(result.lastInsertRowid);
+ res.status(201).json(formatRule(newAlarm));
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// PUT /api/alarms/:id - admin only
+app.put('/api/alarms/:id', requireAuth, requireAdmin, (req, res) => {
+ try {
+ const { id } = req.params;
+ const existing = getAlarmById.get(id);
+ if (!existing) return res.status(404).json({ error: 'Alarm not found' });
+
+ const { name, enabled, trigger, action, colorTags } = req.body;
+
+ let triggerType = 'combined';
+ if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
+ if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
+
+ const triggerData = JSON.stringify(trigger);
+ const actionData = JSON.stringify(action);
+ const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
+
+ updateAlarm.run(name, enabled ? 1 : 0, triggerType, triggerData, 'telegram', actionData, colorTagsData, id);
+
+ const updated = getAlarmById.get(id);
+ res.json(formatRule(updated));
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// PUT /api/alarms/reorder - admin only
+app.put('/api/alarms/reorder', requireAuth, requireAdmin, (req, res) => {
+ try {
+ const { alarmIds } = req.body;
+ if (!Array.isArray(alarmIds)) return res.status(400).json({ error: 'alarmIds array required' });
+
+ alarmIds.forEach((id, index) => {
+ updateAlarmOrder.run(index, id);
+ });
+
+ const rows = getAllAlarms.all();
+ res.json(rows.map(formatRule));
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// DELETE /api/alarms/:id - admin only
+app.delete('/api/alarms/:id', requireAuth, requireAdmin, (req, res) => {
+ try {
+ const { id } = req.params;
+ const existing = getAlarmById.get(id);
+ if (!existing) return res.status(404).json({ error: 'Alarm not found' });
+ deleteAlarm.run(id);
+ res.json({ success: true });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
// API: Devices
app.get('/api/devices', (req, res) => {
try {
diff --git a/src/client/AlarmCard.js b/src/client/AlarmCard.js
new file mode 100644
index 0000000..bb67bed
--- /dev/null
+++ b/src/client/AlarmCard.js
@@ -0,0 +1,135 @@
+import React from 'react';
+import {
+ Card,
+ CardContent,
+ Typography,
+ Box,
+ Switch,
+ IconButton,
+ Chip,
+ Tooltip
+} from '@mui/material';
+
+export default function AlarmCard({ alarm, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags, readOnly }) {
+ // Parse trigger/action data to display summary
+ const trigger = alarm.trigger || {};
+ const action = alarm.action || {};
+
+ // Get color for tag
+ const getTagColor = (tagId) => {
+ const tag = colorTags.find(t => t.id === tagId);
+ return tag ? tag.color : 'transparent';
+ };
+
+ const hasTags = alarm.colorTags && alarm.colorTags.length > 0;
+
+ return (
+
+
+
+ {/* Drag Handle / Sort indicators */}
+ {!readOnly && (onMoveUp || onMoveDown) && (
+
+
+ â˛
+
+
+ âŧ
+
+
+ )}
+
+ {/* Enabled Switch */}
+
+
+
+
+
+ {alarm.name}
+
+
+ {/* Tags */}
+ {hasTags && (
+
+ {alarm.colorTags.map(tagId => (
+
+ ))}
+
+ )}
+
+ {action.severity && (
+
+ )}
+
+
+
+ {/* Trigger Summary */}
+ {trigger.scheduledTime ? (
+ â° {trigger.scheduledTime.time} ({trigger.scheduledTime.days.map(d => d.slice(0, 3)).join(',')})
+ ) : trigger.timeRange ? (
+ â° {trigger.timeRange.start}-{trigger.timeRange.end}
+ ) : trigger.sensors ? (
+
+ đ {trigger.sensors.map(s => `${s.sensorLabel || s.sensor} ${s.operator} ${s.value}`).join(trigger.sensorLogic === 'or' ? ' OR ' : ' AND ')}
+
+ ) : (
+ Unknown Trigger
+ )}
+ â
+ đ Telegram: "{action.message || 'Alert'}"
+
+
+
+ {/* Actions */}
+ {!readOnly && (
+
+
+ â
+
+
+ đ
+
+
+ )}
+
+
+ );
+}
diff --git a/src/client/AlarmEditor.js b/src/client/AlarmEditor.js
new file mode 100644
index 0000000..39ea0e7
--- /dev/null
+++ b/src/client/AlarmEditor.js
@@ -0,0 +1,372 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ Button,
+ Box,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ ToggleButton,
+ ToggleButtonGroup,
+ Typography,
+ Divider,
+ Switch,
+ FormControlLabel,
+ CircularProgress,
+ IconButton,
+ Paper,
+ Chip,
+ Alert
+} from '@mui/material';
+import { useI18n } from './I18nContext';
+
+// Reusing some constants/components from RuleEditor logic if possible, but duplicating for isolation as per plan
+const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
+
+const OPERATORS = [
+ { value: '>', label: '>' },
+ { value: '<', label: '<' },
+ { value: '>=', label: 'âĨ' },
+ { value: '<=', label: 'â¤' },
+ { value: '==', label: '=' }
+];
+
+function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
+ return (
+
+
+ Sensor
+
+
+
+
+
+
+ onChange({ ...condition, value: Number(e.target.value) })}
+ sx={{ width: 80 }}
+ disabled={disabled}
+ />
+
+ {onRemove && (
+
+ â
+
+ )}
+
+ );
+}
+
+export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = [], colorTags: availableColorTags = [], saving }) {
+ const { t } = useI18n();
+ const [name, setName] = useState('');
+ const [selectedTags, setSelectedTags] = useState([]);
+
+ // Scheduled time (not commonly used for alarms, but keeping parity with rules engine if needed)
+ // Actually, alarms are usually condition-based (Value > X). Time-based alarms remind you to do something?
+ // Let's keep it simple: SENSORS ONLY for now described in plan ("triggers (based on sensors, time, etc.)")
+ // I'll keep the UI structure but maybe default to Sensors.
+
+ // Simplification: Alarms usually monitor state.
+ // "Time Range" is valid (only alarm between 8am-8pm).
+ // "Scheduled Time" (Alarm at 8am) is basically a Reminder.
+
+ // I will include: Time Range (Active Window) and Sensor Conditions.
+ // I'll omit "Scheduled Time" as a trigger for now unless requested, to reduce complexity,
+ // as "Alarm at 8am" is just an event. The user asked for "similar to alarms".
+
+ const [useTimeRange, setUseTimeRange] = useState(false);
+ const [timeStart, setTimeStart] = useState('08:00');
+ const [timeEnd, setTimeEnd] = useState('18:00');
+ const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
+
+ const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]);
+ const [sensorLogic, setSensorLogic] = useState('and');
+
+ // Action State (Telegram)
+ const [message, setMessage] = useState('');
+ const [severity, setSeverity] = useState('warning');
+
+ useEffect(() => {
+ if (alarm) {
+ setName(alarm.name);
+ const tags = alarm.colorTags || (alarm.colorTag ? [alarm.colorTag] : []);
+ setSelectedTags(Array.isArray(tags) ? tags : []);
+
+ const trigger = alarm.trigger || {};
+
+ setUseTimeRange(!!trigger.timeRange);
+ if (trigger.timeRange) {
+ setTimeStart(trigger.timeRange.start || '08:00');
+ setTimeEnd(trigger.timeRange.end || '18:00');
+ setTimeRangeDays(trigger.timeRange.days || []);
+ }
+
+ if (trigger.sensors && trigger.sensors.length > 0) {
+ setSensorConditions(trigger.sensors);
+ setSensorLogic(trigger.sensorLogic || 'and');
+ } else {
+ setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
+ }
+
+ const action = alarm.action || {};
+ setMessage(action.message || '');
+ setSeverity(action.severity || 'warning');
+
+ } else {
+ setName('');
+ setSelectedTags([]);
+ setUseTimeRange(false);
+ setTimeStart('08:00');
+ setTimeEnd('18:00');
+ setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
+ setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
+ setSensorLogic('and');
+ setMessage('');
+ setSeverity('warning');
+ }
+ }, [alarm, open, sensors]);
+
+ // Default sensor init
+ useEffect(() => {
+ if (sensorConditions[0]?.sensor === '' && sensors.length > 0) {
+ setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]);
+ }
+ }, [sensors, sensorConditions]);
+
+ const addSensorCondition = () => {
+ setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
+ };
+
+ const updateSensorCondition = (index, newCondition) => {
+ const updated = [...sensorConditions];
+ updated[index] = newCondition;
+ setSensorConditions(updated);
+ };
+
+ const removeSensorCondition = (index) => {
+ if (sensorConditions.length > 1) {
+ setSensorConditions(sensorConditions.filter((_, i) => i !== index));
+ }
+ };
+
+ const handleSave = () => {
+ const trigger = {};
+
+ // Always require sensors for an Alarm (otherwise it's just a time-based notification, which is valid too)
+ // Let's assume user wants to monitor something.
+
+ if (useTimeRange) {
+ trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
+ }
+
+ trigger.sensors = sensorConditions.map(c => ({
+ ...c,
+ sensorLabel: sensors.find(s => s.id === c.sensor)?.label
+ }));
+ trigger.sensorLogic = sensorLogic;
+
+ const action = {
+ type: 'telegram',
+ message,
+ severity
+ };
+
+ onSave({ name, trigger, action, colorTags: selectedTags });
+ };
+
+ const isValid = name.trim().length > 0 &&
+ sensorConditions.every(c => c.sensor) &&
+ message.trim().length > 0;
+
+ return (
+
+ );
+}
diff --git a/src/client/AlarmManager.js b/src/client/AlarmManager.js
new file mode 100644
index 0000000..5da52f6
--- /dev/null
+++ b/src/client/AlarmManager.js
@@ -0,0 +1,295 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ Box,
+ Typography,
+ Button,
+ Paper,
+ Divider,
+ Alert,
+ CircularProgress,
+ Chip,
+} from '@mui/material';
+import AlarmCard from './AlarmCard';
+import AlarmEditor from './AlarmEditor';
+import { useAuth } from './AuthContext';
+import { useI18n } from './I18nContext';
+
+const COLOR_TAGS = [
+ { id: 'red', label: 'Red', color: '#fb4934' },
+ { id: 'orange', label: 'Orange', color: '#fe8019' },
+ { id: 'yellow', label: 'Yellow', color: '#fabd2f' },
+ { id: 'green', label: 'Green', color: '#b8bb26' },
+ { id: 'teal', label: 'Teal', color: '#8ec07c' },
+ { id: 'blue', label: 'Blue', color: '#83a598' },
+ { id: 'purple', label: 'Purple', color: '#d3869b' },
+ { id: 'gray', label: 'Gray', color: '#928374' }
+];
+
+export default function AlarmManager() {
+ const { isAdmin } = useAuth();
+ const { t } = useI18n();
+ const [alarms, setAlarms] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [editingAlarm, setEditingAlarm] = useState(null);
+ const [devices, setDevices] = useState([]);
+ const [saving, setSaving] = useState(false);
+ const [filterTag, setFilterTag] = useState(null);
+
+ const getAuthHeaders = useCallback(() => {
+ const token = localStorage.getItem('authToken');
+ return {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ };
+ }, []);
+
+ const fetchAlarms = useCallback(async () => {
+ try {
+ const res = await fetch('api/alarms');
+ if (!res.ok) throw new Error('Failed to fetch alarms');
+ const data = await res.json();
+ setAlarms(data);
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchDevices = useCallback(async () => {
+ try {
+ const res = await fetch('api/devices');
+ if (res.ok) {
+ const data = await res.json();
+ setDevices(data);
+ }
+ } catch (err) {
+ console.error('Failed to fetch devices:', err);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchAlarms();
+ fetchDevices();
+ }, [fetchAlarms, fetchDevices]);
+
+ // Build available sensors (same usage as RuleManager)
+ const availableSensors = [];
+ const seenDevices = new Set();
+
+ devices.forEach(d => {
+ if (!seenDevices.has(d.dev_name)) {
+ seenDevices.add(d.dev_name);
+ availableSensors.push({ id: `${d.dev_name}:temp`, label: `${d.dev_name} - Temperature`, type: 'temp' });
+ availableSensors.push({ id: `${d.dev_name}:humidity`, label: `${d.dev_name} - Humidity`, type: 'humidity' });
+ }
+ availableSensors.push({
+ id: `${d.dev_name}:${d.port}:level`,
+ label: `${d.dev_name} - ${d.port_name} Level`,
+ type: 'level'
+ });
+ });
+
+ const handleAddAlarm = () => {
+ setEditingAlarm(null);
+ setEditorOpen(true);
+ };
+
+ const handleEditAlarm = (alarm) => {
+ setEditingAlarm(alarm);
+ setEditorOpen(true);
+ };
+
+ const handleDeleteAlarm = async (id) => {
+ if (!confirm('Are you sure you want to delete this alarm?')) return;
+ setSaving(true);
+ try {
+ const res = await fetch(`api/alarms/${id}`, {
+ method: 'DELETE',
+ headers: getAuthHeaders()
+ });
+ if (!res.ok) throw new Error('Failed to delete alarm');
+ setAlarms(alarms.filter(a => a.id !== id));
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleToggleAlarm = async (id) => {
+ const alarm = alarms.find(a => a.id === id);
+ if (!alarm) return;
+ setSaving(true);
+ try {
+ const res = await fetch(`api/alarms/${id}`, {
+ method: 'PUT',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ ...alarm, enabled: !alarm.enabled })
+ });
+ if (!res.ok) throw new Error('Failed to update alarm');
+ const updated = await res.json();
+ setAlarms(alarms.map(a => a.id === id ? updated : a));
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleSaveAlarm = async (alarmData) => {
+ setSaving(true);
+ try {
+ if (editingAlarm) {
+ const res = await fetch(`api/alarms/${editingAlarm.id}`, {
+ method: 'PUT',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ ...alarmData, enabled: editingAlarm.enabled })
+ });
+ if (!res.ok) throw new Error('Failed to update alarm');
+ const updated = await res.json();
+ setAlarms(alarms.map(a => a.id === editingAlarm.id ? updated : a));
+ } else {
+ const res = await fetch('api/alarms', {
+ method: 'POST',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ ...alarmData, enabled: true })
+ });
+ if (!res.ok) throw new Error('Failed to create alarm');
+ const newAlarm = await res.json();
+ setAlarms([...alarms, newAlarm]);
+ }
+ setEditorOpen(false);
+ setEditingAlarm(null);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleMoveAlarm = async (id, direction) => {
+ const idx = alarms.findIndex(a => a.id === id);
+ if (idx === -1) return;
+ if (direction === 'up' && idx === 0) return;
+ if (direction === 'down' && idx === alarms.length - 1) return;
+
+ const newAlarms = [...alarms];
+ const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
+ [newAlarms[idx], newAlarms[swapIdx]] = [newAlarms[swapIdx], newAlarms[idx]];
+ setAlarms(newAlarms);
+
+ try {
+ await fetch('api/alarms/reorder', {
+ method: 'PUT',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ alarmIds: newAlarms.map(a => a.id) })
+ });
+ } catch (err) {
+ setError('Failed to save order');
+ }
+ };
+
+ const filteredAlarms = filterTag
+ ? alarms.filter(a => (a.colorTags || []).includes(filterTag))
+ : alarms;
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ đ¨ {t('alarms.title') || 'Alarms'}
+
+
+ {isAdmin ? 'Manage system alarms and notifications.' : 'View active system alarms.'}
+
+
+ {isAdmin && (
+
+ )}
+
+
+
+
+
+ Filter:
+ setFilterTag(null)}
+ sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }}
+ />
+ {COLOR_TAGS.map(tag => (
+ setFilterTag(filterTag === tag.id ? null : tag.id)}
+ sx={{
+ bgcolor: filterTag === tag.id ? tag.color : '#504945',
+ color: filterTag === tag.id ? '#282828' : tag.color,
+ border: `2px solid ${tag.color}`,
+ '&:hover': { bgcolor: tag.color, color: '#282828' }
+ }}
+ />
+ ))}
+
+
+ {error && setError(null)}>{error}}
+
+ {filteredAlarms.length === 0 ? (
+
+ No alarms found.
+
+ ) : (
+
+ {filteredAlarms.map((alarm, idx) => (
+ handleEditAlarm(alarm) : null}
+ onDelete={isAdmin ? () => handleDeleteAlarm(alarm.id) : null}
+ onToggle={isAdmin ? () => handleToggleAlarm(alarm.id) : null}
+ onMoveUp={isAdmin && idx > 0 ? () => handleMoveAlarm(alarm.id, 'up') : null}
+ onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => handleMoveAlarm(alarm.id, 'down') : null}
+ colorTags={COLOR_TAGS}
+ readOnly={!isAdmin}
+ />
+ ))}
+
+ )}
+
+ {isAdmin && (
+ { setEditorOpen(false); setEditingAlarm(null); }}
+ sensors={availableSensors}
+ colorTags={COLOR_TAGS}
+ saving={saving}
+ />
+ )}
+
+ );
+}
diff --git a/src/client/App.js b/src/client/App.js
index e3dab11..418d66f 100644
--- a/src/client/App.js
+++ b/src/client/App.js
@@ -2,7 +2,9 @@ import React, { useState } from 'react';
import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box, Button, Chip } from '@mui/material';
import Dashboard from './Dashboard';
import RuleManager from './RuleManager';
+import AlarmManager from './AlarmManager';
import LoginDialog from './LoginDialog';
+import ProfileDialog from './ProfileDialog';
import { AuthProvider, useAuth } from './AuthContext';
import { I18nProvider, useI18n } from './I18nContext';
import LanguageSwitcher from './LanguageSwitcher';
@@ -48,6 +50,7 @@ function AppContent() {
const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth();
const { t } = useI18n();
const [showLogin, setShowLogin] = useState(false);
+ const [showProfile, setShowProfile] = useState(false);
return (
@@ -62,10 +65,13 @@ function AppContent() {
<>
setShowProfile(true)}
color={isAdmin ? 'secondary' : 'default'}
size="small"
sx={{
fontWeight: 600,
+ cursor: 'pointer',
+ '&:hover': { opacity: 0.8 },
...(isAdmin && {
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
})
@@ -110,6 +116,9 @@ function AppContent() {
{/* Rule Manager visible to everyone (guests read-only, admins can edit) */}
+
+ {/* Alarm Manager visible to everyone (guests read-only, admins can edit) */}
+
{/* Login dialog - shown on demand */}
@@ -117,6 +126,12 @@ function AppContent() {
open={showLogin}
onClose={() => setShowLogin(false)}
/>
+
+ {/* Profile dialog */}
+ setShowProfile(false)}
+ />
);
}
diff --git a/src/client/ProfileDialog.js b/src/client/ProfileDialog.js
new file mode 100644
index 0000000..a1c6941
--- /dev/null
+++ b/src/client/ProfileDialog.js
@@ -0,0 +1,134 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ Button,
+ Box,
+ Typography,
+ Alert,
+ CircularProgress,
+ Link
+} from '@mui/material';
+import { useAuth } from './AuthContext';
+
+export default function ProfileDialog({ open, onClose }) {
+ const { user, login } = useAuth(); // We need a way to refresh user data or update context.
+ // Actually, AuthContext might not expose a "refreshUser" method.
+ // For now we will update the local state and rely on next page load/auth check to refresh global state,
+ // OR we should ideally update the user object in AuthContext.
+ // Checking AuthContext... it has `user` state. It sets user on login/checkAuth.
+ // `checkAuth` is not exposed in `useAuth` return typically?
+ // Let's assume we can just modify the user locally or ignore it, as long as the server has it.
+
+ // Better: Fetch the latest profile data when opening the dialog.
+
+ const [telegramId, setTelegramId] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ fetchProfile();
+ setSuccess(false);
+ setError(null);
+ }
+ }, [open]);
+
+ const fetchProfile = async () => {
+ setLoading(true);
+ try {
+ const token = localStorage.getItem('authToken');
+ const res = await fetch('api/auth/me', {
+ headers: { 'Authorization': `Bearer ${token}` }
+ });
+ if (res.ok) {
+ const data = await res.json();
+ if (data.user) {
+ setTelegramId(data.user.telegramId || '');
+ }
+ }
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ setError(null);
+ setSuccess(false);
+ try {
+ const token = localStorage.getItem('authToken');
+ const res = await fetch('api/auth/profile', {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify({ telegramId })
+ });
+
+ if (!res.ok) {
+ const errData = await res.json().catch(() => ({}));
+ throw new Error(errData.error || 'Failed to update profile');
+ }
+
+ setSuccess(true);
+ setTimeout(() => onClose(), 1500);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/client/i18n/de.json b/src/client/i18n/de.json
index 187e390..668a75a 100644
--- a/src/client/i18n/de.json
+++ b/src/client/i18n/de.json
@@ -90,5 +90,8 @@
"fri": "Fr",
"sat": "Sa",
"sun": "So"
+ },
+ "alarms": {
+ "title": "Alarme"
}
}
\ No newline at end of file
diff --git a/src/client/i18n/en.json b/src/client/i18n/en.json
index e040fce..06ca8ea 100644
--- a/src/client/i18n/en.json
+++ b/src/client/i18n/en.json
@@ -90,5 +90,8 @@
"fri": "Fri",
"sat": "Sat",
"sun": "Sun"
+ },
+ "alarms": {
+ "title": "Alarms"
}
}
\ No newline at end of file