From eef1adaa73c570a1e6834bbc04bb8f96d58f686d Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:58:23 -0400 Subject: [PATCH] Family devices: shared-screen family board with a who-is-this profile picker - Admin can make a screen a family device; it opens on a family board - Name tiles with KID labels; kids tap, others enter a PIN (lockout after 5 wrong tries) - Name label with Switch user, auto-return to the board after 5 idle minutes - Short 30-minute sessions on family devices - Settings section for PIN and turning a device on or off - Test, README and roadmap updated Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 ++ auth.js | 15 +++++++-- board.js | 65 ++++++++++++++++++++++++++++++++++++ cal.js | 7 +++- chat.js | 2 +- devices.js | 11 ++++++ docs/v2-roadmap.md | 1 + notes.js | 4 ++- package.json | 2 +- public/board.html | 81 +++++++++++++++++++++++++++++++++++++++++++++ public/board.js | 42 +++++++++++++++++++++++ public/boot.js | 2 +- public/device-ui.js | 25 ++++++++++++++ public/index.html | 1 + public/settings.js | 2 ++ public/style.css | 3 ++ public/sw.js | 2 +- recipes.js | 4 ++- server.js | 9 +++-- tasks.js | 4 ++- tools/board-test.js | 30 +++++++++++++++++ 21 files changed, 300 insertions(+), 14 deletions(-) create mode 100644 board.js create mode 100644 devices.js create mode 100644 public/board.html create mode 100644 public/board.js create mode 100644 public/device-ui.js create mode 100644 tools/board-test.js diff --git a/README.md b/README.md index bb59cc6..9c9796d 100644 --- a/README.md +++ b/README.md @@ -196,5 +196,7 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions **Notifications** (the bell on Home and Family): a list of what you have not seen yet: chat messages (one line per conversation, with a count), reminders for tasks, events and mail, and family announcements. Tap one to open it; mark all read or clear. Kept per person in `inbox.json`. +**Family devices** (Settings, under Passkeys): for a kitchen tablet or the family computer. An admin turns on *Make this a family device* while signed in on it; from then on it opens on the **family board** (announcement, today's family calendar, chores with progress, shopping list, family tasks and notes) and a row of name tiles, children marked **KID**. Tap your name: a child signs in with one tap, everyone else enters a PIN they set in Settings (five wrong tries lock that person for five minutes; there is also a passkey link). A label with your name and **Switch user** stays on screen, and the device goes back to the board after five idle minutes (music playing keeps it signed in). Sessions on a family device last 30 minutes at a time, not months. Turn it off from the same place. The Family calendar and Family task list are shared by everyone and appear on the board. + On a tablet or with the menu collapsed, the menu rail shows icons only (the name appears on hover) when the window is not tall enough for a label under every icon. diff --git a/auth.js b/auth.js index d1eb5f7..779ad62 100644 --- a/auth.js +++ b/auth.js @@ -72,13 +72,22 @@ function currentUser(req) { } const isAuthed = req => !!currentUser(req); -const sessionCookie = (req, user) => { const exp = Date.now() + LIFETIME; - return `hub_session=${user.id}.${user.epoch}.${exp}.${mac(`s${user.id}.${user.epoch}.${exp}`)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}${isHttps(req) ? '; Secure' : ''}`; }; +// A family device (a shared screen) is signed in for only a short while after each tap, so whoever used it last is not left signed in. +const KIOSK = 30 * 60e3; +const devices = require('./devices'); +const deviceOf = req => { const [id, sig] = String(cookies(req).hub_device || '').split('.'); return id && sig && safeEq(sig, mac('d' + id)) && devices.has(id) ? id : null; }; +const lifeFor = req => deviceOf(req) ? KIOSK : LIFETIME; +const sessionCookie = (req, user, life = lifeFor(req)) => { const exp = Date.now() + life; + return `hub_session=${user.id}.${user.epoch}.${exp}.${mac(`s${user.id}.${user.epoch}.${exp}`)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.round(life / 1000)}${isHttps(req) ? '; Secure' : ''}`; }; +const deviceCookie = (req, id) => `hub_device=${id}.${mac('d' + id)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${3650 * 86400}${isHttps(req) ? '; Secure' : ''}`; +const enrollDevice = (req, res, name, by) => { const d = devices.add(name, by); addCookie(res, deviceCookie(req, d.id)); return d; }; +const forgetDevice = (req, res) => { const id = deviceOf(req); if (id) devices.remove(id); addCookie(res, 'hub_device=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); }; // Add a Set-Cookie without clobbering ones other code already set on this response. const addCookie = (res, c) => { const cur = res.getHeader('Set-Cookie'); res.setHeader('Set-Cookie', cur ? [].concat(cur, c) : c); }; function startSession(req, res, user) { addCookie(res, sessionCookie(req, user)); } /** Renew the cookie when it is more than a day old (and upgrade old-style cookies), so an active session never runs out. */ function refresh(req, res, user) { + if (deviceOf(req)) { const e = Number(String(cookies(req).hub_session || '').split('.')[2]); if (!(e - Date.now() > KIOSK - 60e3)) addCookie(res, sessionCookie(req, user, KIOSK)); return; } const parts = String(cookies(req).hub_session || '').split('.'); const exp = Number(parts.length === 4 ? parts[2] : parts[0]); if (parts.length !== 4 || exp - Date.now() < LIFETIME - 864e5) addCookie(res, sessionCookie(req, user)); @@ -248,4 +257,4 @@ async function handle(req, res) { const passkeyCount = uid => credsOf(uid).length; function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); delete recovery[uid]; saveRecovery(); } -module.exports = { handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf }; +module.exports = { deviceOf, enrollDevice, forgetDevice, startSession, handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf }; diff --git a/board.js b/board.js new file mode 100644 index 0000000..0f0b4ba --- /dev/null +++ b/board.js @@ -0,0 +1,65 @@ +// The family board and "who is this?": what a family device (see devices.js) shows before anyone signs in, and the way a person picks their name there. +// Children tap their name; everyone else enters a PIN they set for themselves (or signs in with a passkey). The board only ever shows the family's shared things. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const users = require('./users'), auth = require('./auth'), audit = require('./audit'), devices = require('./devices'); +const cal = require('./cal'), chores = require('./chores'), notes = require('./notes'), tasks = require('./tasks'), recipes = require('./recipes'), chat = require('./chat'); +const { readBody } = require('./body'); + +const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const fail = (code, message) => Object.assign(new Error(message), { code }); +const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'pins.json'); +let pins = null; +const db = () => pins || (pins = (() => { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch { return {}; } })()); +const savePins = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); }; +const hash = (pin, salt) => crypto.scryptSync(String(pin), salt, 32).toString('hex'); +const hasPin = uid => !!db()[uid]; +const MAX_FAILS = 5, LOCK_MS = 5 * 60e3; +function setPin(uid, pin) { if (!/^\d{4,8}$/.test(String(pin || ''))) throw fail(400, 'A PIN is 4 to 8 digits'); const salt = crypto.randomBytes(12).toString('hex'); db()[uid] = { s: salt, h: hash(pin, salt), fails: 0, until: 0 }; savePins(); } +function checkPin(uid, pin) { + const r = db()[uid]; if (!r) return false; if (r.until > Date.now()) throw fail(429, 'Too many wrong tries. Wait a few minutes.'); + const ok = /^\d{4,8}$/.test(String(pin || '')) && crypto.timingSafeEqual(Buffer.from(hash(pin, r.s), 'hex'), Buffer.from(r.h, 'hex')); + 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; +} +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']; +const people = () => users.all().filter(u => !u.disabled).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), color: palette[i % palette.length] })); + +async function board(day) { + const t1 = chores.addDays(day, 1), from = new Date(day + 'T00:00:00').getTime() - 864e5, to = new Date(t1 + 'T23:59:59').getTime() + 864e5; + let events = []; try { events = cal.familyEvents({ from, to, fd: day, td: t1 }); } catch {} + return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).map(i => ({ title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done })), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() }; +} + +async function handle(req, res) { + const u = new URL(req.url, 'http://x'), p = u.pathname, me = auth.currentUser(req), dev = auth.deviceOf(req); + try { + if (p === '/api/board' && req.method === 'GET') { if (!dev) throw fail(403, 'This is not a family device'); const day = u.searchParams.get('day'); return json(res, 200, await board(ymdOk(day) ? day : new Date().toISOString().slice(0, 10))); } + if (p === '/api/board/switch' && req.method === 'POST') { + if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), t = users.get(String(b.uid)); + if (!t || t.disabled) throw fail(404, 'Person not found'); + if (!isKid(t) || hasPin(t.id)) { // a child with no PIN just taps their name; everyone else needs theirs + if (!hasPin(t.id)) throw fail(403, 'Set a PIN first (Settings, on your own device), or sign in with your passkey'); + if (!checkPin(t.id, b.pin)) { audit.log('family device: wrong PIN', { who: t.name }, req); throw fail(401, 'That PIN is not right'); } + } + 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/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/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 }); + } + if (p === '/api/device/remove' && req.method === 'POST') { // switch one off: this screen, or another from the list + if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'); + if (b.id) devices.remove(String(b.id)); else auth.forgetDevice(req, res); audit.log('family device removed', { who: me.name }, req); return json(res, 200, { ok: true }); + } + return json(res, 404, { error: 'Not found' }); + } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); } +} +module.exports = { handle, board, setPin, hasPin }; diff --git a/cal.js b/cal.js index a5e24cd..c7a9acb 100644 --- a/cal.js +++ b/cal.js @@ -477,4 +477,9 @@ async function handle(req, res) { } catch (e) { json(res, 400, { error: e.message }); } } -module.exports = { collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs }; +/** The Family calendar's events in a window, for the family board (no signed-in person needed). */ +function familyEvents(win) { + const ics_ = ics.buildCalendar(eventsIn(dirFor(FAM)).map(e => ({ uid: e.uid, fields: e.fields }))); + return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: FAMCAL.color })); +} +module.exports = { familyEvents, collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs }; diff --git a/chat.js b/chat.js index 84fd576..2675298 100644 --- a/chat.js +++ b/chat.js @@ -239,4 +239,4 @@ function searchFor(me, q) { for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); } return out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 8); } -module.exports = { handle, totalUnread, purge, parsePreview, askParents, searchFor }; +module.exports = { announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor }; diff --git a/devices.js b/devices.js new file mode 100644 index 0000000..3a81580 --- /dev/null +++ b/devices.js @@ -0,0 +1,11 @@ +// Family devices: shared screens (a kitchen tablet, the family computer) that open on the family board instead of a sign-in. An admin makes a device one +// while signed in on it; the device keeps a signed cookie, and this list is what lets an admin switch one off again. Kept in DATA_DIR/devices.json. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'devices.json'); +const load = () => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j.devices) ? j.devices : []; } catch { return []; } }; +const save = l => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify({ devices: l }), { mode: 0o600 }); }; +const has = id => load().some(d => d.id === id); +const add = (name, by) => { const d = { id: crypto.randomBytes(9).toString('base64url'), name: String(name || 'Family device').slice(0, 40), by, created: Date.now() }; save([...load(), d]); return d; }; +const remove = id => save(load().filter(d => d.id !== id)); +module.exports = { load, has, add, remove }; diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md index aa13076..9541dc3 100644 --- a/docs/v2-roadmap.md +++ b/docs/v2-roadmap.md @@ -52,6 +52,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup, ## 3. Kids and safety - [x] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits **Done: child accounts get a clean Store, podcasts and daily time limit and bedtime (kidlimits.js); tabs per child already existed.** - [ ] **Approvals (S):** a child asks to add a book, app or extension and a parent approves +- [x] **Family device and profile picker (M):** a shared screen opens on the family board; kids tap their name, everyone else enters a PIN; it returns to the board when idle **Done: board.js, devices.js** - [ ] **Simple mode (S):** a big-button layout with few tabs for young kids or grandparents ## 4. Everyday convenience diff --git a/notes.js b/notes.js index 4c6de5c..f193e6a 100644 --- a/notes.js +++ b/notes.js @@ -65,4 +65,6 @@ function search(me, q) { const at = text.toLowerCase().indexOf(needle); out.push({ id: n.id, type: n.type, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note'), snippet: (at > 20 ? '…' : '') + text.slice(Math.max(0, at - 20), Math.max(0, at - 20) + 90), share: n.share === 'family' ? 'Family' : n.share === 'me' ? 'Just me' : 'Shared' }); } return out.slice(0, 8); } -module.exports = { handle, search }; +/** Notes and lists shared with the whole family, for the family board. */ +const familyNotes = () => all().filter(n => n.share === 'family' && !n.archived).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).slice(0, 6).map(n => ({ id: n.id, type: n.type, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note'), pinned: n.pinned, total: n.items.length, done: n.items.filter(i => i.done).length, next: (n.items.find(i => !i.done) || {}).text || '', body: n.type === 'note' ? n.body.slice(0, 120) : '' })); +module.exports = { handle, search, familyNotes }; diff --git a/package.json b/package.json index c7c46a1..8458988 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family; do node tools/$t-test.js || exit 1; done", + "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/board.html b/public/board.html new file mode 100644 index 0000000..5331b9c --- /dev/null +++ b/public/board.html @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Family board · NE Hub + + + +
+

