diff --git a/public/css/portal.css b/public/css/portal.css
index aac69107..733c78de 100644
--- a/public/css/portal.css
+++ b/public/css/portal.css
@@ -289,6 +289,8 @@ body::before{
.pihole-foot{font-size:11px;color:var(--faint);margin-top:4px}
.pihole-msg{font-size:13px;color:var(--muted);padding:10px 0;line-height:1.5}
.c-pihole.loading{min-height:160px}
+.pihole-seg{font-size:12px;margin-left:auto}
+.pihole-hint{font-size:11px;color:var(--muted)}
/* ============================================================
REDUCED MOTION
diff --git a/public/js/portal.js b/public/js/portal.js
index 283e4803..54290409 100644
--- a/public/js/portal.js
+++ b/public/js/portal.js
@@ -347,37 +347,130 @@
}
// ─── Pi-hole widget ─────────────────────────────────────────────────────────
- function hydratePihole() {
- const card = document.querySelector('.pihole-widget');
+ var piScopeActive = 'device';
+
+ // Endpoint map for the three scopes (TP2a device + TP2b owner/household)
+ var PI_ENDPOINTS = {
+ device: '/api/v1/portal/pihole',
+ owner: '/api/v1/portal/pihole/owner',
+ household: '/api/v1/portal/pihole/household'
+ };
+
+ function hydratePiholeScope(scope) {
+ var card = document.querySelector('.pihole-widget');
if (!card) return; // widget toggled off → not in DOM → no fetch
setLoading(card, true);
- fetch('/api/v1/portal/pihole')
+ var url = PI_ENDPOINTS[scope] || PI_ENDPOINTS.device;
+ fetch(url)
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (body) {
setLoading(card, false);
- const msg = document.getElementById('piMsg');
- const bodyEl = card.querySelector('.pihole-body');
+ var msg = document.getElementById('piMsg');
+ var bodyEl = card.querySelector('.pihole-body');
if (!body.ok || body.data === null) {
- if (body.reason === 'unavailable') { card.style.display = 'none'; return; }
- var key = { unavailable:'piholeUnavailable', collapsed:'piholeCollapsed', no_data:'piholeNoData', unidentified:'piholeUnidentified' }[body.reason] || 'piholeUnavailable';
+ var reason = body.reason;
+ if (reason === 'unavailable' && scope === 'device') { card.style.display = 'none'; return; }
if (bodyEl) bodyEl.style.display = 'none';
- if (msg) { msg.textContent = (PT[key] || ''); msg.style.display = 'block'; } // PT = i18n map (portal.js line 14)
+ if (msg) {
+ msg.replaceChildren();
+ if (reason === 'no_owner' || reason === 'login_required') {
+ // DOM-safe login affordance — no innerHTML with i18n text or /login href
+ var hintKey = reason === 'no_owner' ? 'piholeNoOwner' : 'piholeLoginRequired';
+ var hintSpan = document.createElement('span');
+ hintSpan.textContent = PT[hintKey] || '';
+ var a = document.createElement('a');
+ a.href = '/login';
+ a.textContent = PT.piholeLoginLink || 'Log in';
+ msg.appendChild(hintSpan);
+ msg.appendChild(document.createTextNode(' '));
+ msg.appendChild(a);
+ } else {
+ var key = { collapsed:'piholeCollapsed', no_data:'piholeNoData', unidentified:'piholeUnidentified' }[reason] || 'piholeUnavailable';
+ msg.textContent = PT[key] || ''; // PT = i18n map (portal.js ~line 14)
+ }
+ msg.style.display = 'block';
+ }
return;
}
if (bodyEl) bodyEl.style.display = '';
var d = body.data;
- document.getElementById('piPct').textContent = String(d.blockedPct);
- var bar = document.getElementById('piBar'); if (bar) bar.style.width = d.blockedPct + '%';
- document.getElementById('piTotal').textContent = String(d.total);
- document.getElementById('piBlocked').textContent = String(d.blocked);
- document.getElementById('piAllowed').textContent = String(d.allowed);
- // genuine idle device (in a list, count 0) → show "no queries today" note (spec §5)
+
+ // ── Scope-visibility: deterministically show/hide cross-scope fields ─
+ var allowedWrap = document.getElementById('piAllowedWrap');
+ if (allowedWrap) allowedWrap.style.display = (scope === 'household') ? 'none' : '';
+ var ownerExtra = document.getElementById('piOwnerExtra');
+ if (ownerExtra) ownerExtra.style.display = (scope === 'owner') ? '' : 'none';
+ var hhExtra = document.getElementById('piHouseholdExtra');
+ if (hhExtra) hhExtra.style.display = (scope === 'household') ? '' : 'none';
+
+ // ── Stats common to all scopes ──────────────────────────────────────
+ var pctEl = document.getElementById('piPct');
+ if (pctEl) pctEl.textContent = String(d.blockedPct);
+ var bar = document.getElementById('piBar');
+ if (bar) bar.style.width = d.blockedPct + '%';
+ var totalEl = document.getElementById('piTotal');
+ if (totalEl) totalEl.textContent = String(d.total);
+ var blockedEl = document.getElementById('piBlocked');
+ if (blockedEl) blockedEl.textContent = String(d.blocked);
+
+ // ── Scope-specific fields ───────────────────────────────────────────
+ if (scope !== 'household') {
+ // device + owner: show allowed count
+ var allowedEl = document.getElementById('piAllowed');
+ if (allowedEl) allowedEl.textContent = String(d.allowed);
+ }
+
+ if (scope === 'owner') {
+ // owner: device count across the owner's peers
+ var devCountEl = document.getElementById('piOwnerDevices');
+ if (devCountEl) {
+ devCountEl.textContent = (PT['piholeOwnerDevices'] || '{n}').replace('{n}', String(d.deviceCount));
+ }
+ var devHintEl = document.getElementById('piOwnerDevicesHint');
+ if (devHintEl) devHintEl.textContent = PT['piholeOwnerDevicesHint'] || '';
+ }
+
+ if (scope === 'household') {
+ // household: active client count, no allowed stat
+ var clientsEl = document.getElementById('piActiveClients');
+ if (clientsEl) {
+ clientsEl.textContent = (PT['piholeActiveClients'] || '{n}').replace('{n}', String(d.activeClients || 0));
+ }
+ }
+
+ // ── Zero-queries notice (spec §5) ───────────────────────────────────
if (msg) {
- if (d.total === 0) { msg.textContent = (PT['piholeZeroQueries'] || ''); msg.style.display = 'block'; }
- else { msg.style.display = 'none'; }
+ msg.replaceChildren();
+ if (d.total === 0) {
+ msg.textContent = PT['piholeZeroQueries'] || '';
+ msg.style.display = 'block';
+ } else {
+ msg.style.display = 'none';
+ }
}
})
- .catch(function () { setLoading(card, false); showError(card, hydratePihole); });
+ .catch(function () { setLoading(card, false); showError(card, function () { hydratePiholeScope(scope); }); });
+ }
+
+ // hydratePihole: TP2a entry point — wires the segment switcher once, then
+ // loads the default 'device' scope (boot call below is unchanged).
+ function hydratePihole() {
+ var seg = document.getElementById('piholeSeg');
+ if (seg) {
+ seg.querySelectorAll('button').forEach(function (btn) {
+ btn.classList.toggle('on', btn.dataset.scope === piScopeActive);
+ });
+ seg.addEventListener('click', function (e) {
+ var btn = e.target.closest('button');
+ if (!btn || !btn.dataset.scope) return;
+ piScopeActive = btn.dataset.scope;
+ seg.querySelectorAll('button').forEach(function (b) {
+ b.classList.toggle('on', b.dataset.scope === piScopeActive);
+ });
+ hydratePiholeScope(piScopeActive);
+ });
+ }
+ hydratePiholeScope('device');
}
// ─── Boot ───────────────────────────────────────────────────────────────────
diff --git a/public/js/settings.js b/public/js/settings.js
index 1ed64275..13818f2c 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -1764,9 +1764,10 @@
var widgetTraffic = document.getElementById('portal-widget-traffic');
var widgetServices = document.getElementById('portal-widget-services');
var widgetPihole = document.getElementById('portal-widget-pihole');
+ var trustToggle = document.getElementById('portal-trust-owner-mapping');
if (!enabledToggle) return;
- [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole].forEach(function (el) {
+ [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, trustToggle].forEach(function (el) {
if (el) el.addEventListener('click', function () {
el.classList.toggle('on');
el.dispatchEvent(new Event('change'));
@@ -1786,12 +1787,13 @@
setToggle(widgetTraffic, d.widgets && d.widgets.traffic);
setToggle(widgetServices, d.widgets && d.widgets.services);
setToggle(widgetPihole, d.widgets && d.widgets.pihole);
+ setToggle(trustToggle, d.trustOwnerMapping);
if (window.SettingsAutosave && SettingsAutosave.resync) SettingsAutosave.resync('portal');
}).catch(function (err) {
console.error('Failed to load portal settings:', err);
});
- var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole].filter(Boolean);
+ var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, trustToggle].filter(Boolean);
SettingsAutosave.bind({
cluster: 'portal',
fields: portalFields,
@@ -1803,6 +1805,7 @@
'portal-widget-traffic': widgetTraffic ? widgetTraffic.classList.contains('on') : true,
'portal-widget-services': widgetServices ? widgetServices.classList.contains('on') : true,
'portal-widget-pihole': widgetPihole ? widgetPihole.classList.contains('on') : true,
+ 'portal-trust-owner-mapping': trustToggle ? trustToggle.classList.contains('on') : false,
};
},
save: function () {
@@ -1814,6 +1817,7 @@
services: widgetServices ? widgetServices.classList.contains('on') : true,
pihole: widgetPihole ? widgetPihole.classList.contains('on') : true,
},
+ trust_owner_mapping: trustToggle ? trustToggle.classList.contains('on') : false,
});
},
});
diff --git a/src/i18n/de.json b/src/i18n/de.json
index dc93a422..f57e2a20 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -1980,6 +1980,8 @@
"settings.portal.widget_traffic": "Traffic-Diagramm",
"settings.portal.widget_services": "Dienste",
"settings.portal.widget_pihole": "Pi-hole-Widget",
+ "settings.portal.trust_owner_mapping": "Gerät→Besitzer-Vertrauen (pro-Besitzer ohne Login)",
+ "settings.portal.trust_owner_mapping_help": "Wenn aktiv, sehen nicht eingeloggte Nutzer auf Geräten mit zugeordnetem Besitzer dessen aggregierte DNS-Zahlen ohne Login. Nur aktivieren, wenn Geräte ausschließlich vom zugeordneten Besitzer genutzt werden.",
"settings.portal.saved": "Portal-Einstellungen gespeichert",
"settings.portal.host_not_verified": "Domain ist nicht verifiziert",
"settings.portal.host_invalid_prefix": "Ungültiges Subdomain-Präfix",
@@ -2004,6 +2006,15 @@
"portal.pihole.no_data": "Noch keine Daten für dieses Gerät verfügbar",
"portal.pihole.unidentified": "Gerät nicht erkannt",
"portal.pihole.zero_queries": "Keine Anfragen heute",
+ "portal.pihole.scope_device": "Gerät",
+ "portal.pihole.scope_owner": "Ich",
+ "portal.pihole.scope_household": "Haushalt",
+ "portal.pihole.no_owner": "Einloggen, um alle deine Geräte zu sehen",
+ "portal.pihole.login_required": "Einloggen für die Haushalts-Ansicht",
+ "portal.pihole.login_link": "Einloggen",
+ "portal.pihole.owner.devices_in_snapshot": "{n} Geräte im Snapshot",
+ "portal.pihole.owner.devices_in_snapshot_hint": "Nur Geräte in Pi-holes Top-10; weitere folgen in einem späteren Update.",
+ "portal.pihole.household.active_clients": "{n} aktive Clients",
"settings.autosave.saved": "Gespeichert",
"settings.autosave.error": "Speichern fehlgeschlagen",
"settings.autosave.pending": "Wird gespeichert, sobald alle Pflichtfelder ausgefüllt sind",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 9a1a221b..91c0e2c8 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1980,6 +1980,8 @@
"settings.portal.widget_traffic": "Traffic chart",
"settings.portal.widget_services": "Services",
"settings.portal.widget_pihole": "Pi-hole widget",
+ "settings.portal.trust_owner_mapping": "Trust device→owner mapping (zero-login per-owner)",
+ "settings.portal.trust_owner_mapping_help": "When enabled, unauthenticated users on a device with an assigned owner see that owner's aggregated DNS counts without logging in. Enable only when devices are used exclusively by their assigned owner.",
"settings.portal.saved": "Portal settings saved",
"settings.portal.host_not_verified": "Domain is not verified",
"settings.portal.host_invalid_prefix": "Invalid subdomain prefix",
@@ -2004,6 +2006,15 @@
"portal.pihole.no_data": "No data for this device yet",
"portal.pihole.unidentified": "Device not recognised",
"portal.pihole.zero_queries": "No queries today",
+ "portal.pihole.scope_device": "Device",
+ "portal.pihole.scope_owner": "Mine",
+ "portal.pihole.scope_household": "Household",
+ "portal.pihole.no_owner": "Log in to see all your devices",
+ "portal.pihole.login_required": "Log in to see the household view",
+ "portal.pihole.login_link": "Log in",
+ "portal.pihole.owner.devices_in_snapshot": "{n} devices in snapshot",
+ "portal.pihole.owner.devices_in_snapshot_hint": "Only devices in Pi-hole's top 10; more in a later update.",
+ "portal.pihole.household.active_clients": "{n} active clients",
"settings.autosave.saved": "Saved",
"settings.autosave.error": "Save failed",
"settings.autosave.pending": "Will save once all required fields are filled",
diff --git a/src/middleware/portalOwner.js b/src/middleware/portalOwner.js
new file mode 100644
index 00000000..eeac97ab
--- /dev/null
+++ b/src/middleware/portalOwner.js
@@ -0,0 +1,40 @@
+// src/middleware/portalOwner.js
+'use strict';
+const { getDb } = require('../db/connection');
+const settings = require('../services/settings');
+
+function trustEnabled() {
+ return settings.get('portal.trust_owner_mapping', '0') !== '0';
+}
+
+/** Owner (users.id) of a peer via the TP1 peers.user_id column, or null. */
+function ownerOfPeer(peerId) {
+ if (peerId == null) return null;
+ const row = getDb().prepare('SELECT user_id FROM peers WHERE id = ?').get(peerId);
+ return row && row.user_id != null ? row.user_id : null;
+}
+
+/**
+ * Resolve the portal OWNER on top of portalIdentity (which set req.portalPeerId).
+ * Precedence: an authenticated session ALWAYS wins over device-owner trust.
+ * Device-owner trust only applies when there is no session AND the admin enabled it.
+ * The owner id never comes from the request body/query/header (no IDOR).
+ */
+function portalOwner(req, _res, next) {
+ req.portalLoggedIn = !!(req.session && req.session.userId);
+ if (req.portalLoggedIn) {
+ req.portalOwnerId = req.session.userId;
+ req.portalOwnerSource = 'session';
+ } else if (trustEnabled() && req.portalPeerId != null) {
+ const uid = ownerOfPeer(req.portalPeerId);
+ req.portalOwnerId = uid;
+ req.portalOwnerSource = uid != null ? 'device' : null;
+ } else {
+ req.portalOwnerId = null;
+ req.portalOwnerSource = null;
+ }
+ next();
+}
+
+module.exports = portalOwner;
+module.exports.ownerOfPeer = ownerOfPeer;
diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js
index 42e2d5b9..2df595b1 100644
--- a/src/routes/api/portal.js
+++ b/src/routes/api/portal.js
@@ -163,4 +163,46 @@ router.get('/pihole', (req, res) => {
}
});
+router.get('/pihole/owner', (req, res) => {
+ try {
+ if (!portalConfig().widgets.pihole) return res.status(404).json({ ok: false });
+ const cache = pihole.getCache();
+ if (!license.hasFeature('pihole_integration') || !cache.instances || cache.instances.length === 0) {
+ return res.json({ ok: true, data: null, reason: 'unavailable' });
+ }
+ if (req.portalOwnerId == null) return res.json({ ok: true, data: null, reason: 'no_owner' });
+ if (cache.attribution === 'collapsed') return res.json({ ok: true, data: null, reason: 'collapsed' });
+ const ownerPeerIds = new Set(peers.peersOfOwner(req.portalOwnerId)); // owner id NEVER from req body/query
+ let allowed = 0, blocked = 0; const seen = new Set();
+ for (const c of (cache.topClients || [])) if (ownerPeerIds.has(c.peerId)) { allowed += c.count; seen.add(c.peerId); }
+ for (const c of (cache.topClientsBlocked || [])) if (ownerPeerIds.has(c.peerId)) { blocked += c.count; seen.add(c.peerId); }
+ if (seen.size === 0) return res.json({ ok: true, data: null, reason: 'no_data' });
+ const total = allowed + blocked;
+ const blockedPct = total ? Math.round((blocked / total) * 100) : 0;
+ res.json({ ok: true, data: { total, blocked, allowed, blockedPct, deviceCount: seen.size, asOf: cache.lastSyncAt } });
+ } catch (err) {
+ logger.error({ error: err.message }, 'portal /pihole/owner failed');
+ return res.json({ ok: true, data: null, reason: 'unavailable' });
+ }
+});
+
+router.get('/pihole/household', (req, res) => {
+ try {
+ if (!portalConfig().widgets.pihole) return res.status(404).json({ ok: false });
+ const cache = pihole.getCache();
+ if (!license.hasFeature('pihole_integration') || !cache.instances || cache.instances.length === 0) {
+ return res.json({ ok: true, data: null, reason: 'unavailable' });
+ }
+ if (!req.portalLoggedIn) return res.json({ ok: true, data: null, reason: 'login_required' }); // trust switch never relaxes household
+ const s = cache.summary;
+ if (!s || !s.queries) return res.json({ ok: true, data: null, reason: 'unavailable' });
+ const total = s.queries.total || 0, blocked = s.queries.blocked || 0;
+ const blockedPct = total ? Math.round((blocked / total) * 100) : 0;
+ res.json({ ok: true, data: { total, blocked, blockedPct, activeClients: (s.clients && s.clients.active != null) ? s.clients.active : null, asOf: cache.lastSyncAt } });
+ } catch (err) {
+ logger.error({ error: err.message }, 'portal /pihole/household 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 aaf20752..a91afb2f 100644
--- a/src/routes/api/settings/portal.js
+++ b/src/routes/api/settings/portal.js
@@ -1,7 +1,7 @@
'use strict';
-// Portal settings cluster — master switch + per-widget toggles + public host.
-// Keys: portal.enabled, portal.widget.{device,traffic,services,pihole},
+// Portal settings cluster — master switch + per-widget toggles + public host + owner mapping.
+// Keys: portal.enabled, portal.widget.{device,traffic,services,pihole}, portal.trust_owner_mapping,
// portal.base_domain, portal.prefix
const { Router } = require('express');
@@ -30,11 +30,11 @@ router.get('/portal', (req, res) => {
});
/**
- * PUT /api/v1/settings/portal — Update portal master switch + widget toggles + host
+ * PUT /api/v1/settings/portal — Update portal master switch + widget toggles + host + owner mapping
*
* Accepts:
* { enabled: bool, widgets: { device: bool, traffic: bool, services: bool, pihole: bool },
- * base_domain: string, prefix: string }
+ * trust_owner_mapping: bool, base_domain: string, prefix: string }
*/
router.put('/portal', (req, res) => {
try {
@@ -57,6 +57,10 @@ router.put('/portal', (req, res) => {
settings.set('portal.widget.pihole', widgets.pihole ? '1' : '0');
}
+ if (body.trust_owner_mapping !== undefined) {
+ settings.set('portal.trust_owner_mapping', body.trust_owner_mapping ? '1' : '0');
+ }
+
// Host change (base_domain + prefix committed together).
if (body.base_domain !== undefined || body.prefix !== undefined) {
const base = String(body.base_domain !== undefined ? body.base_domain : settings.get('portal.base_domain', '') || '').trim().toLowerCase();
diff --git a/src/routes/index.js b/src/routes/index.js
index 44525992..08210217 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -298,7 +298,8 @@ router.get('/api/v1/events', requireAuth, require('./api/events'));
// ─── Portal API (source-IP identity, no session auth) ──────────
const portalIdentity = require('../middleware/portalIdentity');
-router.use('/api/v1/portal', apiLimiter, portalIdentity, require('./api/portal'));
+const portalOwner = require('../middleware/portalOwner');
+router.use('/api/v1/portal', apiLimiter, portalIdentity, portalOwner, require('./api/portal'));
// ─── Portal page (source-IP identity, no session auth) ─────────
const portalConfig = require('../services/portalConfig');
diff --git a/src/services/peers.js b/src/services/peers.js
index 33fe41ab..75cf2a3f 100644
--- a/src/services/peers.js
+++ b/src/services/peers.js
@@ -711,6 +711,14 @@ async function batch(action, ids) {
return ids.length;
}
+/**
+ * Get all peer IDs owned by a user
+ */
+function peersOfOwner(userId) {
+ if (userId == null) return [];
+ return getDb().prepare('SELECT id FROM peers WHERE user_id = ?').all(userId).map(r => r.id);
+}
+
module.exports = {
getAll,
getById,
@@ -724,4 +732,5 @@ module.exports = {
batch,
setHostname,
markHostnamesStale,
+ peersOfOwner,
};
diff --git a/src/services/portalConfig.js b/src/services/portalConfig.js
index 53511fcb..e3ba8786 100644
--- a/src/services/portalConfig.js
+++ b/src/services/portalConfig.js
@@ -5,8 +5,9 @@ const settings = require('./settings');
/**
* Returns the current VPN landing portal configuration derived from 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 } }}
+ * @returns {{ enabled: boolean, widgets: { device: boolean, traffic: boolean, services: boolean, pihole: boolean }, trustOwnerMapping: boolean }}
*/
const on = (key) => settings.get(key, '1') !== '0';
@@ -19,6 +20,7 @@ function portalConfig() {
services: on('portal.widget.services'),
pihole: on('portal.widget.pihole'),
},
+ trustOwnerMapping: settings.get('portal.trust_owner_mapping', '0') !== '0',
};
}
diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk
index 9e51a489..d897fc87 100644
--- a/templates/aurora/pages/settings.njk
+++ b/templates/aurora/pages/settings.njk
@@ -977,6 +977,11 @@
{{ t('settings.portal.widget_pihole') }}
+
+
{{ t('settings.portal.trust_owner_mapping') }}
+
+
+ {{ t('settings.portal.trust_owner_mapping_help') }}
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk
index afdec830..caa95d27 100644
--- a/templates/default/pages/settings.njk
+++ b/templates/default/pages/settings.njk
@@ -1142,6 +1142,11 @@
{{ t('settings.portal.widget_pihole') }}
+
+
{{ t('settings.portal.trust_owner_mapping') }}
+
+
+ {{ t('settings.portal.trust_owner_mapping_help') }}
diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk
index 64850490..52772fd8 100644
--- a/templates/portal/portal.njk
+++ b/templates/portal/portal.njk
@@ -29,9 +29,15 @@
piholeCollapsed: t('portal.pihole.collapsed'),
piholeNoData: t('portal.pihole.no_data'),
piholeUnidentified: t('portal.pihole.unidentified'),
- piholeZeroQueries: t('portal.pihole.zero_queries')
+ piholeZeroQueries: t('portal.pihole.zero_queries'),
+ piholeNoOwner: t('portal.pihole.no_owner'),
+ piholeLoginRequired: t('portal.pihole.login_required'),
+ piholeLoginLink: t('portal.pihole.login_link'),
+ piholeOwnerDevices: t('portal.pihole.owner.devices_in_snapshot'),
+ piholeOwnerDevicesHint: t('portal.pihole.owner.devices_in_snapshot_hint'),
+ piholeActiveClients: t('portal.pihole.household.active_clients')
} | dump | safe }}
-
+
@@ -117,15 +123,23 @@
{% if widgets.pihole %}
+
+
{{ t('settings.portal.trust_owner_mapping') }}
+
+
+ {{ t('settings.portal.trust_owner_mapping_help') }}
diff --git a/tests/peers_of_owner.test.js b/tests/peers_of_owner.test.js
new file mode 100644
index 00000000..c1ad140f
--- /dev/null
+++ b/tests/peers_of_owner.test.js
@@ -0,0 +1,23 @@
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const { setup, teardown } = require('./helpers/setup');
+let peers, getDb;
+beforeEach(async () => { await setup(); peers = require('../src/services/peers'); getDb = require('../src/db/connection').getDb; });
+afterEach(teardown);
+function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; }
+function seedPeer(name, uid){ return getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES (?,?,?,1,'regular',?)").run(name,name+'k',name+'/32',uid).lastInsertRowid; }
+
+test('peersOfOwner returns only the owner\'s peer ids', () => {
+ const u1 = seedUser('o1'), u2 = seedUser('o2');
+ const a = seedPeer('10.8.0.1', u1); const b = seedPeer('10.8.0.2', u1); seedPeer('10.8.0.3', u2);
+ const ids = peers.peersOfOwner(u1).sort((x,y)=>x-y);
+ assert.deepEqual(ids, [a,b].sort((x,y)=>x-y));
+});
+test('peersOfOwner returns [] for an owner with no peers and for null', () => {
+ const u = seedUser('lonely');
+ assert.deepEqual(peers.peersOfOwner(u), []);
+ assert.deepEqual(peers.peersOfOwner(null), []);
+});
diff --git a/tests/pihole_portal_household.test.js b/tests/pihole_portal_household.test.js
new file mode 100644
index 00000000..ffa3be25
--- /dev/null
+++ b/tests/pihole_portal_household.test.js
@@ -0,0 +1,48 @@
+// tests/pihole_portal_household.test.js
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const supertest = require('supertest');
+// setup MUST be required before config/default so NODE_ENV=test is set before
+// config/default.js is evaluated (it throws on missing GC_SECRET in non-test mode).
+const { setup, teardown, getAgent } = require('./helpers/setup');
+const config = require('../config/default');
+const HOME_HOST = `home.${config.dns.domain}`;
+let app, getDb, pihole, license;
+beforeEach(async () => {
+ await setup();
+ app = require('../src/app').createApp();
+ getDb = require('../src/db/connection').getDb;
+ pihole = require('../src/services/pihole'); license = require('../src/services/license');
+ license.hasFeature = () => true;
+});
+afterEach(() => { teardown(); });
+function cacheWith(summary){ return { instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1750000000000, summary }; }
+
+test('logged-in → global summary numbers', async () => {
+ pihole.getCache = () => cacheWith({ queries:{ total:1000, blocked:250, percent:25 }, clients:{ active:7 } });
+ const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200);
+ assert.equal(r.body.data.total, 1000);
+ assert.equal(r.body.data.blocked, 250);
+ assert.equal(r.body.data.blockedPct, 25);
+ assert.equal(r.body.data.activeClients, 7);
+ assert.deepEqual(Object.keys(r.body.data).sort(), ['activeClients','asOf','blocked','blockedPct','total']);
+});
+test('not logged in → login_required (even with trust on)', async () => {
+ require('../src/services/settings').set('portal.trust_owner_mapping','1');
+ pihole.getCache = () => cacheWith({ queries:{ total:1000, blocked:250 }, clients:{ active:7 } });
+ const r = await supertest(app).get('/api/v1/portal/pihole/household').set('Host', HOME_HOST).expect(200);
+ assert.equal(r.body.data, null); assert.equal(r.body.reason, 'login_required');
+});
+test('summary null (pre first sync) → unavailable', async () => {
+ pihole.getCache = () => cacheWith(null);
+ const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200);
+ assert.equal(r.body.data, null); assert.equal(r.body.reason, 'unavailable');
+});
+test('feature off → unavailable', async () => {
+ license.hasFeature = () => false;
+ const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200);
+ assert.equal(r.body.data, null); assert.equal(r.body.reason, 'unavailable');
+});
diff --git a/tests/pihole_portal_owner.test.js b/tests/pihole_portal_owner.test.js
new file mode 100644
index 00000000..9a41dc59
--- /dev/null
+++ b/tests/pihole_portal_owner.test.js
@@ -0,0 +1,76 @@
+// tests/pihole_portal_owner.test.js
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const supertest = require('supertest');
+// setup MUST be required before config/default so NODE_ENV=test is set before
+// config/default.js is evaluated (it throws on missing GC_SECRET in non-test mode).
+const { setup, teardown, getAgent } = require('./helpers/setup');
+const config = require('../config/default');
+const HOME_HOST = `home.${config.dns.domain}`;
+let app, getDb, pihole, license, adminId, p1, p2, foreignPeer, foreignUser;
+
+beforeEach(async () => {
+ await setup();
+ app = require('../src/app').createApp();
+ getDb = require('../src/db/connection').getDb;
+ pihole = require('../src/services/pihole');
+ license = require('../src/services/license');
+ license.hasFeature = () => true;
+ adminId = getDb().prepare("SELECT id FROM users WHERE username='admin'").get().id; // getAgent() is this user
+ p1 = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('m1','k1','10.8.0.5/32',1,'regular',?)").run(adminId).lastInsertRowid;
+ p2 = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('m2','k2','10.8.0.6/32',1,'regular',?)").run(adminId).lastInsertRowid;
+ foreignUser = getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES ('bob','x','admin')").run().lastInsertRowid;
+ foreignPeer = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('b1','k3','10.8.0.50/32',1,'regular',?)").run(foreignUser).lastInsertRowid;
+});
+afterEach(() => { teardown(); });
+
+function cacheWith(extra){ return Object.assign({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1750000000000, topClients:[], topClientsBlocked:[] }, extra); }
+
+test('logged-in owner → aggregate over OWN devices only (+ deviceCount), no foreign device', async () => {
+ pihole.getCache = () => cacheWith({
+ topClients: [{ ip:'10.8.0.5', count:500, peerId:p1, peerName:'m1' }, { ip:'10.8.0.6', count:300, peerId:p2, peerName:'m2' }, { ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }],
+ topClientsBlocked: [{ ip:'10.8.0.5', count:50, peerId:p1, peerName:'m1' }, { ip:'10.8.0.50', count:888, peerId:foreignPeer, peerName:'b1' }],
+ });
+ const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200);
+ assert.equal(r.body.data.allowed, 800); // 500+300, NOT 999
+ assert.equal(r.body.data.blocked, 50); // 50, NOT 888
+ assert.equal(r.body.data.total, 850);
+ assert.equal(r.body.data.deviceCount, 2); // p1 + p2
+ assert.deepEqual(Object.keys(r.body.data).sort(), ['allowed','asOf','blocked','blockedPct','deviceCount','total']);
+});
+test('IDOR: a foreign owner_id in body/query is ignored', async () => {
+ pihole.getCache = () => cacheWith({ topClients:[{ ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }] });
+ const r = await getAgent().get('/api/v1/portal/pihole/owner?user_id=' + foreignUser).send({ user_id: foreignUser }).expect(200);
+ // admin owns p1/p2 which are NOT in the cache here → no_data, never the foreign 999
+ assert.equal(r.body.data, null);
+ assert.equal(r.body.reason, 'no_data');
+});
+test('owner with devices but none in lists → no_data', async () => {
+ pihole.getCache = () => cacheWith({});
+ const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200);
+ assert.equal(r.body.data, null); assert.equal(r.body.reason, 'no_data');
+});
+test('not logged in, trust off → no_owner', async () => {
+ pihole.getCache = () => cacheWith({});
+ const r = await supertest(app).get('/api/v1/portal/pihole/owner').set('Host', HOME_HOST).expect(200);
+ assert.equal(r.body.data, null); assert.equal(r.body.reason, 'no_owner');
+});
+test('collapsed → collapsed', async () => {
+ pihole.getCache = () => cacheWith({ attribution:'collapsed' });
+ const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200);
+ assert.equal(r.body.data, null); assert.equal(r.body.reason, 'collapsed');
+});
+test('feature off → unavailable', async () => {
+ license.hasFeature = () => false;
+ const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200);
+ assert.equal(r.body.data, null); assert.equal(r.body.reason, 'unavailable');
+});
+test('logged-in but user deleted (peers user_id nulled) → no_data, not no_owner', async () => {
+ getDb().prepare('UPDATE peers SET user_id = NULL WHERE user_id = ?').run(adminId); // simulate TP1 cleanup
+ pihole.getCache = () => cacheWith({ topClients:[{ ip:'10.8.0.5', count:5, peerId:p1, peerName:'m1' }] });
+ const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200);
+ assert.equal(r.body.data, null); assert.equal(r.body.reason, 'no_data');
+});
diff --git a/tests/pihole_portal_owner_household_no_leak.test.js b/tests/pihole_portal_owner_household_no_leak.test.js
new file mode 100644
index 00000000..719b4079
--- /dev/null
+++ b/tests/pihole_portal_owner_household_no_leak.test.js
@@ -0,0 +1,65 @@
+// tests/pihole_portal_owner_household_no_leak.test.js
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs'); const path = require('node:path');
+const { setup, teardown, getAgent } = require('./helpers/setup');
+let getDb, pihole, license, adminId, p1, foreignPeer, foreignUser;
+beforeEach(async () => {
+ await setup();
+ getDb = require('../src/db/connection').getDb;
+ pihole = require('../src/services/pihole'); license = require('../src/services/license'); license.hasFeature = () => true;
+ adminId = getDb().prepare("SELECT id FROM users WHERE username='admin'").get().id;
+ p1 = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('m1','k1','10.8.0.5/32',1,'regular',?)").run(adminId).lastInsertRowid;
+ foreignUser = getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES ('bob','x','admin')").run().lastInsertRowid;
+ foreignPeer = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES ('b1','k3','10.8.0.50/32',1,'regular',?)").run(foreignUser).lastInsertRowid;
+});
+afterEach(() => { teardown(); });
+
+test('owner endpoint: response exposes only the aggregate, never foreign device or raw fields', async () => {
+ pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1,
+ topClients:[{ ip:'10.8.0.5', count:100, peerId:p1, peerName:'m1' }, { ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }],
+ topClientsBlocked:[{ ip:'10.8.0.5', count:10, peerId:p1, peerName:'m1' }] });
+ const r = await getAgent().get('/api/v1/portal/pihole/owner').expect(200);
+ const raw = JSON.stringify(r.body);
+ assert.equal(r.body.data.allowed, 100);
+ // exhaustive key whitelist — cannot be gamed by field-name collisions
+ assert.deepEqual(Object.keys(r.body.data).sort(), ['allowed','asOf','blocked','blockedPct','deviceCount','total']);
+ assert.ok(!raw.includes('10.8.0.50') && !raw.includes('b1'), 'foreign device leaked');
+ assert.ok(!/\bip\b|peerId|peerName|user_id|owner_name|topClients/.test(raw), 'raw field leaked: ' + raw);
+});
+test('household endpoint: only the global aggregate, no client list', async () => {
+ pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1,
+ summary:{ queries:{ total:9, blocked:3 }, clients:{ active:2 } },
+ topClients:[{ ip:'10.8.0.50', count:999, peerId:foreignPeer, peerName:'b1' }] });
+ const r = await getAgent().get('/api/v1/portal/pihole/household').expect(200);
+ const raw = JSON.stringify(r.body);
+ assert.equal(r.body.data.total, 9);
+ assert.deepEqual(Object.keys(r.body.data).sort(), ['activeClients','asOf','blocked','blockedPct','total']);
+ assert.ok(!/peerId|peerName|topClients|\bip\b|10\.8\.0\.50/.test(raw), 'raw field leaked: ' + raw);
+});
+test('structural: owner+household handlers serialize only locally-built aggregates', () => {
+ const src = fs.readFileSync(path.join(__dirname,'..','src','routes','api','portal.js'),'utf8');
+ for (const route of ['/pihole/owner','/pihole/household']) {
+ // String-based extraction (indexOf + brace-counting) — immune to regex-escaping bugs.
+ const start = src.indexOf("router.get('" + route + "'");
+ assert.ok(start !== -1, route + ' handler not found');
+ let depth = 0, i = start, end = -1;
+ while (i < src.length) {
+ const ch = src[i];
+ if (ch === '{') depth++;
+ else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } }
+ i++;
+ }
+ assert.ok(end !== -1, route + ' handler not terminated');
+ const body = src.slice(start, end + 1);
+ // Regex LITERALS on the extracted string — single-escaped, verified to match real code.
+ assert.ok(!/res\.json\(\s*cache\s*\)/.test(body) && !/res\.json\(\s*\{[\s\S]*?\.\.\.\s*cache/.test(body), route + ' returns/spreads raw cache');
+ // [^\n]* (same-line only) avoids false-positives from `res.json(...)` early-exit
+ // guards that appear in the body BEFORE the topClients loop lines. A real leak
+ // (e.g. `res.json({ data: cache.topClients })`) would be on one line → still caught.
+ assert.ok(!/res\.json\([^\n]*topClients/.test(body), route + ' serializes topClients');
+ }
+});
diff --git a/tests/pihole_portal_scope_js.test.js b/tests/pihole_portal_scope_js.test.js
new file mode 100644
index 00000000..14e013dd
--- /dev/null
+++ b/tests/pihole_portal_scope_js.test.js
@@ -0,0 +1,19 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs'); const path = require('node:path');
+test('portal.js wires scope switching to the 3 endpoints + login affordance, DOM-safe, PT, no raw fields', () => {
+ const js = fs.readFileSync(path.join(__dirname,'..','public','js','portal.js'),'utf8');
+ assert.ok(/hydratePiholeScope/.test(js), 'no hydratePiholeScope');
+ assert.ok(/data-scope|dataset\.scope/.test(js), 'no scope wiring');
+ assert.ok(/\/api\/v1\/portal\/pihole\/owner/.test(js) && /\/api\/v1\/portal\/pihole\/household/.test(js), 'missing endpoints');
+ assert.ok(/no_owner|login_required/.test(js) && /\/login/.test(js), 'no login affordance');
+ assert.ok(/\bPT\[/.test(js) && !/\bI18N\b/.test(js), 'must use PT i18n object');
+ // client-side leak guard (spec §7): scope render must not touch raw cache fields
+ assert.ok(!/\.topClients\b|\.clients\b|data\.ip\b/.test(js), 'raw field referenced');
+ // DOM-safety: the login affordance must NOT be built by concatenating i18n text into innerHTML
+ assert.ok(!/innerHTML\s*[+=][^;]*PT\.pihole(NoOwner|LoginRequired)/.test(js), 'innerHTML + PT i18n (XSS risk)');
+ assert.ok(!/innerHTML\s*[+=][^;]*['"]\/login['"]/.test(js), 'login href via innerHTML');
+ // card-hide on unavailable must be gated to device scope only (owner/household must keep card+switcher visible)
+ assert.ok(/reason\s*===\s*['"]unavailable['"]\s*&&\s*scope\s*===\s*['"]device['"]/.test(js), 'unavailable card-hide must be gated to device scope');
+});
diff --git a/tests/pihole_portal_scope_markup.test.js b/tests/pihole_portal_scope_markup.test.js
new file mode 100644
index 00000000..1d927f29
--- /dev/null
+++ b/tests/pihole_portal_scope_markup.test.js
@@ -0,0 +1,12 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs'); const path = require('node:path');
+test('portal.njk has a scope segment switcher in the pihole card', () => {
+ const html = fs.readFileSync(path.join(__dirname,'..','templates','portal','portal.njk'),'utf8');
+ assert.ok(/data-scope=["']device["']/.test(html) && /data-scope=["']owner["']/.test(html) && /data-scope=["']household["']/.test(html), 'missing scope buttons');
+ assert.ok(/id=["']piholeSeg["']/.test(html), 'missing #piholeSeg');
+ assert.ok(html.includes('portal.pihole.scope_device') && html.includes('portal.pihole.scope_owner') && html.includes('portal.pihole.scope_household'), 'missing scope i18n');
+ // cache-bust fix (Step 3e): portal.js must be versioned like portal.css, else stale cached JS leaves the card on placeholders
+ assert.ok(/\/js\/portal\.js\?v=/.test(html), 'portal.js script tag missing ?v= cache-bust');
+});
diff --git a/tests/pihole_portal_widget_ui.test.js b/tests/pihole_portal_widget_ui.test.js
index 0b8e68f3..364c3546 100644
--- a/tests/pihole_portal_widget_ui.test.js
+++ b/tests/pihole_portal_widget_ui.test.js
@@ -16,13 +16,16 @@ test('portal.js has hydratePihole wired to the endpoint + DOM guard', () => {
assert.ok(/querySelector\(\s*['"]\.pihole-widget['"]\s*\)/.test(js), 'no DOM guard on .pihole-widget');
assert.ok(/hydratePihole\(\);/.test(js), 'hydratePihole not called at boot');
});
-test('hydratePihole uses PT for i18n (not undefined I18N) and leaks no raw fields', () => {
+test('hydratePiholeScope uses PT for i18n (not undefined I18N) and leaks no raw fields', () => {
+ // TP2b refactor: the render logic that TP2a kept in hydratePihole() now lives in
+ // hydratePiholeScope(scope); hydratePihole() is a thin delegator that wires the
+ // segment switcher and calls hydratePiholeScope('device'). Inspect the render fn.
const js = fs.readFileSync(path.join(__dirname,'..','public','js','portal.js'),'utf8');
- const m = js.match(/function hydratePihole\([\s\S]*?\n \}/);
- assert.ok(m, 'hydratePihole not found');
+ const m = js.match(/function hydratePiholeScope\([\s\S]*?\n \}/);
+ assert.ok(m, 'hydratePiholeScope not found');
const body = m[0];
assert.ok(/\bPT\[/.test(body), 'must use PT[key] (the real i18n object, portal.js:14)');
assert.ok(!/\bI18N\b/.test(body), 'I18N is undefined in portal.js — use PT');
// client-side structural leak guard (spec §6 test 3, second half): only whitelisted fields
- assert.ok(!/\.topClients\b|\.clients\b|\.ip\b/.test(body), 'hydratePihole references a raw cache field');
+ assert.ok(!/\.topClients\b|\.clients\b|\.ip\b/.test(body), 'hydratePiholeScope references a raw cache field');
});
diff --git a/tests/portal_owner_middleware.test.js b/tests/portal_owner_middleware.test.js
new file mode 100644
index 00000000..89f4f9ee
--- /dev/null
+++ b/tests/portal_owner_middleware.test.js
@@ -0,0 +1,47 @@
+// tests/portal_owner_middleware.test.js
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const { setup, teardown } = require('./helpers/setup');
+let portalOwner, settings, getDb;
+beforeEach(async () => { await setup(); portalOwner = require('../src/middleware/portalOwner'); settings = require('../src/services/settings'); getDb = require('../src/db/connection').getDb; });
+afterEach(teardown);
+function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; }
+function seedPeer(name, uid){ return getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type,user_id) VALUES (?,?,?,1,'regular',?)").run(name,name+'k',name+'/32',uid).lastInsertRowid; }
+function run(req){ let nexted=false; portalOwner(req, {}, ()=>{ nexted=true; }); assert.ok(nexted,'must call next'); return req; }
+
+test('logged in → owner=session.userId, source session (device irrelevant)', () => {
+ const u = seedUser('alice'); const other = seedUser('bob'); const dev = seedPeer('10.8.0.9', other);
+ const req = run({ session:{ userId:u }, portalPeerId: dev });
+ assert.equal(req.portalOwnerId, u); // session wins over device owner 'bob'
+ assert.equal(req.portalOwnerSource, 'session');
+ assert.equal(req.portalLoggedIn, true);
+});
+test('zero-login + trust OFF → owner null', () => {
+ const u = seedUser('carol'); const dev = seedPeer('10.8.0.10', u);
+ const req = run({ session:{}, portalPeerId: dev });
+ assert.equal(req.portalOwnerId, null);
+ assert.equal(req.portalOwnerSource, null);
+ assert.equal(req.portalLoggedIn, false);
+});
+test('zero-login + trust ON + device has owner → owner=device.user_id, source device', () => {
+ settings.set('portal.trust_owner_mapping','1');
+ const u = seedUser('dave'); const dev = seedPeer('10.8.0.11', u);
+ const req = run({ session:{}, portalPeerId: dev });
+ assert.equal(req.portalOwnerId, u);
+ assert.equal(req.portalOwnerSource, 'device');
+});
+test('zero-login + trust ON + device has NO owner → owner null', () => {
+ settings.set('portal.trust_owner_mapping','1');
+ const dev = seedPeer('10.8.0.12', null);
+ const req = run({ session:{}, portalPeerId: dev });
+ assert.equal(req.portalOwnerId, null);
+ assert.equal(req.portalOwnerSource, null);
+});
+test('zero-login + trust ON + unidentified device → owner null', () => {
+ settings.set('portal.trust_owner_mapping','1');
+ const req = run({ session:{}, portalPeerId: null });
+ assert.equal(req.portalOwnerId, null);
+});
diff --git a/tests/portal_trust_setting.test.js b/tests/portal_trust_setting.test.js
new file mode 100644
index 00000000..1a2b3ff5
--- /dev/null
+++ b/tests/portal_trust_setting.test.js
@@ -0,0 +1,19 @@
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup');
+let portalConfig, settings;
+beforeEach(async () => { await setup(); portalConfig = require('../src/services/portalConfig'); settings = require('../src/services/settings'); });
+afterEach(teardown);
+
+test('trustOwnerMapping defaults to false (key unwritten)', () => {
+ assert.equal(portalConfig().trustOwnerMapping, false);
+});
+test('PUT persists trust_owner_mapping=true and config reflects it', async () => {
+ const agent = getAgent(); const csrf = getCsrf();
+ await agent.put('/api/v1/settings/portal').set('X-CSRF-Token', csrf).send({ trust_owner_mapping: true }).expect(200);
+ assert.equal(settings.get('portal.trust_owner_mapping'), '1');
+ assert.equal(portalConfig().trustOwnerMapping, true);
+});
diff --git a/tests/portal_trust_settings_ui.test.js b/tests/portal_trust_settings_ui.test.js
new file mode 100644
index 00000000..3b6f7dde
--- /dev/null
+++ b/tests/portal_trust_settings_ui.test.js
@@ -0,0 +1,17 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs'); const path = require('node:path');
+test('trust toggle + help present in all 3 theme settings pages', () => {
+ for (const theme of ['aurora','default','pro']) {
+ const html = fs.readFileSync(path.join(__dirname,'..','templates',theme,'pages','settings.njk'),'utf8');
+ assert.ok(html.includes('portal-trust-owner-mapping'), `${theme}: toggle id`);
+ assert.ok(html.includes('settings.portal.trust_owner_mapping'), `${theme}: label key`);
+ assert.ok(html.includes('settings.portal.trust_owner_mapping_help'), `${theme}: help key`);
+ }
+});
+test('settings.js wires trust toggle into portal cluster + PUT', () => {
+ const js = fs.readFileSync(path.join(__dirname,'..','public','js','settings.js'),'utf8');
+ assert.ok(/portal-trust-owner-mapping/.test(js));
+ assert.ok(/trust_owner_mapping:/.test(js));
+});