Fix Store tab stuck on Loading; adjustable idle time for family devices; videos count as in use
build-container / build (push) Successful in 3m35s

- A bare #/store now opens at #/store/books so the Book store page draws
- Admins choose how long a family device waits before returning to the board (1 to 30 minutes)
- Any playing video or audio keeps a family device signed in
- Test updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 16:24:06 -04:00
1 parent 4c6b1a8458
commit aac6f6efc2
5 files changed
+18 -7

No files matched your search

+9 -1
View File
@@ -24,6 +24,10 @@ function checkPin(uid, pin) {
if (ok) { if (r.fails) { r.fails = 0; savePins(); } return true; }
r.fails = (r.fails || 0) + 1; if (r.fails >= MAX_FAILS) { r.fails = 0; r.until = Date.now() + LOCK_MS; } savePins(); return false;
}
// how long a family device sits idle before it goes back to the board (minutes; a signed-in session on a family device lasts 30)
const SET_FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'board-settings.json');
const IDLE_CHOICES = [1, 2, 3, 5, 10, 15, 30];
const idleMin = () => { try { const n = +JSON.parse(fs.readFileSync(SET_FILE(), 'utf8')).idleMin; return IDLE_CHOICES.includes(n) ? n : 5; } catch { return 5; } };
const ymdOk = s => /^\d{4}-\d{2}-\d{2}$/.test(s || '');
const isKid = u => !!(u.kid && u.kid.on);
const palette = ['#e0742b', '#2f9e6e', '#4f7cff', '#8a5cf0', '#d9453a', '#2aa6a0', '#e5a00d'];
@@ -76,11 +80,15 @@ async function handle(req, res) {
identify(t, b.pin, req);
auth.startSession(req, res, t); audit.log('family device: picked a profile', { who: t.name }, req); return json(res, 200, { ok: true });
}
if (p === '/api/device' && req.method === 'GET') { if (!me) throw fail(401, 'Not signed in'); return json(res, 200, { device: !!dev, pin: hasPin(me.id), devices: me.role === 'admin' ? devices.load() : undefined }); }
if (p === '/api/device' && req.method === 'GET') { if (!me) throw fail(401, 'Not signed in'); return json(res, 200, { device: !!dev, idleMin: idleMin(), idleChoices: IDLE_CHOICES, pin: hasPin(me.id), devices: me.role === 'admin' ? devices.load() : undefined }); }
if (p === '/api/device/pin' && (req.method === 'POST' || req.method === 'DELETE')) {
if (!me) throw fail(401, 'Not signed in'); if (req.method === 'DELETE') { delete db()[me.id]; savePins(); return json(res, 200, { pin: false }); }
const b = JSON.parse(await readBody(req, 5e3) || '{}'); setPin(me.id, b.pin); return json(res, 200, { pin: true });
}
if (p === '/api/device/idle' && req.method === 'POST') { // an admin sets how long a family device waits before going back to the board
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 2e3) || '{}'), n = Math.round(+b.minutes);
if (!IDLE_CHOICES.includes(n)) throw fail(400, 'Choose between 1 and 30 minutes'); fs.mkdirSync(path.dirname(SET_FILE()), { recursive: true }); atomicWrite(SET_FILE(), JSON.stringify({ idleMin: n }), { mode: 0o600 }); audit.log('family device idle time set', { who: me.name, detail: n + ' min' }, req); return json(res, 200, { idleMin: n });
}
if (p === '/api/device/enable' && req.method === 'POST') { // an admin makes this screen a family device (and it goes back to the board)
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'); auth.enrollDevice(req, res, b.name, me.name); audit.log('family device added', { who: me.name, detail: String(b.name || '').slice(0, 40) }, req);
res.setHeader('Set-Cookie', [].concat(res.getHeader('Set-Cookie') || [], 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0')); return json(res, 200, { ok: true });
+6 -5
View File
@@ -1,25 +1,26 @@
'use strict';
// ---- Family devices: on a shared screen, a label that says whose account this is, a Switch button, and a return to the family board after a few idle minutes.
// Also the Settings part: your PIN for shared screens, and (admins) making this screen a family device.
let devOn = false, devIdle = 0;
const DEV_IDLE_MS = 5 * 60e3;
let devOn = false, devIdle = 0, devIdleMs = 5 * 60e3;
async function devBack() { try { await fetch('/api/auth/logout', { method: 'POST', body: '{}' }); } catch {} location.replace('/board'); }
async function devStart() {
try { const r = await jfetch('/api/device'); devOn = !!r.device; } catch { return; }
try { const r = await jfetch('/api/device'); devOn = !!r.device; if (r.idleMin) devIdleMs = r.idleMin * 60e3; } catch { return; }
if (!devOn || !ME) return;
const kid = ME.kid && ME.kid.on, chip = document.createElement('div'); chip.id = 'devchip';
chip.innerHTML = `<span class="devwho"><b>${esc(ME.name)}</b>${kid ? '<i class="devkid">KID</i>' : ''}</span><button class="btn" id="devswitch">Switch user</button>`;
document.body.appendChild(chip); document.body.classList.add('ondevice'); chip.querySelector('#devswitch').onclick = devBack;
const bump = () => { devIdle = Date.now(); }; ['pointerdown', 'keydown', 'scroll', 'touchstart'].forEach(e => addEventListener(e, bump, { passive: true })); bump();
setInterval(() => { const a = typeof pl !== 'undefined' && pl.a && !pl.a.paused; if (!a && Date.now() - devIdle > DEV_IDLE_MS) devBack(); }, 15000); // (music playing keeps it signed in)
setInterval(() => { const a = (typeof pl !== 'undefined' && pl.a && !pl.a.paused) || [...document.querySelectorAll('video, audio')].some(m => !m.paused && !m.ended); /* music, a movie or an episode playing counts as being in use */ if (!a && Date.now() - devIdle > devIdleMs) devBack(); }, 15000); // (music or a video playing keeps it signed in)
}
async function devSettings(box) {
if (!box) return; let r; try { r = await jfetch('/api/device'); } catch { return; }
const admin = ME && ME.role === 'admin', post = (p, b) => jfetch('/api/device/' + p, { method: 'POST', body: JSON.stringify(b || {}) });
box.innerHTML = `<h2>Shared screens</h2><div class="list">
<div class="row"><span class="grow"><b>${r.pin ? 'Your PIN is set' : 'Set a PIN'}</b><small style="white-space:normal">On a shared family screen, you pick your name and enter this PIN (children can just tap their name). 4 to 8 digits.</small></span><button class="btn ghost" id="devpin">${r.pin ? 'Change' : 'Set PIN'}</button>${r.pin ? '<button class="btn ghost" id="devpinx">Remove</button>' : ''}</div>
${admin ? `<div class="row"><span class="grow"><b>${r.device ? 'This is a family device' : 'Make this a family device'}</b><small style="white-space:normal">${r.device ? 'It opens on the family board and signs people out after a few idle minutes.' : 'For a kitchen tablet or the family computer: it opens on the family board, and everyone picks their name to use it. You will be signed out here.'}</small></span><button class="btn ${r.device ? 'ghost' : ''}" id="devtoggle">${r.device ? 'Turn off' : 'Turn on'}</button></div>` : ''}</div>`;
${admin ? `<div class="row"><span class="grow"><b>Back to the board after</b><small style="white-space:normal">When nobody touches a family device for this long, it signs the person out and shows the family board. Music or a video playing keeps it signed in.</small></span><select id="devidle" style="width:auto">${(r.idleChoices || [5]).map(n => `<option value="${n}"${n === r.idleMin ? ' selected' : ''}>${n} min</option>`).join('')}</select></div>
<div class="row"><span class="grow"><b>${r.device ? 'This is a family device' : 'Make this a family device'}</b><small style="white-space:normal">${r.device ? 'It opens on the family board and signs people out after a few idle minutes.' : 'For a kitchen tablet or the family computer: it opens on the family board, and everyone picks their name to use it. You will be signed out here.'}</small></span><button class="btn ${r.device ? 'ghost' : ''}" id="devtoggle">${r.device ? 'Turn off' : 'Turn on'}</button></div>` : ''}</div>`;
box.querySelector('#devpin').onclick = () => { const el = openSheet('Your PIN', 'Used to pick your name on a shared family screen.', '<input type="password" inputmode="numeric" pattern="[0-9]*" id="pn" maxlength="8" placeholder="4 to 8 digits" autocomplete="off"><div class="bar"><button class="btn" id="pns" style="flex:1;justify-content:center">Save</button></div>'); el.querySelector('#pns').onclick = async () => { try { await post('pin', { pin: el.querySelector('#pn').value }); closeSheet(); toast('PIN saved'); devSettings(box); } catch (e) { toast(e.message); } }; };
const idle = box.querySelector('#devidle'); if (idle) idle.onchange = async () => { try { await post('idle', { minutes: +idle.value }); toast('Saved'); } catch (e) { toast(e.message); } };
const x = box.querySelector('#devpinx'); if (x) x.onclick = async () => { try { await jfetch('/api/device/pin', { method: 'DELETE' }); devSettings(box); } catch (e) { toast(e.message); } };
const t = box.querySelector('#devtoggle'); if (t) t.onclick = () => r.device ? confirmSheet('Turn off family device?', 'This screen goes back to the normal sign-in.', 'Turn off', async () => { try { await post('remove'); location.replace('/login'); } catch (e) { toast(e.message); } }) : confirmSheet('Make this a family device?', 'You will be signed out, and this screen opens on the family board from now on.', 'Make it', async () => { try { await post('enable', { name: /iPad/.test(navigator.userAgent) ? 'iPad' : 'Family screen' }); location.replace('/board'); } catch (e) { toast(e.message); } });
}
+1
View File
@@ -15,6 +15,7 @@ const stSections = () => [['books', 'Books', allowed('read')], ['audiobooks', 'A
async function viewStore(args = []) {
const secs = stSections(), want = args[0] === 'ext' || args[0] === 'site' ? 'mine' : args[0], sec = (secs.find(s => s[0] === want) || secs[0])[0];
if (!args[0] || (!secs.some(s => s[0] === args[0]) && args[0] !== 'ext' && args[0] !== 'site')) return location.replace('#/store/' + sec); // (a bare #/store opens the first section by its own address, so its page knows it is the one showing)
const v = $('#view');
v.innerHTML = `<div class="wrap" id="stwrap"><div class="ptop"><h1>Store</h1></div><div class="seg">${secs.map(s => `<a href="#/store/${s[0]}" class="${s[0] === sec ? 'on' : ''}">${esc(s[1])}</a>`).join('')}</div><div id="stbody"></div></div>`;
const el = $('#stbody'), host = { el, hash: '#/store/' + sec };
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v84';
const CACHE = 'hub-v86';
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+1
View File
@@ -35,6 +35,7 @@ const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ?
assert.equal((await post('/api/device/pin', { pin: '12' }, session(bob))).status, 400, 'a PIN is 4 to 8 digits'); assert.equal((await post('/api/device/pin', { pin: '2468' }, session(bob))).status, 200); assert.equal((await call('/api/board?day=2026-10-26', {}, dev)).json.people.find(p => p.name === 'Bob').pinLen, 4, 'the board knows how many digits, to enter the PIN as soon as it is all typed');
assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '1111' }, dev)).status, 401); assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '2468' }, dev)).status, 200);
for (let i = 0; i < 5; i++) await post('/api/board/switch', { uid: bob.id, pin: '0000' }, dev); assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '2468' }, dev)).status, 429, 'five wrong tries lock it for a few minutes');
assert.equal((await call('/api/device', {}, session(ann) + '; ' + dev)).json.idleMin, 5, 'five idle minutes unless changed'); assert.equal((await post('/api/device/idle', { minutes: 2 }, session(bob))).status, 403); assert.equal((await post('/api/device/idle', { minutes: 7 }, session(ann))).status, 400, 'only the offered choices'); assert.equal((await post('/api/device/idle', { minutes: 2 }, session(ann))).status, 200); assert.equal((await call('/api/device', {}, session(ann) + '; ' + dev)).json.idleMin, 2, 'the new idle time sticks');
assert.equal((await call('/api/device', {}, session(ann) + '; ' + dev)).json.devices.length, 1);
r = await post('/api/device/remove', {}, session(ann) + '; ' + dev); assert.equal(r.status, 200); assert.equal((await call('/api/board', {}, dev)).status, 403, 'switched off: the old cookie no longer works');
console.log('board ok');