Family board

+
+

Who's using this? Tap your name

+
+

Sign in with a passkey instead

+
+ + + + diff --git a/public/board.js b/public/board.js new file mode 100644 index 0000000..c077341 --- /dev/null +++ b/public/board.js @@ -0,0 +1,42 @@ +'use strict'; +// ---- Family board: what a family device shows before anyone signs in, and the "who is this?" step. Only the family's shared things are shown here. +const $ = s => document.querySelector(s); +const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +const ymd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); +let data = null; + +function clock() { const d = new Date(); $('#clock').textContent = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); const h = d.getHours(); $('#hi').textContent = (h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening') + ', family'; $('#date').textContent = d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); } +const card = (t, body) => `

${t}

${body}
`; +const none = t => `

${t}

`; + +function draw() { + const t0 = ymd(new Date()), t1 = ymd(new Date(Date.now() + 864e5)), d = data; + $('#annc').innerHTML = d.announcement ? `
${esc(d.announcement.text)}
${esc(d.announcement.by)}
` : ''; + $('#tiles').innerHTML = d.people.map(p => ``).join(''); + $('#tiles').querySelectorAll('.tile').forEach(b => b.onclick = () => pick(d.people.find(p => p.id === b.dataset.id))); + const day = (label, ds) => { const l = d.events.filter(e => e.allDay ? e.start <= ds && e.end > ds : ymd(new Date(e.start)) === ds).sort((a, b) => (b.allDay - a.allDay) || a.start - b.start); return `${label}` + (l.map(e => `
${esc(e.title)}${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
`).join('') || none('Nothing')); }; + const per = {}; d.chores.forEach(c => { const k = c.userName; (per[k] = per[k] || []).push(c); }); + const chores = Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length; return `
${esc(k)}${dn} of ${l.length}
${esc(l.filter(c => !c.done).slice(0, 3).map(c => c.title).join(' · ') || 'All done 🎉')}
`; }).join(''); + $('#grid').innerHTML = card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1)) + + card('Chores today', chores || none('No chores today')) + + card('Shopping list', d.shopping.map(i => `
${esc(i.text)}
`).join('') || none('Nothing to buy')) + + card('Family tasks', d.tasks.map(t => `
${esc(t.title)}${t.due === t0 ? 'Today' : esc(t.due)}
`).join('') || none('Nothing due')) + + card('Family notes and lists', d.notes.map(n => `
${esc(n.title)}${n.type === 'list' ? ` ${n.done} of ${n.total} done${n.next ? ' · next: ' + esc(n.next) : ''}` : ` ${esc(n.body)}`}
`).join('') || none('No shared notes')); +} +async function load() { try { const r = await fetch('/api/board?day=' + ymd(new Date())); if (r.status === 403) { location.replace('/login'); return; } data = await r.json(); draw(); } catch {} } + +function pick(p) { + if (!p) return; + const ov = $('#ov'), box = $('#ovb'); ov.hidden = false; + const close = () => { ov.hidden = true; box.innerHTML = ''; }; + const go = async pin => { const r = await fetch('/api/board/switch', { method: 'POST', body: JSON.stringify({ uid: p.id, pin }) }), j = await r.json().catch(() => ({})); if (r.ok) return location.replace('/'); throw new Error(j.error || 'Could not sign in'); }; + const head = `
${esc((p.name.trim()[0] || '?').toUpperCase())}${esc(p.name)}${p.kid ? 'KID' : ''}
`; + if (p.kid && !p.pin) { box.innerHTML = head + `

Sign in as ${esc(p.name)}?

`; $('#no').onclick = close; $('#yes').onclick = () => go().catch(e => $('#e').textContent = e.message); return; } + if (!p.pin) { box.innerHTML = head + `

${esc(p.name)} has not set a PIN yet. Set one in Settings on your own device, or sign in with your passkey.

`; $('#no').onclick = close; return; } + let pin = ''; const paint = () => { $('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => ``).join(''); }; + box.innerHTML = head + `

Enter your PIN

${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => ``).join('')}

