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 => `${c.user && !c.done ? 'This one is ' + esc(c.userName) + "'s. Tap your name" : 'Who are you?'}
${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
${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);