diff --git a/manage-users.js b/manage-users.js
index 8f34cc7..5166945 100644
--- a/manage-users.js
+++ b/manage-users.js
@@ -23,9 +23,9 @@ const updateUserRole = db.prepare('UPDATE users SET role = ? WHERE id = ?');
const updateUserPassword = db.prepare('UPDATE users SET password_hash = ? WHERE id = ?');
const deleteUser = db.prepare('DELETE FROM users WHERE id = ?');
-console.log('\n╔════════════════════════════════════╗');
-console.log('║ 🔐 User Manager - AC Dashboard ║');
-console.log('╚════════════════════════════════════╝\n');
+console.log('\n╔══════════════════════════════════════╗');
+console.log('║ 🔐 User Manager - AC Dashboard ║');
+console.log('╚══════════════════════════════════════╝\n');
async function listUsers() {
const users = getAllUsers.all();
diff --git a/server.js b/server.js
index 073b846..734b5fc 100644
--- a/server.js
+++ b/server.js
@@ -65,6 +65,20 @@ db.exec(`
)
`);
+db.exec(`
+ CREATE TABLE IF NOT EXISTS rules (
+ 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,
+ action_data TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+`);
+
const insertStmt = db.prepare(`
INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -72,6 +86,19 @@ const insertStmt = db.prepare(`
const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
+// Rules prepared statements
+const getAllRules = db.prepare('SELECT * FROM rules ORDER BY id');
+const getRuleById = db.prepare('SELECT * FROM rules WHERE id = ?');
+const insertRule = db.prepare(`
+ INSERT INTO rules (name, enabled, trigger_type, trigger_data, action_type, action_data)
+ VALUES (?, ?, ?, ?, ?, ?)
+`);
+const updateRule = db.prepare(`
+ UPDATE rules SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+`);
+const deleteRule = db.prepare('DELETE FROM rules WHERE id = ?');
+
// --- AC INFINITY API LOGIC ---
let token = null;
@@ -269,6 +296,137 @@ app.get('/api/auth/me', (req, res) => {
}
});
+// --- AUTH MIDDLEWARE ---
+function optionalAuth(req, res, next) {
+ const authHeader = req.headers.authorization;
+ if (authHeader && authHeader.startsWith('Bearer ')) {
+ try {
+ const token = authHeader.split(' ')[1];
+ req.user = jwt.verify(token, JWT_SECRET);
+ } catch (e) {
+ req.user = null;
+ }
+ }
+ next();
+}
+
+function requireAuth(req, res, next) {
+ const authHeader = req.headers.authorization;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+ try {
+ const token = authHeader.split(' ')[1];
+ req.user = jwt.verify(token, JWT_SECRET);
+ next();
+ } catch (e) {
+ return res.status(401).json({ error: 'Invalid or expired token' });
+ }
+}
+
+function requireAdmin(req, res, next) {
+ if (!req.user || req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'Admin access required' });
+ }
+ next();
+}
+
+// --- RULES API ---
+// Helper to format rule for API response
+function formatRule(row) {
+ const trigger = JSON.parse(row.trigger_data);
+ const action = JSON.parse(row.action_data);
+
+ // Add type back for legacy/action compatibility
+ if (action.type === undefined && row.action_type) {
+ action.type = row.action_type;
+ }
+
+ return {
+ id: row.id,
+ name: row.name,
+ enabled: row.enabled === 1,
+ trigger,
+ action
+ };
+}
+
+// GET /api/rules - public (guests can view)
+app.get('/api/rules', (req, res) => {
+ try {
+ const rows = getAllRules.all();
+ res.json(rows.map(formatRule));
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// POST /api/rules - admin only
+app.post('/api/rules', requireAuth, requireAdmin, (req, res) => {
+ try {
+ const { name, enabled, trigger, action } = req.body;
+ if (!name || !trigger || !action) {
+ return res.status(400).json({ error: 'name, trigger, and action required' });
+ }
+
+ // Determine trigger type for storage (combined, time, sensor)
+ 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 result = insertRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData);
+
+ const newRule = getRuleById.get(result.lastInsertRowid);
+ res.status(201).json(formatRule(newRule));
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// PUT /api/rules/:id - admin only
+app.put('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
+ try {
+ const { id } = req.params;
+ const existing = getRuleById.get(id);
+ if (!existing) {
+ return res.status(404).json({ error: 'Rule not found' });
+ }
+
+ const { name, enabled, trigger, action } = req.body;
+
+ // Determine trigger type for storage
+ 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);
+ updateRule.run(name, enabled ? 1 : 0, triggerType, triggerData, action.type, actionData, id);
+
+ const updated = getRuleById.get(id);
+ res.json(formatRule(updated));
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+// DELETE /api/rules/:id - admin only
+app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
+ try {
+ const { id } = req.params;
+ const existing = getRuleById.get(id);
+ if (!existing) {
+ return res.status(404).json({ error: 'Rule not found' });
+ }
+ deleteRule.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/App.js b/src/client/App.js
index fcf2d22..5e141ff 100644
--- a/src/client/App.js
+++ b/src/client/App.js
@@ -104,8 +104,8 @@ function AppContent() {
{/* Dashboard is always visible to everyone */}
- {/* Rule Manager only visible to logged-in admins */}
- {isAdmin && }
+ {/* Rule Manager visible to everyone (guests read-only, admins can edit) */}
+
{/* Login dialog - shown on demand */}
diff --git a/src/client/RuleCard.js b/src/client/RuleCard.js
index 522c0ce..647cfb0 100644
--- a/src/client/RuleCard.js
+++ b/src/client/RuleCard.js
@@ -13,70 +13,97 @@ import {
const EditIcon = () => ✏️;
const DeleteIcon = () => 🗑️;
-const dayLabels = {
- mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S', sun: 'S'
-};
-
+const dayLabels = { mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S', sun: 'S' };
const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
function TriggerSummary({ trigger }) {
- if (trigger.type === 'time') {
+ const parts = [];
+
+ // Time range
+ if (trigger.timeRange) {
+ const { start, end, days } = trigger.timeRange;
+ const isEveryDay = days?.length === 7;
+ const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
+ let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' :
+ dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
+
+ parts.push(
+
+
+
+ {start}–{end} ({dayText})
+
+
+ );
+ }
+
+ // Sensor conditions
+ if (trigger.sensors && trigger.sensors.length > 0) {
+ const logic = trigger.sensorLogic || 'and';
+ const sensorText = trigger.sensors.map((s, i) => (
+
+ {i > 0 && }
+ {s.sensorLabel || s.sensor} {s.operator} {s.value}
+
+ ));
+
+ parts.push(
+
+
+
+ {sensorText}
+
+
+ );
+ }
+
+ // Legacy support for old trigger format
+ if (trigger.type === 'time' && !trigger.timeRange) {
const days = trigger.days || [];
const isEveryDay = days.length === 7;
- const isWeekdays = days.length === 5 &&
- ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
+ const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
+ let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' :
+ dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
- let dayText = isEveryDay ? 'Every day' :
- isWeekdays ? 'Weekdays' :
- dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
+ parts.push(
+
+
+ At {trigger.time} ({dayText})
+
+ );
+ }
- return (
-
-
+ if (trigger.type === 'sensor' && !trigger.sensors) {
+ parts.push(
+
+
- At {trigger.time} • {dayText}
+ {trigger.sensorLabel || trigger.sensor} {trigger.operator} {trigger.value}
);
}
- if (trigger.type === 'sensor') {
- return (
-
-
-
- When {trigger.sensor} {trigger.operator} {trigger.value}
-
-
- );
- }
+ if (parts.length === 0) return null;
- return null;
+ return (
+
+ {parts}
+
+ );
}
function ActionSummary({ action }) {
if (action.type === 'toggle') {
return (
-
+
- Turn {action.target} {action.state ? 'on' : 'off'}
+ → {action.targetLabel || action.target} {action.state ? 'ON' : 'OFF'}
);
@@ -84,14 +111,10 @@ function ActionSummary({ action }) {
if (action.type === 'keepOn') {
return (
-
-
+
+
- Keep {action.target} on for {action.duration} min
+ → {action.targetLabel || action.target} ON for {action.duration}m
);
@@ -100,7 +123,7 @@ function ActionSummary({ action }) {
return null;
}
-export default function RuleCard({ rule, onEdit, onDelete, onToggle }) {
+export default function RuleCard({ rule, onEdit, onDelete, onToggle, readOnly }) {
return (
{!rule.enabled && (
-
+
)}
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {!readOnly && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
);
diff --git a/src/client/RuleEditor.js b/src/client/RuleEditor.js
index 11c24b7..522054a 100644
--- a/src/client/RuleEditor.js
+++ b/src/client/RuleEditor.js
@@ -17,7 +17,11 @@ import {
Divider,
Slider,
Switch,
- FormControlLabel
+ FormControlLabel,
+ CircularProgress,
+ IconButton,
+ Paper,
+ Chip
} from '@mui/material';
const DAYS = [
@@ -30,109 +34,189 @@ const DAYS = [
{ key: 'sun', label: 'Sun' }
];
-const SENSORS = ['Temperature', 'Humidity', 'CO2', 'VPD', 'Light Level'];
const OPERATORS = [
- { value: '>', label: 'Greater than (>)' },
- { value: '<', label: 'Less than (<)' },
- { value: '>=', label: 'Greater or equal (≥)' },
- { value: '<=', label: 'Less or equal (≤)' },
- { value: '==', label: 'Equal to (=)' }
+ { value: '>', label: '>' },
+ { value: '<', label: '<' },
+ { value: '>=', label: '≥' },
+ { value: '<=', label: '≤' },
+ { value: '==', label: '=' }
];
-const OUTPUTS = [
- 'Workshop Light',
- 'Exhaust Fan',
- 'Heater',
- 'Humidifier',
- 'All Outlets',
- 'Grow Light',
- 'Circulation Fan'
-];
+// Single sensor condition component
+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 RuleEditor({ open, rule, onSave, onClose }) {
+export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], saving }) {
const [name, setName] = useState('');
- const [triggerType, setTriggerType] = useState('time');
- // Time trigger state
- const [time, setTime] = useState('08:00');
+ // Time range state
+ const [useTimeRange, setUseTimeRange] = useState(false);
+ const [timeStart, setTimeStart] = useState('08:00');
+ const [timeEnd, setTimeEnd] = useState('18:00');
const [days, setDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
- // Sensor trigger state
- const [sensor, setSensor] = useState('Temperature');
- const [operator, setOperator] = useState('>');
- const [sensorValue, setSensorValue] = useState(25);
+ // Sensor conditions state
+ const [useSensors, setUseSensors] = useState(false);
+ const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]);
+ const [sensorLogic, setSensorLogic] = useState('and'); // 'and' or 'or'
// Action state
const [actionType, setActionType] = useState('toggle');
- const [target, setTarget] = useState('Workshop Light');
+ const [target, setTarget] = useState('');
const [toggleState, setToggleState] = useState(true);
const [duration, setDuration] = useState(15);
- // Reset form when rule changes
+ // Reset form when rule changes or dialog opens
useEffect(() => {
if (rule) {
setName(rule.name);
- setTriggerType(rule.trigger.type);
- if (rule.trigger.type === 'time') {
- setTime(rule.trigger.time);
- setDays(rule.trigger.days || []);
- } else {
- setSensor(rule.trigger.sensor);
- setOperator(rule.trigger.operator);
- setSensorValue(rule.trigger.value);
+ // Parse trigger
+ const trigger = rule.trigger || {};
+ setUseTimeRange(!!trigger.timeRange);
+ if (trigger.timeRange) {
+ setTimeStart(trigger.timeRange.start || '08:00');
+ setTimeEnd(trigger.timeRange.end || '18:00');
+ setDays(trigger.timeRange.days || []);
}
- setActionType(rule.action.type);
- setTarget(rule.action.target);
- if (rule.action.type === 'toggle') {
- setToggleState(rule.action.state);
+ setUseSensors(!!trigger.sensors && trigger.sensors.length > 0);
+ if (trigger.sensors && trigger.sensors.length > 0) {
+ setSensorConditions(trigger.sensors);
+ setSensorLogic(trigger.sensorLogic || 'and');
+ }
+
+ // Parse action
+ setActionType(rule.action?.type || 'toggle');
+ setTarget(rule.action?.target || '');
+ if (rule.action?.type === 'toggle') {
+ setToggleState(rule.action?.state ?? true);
} else {
- setDuration(rule.action.duration);
+ setDuration(rule.action?.duration || 15);
}
} else {
- // Reset to defaults for new rule
+ // Reset to defaults
setName('');
- setTriggerType('time');
- setTime('08:00');
+ setUseTimeRange(true);
+ setTimeStart('08:00');
+ setTimeEnd('18:00');
setDays(['mon', 'tue', 'wed', 'thu', 'fri']);
- setSensor('Temperature');
- setOperator('>');
- setSensorValue(25);
+ setUseSensors(false);
+ setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
+ setSensorLogic('and');
setActionType('toggle');
- setTarget('Workshop Light');
+ setTarget(outputs[0]?.id || '');
setToggleState(true);
setDuration(15);
}
- }, [rule, open]);
+ }, [rule, open, sensors, outputs]);
+
+ // Set default sensor/output when lists load
+ useEffect(() => {
+ if (sensorConditions[0]?.sensor === '' && sensors.length > 0) {
+ setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]);
+ }
+ if (!target && outputs.length > 0) setTarget(outputs[0].id);
+ }, [sensors, outputs, sensorConditions, target]);
const handleDaysChange = (event, newDays) => {
- if (newDays.length > 0) {
- setDays(newDays);
+ if (newDays.length > 0) setDays(newDays);
+ };
+
+ 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 selectedOutput = outputs.find(o => o.id === target);
+
+ // Build trigger object
+ const trigger = {};
+ if (useTimeRange) {
+ trigger.timeRange = { start: timeStart, end: timeEnd, days };
+ }
+ if (useSensors && sensorConditions.length > 0) {
+ trigger.sensors = sensorConditions.map(c => ({
+ ...c,
+ sensorLabel: sensors.find(s => s.id === c.sensor)?.label
+ }));
+ trigger.sensorLogic = sensorLogic;
+ }
+
const ruleData = {
name,
- trigger: triggerType === 'time'
- ? { type: 'time', time, days }
- : { type: 'sensor', sensor, operator, value: sensorValue },
+ trigger,
action: actionType === 'toggle'
- ? { type: 'toggle', target, state: toggleState }
- : { type: 'keepOn', target, duration }
+ ? { type: 'toggle', target, targetLabel: selectedOutput?.label, state: toggleState }
+ : { type: 'keepOn', target, targetLabel: selectedOutput?.label, duration }
};
onSave(ruleData);
};
const isValid = name.trim().length > 0 &&
- (triggerType !== 'time' || days.length > 0);
+ (useTimeRange || useSensors) &&
+ (!useTimeRange || days.length > 0) &&
+ (!useSensors || sensorConditions.every(c => c.sensor)) &&
+ target;
return (
diff --git a/src/client/RuleManager.js b/src/client/RuleManager.js
index 6d9aecf..da0190f 100644
--- a/src/client/RuleManager.js
+++ b/src/client/RuleManager.js
@@ -1,68 +1,123 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Typography,
Button,
Paper,
- Divider
+ Divider,
+ Alert,
+ CircularProgress
} from '@mui/material';
import RuleCard from './RuleCard';
import RuleEditor from './RuleEditor';
-
-// Initial mock rules for demonstration
-const initialRules = [
- {
- id: 1,
- name: 'Morning Light',
- enabled: true,
- trigger: {
- type: 'time',
- time: '06:30',
- days: ['mon', 'tue', 'wed', 'thu', 'fri']
- },
- action: {
- type: 'toggle',
- target: 'Workshop Light',
- state: true
- }
- },
- {
- id: 2,
- name: 'High Humidity Fan',
- enabled: true,
- trigger: {
- type: 'sensor',
- sensor: 'Humidity',
- operator: '>',
- value: 70
- },
- action: {
- type: 'keepOn',
- target: 'Exhaust Fan',
- duration: 15
- }
- },
- {
- id: 3,
- name: 'Evening Shutdown',
- enabled: false,
- trigger: {
- type: 'time',
- time: '18:00',
- days: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
- },
- action: {
- type: 'toggle',
- target: 'All Outlets',
- state: false
- }
- }
-];
+import { useAuth } from './AuthContext';
export default function RuleManager() {
- const [rules, setRules] = useState(initialRules);
+ const { isAdmin } = useAuth();
+ const [rules, setRules] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editingRule, setEditingRule] = useState(null);
+ const [devices, setDevices] = useState([]);
+ const [saving, setSaving] = useState(false);
+
+ // Get auth token from localStorage
+ const getAuthHeaders = useCallback(() => {
+ const token = localStorage.getItem('authToken');
+ return {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ };
+ }, []);
+
+ // Fetch rules from server (public endpoint)
+ const fetchRules = useCallback(async () => {
+ try {
+ const res = await fetch('api/rules');
+ if (!res.ok) {
+ throw new Error('Failed to fetch rules');
+ }
+ const data = await res.json();
+ setRules(data);
+ setError(null);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // Fetch devices for sensor/output selection
+ 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(() => {
+ fetchRules();
+ fetchDevices();
+ }, [fetchRules, fetchDevices]);
+
+ // Build available sensors
+ // - Environment sensors (Temp, Humidity) are per DEVICE
+ // - Port values (Fan Speed, Brightness, CO2, etc.) are per PORT
+ const availableSensors = [];
+ const seenDevices = new Set();
+
+ devices.forEach(d => {
+ // Add environment sensors once per device
+ if (!seenDevices.has(d.dev_name)) {
+ seenDevices.add(d.dev_name);
+ availableSensors.push({
+ id: `${d.dev_name}:temp`,
+ label: `${d.dev_name} - Temperature`,
+ type: 'temperature'
+ });
+ availableSensors.push({
+ id: `${d.dev_name}:humidity`,
+ label: `${d.dev_name} - Humidity`,
+ type: 'humidity'
+ });
+ }
+
+ // Add each port as a sensor (Fan Speed, Brightness, CO2, etc.)
+ availableSensors.push({
+ id: `${d.dev_name}:${d.port}:level`,
+ label: `${d.dev_name} - ${d.port_name} Level`,
+ type: d.port_name.toLowerCase()
+ });
+ });
+
+ // Build available outputs: Tapo plugs + device fans/lights
+ const availableOutputs = [
+ { id: 'tapo-001', label: 'Tapo 001', type: 'plug' },
+ { id: 'tapo-002', label: 'Tapo 002', type: 'plug' },
+ { id: 'tapo-003', label: 'Tapo 003', type: 'plug' },
+ { id: 'tapo-004', label: 'Tapo 004', type: 'plug' },
+ { id: 'tapo-005', label: 'Tapo 005', type: 'plug' },
+ ...devices
+ .filter(d => d.port_name === 'Fan')
+ .map(d => ({
+ id: `${d.dev_name}:fan:${d.port}`,
+ label: `${d.dev_name} - Fan`,
+ type: 'fan'
+ })),
+ ...devices
+ .filter(d => d.port_name === 'Light')
+ .map(d => ({
+ id: `${d.dev_name}:light:${d.port}`,
+ label: `${d.dev_name} - Light`,
+ type: 'light'
+ }))
+ ];
const handleAddRule = () => {
setEditingRule(null);
@@ -74,33 +129,76 @@ export default function RuleManager() {
setEditorOpen(true);
};
- const handleDeleteRule = (ruleId) => {
- setRules(rules.filter(r => r.id !== ruleId));
- };
+ const handleDeleteRule = async (ruleId) => {
+ if (!confirm('Delete this rule?')) return;
- const handleToggleRule = (ruleId) => {
- setRules(rules.map(r =>
- r.id === ruleId ? { ...r, enabled: !r.enabled } : r
- ));
- };
-
- const handleSaveRule = (ruleData) => {
- if (editingRule) {
- // Update existing rule
- setRules(rules.map(r =>
- r.id === editingRule.id ? { ...r, ...ruleData } : r
- ));
- } else {
- // Add new rule
- const newRule = {
- ...ruleData,
- id: Math.max(0, ...rules.map(r => r.id)) + 1,
- enabled: true
- };
- setRules([...rules, newRule]);
+ setSaving(true);
+ try {
+ const res = await fetch(`api/rules/${ruleId}`, {
+ method: 'DELETE',
+ headers: getAuthHeaders()
+ });
+ if (!res.ok) throw new Error('Failed to delete rule');
+ setRules(rules.filter(r => r.id !== ruleId));
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleToggleRule = async (ruleId) => {
+ const rule = rules.find(r => r.id === ruleId);
+ if (!rule) return;
+
+ setSaving(true);
+ try {
+ const res = await fetch(`api/rules/${ruleId}`, {
+ method: 'PUT',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ ...rule, enabled: !rule.enabled })
+ });
+ if (!res.ok) throw new Error('Failed to update rule');
+ const updated = await res.json();
+ setRules(rules.map(r => r.id === ruleId ? updated : r));
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleSaveRule = async (ruleData) => {
+ setSaving(true);
+ try {
+ if (editingRule) {
+ // Update existing rule
+ const res = await fetch(`api/rules/${editingRule.id}`, {
+ method: 'PUT',
+ headers: getAuthHeaders(),
+ body: JSON.stringify(ruleData)
+ });
+ if (!res.ok) throw new Error('Failed to update rule');
+ const updated = await res.json();
+ setRules(rules.map(r => r.id === editingRule.id ? updated : r));
+ } else {
+ // Create new rule
+ const res = await fetch('api/rules', {
+ method: 'POST',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ ...ruleData, enabled: true })
+ });
+ if (!res.ok) throw new Error('Failed to create rule');
+ const newRule = await res.json();
+ setRules([...rules, newRule]);
+ }
+ setEditorOpen(false);
+ setEditingRule(null);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
}
- setEditorOpen(false);
- setEditingRule(null);
};
const handleCloseEditor = () => {
@@ -108,6 +206,15 @@ export default function RuleManager() {
setEditingRule(null);
};
+ if (loading) {
+ return (
+
+
+ Loading rules...
+
+ );
+ }
+
return (
- Configure triggers and actions for home automation
+ {isAdmin ? 'Configure triggers and actions for home automation' : 'View automation rules (read-only)'}
-
- + Add Rule
-
+ {isAdmin && (
+
+ + Add Rule
+
+ )}
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
{rules.length === 0 ? (
- No rules configured. Click "Add Rule" to create one.
+ No rules configured. {isAdmin && 'Click "Add Rule" to create one.'}
) : (
@@ -154,20 +270,26 @@ export default function RuleManager() {
handleEditRule(rule)}
- onDelete={() => handleDeleteRule(rule.id)}
- onToggle={() => handleToggleRule(rule.id)}
+ onEdit={isAdmin ? () => handleEditRule(rule) : null}
+ onDelete={isAdmin ? () => handleDeleteRule(rule.id) : null}
+ onToggle={isAdmin ? () => handleToggleRule(rule.id) : null}
+ readOnly={!isAdmin}
/>
))}
)}
-
+ {isAdmin && (
+
+ )}
);
}