diff --git a/client/src/app.jsx b/client/src/app.jsx index c99dfec..5e64351 100644 --- a/client/src/app.jsx +++ b/client/src/app.jsx @@ -25,6 +25,7 @@ import { } from './utils/interfaceSettings.js'; import { normalizeWidgetSettings, BASE_WIDGET_SETTINGS } from './utils/widgetSettings.js'; import { buildMobileWidgetList } from './utils/mobileWidgets.js'; +import { CORE_CONTROLS, resolveHiddenControls } from './utils/displayControls.js'; import './index.css'; const loadAdminPanel = () => import('./components/AdminPanel.jsx'); @@ -97,6 +98,35 @@ const DEFAULT_WIDGET_SETTINGS = { darkButtonGradientEnd: '#620808', }; +const CORE_CONTROL_IDS = CORE_CONTROLS.map((control) => control.id); + +// Backoff for the admin-PIN existence check. Four attempts over ~6s: long enough +// to ride out a server still coming up behind a kiosk that boots with it, short +// enough that nothing waits on it. +const ADMIN_PIN_EXISTS_RETRY_DELAYS_MS = [500, 1500, 4000]; + +// How long to wait before starting that ladder over, while the answer is still +// unknown. Minutes, not seconds: the ladder already covers the fast case, so +// anything still unresolved is an outage measured in minutes, and polling it +// quickly would only add load to a server that is evidently already struggling. +// The poll exists because the cost of an unresolved answer does not expire (see +// the recovery effect), and it stops the moment one lands. +const ADMIN_PIN_EXISTS_RECOVERY_INTERVAL_MS = 5 * 60 * 1000; + +/** + * Control Limits ids are namespaced in storage (`plugin::`) + * so two plugins cannot collide. A plugin only ever knows its own unprefixed + * ids, so the namespace is stripped here: it is core's storage concern and must + * not leak into every plugin author's widget. + */ +const unprefixedHiddenControlsFor = (hiddenControls, pluginId) => { + if (!pluginId || !Array.isArray(hiddenControls)) return []; + const prefix = `plugin:${pluginId}:`; + return hiddenControls + .filter((id) => id.startsWith(prefix)) + .map((id) => id.slice(prefix.length)); +}; + const readLocalTheme = () => { const savedTheme = localStorage.getItem(THEME_STORAGE_KEY); return savedTheme === 'dark' ? 'dark' : 'light'; @@ -165,6 +195,21 @@ const App = () => { // Credentials are no longer among them — GET /api/settings redacts secrets, // and weather is fetched server-side. const [householdSettings, setHouseholdSettings] = useState({}); + // The raw device settings blob, kept alongside the hydrated view above: + // Control Limits reads keys this component does not otherwise model + // (`controlLimits`, `adminPinRemembered`), and resolveHiddenControls wants the + // blob as stored, not a projection of it. + const [rawDeviceSettings, setRawDeviceSettings] = useState(null); + // null = the PIN check has not landed. Passed into displayControls as + // `undefined`, never `false`: `false` states that no PIN exists, which makes a + // remembered display stop being exempt. See isDisplayUnlocked. + const [adminPinExists, setAdminPinExists] = useState(null); + // Serializes runs of the PIN check. It is started from bootstrap, from every + // device-settings-updated event and from the recovery poll, so a run sitting + // in its backoff and a freshly started one can be in flight together; without + // a token the older one can land last and publish the staler answer. Matches + // how ControlsOnDisplay's loadLimits guards the same pattern. + const adminPinExistsTokenRef = useRef(0); const [installedPlugins, setInstalledPlugins] = useState([]); const [activeTab, setActiveTab] = useState(1); const { tabs, fetchTabs } = useFetchTabs(API_DEVICE_URL); @@ -190,6 +235,8 @@ const App = () => { }, []); const hydrateFromDeviceSettings = useCallback((settings) => { + setRawDeviceSettings(settings || {}); + const widgetSettingsFromServer = normalizeWidgetSettings(settings?.widgetSettings, DEFAULT_WIDGET_SETTINGS); const pluginSettingsFromServer = settings?.pluginSettings && typeof settings.pluginSettings === 'object' ? settings.pluginSettings @@ -217,6 +264,61 @@ const App = () => { } }, [API_DEVICE_URL, hydrateFromDeviceSettings]); + const fetchHouseholdSettings = useCallback(async () => { + try { + const response = await axios.get(`${API_BASE_URL}/api/settings`); + setHouseholdSettings(response.data || {}); + } catch (error) { + console.error('Error fetching household settings:', error); + } + }, []); + + // Whether a household admin PIN is configured. Only a clean read may report + // `false` — claiming "no PIN exists" on the strength of a request that did not + // answer would make every remembered display drop its exemption and strip a + // parent's controls. Control Limits is visibility, not access control, so an + // unreadable check resolves the generous way. + // + // Retried because the generous way is not free: while this stays unresolved, + // every display that remembers the PIN is exempt, which is the household-wide + // disable that displayControls' `pinExists !== false` guard exists to prevent. + // displayControls time-boxes that cost by obliging the caller to resolve the + // value; a single failed request would leave it unresolved forever, so one + // flaky response must not be the end of it. The ladder is bounded and loud + // when it runs out, but it is not the last word: the recovery effect below + // starts it again for as long as the answer stays unknown. + const fetchAdminPinExists = useCallback(async () => { + adminPinExistsTokenRef.current += 1; + const token = adminPinExistsTokenRef.current; + // A superseded run abandons both its write and its remaining backoff: a + // newer run owns the answer, and an older one waking up mid-ladder would + // otherwise overwrite it with a staler read. + const superseded = () => token !== adminPinExistsTokenRef.current; + + for (let attempt = 0; attempt <= ADMIN_PIN_EXISTS_RETRY_DELAYS_MS.length; attempt += 1) { + try { + const response = await axios.get(`${API_BASE_URL}/api/admin-pin/exists`); + if (superseded()) return; + setAdminPinExists(response.data?.exists === true); + return; + } catch (error) { + if (superseded()) return; + const retryDelay = ADMIN_PIN_EXISTS_RETRY_DELAYS_MS[attempt]; + if (retryDelay === undefined) { + console.error( + 'Admin PIN existence check failed after ' + + `${ADMIN_PIN_EXISTS_RETRY_DELAYS_MS.length + 1} attempts; Control Limits stay ` + + `disabled on displays that remember the PIN, retrying every ${ + ADMIN_PIN_EXISTS_RECOVERY_INTERVAL_MS / 60000} minutes:`, + error, + ); + return; + } + await new Promise((resolve) => { setTimeout(resolve, retryDelay); }); + } + } + }, []); + // region #98 - expected to get removed in the future (one-time local-to-server settings migration) const migrateLocalDeviceSettingsToServer = useCallback(async () => { const localPayload = {}; @@ -316,15 +418,6 @@ const App = () => { // region #98 - expected to get removed in the future (invoke migration bridge during bootstrap) useEffect(() => { - const fetchHouseholdSettings = async () => { - try { - const response = await axios.get(`${API_BASE_URL}/api/settings`); - setHouseholdSettings(response.data || {}); - } catch (error) { - console.error('Error fetching household settings:', error); - } - }; - const fetchDemoStatus = async () => { try { const response = await axios.get(`${API_BASE_URL}/api/demo`); @@ -335,6 +428,11 @@ const App = () => { }; const initialize = async () => { + // Started, not awaited: it retries on its own schedule and nothing else + // here depends on the answer, so a slow or failing PIN endpoint must not + // delay the settings, tabs and plugins this dashboard renders from. + void fetchAdminPinExists(); + await migrateLocalDeviceSettingsToServer(); await fetchDemoStatus(); await fetchDeviceSettings(); @@ -347,7 +445,12 @@ const App = () => { }; void initialize(); - }, [fetchDeviceSettings, migrateLocalDeviceSettingsToServer]); + }, [ + fetchDeviceSettings, + migrateLocalDeviceSettingsToServer, + fetchHouseholdSettings, + fetchAdminPinExists, + ]); // endRegion #98 // Demo mode: each visitor's browser is a fresh "device", which normally @@ -507,6 +610,14 @@ const App = () => { useEffect(() => { const handleDeviceSettingsUpdated = () => { void fetchDeviceSettings(); + // Control Limits resolve from three inputs, and Admin can change any of + // them: this display's own limits (device settings), the household default + // (household settings) and whether a PIN exists at all — removing the PIN + // re-applies limits to every remembered display. Refetching all three here + // is what makes a change in Admin take effect on this screen without a + // reload. + void fetchHouseholdSettings(); + void fetchAdminPinExists(); }; const handleInterfaceSettingsUpdated = () => { @@ -527,7 +638,38 @@ const App = () => { window.removeEventListener(DEVICE_SETTINGS_UPDATED_EVENT, handleDeviceSettingsUpdated); window.removeEventListener(INTERFACE_SETTINGS_UPDATED_EVENT, handleInterfaceSettingsUpdated); }; - }, [fetchDeviceSettings]); + }, [fetchDeviceSettings, fetchHouseholdSettings, fetchAdminPinExists]); + + // Recovery for a PIN check that never landed. + // + // While adminPinExists is unresolved, every display that remembers the PIN is + // exempt from Control Limits — the household-wide disable isDisplayUnlocked's + // `pinExists !== false` guard exists to prevent, which is why its contract + // puts the obligation to resolve the value on this caller. The ladder above + // covers a server coming up a moment behind its kiosk; it does not cover an + // API unreachable for the length of a migration after an LXC reboot. Past + // that, the only other trigger is a device-settings-updated event from this + // window's own Admin Panel, which nobody opens on a wall display — so the + // exemption lasted until a human reloaded the page, i.e. indefinitely. + // + // A slow poll rather than re-attempting from the focus/visibilitychange + // handlers the auto-theme effect uses: a kiosk is never backgrounded and + // never blurred, so those are precisely the events the failing case does not + // produce, and they are gated on `themeMode === 'auto'` besides. Keyed on + // adminPinExists, so the first answer tears the interval down — unresolved is + // the only state in which this polls at all, and nothing here ever asserts + // `false` from a request that did not answer. + useEffect(() => { + if (adminPinExists !== null) { + return undefined; + } + + const intervalId = setInterval(() => { + void fetchAdminPinExists(); + }, ADMIN_PIN_EXISTS_RECOVERY_INTERVAL_MS); + + return () => clearInterval(intervalId); + }, [adminPinExists, fetchAdminPinExists]); useEffect(() => { if (themeMode !== 'auto') { @@ -799,6 +941,39 @@ const App = () => { return { x: match.layout_x, y: match.layout_y, w: match.layout_w, h: match.layout_h }; }; + // Every control id this dashboard can name: the core catalog plus whatever the + // installed plugins declare. Only a source of candidates — a stored id for a + // plugin that is not installed here still applies, which is the module's job, + // not this list's. + const knownControlIds = useMemo(() => { + const ids = [...CORE_CONTROL_IDS]; + + installedPlugins.forEach((plugin) => { + const pluginId = plugin?.manifest?.id; + const declared = plugin?.manifest?.hideableControls; + if (!pluginId || !Array.isArray(declared)) return; + + declared.forEach((control) => { + if (control && typeof control.id === 'string') { + ids.push(`plugin:${pluginId}:${control.id}`); + } + }); + }); + + return ids; + }, [installedPlugins]); + + // Resolved once here and passed down, so every widget on this display agrees + // about what it may render. Until the PIN check lands, adminPinExists is null + // and must reach the module as `undefined` — `false` would assert that no PIN + // is configured. + const hiddenControls = useMemo(() => resolveHiddenControls({ + deviceSettings: rawDeviceSettings, + householdSettings, + pinExists: adminPinExists === null ? undefined : adminPinExists, + knownControlIds, + }), [rawDeviceSettings, householdSettings, adminPinExists, knownControlIds]); + const widgets = useMemo(() => { const result = []; @@ -855,7 +1030,7 @@ const App = () => { savedLayout: dbLayout, content: ( }> - + ), }); @@ -899,12 +1074,13 @@ const App = () => { theme={theme} transparentBackground={pSettings.transparent || false} events={plugin.manifest?.events || []} + hiddenControls={unprefixedHiddenControlsFor(hiddenControls, plugin.manifest?.id)} />, }); }); return result; - }, [widgetSettings, pluginSettings, activeTab, widgetAssignments, installedPlugins, theme, demoStatus.demo]); + }, [widgetSettings, pluginSettings, activeTab, widgetAssignments, installedPlugins, theme, demoStatus.demo, hiddenControls]); // Mobile stack (issue #118): same widget content nodes, fixed order, photos // excluded, grid metadata ignored. diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx index ceb5e14..5c61d10 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Box, Typography, @@ -85,6 +85,7 @@ import ScreensaverIntervalSlider from './ScreensaverIntervalSlider'; import GoogleAccountConnection from './GoogleAccountConnection'; import ClamValueModal from './ClamValueModal'; import SoundPicker from './SoundPicker'; +import ControlsOnDisplay from './ControlsOnDisplay'; import useFetchTabs from '../hooks/useFetchTabs.js'; import useIsMobile from '../hooks/useIsMobile.js'; import { syncWidgetAssignments } from '../utils/assignmentSync.js'; @@ -104,6 +105,7 @@ import { readLocalAutoDarkModeSettings, readLocalVacationModeSettings, } from '../utils/interfaceSettings.js'; +import { CONTROL_LIMITS_DEFAULT_KEY } from '../utils/displayControls.js'; import { useTranslation } from 'react-i18next'; import { changeLanguage, SUPPORTED_LANGUAGES } from '../i18n/index.js'; @@ -437,6 +439,22 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => { window.dispatchEvent(new Event(DEVICE_SETTINGS_UPDATED_EVENT)); }; + // Fold the saved household default into the settings we already hold rather + // than re-reading, and tell the live dashboard so the change lands on this + // screen without a reload. + const handleHouseholdControlLimitsSaved = useCallback((value) => { + setSettings((prev) => ({ ...prev, [CONTROL_LIMITS_DEFAULT_KEY]: value })); + window.dispatchEvent(new Event(DEVICE_SETTINGS_UPDATED_EVENT)); + }, []); + + // The form holds its own copy of every display's settings, so there is + // nothing to fold in here; this exists only to tell the live dashboard that + // the screen it is drawing just changed. + const handleDisplayControlLimitsSaved = useCallback((deviceName) => { + if (deviceName !== currentDeviceName) return; + window.dispatchEvent(new Event(DEVICE_SETTINGS_UPDATED_EVENT)); + }, [currentDeviceName]); + const fetchUsers = async () => { try { const response = await axios.get(`${API_BASE_URL}/api/users`); @@ -3968,6 +3986,17 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged }) => { )} + + )} diff --git a/client/src/components/ChoreWidget.jsx b/client/src/components/ChoreWidget.jsx index 2cdf1a6..6d9a39d 100644 --- a/client/src/components/ChoreWidget.jsx +++ b/client/src/components/ChoreWidget.jsx @@ -41,6 +41,7 @@ import { getDeviceApiBase } from '../utils/deviceName.js'; import { fetchPinRemembered, setPinRemembered, shouldPromptForPin } from '../utils/adminPinDevice.js'; import { shouldShowChoreToday, getTodayDateString, convertDaysToCrontab, getDueDateStatus, formatDueDate, hasOutstandingBonusChore } from '../utils/choreHelpers.js'; import { filterVisibleUsers, toggleHiddenUserId, pruneHiddenUserIds } from '../utils/choreUserVisibility.js'; +import { isControlHidden } from '../utils/displayControls.js'; import { subscribePluginEvents } from '../utils/pluginEventBridge.js'; import { subscribePluginDataChanged } from '../utils/pluginDataBridge.js'; import { playSound, soundUrl } from '../utils/choreSound.js'; @@ -62,7 +63,7 @@ const formatDueTime = (dueTime) => { return formatTime(date); }; -const ChoreWidget = ({ refreshNonce = 0 }) => { +const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { const { t } = useTranslation(['chores', 'common']); const API_DEVICE_URL = getDeviceApiBase(API_BASE_URL); const [users, setUsers] = useState([]); @@ -119,6 +120,40 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { const daysOfWeek = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + // Control Limits (resolved once in app.jsx and handed down). Hidden means NOT + // RENDERED — never a disabled button and never a PIN prompt offered in place of + // the action, because the point is that this display does not carry the control + // at all. + const hideAddChore = isControlHidden(hiddenControls, 'core:addChore'); + const hideTransferChore = isControlHidden(hiddenControls, 'core:transferChore'); + const hideSnoozeChore = isControlHidden(hiddenControls, 'core:snoozeChore'); + const hidePrizeApproval = isControlHidden(hiddenControls, 'core:prizeApproval'); + const hideQuickSpend = isControlHidden(hiddenControls, 'core:quickSpend'); + + // A control hidden while its dialog is already open would otherwise stay + // completable — hiding the entry point is not enough once someone is past it. + // Each updater returns the previous state unchanged when there is nothing to + // close, so React bails out instead of re-rendering this effect's inputs. + useEffect(() => { + if (hideAddChore) { + setShowAddDialog((prev) => (prev ? false : prev)); + } + if (hideTransferChore) { + setTransferDialog((prev) => (prev.open ? { ...prev, open: false } : prev)); + } + if (hideSnoozeChore) { + setSnoozeDialog((prev) => (prev.open ? { ...prev, open: false } : prev)); + } + if (hideQuickSpend) { + setQuickSpend((prev) => (prev.open ? { open: false, user: null, amount: '', note: '' } : prev)); + } + // The menu holds only these two items, so it is empty exactly when both are + // hidden — and an open empty menu is a dead backdrop the user has to dismiss. + if (hideTransferChore && hideSnoozeChore) { + setChoreMenu((prev) => (prev.position ? { position: null, schedule: null } : prev)); + } + }, [hideAddChore, hideTransferChore, hideSnoozeChore, hideQuickSpend]); + useEffect(() => { fetchData(); }, []); @@ -579,8 +614,13 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { const openChoreMenu = (clientX, clientY, schedule) => { if (choreMenu.schedule) return; // already open (Android contextmenu + timer double-fire guard) - const canTransfer = schedule.transferable !== 0 && users.filter(u => u.id !== 0 && u.id !== schedule.user_id).length > 0; - const canSnooze = schedule.can_snooze !== 0; + // Control Limits fold in here rather than only at the MenuItem: hiding both + // items but still opening the menu leaves an empty popover over the chore, + // which reads as a bug and has to be dismissed before anything else works. + const canTransfer = !hideTransferChore + && schedule.transferable !== 0 + && users.filter(u => u.id !== 0 && u.id !== schedule.user_id).length > 0; + const canSnooze = !hideSnoozeChore && schedule.can_snooze !== 0; if (!canTransfer && !canSnooze) return; choreMenuOpenedAtRef.current = Date.now(); setChoreMenu({ position: { top: clientY, left: clientX }, schedule }); @@ -717,6 +757,11 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { } } setTransferDialog(prev => ({ ...prev, open: false })); + // Control Limits, decided not overlooked: if core:transferChore is hidden + // while this PIN modal is up, the transfer still completes on verification. + // The parent initiated it while the control was visible, and Control Limits + // is visibility rather than a security boundary, so interrupting an action + // already in flight buys nothing. Do not "fix" this with a ref. requirePin(() => reassignChore(schedule.id, targetUserId, extras)); }; @@ -749,6 +794,8 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { return; } setSnoozeDialog(prev => ({ ...prev, open: false })); + // Same decision as confirmTransfer: hiding core:snoozeChore while this PIN + // modal is up does not cancel the snooze the parent already confirmed. requirePin(async () => { try { setIsLoading(true); @@ -850,11 +897,14 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { } } + // The avatar itself is never hidden — it carries the clam balance, which is + // how a child reads their own total. Quick-spend being hidden costs it only + // the handler and the affordances that advertise one. return ( setQuickSpend({ open: true, user, amount: '', note: '' })} + sx={{ position: 'relative', display: 'inline-block', cursor: hideQuickSpend ? 'default' : 'pointer' }} + title={hideQuickSpend ? undefined : t('chores:widget.redeemClamsFor', { name: user.username })} + onClick={hideQuickSpend ? undefined : () => setQuickSpend({ open: true, user, amount: '', note: '' })} > {imageUrl ? ( <> @@ -1124,14 +1174,16 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { > - + {!hideAddChore && ( + + )} @@ -1345,13 +1397,23 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { })()} - - - + + + )} + {/* Pushed right only while it shares the row with the + verdict buttons; alone it sits where any lone button + would. */} + @@ -1693,13 +1755,13 @@ const ChoreWidget = ({ refreshNonce = 0 }) => { anchorReference="anchorPosition" anchorPosition={choreMenu.position || undefined} > - {choreMenu.schedule?.transferable !== 0 && users.filter(u => u.id !== 0 && u.id !== choreMenu.schedule?.user_id).length > 0 && ( + {!hideTransferChore && choreMenu.schedule?.transferable !== 0 && users.filter(u => u.id !== 0 && u.id !== choreMenu.schedule?.user_id).length > 0 && ( )} - {choreMenu.schedule?.can_snooze !== 0 && ( + {!hideSnoozeChore && choreMenu.schedule?.can_snooze !== 0 && ( diff --git a/client/src/components/ControlsOnDisplay.jsx b/client/src/components/ControlsOnDisplay.jsx new file mode 100644 index 0000000..1e7694b --- /dev/null +++ b/client/src/components/ControlsOnDisplay.jsx @@ -0,0 +1,877 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormGroup, + InputLabel, + MenuItem, + Paper, + Select, + Switch, + Typography, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { + CONTROL_LIMITS_KEY, + CONTROL_PRESETS, + CORE_CONTROLS, + defaultWouldRestrict, + editorState, + fetchAllDisplayLimits, + householdDefaultLimits, + isControlHidden, + isDisplayUnlocked, + isValidControlId, + normalizeControlLimits, + saveDisplayLimits, + saveHouseholdDefaultLimits, + toggleControl, +} from '../utils/displayControls.js'; + +/** + * Control Limits admin form — which parent-facing controls each display offers. + * + * Every decision is asked of `utils/displayControls.js`; nothing here re-derives + * the mode/except inversion and nothing here reads `except`. This file is the + * form: fetch, render, write, fold the answer back in. + * + * Four invariants it has to hold: + * + * - Switches show the CONFIGURED state, never the in-force one. An exempt + * display hides nothing in force, but its switches must still show what is + * saved so it can be changed — so `editorState`, which cannot see the PIN + * status, is the only source of a switch position, and the exemption is + * surfaced as a notice rather than by moving switches. + * - No display renders until every read has settled, or a configured row reads + * "Using household default" before we know whether it is. + * - A row we could not read asserts nothing: no positions, no caption, no + * preset, and no write (see `requestHouseholdDefault`). `settingsRead: false` + * means unknown, and unknown is not a licence to overwrite. + * - Busy and error state are per row, and a settled read may not overwrite a row + * that was written while it was in flight (see `writeClockRef`, `mergeRead`). + */ + +// `busyScopes` and `errorScopes` hold device names, or this sentinel for the +// household default. A colon cannot appear in a device name (see +// DEVICE_NAME_ALLOWED in utils/deviceName.js), so the two can never collide. +const HOUSEHOLD_SCOPE = ':household'; + +const SELECTABLE_PRESETS = ['fullControl', 'wallDisplay']; + +// Sets of scopes, copied only when the membership actually changes so an +// unrelated row does not re-render on every write. +const withScope = (scopes, scope) => { + if (scopes.has(scope)) return scopes; + const next = new Set(scopes); + next.add(scope); + return next; +}; + +const withoutScope = (scopes, scope) => { + if (!scopes.has(scope)) return scopes; + const next = new Set(scopes); + next.delete(scope); + return next; +}; + +/** + * Read/write ordering, per display. + * + * A read started before a write cannot be allowed to land over that write's + * answer. The read token alone only orders read-against-read, so the sequence + * "Refresh starts → admin toggles → PATCH echoes and folds in → the older read + * resolves" replaced the freshly-written row with the pre-toggle value, and the + * admin's NEXT toggle then computed from that stale row and silently un-hid the + * first control on the server. + * + * One monotonic counter answers it. A write stamps the clock when it settles; a + * read remembers the clock it started at. When the read lands, a row is kept + * from the previous state if a write for it is still open, or if a write for it + * settled after the read began — both mean the read's copy of that row is older + * than what we know. Every other row folds in normally, so one busy display + * does not cost the rest of the table its refresh. + */ +const newWriteClock = () => ({ now: 0, rows: new Map() }); + +const beginWrite = (clock, name) => { + clock.now += 1; + const row = clock.rows.get(name) ?? { open: 0, settledAt: 0 }; + clock.rows.set(name, { open: row.open + 1, settledAt: row.settledAt }); +}; + +const endWrite = (clock, name) => { + clock.now += 1; + const row = clock.rows.get(name) ?? { open: 0, settledAt: 0 }; + clock.rows.set(name, { open: Math.max(0, row.open - 1), settledAt: clock.now }); +}; + +const readStartedAt = (clock) => { + clock.now += 1; + return clock.now; +}; + +const writeWonTheRace = (clock, name, startedAt) => { + const row = clock.rows.get(name); + if (!row) return false; + return row.open > 0 || row.settledAt > startedAt; +}; + +/** + * Fold a finished read into state, per display rather than by replacing the + * whole map: `names` is what the read asked for, so a display dropped from the + * list drops out of state, and a display whose write outranks the read keeps the + * value we already have. + */ +const mergeRead = (prev, next, names, keepPrevious) => { + if (!prev) return next; + const merged = { ...next }; + for (const name of names) { + if (keepPrevious(name) && Object.prototype.hasOwnProperty.call(prev, name)) { + merged[name] = prev[name]; + } + } + return merged; +}; + +/** + * Plugin manifests are author-supplied JSON and the server does not validate + * `hideableControls` at all, so every value taken out of one is a value that can + * be any JSON type. An object reaching JSX throws "Objects are not valid as a + * React child", and with no error boundary anywhere in this client that unmounts + * the whole admin tree — including the only UI for uninstalling the plugin that + * did it. A non-string is therefore not rendered; it falls back. + */ +const asText = (value, fallback) => ( + typeof value === 'string' && value.trim() !== '' ? value : fallback +); + +/** + * One group of switches. ON means the control is SHOWN — storage is the inverse, + * but the label has to read the way a parent thinks, so the inversion happens + * here at the edge and only here. + * + * `titleId` ties the group's heading to the switches inside it. Without it a + * screen reader reads "Edit the menu, switch, on" with nothing saying which + * plugin, and two plugins whose authors picked the same control label are + * indistinguishable. + */ +const ControlSwitchGroup = ({ title, titleId, items, hiddenIds, disabled, onToggle }) => ( + + + {title} + + + {items.map((item) => ( + onToggle(item.id, event.target.checked)} + /> + )} + label={item.label} + /> + ))} + + +); + +const ControlsOnDisplay = ({ + apiBaseUrl, + devices, + currentDeviceName, + pinExists, + householdSettings, + plugins, + onHouseholdDefaultSaved, + onDisplayLimitsSaved, +}) => { + const { t } = useTranslation(['admin', 'common']); + + const [limitsByDevice, setLimitsByDevice] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + // Scoped to the rows being written, never global, and a SET rather than one + // name: a ten-display household flipping one switch must not freeze the other + // nine, and `kitchen`'s write finishing must not re-enable `hallway`'s + // switches while `hallway`'s own PATCH is still in flight. + const [busyScopes, setBusyScopes] = useState(() => new Set()); + // Which rows last failed to save, not their messages. Holding the translated + // string would freeze it in the language it was produced in, and would drag + // `t` into the dependencies of the fetch callback below. A set for the same + // reason as above: one row's write must not erase an error the admin has not + // read on another row. + const [errorScopes, setErrorScopes] = useState(() => new Set()); + // A household-default change that is waiting on the "restrict this display + // too?" question. Holds the limits object the admin asked for, and is kept + // until the write actually lands — see the dialog at the bottom. + const [pendingDefault, setPendingDefault] = useState(null); + // null | 'writing' | 'failed' for that dialog's own two-step write. The + // dialog is where the admin is looking, so it is where its progress and its + // failure belong. + const [defaultStatus, setDefaultStatus] = useState(null); + // A preset choice that would throw away individually-set controls, waiting on + // confirmation. `{scope, deviceName, limits, preset, count}` — the preset NAME, + // not its translated label, so the dialog re-renders in the current language. + const [pendingPreset, setPendingPreset] = useState(null); + + // Stringified so the fetch effect and its callback depend on the *names*, not + // on the identity of a freshly-mapped array. The names are read back out of + // the key, so there is no second copy to drift. + const deviceNamesKey = useMemo(() => JSON.stringify( + (devices || []).map((device) => device?.name).filter((name) => typeof name === 'string' && name.length > 0), + ), [devices]); + const deviceNames = useMemo(() => JSON.parse(deviceNamesKey), [deviceNamesKey]); + + // Plugin-contributed controls. Labels are author-supplied and deliberately not + // translated, matching how declared plugin settings already render + // (`setting.label || setting.key`) — but they are type-checked on the way in, + // because a manifest is not a trusted source of React children. + // + // Ids that `isValidControlId` rejects are dropped rather than rendered: they + // cannot be stored, so a switch for one would move and then do nothing. A + // non-string id is dropped for the same reason — interpolating one produces + // `plugin:x:undefined`, which the id regex happens to accept. + const pluginGroups = useMemo(() => (plugins || []).map((plugin) => { + const pluginId = asText(plugin?.manifest?.id, asText(plugin?.pluginId, null)); + const declared = Array.isArray(plugin?.manifest?.hideableControls) ? plugin.manifest.hideableControls : []; + if (!pluginId || declared.length === 0) return null; + + const seen = new Set(); + const items = declared + .map((control) => { + const controlId = control?.id; + if (typeof controlId !== 'string') return null; + return { id: `plugin:${pluginId}:${controlId}`, label: asText(control?.label, controlId) }; + }) + .filter((item) => item !== null && isValidControlId(item.id)) + // A manifest may name the same control twice; two switches writing one id + // would also collide as React keys. + .filter((item) => (seen.has(item.id) ? false : seen.add(item.id))); + + if (items.length === 0) return null; + const title = asText(plugin?.manifest?.name, asText(plugin?.name, pluginId)); + return { key: pluginId, title, items }; + }).filter(Boolean), [plugins]); + + const groups = useMemo(() => [ + { + key: 'core', + title: t('admin:controls.coreGroup'), + items: CORE_CONTROLS.map((control) => ({ id: control.id, label: t(control.labelKey) })), + }, + ...pluginGroups, + ], [pluginGroups, t]); + + // The live catalog. `editorState` needs it to name a plugin control at all; + // it is a source of candidates, never a filter. + const knownControlIds = useMemo( + () => groups.flatMap((group) => group.items.map((item) => item.id)), + [groups], + ); + + const householdDefault = useMemo(() => householdDefaultLimits(householdSettings), [householdSettings]); + + /** + * How many of the switches on screen would move if `next` replaced `own`. + * + * Asked of `editorState` and `isControlHidden` rather than counting `except`, + * which is private to the module, and restricted to the rendered catalog so + * the number names something the admin can actually see and check. + */ + const countMovedSwitches = useCallback((own, next, inherited) => { + const before = editorState({ own, inherited, knownControlIds }).hiddenIds; + const after = editorState({ own: next, inherited, knownControlIds }).hiddenIds; + return knownControlIds.filter( + (id) => isControlHidden(before, id) !== isControlHidden(after, id), + ).length; + }, [knownControlIds]); + + // Only the newest read may land. Two reads can be in flight — the device list + // arriving while the first is running, or an admin pressing Refresh — and the + // slower one finishing last would otherwise overwrite the newer answer. + const readTokenRef = useRef(0); + // Read-against-write ordering, per display. See newWriteClock above. + const writeClockRef = useRef(newWriteClock()); + + const loadLimits = useCallback(async () => { + const names = JSON.parse(deviceNamesKey); + readTokenRef.current += 1; + const token = readTokenRef.current; + const startedAt = readStartedAt(writeClockRef.current); + const keepPrevious = (name) => writeWonTheRace(writeClockRef.current, name, startedAt); + try { + const next = await fetchAllDisplayLimits(apiBaseUrl, names); + if (token !== readTokenRef.current) return; + setLimitsByDevice((prev) => mergeRead(prev, next, names, keepPrevious)); + setLoadFailed(false); + } catch (error) { + // fetchAllDisplayLimits settles rather than races, so this is close to + // unread — but an empty map is the honest fallback: every row then + // says "could not read", which is exactly what happened. Rows with a + // fresher write are still kept: that value came from the server's own + // echo, and throwing it away would report "could not read" about a + // display we just successfully wrote. + console.error('Error reading display control limits:', error); + if (token !== readTokenRef.current) return; + setLimitsByDevice((prev) => mergeRead(prev, {}, names, keepPrevious)); + setLoadFailed(true); + } + }, [apiBaseUrl, deviceNamesKey]); + + useEffect(() => { + void loadLimits(); + }, [loadLimits]); + + /** + * Write one display and fold the echoed blob into local state. + * + * Deliberately does NOT re-read every display afterwards: the PATCH route + * echoes the merged settings, so the answer is already in hand. Re-reading was + * eleven requests per switch on a ten-display household. + * + * A failure re-reads on purpose. A local copy that may have diverged from the + * server is worse for an admin than one extra round trip. + */ + const writeDisplay = useCallback(async (deviceName, limits) => { + beginWrite(writeClockRef.current, deviceName); + setBusyScopes((prev) => withScope(prev, deviceName)); + setErrorScopes((prev) => withoutScope(prev, deviceName)); + try { + let blob; + try { + blob = await saveDisplayLimits(apiBaseUrl, deviceName, limits); + } finally { + // Closed the instant the PATCH settles, which is before the corrective + // re-read below: the read merge must not protect the one row that read + // exists to resync. + endWrite(writeClockRef.current, deviceName); + } + // Key presence, not truthiness: a retraction legitimately echoes null. + // A response that omits the key entirely is not an answer, so fall back + // to what we just sent rather than recording "inheriting". + const echoed = Object.prototype.hasOwnProperty.call(blob, CONTROL_LIMITS_KEY) + ? blob[CONTROL_LIMITS_KEY] + : limits; + // `settings` carries `echoed` explicitly so the two halves of the entry + // cannot disagree: `isDisplayUnlocked` and `defaultWouldRestrict` read the + // blob, `editorState` reads `limits`, and a blob missing the key would + // otherwise report "inheriting" to one and "configured" to the other. + setLimitsByDevice((prev) => ({ + ...(prev || {}), + [deviceName]: { + limits: normalizeControlLimits(echoed), + settingsRead: true, + settings: { ...blob, [CONTROL_LIMITS_KEY]: echoed }, + }, + })); + if (onDisplayLimitsSaved) onDisplayLimitsSaved(deviceName, blob); + return true; + } catch (error) { + console.error(`Error saving control limits for display ${deviceName}:`, error); + setErrorScopes((prev) => withScope(prev, deviceName)); + await loadLimits(); + return false; + } finally { + setBusyScopes((prev) => withoutScope(prev, deviceName)); + } + }, [apiBaseUrl, loadLimits, onDisplayLimitsSaved]); + + const writeHouseholdDefault = useCallback(async (limits) => { + setBusyScopes((prev) => withScope(prev, HOUSEHOLD_SCOPE)); + setErrorScopes((prev) => withoutScope(prev, HOUSEHOLD_SCOPE)); + try { + const written = await saveHouseholdDefaultLimits(apiBaseUrl, limits); + if (onHouseholdDefaultSaved) onHouseholdDefaultSaved(written); + return true; + } catch (error) { + console.error('Error saving the household control-limits default:', error); + // No re-read: the household default is only folded into state on success, + // so a failure leaves nothing to diverge. + setErrorScopes((prev) => withScope(prev, HOUSEHOLD_SCOPE)); + return false; + } finally { + setBusyScopes((prev) => withoutScope(prev, HOUSEHOLD_SCOPE)); + } + }, [apiBaseUrl, onHouseholdDefaultSaved]); + + // The display the admin is holding, and whether we actually read it. Every + // decision about that display is gated on the second, never on the first + // being merely present. + const currentEntry = currentDeviceName ? limitsByDevice?.[currentDeviceName] : undefined; + const currentReadable = currentEntry?.settingsRead === true; + + /** + * Ask before a household-default change takes controls away from the display + * the admin is holding. `defaultWouldRestrict` is the whole decision: a + * display with its own configuration, or an exempt one, is unaffected. + * + * Gated on `settingsRead`, not on the entry existing. A failed read stores + * `settings: null` precisely so it is distinguishable from "reached, stores + * nothing" — and `defaultWouldRestrict(null)` reports "inherits, will be + * restricted", which opened the dialog whose "Keep full control here" then + * PATCHes `{showAll, except: []}` over a configuration we never managed to + * read. Unknown is not a licence to overwrite, so an unreadable current + * display gets the household write it asked for and a notice saying we could + * not check it, rather than a dialog offering to overwrite it. + */ + const requestHouseholdDefault = useCallback((nextDefault) => { + const shouldAsk = currentReadable && defaultWouldRestrict({ + deviceSettings: currentEntry.settings, + nextDefault, + pinExists, + }); + if (shouldAsk) { + setDefaultStatus(null); + setPendingDefault(nextDefault); + return; + } + void writeHouseholdDefault(nextDefault); + }, [currentEntry, currentReadable, pinExists, writeHouseholdDefault]); + + const closeDefaultDialog = useCallback(() => { + setPendingDefault(null); + setDefaultStatus(null); + }, []); + + // Unset default edits as Full control: `householdDefaultLimits` returns null + // when nothing is stored, and null through editorState would report + // `inheriting` — there is nothing for the household to inherit from, and the + // switches must work. + const householdBase = householdDefault ?? CONTROL_PRESETS.fullControl; + const householdEditor = editorState({ own: householdBase, knownControlIds }); + const householdBusy = busyScopes.has(HOUSEHOLD_SCOPE); + // Locked until the displays have been read: `defaultWouldRestrict` cannot say + // whether this change takes controls away from the screen in the admin's hands + // until we know what that screen stores, and asking before then either prompts + // for nothing or skips a prompt that was warranted. Locked while either + // dialog is holding a household change too, so the two cannot be stacked. + const householdLocked = householdBusy + || pendingDefault !== null + || pendingPreset?.scope === HOUSEHOLD_SCOPE + || limitsByDevice === null; + + // Disabled options are still rendered because MUI takes the selected label + // from its children: without them a "Custom" or unreadable row would hand + // Select an out-of-range value and render blank. + const renderPresetOptions = (perDisplay) => [ + ...(perDisplay + ? [{t('admin:controls.presets.householdDefault')}] + : []), + ...SELECTABLE_PRESETS.map((name) => ( + {t(`admin:controls.presets.${name}`)} + )), + // Custom is a state, not an action: it is what the select reads when the + // switches do not match a preset. Selectable it would mean nothing. + {t('admin:controls.presets.custom')}, + ...(perDisplay + ? [{t('admin:controls.presets.unknown')}] + : []), + ]; + + const renderDisplayCard = (deviceName, index) => { + const entry = limitsByDevice?.[deviceName] ?? { limits: null, settingsRead: false, settings: null }; + // Null, not an editorState, for a row we could not read. Every position, + // preset and mode caption below is a claim about stored configuration, and + // we have none — feeding it `inherited: householdDefault` drew the switches + // as what the display WOULD inherit, which is a claim about a configuration + // the read just failed to fetch. + const state = entry.settingsRead + ? editorState({ own: entry.limits, inherited: householdDefault, knownControlIds }) + : null; + const isCurrent = deviceName === currentDeviceName; + const busy = busyScopes.has(deviceName); + // A confirmation holding a write for this row locks it too, so a second + // choice cannot be queued behind the dialog. + const locked = busy || pendingPreset?.scope === deviceName; + // Gated on `settingsRead` as well as the module's answer. `settings: null` from + // a failed read already reports "not unlocked", but an unreadable row must + // keep its own treatment no matter what: the two claims are about different + // things, and only one of them can be true of a row we could not read. + const unlocked = entry.settingsRead && isDisplayUnlocked({ deviceSettings: entry.settings, pinExists }); + + // Inheriting rows are read-only: editing a switch there would quietly give + // the display its own configuration, which is not what clicking a switch + // looks like it does. + const editable = state !== null && !state.inheriting && !locked; + const selectValue = state === null + ? 'unknown' + : (state.inheriting ? 'householdDefault' : state.preset); + + return ( + + + {deviceName} + {isCurrent && } + + {busy && } + + + {state === null && ( + {t('admin:controls.unreadableHelp')} + )} + + {unlocked && ( + + {t('admin:controls.unlockedNotice', { action: t('admin:pin.forgetDevices') })} + + )} + + {/* A live region that is always in the DOM, not an Alert that appears in + one. Every switch here writes immediately and a failure is the only + thing that tells the admin it did not take, so it has to be + announced rather than merely drawn — and a message inserted into a + region that was already there is announced far more reliably than + one carried in by a freshly mounted role="alert". + The Alert drops that role for exactly that reason: two overlapping + live regions for one message is worse than one. */} + + {errorScopes.has(deviceName) && ( + + {t('admin:controls.saveFailed')} + + )} + + + + {/* Indexed, not named: a device name may contain spaces, and a DOM id + with a space breaks the aria-labelledby token list. */} + {t('admin:controls.presetLabel')} + + + + {/* Nothing below this line is rendered for a row we could not read: + switch positions, the inherit hint and the mode caption are all + assertions about stored configuration. */} + {state !== null && ( + <> + {state.inheriting && ( + + {t('admin:controls.inheritingHelp')} + + )} + + + {t('admin:controls.switchHint')} + + + {/* The mode is the only thing distinguishing two configurations that + look identical in the switches: "hideAll with everything switched on" + and "showAll with nothing switched off" hide nothing today, and + disagree about a control added tomorrow. Without this line the preset + reads "Custom" for no visible reason. Worded as on/off to match the + switches it sits under, rather than introducing hidden/shown as a + second vocabulary for the same thing. */} + + {t(state.mode === 'hideAll' + ? 'admin:controls.futureDefaultOff' + : 'admin:controls.futureDefaultOn')} + + + {groups.map((group) => ( + { + void writeDisplay(deviceName, toggleControl(entry.limits, controlId, !shown)); + }} + /> + ))} + + )} + + ); + }; + + return ( + + + {t('admin:controls.heading')} + + + {t('admin:controls.help')} + + + {/* Household default */} + + + + {t('admin:controls.defaultHeading')} + + {householdBusy && } + + + {t('admin:controls.defaultHelp')} + + + {/* Said once, here, rather than inferred: with the current display + unreadable we cannot tell whether a change here takes controls away + from the screen in the admin's hands, and the honest move is to say + so instead of guessing in either direction. */} + {limitsByDevice !== null && currentDeviceName && !currentReadable && ( + {t('admin:controls.currentUnknownHelp')} + )} + + + {errorScopes.has(HOUSEHOLD_SCOPE) && ( + + {t('admin:controls.saveFailed')} + + )} + + + + {t('admin:controls.presetLabel')} + + + + + {t('admin:controls.switchHint')} + + + {/* The mode is the only thing distinguishing two configurations that + look identical in the switches: "hideAll with everything switched on" + and "showAll with nothing switched off" hide nothing today, and + disagree about a control added tomorrow. Without this line the preset + reads "Custom" for no visible reason. Worded as on/off to match the + switches it sits under, rather than introducing hidden/shown as a + second vocabulary for the same thing. */} + + {t(householdEditor.mode === 'hideAll' + ? 'admin:controls.futureDefaultOff' + : 'admin:controls.futureDefaultOn')} + + + {groups.map((group) => ( + { + requestHouseholdDefault(toggleControl(householdBase, controlId, !shown)); + }} + /> + ))} + + + {/* One card per display */} + + + {t('admin:controls.displaysHeading')} + + + + + + {loadFailed && ( + + {t('admin:controls.loadFailed')} + + )} + + + {limitsByDevice === null ? ( + + + {t('admin:controls.loading')} + + ) : ( + <> + {deviceNames.length === 0 && ( + {t('admin:controls.noDisplays')} + )} + {deviceNames.map((name, index) => renderDisplayCard(name, index))} + + )} + + {/* Individually-set controls are about to be replaced wholesale. */} + setPendingPreset(null)}> + {t('admin:controls.clearExceptions.title')} + + + {pendingPreset && t( + pendingPreset.scope === HOUSEHOLD_SCOPE + ? 'admin:controls.clearExceptions.household' + : 'admin:controls.clearExceptions.display', + { + count: pendingPreset.count, + device: pendingPreset.deviceName, + preset: t(`admin:controls.presets.${pendingPreset.preset}`), + }, + )} + + + + + + + + + {/* The household change is held here until it actually lands. Clearing it + before the write meant a failed "Keep full control here" threw the + admin's household change away and reported the failure inside a display + card that may be scrolled off screen — or, when that display was not in + the device list at all, nowhere. */} + { if (defaultStatus !== 'writing') closeDefaultDialog(); }} + > + {t('admin:controls.confirm.title')} + + + {t('admin:controls.confirm.body', { device: currentDeviceName })} + + + {defaultStatus === 'failed' && ( + + {t('admin:controls.confirm.failed')} + + )} + + + + + + + + + + ); +}; + +export default ControlsOnDisplay; diff --git a/client/src/components/PluginWidgetWrapper.jsx b/client/src/components/PluginWidgetWrapper.jsx index 2638e08..f595bd5 100644 --- a/client/src/components/PluginWidgetWrapper.jsx +++ b/client/src/components/PluginWidgetWrapper.jsx @@ -6,7 +6,18 @@ import { getDeviceName } from '../utils/deviceName.js'; import { subscribePluginEvents } from '../utils/pluginEventBridge.js'; import { acceptPluginDataMessage, emitPluginDataChanged } from '../utils/pluginDataBridge.js'; -const PluginWidgetWrapper = ({ filename, name, theme, transparentBackground = false, refreshNonce = 0, events = [] }) => { +const PluginWidgetWrapper = ({ + filename, + name, + theme, + transparentBackground = false, + refreshNonce = 0, + events = [], + // Control Limits: this plugin's own hidden control ids, already unprefixed by + // the dashboard. HomeGlow cannot reach into the iframe, so the plugin is told + // and does its own hiding — see docs/guides/plugin-development.md. + hiddenControls = [], +}) => { const { i18n } = useTranslation(); // Manifest plugins need the display's device name so device-scoped settings // resolve via the plugin SDK (issue #105 Phase 2). @@ -57,6 +68,15 @@ const PluginWidgetWrapper = ({ filename, name, theme, transparentBackground = fa return () => window.removeEventListener('message', onMessage); }, [iframeOrigin, filename]); + // Omitted entirely when nothing is hidden: a plugin (and an older dashboard) + // must read "no hide param" as "hide nothing". Flattened to a string so an + // unchanged hidden set produces a byte-identical src — changing src navigates + // the frame, which is what makes a newly hidden control take effect, and is + // waste when a settings refetch merely handed us an equal array. + const hideParam = (Array.isArray(hiddenControls) ? hiddenControls : []) + .map((id) => encodeURIComponent(id)) + .join(','); + return ( {/* Keying the iframe on refreshNonce reloads the plugin on refresh @@ -68,7 +88,7 @@ const PluginWidgetWrapper = ({ filename, name, theme, transparentBackground = fa // lang rides the same channel as theme (issue #137) so a plugin that // ships translations can follow the display's language; plugins that // ignore it are unaffected. - src={`${API_BASE_URL}/widgets/${filename}?theme=${theme}&device=${encodeURIComponent(deviceName)}&lang=${i18n.language || 'en'}`} + src={`${API_BASE_URL}/widgets/${filename}?theme=${theme}&device=${encodeURIComponent(deviceName)}&lang=${i18n.language || 'en'}${hideParam ? `&hide=${hideParam}` : ''}`} title={name} style={{ width: '100%', diff --git a/client/src/i18n/locales/en/admin.json b/client/src/i18n/locales/en/admin.json index d8e7bec..53c1efa 100644 --- a/client/src/i18n/locales/en/admin.json +++ b/client/src/i18n/locales/en/admin.json @@ -313,6 +313,60 @@ "removePin": "Remove PIN", "changeNote": "Changing your PIN will require you to use the new PIN on your next admin panel access." }, + "controls": { + "heading": "Controls on Displays", + "help": "Choose which parent controls each display offers. Hiding a control only stops that display from drawing it — it is not a password, and anything hidden is still available here.", + "defaultHeading": "Default for new displays", + "defaultHelp": "Displays with no setting of their own follow this, including displays added later.", + "displaysHeading": "Each display", + "noDisplays": "No displays have checked in yet.", + "loading": "Reading displays...", + "loadFailed": "Could not read the displays. Use Refresh to try again.", + "presetLabel": "Controls offered", + "switchHint": "On means the control is shown.", + "futureDefaultOn": "Controls added later will default to on.", + "futureDefaultOff": "Controls added later will default to off.", + "coreGroup": "Built-in controls", + "inheritingHelp": "These switches follow the household default. Choose Full control or Wall display to give this display a setting of its own.", + "unreadableHelp": "We could not read this display's settings, so what it offers is unknown. Nothing has been changed for it.", + "unlockedNotice": "This display remembers the admin PIN, so it is exempt and offers every control. The settings below are saved but not in effect here. Use \"{{action}}\" above to apply them again.", + "saveFailed": "Could not save that change, so the displays were read again.", + "saving": "Saving changes", + "currentUnknownHelp": "We could not read the settings for the display you are using, so we cannot tell whether a change here would take controls away from it.", + "presets": { + "householdDefault": "Household default", + "fullControl": "Full control", + "wallDisplay": "Wall display", + "custom": "Custom", + "unknown": "Unknown" + }, + "status": { + "inheriting": "Using household default", + "unreadable": "Could not read this display" + }, + "items": { + "addChore": "Add a chore", + "transferChore": "Transfer a chore to someone else", + "snoozeChore": "Snooze a chore", + "prizeApproval": "Approve or decline prize requests", + "quickSpend": "Spend clams from a profile" + }, + "confirm": { + "title": "Restrict this display too?", + "body": "\"{{device}}\" is the display you are using, and it follows the household default, so this change would take controls away from it.", + "keepFull": "Keep full control here", + "applyHere": "Apply here too", + "failed": "Could not save that change, so the household default was left as it was. Try again, or cancel to keep it." + }, + "clearExceptions": { + "title": "Replace the individual control settings?", + "display_one": "\"{{device}}\" has controls set individually. \"{{preset}}\" replaces all of them, and 1 switch below changes.", + "display_other": "\"{{device}}\" has controls set individually. \"{{preset}}\" replaces all of them, and {{count}} switches below change.", + "household_one": "The default has controls set individually. \"{{preset}}\" replaces all of them, for every display that follows the default, and 1 switch below changes.", + "household_other": "The default has controls set individually. \"{{preset}}\" replaces all of them, for every display that follows the default, and {{count}} switches below change.", + "confirm": "Replace" + } + }, "connections": { "heading": "Connections", "apiKeys": "API Keys", diff --git a/client/src/i18n/locales/es/admin.json b/client/src/i18n/locales/es/admin.json index 1834b43..f7e1604 100644 --- a/client/src/i18n/locales/es/admin.json +++ b/client/src/i18n/locales/es/admin.json @@ -313,6 +313,60 @@ "removePin": "Eliminar el PIN", "changeNote": "Si cambias el PIN, tendrás que usar el nuevo la próxima vez que accedas al panel." }, + "controls": { + "heading": "Controles en las pantallas", + "help": "Elige qué controles de madres y padres ofrece cada pantalla. Ocultar un control solo evita que esa pantalla lo dibuje: no es una contraseña, y todo lo oculto sigue disponible aquí.", + "defaultHeading": "Predeterminado para pantallas nuevas", + "defaultHelp": "Las pantallas que no tienen su propia configuración siguen esta, incluidas las que se añadan más adelante.", + "displaysHeading": "Cada pantalla", + "noDisplays": "Todavía no se ha conectado ninguna pantalla.", + "loading": "Leyendo las pantallas...", + "loadFailed": "No se pudieron leer las pantallas. Usa Actualizar para volver a intentarlo.", + "presetLabel": "Controles ofrecidos", + "switchHint": "Activado significa que el control se muestra.", + "futureDefaultOn": "Los controles que se añadan más adelante estarán activados por defecto.", + "futureDefaultOff": "Los controles que se añadan más adelante estarán desactivados por defecto.", + "coreGroup": "Controles integrados", + "inheritingHelp": "Estos interruptores siguen el valor predeterminado del hogar. Elige Control total o Pantalla de pared para dar a esta pantalla su propia configuración.", + "unreadableHelp": "No se pudo leer la configuración de esta pantalla, así que no sabemos qué ofrece. No se ha cambiado nada en ella.", + "unlockedNotice": "Esta pantalla recuerda el PIN de administrador, por lo que está exenta y ofrece todos los controles. La configuración de abajo está guardada, pero no se aplica aquí. Usa «{{action}}» arriba para volver a aplicarla.", + "saveFailed": "No se pudo guardar ese cambio, así que se volvieron a leer las pantallas.", + "saving": "Guardando cambios", + "currentUnknownHelp": "No se pudo leer la configuración de la pantalla que estás usando, así que no sabemos si un cambio aquí le quitaría controles.", + "presets": { + "householdDefault": "Predeterminado del hogar", + "fullControl": "Control total", + "wallDisplay": "Pantalla de pared", + "custom": "Personalizado", + "unknown": "Desconocido" + }, + "status": { + "inheriting": "Usa el predeterminado del hogar", + "unreadable": "No se pudo leer esta pantalla" + }, + "items": { + "addChore": "Añadir una tarea", + "transferChore": "Transferir una tarea a otra persona", + "snoozeChore": "Posponer una tarea", + "prizeApproval": "Aprobar o rechazar solicitudes de premios", + "quickSpend": "Gastar almejas desde un perfil" + }, + "confirm": { + "title": "¿Restringir también esta pantalla?", + "body": "«{{device}}» es la pantalla que estás usando y sigue el predeterminado del hogar, así que este cambio le quitaría controles.", + "keepFull": "Mantener el control total aquí", + "applyHere": "Aplicar también aquí", + "failed": "No se pudo guardar ese cambio, así que el predeterminado del hogar se quedó como estaba. Vuelve a intentarlo o cancela para dejarlo así." + }, + "clearExceptions": { + "title": "¿Reemplazar los controles ajustados uno a uno?", + "display_one": "«{{device}}» tiene controles ajustados uno a uno. «{{preset}}» los reemplaza todos, y 1 interruptor de abajo cambia.", + "display_other": "«{{device}}» tiene controles ajustados uno a uno. «{{preset}}» los reemplaza todos, y {{count}} interruptores de abajo cambian.", + "household_one": "El predeterminado tiene controles ajustados uno a uno. «{{preset}}» los reemplaza todos, para todas las pantallas que siguen el predeterminado, y 1 interruptor de abajo cambia.", + "household_other": "El predeterminado tiene controles ajustados uno a uno. «{{preset}}» los reemplaza todos, para todas las pantallas que siguen el predeterminado, y {{count}} interruptores de abajo cambian.", + "confirm": "Reemplazar" + } + }, "connections": { "heading": "Conexiones", "apiKeys": "Claves de API", diff --git a/client/src/utils/displayControls.js b/client/src/utils/displayControls.js new file mode 100644 index 0000000..1dd51d3 --- /dev/null +++ b/client/src/utils/displayControls.js @@ -0,0 +1,375 @@ +import axios from 'axios'; +import { isPinRemembered } from './adminPinDevice.js'; + +/** + * Control Limits — which parent-facing controls a given display offers. + * + * Visibility, not access control: the API has no per-device auth, so every failure + * path here resolves to hiding nothing. + * + * { mode: 'showAll' | 'hideAll', except: ['core:addChore', ...] } + * + * `mode` governs every control NOT named in `except`, including ones that do not + * exist yet — so a plugin installed next month is hidden on a wall display without + * anyone revisiting it. `except` is the inverse under each mode (hidden ids under + * showAll, visible ones under hideAll), so it is private to this module: ask + * `isHiddenUnder`. + * + * Stored per display as the device key `controlLimits` (absent or null means + * inherit; PATCH merges, so null is the only retraction) and per household as + * `DISPLAY_CONTROL_LIMITS_DEFAULT`, a JSON string because settings values are TEXT. + */ + +export const CONTROL_LIMITS_KEY = 'controlLimits'; +export const CONTROL_LIMITS_DEFAULT_KEY = 'DISPLAY_CONTROL_LIMITS_DEFAULT'; + +/** + * The fixed catalog of built-in controls. Plugins contribute their own ids from + * their manifests, so this list is a floor, never the whole universe — which is + * why no decision below is derived from its length. + */ +export const CORE_CONTROLS = [ + { id: 'core:addChore', labelKey: 'admin:controls.items.addChore' }, + { id: 'core:transferChore', labelKey: 'admin:controls.items.transferChore' }, + { id: 'core:snoozeChore', labelKey: 'admin:controls.items.snoozeChore' }, + { id: 'core:prizeApproval', labelKey: 'admin:controls.items.prizeApproval' }, + { id: 'core:quickSpend', labelKey: 'admin:controls.items.quickSpend' }, +]; + +const CORE_CONTROL_IDS = CORE_CONTROLS.map((control) => control.id); + +/** + * The two configurations worth naming. Both ship with an empty `except`, so the + * feature is inert until someone picks Wall display or hides an individual + * control. Frozen because these are shared constants, and a caller that mutated + * one would silently redefine "Full control" for the whole app. + */ +export const CONTROL_PRESETS = Object.freeze({ + fullControl: Object.freeze({ mode: 'showAll', except: Object.freeze([]) }), + wallDisplay: Object.freeze({ mode: 'hideAll', except: Object.freeze([]) }), +}); + +const VALID_MODES = new Set(['showAll', 'hideAll']); + +// Plugin slugs match the server's PLUGIN_ID_REGEX; control ids within a +// namespace are camelCase so they can be used as i18n key fragments. +const CORE_CONTROL_ID = /^core:[a-z][a-zA-Z0-9]*$/; +const PLUGIN_CONTROL_ID = /^plugin:[a-z0-9][a-z0-9-]{0,63}:[a-z][a-zA-Z0-9]*$/; + +/** + * Ids are stored and compared as opaque strings, so a malformed one would sit + * in `except` forever, never matching anything and making two identical-looking + * configurations compare unequal. Validating on the way in keeps that out. + */ +export function isValidControlId(id) { + if (typeof id !== 'string') return false; + return CORE_CONTROL_ID.test(id) || PLUGIN_CONTROL_ID.test(id); +} + +/** + * Sorted, not insertion-ordered. `except` is a set, and the UI compares a + * configuration against the presets to decide whether to show "Custom" — two + * configurations that hide the same controls must therefore produce the same + * array, regardless of the order the user clicked them in. + */ +const sortIds = (ids) => [...ids].sort(); + +const isPlainObject = (value) => ( + typeof value === 'object' && value !== null && !Array.isArray(value) +); + +const validUniqueIds = (ids) => { + if (!Array.isArray(ids)) return []; + const seen = new Set(); + for (const id of ids) { + if (isValidControlId(id)) seen.add(id); + } + return sortIds(seen); +}; + +/** + * Parse whatever was stored into `{mode, except}`, or null for nothing valid. + * + * null is NOT `{mode:'showAll', except:[]}`: both hide nothing today, but the first + * follows a household default that changes tomorrow and the second does not. A + * missing `mode` yields null rather than a guess, since `mode` is what governs + * controls nobody has enumerated. + */ +export function normalizeControlLimits(raw) { + let value = raw; + + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch { + // Hand-edited settings rows and half-written values land here. Treated as + // "nothing stored" so the display falls back instead of breaking. + return null; + } + } + + if (!isPlainObject(value)) return null; + if (!VALID_MODES.has(value.mode)) return null; + + return { mode: value.mode, except: validUniqueIds(value.except) }; +} + +// Has this display its own configuration, rather than inheriting one? Only an +// inheriting display can be restricted by a change to what it inherits, which is +// the one thing this answers. Not exported: the admin form reads `inheriting` off +// editorState instead, so the switches and the label cannot disagree. +function hasOwnLimits(deviceSettings) { + return normalizeControlLimits(deviceSettings?.[CONTROL_LIMITS_KEY]) !== null; +} + +/** The household default, or null when none is configured. */ +export function householdDefaultLimits(householdSettings) { + return normalizeControlLimits(householdSettings?.[CONTROL_LIMITS_DEFAULT_KEY]); +} + +/** + * An "unlocked" display remembers the admin PIN, so it is exempt entirely — + * hiding parent controls on a display a parent administers from is unhelpful. + * + * `pinExists !== false` matters: removing the household PIN leaves + * adminPinRemembered behind on every device, and honoring a stale flag would exempt + * every display at once. `undefined` means not yet known and exempts on purpose, + * because controls that render, vanish and return read as a broken display — so the + * caller is obliged to resolve it, and one that never does leaves every remembered + * display exempt with nothing here able to detect it. + */ +export function isDisplayUnlocked({ deviceSettings, pinExists } = {}) { + return isPinRemembered(deviceSettings) && pinExists !== false; +} + +/** + * Does this configuration hide that control? The single place the mode/except + * inversion is written down, and the only supported way to ask. + * + * Everything goes through here rather than reading `except`, so the inversion has + * one expression that cannot drift from another. + * + * An unconfigured (null) configuration hides nothing. Neither does an invalid + * control id, even under hideAll: it cannot name a real control, so reporting it + * hidden would only mislead the caller. + */ +export function isHiddenUnder(limits, controlId) { + const normalized = normalizeControlLimits(limits); + if (normalized === null) return false; + if (!isValidControlId(controlId)) return false; + + const excepted = normalized.except.includes(controlId); + return normalized.mode === 'hideAll' ? !excepted : excepted; +} + +/** + * Candidates are the core catalog, whatever the caller knows about, and whatever the + * configuration names. `knownControlIds` only adds candidates and never filters, so a + * stored entry for a plugin whose manifest has not loaded still applies. + */ +const hiddenIdsFor = (limits, knownControlIds) => { + const candidates = validUniqueIds([ + ...CORE_CONTROL_IDS, + ...(knownControlIds ?? []), + ...limits.except, + ]); + return candidates.filter((id) => isHiddenUnder(limits, id)); +}; + +/** + * The in-force answer: which control ids this display must not render. + */ +export function resolveHiddenControls({ + deviceSettings, + householdSettings, + pinExists, + knownControlIds, +} = {}) { + if (isDisplayUnlocked({ deviceSettings, pinExists })) return []; + + const limits = normalizeControlLimits(deviceSettings?.[CONTROL_LIMITS_KEY]) + ?? householdDefaultLimits(householdSettings); + + // Nothing configured anywhere, or nothing readable: hide nothing. + if (limits === null) return []; + + return hiddenIdsFor(limits, knownControlIds); +} + +/** Membership test, tolerant of a caller that has not resolved anything yet. */ +export function isControlHidden(hiddenIds, controlId) { + if (!Array.isArray(hiddenIds) || typeof controlId !== 'string') return false; + return hiddenIds.includes(controlId); +} + +/** + * Which named preset a configuration is, for the editor's radio group. + * + * Decided from `mode` and whether `except` is empty, never from `except.length`: + * a count cannot distinguish a set from its size, so any configuration naming as + * many controls as the catalog holds would read as Wall display. + * + * Unreadable or absent input reports fullControl — identical in force (hide + * nothing), and the editor needs a selected radio. The inherit-vs-configured + * distinction is carried by editorState's `inheriting` flag. + */ +export function presetNameFor(limits) { + const normalized = normalizeControlLimits(limits); + if (normalized === null) return 'fullControl'; + if (normalized.except.length > 0) return 'custom'; + return normalized.mode === 'hideAll' ? 'wallDisplay' : 'fullControl'; +} + +/** + * Set one control's hidden state, returning a new configuration. Never mutates its + * input, and sorts, so two equivalent configurations compare equal rather than one + * rendering as "Custom". Only a literal `true` hides. + */ +export function toggleControl(limits, controlId, hidden) { + const base = normalizeControlLimits(limits) ?? CONTROL_PRESETS.fullControl; + const except = new Set(base.except); + + // Only a literal true hides. The id belongs in `except` whenever the mode + // alone would give the wrong answer — asked of the accessor rather than + // re-derived, so the inversion still lives in exactly one place. + if (isValidControlId(controlId)) { + const wantHidden = hidden === true; + if (isHiddenUnder({ mode: base.mode, except: [] }, controlId) !== wantHidden) { + except.add(controlId); + } else { + except.delete(controlId); + } + } + + return { mode: base.mode, except: sortIds(except) }; +} + +/** + * What the admin form should draw — the CONFIGURED state, never the in-force + * one. + * + * It cannot see the device blob or the PIN status, by design: an exempt display + * hides nothing in force, and drawing that in an editable switch would contradict + * what is saved for it. + * + * `knownControlIds` is admissible because a catalog is not in-force state — it + * cannot make this describe anything but the configuration. Without it + * `hiddenIds` could not name a plugin control, pushing the mode inversion back out + * to every caller. + * + * `own === null` means the display inherits: the form shows what it would + * inherit and `inheriting: true` tells the caller to render it read-only. + */ +export function editorState({ own, inherited, knownControlIds } = {}) { + const ownLimits = normalizeControlLimits(own); + const effective = ownLimits + ?? normalizeControlLimits(inherited) + ?? CONTROL_PRESETS.fullControl; + + return { + inheriting: ownLimits === null, + mode: effective.mode, + hiddenIds: hiddenIdsFor(effective, knownControlIds), + preset: presetNameFor(effective), + }; +} + +/** + * Would this household default take controls away from a display that inherits it? + * hideAll always counts, even with everything excepted, because it covers controls + * that do not exist yet. + */ +export function defaultWouldRestrict({ deviceSettings, nextDefault, pinExists } = {}) { + if (hasOwnLimits(deviceSettings)) return false; + if (isDisplayUnlocked({ deviceSettings, pinExists })) return false; + + const limits = normalizeControlLimits(nextDefault); + if (limits === null) return false; + + return limits.mode === 'hideAll' || limits.except.length > 0; +} + +/** + * Write one display's configuration; null retracts it back to inheriting, since PATCH + * merges and there is no delete route. + * + * `limits` is sent as given rather than normalized, so a caller's typo cannot become a + * silent retraction. Returns the merged blob the route echoes back, `{}` if it carries + * none, so a caller that just saved need not re-read. + */ +export async function saveDisplayLimits(apiBaseUrl, deviceName, limits) { + const { data } = await axios.patch( + `${apiBaseUrl}/api/devices/${encodeURIComponent(deviceName)}/settings`, + { [CONTROL_LIMITS_KEY]: limits === undefined ? null : limits }, + ); + return isPlainObject(data) ? data : {}; +} + +/** + * Write the household default, stringified because settings values are TEXT and + * better-sqlite3 cannot bind an object. + * + * Returns the normalized value written, not the server's word for it: POST + * /api/settings echoes only `{success, message}`, and the read path applies the same + * normalization. + */ +export async function saveHouseholdDefaultLimits(apiBaseUrl, limits) { + const value = JSON.stringify(limits === undefined ? null : limits); + + await axios.post(`${apiBaseUrl}/api/settings`, { + key: CONTROL_LIMITS_DEFAULT_KEY, + value, + }); + + return normalizeControlLimits(value); +} + +/** + * Read every display's state for the admin overview, keyed by device name. + * + * Settled rather than raced: one display we cannot read must not cost the admin the + * whole table. `settings` is the whole blob because the caller needs it for + * `isDisplayUnlocked`, and fetching it separately would be a request per display. + * + * A failed read is `{limits: null, settingsRead: false, settings: null}` — null, not + * `{}`, because an empty blob reads as unlocked-by-omission rather than unknown. + * + * `settings` is the whole device blob, not just the controlLimits key, because + * the blob is what isDisplayUnlocked needs: a remote display that remembers the + * admin PIN is exempt, so its saved configuration is not in effect, and the form + * has to be able to say so. Returning only `limits` would leave the caller able + * to check that for the display the admin is sitting at and nothing else — or + * force a second GET per display, which is the 2N request pattern this function + * exists to avoid. The response already carries it; discarding it was the bug. + * + * `settings: null` on a failed read, never `{}` — "reached, and stores nothing" + * and "could not read" are the same pair of facts `settingsRead` distinguishes, and + * an empty blob would make a display look unlocked-by-omission rather than + * unknown. + */ +export async function fetchAllDisplayLimits(apiBaseUrl, deviceNames) { + const names = Array.isArray(deviceNames) ? deviceNames : []; + + const results = await Promise.allSettled(names.map((name) => axios.get( + `${apiBaseUrl}/api/devices/${encodeURIComponent(name)}/settings`, + ))); + + const stateByDevice = {}; + names.forEach((name, index) => { + const result = results[index]; + if (result.status !== 'fulfilled') { + stateByDevice[name] = { limits: null, settingsRead: false, settings: null }; + return; + } + + // `{}` rather than the raw body when the response carries no object, so + // callers that spread `settings` are not handed a string or an array. + const settings = isPlainObject(result.value?.data) ? result.value.data : {}; + stateByDevice[name] = { + limits: normalizeControlLimits(settings[CONTROL_LIMITS_KEY]), + settingsRead: true, + settings, + }; + }); + return stateByDevice; +} diff --git a/client/src/utils/displayControls.test.js b/client/src/utils/displayControls.test.js new file mode 100644 index 0000000..2b9def3 --- /dev/null +++ b/client/src/utils/displayControls.test.js @@ -0,0 +1,947 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ADMIN_PIN_REMEMBERED_KEY } from './adminPinDevice.js'; +import { + CORE_CONTROLS, + CONTROL_PRESETS, + CONTROL_LIMITS_KEY, + CONTROL_LIMITS_DEFAULT_KEY, + isValidControlId, + normalizeControlLimits, + householdDefaultLimits, + isDisplayUnlocked, + resolveHiddenControls, + isControlHidden, + isHiddenUnder, + presetNameFor, + toggleControl, + editorState, + defaultWouldRestrict, +} from './displayControls.js'; + +const CORE_IDS = CORE_CONTROLS.map((control) => control.id); +const CORE_IDS_SORTED = [...CORE_IDS].sort(); +const remembered = { [ADMIN_PIN_REMEMBERED_KEY]: true }; + +// A stored configuration for a display, as the device settings blob holds it. +const deviceWith = (limits) => ({ [CONTROL_LIMITS_KEY]: limits }); +// The household default, as GET /api/settings hands it back once deserialized. +const householdWith = (limits) => ({ [CONTROL_LIMITS_DEFAULT_KEY]: limits }); + +describe('constants', () => { + it('ships the documented core catalog with i18n label keys', () => { + expect(CORE_IDS).toEqual([ + 'core:addChore', + 'core:transferChore', + 'core:snoozeChore', + 'core:prizeApproval', + 'core:quickSpend', + ]); + for (const control of CORE_CONTROLS) { + expect(control.labelKey).toMatch(/^admin:controls\.items\.[a-z][a-zA-Z0-9]*$/); + expect(isValidControlId(control.id)).toBe(true); + } + }); + + it('ships both presets inert', () => { + // The feature must do nothing until an admin opts a display in. + expect(CONTROL_PRESETS.fullControl).toEqual({ mode: 'showAll', except: [] }); + expect(CONTROL_PRESETS.wallDisplay).toEqual({ mode: 'hideAll', except: [] }); + expect(CONTROL_LIMITS_KEY).toBe('controlLimits'); + expect(CONTROL_LIMITS_DEFAULT_KEY).toBe('DISPLAY_CONTROL_LIMITS_DEFAULT'); + }); +}); + +describe('isValidControlId', () => { + it('accepts core and plugin ids', () => { + expect(isValidControlId('core:addChore')).toBe(true); + expect(isValidControlId('core:a')).toBe(true); + expect(isValidControlId('plugin:tasty-tiles:openEditor')).toBe(true); + expect(isValidControlId('plugin:polls2:vote')).toBe(true); + }); + + it('rejects everything else', () => { + for (const id of [ + 'addChore', 'core:', 'core:AddChore', 'core:add_chore', 'core:add-chore', + 'core:addChore:extra', 'plugin:addChore', 'plugin::vote', + 'plugin:Tasty:vote', 'plugin:tasty tiles:vote', 'plugin:tasty:', + 'widget:addChore', '', ' ', 'core:addChore ', + null, undefined, 5, {}, ['core:addChore'], true, + ]) { + expect(isValidControlId(id)).toBe(false); + } + }); +}); + +describe('normalizeControlLimits', () => { + it('accepts an object', () => { + expect(normalizeControlLimits({ mode: 'hideAll', except: ['core:addChore'] })) + .toEqual({ mode: 'hideAll', except: ['core:addChore'] }); + }); + + it('accepts the JSON string form the settings table stores', () => { + // Settings values are a TEXT column, so the household default arrives as a + // string whenever it has not been deserialized for us. + expect(normalizeControlLimits('{"mode":"showAll","except":["core:quickSpend"]}')) + .toEqual({ mode: 'showAll', except: ['core:quickSpend'] }); + }); + + it('drops invalid ids and dedupes into a stable order', () => { + expect(normalizeControlLimits({ + mode: 'showAll', + except: ['core:quickSpend', 'bogus', 'core:addChore', 'core:quickSpend', 42, null], + })).toEqual({ mode: 'showAll', except: ['core:addChore', 'core:quickSpend'] }); + }); + + it('orders two equivalent configurations identically', () => { + const a = normalizeControlLimits({ mode: 'showAll', except: ['core:quickSpend', 'core:addChore'] }); + const b = normalizeControlLimits({ mode: 'showAll', except: ['core:addChore', 'core:quickSpend'] }); + expect(a).toEqual(b); + }); + + // Behavior 8. + it('distinguishes nothing-stored (null) from explicitly-configured', () => { + // Both hide nothing today. Only the first follows the household default + // when that default changes tomorrow, so the two must not collapse. + expect(normalizeControlLimits(undefined)).toBeNull(); + expect(normalizeControlLimits(null)).toBeNull(); + expect(normalizeControlLimits({ mode: 'showAll', except: [] })) + .toEqual({ mode: 'showAll', except: [] }); + }); + + // Behavior 6. + it('returns null for every unreadable shape', () => { + for (const raw of [ + '', 'not json', '{"mode":', '[]', '"showAll"', 'null', '5', + [], 5, true, () => {}, {}, + { except: ['core:addChore'] }, + { mode: 'hideSome', except: [] }, + { mode: 'showall', except: [] }, + { mode: true }, + { mode: ['hideAll'] }, + ]) { + expect(normalizeControlLimits(raw)).toBeNull(); + } + }); + + it('survives an except that is not an array', () => { + // mode is still readable, so the configuration stands with no exceptions. + expect(normalizeControlLimits({ mode: 'hideAll', except: 'core:addChore' })) + .toEqual({ mode: 'hideAll', except: [] }); + expect(normalizeControlLimits({ mode: 'hideAll' })) + .toEqual({ mode: 'hideAll', except: [] }); + }); + + it('never hands back the caller\'s array', () => { + const except = ['core:addChore']; + const normalized = normalizeControlLimits({ mode: 'showAll', except }); + normalized.except.push('core:quickSpend'); + expect(except).toEqual(['core:addChore']); + }); +}); + + +describe('isDisplayUnlocked', () => { + it('unlocks a remembered display while a PIN exists', () => { + expect(isDisplayUnlocked({ deviceSettings: remembered, pinExists: true })).toBe(true); + }); + + // Behavior 1. + it('does not unlock once the household PIN is gone', () => { + // Removing the PIN leaves adminPinRemembered behind on every device it was + // set on. Honoring it then would exempt every display at once and disable + // the whole feature household-wide. + expect(isDisplayUnlocked({ deviceSettings: remembered, pinExists: false })).toBe(false); + }); + + // Behavior 2. + it('unlocks while the PIN status is still unknown', () => { + // Controls must not render, vanish, then reappear when the check lands. + expect(isDisplayUnlocked({ deviceSettings: remembered })).toBe(true); + expect(isDisplayUnlocked({ deviceSettings: remembered, pinExists: undefined })).toBe(true); + expect(isDisplayUnlocked({ deviceSettings: remembered, pinExists: null })).toBe(true); + }); + + // Behavior 3. + it('accepts only a literal true as remembered', () => { + for (const value of ['true', 1, 'yes', {}, [], 'TRUE']) { + expect(isDisplayUnlocked({ + deviceSettings: { [ADMIN_PIN_REMEMBERED_KEY]: value }, + pinExists: true, + })).toBe(false); + } + }); + + it('does not unlock a display that never remembered the PIN', () => { + expect(isDisplayUnlocked({ deviceSettings: {}, pinExists: true })).toBe(false); + expect(isDisplayUnlocked({ pinExists: true })).toBe(false); + expect(isDisplayUnlocked()).toBe(false); + }); +}); + +describe('resolveHiddenControls', () => { + it('hides the listed controls under showAll', () => { + expect(resolveHiddenControls({ + deviceSettings: deviceWith({ mode: 'showAll', except: ['core:addChore'] }), + knownControlIds: CORE_IDS, + })).toEqual(['core:addChore']); + }); + + it('hides everything but the exceptions under hideAll', () => { + expect(resolveHiddenControls({ + deviceSettings: deviceWith({ mode: 'hideAll', except: ['core:quickSpend'] }), + knownControlIds: CORE_IDS, + })).toEqual([...CORE_IDS].filter((id) => id !== 'core:quickSpend').sort()); + }); + + // Behavior 10. + it('hides a control the build has never seen under hideAll', () => { + // The point of mode-over-snapshot: a plugin installed later is hidden on a + // wall display without anyone revisiting that display's settings. + const hidden = resolveHiddenControls({ + deviceSettings: deviceWith(CONTROL_PRESETS.wallDisplay), + knownControlIds: [...CORE_IDS, 'plugin:installed-later:openEditor'], + }); + expect(hidden).toContain('plugin:installed-later:openEditor'); + for (const id of CORE_IDS) expect(hidden).toContain(id); + }); + + // Behavior 10. + it('does not drop a configured id merely for being unknown', () => { + // A plugin whose manifest has not loaded yet is absent from the catalog; + // its control still has to stay hidden. + expect(resolveHiddenControls({ + deviceSettings: deviceWith({ mode: 'showAll', except: ['plugin:not-loaded-yet:vote'] }), + knownControlIds: CORE_IDS, + })).toEqual(['plugin:not-loaded-yet:vote']); + }); + + it('still hides the core catalog under hideAll with no catalog supplied', () => { + expect(resolveHiddenControls({ + deviceSettings: deviceWith(CONTROL_PRESETS.wallDisplay), + })).toEqual([...CORE_IDS].sort()); + }); + + it('inherits the household default when the display stores nothing', () => { + const household = householdWith('{"mode":"hideAll","except":[]}'); + expect(resolveHiddenControls({ + deviceSettings: {}, + householdSettings: household, + knownControlIds: CORE_IDS, + })).toEqual([...CORE_IDS].sort()); + // A literal null is how PATCH retracts the key, so it must inherit too. + expect(resolveHiddenControls({ + deviceSettings: deviceWith(null), + householdSettings: household, + knownControlIds: CORE_IDS, + })).toEqual([...CORE_IDS].sort()); + }); + + it('lets an own configuration win over the household default', () => { + expect(resolveHiddenControls({ + deviceSettings: deviceWith(CONTROL_PRESETS.fullControl), + householdSettings: householdWith(CONTROL_PRESETS.wallDisplay), + knownControlIds: CORE_IDS, + })).toEqual([]); + }); + + // Behavior 1. + it('applies limits to a remembered display once the PIN is gone', () => { + expect(resolveHiddenControls({ + deviceSettings: { ...remembered, ...deviceWith(CONTROL_PRESETS.wallDisplay) }, + pinExists: false, + knownControlIds: CORE_IDS, + })).toEqual([...CORE_IDS].sort()); + }); + + // Behaviors 2 and 3. + it('exempts an unlocked display entirely', () => { + const deviceSettings = { ...remembered, ...deviceWith(CONTROL_PRESETS.wallDisplay) }; + expect(resolveHiddenControls({ deviceSettings, pinExists: true, knownControlIds: CORE_IDS })) + .toEqual([]); + expect(resolveHiddenControls({ deviceSettings, knownControlIds: CORE_IDS })) + .toEqual([]); + expect(resolveHiddenControls({ + deviceSettings: { [ADMIN_PIN_REMEMBERED_KEY]: 'true', ...deviceWith(CONTROL_PRESETS.wallDisplay) }, + pinExists: true, + knownControlIds: CORE_IDS, + })).toEqual([...CORE_IDS].sort()); + }); + + // Behavior 6. + it('hides nothing on every failure path', () => { + // Unreadable settings must never strip a parent's buttons; there is no + // security claim here that failing closed would protect. + const cases = [ + {}, + { deviceSettings: undefined, householdSettings: undefined }, + { deviceSettings: 'not an object', householdSettings: 42 }, + { deviceSettings: deviceWith('{"mode":'), householdSettings: householdWith('garbage') }, + { deviceSettings: deviceWith({ mode: 'hideEverything' }) }, + { deviceSettings: deviceWith([]), householdSettings: householdWith([]) }, + { deviceSettings: {}, householdSettings: householdWith(undefined) }, + ]; + for (const input of cases) { + expect(resolveHiddenControls({ ...input, knownControlIds: CORE_IDS })).toEqual([]); + } + expect(resolveHiddenControls()).toEqual([]); + }); + + it('ignores junk in the supplied catalog', () => { + expect(resolveHiddenControls({ + deviceSettings: deviceWith(CONTROL_PRESETS.wallDisplay), + knownControlIds: ['not-an-id', null, 'core:addChore', 'core:addChore'], + })).toEqual([...CORE_IDS].sort()); + }); +}); + +describe('householdDefaultLimits', () => { + it('reports an unset default as null, not as an empty configuration', () => { + // null means "no household policy", which a display inherits as hide-nothing. + // {showAll, except: []} is a deliberate choice with the same effect today, and + // collapsing the two would make an unset household look configured. + expect(householdDefaultLimits({})).toBe(null); + expect(householdDefaultLimits(undefined)).toBe(null); + }); + + it('reads the household key and normalizes what it finds', () => { + expect(householdDefaultLimits({ + [CONTROL_LIMITS_DEFAULT_KEY]: { mode: 'hideAll', except: ['core:addChore', 'bogus'] }, + })).toEqual({ mode: 'hideAll', except: ['core:addChore'] }); + }); + + it('accepts the JSON string the settings table round-trips', () => { + expect(householdDefaultLimits({ + [CONTROL_LIMITS_DEFAULT_KEY]: '{"mode":"hideAll","except":[]}', + })).toEqual({ mode: 'hideAll', except: [] }); + }); +}); + +describe('isControlHidden', () => { + it('answers membership', () => { + expect(isControlHidden(['core:addChore'], 'core:addChore')).toBe(true); + expect(isControlHidden(['core:addChore'], 'core:quickSpend')).toBe(false); + }); + + it('says "not hidden" when handed nothing usable', () => { + expect(isControlHidden(undefined, 'core:addChore')).toBe(false); + expect(isControlHidden(null, 'core:addChore')).toBe(false); + expect(isControlHidden([], 'core:addChore')).toBe(false); + expect(isControlHidden(['core:addChore'], undefined)).toBe(false); + }); +}); + +describe('isHiddenUnder', () => { + it('reads except as the hidden list under showAll', () => { + const limits = { mode: 'showAll', except: ['core:addChore'] }; + expect(isHiddenUnder(limits, 'core:addChore')).toBe(true); + expect(isHiddenUnder(limits, 'core:quickSpend')).toBe(false); + }); + + it('reads except as the VISIBLE list under hideAll', () => { + // The inversion lives here and nowhere else, so it is pinned here. + const limits = { mode: 'hideAll', except: ['core:addChore'] }; + expect(isHiddenUnder(limits, 'core:addChore')).toBe(false); + expect(isHiddenUnder(limits, 'core:quickSpend')).toBe(true); + }); + + // Behavior 10. + it('hides a control it has never heard of under hideAll', () => { + expect(isHiddenUnder(CONTROL_PRESETS.wallDisplay, 'plugin:installed-later:openEditor')) + .toBe(true); + expect(isHiddenUnder(CONTROL_PRESETS.fullControl, 'plugin:installed-later:openEditor')) + .toBe(false); + }); + + it('accepts the JSON string form', () => { + expect(isHiddenUnder('{"mode":"hideAll","except":[]}', 'core:addChore')).toBe(true); + }); + + // Behavior 6. + it('hides nothing when nothing is readable', () => { + for (const limits of [null, undefined, 'garbage', '{"mode":', {}, { mode: 'nope' }, []]) { + expect(isHiddenUnder(limits, 'core:addChore')).toBe(false); + } + }); + + it('never reports a malformed id as hidden, even under hideAll', () => { + // It cannot name a real control, so claiming it is hidden only misleads. + for (const id of ['bogus', '', 'core:Add', undefined, null, 5]) { + expect(isHiddenUnder(CONTROL_PRESETS.wallDisplay, id)).toBe(false); + } + }); + + it('agrees with resolveHiddenControls, which is the contract', () => { + const ids = [...CORE_IDS, 'plugin:later:openEditor']; + for (const limits of [ + CONTROL_PRESETS.fullControl, + CONTROL_PRESETS.wallDisplay, + { mode: 'showAll', except: ['core:addChore', 'plugin:later:openEditor'] }, + { mode: 'hideAll', except: ['core:quickSpend'] }, + ]) { + const hidden = resolveHiddenControls({ + deviceSettings: deviceWith(limits), + knownControlIds: ids, + }); + for (const id of ids) { + expect(isControlHidden(hidden, id)).toBe(isHiddenUnder(limits, id)); + } + } + }); +}); + +describe('presetNameFor', () => { + it('names the two presets', () => { + expect(presetNameFor(CONTROL_PRESETS.fullControl)).toBe('fullControl'); + expect(presetNameFor(CONTROL_PRESETS.wallDisplay)).toBe('wallDisplay'); + expect(presetNameFor('{"mode":"hideAll","except":[]}')).toBe('wallDisplay'); + }); + + // Behavior 7. + it('reads mode and except, never a length against the catalog', () => { + // A length comparison shipped once and reported "Wall display" for any + // configuration that happened to name five controls, because + // CORE_CONTROLS has five entries. + const fivePluginIds = [ + 'plugin:a:one', 'plugin:b:two', 'plugin:c:three', 'plugin:d:four', 'plugin:e:five', + ]; + expect(CORE_CONTROLS).toHaveLength(fivePluginIds.length); + expect(presetNameFor({ mode: 'showAll', except: fivePluginIds })).toBe('custom'); + expect(presetNameFor({ mode: 'showAll', except: CORE_IDS })).toBe('custom'); + expect(presetNameFor({ mode: 'hideAll', except: CORE_IDS })).toBe('custom'); + }); + + it('calls a single hidden control custom', () => { + expect(presetNameFor({ mode: 'showAll', except: ['core:addChore'] })).toBe('custom'); + expect(presetNameFor({ mode: 'hideAll', except: ['core:addChore'] })).toBe('custom'); + }); + + it('treats nothing-stored as fullControl so the radio group has a selection', () => { + expect(presetNameFor(null)).toBe('fullControl'); + expect(presetNameFor(undefined)).toBe('fullControl'); + expect(presetNameFor('garbage')).toBe('fullControl'); + }); + + it('ignores invalid ids when deciding, since they are dropped on read', () => { + expect(presetNameFor({ mode: 'hideAll', except: ['nonsense'] })).toBe('wallDisplay'); + }); +}); + +describe('toggleControl', () => { + // Behavior 9. + it('never mutates its input', () => { + const limits = { mode: 'showAll', except: ['core:addChore'] }; + const frozen = Object.freeze({ mode: 'hideAll', except: Object.freeze(['core:quickSpend']) }); + const next = toggleControl(limits, 'core:quickSpend', true); + expect(limits).toEqual({ mode: 'showAll', except: ['core:addChore'] }); + expect(next).not.toBe(limits); + expect(next.except).not.toBe(limits.except); + expect(() => toggleControl(frozen, 'core:addChore', true)).not.toThrow(); + expect(frozen.except).toEqual(['core:quickSpend']); + // The shared presets are a frequent starting point and must survive it. + toggleControl(CONTROL_PRESETS.fullControl, 'core:addChore', true); + expect(CONTROL_PRESETS.fullControl).toEqual({ mode: 'showAll', except: [] }); + }); + + // Behavior 9. + it('returns a stable order so equivalent configurations compare equal', () => { + const clickedOneWay = toggleControl( + toggleControl(CONTROL_PRESETS.fullControl, 'core:quickSpend', true), + 'core:addChore', true, + ); + const clickedTheOther = toggleControl( + toggleControl(CONTROL_PRESETS.fullControl, 'core:addChore', true), + 'core:quickSpend', true, + ); + expect(clickedOneWay).toEqual(clickedTheOther); + expect(clickedOneWay.except).toEqual(['core:addChore', 'core:quickSpend']); + // And it keeps reading as Custom rather than flipping to a preset name. + expect(presetNameFor(clickedOneWay)).toBe('custom'); + }); + + it('adds to except when hiding under showAll, removes when showing', () => { + const hidden = toggleControl({ mode: 'showAll', except: [] }, 'core:addChore', true); + expect(hidden).toEqual({ mode: 'showAll', except: ['core:addChore'] }); + expect(toggleControl(hidden, 'core:addChore', false)) + .toEqual({ mode: 'showAll', except: [] }); + }); + + it('inverts under hideAll, where except lists what stays visible', () => { + const shown = toggleControl({ mode: 'hideAll', except: [] }, 'core:addChore', false); + expect(shown).toEqual({ mode: 'hideAll', except: ['core:addChore'] }); + expect(toggleControl(shown, 'core:addChore', true)) + .toEqual({ mode: 'hideAll', except: [] }); + }); + + it('agrees with resolveHiddenControls in both modes', () => { + // The inversion is the bug-prone half, so pin the round trip. + for (const mode of ['showAll', 'hideAll']) { + const limits = toggleControl({ mode, except: [] }, 'core:addChore', true); + expect(resolveHiddenControls({ + deviceSettings: deviceWith(limits), + knownControlIds: CORE_IDS, + })).toContain('core:addChore'); + + const shown = toggleControl({ mode, except: [] }, 'core:addChore', false); + expect(resolveHiddenControls({ + deviceSettings: deviceWith(shown), + knownControlIds: CORE_IDS, + })).not.toContain('core:addChore'); + } + }); + + it('is idempotent', () => { + const once = toggleControl({ mode: 'showAll', except: [] }, 'core:addChore', true); + expect(toggleControl(once, 'core:addChore', true)).toEqual(once); + }); + + it('only hides on a literal true', () => { + expect(toggleControl({ mode: 'showAll', except: [] }, 'core:addChore', 'true')) + .toEqual({ mode: 'showAll', except: [] }); + }); + + it('ignores an invalid id instead of storing it', () => { + // A malformed id would sit in except forever, matching nothing. + expect(toggleControl({ mode: 'showAll', except: ['core:addChore'] }, 'bogus', true)) + .toEqual({ mode: 'showAll', except: ['core:addChore'] }); + }); + + it('starts from fullControl when nothing is stored', () => { + expect(toggleControl(null, 'core:addChore', true)) + .toEqual({ mode: 'showAll', except: ['core:addChore'] }); + expect(toggleControl('garbage', 'core:addChore', true)) + .toEqual({ mode: 'showAll', except: ['core:addChore'] }); + }); +}); + +describe('editorState', () => { + // Behavior 4. + it('cannot see the device blob or the PIN, though it may see the catalog', () => { + // An earlier version reported the in-force answer, so an unlocked display + // (exempt, hides nothing) drew a form with every toggle off while the + // writes were landing correctly. The signature is the fix. + // Structural, not behavioral: the function body must not so much as name + // the device blob or the PIN, so it cannot grow a dependency on them. + // Structural, not behavioral: the body must not so much as name the device + // blob or the PIN, so it cannot grow a dependency on in-force state. A + // catalog of known ids is not in-force state and is deliberately allowed. + expect(editorState.toString()).not.toMatch(/deviceSettings|pinExists|adminPinRemembered|isDisplayUnlocked/); + expect(editorState.toString()).toMatch(/knownControlIds/); + const withNoise = editorState({ + own: CONTROL_PRESETS.wallDisplay, + inherited: CONTROL_PRESETS.fullControl, + deviceSettings: { ...remembered, ...deviceWith(CONTROL_PRESETS.fullControl) }, + pinExists: true, + knownControlIds: CORE_IDS, + }); + expect(withNoise).toEqual(editorState({ + own: CONTROL_PRESETS.wallDisplay, + inherited: CONTROL_PRESETS.fullControl, + knownControlIds: CORE_IDS, + })); + // Configured as a wall display, so that is what the form draws, even though + // an unlocked display hides nothing in force. + expect(withNoise.preset).toBe('wallDisplay'); + expect(withNoise.mode).toBe('hideAll'); + expect(withNoise.hiddenIds).toEqual(CORE_IDS_SORTED); + }); + + // Behavior 4. + it('draws the configured state even where it is not in force', () => { + const state = editorState({ own: { mode: 'showAll', except: ['core:addChore'] } }); + expect(state).toEqual({ + inheriting: false, + mode: 'showAll', + hiddenIds: ['core:addChore'], + preset: 'custom', + }); + }); + + // Behavior 5. + it('shows what an inheriting display would inherit, flagged read-only', () => { + const state = editorState({ own: null, inherited: CONTROL_PRESETS.wallDisplay }); + expect(state.inheriting).toBe(true); + expect(state.mode).toBe('hideAll'); + expect(state.preset).toBe('wallDisplay'); + expect(state.hiddenIds).toEqual(CORE_IDS_SORTED); + }); + + // Behavior 5. + it('is not inheriting as soon as the display stores its own configuration', () => { + expect(editorState({ + own: CONTROL_PRESETS.fullControl, + inherited: CONTROL_PRESETS.wallDisplay, + })).toEqual({ + inheriting: true === false, mode: 'showAll', hiddenIds: [], preset: 'fullControl', + }); + }); + + // Behavior 8. + it('separates nothing-stored from an explicit fullControl', () => { + // Identical in force, different tomorrow: only the inheriting one follows + // a household default that changes. + const inheriting = editorState({ own: null, inherited: CONTROL_PRESETS.fullControl }); + const configured = editorState({ own: CONTROL_PRESETS.fullControl, inherited: CONTROL_PRESETS.fullControl }); + expect(inheriting.inheriting).toBe(true); + expect(configured.inheriting).toBe(false); + expect(inheriting.hiddenIds).toEqual(configured.hiddenIds); + }); + + // Behavior 6. + it('falls back to fullControl when nothing anywhere is readable', () => { + for (const input of [undefined, {}, { own: 'garbage', inherited: '{"mode":' }, { own: [], inherited: 7 }]) { + expect(editorState(input)).toEqual({ + inheriting: true, mode: 'showAll', hiddenIds: [], preset: 'fullControl', + }); + } + }); + + it('names plugin controls in hiddenIds when given the catalog', () => { + // The whole reason the catalog is a parameter: without it hiddenIds could + // not describe a plugin row at all, and every caller would have to + // re-derive the mode/except inversion by hand. + const knownControlIds = [...CORE_IDS, 'plugin:polls:editPoll', 'plugin:polls:deletePoll']; + const wall = editorState({ own: CONTROL_PRESETS.wallDisplay, knownControlIds }); + expect(wall.hiddenIds).toContain('plugin:polls:editPoll'); + expect(wall.hiddenIds).toContain('plugin:polls:deletePoll'); + expect(wall.hiddenIds).toEqual([...knownControlIds].sort()); + + const oneShown = editorState({ + own: { mode: 'hideAll', except: ['plugin:polls:editPoll'] }, + knownControlIds, + }); + expect(oneShown.hiddenIds).not.toContain('plugin:polls:editPoll'); + expect(oneShown.hiddenIds).toContain('plugin:polls:deletePoll'); + expect(oneShown.preset).toBe('custom'); + }); + + it('keeps a configured id that the catalog does not mention', () => { + // A plugin whose manifest has not loaded yet must still draw as hidden. + expect(editorState({ + own: { mode: 'showAll', except: ['plugin:not-loaded-yet:vote'] }, + knownControlIds: CORE_IDS, + }).hiddenIds).toEqual(['plugin:not-loaded-yet:vote']); + }); + + it('still covers the core catalog with no catalog supplied', () => { + expect(editorState({ own: CONTROL_PRESETS.wallDisplay }).hiddenIds) + .toEqual(CORE_IDS_SORTED); + }); + + it('describes the configured state, which resolve then gates', () => { + // Same configuration, same ids: the only difference between the two is the + // unlock gate, which editorState must not have. + const knownControlIds = [...CORE_IDS, 'plugin:polls:editPoll']; + const own = { mode: 'hideAll', except: ['core:addChore'] }; + expect(editorState({ own, knownControlIds }).hiddenIds).toEqual( + resolveHiddenControls({ deviceSettings: deviceWith(own), knownControlIds }), + ); + }); + + it('accepts the JSON string form for the inherited value', () => { + expect(editorState({ own: null, inherited: '{"mode":"hideAll","except":["core:addChore"]}' })) + .toEqual({ + inheriting: true, + mode: 'hideAll', + hiddenIds: CORE_IDS_SORTED.filter((id) => id !== 'core:addChore'), + preset: 'custom', + }); + }); +}); + +describe('defaultWouldRestrict', () => { + it('warns for an inheriting display when the next default hides something', () => { + expect(defaultWouldRestrict({ + deviceSettings: {}, + nextDefault: CONTROL_PRESETS.wallDisplay, + })).toBe(true); + expect(defaultWouldRestrict({ + deviceSettings: deviceWith(null), + nextDefault: { mode: 'showAll', except: ['core:addChore'] }, + })).toBe(true); + }); + + it('does not warn when the next default hides nothing', () => { + expect(defaultWouldRestrict({ + deviceSettings: {}, + nextDefault: CONTROL_PRESETS.fullControl, + })).toBe(false); + }); + + it('counts hideAll as restricting even when every known control is excepted', () => { + // It still covers the plugin installed tomorrow. + expect(defaultWouldRestrict({ + deviceSettings: {}, + nextDefault: { mode: 'hideAll', except: CORE_IDS }, + })).toBe(true); + }); + + it('does not warn about a display that has its own configuration', () => { + expect(defaultWouldRestrict({ + deviceSettings: deviceWith(CONTROL_PRESETS.fullControl), + nextDefault: CONTROL_PRESETS.wallDisplay, + })).toBe(false); + }); + + // Behaviors 1, 2 and 3. + it('follows the unlock rules', () => { + const deviceSettings = { ...remembered }; + expect(defaultWouldRestrict({ deviceSettings, nextDefault: CONTROL_PRESETS.wallDisplay, pinExists: true })) + .toBe(false); + expect(defaultWouldRestrict({ deviceSettings, nextDefault: CONTROL_PRESETS.wallDisplay })) + .toBe(false); + // Stale flag with no PIN left: this display would be restricted after all. + expect(defaultWouldRestrict({ deviceSettings, nextDefault: CONTROL_PRESETS.wallDisplay, pinExists: false })) + .toBe(true); + expect(defaultWouldRestrict({ + deviceSettings: { [ADMIN_PIN_REMEMBERED_KEY]: 'true' }, + nextDefault: CONTROL_PRESETS.wallDisplay, + pinExists: true, + })).toBe(true); + }); + + // Behavior 6. + it('never warns about an unreadable next default', () => { + for (const nextDefault of [undefined, null, 'garbage', '{"mode":', {}, { mode: 'nope' }, []]) { + expect(defaultWouldRestrict({ deviceSettings: {}, nextDefault })).toBe(false); + } + expect(defaultWouldRestrict()).toBe(false); + }); +}); + +describe('axios helpers', () => { + const API = 'http://localhost:5000'; + let axiosMock; + let displayControls; + + beforeEach(async () => { + axiosMock = { + get: vi.fn(), + patch: vi.fn().mockResolvedValue({ data: {} }), + post: vi.fn().mockResolvedValue({ data: { success: true } }), + }; + vi.resetModules(); + vi.doMock('axios', () => ({ default: axiosMock })); + displayControls = await import('./displayControls.js'); + }); + + afterEach(() => { + vi.doUnmock('axios'); + vi.resetModules(); + }); + + describe('saveDisplayLimits', () => { + it('PATCHes the device settings route with the limits under controlLimits', async () => { + const limits = { mode: 'hideAll', except: ['core:addChore'] }; + await displayControls.saveDisplayLimits(API, 'Kitchen Display', limits); + expect(axiosMock.patch).toHaveBeenCalledTimes(1); + expect(axiosMock.patch).toHaveBeenCalledWith( + `${API}/api/devices/Kitchen%20Display/settings`, + { controlLimits: limits }, + ); + }); + + it('sends a literal null to retract', async () => { + // PATCH merges, so null is the only retraction — there is no delete route. + await displayControls.saveDisplayLimits(API, 'kiosk', null); + const [, payload] = axiosMock.patch.mock.calls[0]; + expect(payload).toEqual({ controlLimits: null }); + expect(payload.controlLimits).toBeNull(); + expect('controlLimits' in payload).toBe(true); + }); + + it('encodes a device name with URL-hostile characters', async () => { + await displayControls.saveDisplayLimits(API, 'Ram\'s iPad/2?', null); + expect(axiosMock.patch.mock.calls[0][0]) + .toBe(`${API}/api/devices/Ram's%20iPad%2F2%3F/settings`); + }); + + it('returns the merged blob the route echoes back', async () => { + // So a caller never has to re-read to learn what the display now stores. + const merged = { controlLimits: { mode: 'hideAll', except: [] }, adminPinRemembered: true }; + axiosMock.patch.mockResolvedValue({ data: merged }); + await expect(displayControls.saveDisplayLimits(API, 'kiosk', CONTROL_PRESETS.wallDisplay)) + .resolves.toEqual(merged); + }); + + it('returns {} when the response carries no object', async () => { + for (const data of [undefined, null, '', 'ok', []]) { + axiosMock.patch.mockResolvedValue({ data }); + await expect(displayControls.saveDisplayLimits(API, 'kiosk', null)).resolves.toEqual({}); + } + }); + + it('propagates a failed write rather than reporting success', async () => { + axiosMock.patch.mockRejectedValue(new Error('boom')); + await expect(displayControls.saveDisplayLimits(API, 'kiosk', null)).rejects.toThrow('boom'); + }); + }); + + describe('saveHouseholdDefaultLimits', () => { + it('POSTs the key with a JSON STRING value', async () => { + // Settings values are a TEXT column; better-sqlite3 cannot bind an object, + // so an object here fails at the driver rather than at validation. + await displayControls.saveHouseholdDefaultLimits(API, CONTROL_PRESETS.wallDisplay); + expect(axiosMock.post).toHaveBeenCalledTimes(1); + const [url, body] = axiosMock.post.mock.calls[0]; + expect(url).toBe(`${API}/api/settings`); + expect(body.key).toBe(CONTROL_LIMITS_DEFAULT_KEY); + expect(typeof body.value).toBe('string'); + expect(body.value).toBe('{"mode":"hideAll","except":[]}'); + expect(JSON.parse(body.value)).toEqual(CONTROL_PRESETS.wallDisplay); + }); + + it('still sends a string when clearing the default', async () => { + // POST /api/settings rejects an undefined value with a 400. + await displayControls.saveHouseholdDefaultLimits(API, null); + const [, body] = axiosMock.post.mock.calls[0]; + expect(body.value).toBe('null'); + expect(typeof body.value).toBe('string'); + }); + + it('resolves to the normalized value it wrote', async () => { + // Symmetry with saveDisplayLimits: the caller never has to re-read to + // learn what displays will now inherit. + await expect(displayControls.saveHouseholdDefaultLimits(API, { + mode: 'hideAll', + except: ['core:quickSpend', 'bogus', 'core:addChore', 'core:quickSpend'], + })).resolves.toEqual({ + mode: 'hideAll', + except: ['core:addChore', 'core:quickSpend'], + }); + }); + + it('resolves to null when the default is cleared or unwritable', async () => { + await expect(displayControls.saveHouseholdDefaultLimits(API, null)).resolves.toBeNull(); + await expect(displayControls.saveHouseholdDefaultLimits(API, 'garbage')).resolves.toBeNull(); + }); + + it('propagates a failed write rather than reporting what it meant to write', async () => { + axiosMock.post.mockRejectedValue(new Error('boom')); + await expect(displayControls.saveHouseholdDefaultLimits(API, CONTROL_PRESETS.wallDisplay)) + .rejects.toThrow('boom'); + }); + }); + + describe('fetchAllDisplayLimits', () => { + it('GETs each display once and keys the map by name', async () => { + axiosMock.get.mockImplementation((url) => { + if (url.includes('kitchen')) { + return Promise.resolve({ data: { controlLimits: { mode: 'hideAll', except: [] } } }); + } + return Promise.resolve({ data: {} }); + }); + + await expect(displayControls.fetchAllDisplayLimits(API, ['kitchen', 'hallway'])) + .resolves.toEqual({ + kitchen: { + limits: { mode: 'hideAll', except: [] }, + settingsRead: true, + settings: { controlLimits: { mode: 'hideAll', except: [] } }, + }, + hallway: { limits: null, settingsRead: true, settings: {} }, + }); + expect(axiosMock.get).toHaveBeenCalledTimes(2); + expect(axiosMock.get).toHaveBeenCalledWith(`${API}/api/devices/kitchen/settings`); + expect(axiosMock.get).toHaveBeenCalledWith(`${API}/api/devices/hallway/settings`); + }); + + it('distinguishes a failed read from unconfigured', async () => { + // Both carry limits: null, but only one is a statement about what the + // display stores. Rendering a dead kiosk as "Inheriting" is a claim about + // configuration we just failed to read, and an admin would act on it. + axiosMock.get.mockImplementation((url) => (url.includes('down') + ? Promise.reject(new Error('ECONNREFUSED')) + : Promise.resolve({ data: {} }))); + + const map = await displayControls.fetchAllDisplayLimits(API, ['down', 'inheriting']); + expect(map.down).toEqual({ limits: null, settingsRead: false, settings: null }); + expect(map.inheriting).toEqual({ limits: null, settingsRead: true, settings: {} }); + expect(map.down.settingsRead).not.toBe(map.inheriting.settingsRead); + }); + + it('does not let one display whose settings could not be read cost the whole table', async () => { + axiosMock.get.mockImplementation((url) => (url.includes('down') + ? Promise.reject(new Error('ECONNREFUSED')) + : Promise.resolve({ data: { controlLimits: { mode: 'showAll', except: ['core:addChore'] } } }))); + + const map = await displayControls.fetchAllDisplayLimits(API, ['down', 'up']); + expect(map.down).toEqual({ limits: null, settingsRead: false, settings: null }); + expect(map.up).toEqual({ + limits: { mode: 'showAll', except: ['core:addChore'] }, + settingsRead: true, + settings: { controlLimits: { mode: 'showAll', except: ['core:addChore'] } }, + }); + }); + + it('reports an unreadable stored value as settingsRead but unconfigured', async () => { + // We reached the display; what it stores is just not usable. + axiosMock.get.mockResolvedValue({ data: { controlLimits: '{"mode":' } }); + await expect(displayControls.fetchAllDisplayLimits(API, ['one'])).resolves.toEqual({ + one: { limits: null, settingsRead: true, settings: { controlLimits: '{"mode":' } }, + }); + }); + + it('normalizes what each display stored', async () => { + axiosMock.get.mockResolvedValue({ + data: { controlLimits: { mode: 'showAll', except: ['core:quickSpend', 'bogus', 'core:addChore'] } }, + }); + await expect(displayControls.fetchAllDisplayLimits(API, ['one'])).resolves.toEqual({ + one: { + limits: { mode: 'showAll', except: ['core:addChore', 'core:quickSpend'] }, + settingsRead: true, + settings: { controlLimits: { mode: 'showAll', except: ['core:quickSpend', 'bogus', 'core:addChore'] } }, + }, + }); + }); + + it('returns the whole blob, not just the controlLimits key', async () => { + // The caller needs it for isDisplayUnlocked: a remote display that + // remembers the PIN is exempt, so its saved configuration is not in + // effect and the form has to be able to say so. The alternative was a + // second GET per display. + const blob = { + controlLimits: { mode: 'hideAll', except: [] }, + [ADMIN_PIN_REMEMBERED_KEY]: true, + choreWidgetSettings: { soundEnabled: true, hiddenUserIds: [1, 2] }, + wakeLock: false, + }; + axiosMock.get.mockResolvedValue({ data: blob }); + + const map = await displayControls.fetchAllDisplayLimits(API, ['kiosk']); + expect(map.kiosk.settings).toEqual(blob); + // The point of carrying it: this is answerable for a remote display now. + expect(isDisplayUnlocked({ deviceSettings: map.kiosk.settings, pinExists: true })).toBe(true); + expect(map.kiosk.limits).toEqual({ mode: 'hideAll', except: [] }); + }); + + it('returns {} for settings when the response body is not an object', async () => { + // Keeps the type honest for a caller that spreads it. + for (const data of [undefined, null, '', 'ok', [], 7]) { + axiosMock.get.mockResolvedValue({ data }); + const map = await displayControls.fetchAllDisplayLimits(API, ['one']); + expect(map.one).toEqual({ limits: null, settingsRead: true, settings: {} }); + } + }); + + it('returns null for settings on a failed read, not {}', async () => { + // "Reached and empty" and "could not read" are different facts — the same + // distinction settingsRead exists for. An empty blob would make the display + // look unlocked-by-omission rather than unknown. + axiosMock.get.mockRejectedValue(new Error('ECONNREFUSED')); + const map = await displayControls.fetchAllDisplayLimits(API, ['down']); + expect(map.down.settings).toBeNull(); + expect(map.down.settings).not.toEqual({}); + expect(isDisplayUnlocked({ deviceSettings: map.down.settings, pinExists: true })).toBe(false); + }); + + it('encodes device names and handles an empty list', async () => { + await expect(displayControls.fetchAllDisplayLimits(API, [])).resolves.toEqual({}); + await expect(displayControls.fetchAllDisplayLimits(API, undefined)).resolves.toEqual({}); + expect(axiosMock.get).not.toHaveBeenCalled(); + + axiosMock.get.mockResolvedValue({ data: {} }); + await displayControls.fetchAllDisplayLimits(API, ['Play Room']); + expect(axiosMock.get).toHaveBeenCalledWith(`${API}/api/devices/Play%20Room/settings`); + }); + }); +}); diff --git a/docs/README.md b/docs/README.md index fd67d56..f5bd94f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ stays relevant. - [Deployment](guides/deployment.md) — Docker Compose, Portainer, Proxmox LXC, and updating. - [Demo Mode](guides/demo-mode.md) — run a public, self-resetting showcase instance with sample data. - [Google Integration](guides/google-integration.md) — connect Google Calendar and Google Photos: OAuth client, redirect URI, which APIs to enable, and what verification does and does not mean. +- [Control Limits](guides/control-limits.md) — choose which parent controls each display offers, so a wall screen isn't a control panel. - [Configuration](reference/configuration.md) — environment variables and admin-panel settings. - [Custom Widget Development](guides/custom-widgets.md) — build and publish your own HTML widgets. - [Plugin Development](guides/plugin-development.md) — the full guide: manifest, storage, settings, events, reactions, and a complete worked example. diff --git a/docs/guides/control-limits.md b/docs/guides/control-limits.md new file mode 100644 index 0000000..98dfa73 --- /dev/null +++ b/docs/guides/control-limits.md @@ -0,0 +1,52 @@ +# Control Limits + +Control limits decide **which parent controls each display offers**. Turn one off +and that button isn't drawn on that display — no button, no prompt. + +Every display starts showing everything, so nothing changes until you set +something. + +## Setting them + +**Admin → Security → Controls on Displays** + +1. Set **Default for new displays** to **Wall display**. +2. Set the display you administer from to **Full control**. + +Restrict by default, then promote the few screens you run the household from. +That order matters: a new phone, or a browser whose data was cleared, arrives +following your default instead of fully capable. + +**Wall display means "hide everything switchable" — as a policy, not a snapshot.** +Install a plugin next month and its management controls are hidden there too, +without you revisiting the screen. + +You can limit Add Chore, transferring a chore, snoozing a due date, approving +prize requests, and redeeming clams from a child's profile picture — plus +whatever each installed plugin offers. + +## Plugin controls + +A plugin can declare which of its own controls a display may hide; those appear +under the plugin's name. A plugin that declares none is unaffected — so Wall +display means *everything a plugin offered to hide* is hidden, not that the +plugin shows no management UI at all. + +## If nothing seems to change + +**A display that remembers the admin PIN ignores its limits and shows +everything.** Your switches still save; they just don't apply there. Clear it +with **Require PIN on all devices again** in the same section. + +Otherwise: check whether the display is set individually or marked *Using +household default* — the default only reaches the latter. + +## What it isn't + +Not access control. HomeGlow's API has no per-device authentication, so a hidden +control is hidden, not forbidden. Limits stick because **Admin sits behind the +PIN** — without a PIN set they're decluttering, which is worth having but isn't a +lock. + +Admin itself is never one of the toggles, so a display can't hide the screen that +configures it. diff --git a/docs/guides/plugin-development.md b/docs/guides/plugin-development.md index f695630..ee5868e 100644 --- a/docs/guides/plugin-development.md +++ b/docs/guides/plugin-development.md @@ -130,6 +130,10 @@ serving your widget, and the SDK picks it up — you never pass your own id. | `settings` | — | Declared settings the Admin Panel renders (see §4). | | `events` | — | Core events to receive while mounted (see §5). | | `reactions` | — | Server-side increments run on events (see §6). Requires `storage: true`. | +| `hideableControls` | — | Controls a display may be configured to hide (see §4). | + +Keys not listed here are **ignored**, not rejected — so a misspelled field fails +silently rather than loudly. **Validation is strict and loud**: an invalid manifest rejects the upload with the exact errors (400), and a duplicate `id` is a 409. A widget *without* a @@ -201,6 +205,72 @@ await HomeGlow.settings.set({ mode: 'give' }); // validated server-side Values are validated against your declared schema on every write (wrong type, out-of-range, unknown key → 400; all-or-nothing). +### Control limits — let a display hide your management controls + +A wall display in the kitchen shouldn't offer your Edit and Delete buttons. Declare +which controls can be hidden and the Admin Panel makes them per-display switches. +**You do the hiding** — HomeGlow can't reach into your iframe. Household-facing +behavior is in the [Control Limits guide](control-limits.md). + +```json +"hideableControls": [ + { "id": "editPoll", "label": "Create and edit polls" } +] +``` + +```js +// At parse time, not in DOMContentLoaded — later than that and the control +// paints before you remove it, flashing on every iframe reload. +const hidden = (new URLSearchParams(location.search).get('hide') || '') + .split(',').filter(Boolean); + +if (!hidden.includes('editPoll')) renderEditButton(); +``` + +Literal markup is fine too — remove the nodes instead of skipping a call. + +#### Rules + +- **Ids are unprefixed, camelCase, and permanent.** `hide` names only your own + controls. `edit_poll` and `EditPoll` are rejected outright, and upload doesn't + validate it, so a typo fails silently. Ids are stored verbatim in every display's + settings, so renaming one orphans their configuration — same rule as `manifest.id`. +- **Check nothing else references a node you removed.** Enable/disable, focus or + label code doing `getElementById` on a hidden control will throw, and an exception + mid-render can blank the widget. This is the one way adopting this breaks a plugin. +- **Fail open.** No `hide` param means hide nothing, so an older dashboard and a + direct load both still work. +- **Declaring a control means a wall display hides it.** Don't declare what should + always be visible; undeclared controls can never be hidden. A control reachable + only *through* another needs no id of its own — hide the gate, and block entering + that mode, not just its button. +- **Drop the container when nothing visible is left in it**, or you ship empty + padding and headings floating over nothing. +- **`label` is optional** (falls back to the id) and **is not translated**, even + though `lang` lets you localize the rest of your widget. +- **Not security.** The API has no per-device auth. Don't gate anything that matters + on this. + +#### Two things that will cost you time + +**Retrofitting changes behavior on upgrade.** `hideAll` covers controls that didn't +exist when it was set, so the moment your update installs, every display already set +to Wall display loses your newly declared controls with nobody touching a setting. +Usually desirable — worth a line in your release notes. + +**If hiding seems to do nothing, check whether that display remembers the admin +PIN.** Such displays are exempt and get an empty hide list whatever is configured, so +your code is probably fine. Clear it with Admin → Security → "Require PIN on all +devices again". + +#### Testing + +Append the param yourself — `/widgets/YourPlugin.html?hide=editPoll` — the cheapest +honest test of your hiding logic. Note a direct load isn't sandboxed, so `confirm()`, +`prompt()` and `alert()` work there and won't work embedded. Then try it in the +dashboard, and **test the all-hidden case**: it's where empty containers show up and +the state you're least likely to try. + ## 5. Events — react live to what happens in HomeGlow Declare the events you want; while your widget is visible on a display, the