diff --git a/public/css/portal.css b/public/css/portal.css index 3edd74b6..fb1b44d9 100644 --- a/public/css/portal.css +++ b/public/css/portal.css @@ -395,6 +395,17 @@ body::before{ .midea-login-hint a:hover{filter:brightness(1.05)} .midea-msg{font-size:13px;color:var(--muted);padding:10px 0;line-height:1.5} +/* ============================================================ + SMART HOME WIDGET + ============================================================ */ +.c-sh-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px} +.c-sh-card{background:var(--surface-2,#16212e);border:1px solid var(--line,rgba(255,255,255,.08));border-radius:12px;padding:12px} +.c-sh-name{font-weight:600;font-size:14px;margin-bottom:10px} +.c-sh-sw{border:1px solid var(--line-2,rgba(255,255,255,.14));border-radius:8px;padding:6px 12px;cursor:pointer;background:var(--surface-3,#1b2836);color:inherit} +.c-sh-sw.on{background:linear-gradient(145deg,var(--green,#4ade80),#15924f);border-color:transparent;color:#fff} +.c-sh-bri{width:100%;margin-top:10px} +.c-sh-msg{margin-top:10px;font-size:13px;color:var(--muted,#90a1b3)} + /* ============================================================ REDUCED MOTION ============================================================ */ diff --git a/public/css/smarthome.css b/public/css/smarthome.css index 0c136ab5..a75d3a13 100644 --- a/public/css/smarthome.css +++ b/public/css/smarthome.css @@ -26,3 +26,10 @@ input[type=range].sh-bri::-webkit-slider-thumb{-webkit-appearance:none;width:19p .sh-sensorval{font-weight:800;font-size:26px;margin-top:10px} .sh-empty{color:var(--faint,#5f6f7e);font-size:13px;padding:20px 0} #sh-test-result{font-size:13px;font-weight:600;padding:8px 12px;border-radius:8px;margin-bottom:12px} +.sh-owner-sub{font-size:12px;color:var(--muted,#90a1b3);margin-bottom:10px} +.sh-owner-list{margin-top:10px;max-height:300px;overflow:auto;display:flex;flex-direction:column;gap:4px} +.sh-owner-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer} +.sh-owner-row:hover{background:var(--surface-3,#1b2836)} +.sh-owner-row input{width:16px;height:16px} +.sh-owner-btn{margin-top:10px;font-size:12px;color:var(--accent,#8b9cff);background:none;border:none;cursor:pointer;padding:0;display:flex;align-items:center;gap:6px} +.sh-owner-chips{font-size:12px;color:var(--muted,#90a1b3);margin-top:6px} diff --git a/public/js/portal.js b/public/js/portal.js index 0e51581b..bac659f6 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -678,6 +678,52 @@ if (patch) mideaControl(cardEl, patch); } + // ─── Smart Home widget ────────────────────────────────────────────────────── + function shControl(id, patch, card) { + fetch('/api/v1/portal/smarthome/' + Number(id) + '/state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ patch: patch }) + }).then(function (r) { return r.json(); }).then(function (j) { + if (j && j.reason === 'login_required') { showSmarthomeLogin(card); } + }).catch(function () {}); + } + function showSmarthomeLogin(card) { + var msg = document.getElementById('smarthomeMsg'); + if (msg) { msg.style.display = 'block'; msg.textContent = PT.smarthomeLoginToControl || 'Login required'; } + } + function renderSmarthomeCard(d) { + var el = document.createElement('div'); el.className = 'c-sh-card'; + var st = d.state || {}, caps = d.capabilities || {}; + var name = document.createElement('div'); name.className = 'c-sh-name'; name.textContent = d.name || ''; el.appendChild(name); + if (d.kind === 'scene') { + var b = document.createElement('button'); b.className = 'btn btn-sm'; b.textContent = PT.smarthomeActivate || 'Activate'; + b.addEventListener('click', function () { shControl(d.id, {}, el); }); + el.appendChild(b); return el; + } + var sw = document.createElement('button'); sw.className = 'c-sh-sw' + (st.on ? ' on' : ''); + sw.textContent = PT.smarthomePower || 'Power'; + sw.addEventListener('click', function () { var on = !sw.classList.contains('on'); sw.classList.toggle('on', on); shControl(d.id, { on: on }, el); }); + el.appendChild(sw); + if (caps.bri) { + var range = document.createElement('input'); range.type = 'range'; range.min = 0; range.max = 100; range.value = (st.bri != null ? st.bri : 0); range.className = 'c-sh-bri'; + range.addEventListener('change', function () { shControl(d.id, { bri: Number(range.value) }, el); }); + el.appendChild(range); + } + return el; + } + function hydrateSmarthome() { + var card = document.querySelector('.c-smarthome'); + if (!card) return; + fetch('/api/v1/portal/smarthome').then(function (r) { return r.json(); }).then(function (j) { + var list = document.getElementById('smarthome-list'); if (!list) return; + list.innerHTML = ''; + if (!j || !j.data || !j.data.devices || !j.data.devices.length) { card.style.display = 'none'; return; } + card.style.display = ''; + j.data.devices.forEach(function (d) { list.appendChild(renderSmarthomeCard(d)); }); + }).catch(function () { card.style.display = 'none'; }); + } + function hydrateMidea() { var card = document.querySelector('.c-midea'); if (!card) return; @@ -737,5 +783,6 @@ hydrateServices(); hydratePihole(); hydrateMidea(); + hydrateSmarthome(); })(); diff --git a/public/js/settings.js b/public/js/settings.js index 7b72db4e..32bbb90b 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1770,11 +1770,12 @@ var widgetServices = document.getElementById('portal-widget-services'); var widgetPihole = document.getElementById('portal-widget-pihole'); var widgetMidea = document.getElementById('portal-widget-midea'); + var widgetSmarthome = document.getElementById('portal-widget-smarthome'); var trustToggle = document.getElementById('portal-trust-owner-mapping'); var autoappearToggle = document.getElementById('portal-autoappear'); if (!enabledToggle) return; - [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, trustToggle, autoappearToggle].forEach(function (el) { + [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, widgetSmarthome, trustToggle, autoappearToggle].forEach(function (el) { if (el) el.addEventListener('click', function () { el.classList.toggle('on'); el.dispatchEvent(new Event('change')); @@ -1795,6 +1796,7 @@ setToggle(widgetServices, d.widgets && d.widgets.services); setToggle(widgetPihole, d.widgets && d.widgets.pihole); setToggle(widgetMidea, d.widgets && d.widgets.midea); + setToggle(widgetSmarthome, d.widgets && d.widgets.smarthome); setToggle(trustToggle, d.trustOwnerMapping); setToggle(autoappearToggle, d.autoappear !== false); if (window.SettingsAutosave && SettingsAutosave.resync) SettingsAutosave.resync('portal'); @@ -1802,7 +1804,7 @@ console.error('Failed to load portal settings:', err); }); - var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, trustToggle, autoappearToggle].filter(Boolean); + var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, widgetSmarthome, trustToggle, autoappearToggle].filter(Boolean); SettingsAutosave.bind({ cluster: 'portal', fields: portalFields, @@ -1815,6 +1817,7 @@ 'portal-widget-services': widgetServices ? widgetServices.classList.contains('on') : true, 'portal-widget-pihole': widgetPihole ? widgetPihole.classList.contains('on') : true, 'portal-widget-midea': widgetMidea ? widgetMidea.classList.contains('on') : true, + 'portal-widget-smarthome': widgetSmarthome ? widgetSmarthome.classList.contains('on') : true, 'portal-trust-owner-mapping': trustToggle ? trustToggle.classList.contains('on') : false, 'portal-autoappear': autoappearToggle ? autoappearToggle.classList.contains('on') : true, }; @@ -1828,6 +1831,7 @@ services: widgetServices ? widgetServices.classList.contains('on') : true, pihole: widgetPihole ? widgetPihole.classList.contains('on') : true, midea: widgetMidea ? widgetMidea.classList.contains('on') : true, + smarthome: widgetSmarthome ? widgetSmarthome.classList.contains('on') : true, }, trust_owner_mapping: trustToggle ? trustToggle.classList.contains('on') : false, autoappear: autoappearToggle ? autoappearToggle.classList.contains('on') : true, diff --git a/public/js/smarthome.js b/public/js/smarthome.js index 91e6b930..49a1f25c 100644 --- a/public/js/smarthome.js +++ b/public/js/smarthome.js @@ -100,6 +100,16 @@ wrap.querySelector('input').addEventListener('change', (e) => send(r.id, { ct: Number(e.target.value) }).catch(() => {})); body.appendChild(wrap); } + if (r.kind === 'light' || r.kind === 'plug' || r.kind === 'group') { + const own = document.createElement('div'); + const names = (r.owners || []).map((o) => o.username); + own.innerHTML = `
${names.length ? esc(names.join(', ')) : esc(T('smarthome.owners.none'))}
`; + const btn = document.createElement('button'); btn.className = 'sh-owner-btn'; btn.type = 'button'; + btn.textContent = T('smarthome.owners.manage'); + btn.addEventListener('click', () => openOwners(r)); + own.appendChild(btn); + body.appendChild(own); + } el.appendChild(body); return el; } @@ -110,6 +120,11 @@ btn.textContent = T('smarthome.activate'); btn.addEventListener('click', () => send(r.id, {}).catch(() => {})); el.appendChild(btn); + if (r.owners && r.owners.length) { + const chip = document.createElement('div'); chip.className = 'sh-owner-chips'; + chip.textContent = r.owners.map((o) => o.username).join(', '); + el.appendChild(chip); + } return el; } @@ -201,6 +216,7 @@ const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return; if ($('#sh-connect-modal') && $('#sh-connect-modal').style.display === 'flex') return; + if ($('#sh-owner-modal') && $('#sh-owner-modal').style.display === 'flex') return; const sel = $('#sh-gateway-select'); loadResources(sel && sel.value ? Number(sel.value) : undefined); }, 30000); @@ -266,6 +282,55 @@ }); } - document.addEventListener('DOMContentLoaded', () => { wireModal(); fillRoutes(); wireConnect(); wireTest(); loadGateways(); startAutoPoll(); }); + let allUsers = null; + async function fetchUsers() { + if (allUsers) return allUsers; + try { + // /api/v1/users returns { ok:true, users:[...] } + const r = await (await fetch('/api/v1/users')).json(); allUsers = (r.users || []).map((u) => ({ id: u.id, username: u.username })); + } catch (_) { allUsers = []; } + return allUsers; + } + let ownerTarget = null; + async function openOwners(r) { + ownerTarget = r; + const modal = $('#sh-owner-modal'); if (!modal) return; + $('#sh-owner-sub').textContent = r.name || ''; + $('#sh-owner-search').value = ''; + const users = await fetchUsers(); + const ownedIds = new Set((r.owners || []).map((o) => o.id)); + const list = $('#sh-owner-list'); + list.innerHTML = ''; + users.forEach((u) => { + const row = document.createElement('label'); row.className = 'sh-owner-row'; row.dataset.name = (u.username || '').toLowerCase(); + const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = String(u.id); cb.checked = ownedIds.has(u.id); + const span = document.createElement('span'); span.textContent = u.username; + row.appendChild(cb); row.appendChild(span); list.appendChild(row); + }); + modal.style.display = 'flex'; + } + function wireOwners() { + const search = $('#sh-owner-search'); + if (search) search.addEventListener('input', () => { + const q = search.value.toLowerCase(); + document.querySelectorAll('#sh-owner-list .sh-owner-row').forEach((row) => { + row.style.display = row.dataset.name.includes(q) ? '' : 'none'; + }); + }); + const save = $('#sh-owner-save'); + if (save) save.addEventListener('click', async () => { + if (!ownerTarget) return; + const ids = [...document.querySelectorAll('#sh-owner-list input:checked')].map((c) => Number(c.value)); + try { + await api(`/resources/${ownerTarget.id}/owners`, { method: 'PUT', body: JSON.stringify({ userIds: ids }) }); + $('#sh-owner-modal').style.display = 'none'; + const sel = $('#sh-gateway-select'); await loadResources(sel && sel.value ? Number(sel.value) : undefined); + } catch (e) { alert(e.message); } + }); + document.querySelectorAll('[data-sh-close-owner]').forEach((el) => + el.addEventListener('click', () => { const m = $('#sh-owner-modal'); if (m) m.style.display = 'none'; })); + } + + document.addEventListener('DOMContentLoaded', () => { wireModal(); fillRoutes(); wireConnect(); wireTest(); loadGateways(); startAutoPoll(); wireOwners(); }); window.SmartHome = { loadGateways, loadResources, api }; })(); diff --git a/src/i18n/de.json b/src/i18n/de.json index 0b2e22a4..6680be8d 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1928,6 +1928,7 @@ "settings.portal.widget_services": "Dienste", "settings.portal.widget_pihole": "Pi-hole-Widget", "settings.portal.widget_midea": "Klima-Widget", + "settings.portal.widget_smarthome": "Smart Home", "settings.portal.autoappear": "Portal automatisch öffnen beim Verbinden", "settings.portal.autoappear_help": "Wenn aktiv, öffnen VPN-Clients das Portal nach dem Verbinden automatisch. Deaktivieren, um das Portal erreichbar zu halten, ohne es automatisch zu öffnen.", "settings.portal.trust_owner_mapping": "Gerät→Besitzer-Vertrauen (pro-Besitzer ohne Login)", @@ -2087,6 +2088,10 @@ "error.smarthome.link_button": "Phoscon-Link-Fenster abgelaufen — „App authentifizieren“ erneut drücken", "error.smarthome.no_route": "Gewählte Route ist nicht auflösbar", "error.smarthome.no_api_key": "Gateway hat keinen API-Key — erneut verbinden", + "error.smarthome.not_assignable": "Dieser Gerätetyp kann keinen Nutzern zugewiesen werden", + "error.smarthome.owner_unknown_user": "Unbekannter Nutzer in der Besitzerliste", + "error.smarthome.user_ids_required": "userIds muss ein Array sein", + "error.smarthome.resource_not_found": "Smart-Home-Ressource nicht gefunden", "smarthome.title": "Smart Home", "smarthome.subtitle": "Phoscon/deCONZ-Lichter und -Sensoren einbinden, Haushaltsmitgliedern zuweisen und Logikketten erstellen.", "smarthome.eyebrow": "System · Smart Home", @@ -2135,11 +2140,21 @@ "smarthome.connect.apikey_ph": "leer lassen für Auto-Holen", "smarthome.connect.apikey_hint": "Leer = GateControl holt den Key automatisch (vorher in Phoscon „App authentifizieren“).", "smarthome.connect.acquire": "Verbinden & Key holen", + "smarthome.owners.title": "Besitzer zuweisen", + "smarthome.owners.manage": "Besitzer verwalten", + "smarthome.owners.none": "Niemand zugewiesen", + "smarthome.owners.save": "Speichern", + "smarthome.owners.search": "Nutzer suchen…", "portal.midea.fan": "Lüfter", "portal.midea.fan_auto": "Auto", "portal.midea.fan_silent": "Silent", "portal.midea.extras": "Extras", "portal.midea.turbo": "Turbo", "portal.midea.eco": "Eco", - "portal.midea.outdoor": "Außen" + "portal.midea.outdoor": "Außen", + "portal.smarthome.title": "Smart Home", + "portal.smarthome.power": "Ein/Aus", + "portal.smarthome.brightness": "Helligkeit", + "portal.smarthome.activate": "Aktivieren", + "portal.smarthome.login_to_control": "Zum Steuern bitte anmelden" } diff --git a/src/i18n/en.json b/src/i18n/en.json index 9163dc0b..591b2132 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1984,6 +1984,7 @@ "settings.portal.widget_services": "Services", "settings.portal.widget_pihole": "Pi-hole widget", "settings.portal.widget_midea": "Climate widget", + "settings.portal.widget_smarthome": "Smart Home", "settings.portal.autoappear": "Auto-open portal on connect", "settings.portal.autoappear_help": "When enabled, VPN clients automatically open the portal after connecting. Disable to keep the portal accessible but not auto-opened.", "settings.portal.trust_owner_mapping": "Trust device→owner mapping (zero-login per-owner)", @@ -2143,6 +2144,10 @@ "error.smarthome.link_button": "Phoscon link window expired — press \"Authenticate app\" again", "error.smarthome.no_route": "Selected route cannot be resolved", "error.smarthome.no_api_key": "Gateway has no API key — connect again", + "error.smarthome.not_assignable": "This device type cannot be assigned to users", + "error.smarthome.owner_unknown_user": "Unknown user in owner list", + "error.smarthome.user_ids_required": "userIds must be an array", + "error.smarthome.resource_not_found": "Smart Home resource not found", "smarthome.title": "Smart Home", "smarthome.subtitle": "Connect Phoscon/deCONZ lights and sensors, assign to household members, and create automation rules.", "smarthome.eyebrow": "System · Smart Home", @@ -2191,11 +2196,21 @@ "smarthome.connect.apikey_ph": "leave empty for auto-fetch", "smarthome.connect.apikey_hint": "Empty = GateControl fetches the key automatically (first authenticate the app in Phoscon).", "smarthome.connect.acquire": "Connect & fetch key", + "smarthome.owners.title": "Assign owners", + "smarthome.owners.manage": "Manage owners", + "smarthome.owners.none": "None assigned", + "smarthome.owners.save": "Save", + "smarthome.owners.search": "Search users…", "portal.midea.fan": "Fan", "portal.midea.fan_auto": "Auto", "portal.midea.fan_silent": "Silent", "portal.midea.extras": "Extras", "portal.midea.turbo": "Turbo", "portal.midea.eco": "Eco", - "portal.midea.outdoor": "Outdoor" + "portal.midea.outdoor": "Outdoor", + "portal.smarthome.title": "Smart Home", + "portal.smarthome.power": "Power", + "portal.smarthome.brightness": "Brightness", + "portal.smarthome.activate": "Activate", + "portal.smarthome.login_to_control": "Log in to control" } diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index 86c33f8c..78b43abc 100644 --- a/src/routes/api/portal.js +++ b/src/routes/api/portal.js @@ -12,6 +12,8 @@ const license = require('../../services/license'); const mideaOwners = require('../../services/midea/mideaOwners'); const midea = require('../../services/midea'); const mideaDevices = require('../../services/midea/mideaDevices'); +const smarthomeOwners = require('../../services/smarthome/smarthomeOwners'); +const smarthome = require('../../services/smarthome'); const router = Router(); @@ -297,4 +299,66 @@ router.post('/midea/:id/state', async (req, res) => { } }); +function smarthomeUnavailable() { + return !license.hasFeature('smarthome'); +} +const SH_STATE_KEYS = new Set(['on', 'bri', 'reachable']); +function redactState(s) { + if (!s || typeof s !== 'object') return {}; + return Object.fromEntries(Object.entries(s).filter(([k]) => SH_STATE_KEYS.has(k))); +} +// Portal redaction: only controllable surface + resource id. NEVER gateway/route/deconz internals. +function redactSmarthomeResource(r) { + return { id: r.id, kind: r.kind, name: r.name, capabilities: r.capabilities || {}, state: redactState(r.state) }; +} +function validateSmarthomePatch(raw, caps) { + if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) return {}; // scene/empty = ok + const c = caps || {}; const patch = {}; + if ('on' in raw) { if (typeof raw.on !== 'boolean') return null; patch.on = raw.on; } + if ('bri' in raw && c.bri) { const b = Number(raw.bri); if (!Number.isFinite(b) || b < 0 || b > 100) return null; patch.bri = b; } + return patch; +} + +// GET /smarthome — owner-scoped controllable resources (trust allowed, view). Owner id from middleware only. +router.get('/smarthome', async (req, res) => { + try { + if (!portalConfig().widgets.smarthome) return res.status(404).json({ ok: false }); + if (smarthomeUnavailable()) return res.json({ ok: true, data: null, reason: 'unavailable' }); + if (req.portalOwnerId == null) return res.json({ ok: true, data: null, reason: 'no_owner' }); + const ids = new Set(smarthomeOwners.resourcesOwnedBy(req.portalOwnerId)); + if (!ids.size) return res.json({ ok: true, data: null, reason: 'no_data' }); + const all = await smarthome.getResources(); + const devices = all + .filter((r) => r.enabled && ids.has(r.id) && r.kind !== 'sensor' && r.kind !== 'switch') + .map(redactSmarthomeResource); + if (!devices.length) return res.json({ ok: true, data: null, reason: 'no_data' }); + res.json({ ok: true, data: { devices, loggedIn: req.portalLoggedIn } }); + } catch (err) { + logger.error({ error: err.message }, 'portal /smarthome failed'); + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } +}); + +// POST /smarthome/:id/state — control. Login required (trust does NOT control) + ownership. +// ponytail: canAccess not isOwner — scene control inherits group-owner access +router.post('/smarthome/:id/state', async (req, res) => { + try { + if (!portalConfig().widgets.smarthome) return res.status(404).json({ ok: false }); + if (smarthomeUnavailable()) return res.json({ ok: true, data: null, reason: 'unavailable' }); + if (!req.portalLoggedIn) return res.json({ ok: true, data: null, reason: 'login_required' }); + const id = Number(req.params.id); + if (!smarthomeOwners.canAccess(id, req.session.userId)) return res.status(403).json({ ok: false, error: 'SMARTHOME_NOT_OWNER' }); + const all = await smarthome.getResources(); + const resource = all.find((r) => r.id === id); + if (!resource || !resource.enabled) return res.status(404).json({ ok: false, error: 'SMARTHOME_RESOURCE_NOT_FOUND' }); + const patch = validateSmarthomePatch(req.body && req.body.patch, resource.capabilities); + if (patch === null) return res.status(400).json({ ok: false, error: 'SMARTHOME_INVALID_PATCH' }); + await smarthome.setResourceState(id, patch); + res.json({ ok: true }); + } catch (err) { + logger.error({ error: err.message }, 'portal /smarthome control failed'); + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } +}); + module.exports = router; diff --git a/src/routes/api/settings/portal.js b/src/routes/api/settings/portal.js index e4800b97..b909f48f 100644 --- a/src/routes/api/settings/portal.js +++ b/src/routes/api/settings/portal.js @@ -60,6 +60,9 @@ router.put('/portal', (req, res) => { if (widgets.midea !== undefined) { settings.set('portal.widget.midea', widgets.midea ? '1' : '0'); } + if (widgets.smarthome !== undefined) { + settings.set('portal.widget.smarthome', widgets.smarthome ? '1' : '0'); + } if (body.trust_owner_mapping !== undefined) { settings.set('portal.trust_owner_mapping', body.trust_owner_mapping ? '1' : '0'); diff --git a/src/routes/api/smarthome.js b/src/routes/api/smarthome.js index 4894a322..d747a9e1 100644 --- a/src/routes/api/smarthome.js +++ b/src/routes/api/smarthome.js @@ -4,6 +4,7 @@ const { Router } = require('express'); const { requireFeature } = require('../../middleware/license'); const users = require('../../services/users'); const smarthome = require('../../services/smarthome'); +const smarthomeOwners = require('../../services/smarthome/smarthomeOwners'); const router = Router(); @@ -62,7 +63,27 @@ router.post('/gateways/:id/test', wrap(async (req, res) => { router.get('/resources', wrap(async (req, res) => { const gatewayId = req.query.gateway_id ? Number(req.query.gateway_id) : undefined; - res.json({ resources: await smarthome.getResources(gatewayId) }); + const resources = (await smarthome.getResources(gatewayId)).map((r) => ({ + ...r, + owners: r.kind === 'scene' ? smarthomeOwners.inheritedOwnersOf(r) : smarthomeOwners.ownersOf(r.id), + })); + res.json({ resources }); +})); + +router.put('/resources/:id/owners', wrap(async (req, res) => { + const rawIds = req.body && req.body.userIds; + if (!Array.isArray(rawIds)) { + return res.status(400).json({ ok: false, error: req.t('error.smarthome.user_ids_required'), code: 'SMARTHOME_USER_IDS_REQUIRED' }); + } + try { + const owners = smarthomeOwners.setOwners(Number(req.params.id), rawIds); + res.json({ resource_id: Number(req.params.id), owners }); + } catch (e) { + if (e.code === 'SMARTHOME_OWNER_UNKNOWN_USER') return res.status(400).json({ ok: false, error: req.t('error.smarthome.owner_unknown_user'), code: e.code }); + if (e.code === 'SMARTHOME_NOT_ASSIGNABLE') return res.status(400).json({ ok: false, error: req.t('error.smarthome.not_assignable'), code: e.code }); + if (e.code === 'SMARTHOME_RESOURCE_NOT_FOUND') return res.status(404).json({ ok: false, error: req.t('error.smarthome.resource_not_found'), code: e.code }); + throw e; + } })); router.post('/resources/:id/state', wrap(async (req, res) => { diff --git a/src/services/portalConfig.js b/src/services/portalConfig.js index 932a8e46..ddde4f75 100644 --- a/src/services/portalConfig.js +++ b/src/services/portalConfig.js @@ -7,7 +7,7 @@ const settings = require('./settings'); * All values default to enabled ('1') unless explicitly set to '0'. * Note: trustOwnerMapping defaults to disabled ('0') — unlike the widgets. * - * @returns {{ enabled: boolean, widgets: { device: boolean, traffic: boolean, services: boolean, pihole: boolean, midea: boolean }, trustOwnerMapping: boolean }} + * @returns {{ enabled: boolean, widgets: { device: boolean, traffic: boolean, services: boolean, pihole: boolean, midea: boolean, smarthome: boolean }, trustOwnerMapping: boolean }} */ const on = (key) => settings.get(key, '1') !== '0'; @@ -20,6 +20,7 @@ function portalConfig() { services: on('portal.widget.services'), pihole: on('portal.widget.pihole'), midea: on('portal.widget.midea'), + smarthome: on('portal.widget.smarthome'), }, trustOwnerMapping: settings.get('portal.trust_owner_mapping', '0') !== '0', }; diff --git a/src/services/smarthome/smarthomeOwners.js b/src/services/smarthome/smarthomeOwners.js new file mode 100644 index 00000000..20c421d7 --- /dev/null +++ b/src/services/smarthome/smarthomeOwners.js @@ -0,0 +1,91 @@ +// src/services/smarthome/smarthomeOwners.js +'use strict'; + +const { getDb } = require('../../db/connection'); + +const ASSIGNABLE = new Set(['light', 'plug', 'group']); + +// Validate-before-write: resource must exist + be assignable; every userId must exist. +// On any failure throw and write nothing. Then replace the owner set atomically. +function setOwners(resourceId, userIds) { + const db = getDb(); + const r = db.prepare('SELECT id, kind FROM smarthome_resources WHERE id = ?').get(resourceId); + if (!r) { const e = new Error(`resource ${resourceId} not found`); e.code = 'SMARTHOME_RESOURCE_NOT_FOUND'; throw e; } + if (!ASSIGNABLE.has(r.kind)) { const e = new Error(`resource ${resourceId} kind ${r.kind} not assignable`); e.code = 'SMARTHOME_NOT_ASSIGNABLE'; throw e; } + const ids = [...new Set((Array.isArray(userIds) ? userIds : []).map(Number))] + .filter((n) => Number.isInteger(n) && n > 0); + for (const uid of ids) { + if (!db.prepare('SELECT id FROM users WHERE id = ?').get(uid)) { + const e = new Error(`unknown user ${uid}`); e.code = 'SMARTHOME_OWNER_UNKNOWN_USER'; throw e; + } + } + db.transaction(() => { + db.prepare('DELETE FROM smarthome_resource_owners WHERE resource_id = ?').run(resourceId); + const ins = db.prepare('INSERT OR IGNORE INTO smarthome_resource_owners (resource_id, user_id) VALUES (?, ?)'); + for (const uid of ids) ins.run(resourceId, uid); + })(); + return ownersOf(resourceId); +} + +function ownersOf(resourceId) { + return getDb().prepare( + `SELECT u.id, u.username + FROM smarthome_resource_owners o JOIN users u ON u.id = o.user_id + WHERE o.resource_id = ? + ORDER BY u.username`, + ).all(resourceId); +} + +// Direct ownership only. +function isOwner(resourceId, userId) { + return !!getDb().prepare( + 'SELECT 1 FROM smarthome_resource_owners WHERE resource_id = ? AND user_id = ?', + ).get(resourceId, userId); +} + +// Direct-owned light/plug/group ids + scene ids whose group is owned (inheritance, §16). +function resourcesOwnedBy(userId) { + const db = getDb(); + const owned = db.prepare('SELECT resource_id FROM smarthome_resource_owners WHERE user_id = ?') + .all(userId).map((r) => r.resource_id); + if (!owned.length) return []; + const ph = owned.map(() => '?').join(','); + const groups = db.prepare( + `SELECT gateway_id, deconz_id FROM smarthome_resources WHERE id IN (${ph}) AND kind = 'group'`, + ).all(...owned); + const out = new Set(owned); + // TP1 stores scene deconz_id as '/'; group deconz_ids are integers → no LIKE wildcards. + const sceneStmt = db.prepare("SELECT id FROM smarthome_resources WHERE gateway_id = ? AND kind = 'scene' AND enabled = 1 AND deconz_id LIKE ?"); + for (const g of groups) { + for (const s of sceneStmt.all(g.gateway_id, `${g.deconz_id}/%`)) out.add(s.id); + } + return [...out]; +} + +// Portal control gate: direct ownership OR scene of an owned group. +function canAccess(resourceId, userId) { + const r = getDb().prepare('SELECT enabled FROM smarthome_resources WHERE id = ?').get(resourceId); + if (!r || !r.enabled) return false; + if (isOwner(resourceId, userId)) return true; + return resourcesOwnedBy(userId).includes(Number(resourceId)); +} + +// Bare deletes — NO own transaction (callers own the tx boundary). +function removeAllForResource(resourceId) { + getDb().prepare('DELETE FROM smarthome_resource_owners WHERE resource_id = ?').run(resourceId); +} +function removeAllForUser(userId) { + getDb().prepare('DELETE FROM smarthome_resource_owners WHERE user_id = ?').run(userId); +} + +// For a scene, the "owners" shown read-only are its group's owners (§16 inheritance). +function inheritedOwnersOf(resource) { + if (!resource || resource.kind !== 'scene') return []; + const db = getDb(); + const groupDeconzId = String(resource.deconz_id).split('/')[0]; + const grp = db.prepare("SELECT id FROM smarthome_resources WHERE gateway_id = ? AND kind = 'group' AND deconz_id = ?") + .get(resource.gateway_id, groupDeconzId); + return grp ? ownersOf(grp.id) : []; +} + +module.exports = { setOwners, ownersOf, isOwner, resourcesOwnedBy, canAccess, removeAllForResource, removeAllForUser, inheritedOwnersOf }; diff --git a/src/services/users.js b/src/services/users.js index acab1121..5cf9f2d0 100644 --- a/src/services/users.js +++ b/src/services/users.js @@ -6,6 +6,7 @@ const activity = require('./activity'); const logger = require('../utils/logger'); const argon2Options = require('../utils/argon2Options'); const mideaOwners = require('./midea/mideaOwners'); +const smarthomeOwners = require('./smarthome/smarthomeOwners'); const NO_PASSWORD_SENTINEL = '!'; @@ -265,6 +266,7 @@ function remove(id) { db.transaction(() => { db.prepare('UPDATE peers SET user_id = NULL WHERE user_id = ?').run(id); mideaOwners.removeAllForUser(id); // clear AC ownership (no own tx) + smarthomeOwners.removeAllForUser(id); // clear smarthome ownership (no own tx) db.prepare('DELETE FROM users WHERE id = ?').run(id); })(); diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index 1c57d532..2e6b174d 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -88,6 +88,11 @@ 'smarthome.val.closed': {{ t('smarthome.val.closed') | dump | safe }}, 'smarthome.val.wet': {{ t('smarthome.val.wet') | dump | safe }}, 'smarthome.val.dry': {{ t('smarthome.val.dry') | dump | safe }}, + 'smarthome.owners.title': {{ t('smarthome.owners.title') | dump | safe }}, + 'smarthome.owners.manage': {{ t('smarthome.owners.manage') | dump | safe }}, + 'smarthome.owners.none': {{ t('smarthome.owners.none') | dump | safe }}, + 'smarthome.owners.save': {{ t('smarthome.owners.save') | dump | safe }}, + 'smarthome.owners.search': {{ t('smarthome.owners.search') | dump | safe }}, 'peers.no_peers': {{ t('peers.no_peers') | dump | safe }}, 'peers.online': {{ t('peers.online') | dump | safe }}, 'peers.offline': {{ t('peers.offline') | dump | safe }}, diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk index 03623add..7c13dd67 100644 --- a/templates/aurora/pages/settings.njk +++ b/templates/aurora/pages/settings.njk @@ -986,6 +986,10 @@ {{ t('settings.portal.widget_midea') }}
+
+ {{ t('settings.portal.widget_smarthome') }} +
+
{{ t('settings.portal.trust_owner_mapping') }}
diff --git a/templates/aurora/pages/smarthome.njk b/templates/aurora/pages/smarthome.njk index 1dea4649..0cbaee1e 100644 --- a/templates/aurora/pages/smarthome.njk +++ b/templates/aurora/pages/smarthome.njk @@ -20,6 +20,25 @@
{% include theme + "/partials/modals/smarthome-connect.njk" %} + {% endblock %} {% block scripts %} diff --git a/templates/default/layout.njk b/templates/default/layout.njk index f9bf7eef..da78c9f5 100644 --- a/templates/default/layout.njk +++ b/templates/default/layout.njk @@ -87,6 +87,11 @@ 'smarthome.val.closed': {{ t('smarthome.val.closed') | dump | safe }}, 'smarthome.val.wet': {{ t('smarthome.val.wet') | dump | safe }}, 'smarthome.val.dry': {{ t('smarthome.val.dry') | dump | safe }}, + 'smarthome.owners.title': {{ t('smarthome.owners.title') | dump | safe }}, + 'smarthome.owners.manage': {{ t('smarthome.owners.manage') | dump | safe }}, + 'smarthome.owners.none': {{ t('smarthome.owners.none') | dump | safe }}, + 'smarthome.owners.save': {{ t('smarthome.owners.save') | dump | safe }}, + 'smarthome.owners.search': {{ t('smarthome.owners.search') | dump | safe }}, 'peers.no_peers': {{ t('peers.no_peers') | dump | safe }}, 'peers.online': {{ t('peers.online') | dump | safe }}, 'peers.offline': {{ t('peers.offline') | dump | safe }}, diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk index d4ac912c..6e2ba54d 100644 --- a/templates/default/pages/settings.njk +++ b/templates/default/pages/settings.njk @@ -1151,6 +1151,10 @@ {{ t('settings.portal.widget_midea') }}
+
+ {{ t('settings.portal.widget_smarthome') }} +
+
{{ t('settings.portal.trust_owner_mapping') }}
diff --git a/templates/default/pages/smarthome.njk b/templates/default/pages/smarthome.njk index 31358655..7dc10070 100644 --- a/templates/default/pages/smarthome.njk +++ b/templates/default/pages/smarthome.njk @@ -20,6 +20,25 @@
{% include theme + "/partials/modals/smarthome-connect.njk" %} + {% endblock %} {% block scripts %} diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk index 3f15403c..af16a459 100644 --- a/templates/portal/portal.njk +++ b/templates/portal/portal.njk @@ -57,7 +57,11 @@ mideaExtras: t('portal.midea.extras'), mideaTurbo: t('portal.midea.turbo'), mideaEco: t('portal.midea.eco'), - mideaOutdoor: t('portal.midea.outdoor') + mideaOutdoor: t('portal.midea.outdoor'), + smarthomePower: t('portal.smarthome.power'), + smarthomeBrightness: t('portal.smarthome.brightness'), + smarthomeActivate: t('portal.smarthome.activate'), + smarthomeLoginToControl: t('portal.smarthome.login_to_control') } | dump | safe }} @@ -185,6 +189,15 @@ {% endif %} + {% if widgets.smarthome %} + +
+

{{ t('portal.smarthome.title') }}

+
+ +
+ {% endif %} +
diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk index a2ec6730..b89b0d28 100644 --- a/templates/pro/layout.njk +++ b/templates/pro/layout.njk @@ -89,6 +89,11 @@ 'smarthome.val.closed': {{ t('smarthome.val.closed') | dump | safe }}, 'smarthome.val.wet': {{ t('smarthome.val.wet') | dump | safe }}, 'smarthome.val.dry': {{ t('smarthome.val.dry') | dump | safe }}, + 'smarthome.owners.title': {{ t('smarthome.owners.title') | dump | safe }}, + 'smarthome.owners.manage': {{ t('smarthome.owners.manage') | dump | safe }}, + 'smarthome.owners.none': {{ t('smarthome.owners.none') | dump | safe }}, + 'smarthome.owners.save': {{ t('smarthome.owners.save') | dump | safe }}, + 'smarthome.owners.search': {{ t('smarthome.owners.search') | dump | safe }}, 'peers.no_peers': {{ t('peers.no_peers') | dump | safe }}, 'peers.online': {{ t('peers.online') | dump | safe }}, 'peers.offline': {{ t('peers.offline') | dump | safe }}, diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk index 19b8364e..d8ae060b 100644 --- a/templates/pro/pages/settings.njk +++ b/templates/pro/pages/settings.njk @@ -1038,6 +1038,10 @@ {{ t('settings.portal.widget_midea') }}
+
+ {{ t('settings.portal.widget_smarthome') }} +
+
{{ t('settings.portal.trust_owner_mapping') }}
diff --git a/templates/pro/pages/smarthome.njk b/templates/pro/pages/smarthome.njk index 31358655..7dc10070 100644 --- a/templates/pro/pages/smarthome.njk +++ b/templates/pro/pages/smarthome.njk @@ -20,6 +20,25 @@
{% include theme + "/partials/modals/smarthome-connect.njk" %} + {% endblock %} {% block scripts %} diff --git a/tests/portal_settings.test.js b/tests/portal_settings.test.js index 6f2bc99f..68013f0f 100644 --- a/tests/portal_settings.test.js +++ b/tests/portal_settings.test.js @@ -22,7 +22,7 @@ after(teardown); test('defaults: enabled, all widgets on', () => { const c = portalCfg(); assert.equal(c.enabled, true); - assert.deepEqual(c.widgets, { device: true, traffic: true, services: true, pihole: true, midea: true }); + assert.deepEqual(c.widgets, { device: true, traffic: true, services: true, pihole: true, midea: true, smarthome: true }); }); test('a disabled widget is reflected', () => { diff --git a/tests/smarthome_owners.test.js b/tests/smarthome_owners.test.js new file mode 100644 index 00000000..9a4037ce --- /dev/null +++ b/tests/smarthome_owners.test.js @@ -0,0 +1,73 @@ +// tests/smarthome_owners.test.js +'use strict'; +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const nodeCrypto = require('node:crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex'); +const { setup, teardown } = require('./helpers/setup'); +const { getDb } = require('../src/db/connection'); + +let dev, owners; +beforeEach(async () => { + await setup(); + dev = require('../src/services/smarthome/smarthomeDevices'); + owners = require('../src/services/smarthome/smarthomeOwners'); +}); +afterEach(async () => { await teardown(); }); + +function mkUser(name) { + return Number(getDb().prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', 'user')").run(name).lastInsertRowid); +} + +test('setOwners validates user existence, replaces set, ownersOf reflects it', () => { + const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true }); + const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '1', deconz_type: 'lights', kind: 'light', name: 'L', capabilities: {} }); + const u1 = mkUser('a'); const u2 = mkUser('b'); + owners.setOwners(rid, [u1, u2]); + assert.deepEqual(owners.ownersOf(rid).map((o) => o.username).sort(), ['a', 'b']); + owners.setOwners(rid, [u1]); // replace + assert.deepEqual(owners.ownersOf(rid).map((o) => o.username), ['a']); + assert.throws(() => owners.setOwners(rid, [99999]), (e) => e.code === 'SMARTHOME_OWNER_UNKNOWN_USER'); +}); + +test('setOwners works for plug kind (assignable)', () => { + const gw = dev.createGateway({ name: 'GWp', route_id: null, apiKey: 'K', enabled: true }); + const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '3', deconz_type: 'lights', kind: 'plug', name: 'Plug', capabilities: {} }); + const u = mkUser('p'); + owners.setOwners(rid, [u]); + assert.deepEqual(owners.ownersOf(rid).map((o) => o.username), ['p']); +}); + +test('setOwners refuses non-assignable kinds and missing resource', () => { + const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true }); + const sid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '2', deconz_type: 'sensors', kind: 'sensor', name: 'S', capabilities: {} }); + assert.throws(() => owners.setOwners(sid, []), (e) => e.code === 'SMARTHOME_NOT_ASSIGNABLE'); + assert.throws(() => owners.setOwners(99999, []), (e) => e.code === 'SMARTHOME_RESOURCE_NOT_FOUND'); +}); + +test('resourcesOwnedBy includes scenes of owned groups; isOwner is direct-only', () => { + const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true }); + const grp = dev.upsertResource({ gateway_id: gw.id, deconz_id: '5', deconz_type: 'groups', kind: 'group', name: 'G', capabilities: {} }); + const scn = dev.upsertResource({ gateway_id: gw.id, deconz_id: '5/1', deconz_type: 'scenes', kind: 'scene', name: 'G · S', capabilities: {} }); + const u = mkUser('u'); + owners.setOwners(grp, [u]); + const owned = owners.resourcesOwnedBy(u); + assert.ok(owned.includes(grp)); + assert.ok(owned.includes(scn)); // inherited + assert.equal(owners.isOwner(scn, u), false); // no own record + assert.equal(owners.canAccess(scn, u), true); // inherited access + assert.deepEqual(owners.inheritedOwnersOf(dev.getResource(scn)).map((o) => o.username), ['u']); + assert.equal(owners.isOwner(grp, u), true); +}); + +test('removeAllForUser and removeAllForResource clear rows', () => { + const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true }); + const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '1', deconz_type: 'lights', kind: 'light', name: 'L', capabilities: {} }); + const u = mkUser('u'); + owners.setOwners(rid, [u]); + owners.removeAllForUser(u); + assert.equal(owners.ownersOf(rid).length, 0); + owners.setOwners(rid, [u]); + owners.removeAllForResource(rid); + assert.equal(owners.ownersOf(rid).length, 0); +}); diff --git a/tests/smarthome_owners_api.test.js b/tests/smarthome_owners_api.test.js new file mode 100644 index 00000000..3afc3414 --- /dev/null +++ b/tests/smarthome_owners_api.test.js @@ -0,0 +1,42 @@ +'use strict'; +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const nodeCrypto = require('node:crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex'); +const { setup, teardown } = require('./helpers/setup'); +const { getDb } = require('../src/db/connection'); + +let app, agent, csrfToken; +before(async () => { + ({ app, agent, csrfToken } = await setup()); + require('../src/services/license')._overrideForTest({ smarthome: true }); +}); +after(async () => { await teardown(); }); + +test('GET /resources includes owners array', async () => { + const dev = require('../src/services/smarthome/smarthomeDevices'); + const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true }); + dev.upsertResource({ gateway_id: gw.id, deconz_id: '1', deconz_type: 'lights', kind: 'light', name: 'L', capabilities: {} }); + const res = await agent.get('/api/v1/smarthome/resources').expect(200); + assert.ok(Array.isArray(res.body.resources)); + assert.ok(Array.isArray(res.body.resources[0].owners)); +}); + +test('PUT /resources/:id/owners sets owners; unknown user → 400; non-assignable → 400', async () => { + const dev = require('../src/services/smarthome/smarthomeDevices'); + const gw = dev.createGateway({ name: 'GW2', route_id: null, apiKey: 'K', enabled: true }); + const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '9', deconz_type: 'lights', kind: 'light', name: 'L9', capabilities: {} }); + const sid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '8', deconz_type: 'sensors', kind: 'sensor', name: 'S8', capabilities: {} }); + const uid = Number(getDb().prepare("INSERT INTO users (username, password_hash, role) VALUES ('owner1', 'x', 'user')").run().lastInsertRowid); + const ok = await agent.put(`/api/v1/smarthome/resources/${rid}/owners`).set('x-csrf-token', csrfToken).send({ userIds: [uid] }).expect(200); + assert.deepEqual(ok.body.owners.map((o) => o.username), ['owner1']); + await agent.put(`/api/v1/smarthome/resources/${rid}/owners`).set('x-csrf-token', csrfToken).send({ userIds: [99999] }).expect(400); + await agent.put(`/api/v1/smarthome/resources/${sid}/owners`).set('x-csrf-token', csrfToken).send({ userIds: [uid] }).expect(400); +}); + +test('PUT /resources/:id/owners with non-array userIds → 400', async () => { + const dev = require('../src/services/smarthome/smarthomeDevices'); + const gw = dev.createGateway({ name: 'GW3', route_id: null, apiKey: 'K', enabled: true }); + const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '4', deconz_type: 'lights', kind: 'light', name: 'L4', capabilities: {} }); + await agent.put(`/api/v1/smarthome/resources/${rid}/owners`).set('x-csrf-token', csrfToken).send({ userIds: 'nope' }).expect(400); +}); diff --git a/tests/smarthome_owners_cleanup.test.js b/tests/smarthome_owners_cleanup.test.js new file mode 100644 index 00000000..f4723234 --- /dev/null +++ b/tests/smarthome_owners_cleanup.test.js @@ -0,0 +1,25 @@ +'use strict'; +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const nodeCrypto = require('node:crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex'); +const { setup, teardown } = require('./helpers/setup'); +const { getDb } = require('../src/db/connection'); + +let dev, owners, users; +beforeEach(async () => { + await setup(); + dev = require('../src/services/smarthome/smarthomeDevices'); + owners = require('../src/services/smarthome/smarthomeOwners'); + users = require('../src/services/users'); +}); +afterEach(async () => { await teardown(); }); + +test('removing a user clears their smarthome ownership rows', () => { + const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true }); + const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '1', deconz_type: 'lights', kind: 'light', name: 'L', capabilities: {} }); + const uid = Number(getDb().prepare("INSERT INTO users (username, password_hash, role) VALUES ('victim', 'x', 'user')").run().lastInsertRowid); + owners.setOwners(rid, [uid]); + users.remove(uid); + assert.equal(getDb().prepare('SELECT COUNT(*) c FROM smarthome_resource_owners WHERE user_id = ?').get(uid).c, 0); +}); diff --git a/tests/smarthome_portal_api.test.js b/tests/smarthome_portal_api.test.js new file mode 100644 index 00000000..04eaf1fa --- /dev/null +++ b/tests/smarthome_portal_api.test.js @@ -0,0 +1,53 @@ +// tests/smarthome_portal_api.test.js +'use strict'; +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const nodeCrypto = require('node:crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex'); +const { setup, teardown } = require('./helpers/setup'); +const { getDb } = require('../src/db/connection'); + +let app, agent, csrfToken; +before(async () => { + ({ app, agent, csrfToken } = await setup()); + require('../src/services/license')._overrideForTest({ smarthome: true }); +}); +after(async () => { await teardown(); }); + +// NOTE: the default test agent is an authenticated admin session → portalLoggedIn true, +// portalOwnerId = admin user id. Assign ownership to that same admin id. +test('GET /portal/smarthome returns only owned controllable resources (redacted)', async () => { + const dev = require('../src/services/smarthome/smarthomeDevices'); + const owners = require('../src/services/smarthome/smarthomeOwners'); + const adminId = getDb().prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get().id; + const gw = dev.createGateway({ name: 'GW', route_id: null, apiKey: 'K', enabled: true }); + const owned = dev.upsertResource({ gateway_id: gw.id, deconz_id: '1', deconz_type: 'lights', kind: 'light', name: 'Mine', capabilities: { on: true }, state: { on: false } }); + dev.upsertResource({ gateway_id: gw.id, deconz_id: '2', deconz_type: 'lights', kind: 'light', name: 'NotMine', capabilities: { on: true } }); + owners.setOwners(owned, [adminId]); + const res = await agent.get('/api/v1/portal/smarthome').expect(200); + assert.ok(res.body.data && Array.isArray(res.body.data.devices)); + const names = res.body.data.devices.map((d) => d.name); + assert.ok(names.includes('Mine')); + assert.ok(!names.includes('NotMine')); + const d = res.body.data.devices.find((x) => x.name === 'Mine'); + assert.equal(d.gateway_id, undefined); // redacted + assert.equal(d.deconz_id, undefined); // redacted + assert.ok('state' in d); +}); + +test('POST /portal/smarthome/:id/state on a non-owned resource → 403', async () => { + const dev = require('../src/services/smarthome/smarthomeDevices'); + const gw = dev.createGateway({ name: 'GW2', route_id: null, apiKey: 'K', enabled: true }); + const other = dev.upsertResource({ gateway_id: gw.id, deconz_id: '7', deconz_type: 'lights', kind: 'light', name: 'Other', capabilities: { on: true } }); + await agent.post(`/api/v1/portal/smarthome/${other}/state`).set('x-csrf-token', csrfToken).send({ patch: { on: true } }).expect(403); +}); + +test('POST /portal/smarthome/:id/state without login → login_required', async () => { + const supertest = require('supertest'); + const anon = supertest(app); + const dev = require('../src/services/smarthome/smarthomeDevices'); + const gw = dev.createGateway({ name: 'GWA', route_id: null, apiKey: 'K', enabled: true }); + const rid = dev.upsertResource({ gateway_id: gw.id, deconz_id: '3', deconz_type: 'lights', kind: 'light', name: 'L', capabilities: {} }); + const r = await anon.post(`/api/v1/portal/smarthome/${rid}/state`).send({ patch: { on: true } }).expect(200); + assert.equal(r.body.reason, 'login_required'); +}); diff --git a/tests/smarthome_portal_widget_flag.test.js b/tests/smarthome_portal_widget_flag.test.js new file mode 100644 index 00000000..a6e2a8de --- /dev/null +++ b/tests/smarthome_portal_widget_flag.test.js @@ -0,0 +1,15 @@ +'use strict'; +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const nodeCrypto = require('node:crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex'); +const { setup, teardown } = require('./helpers/setup'); + +beforeEach(async () => { await setup(); }); +afterEach(async () => { await teardown(); }); + +test('portalConfig exposes widgets.smarthome (default true)', () => { + const portalConfig = require('../src/services/portalConfig'); + assert.equal(typeof portalConfig().widgets.smarthome, 'boolean'); + assert.equal(portalConfig().widgets.smarthome, true); +});