`; + paint(); $('#no').onclick = close; + box.querySelectorAll('.pad button').forEach(b => b.onclick = async () => { const k = b.dataset.k; $('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k === '✓') { try { await go(pin); } catch (e) { $('#e').textContent = e.message; pin = ''; } } else if (pin.length < 8) pin += k; paint(); }); +} +clock(); setInterval(clock, 15000); load(); setInterval(() => { if (!document.hidden && $('#ov').hidden) load(); }, 60000); +document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); }); diff --git a/public/boot.js b/public/boot.js index d196081..a0847f6 100644 --- a/public/boot.js +++ b/public/boot.js @@ -18,7 +18,7 @@ setInterval(checkForUpdate, 10 * 60e3); await loadMe(); await loadSettings(); - sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); + sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); addEventListener('hashchange', route); restorePlayer(); route(); diff --git a/public/device-ui.js b/public/device-ui.js new file mode 100644 index 0000000..5294491 --- /dev/null +++ b/public/device-ui.js @@ -0,0 +1,25 @@ +'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; +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; } + if (!devOn || !ME) return; + const kid = ME.kid && ME.kid.on, chip = document.createElement('div'); chip.id = 'devchip'; + chip.innerHTML = `${esc(ME.name)}${kid ? 'KID' : ''}`; + 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) +} +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 = `

