diff --git a/README.md b/README.md index 4671963..0b96cb9 100644 --- a/README.md +++ b/README.md @@ -196,7 +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 admin picks who appears on the board (a **Show on the family board** switch in each person's settings; people left off do not get a tile or show in family chore progress). The Family calendar and Family task list are shared by everyone and appear on the board. +**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. Chores on the board are tappable: tap one (up-for-grabs chores ask *I did it* or *Take it for later*), tap your name, and enter your PIN if you are not a child; the chore is then done or claimed as you, with all the usual chore rules, without signing in. The admin picks who appears on the board (a **Show on the family board** switch in each person's settings; people left off do not get a tile or show in family chore progress). 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/board.js b/board.js index 93af99f..fe3347a 100644 --- a/board.js +++ b/board.js @@ -5,6 +5,7 @@ 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 { Readable } = require('stream'); 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 }); @@ -28,23 +29,33 @@ const palette = ['#e0742b', '#2f9e6e', '#4f7cff', '#8a5cf0', '#d9453a', '#2aa6a0 const onBoard = u => !u.disabled && u.board !== false; // the admin picks who is on the family board const people = () => users.all().filter(onBoard).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), color: palette[i % palette.length] })); +/** Check it is really this person: a child with no PIN just taps their name; everyone else needs their PIN. */ +function identify(t, pin, req) { + if (isKid(t) && !hasPin(t.id)) return; + 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, pin)) { audit.log('family device: wrong PIN', { who: t.name }, req); throw fail(401, 'That PIN is not right'); } +} 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).filter(i => !i.user || onBoard(users.get(i.user) || {})).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() }; + return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), 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/chore' && req.method === 'POST') { // a person on the shared screen takes a chore, ticks it off, or undoes it, without signing in + 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 || !onBoard(t)) throw fail(404, 'Person not found'); if (!users.can(t, 'chores')) throw fail(403, 'Chores are not turned on for that person'); identify(t, b.pin); + const path = { claim: '/api/chores/claim', unclaim: '/api/chores/claim', done: '/api/chores/done', undo: '/api/chores/done' }[b.action]; if (!path) throw fail(400, 'Unknown action'); + const body = JSON.stringify({ chore: b.chore, date: ymdOk(b.date) ? b.date : undefined, undo: b.action === 'unclaim' || b.action === 'undo' || undefined }), r = Readable.from([Buffer.from(body)]); r.url = path; r.method = 'POST'; r.headers = {}; + return users.runAs(t, () => chores.handle(r, res)); // (as that person: all of the chore rules apply to them) + } 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 || !onBoard(t)) 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'); } - } + 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 }); } diff --git a/public/board.html b/public/board.html index 5331b9c..ab4b2a7 100644 --- a/public/board.html +++ b/public/board.html @@ -65,6 +65,7 @@ #ov { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: grid; place-items: center; padding: 18px; } #ov[hidden] { display: none; } #ovb { background: var(--panel); border: 1px solid var(--line); border-radius: 20px; padding: 20px; width: 100%; max-width: 380px; text-align: center; } .err { color: var(--danger); min-height: 20px; } + .chrow { width: 100%; background: none; border: 0; border-bottom: 1px solid var(--line); color: var(--text); font: inherit; font-size: 17px; text-align: left; cursor: pointer; } .chrow .chk { width: 24px; height: 24px; border: 2px solid var(--line); border-radius: 7px; display: grid; place-items: center; flex: none; font-size: 15px; } .chrow.dn .chk { background: var(--accent); border-color: var(--accent); color: #fff; } .chrow.dn .g { text-decoration: line-through; opacity: .6; } diff --git a/public/board.js b/public/board.js index c077341..be1c56d 100644 --- a/public/board.js +++ b/public/board.js @@ -15,14 +15,17 @@ function draw() { $('#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(''); + const row = c => ``; + const pool = d.chores.filter(c => c.claimable && !c.done), per = {}; d.chores.filter(c => !(c.claimable && !c.done)).forEach(c => { (per[c.userName] = per[c.userName] || []).push(c); }); + const chores = (pool.length ? `Up for grabs: tap to take one${pool.map(row).join('')}` : '') + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length; return `
${esc(k)}${dn} of ${l.length}
${l.map(row).join('')}
`; }).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')); + wireChores(); } +function wireChores() { document.querySelectorAll('.chrow').forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c))); } 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) { @@ -40,3 +43,25 @@ function pick(p) { } clock(); setInterval(clock, 15000); load(); setInterval(() => { if (!document.hidden && $('#ov').hidden) load(); }, 60000); document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); }); + +// ---- tap a chore: (if it is up for grabs, choose what you are doing), pick your name, PIN if you are not a child, then it happens as you +const avatar = p => `${esc((p.name.trim()[0] || '?').toUpperCase())}`; +function sheet(html) { const ov = $('#ov'); ov.hidden = false; $('#ovb').innerHTML = html; return { box: $('#ovb'), close: () => { ov.hidden = true; $('#ovb').innerHTML = ''; } }; } +function choreTap(c) { + if (!c) return; const title = `

${esc(c.title)}

`; + const who = action => { // step: which person + const list = c.user ? data.people.filter(p => p.id === c.user) : data.people, { box, close } = sheet(`${title}

${c.user && !c.done ? 'This one is ' + esc(c.userName) + "'s. Tap your name" : 'Who are you?'}

${list.map(p => ``).join('')}

`); + box.querySelector('#no').onclick = close; box.querySelectorAll('.tile').forEach(t => t.onclick = () => proof(data.people.find(p => p.id === t.dataset.id), action, close)); + }; + const proof = (p, action, close) => { // step: PIN if needed, then do it + const send = async pin => { const r = await fetch('/api/board/chore', { method: 'POST', body: JSON.stringify({ uid: p.id, pin, chore: c.chore, action, date: ymd(new Date()) }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'That did not work'); close(); await load(); }; + if (p.kid && !p.pin) return send().catch(e => { const x = sheet(`${title}

${esc(e.message)}

`); x.box.querySelector('#no').onclick = x.close; }); + if (!p.pin) { const x = sheet(`${title}

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

`); x.box.querySelector('#no').onclick = x.close; return; } + let pin = ''; const { box } = sheet(`${title}

${esc(p.name)}, enter your PIN

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

`); + const paint = () => { box.querySelector('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => ``).join(''); }; paint(); box.querySelector('#no').onclick = close; + box.querySelectorAll('.pad button').forEach(b => b.onclick = async () => { const k = b.dataset.k; box.querySelector('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k === '✓') { try { await send(pin); return; } catch (e) { box.querySelector('#e').textContent = e.message; pin = ''; } } else if (pin.length < 8) pin += k; paint(); }); + }; + if (c.done) return who('undo'); + if (c.claimable) { const { box, close } = sheet(`${title}

${c.points ? c.points + ' points' : 'Up for grabs'}

`); box.querySelector('#no').onclick = close; box.querySelector('#did').onclick = () => who('done'); box.querySelector('#take').onclick = () => who('claim'); return; } + who('done'); +} diff --git a/public/style.css b/public/style.css index 93177c7..8e2db8f 100644 --- a/public/style.css +++ b/public/style.css @@ -241,7 +241,7 @@ body.onaudio #view { flex: none; overflow: visible; padding-bottom: 0; } .cday .chips small { color: var(--muted); font-size: 11px; text-align: left; padding-left: 6px; } } -select, textarea, input[type=date], input[type=datetime-local], input[type=time] { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 16px; color: var(--text); } +select, textarea, input[type=number], input[type=date], input[type=datetime-local], input[type=time] { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 16px; color: var(--text); } textarea { padding: 12px 14px; resize: vertical; font-family: inherit; } .chk { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 16px; margin: 14px 0 0; } .chk input { width: 22px; height: 22px; } diff --git a/public/sw.js b/public/sw.js index c48bedb..8497f00 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-v74'; +const CACHE = 'hub-v75'; 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/tools/board-test.js b/tools/board-test.js index 54cdd9b..14b8b82 100644 --- a/tools/board-test.js +++ b/tools/board-test.js @@ -1,12 +1,12 @@ // 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' }); +const users = require('../users'), auth = require('../auth'), board = require('../board'), chores = require('../chores'); +const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), kid = users.create({ name: 'Kai', role: 'user', tabs: ['chat', 'chores'], handle: 'Yg' }), bob = users.create({ name: 'Bob', role: 'user', tabs: ['chat', 'chores'], 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 hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ? users.runAs(ann, () => chores.handle(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); @@ -19,6 +19,11 @@ const hub = http.createServer((req, res) => board.handle(req, res)).listen(0, as users.update(bob.id, { board: false }); r = await call('/api/board?day=2026-10-26', {}, dev); assert.deepEqual(r.json.people.map(p => p.name), ['Ann', 'Kai'], 'only the people the admin picks are on the board'); assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '2468' }, dev)).status, 404); users.update(bob.id, { board: true }); 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); + const ch = (await post('/api/chores', { title: 'Sweep', repeat: 'daily', assign: 'anyone', points: 4 })).json, today = new Date().toISOString().slice(0, 10), act = (uid, action, pin) => post('/api/board/chore', { uid, chore: ch.id, action, pin, date: today }, dev); + let cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.claimable, true, 'the board shows the chore up for grabs'); + assert.equal((await act(kid.id, 'claim')).status, 200, 'Kai takes it with one tap'); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.claimed, true); assert.equal(cb.userName, 'Kai'); + assert.equal((await act(bob.id, 'done')).status, 403, 'Bob has no PIN, so he cannot act as himself yet'); assert.equal((await act(kid.id, 'done')).status, 200); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.done, true); assert.equal(cb.doneBy, 'Kai'); + assert.equal((await act(kid.id, 'undo')).status, 200); assert.equal((await post('/api/board/chore', { uid: kid.id, chore: ch.id, action: 'done' }, '')).status, 403, 'only a family device can do this'); assert.equal((await act(kid.id, 'nope')).status, 400); 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);