From e0dd4c07215d9753e259501331b75b6ef6694382 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Mon, 14 Sep 2026 15:33:07 +0100 Subject: [PATCH] add shared computer feature --- frontend/src/lib/client/lock.ts | 53 +++ frontend/src/lib/client/shared-device.ts | 32 ++ .../src/lib/components/JoinPinFlow.svelte | 157 +++++++++ frontend/src/lib/components/PinPad.svelte | 110 +++++++ .../src/lib/components/SharedPicker.svelte | 305 ++++++++++++++++++ frontend/src/lib/components/icons.ts | 2 + frontend/src/lib/server/pins.ts | 50 +++ frontend/src/routes/[fam]/+layout.server.ts | 6 +- frontend/src/routes/[fam]/+layout.svelte | 108 ++++++- .../src/routes/[fam]/switch/+page.server.ts | 13 + frontend/src/routes/[fam]/switch/+page.svelte | 4 + .../src/routes/api/device/remove/+server.ts | 23 ++ frontend/src/routes/api/pins/+server.ts | 74 +++++ .../src/routes/api/switch-user/+server.ts | 84 +++++ shared-device.md | 123 +++++++ 15 files changed, 1137 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/client/lock.ts create mode 100644 frontend/src/lib/client/shared-device.ts create mode 100644 frontend/src/lib/components/JoinPinFlow.svelte create mode 100644 frontend/src/lib/components/PinPad.svelte create mode 100644 frontend/src/lib/components/SharedPicker.svelte create mode 100644 frontend/src/lib/server/pins.ts create mode 100644 frontend/src/routes/[fam]/switch/+page.server.ts create mode 100644 frontend/src/routes/[fam]/switch/+page.svelte create mode 100644 frontend/src/routes/api/device/remove/+server.ts create mode 100644 frontend/src/routes/api/pins/+server.ts create mode 100644 frontend/src/routes/api/switch-user/+server.ts create mode 100644 shared-device.md diff --git a/frontend/src/lib/client/lock.ts b/frontend/src/lib/client/lock.ts new file mode 100644 index 0000000..48f385b --- /dev/null +++ b/frontend/src/lib/client/lock.ts @@ -0,0 +1,53 @@ +// Shared-device idle lock (client-side). Tracks the last interaction +// timestamp in localStorage and exposes "should the app return to the profile +// picker?" checks. localStorage (not cookies/beacons) so the timestamp +// survives browser close, sleeps and restarts — the pagehide write is +// synchronous and can't be lost on tab close. + +const KEY = 'fam_last_active'; +const WRITE_THROTTLE_MS = 10_000; + +let installed = false; +let lastWrite = 0; + +export function recordActivity(force = false) { + if (typeof localStorage === 'undefined') return; + const now = Date.now(); + if (!force && now - lastWrite < WRITE_THROTTLE_MS) return; + try { + localStorage.setItem(KEY, String(now)); + lastWrite = now; + } catch { + /* private mode etc. — the lock simply has no data yet */ + } +} + +export function lastActiveAt(): number { + if (typeof localStorage === 'undefined') return 0; + const raw = localStorage.getItem(KEY); + const n = raw ? Number(raw) : 0; + return Number.isFinite(n) && n > 0 ? n : 0; +} + +export function lockDue(lockMins: number): boolean { + if (!lockMins || lockMins <= 0) return false; + const last = lastActiveAt(); + if (!last) return false; + return Date.now() - last > lockMins * 60_000; +} + +export function installLockTracking() { + if (installed || typeof document === 'undefined') return; + installed = true; + const on = () => recordActivity(); + document.addEventListener('click', on); + document.addEventListener('keydown', on); + document.addEventListener('touchstart', on); + window.addEventListener('pagehide', () => recordActivity(true)); +} + +// Fresh activation (join wizard, picker switch) — write now so the just-opened +// session doesn't instantly trip the mount-time lock check. +export function resetClock() { + recordActivity(true); +} diff --git a/frontend/src/lib/client/shared-device.ts b/frontend/src/lib/client/shared-device.ts new file mode 100644 index 0000000..aef8572 --- /dev/null +++ b/frontend/src/lib/client/shared-device.ts @@ -0,0 +1,32 @@ +// Per-device "this is a shared computer" flag. localStorage is the source of +// truth (shared-ness is a property of THIS browser, not the family — a DB +// flag would force PIN mode on every device including a parent's phone). +// A plain cookie mirror lets server loads see it too (localStorage never +// reaches the server). Neither is a security boundary: the PIN + device +// session cookies remain the actual gate (see shared-device.md). +const LS_KEY = 'fam_shared_device'; +const COOKIE = 'fam_shared_device'; + +export function isSharedDevice(): boolean { + if (typeof localStorage === 'undefined') return false; + try { + return localStorage.getItem(LS_KEY) === '1'; + } catch { + return false; + } +} + +export function setSharedDevice(on: boolean) { + try { + if (on) localStorage.setItem(LS_KEY, '1'); + else localStorage.removeItem(LS_KEY); + } catch { + /* private mode etc. — flag simply doesn't persist */ + } + if (typeof document !== 'undefined') { + document.cookie = + on + ? `${COOKIE}=1; path=/; max-age=31536000; SameSite=Lax` + : `${COOKIE}=; path=/; max-age=0; SameSite=Lax`; + } +} diff --git a/frontend/src/lib/components/JoinPinFlow.svelte b/frontend/src/lib/components/JoinPinFlow.svelte new file mode 100644 index 0000000..33054c1 --- /dev/null +++ b/frontend/src/lib/components/JoinPinFlow.svelte @@ -0,0 +1,157 @@ + + +{#if step === 'q'} +
+