Shared screens

+
${r.pin ? 'Your PIN is set' : 'Set a PIN'}On a shared family screen, you pick your name and enter this PIN (children can just tap their name). 4 to 8 digits.${r.pin ? '' : ''}
+ ${admin ? `
${r.device ? 'This is a family device' : 'Make this a family device'}${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.'}
` : ''}
`; + box.querySelector('#devpin').onclick = () => { const el = openSheet('Your PIN', 'Used to pick your name on a shared family screen.', '
'); 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 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); } }); +} diff --git a/public/index.html b/public/index.html index 4f8b9d6..ba166c1 100644 --- a/public/index.html +++ b/public/index.html @@ -102,6 +102,7 @@ + diff --git a/public/settings.js b/public/settings.js index fc43451..6071a85 100644 --- a/public/settings.js +++ b/public/settings.js @@ -100,6 +100,7 @@ function viewSettings() {

Passkeys

Loading…
+

Recovery codes

Checking…One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper).
${isAdmin ? `

People

Loading…
@@ -195,6 +196,7 @@ function viewSettings() { const el = $('#audit'); if (!el) return; el.innerHTML = events.length ? events.slice(0, 40).map(e => `
${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}
`).join('') : '
Nothing yet
'; }).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '
Could not load
'; }); + if (typeof devSettings === 'function') devSettings($('#famdev')); $('#invpk').onclick = async () => { try { const { code, minutes } = await authPost('/api/auth/invite'); diff --git a/public/style.css b/public/style.css index b4ec518..93177c7 100644 --- a/public/style.css +++ b/public/style.css @@ -1057,3 +1057,6 @@ body.vizopen { overflow: hidden; } .inboxbell { position: relative; } .inboxbell .nbadge { position: absolute; top: -6px; right: -6px; } .fmrow .grow { min-width: 0; } + +#devchip { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 8px); right: 10px; z-index: 60; display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 22px; padding: 4px 4px 4px 14px; box-shadow: 0 4px 14px rgba(0,0,0,.3); } +#devchip .btn { min-height: 34px; padding: 0 14px; border-radius: 18px; } .devwho { display: flex; align-items: center; gap: 6px; } .devkid { font-style: normal; background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; } diff --git a/public/sw.js b/public/sw.js index e9d2a0e..65508f9 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v72'; +const CACHE = 'hub-v73'; 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']; diff --git a/recipes.js b/recipes.js index 6f5e313..3c11bfe 100644 --- a/recipes.js +++ b/recipes.js @@ -147,4 +147,6 @@ async function handle(req, res) { /** Recipes in Mealie that match the words (none when Mealie is not set up or does not answer). */ async function search(q) { if (!enabled()) return []; try { const j = await mealie(`/api/recipes?page=1&perPage=6&orderBy=name&orderDirection=asc&search=${encodeURIComponent(q)}`, { timeout: 6000 }); return (j.items || []).map(card); } catch { return []; } } -module.exports = { handle, enabled, minutes, full, ingredientText, search }; +/** The open items on the shopping list, for the family board ([] when Mealie is not set up or does not answer). */ +async function shoppingOpen() { if (!enabled()) return []; try { return (await readList()).items.filter(i => !i.checked).slice(0, 12).map(i => ({ id: i.id, text: i.text })); } catch { return []; } } +module.exports = { shoppingOpen, handle, enabled, minutes, full, ingredientText, search }; diff --git a/server.js b/server.js index 474c476..4640b37 100644 --- a/server.js +++ b/server.js @@ -30,6 +30,7 @@ const cal = require('./cal'); const tasks = require('./tasks'); const settings = require('./settings'); const inbox = require('./inbox'); +const board = require('./board'); const search = require('./search'); const users = require('./users'); const admin = require('./admin'); @@ -127,13 +128,14 @@ function serveStatic(req, res) { let p = decodeURIComponent(new URL(req.url, 'http://x').pathname); if (p === '/') p = '/index.html'; if (p === '/login') p = '/login.html'; + if (p === '/board') p = '/board.html'; const file = path.join(PUBLIC, path.normalize(p)); if (!file.startsWith(PUBLIC)) { res.writeHead(403); return res.end(); } fs.readFile(file, (err, data) => { if (err) { res.writeHead(404); return res.end('Not found'); } // The pages link to /app.js?v= etc. A new release changes the address, so no proxy cache ("Cache Assets" in // Nginx Proxy Manager, a CDN, the browser) can hand out an old copy. - if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/[\w-]+\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); + if (p === '/index.html' || p === '/login.html' || p === '/board.html') data = Buffer.from(String(data).replace(/(src|href)="(\/[\w-]+\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed @@ -603,20 +605,21 @@ async function handleRequest(req, res) { if (pathname === '/healthz') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end('{"ok":true}'); } if (crossSite(req) && pathname !== '/api/oo/callback') return forbidden(res, 'Cross-site request refused'); if (pathname.startsWith('/api/auth/')) return auth.handle(req, res); + if (pathname === '/api/board' || pathname.startsWith('/api/board/') || pathname === '/api/device' || pathname.startsWith('/api/device/')) return board.handle(req, res); // family devices: the board and picking a profile (each checks who is asking) if (pathname === '/api/mail/callback' && mail.outlookOn()) return mail.callback(req, res); // Microsoft sends the person back here; the one-time state says who they are if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session // Everything else needs a passkey session, except the sign-in page and its assets, // and OnlyOffice's save callback (which carries its own signed token). const user = auth.currentUser(req); if (user) auth.refresh(req, res, user); - const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback']; + const open = ['/login', '/login.js', '/board', '/board.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback']; if (!user) { if (open.includes(pathname) || pathname.startsWith('/icons/')) { if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return OO_OK ? ooCallback(req, res) : json(res, 403, { error: 1 }); return serveStatic(req, res); } if (/^\/(api|sf|plex)\//.test(pathname)) { res.writeHead(401, { 'content-type': 'application/json', 'x-hub-auth': 'required' }); return res.end('{"error":"Sign in required"}'); } - res.writeHead(302, { location: '/login' }); return res.end(); + res.writeHead(302, { location: auth.deviceOf(req) ? '/board' : '/login' }); return res.end(); } // From here on, everything runs "as" this person: their own folder, and only the tabs they were given. return users.runAs(user, async () => { diff --git a/tasks.js b/tasks.js index 63a00ad..ae966b7 100644 --- a/tasks.js +++ b/tasks.js @@ -187,4 +187,6 @@ async function allTasks() { return out; } -module.exports = { handle, parseTodos, allTasks, nextDue, stepDate }; +/** Open tasks on the shared Family list, soonest first, for the family board. */ +const familyOpen = () => { const t = readJ(famFile(), { tasks: [] }).tasks || []; return t.filter(x => !x.done && !x.parent).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title)).slice(0, 8).map(x => ({ id: x.id, title: x.title, due: x.due || '' })); }; +module.exports = { familyOpen, handle, parseTodos, allTasks, nextDue, stepDate }; diff --git a/tools/board-test.js b/tools/board-test.js new file mode 100644 index 0000000..1e3c786 --- /dev/null +++ b/tools/board-test.js @@ -0,0 +1,30 @@ +// Family devices: making one, the board, picking a profile (kids tap, others use a PIN), short sessions, and turning one off. +const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-board-')); process.env.COOKIE_SECRET = 'x'.repeat(40); +const users = require('../users'), auth = require('../auth'), board = require('../board'); +const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), kid = users.create({ name: 'Kai', role: 'user', tabs: ['chat'], handle: 'Yg' }), bob = users.create({ name: 'Bob', role: 'user', tabs: ['chat'], handle: 'Yw' }); +users.update(kid.id, { kid: { on: true } }); +const mac = v => crypto.createHmac('sha256', auth.secret).update(v).digest('base64url'); +const session = u => { const exp = Date.now() + 864e5; return `hub_session=${u.id}.${u.epoch}.${exp}.${mac(`s${u.id}.${u.epoch}.${exp}`)}`; }; +const hub = http.createServer((req, res) => board.handle(req, res)).listen(0, async () => { + const B = 'http://127.0.0.1:' + hub.address().port; let jar = {}; + const call = async (p, o = {}, cookie) => { const r = await fetch(B + p, { ...o, headers: { ...(cookie ? { cookie } : {}), ...(o.headers || {}) } }); return { status: r.status, json: await r.json().catch(() => ({})), set: r.headers.getSetCookie ? r.headers.getSetCookie() : [] }; }; + const post = (p, b, cookie) => call(p, { method: 'POST', body: JSON.stringify(b || {}) }, cookie); + try { + assert.equal((await call('/api/board')).status, 403, 'no board for a plain browser'); + assert.equal((await post('/api/device/enable', {}, session(bob))).status, 403, 'only an admin makes a family device'); + let r = await post('/api/device/enable', { name: 'Kitchen' }, session(ann)); assert.equal(r.status, 200); const dev = r.set.find(c => c.startsWith('hub_device=')).split(';')[0]; assert.ok(dev); + assert.ok(r.set.some(c => c.startsWith('hub_session=;') ), 'the admin is signed out of the device'); + r = await call('/api/board?day=2026-10-26', {}, dev); assert.equal(r.status, 200); assert.deepEqual(r.json.people.map(p => [p.name, p.kid]), [['Ann', false], ['Kai', true], ['Bob', false]]); + assert.equal((await call('/api/board', {}, 'hub_device=fake.sig')).status, 403, 'a forged device cookie is refused'); + r = await post('/api/board/switch', { uid: kid.id }, dev); assert.equal(r.status, 200, JSON.stringify(r.json)); const sess = r.set.find(c => c.startsWith('hub_session=')); assert.ok(/Max-Age=1800/.test(sess), 'a short session on a family device'); assert.equal(auth.currentUser({ headers: { cookie: sess.split(';')[0] } }).id, kid.id); + r = await post('/api/board/switch', { uid: bob.id }, dev); assert.equal(r.status, 403, 'an adult with no PIN cannot just tap'); assert.equal((await post('/api/board/switch', { uid: kid.id }, '')).status, 403, 'and only a family device can switch'); + 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 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.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'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); +});