From ac43148a2da1c84f389b8ab749986819c3a9572b Mon Sep 17 00:00:00 2001 From: mrramam Date: Sat, 12 Sep 2026 13:47:05 -0700 Subject: [PATCH 1/4] feat(client): add the Control Limits decision module Every decision about which parent-facing controls a display offers, in one pure module so it is testable: this project's vitest config is node-env and matches `src/**/*.test.js`, so nothing rendered can be unit-tested and the logic has to live somewhere that can be. Storage is mode over snapshot: { mode: 'showAll' | 'hideAll', except: ['core:addChore', ...] } `mode` governs every control NOT named in `except`, including controls that do not exist yet. A stored list of hidden ids cannot express that, so "Wall display" would have been a snapshot of whatever existed when it was clicked, and a plugin installed next month would appear there until someone revisited the screen. The cost is that `except` means the opposite thing in each mode: the hidden ids under showAll, the surviving visible ones under hideAll. That inversion is written down exactly once, in `isHiddenUnder`, and nothing outside this module reads `except`. `toggleControl` derives it by asking that function rather than restating it, so a second expression of the rule cannot drift from the first. Decisions worth knowing: - Nothing-stored stays distinguishable from explicitly-configured. Both hide nothing today; only the first follows a household default that changes tomorrow. - A missing `mode` yields null rather than a guess, because guessing invents a policy for every control nobody has enumerated. - The Unlocked exemption requires a PIN to exist. Removing the household PIN leaves `adminPinRemembered` behind on every device, and honoring a stale flag would disable the whole feature household-wide. - An unresolved PIN check exempts, deliberately: controls that render, vanish and return read as a broken display. The obligation to resolve it is the caller's, and the JSDoc says so. - `editorState` answers what is CONFIGURED and cannot see the device blob or the PIN status, so an exempt display still draws its real configuration. It does take the control catalog, because a catalog is not in-force state and without it the function could not name a plugin control, pushing the mode inversion back out to every caller. - Reads report whether the settings were actually read. Labelling a display we failed to read as "inheriting" is a claim about configuration we do not have. - Every failure path resolves to hiding nothing. Unreadable settings must not strip a parent's buttons, and there is no attacker here to fail closed against. 91 tests. Each behavior above was mutation-checked: 30 deliberate breaks attempted, 30 caught. --- client/src/utils/displayControls.js | 375 +++++++++ client/src/utils/displayControls.test.js | 947 +++++++++++++++++++++++ 2 files changed, 1322 insertions(+) create mode 100644 client/src/utils/displayControls.js create mode 100644 client/src/utils/displayControls.test.js 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`); + }); + }); +}); From 35eb3894eb83ba792acef92b4b8c3c5f41cfbdbe Mon Sep 17 00:00:00 2001 From: mrramam Date: Sat, 12 Sep 2026 13:47:05 -0700 Subject: [PATCH 2/4] feat(client): resolve control limits and apply them in the chores widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.jsx resolves once and passes the result down. It keeps the raw device settings blob, because the hydrated views discard the keys this needs; resolves whether an admin PIN exists; and builds the known-control catalog from the core list plus each installed plugin's declared hideableControls. The PIN check retries four times over ~6s and then polls every five minutes while the answer is still unresolved, because an unresolved answer exempts every display that remembers the PIN and the only other trigger is an event this window's own Admin Panel fires — which nobody opens on a wall display. A kiosk that booted while the API was still starting would otherwise stay exempt until someone reloaded it. It never asserts `false` from a request that did not answer. Five guards in the chores widget, two of which are not simply "do not render": - suppressing both long-press menu items folds into the condition that opens the menu, so a long-press does nothing rather than opening an empty one - the avatar keeps rendering when clam redemption is hidden, losing only its handler and pointer affordance, because it is how a child sees their own balance An effect closes any dialog whose control disappears while it is open, each updater returning the previous state unchanged when already closed so it cannot churn renders. PluginWidgetWrapper appends `hide=` to the iframe src with that plugin's ids **unprefixed**: the `plugin::` namespace is core's storage concern and must not leak to plugin authors. Prefix-stripping lives in one helper, and the param is omitted entirely when nothing is hidden, so a plugin that knows nothing about this sees no change. Known and deliberate: transfer and snooze close their dialog before the PIN modal, so an action verified after its control was hidden still completes. The parent initiated it while the control was visible, this is visibility rather than a security boundary, and both sites carry a comment saying so. --- client/src/app.jsx | 202 ++++++++++++++++-- client/src/components/ChoreWidget.jsx | 108 ++++++++-- client/src/components/PluginWidgetWrapper.jsx | 24 ++- 3 files changed, 296 insertions(+), 38 deletions(-) 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/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/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%', From de03ffc81b21c037ab5bd80fa4f737f4867ba980 Mon Sep 17 00:00:00 2001 From: mrramam Date: Sat, 12 Sep 2026 13:47:05 -0700 Subject: [PATCH 3/4] feat(client): add the Control Limits form to Admin -> Security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A household default block and one card per display, each a preset picker over a switch list. A switch ON means the control is SHOWN; storage is the inverse, so the label reads the way a parent thinks. Every switch position comes from `editorState`, which reports what is configured rather than what is in force. That distinction is the whole reason the form works: an exempt display hides nothing in force, so drawing the in-force answer in an editable switch would make the form appear dead while its writes landed correctly. Three states a row can be in, and none may impersonate another. A configured display shows its preset. An inheriting one shows what it would inherit, read-only, since editing it would silently un-inherit the display. A display whose settings could not be read says so, draws no switch positions at all, and is read-only — toggling from an unknown base defaults to showAll and would overwrite a restrictive configuration nobody has seen. A display that remembers the admin PIN carries a notice that its settings are saved but not in effect, resolved per row from that display's own blob rather than only for the screen the admin is sitting at. Writes are guarded against losing a household's work: - Reads fold in per row rather than replacing the map, keeping any row with a write open or one that settled after the read began. Otherwise a read landing after a write reverted the row in the UI while the server kept the change, and the next toggle computed from the stale entry and wrote the loss back. - Choosing a preset, or retracting to the household default, discards every per-control exception. Both now confirm, naming how many switches would move, counted from rendered positions so it names something the admin can check. - Busy and error state are per row, not shared scalars, so one display's write cannot re-enable another mid-flight or clear an error nobody has read. - A failed write holds its request and reports in the dialog rather than in a card that may be scrolled away, or absent entirely when the device list failed to load. - "Keep full control here" is not offered when the current display's settings could not be read, since the offer would PATCH over a configuration we never saw. Plugin controls appear per plugin from `manifest.hideableControls`. Every manifest value that reaches JSX is coerced to a string first: the server validates none of them, there is no error boundary anywhere in this client, and an author writing `"label": {"en": ...}` would otherwise take down the whole Admin Panel — including the only page that can uninstall their plugin. Non-string control ids are dropped rather than becoming `plugin:x:undefined`, which the id pattern accepts. Each editor states whether controls added later default to on or off. Without it the preset reads "Custom" for no visible reason when a wall display has every control switched back on — correct, since that differs from Full control for a control installed tomorrow, but unexplained. Accessibility: switch groups carry `role="group"` and a label association so a screen reader can tell which plugin a control belongs to, spinners are named, cards carry `aria-busy`, and error alerts sit in live regions — marked presentational inside, since MUI's Alert already carries `role="alert"` and two overlapping live regions would announce one message twice. Author-supplied plugin labels render untranslated, matching how declared plugin settings already behave. --- client/src/components/AdminPanel.jsx | 31 +- client/src/components/ControlsOnDisplay.jsx | 877 ++++++++++++++++++++ client/src/i18n/locales/en/admin.json | 54 ++ client/src/i18n/locales/es/admin.json | 54 ++ 4 files changed, 1015 insertions(+), 1 deletion(-) create mode 100644 client/src/components/ControlsOnDisplay.jsx 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/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/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", From ef4e0bdb0575357ca99e2159b7a3f765085097f7 Mon Sep 17 00:00:00 2001 From: mrramam Date: Sat, 12 Sep 2026 13:47:05 -0700 Subject: [PATCH 4/4] docs: document Control Limits for households and for plugin authors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two audiences, two documents. `guides/control-limits.md` is forty lines, because most of what a first draft wanted to say is visible in the admin panel already. What survives is what a reader cannot work out from the screen: set the household default first and promote the few screens you administer from, so a browser whose data was cleared arrives following your policy rather than fully capable; Wall display is a policy and not a snapshot, so a plugin installed next month is hidden there without anyone revisiting the screen; and a display that remembers the admin PIN ignores its limits while still saving them, which presents as a settings page that looks broken. The plugin-developer section in `guides/plugin-development.md` was written first and then rewritten, after an agent was handed it as its only specification and returned fifteen places it was insufficient. The worst were not omissions but misdirection: `hideableControls` was absent from the manifest reference table, which reads as exhaustive and is immediately followed by "validation is strict and loud", so an author would never learn the field exists and would assume an undocumented key gets rejected — it is silently ignored. The admin PIN was not mentioned at all, and a display that remembers it receives an empty hide list whatever is configured, which on a dev machine is the likeliest reason an author's own test appears to do nothing. It now also covers: retrofitting this onto a published plugin silently removes those controls from every display already set to Wall display, because the mode governs controls that did not exist when it was set; read the hide list at parse time or the control flashes on every iframe reload; other code holding a reference to a node you removed will throw, which is the one way adopting this can break a plugin; ids are stored verbatim so renaming one orphans every display's configuration; and the cheapest honest test is appending `?hide=...` to the widget URL yourself, which the first draft steered authors away from. --- docs/README.md | 1 + docs/guides/control-limits.md | 52 +++++++++++++++++++++++ docs/guides/plugin-development.md | 70 +++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 docs/guides/control-limits.md 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