Is this computer shared?

+

+ Will your brothers or sisters use this computer too? A 3-digit PIN lets you switch to your + chores in a tap. +

+
+ + +
+
+{:else if step === 'p1'} +
+

Pick a PIN

+

3 numbers that are easy for you to remember — you'll use it to switch.

+ {#key padKey} + { + pin1 = pin; + step = 'p2'; + }} + /> + {/key} + {#if status}

{status}

{/if} + +
+{:else if step === 'p2'} +
+

Confirm your PIN

+

+ Enter it once more — {pin1 ? `it starts with ${pin1[0]}·` : ''}your parent can always read it + out if you forget. +

+ {#key padKey} + + {/key} + {#if status}

{status}

{/if} + +
+{/if} + + diff --git a/frontend/src/lib/components/PinPad.svelte b/frontend/src/lib/components/PinPad.svelte new file mode 100644 index 0000000..9e46413 --- /dev/null +++ b/frontend/src/lib/components/PinPad.svelte @@ -0,0 +1,110 @@ + + +
+

{label}

+ +
+ {#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as d} + + {/each} + + +   +
+
+ + diff --git a/frontend/src/lib/components/SharedPicker.svelte b/frontend/src/lib/components/SharedPicker.svelte new file mode 100644 index 0000000..6746928 --- /dev/null +++ b/frontend/src/lib/components/SharedPicker.svelte @@ -0,0 +1,305 @@ + + +
+
+ {#if selected} +

Hi {selected.name}!

+

Enter your 3-digit PIN to switch.

+ {#key padKey} + switchTo(selected.id, pin)} /> + {/key} + {#if status} +

{status}

+ {/if} +
+ +
+ {:else} +

+ {standalone ? famName || 'Welcome' : "Who's using the computer?"} +

+

+ {standalone + ? 'Pick your profile to get to your chores.' + : 'Pick a profile and enter its PIN.'} +

+
+ {#each profiles as p (p.id)} +
(selectedId = p.id)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') selectedId = p.id; + }} + > + {initial(p.name)} + {p.name} + {#if p.id === activeId}current{/if} + +
+ {/each} +
+ {#if standalone} + + {:else} +
+ +
+ {/if} + {/if} +
+
+ + diff --git a/frontend/src/lib/components/icons.ts b/frontend/src/lib/components/icons.ts index 06b51c2..669536d 100644 --- a/frontend/src/lib/components/icons.ts +++ b/frontend/src/lib/components/icons.ts @@ -36,6 +36,8 @@ export const bellIcon = ''; export const chatIcon = ''; +export const monitorIcon = + ''; export const sendIcon = ''; export const checkCircleIcon = diff --git a/frontend/src/lib/server/pins.ts b/frontend/src/lib/server/pins.ts new file mode 100644 index 0000000..1c45a4c --- /dev/null +++ b/frontend/src/lib/server/pins.ts @@ -0,0 +1,50 @@ +import { randomBytes } from 'node:crypto'; +import { createSuperClient } from '$lib/server/pocketbase'; + +// Child shared-device PINs. Superuser-only collection (like `otp`): every +// read/write goes through server endpoints with session-role checks, so a +// child can never read a sibling's PIN via PB rules. +export const PIN_RE = /^\d{3}$/; + +export function generatePin(): string { + const n = randomBytes(2).readUIntBE(0, 2) % 1000; + return n.toString().padStart(3, '0'); +} + +async function getPinRow(userId: string) { + const pb = await createSuperClient(); + return pb + .collection('pins') + .getFirstListItem(`userId='${userId}'`) + .catch(() => null); +} + +export async function getPin(userId: string): Promise { + const row = await getPinRow(userId); + return row?.pin || null; +} + +export async function hasPin(userId: string): Promise { + return (await getPin(userId)) !== null; +} + +export async function setPin(famId: string, userId: string, pin: string): Promise { + const pb = await createSuperClient(); + const row = await getPinRow(userId); + if (row) { + await pb.collection('pins').update(row.id, { pin }); + } else { + await pb.collection('pins').create({ famId, userId, pin }); + } +} + +export async function resetPin(famId: string, userId: string): Promise { + const pin = generatePin(); + await setPin(famId, userId, pin); + return pin; +} + +export async function verifyPin(userId: string, pin: string): Promise { + const row = await getPinRow(userId); + return !!row && row.pin === pin; +} diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index bd045b9..3a2e12d 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -194,6 +194,10 @@ export async function load(event) { pickerFamName: '', pickerChildren: [], deviceChildIds, - lockMins + lockMins, + // Per-device shared-computer flag (cookie mirror of the localStorage + // flag the TopNav toggle writes). Server can only hint — the client + // re-reads localStorage on hydration. + sharedDevice: event.cookies.get('fam_shared_device') === '1' }; } diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index cd21f1f..2f545c3 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -7,9 +7,10 @@ import { chatStore } from '$lib/stores/chat.svelte'; import { notices } from '$lib/stores/notices.svelte'; import { Sidebar, TopNav, Footer, Chat, SharedPicker } from '$lib/components'; - import { chatIcon } from '$lib/components/icons'; + import { chatIcon, monitorIcon } from '$lib/components/icons'; import { recordShortcut } from '$lib/shortcut'; import { installLockTracking, lockDue } from '$lib/client/lock'; + import { isSharedDevice, setSharedDevice } from '$lib/client/shared-device'; import { themeShades } from '$lib/theme'; import '$lib/theme-patterns.css'; import { themeDraft } from '$lib/stores/theme.svelte'; @@ -43,13 +44,59 @@ .map((m: any) => ({ id: m.id, name: m.name, color: m.color, username: m.username })) ); - // Idle lock (children only): return to the picker after `lockMins` of no - // interaction. localStorage-backed (see lib/client/lock.ts) so a closed - // browser still trips the lock on next launch. + // Shared-computer mode (per-device flag, NOT a fam setting — see + // lib/client/shared-device.ts). Makes the PIN system functional on this + // browser: profile switcher + idle lock. Anyone logged in (or not) can + // flip it; server sees the cookie mirror as data.sharedDevice. + let sharedOn = $state( + typeof localStorage !== 'undefined' ? isSharedDevice() : !!(data as any).sharedDevice + ); + let sharedToast = $state(''); + + async function toggleShared() { + sharedOn = !sharedOn; + setSharedDevice(sharedOn); + if (!sharedOn) { + sharedToast = ''; + return; + } + // Just enabled — remind about PIN state. Kids without a PIN can't be + // picked until one is set (picker shows "ask a parent"); kids with one + // get a nudge to remember it. Values never exposed — only set/unset. + try { + const res = await fetch('/api/pins/status'); + if (!res.ok) throw new Error(); + const s = await res.json(); + if (Array.isArray(s.children)) { + // Parent view: roster of who still needs a PIN. + const missing = s.children.filter((c: any) => !c.hasPin).map((c: any) => c.name); + const ready = s.children.filter((c: any) => c.hasPin).map((c: any) => c.name); + sharedToast = + missing.length > 0 + ? `Shared mode on — still need a PIN: ${missing.join(', ')} (set in Settings → Members).` + + (ready.length > 0 ? ` Ready: ${ready.join(', ')}.` : '') + : `Shared mode on — PINs ready for ${ready.length > 0 ? ready.join(', ') : 'everyone'}. Remind the kids!`; + } else if (typeof s.hasPin === 'boolean') { + // Child view: only their own state. + sharedToast = s.hasPin + ? 'Shared mode on — remember your 3-digit PIN to switch back in!' + : 'Shared mode on — you need a PIN first. Ask a parent to set one up.'; + } else { + sharedToast = 'Shared mode on for this computer.'; + } + } catch { + sharedToast = 'Shared mode on for this computer.'; + } + setTimeout(() => (sharedToast = ''), 8000); + } + + // Idle lock (children on shared devices only): return to the picker after + // `lockMins` of no interaction. localStorage-backed (see lib/client/lock.ts) + // so a closed browser still trips the lock on next launch. $effect(() => { const isChild = data.session?.role === 'child'; const lockMins = Number(data.lockMins) || 0; - if (!isChild || lockMins <= 0 || data.picker) return; + if (!isChild || !sharedOn || lockMins <= 0 || data.picker) return; installLockTracking(); if (lockDue(lockMins)) pickerOpen = true; if (!lockTimer) { @@ -258,7 +305,7 @@ {chatStore.unread > 9 ? '9+' : chatStore.unread} {/if} - {#if data.session && (data.deviceChildIds?.length || 0) > 0} + {#if data.session && sharedOn && (data.deviceChildIds?.length || 0) > 0} {/if} +
@@ -304,6 +361,9 @@ {claimToast} — view dashboard → {/if} + {#if sharedToast} +
{sharedToast}
+ {/if}
{#if chatStore.open} @@ -491,6 +551,42 @@ border: 2px solid #fff; box-shadow: 0 0 0 1px #e2e8f0; } + .shared-toggle { + width: 40px; + height: 40px; + border: none; + border-radius: 10px; + background: #f3f4f6; + color: #9ca3af; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + .shared-toggle:hover { + background: #e5e7eb; + } + .shared-toggle.on { + background: #6366f1; + color: #fff; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.25); + } + .shared-toast { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + background: #1f2937; + color: #fff; + padding: 0.6rem 1.2rem; + border-radius: 12px; + font-size: 0.85rem; + font-weight: 600; + z-index: 96; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3); + max-width: min(92vw, 560px); + text-align: center; + } .picker-stage { min-height: 100vh; width: 100%; diff --git a/frontend/src/routes/[fam]/switch/+page.server.ts b/frontend/src/routes/[fam]/switch/+page.server.ts new file mode 100644 index 0000000..d3f1780 --- /dev/null +++ b/frontend/src/routes/[fam]/switch/+page.server.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; + +// The fam layout renders the shared-device picker when child sessions exist +// but none is active. With an active session this route just sends you home. +export async function load(event) { + const session = event.locals.user; + if (session) { + const fam = event.params.fam; + if (session.role === 'parent') throw redirect(303, `/${fam}`); + throw redirect(303, `/${fam}/${encodeURIComponent(session.username || '')}`); + } + return {}; +} diff --git a/frontend/src/routes/[fam]/switch/+page.svelte b/frontend/src/routes/[fam]/switch/+page.svelte new file mode 100644 index 0000000..08f21a8 --- /dev/null +++ b/frontend/src/routes/[fam]/switch/+page.svelte @@ -0,0 +1,4 @@ + +
diff --git a/frontend/src/routes/api/device/remove/+server.ts b/frontend/src/routes/api/device/remove/+server.ts new file mode 100644 index 0000000..0720624 --- /dev/null +++ b/frontend/src/routes/api/device/remove/+server.ts @@ -0,0 +1,23 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { + ACTIVE_COOKIE, + childSessionCookie, + clearChildSession, + clearActiveChild +} from '$lib/server/session'; + +// Remove ONE child profile from this device (picker ✕). Device-local cookie +// surgery only — no PB writes, no session required (the picker is reachable +// with no active user). +export async function POST(event: RequestEvent) { + const body = await event.request.json().catch(() => ({})); + const { userId } = body as { userId?: string }; + if (!userId) throw error(400, 'Missing userId'); + if (!event.cookies.get(childSessionCookie(userId))) { + throw error(400, 'No session on this device'); + } + clearChildSession(event.cookies, userId); + if (event.cookies.get(ACTIVE_COOKIE) === userId) clearActiveChild(event.cookies); + return json({ ok: true }); +} diff --git a/frontend/src/routes/api/pins/+server.ts b/frontend/src/routes/api/pins/+server.ts new file mode 100644 index 0000000..7b3b2d2 --- /dev/null +++ b/frontend/src/routes/api/pins/+server.ts @@ -0,0 +1,74 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { getPin, setPin, verifyPin, hasPin, PIN_RE } from '$lib/server/pins'; +import { createPbClient } from '$lib/server/pocketbase'; + +// PIN status for the shared-device toggle reminders. Never reveals values: +// - child → whether THEIR OWN pin is set (about self only); +// - parent → per-child set/unset roster (names + booleans, no PIN values; +// actual values stay behind the settings revealPin action). +export async function GET(event: RequestEvent) { + const u = event.locals.user; + if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); + if (u.role === 'child') { + return json({ hasPin: await hasPin(u.id) }); + } + if (u.role !== 'parent') throw error(403, 'Forbidden'); + const pb = createPbClient(event.locals.pbToken); + const kids: any[] = await pb + .collection('users') + .getFullList({ filter: `famId = '${u.famId}' && role = 'child'` }) + .catch(() => []); + const children = await Promise.all( + kids.map(async (k: any) => ({ + userId: k.id, + name: k.name || '?', + hasPin: await hasPin(k.id) + })) + ); + return json({ children }); +} + +// Child PIN management. Session-role checked here (PB rules are superuser-only +// on `pins`): only the child themselves can set/change their own PIN. +export async function POST(event: RequestEvent) { + const u = event.locals.user; + if (!u) throw error(401, 'Unauthorized'); + if (u.role !== 'child') throw error(403, 'Only children use PINs'); + + const body = await event.request.json().catch(() => ({})); + const { action, pin, currentPin } = body as { + action?: string; + pin?: string; + currentPin?: string; + }; + if (!action || !pin || !PIN_RE.test(pin)) { + throw error(400, 'PIN must be exactly 3 digits'); + } + + if (action === 'set') { + if (await getPin(u.id)) throw error(400, 'PIN already set'); + await setPin(u.famId, u.id, pin); + return json({ ok: true }); + } + + // Join wizard: assign the PIN on THIS device without touching an existing + // one (a kid joining a second shared device already has a PIN — their pin + // works everywhere; nobody can silently reassign it). + if (action === 'ensure') { + if (!(await getPin(u.id))) await setPin(u.famId, u.id, pin); + return json({ ok: true }); + } + + if (action === 'change') { + const existing = await getPin(u.id); + if (!existing) throw error(400, 'No PIN set yet'); + if (!currentPin || !(await verifyPin(u.id, currentPin))) { + throw error(401, 'Current PIN is wrong'); + } + await setPin(u.famId, u.id, pin); + return json({ ok: true }); + } + + throw error(400, 'Unknown action'); +} diff --git a/frontend/src/routes/api/switch-user/+server.ts b/frontend/src/routes/api/switch-user/+server.ts new file mode 100644 index 0000000..3394e4d --- /dev/null +++ b/frontend/src/routes/api/switch-user/+server.ts @@ -0,0 +1,84 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { createPbClient, createSuperClient } from '$lib/server/pocketbase'; +import { childSessionCookie, setChildSessionCookie, setActiveChild } from '$lib/server/session'; +import { verifyPin } from '$lib/server/pins'; +import { derivePassword } from '$lib/server/member-otp'; +import { famUsername } from '@shared/slugify'; + +// Shared-device PIN switch. The PIN *selects* among sessions that already +// exist on this device — it is not a credential that mints anything on a +// fresh device. The target must have a pb_token_ cookie; the PIN is +// verified server-side via the superuser client (children must never read +// siblings' pins), with a small per-user rate limit on the 3-digit space. + +const MAX_TRIES = 5; +const LOCK_MS = 30_000; +const tries = new Map(); + +function checkRateLimit(userId: string) { + const rec = tries.get(userId); + if (rec && rec.until > Date.now()) { + throw error(429, 'Too many attempts — try again in a few seconds'); + } +} + +function noteFailure(userId: string) { + const rec = tries.get(userId) || { fails: 0, until: 0 }; + rec.fails += 1; + if (rec.fails >= MAX_TRIES) { + rec.until = Date.now() + LOCK_MS; + rec.fails = 0; + } + tries.set(userId, rec); +} + +export async function POST(event: RequestEvent) { + const body = await event.request.json().catch(() => ({})); + const { userId, pin } = body as { userId?: string; pin?: string }; + if (!userId || !pin) throw error(400, 'Missing userId or pin'); + if (!event.cookies.get(childSessionCookie(userId))) { + throw error(400, 'That profile has no session on this device'); + } + + checkRateLimit(userId); + if (!(await verifyPin(userId, String(pin)))) { + noteFailure(userId); + throw error(401, 'Wrong PIN — try again'); + } + + // Ensure a usable token: refresh the device cookie; if it has expired, + // re-mint via the derived password (server-side only — the child never + // knows it, and a stale session heals itself on switch). + const pb = await createSuperClient(); + const user = await pb + .collection('users') + .getOne(userId) + .catch(() => null); + if (!user || user.role !== 'child') throw error(400, 'Not a child account'); + + let freshToken = ''; + try { + const cookieToken = event.cookies.get(childSessionCookie(userId)); + if (cookieToken) { + const { token } = await createPbClient(cookieToken).collection('users').authRefresh(); + freshToken = token; + } + } catch { + /* expired — re-mint below */ + } + if (!freshToken) { + // username = `{famSlug}:{handle}` — the server can always re-mint. + const [famSlug, handleName] = (user.username || '').split(':'); + if (!famSlug || !handleName) throw error(400, 'Cannot restore session'); + const auth = createPbClient(); + const { token } = await auth + .collection('users') + .authWithPassword(famUsername(famSlug, handleName), derivePassword(famSlug, handleName)); + freshToken = token; + } + + setChildSessionCookie(event.cookies, userId, freshToken); + setActiveChild(event.cookies, userId); + return json({ ok: true }); +} diff --git a/shared-device.md b/shared-device.md new file mode 100644 index 0000000..71b2d9f --- /dev/null +++ b/shared-device.md @@ -0,0 +1,123 @@ +# Shared-device switching (PIN) + +Design decisions for children sharing a family computer/tablet — recorded +2026-09-11. Implements: multi-session cookies, `pins` collection, profile +picker, and the idle lock. + +## Model + +- **The PIN is NOT a login.** It selects among sessions that already exist on + the device. A PIN alone mints nothing on a fresh device; a stolen cookie + alone selects nothing without the PIN. Threat model: sibling mischief on a + family device (devtools-level bypass is explicitly accepted — data is + family-scoped and actions are reversible). +- Per-device state lives in cookies: + - `pb_token_` — one httpOnly cookie per child with a session on + this device (the "device is logged into both accounts" property). + - `pb_active` — which child session is currently active. + - `pb_token` — unchanged single session for parents (and legacy children). + Parent login supersedes kid mode by clearing `pb_active`; a kid switch + shadows (but does not delete) a parent session; resolving order is + `pb_active` first, then `pb_token`. +- Sessions are never the security boundary: the OTP gate still guards each + child's first-ever join on a device, and switching re-mints an expired token + server-side via the derived password (never exposed to the client). + +## `pins` collection + +- Superuser-only rules (like `otp`); all reads/writes via server endpoints + with session-role checks. Plaintext 3-digit PIN — deliberately recoverable + so a parent can read it out (Q1: View, not reset). Child changes their own + PIN in Preferences (requires current PIN). +- Fields: `famId` (rel), `userId` (rel), `pin` (text). + +## Switch flow + +`POST /api/switch-user { userId, pin }`: + +1. Request must carry `pb_token_` (the profile must be on this + device). No session required — the picker is reachable with no active user. +2. PIN verified server-side via superuser (children must NOT be able to read + each other's pins, so collection rules can't be the check). +3. In-memory rate limit: 5 fails → 30s lockout per userId (3-digit space). +4. Refresh the device cookie's token; if expired, re-auth with the derived + password (heals sessions on switch). +5. Set `pb_token_` + `pb_active`; client full-reloads to that child's + dashboard URL (`/famSlug/handle`). + +Per-profile removal: `POST /api/device/remove { userId }` — deletes that +cookie (+ `pb_active` if it was active). No session required (device-local +cookie surgery only). + +## Picker + +One component (`SharedPicker`) used everywhere: + +- TopNav switcher button (always visible when the device has child sessions). +- Idle-lock return (overlay). +- `/{famSlug}/switch` — standalone landing when the device has child sessions + but no active session (e.g. after per-profile logout). The fam layout + redirects unauthenticated device requests to it. Links: "Add a child with a + code" → `/{famSlug}/join`, "Parent login" → `/login`. + +Every pick (including "resume" as the current kid) requires the PIN. + +## Idle lock + +- Default 10 min; parent-adjustable in Family Settings: `Off / 2 / 10` + (`settings.lockMins`, 0 = off; `shared-device.md` settled default 10). +- Client-side only, child sessions only (parent sessions never lock). +- **Only fires when shared-computer mode is ON for the device** (see below) — + a personal device never locks, even with lockMins set. +- `localStorage['fam_last_active']` updated on click/key/touch (throttled + 10s) and force-written on `pagehide` — survives browser close, sleep and + restarts. Next launch compares elapsed time; past the timeout → picker. + Live timer (15s interval) does the same mid-session. +- Resets: join wizard and picker switch write a fresh timestamp before + navigating, so freshly-activated sessions don't instantly lock. + +## Shared-computer toggle (TopNav) + +- **Storage: localStorage, NOT the DB.** Shared-ness is a property of *this + browser* — a DB flag on `fams`/`settings` would force PIN mode on every + device including a parent's phone. `localStorage['fam_shared_device']` + is the source of truth; a plain `fam_shared_device=1` cookie mirrors it + so server loads see it (`data.sharedDevice`). Neither is a security + boundary — the PIN + device session cookies remain the gate. +- **Location:** monitor icon in the TopNav (fam layout children slot, next + to the profile switcher). Rendered for any role, even logged out — + whoever holds the device can mark it shared. Active state: indigo fill. +- **What it gates:** + - Profile switcher (`kid-switch`) button — only shown when shared mode + is ON and the device holds child sessions. + - Idle lock — only fires for child sessions on shared devices. + - (Server `/switch` redirect unchanged — the picker is harmless on + - non-shared devices and the server can't do better without the cookie.) +- **PIN reminders on enable** (`GET /api/pins/status`, set/unset only — + values never leave the server except via the settings `revealPin` + action): + - Parent toggling sees the roster: who still needs a PIN (→ Settings → + Members) vs who is ready (→ read the PINs out to them). + - A child toggling sees only their own state: "remember your PIN" or + - "ask a parent to set one up" (no PIN → picker shows "ask a parent"). +- Client flag helpers: `lib/client/shared-device.ts` + (`isSharedDevice` / `setSharedDevice`, writes the cookie mirror too). + +## Join flow (child) + +After OTP redeem: + +1. If the device already has other child sessions → PIN setup (required). +2. Else ask "Will this computer be shared with a sibling?" — yes → PIN setup; + no → skip (PIN can be added later in Preferences; parents can set it in + Family Settings). +3. Land on the child's dashboard. + +Parents keep the existing join (set own password, single session). + +## Edge cases + +- Two tabs share the timestamp → the active tab keeps the other from locking. +- Tab switches don't lock (kids alt-tab constantly). +- Expired sessions: the picker still shows the profile; switching re-mints. +- No PIN set on a profile → picker shows "ask a parent to set it up".