{#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".