From 7003609afe97afb9feb0b646c5663e840174edea Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Sat, 12 Sep 2026 08:45:13 +0100 Subject: [PATCH] add shared login pin mechansim --- AGENTS.md | 22 +- MEMORY.md | 32 +- frontend/src/hooks.server.ts | 79 +++- frontend/src/lib/components/index.ts | 3 + frontend/src/lib/server/member-otp.ts | 4 +- frontend/src/lib/server/migrate.ts | 54 +++ frontend/src/lib/server/session.ts | 58 ++- frontend/src/routes/+layout.server.ts | 9 +- frontend/src/routes/[fam]/+layout.server.ts | 83 +++- frontend/src/routes/[fam]/+layout.svelte | 208 ++++++--- .../routes/[fam]/[username]/+page.server.ts | 7 +- .../[username]/preferences/+page.server.ts | 23 +- .../[fam]/[username]/preferences/+page.svelte | 129 +++++ .../[fam]/[username]/settings/+page.server.ts | 72 ++- .../[fam]/[username]/settings/+page.svelte | 110 ++++- .../src/routes/[fam]/join/+page.server.ts | 23 +- frontend/src/routes/[fam]/join/+page.svelte | 102 ++-- .../[fam]/join/[username]/+page.server.ts | 31 +- .../routes/[fam]/join/[username]/+page.svelte | 146 +++--- frontend/src/routes/join/+page.server.ts | 24 +- frontend/src/routes/join/+page.svelte | 128 ++--- frontend/src/routes/login/+page.server.ts | 4 +- frontend/src/routes/logout/+page.server.ts | 4 +- shared/pb/schema.ts | 440 ++++++++++-------- 24 files changed, 1302 insertions(+), 493 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e014d4..09ced3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,14 +19,14 @@ ## Auth -| Role | Auth | Session | Record in | -| -------------- | --------------------------------------------- | -------------------------- | ------------------------- | -| Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) | -| Member (child) | Invite OTP + server-derived password | httpOnly `pb_token` cookie | `users` (role `child`) | -| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — | +| Role | Auth | Session | Record in | +| -------------- | ----------------------------------------------- | ------------------------------------------------ | ----------------------- | +| Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) | +| Member (child) | Invite OTP + server-derived password | Shared-device: `pb_token_` + `pb_active` | `users` (role `child`) | +| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — | - **Admins** (parents) are `users` records (role `parent`). They authenticate via email/password login, get an httpOnly `pb_token` cookie with `{ id, name, username, role: "parent", famId, color }`. -- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. They get the same httpOnly `pb_token` cookie. There is **no `members` collection**. +- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. **Shared-computer sessions:** children keep ONE httpOnly cookie per account (`pb_token_`, set at join) + a `pb_active` cookie naming the current session; a 3-digit PIN _selects_ among those device sessions (see `shared-device.md`) — it is NOT a login and mints nothing on a fresh device. Parents stay on the single `pb_token`. Resolution order in hooks: `pb_active` → `pb_token`. There is **no `members` collection**. - **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard) and OTP/signup writes. Not an app role. - The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`. - Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`). @@ -35,6 +35,7 @@ - `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only) - `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour lives on `users.color`) +- `pins` — famId, userId, pin (superuser-only shared-device PINs, plaintext so parents can read them out; all access via server endpoints) - `accesscodes` — value (unique), name, duration, expiry, active, createdAt (superuser-only; platform access codes) - `platform` — label (`global` singleton), flags (json) — platform feature flags; **public read** (empty list/view rules), superuser-only writes. Loaded on every page via root `+layout.server.ts` as `page.data.platformFlags`; toggle via `/admin` Platform Flags card. The `debug` flag gates dev-only CTAs (e.g. settings "Revoke code"). - `fams` — name, slug, stripeCustomerId, paymentMode (`none|code|sub|canceled`), active, accessCodeId, accessCodeEnteredAt @@ -46,7 +47,7 @@ - `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId - `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl -> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp` collection are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot. +> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp`/`pins` collections are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`/`ensurePins`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot. ## Routes @@ -56,6 +57,7 @@ /login · /logout Parent email/password login / logout /signup Parent + family signup (wizard: fam → child → code → plan) /{fam}/join/{username} Member invite (OTP join), auto-fills from ?code= +/{fam}/switch Shared-device profile picker (standalone landing when child sessions exist but none active) /{fam} Fam dashboard /{fam}/{username} Parent → admin overview, Child → member kanban (role from session) /{fam}/{username}/chores Chore templates & assignment grid @@ -157,10 +159,10 @@ let configs = $derived( Two patterns based on who's acting: -| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth | -| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- | +| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth | +| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ---------------------------------- | | Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `Authorization: Bearer ` | -| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `pb_token` cookie | +| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `pb_token` cookie | **Member direct fetch** — optimistic UI via local state mutation, reconciled on response: diff --git a/MEMORY.md b/MEMORY.md index 4534100..e7ef32b 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -20,6 +20,7 @@ ## UI Component Architecture (Jul 2026) ### Layout Hierarchy + ``` +layout.svelte ← global styles, meta, favicon ├── /login, /signup, /join/* ← auth pages (no shell) @@ -34,6 +35,7 @@ ``` ### Sidebar (collapsible to mini-mode) + - Header: app name (FamDone) - Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses - Member CTAs: Dashboard, Preferences @@ -41,10 +43,12 @@ - Role-aware: items differ based on admin vs member route ### TopNav + - Slot `announcement` (center) — system/family messages - Slot `actions` (right) — user status, claim/message ### Page Content + - `ViewHeader` — title + subtitle + tool bar (tabs, weeknav, sort) - `CardGrid` — 3-column grid, Cards span columns via `cols` prop - `Card` — 1/2/3 col span, micro-layout per page @@ -52,6 +56,7 @@ - `Button` — consistent CTAs with `variant` (primary/secondary/ghost/danger) and `size` (sm/md/lg) ### Components (frontend/src/lib/components/) + - `Sidebar.svelte`, `TopNav.svelte`, `Footer.svelte` - `ViewHeader.svelte`, `Card.svelte`, `CardGrid.svelte` - `Button.svelte`, `Accordion.svelte` @@ -177,14 +182,12 @@ - **Admin dashboard stat tiles**: added 4 gradient tiles (members / points / cash / chores done) reusing the child `.tiles`/`.tile` pattern + new `.tile-members`/`.tile-chores` colors, from a new `adminTiles` derived summing `summary.summaries`. Also fixed admin subtitle `Week of {YYYY-MM-DD}` → `Week of {DDMMYY}`. - **Check**: frontend `svelte-check` stays at 12 baseline errors. Note: frontend dev server on :2080 was not running when verified (proxy :3456 up). - - ### 2026-08-06 — Env Consolidation: `SERVER_IP`, `PROXY_URL`, and SvelteKit env only - **`config.ts` is proxy-only.** It now holds just the three ports (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`). SvelteKit **never imports `config.ts`** — SvelteKit env vars are declared in `frontend/src/env.ts` and read via `$app/env/*`. Deleted the stale `config.js/.d.ts/.map` artifacts. - **`frontend/src/env.ts`** declares: `PROXY_URL` (public, default `http://127.0.0.1:3456`), `SERVER_IP` (public, default `192.168.1.225`), `PB_EMAIL`/`PB_PASSWORD` (private, defaults). `PUBLIC_PB_URL` removed (was the source of a startup crash when unset). - **Deleted `frontend/src/lib/server/env.ts`** (untracked). All server modules now `import { PROXY_URL } from '$app/env/public'` (`hono.ts`, `auth.ts`, `+layout.server.ts`, `+page.server.ts`, `preferences`, `join/[code]/[member]`). `admin/+page.server.ts` imports creds from `$app/env/private`. -- **`frontend/src/lib/pocketbase.ts` (browser) + `pb-admin.ts`**: `PB_ENDPOINT = import.meta.env.PROD ? '/pb' : \`http://${SERVER_IP}:8090\``. (Fixed a bug where `pocketbase.ts` used `import.meta.env.SERVER_IP` → undefined.) +- **`frontend/src/lib/pocketbase.ts` (browser) + `pb-admin.ts`**: `PB_ENDPOINT = import.meta.env.PROD ? '/pb' : \`http://${SERVER_IP}:8090\``. (Fixed a bug where `pocketbase.ts`used`import.meta.env.SERVER_IP` → undefined.) - **`proxy/src/env.ts`** (new): `PB_ENDPOINT = SERVER_IP ? \`http://${SERVER_IP}:8090\` : \`http://127.0.0.1:8090\``. Dev env is loaded by the proxy's `dev`/`seed` scripts via `tsx --env-file-if-exists=../.env` (pnpm has no `--env-file`; `NODE_OPTIONS='--env-file=…'` is rejected by Node). No `loadEnvFile` hack in code. - **Docker**: removed dead `ENV PB_ENDPOINT` from `Dockerfile`; `EXPOSE 3001` (was `3005 8090`); compose public port is `${PORT:-3001}:3001`, creds default to the code fallback, redundant `FRONTEND_PORT`/`PROXY_PORT` passthrough dropped; `entrypoint.sh` simplified (`PB_DATA=/app/pb_data`, `PORT=$FRONTEND_PORT`, no `:-` fallbacks). - **Frontend deps added** (were missing imports): `chart.js`, `qrcode`, `@hiseb/confetti`. @@ -198,12 +201,14 @@ - **Watch-out**: a careless `docker run` with a **fresh volume** (my first attempt, aborted in time) would have wiped the permanent PB data. Restore command is in RULES.md. Two containers (`pb-dev` :8090 and the docker app's internal PB :8091) currently **share the same host `./pb_data`** — be careful with both. ### 2026-08-06 — Added root `shared/` for cross-package code + - Created `shared/timezone.ts` (moved from root `timezone.ts`). Imported by `frontend/src/routes/[fam]/[username]/+page.svelte`, `.../settings/+page.svelte`, and `proxy/src/index.ts`. Deleted the root `timezone.ts`. - Created `shared/pb/schema.ts` — single source of truth for the PocketBase schema + field builders (`SCHEMA_PLAN` ordered collection plan + `text/select/rel/...` helpers). Both `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts` now iterate `SCHEMA_PLAN`; kills the previous duplicated schema/field-helper definitions in both files. - Reason: `timezone.ts` and the PB schema are consumed by more than one package; `shared/` is the root location both can reach. Rule added to RULES.md: shared code lives in `shared/`, never inside `frontend/` or `proxy/`. - Note: proxy `tsc --noEmit` already errors on `.ts`-extension imports (`allowImportingTsExtensions` unset) — pre-existing, not from this change. Runtime uses esbuild (build) + tsx (dev), both of which bundle the `shared/` imports correctly. Verified `pnpm build` clean for both packages. ### 2026-08-07 — `@shared/*` import alias (path alias, not a pnpm package) + - Moved `config.ts` → `shared/config.ts`. All `shared/` code is now imported as `@shared/*` instead of relative `../../shared/...`. - This is a **path alias**, not a pnpm workspace package (`@shared` alone isn't a valid npm package name; a real package would need `@scope/name`). - Proxy: `tsconfig.json` sets `paths: { "@shared/*": ["../shared/*"] }`; esbuild build adds `--alias:@shared=../shared`; tsx resolves via tsconfig paths. Proxy keeps `.ts` extensions (`@shared/config.ts`). @@ -212,6 +217,7 @@ - Docker note: runtime image only copies `frontend/build` + `proxy/dist` (both already bundle `shared/`), so `shared/` needn't be copied into the image. ### 2026-08-10 — Dev runtime cleanup (PB instances / containers) + - **Removed** test container `31e74178b3f0` (`famdone-service-app-1`, host :3010 + :8092). It ran **PB 0.39.10** and bound the **same host `pb_data`** as pb-dev → two PBs (v0.25 + v0.39) writing one SQLite DB = corruption/lock risk (likely source of dev instability/login lag). - **Killed** 7 stale host `tsx watch` dev-proxy processes (Jul 28–Aug 5) + my throwaway 0.39 PBs. - **Reset pb_data**: stopped pb-dev, wiped `/home/threejjjs/development/famchamp/pb_data`, **recreated** pb-dev container (fresh v0.25 store) with `--automigrate=false`, recreated dev superuser `debug@famchamp.dev`/`debug123`. @@ -219,6 +225,7 @@ - **Desired end state (confirmed)**: `8090` = pb-dev (v0.25, single instance, automigrate off); `3001`+`8091` = PROD app `cffd636cc772` (kept, not live); PROD pb_data at `/data/coolify/.../pb_data` (separate from dev). ### 2026-08-10 — PB 0.25 → 0.39 migration (branch `feature/migrate-pocketbase`) + - **Decision**: keep our own `migrate.ts` schema-as-code (API-driven, does data migrations + rule locking), NOT PocketBase's built-in automigrate (schema-only, generates version-specific migration files, and generates conflicting snapshots on upgraded stores). Disable PB automigrate in the runtime. - **Verified against 0.39.10** (throwaway binaries on `127.0.0.1:8098/8099`, temp data dirs): 1. Fresh store: `migrate()` bootstraps all 14 `SCHEMA_PLAN` collections + every field migration cleanly (schema field builders are 0.39-compatible). @@ -228,7 +235,7 @@ - `docker/Dockerfile` `POCKETBASE_VERSION` → `0.39.10`. - `docker/Dockerfile.dev` → `0.39.10` + `CMD ... --automigrate=false`. - `docker/entrypoint.sh` → `pocketbase serve ... --automigrate=false`. - - `proxy/src/env.ts` → added non-breaking `PB_ENDPOINT` env override (used to point migrate at a throwaway PB on another port; default dev/prod split unchanged). + - `proxy/src/env.ts` → added non-breaking `PB_ENDPOINT` env override (used to point migrate at a throwaway PB on another port; default dev/prod split unchanged). - **⚠️ 0.39 schema breaking change**: PB 0.39 does **NOT** auto-add `createdAt`/`updatedAt` to **API-created** collections (0.25 did). The chat store filters/sorts on a custom `messages.createdAt`, so a fresh 0.39 store is missing it → raw PB 400. Fixed two ways: - `shared/pb/schema.ts`: `messages` now declares `date("createdAt")` explicitly (source of truth → `ensureSchema`). - `proxy/src/migrate.ts`: the "messages already exists" branch now adds `createdAt` if missing (idempotent hardening for drifted/upgraded stores). @@ -236,6 +243,7 @@ - **Prod upgrade steps**: backup `pb_data` → run the 0.39 image (automigrate off) → run `migrate()` → verify no drift (`messages.createdAt`, `id.autogeneratePattern`). ### 2026-08-10 — Revert PB to 0.25.8 (backtrack from 0.39) + - **Decision**: backtrack off the PocketBase 0.39 bump (introduced in commit `3725c54` via `ARG POCKETBASE_VERSION=0.39.10`) and work from a **0.25.8 baseline** in BOTH dev and prod, then migrate to 0.39 deliberately later. - Reverted `docker/Dockerfile` `POCKETBASE_VERSION` back to `0.25.8` (matches `docker/Dockerfile.dev`). Dev `pb-dev` and the docker app internal PB `:8091` share host `./pb_data`. - **Migration schema scripts (DO NOT FORGET)**: the schema single source of truth is `shared/pb/schema.ts` (`SCHEMA_PLAN`), iterated by `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts`. The chat `messages` / `chat_typing` collections are defined there **without** an explicit `createdAt` — they rely on PB auto-adding it on first create. @@ -332,7 +340,7 @@ ### 2026-08-31 — Bonus progress window: `completeBy` + `startDate` implemented -- **Problem**: a standalone cash bonus (core reward with a points threshold) displayed progress differently on the child dashboard (current week → "0/500") vs the admin dashboard (cumulative since records began). Both were "correct" because `bonus_configs` had no way to scope tracking — with no `period` set, progress()/evaluateFam totalled *all* completions, and the child dashboard forced `cfg.period || 'weekly'`. +- **Problem**: a standalone cash bonus (core reward with a points threshold) displayed progress differently on the child dashboard (current week → "0/500") vs the admin dashboard (cumulative since records began). Both were "correct" because `bonus_configs` had no way to scope tracking — with no `period` set, progress()/evaluateFam totalled _all_ completions, and the child dashboard forced `cfg.period || 'weekly'`. - **Fix**: added to `bonus_configs` (schema + `ensureBonusFields` idempotent field-add in migrate.ts, so existing installs upgrade without wiping): `completeBy` (select `unlimited|week|custom`), `startDate` (text), `completeByDate` (text). New shared helpers in `shared/timezone.ts`: `bonusWindow(cfg, payday, tz)` + `completionInWindow(c, {from,to})` ('' = unbounded side). - **Window semantics** (unified across server `progress()`/`evaluateFam()` and the child `thresholdGoals`): recurring configs (period set) keep their period window; standalone configs use `completeBy`: - `unlimited` (default) → `[startDate, ∞]` cumulative @@ -367,3 +375,17 @@ - **Feature:** Count-type rewards now support pinning to a single assigned chore (`bonus_configs.targetChoreId`). On the fam-admin Rewards modal (Step 2), when Type = "Count - (chores)" a **Target Chore** dropdown appears after the Target Value field — options are the selected member's assigned (non-todo) chores, default "All Chores". Evaluation (`bonuses.evaluateFam`) + display (`bonuses.progress`, dashboard `thresholdGoals`) now filter Count progress to only that chore's completions when set. Platform-level template form only needed the relabeled "Count - (chores)" — no target chore at template level. Pocket-money checkbox removed from the platform template form (only the auto-created per-child droplet needs it). - **TODO (logic, not yet fixed):** Count rewards with a `period` (e.g. weekly) reset + re-earn each period like a points/cash threshold. Once a Count reward is pinned to a target (or all chores), the intended semantics are ambiguous: should it be **continuous** (unlimited, measured once since startDate until reached) or **limited to the period window** (re-earn each week)? Currently it follows the periodic-reset behavior. Decide whether Count rewards should ignore `period` (behave as standalone/unlimited since startDate) and adjust `bonusWindow`/evaluation accordingly. Not attempted in this change. + +### 2026-09-11 — Shared-device PIN switching (a computer shared by siblings) + +- **Problem:** a single `pb_token` cookie meant joining kid B on the family computer silently logged out kid A; every hand-off needed a fresh parent-issued OTP. Design note: `shared-device.md` (decisions + flows). +- **Model — the PIN is NOT a login:** it _selects_ among sessions that already exist on the device (`pb_token_` per child + `pb_active` naming the current one). PIN alone mints nothing on a fresh device; a stolen cookie alone selects nothing. Threat model = sibling mischief; devtools-level bypass explicitly accepted (data is family-scoped, toggles reversible). +- **Sessions (`session.ts` + `hooks.server.ts`):** children store one httpOnly cookie per account (`pb_token_`, 5d TTL) + `pb_active`. Resolution order: `pb_active` first, then legacy `pb_token` (parents + pre-feature children). Parent login/join clears `pb_active` → supersedes kid mode; kid switches shadow (don't delete) a parent session; `/logout` clears everything. Per-profile removal = picker ✕ → `POST /api/device/remove` (device-local cookie surgery, no session required). +- **`pins` collection** (superuser-only rules like `otp`): plaintext 3-digit PIN, deliberately parent-recoverable (Q1: View). All access server-side with session-role checks — kids can never read siblings' pins via PB rules. Created on boot via `ensurePins({})` in the always-run migrate path (existing installs — no DB wipe), plus `settings.lockMins` via `SCHEMA_PLAN` + `ensureSettingsFields`. +- **Endpoints:** `POST /api/switch-user {userId, pin}` — cookie-presence gate, superuser PIN verify, in-memory rate limit (5 fails → 30s), **self-healing**: expired device tokens re-mint via the derived password (server-only) instead of forcing a re-join. `POST /api/pins` — `set` (first-time), `change` (needs current), `ensure` (set-if-missing, used by the join wizard so joining a _second_ shared device doesn't clash with an existing PIN). +- **Picker (`SharedPicker` component)** — one UI everywhere: top-nav colour-dot (any session), idle-lock overlay, and standalone `/{fam}/switch` landing (fam layout redirects there when kid cookies exist but none is active; join pages excluded from the redirect; `+page.server.ts` sends active sessions home). PIN required on every pick incl. "resume" (deliberate-select property). Standalone footer links: join with a code + parent login. +- **Join wizard (`JoinPinFlow`):** after OTP redeem → "shared with siblings?" → PIN setup (skipped on "no"), **forced when the device already has other kid sessions**; then straight to the kid's dashboard. All three child join routes updated (root `/join`, `/{fam}/join`, `/{fam}/join/{username}`); parents keep the single-session flow. +- **Idle lock (client, `lib/client/lock.ts`):** `localStorage[fam_last_active]` written on click/key/touch (throttled 10s) + force-written on `pagehide` → survives browser close/sleep/restart (next launch compares elapsed; live 15s interval mid-session). Default **10 min** (user-set: default 10, not 2-3); parent-adjustable `Off / 2 / 10` in Settings → Family → **Shared computer**. Children only; tab-switches don't lock; two tabs share the timestamp so the active tab arbitrates. +- **Parent/child PIN UI:** Settings → Invites → All members → **PIN** per member (reveal + give-a-new-PIN modal); child Preferences → **My PIN** (set, or change with current PIN; "forgot? parent can read it out"). +- **Typecheck/build:** `svelte-check` still 4 pre-existing canary errors only (chores RewardType/Frequency ×3, qrcode decl — files untouched by this change); prettier run over touched files. Migration applies on next dev-server boot (migrateOnBoot) — no DB wipe; hooks changes need the dev server restart (auto). +- **Caveats:** legacy single-cookie child sessions work but don't appear in the picker until re-join; multi-tab concurrency accepted (single-tab-norm on family desktops); `shared-device.md` records the settled open questions (view-not-reset, optional-at-join, no parent switcher, straight-to-dashboard, no cross-family). diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 715172d..9ec589b 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -2,8 +2,12 @@ import type { Handle } from '@sveltejs/kit'; import { createPbClient } from '$lib/server/pocketbase'; import { SESSION_COOKIE, + ACTIVE_COOKIE, + childSessionCookie, setSessionCookie, clearSessionCookie, + setChildSessionCookie, + clearActiveChild, PLATFORM_SESSION_COOKIE, clearPlatformSession } from '$lib/server/session'; @@ -14,6 +18,21 @@ import { migrateOnBoot } from '$lib/server/migrate-boot'; // Run the PB schema migration once at server boot (idempotent). void migrateOnBoot(); +function sessionFrom(record: any, freshToken: string) { + return { + id: record.id, + name: record.name || record.username || '', + username: handleOf(record.username || ''), + role: record.role || 'parent', + famId: record.famId, + color: record.color || '', + pattern: record.pattern || '', + themeSize: record.themeSize || '', + themeOpacity: record.themeOpacity || '', + token: freshToken + } satisfies SessionUser & { token: string }; +} + export const handle: Handle = async ({ event, resolve }) => { event.locals.user = null; event.locals.pbToken = null; @@ -36,32 +55,50 @@ export const handle: Handle = async ({ event, resolve }) => { return resolve(event); } - // Fam-user session: pb_token JWT → authRefresh → locals.user. + // Shared-device sessions: `pb_active` names the child whose cookie is the + // current session. Resolved FIRST so a kid switch on a family computer + // supersedes any shadowed single pb_token (parent) session. + const activeId = event.cookies.get(ACTIVE_COOKIE); + if (activeId) { + const childToken = event.cookies.get(childSessionCookie(activeId)); + if (!childToken) { + // Stale active pointer (cookie removed) — clean it up and fall through. + clearActiveChild(event.cookies); + } else { + try { + const pb = createPbClient(childToken); + // authRefresh() does two jobs in one call: + // 1. Verifies the token (PB JWTs can't be checked offline — the + // signing secret is per-record and never leaves PB). + // 2. Returns the current record — the only way to get + // name/role/famId, since PB doesn't embed custom fields. + const { record, token: freshToken } = await pb.collection('users').authRefresh(); + if (record.role === 'child') { + const session = sessionFrom(record, freshToken); + event.locals.user = session; + event.locals.pbToken = session.token; + if (freshToken !== childToken) { + setChildSessionCookie(event.cookies, activeId, freshToken); + } + return resolve(event); + } + } catch { + // Expired/revoked/malformed — leave the cookie; the picker switch + // re-mints it server-side. Fall through to the single session. + } + } + } + + // Single session: pb_token JWT (parents, or children from before the + // multi-session scheme) → authRefresh → locals.user. const token = event.cookies.get(SESSION_COOKIE); if (token) { const pb = createPbClient(token); try { - // authRefresh() does two jobs in one call: - // 1. Verifies the token (PB JWTs can't be checked offline — the - // signing secret is per-record and never leaves PB), so this - // round trip IS the verification step. - // 2. Returns the current record — the only way to get - // name/role/famId, since PB doesn't embed custom fields in the - // token itself. const { record, token: freshToken } = await pb.collection('users').authRefresh(); - event.locals.user = { - id: record.id, - name: record.name || record.username || '', - username: handleOf(record.username || ''), - role: record.role || 'parent', - famId: record.famId, - color: record.color || '', - pattern: record.pattern || '', - themeSize: record.themeSize || '', - themeOpacity: record.themeOpacity || '' - } satisfies SessionUser; - event.locals.pbToken = freshToken; - + const session = sessionFrom(record, freshToken); + event.locals.user = session; + event.locals.pbToken = session.token; if (freshToken !== token) { setSessionCookie(event.cookies, freshToken); } diff --git a/frontend/src/lib/components/index.ts b/frontend/src/lib/components/index.ts index 4edd5f6..9dd2e32 100644 --- a/frontend/src/lib/components/index.ts +++ b/frontend/src/lib/components/index.ts @@ -14,3 +14,6 @@ export { default as NoticeDialog } from './NoticeDialog.svelte'; export { default as PricingPlans } from './PricingPlans.svelte'; export { default as CheckboxGrid } from './CheckboxGrid.svelte'; export { default as PatternPicker } from './PatternPicker.svelte'; +export { default as PinPad } from './PinPad.svelte'; +export { default as SharedPicker } from './SharedPicker.svelte'; +export { default as JoinPinFlow } from './JoinPinFlow.svelte'; diff --git a/frontend/src/lib/server/member-otp.ts b/frontend/src/lib/server/member-otp.ts index a6f5272..5aaac5c 100644 --- a/frontend/src/lib/server/member-otp.ts +++ b/frontend/src/lib/server/member-otp.ts @@ -235,8 +235,8 @@ export async function redeemOtp(opts: { famSlug: string; username: string; otp: if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired'); const authPb = createPbClient(); - await authPb + const { record } = await authPb .collection('users') .authWithPassword(fullUsername, derivePassword(famSlug, handleName)); - return authPb.authStore.token; + return { token: authPb.authStore.token, userId: record.id }; } diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index a5305ea..a22f711 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -262,6 +262,54 @@ async function ensureOtp(ids: Record): Promise { }); } +// Child shared-device PINs. Superuser-only rules (like `otp`) — all access +// goes through server endpoints with session-role checks, so children can +// never read siblings' pins via PB rules. +async function ensurePins(ids: Record): Promise { + if (await getCollection('pins')) return; + const famsId = ids.fams || (await getCollection('fams'))?.id; + const usersId = ids.users || (await getCollection('users'))?.id; + if (!famsId || !usersId) throw new Error('fams/users collection not found'); + await createCollection({ + name: 'pins', + type: 'base', + listRule: null, + viewRule: null, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { + name: 'famId', + type: 'relation', + required: true, + collectionId: famsId, + maxSelect: 1, + cascadeDelete: false + }, + { + name: 'userId', + type: 'relation', + required: true, + collectionId: usersId, + maxSelect: 1, + cascadeDelete: false + }, + { name: 'pin', type: 'text', required: false } + ] + }); +} + +// Idempotent field-add for the shared-device idle lock (0 = off, else mins). +async function ensureSettingsFields(): Promise { + const settingsCol = await getCollection('settings'); + if (!settingsCol) return; + const has = (n: string) => settingsCol.fields.some((f: any) => f.name === n); + if (has('lockMins')) return; + settingsCol.fields.push({ name: 'lockMins', type: 'number', required: false }); + await updateCollection(settingsCol.id, { fields: settingsCol.fields }); +} + // Platform access codes — the codes that enable access to the platform. They're // global (not fam-scoped) and managed via the platform admin page (superuser // only), so all rules are null like `otp`. A code grants a family a subscription @@ -404,6 +452,7 @@ async function ensureSchema(): Promise { await ensureUsers(ids); await ensureOtp(ids); + await ensurePins(ids); console.log('[migrate] Schema bootstrapped.'); } @@ -473,11 +522,16 @@ export async function migrate(): Promise { // return) so new platform collections/fields/seed land on existing installs. await ensureUserFields(); await ensureFamFields(); + await ensureSettingsFields(); await ensureBonusFields(); await ensureTemplateFields(); await ensureAssignedChoreFields(); await ensureAccessCodes(); await ensurePlatform(); + // Superuser-only collections also land on EXISTING installs (ensureSchema's + // early return skips them). Like ensureOtp before it, ensurePins no-ops when + // the collection already exists. + await ensurePins({}); await ensureDefaultSeasons(); if (await isDemo()) { await seedDemoFamily(); diff --git a/frontend/src/lib/server/session.ts b/frontend/src/lib/server/session.ts index 9a8fdf2..60a1930 100644 --- a/frontend/src/lib/server/session.ts +++ b/frontend/src/lib/server/session.ts @@ -1,22 +1,66 @@ import type { Cookies } from '@sveltejs/kit'; -// The PocketBase JWT lives in a single cookie shared by: +// PocketBase JWTs live in cookies shared by: // - the server hooks (authRefresh -> locals.user) // - the client SDK (seeded from page.data.pbToken -> authenticated famStore reads/subscribe) -// httpOnly keeps the token out of reach of browser JS/XSS; the client receives +// httpOnly keeps tokens out of reach of browser JS/XSS; the client receives // the token server-side via the layout load (pbToken) and seeds pb.authStore. // Secure flag is set in prod so it's only sent over HTTPS. export const SESSION_COOKIE = 'pb_token'; +// Shared-device multi-session: children hold ONE cookie per account +// (`pb_token_`); `pb_active` names which one is the current session. +// Parents stay on the single `pb_token`. +export const CHILD_COOKIE_PREFIX = 'pb_token_'; +export const ACTIVE_COOKIE = 'pb_active'; const MAX_AGE = 60 * 60 * 24 * 5; // 5 days — matches the PB users auth token duration -export function setSessionCookie(cookies: Cookies, token: string) { - cookies.set(SESSION_COOKIE, token, { +function cookieOpts() { + return { httpOnly: true, - sameSite: 'lax', + sameSite: 'lax' as const, path: '/', maxAge: MAX_AGE, secure: import.meta.env.PROD - }); + }; +} + +export function setSessionCookie(cookies: Cookies, token: string) { + cookies.set(SESSION_COOKIE, token, cookieOpts()); +} + +export function childSessionCookie(userId: string) { + return `${CHILD_COOKIE_PREFIX}${userId}`; +} + +export function setChildSessionCookie(cookies: Cookies, userId: string, token: string) { + cookies.set(childSessionCookie(userId), token, cookieOpts()); +} + +export function clearChildSession(cookies: Cookies, userId: string) { + cookies.delete(childSessionCookie(userId), { path: '/' }); +} + +export function setActiveChild(cookies: Cookies, userId: string) { + cookies.set(ACTIVE_COOKIE, userId, cookieOpts()); +} + +export function clearActiveChild(cookies: Cookies) { + cookies.delete(ACTIVE_COOKIE, { path: '/' }); +} + +// Ids of every child that has a session cookie on this device. +export function scanChildSessions(cookies: Cookies): string[] { + return cookies + .getAll() + .filter((c) => c.name.startsWith(CHILD_COOKIE_PREFIX)) + .map((c) => c.name.slice(CHILD_COOKIE_PREFIX.length)) + .filter(Boolean); +} + +// Remove every child session on this device (logout-all). +export function clearDeviceSessions(cookies: Cookies) { + for (const id of scanChildSessions(cookies)) clearChildSession(cookies, id); + clearActiveChild(cookies); } export function clearSessionCookie(cookies: Cookies) { @@ -50,4 +94,4 @@ export function setPlatformSession(cookies: Cookies, token: string) { export function clearPlatformSession(cookies: Cookies) { cookies.delete(PLATFORM_SESSION_COOKIE, { path: '/' }); -} \ No newline at end of file +} diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts index 3908002..55d9f76 100644 --- a/frontend/src/routes/+layout.server.ts +++ b/frontend/src/routes/+layout.server.ts @@ -1,8 +1,13 @@ import type { LayoutServerLoad } from './$types'; import { getPlatformFlags } from '$lib/server/platform'; +import { scanChildSessions } from '$lib/server/session'; // Platform settings are public (read-only): feature flags ride along with // every page's data so any component can deduce them via page.data.platformFlags. -export const load: LayoutServerLoad = async () => { - return { platformFlags: await getPlatformFlags() }; +export const load: LayoutServerLoad = async (event) => { + return { + platformFlags: await getPlatformFlags(), + // Children with session cookies on THIS device (shared-computer picker). + deviceChildIds: scanChildSessions(event.cookies) + }; }; diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index 449d86c..bd045b9 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -1,9 +1,10 @@ import { redirect } from '@sveltejs/kit'; -import { createPbClient } from '$lib/server/pocketbase'; +import { createPbClient, createSuperClient } from '$lib/server/pocketbase'; import { createServices, type ChatActor } from '$lib/server/services'; import { ensureFamAccess } from '$lib/server/access'; import { seedDemoCompletions } from '$lib/server/migrate'; import { getPlatformFlags } from '$lib/server/platform'; +import { scanChildSessions } from '$lib/server/session'; async function paydayCheck(famId: string, pbToken: string) { try { @@ -56,7 +57,62 @@ export async function load(event) { const session = event.locals.user; const role = session?.role || 'child'; const isParent = role === 'parent'; - const pbToken = event.cookies.get('pb_token') || ''; + const pbToken = event.locals.pbToken || ''; + const deviceChildIds = scanChildSessions(event.cookies); + + // ── Shared-device picker mode ── + // The device holds child sessions but none is active (per-profile logout). + // Anything except the join flow lands on the standalone picker. + const paramFam = event.params.fam; + const isJoinPage = (event.url.pathname || '').split('/').includes('join'); + if (!session && deviceChildIds.length > 0 && !isJoinPage) { + if (!(event.url.pathname || '').endsWith('/switch')) { + throw redirect(303, `/${encodeURIComponent(paramFam)}/switch`); + } + let pickerFamName = paramFam || ''; + let pickerChildren: { + id: string; + name: string; + color: string; + username: string; + }[] = []; + try { + const pb = await createSuperClient(); + const fam = await pb + .collection('fams') + .getFirstListItem(`slug='${paramFam}'`) + .catch(() => null); + if (fam) { + pickerFamName = fam.name || fam.slug; + const users = await pb.collection('users').getFullList({ + filter: `id in ('${deviceChildIds.join("','")}') && role='child'` + }); + pickerChildren = (users || []).map((u: any) => ({ + id: u.id, + name: u.name || u.username || '', + color: u.color || '#6366f1', + username: u.username || '' + })); + } + } catch {} + return { + famSlug: paramFam || '', + session: null, + isParent, + role, + famId: '', + chat: null, + pbToken: '', + fam: null, + famAccess: { disabled: false, mode: 'none', reason: '' }, + demoMode: (await getPlatformFlags()).demo, + picker: true, + pickerFamName, + pickerChildren, + deviceChildIds, + lockMins: 0 + }; + } let famId = ''; let chat: { @@ -70,6 +126,7 @@ export async function load(event) { mode: 'none' as 'none' | 'code' | 'sub' | 'canceled', reason: '' }; + let lockMins = 0; if (session && pbToken) { famId = session.famId; @@ -82,11 +139,22 @@ export async function load(event) { fam = res.fam; famAccess = res.access; } + // Shared-device idle lock setting (0 = off; missing row defaults to 10 + // min). Superuser read — the settings rules are parent-oriented and + // children must see it too. + try { + const pb = await createSuperClient(); + const settings = await pb + .collection('settings') + .getFullList({ filter: `famId='${famId}'` }) + .catch(() => []); + const row = (settings as any[])?.[0]; + lockMins = row && row.lockMins != null ? Number(row.lockMins) : 10; + } catch {} // Canonical URL: the [fam] segment must be the family SLUG, never the PB // id. If someone lands on /{famId}/... (a stale shortcut, bookmark, or a // login that fell back to the id), rewrite the first path segment to the // slug so the id is replaced everywhere it'd otherwise persist. - const paramFam = event.params.fam; const canonicalSlug = fam?.slug; if (canonicalSlug && paramFam && paramFam !== canonicalSlug) { const rest = event.url.pathname.replace(`/${paramFam}`, '') || '/'; @@ -100,7 +168,7 @@ export async function load(event) { return { // Canonical fam slug — from the URL param ([fam] routes). Client code // reads page.data.famSlug; never copy it into local $state. - famSlug: event.params.fam || '', + famSlug: paramFam || '', session: session ? { famId: session.famId, @@ -121,6 +189,11 @@ export async function load(event) { pbToken, fam, famAccess, - demoMode + demoMode, + picker: false, + pickerFamName: '', + pickerChildren: [], + deviceChildIds, + lockMins }; } diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index d2356e7..cd21f1f 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -6,9 +6,10 @@ import { famStore } from '$lib/stores/fam.svelte'; import { chatStore } from '$lib/stores/chat.svelte'; import { notices } from '$lib/stores/notices.svelte'; - import { Sidebar, TopNav, Footer, Chat } from '$lib/components'; + import { Sidebar, TopNav, Footer, Chat, SharedPicker } from '$lib/components'; import { chatIcon } from '$lib/components/icons'; import { recordShortcut } from '$lib/shortcut'; + import { installLockTracking, lockDue } from '$lib/client/lock'; import { themeShades } from '$lib/theme'; import '$lib/theme-patterns.css'; import { themeDraft } from '$lib/stores/theme.svelte'; @@ -29,6 +30,41 @@ // Pattern size in vw units ('' = untouched → pattern class default). let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || '')); let bgSizeVw = $derived(Number(bgSize) > 0 ? bgSize : ''); + // Shared-device picker: standalone when the device has child sessions but + // none is active (layout load redirects here); overlay when the idle lock + // fires or the top-nav switcher is opened. + const pickerMode = $derived(!!data.picker); + let pickerOpen = $state(false); + let lockTimer: ReturnType | null = null; + let deviceProfiles = $derived( + (data.deviceChildIds || []) + .map((id) => famStore.members.find((m) => m.id === id)) + .filter(Boolean) + .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. + $effect(() => { + const isChild = data.session?.role === 'child'; + const lockMins = Number(data.lockMins) || 0; + if (!isChild || lockMins <= 0 || data.picker) return; + installLockTracking(); + if (lockDue(lockMins)) pickerOpen = true; + if (!lockTimer) { + lockTimer = setInterval(() => { + if (lockDue(lockMins)) pickerOpen = true; + }, 15_000); + } + return () => { + if (lockTimer) { + clearInterval(lockTimer); + lockTimer = null; + } + }; + }); + let session = $state(data.session); let isParent = $state(data.isParent); let role = $state(data.role); @@ -194,66 +230,97 @@ }); -
-
- - s.active !== false)} - announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''} + +{#if pickerMode} +
+ +
+{:else} +
+
- - -
-
-
{@render children()}
- - {#if locked} -
-
- {#if !hasAuth} -

You need to be logged in to view this page

- {:else if activating} - - Activating your subscription… - Payment received — this usually only takes a few seconds. - {:else} - Access paused - - {isParent - ? 'Add an access code or resume your subscription to keep using FamDone.' - : 'Your family access is paused.'} - - {/if} -
-
- {/if} -
-
- {#if claimToast} - + s.active !== false)} + announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''} > - {claimToast} — view dashboard → - + + {#if data.session && (data.deviceChildIds?.length || 0) > 0} + + {/if} + +
+
+
{@render children()}
+ + {#if locked} +
+
+ {#if !hasAuth} +

You need to be logged in to view this page

+ {:else if activating} + + Activating your subscription… + Payment received — this usually only takes a few seconds. + {:else} + Access paused + + {isParent + ? 'Add an access code or resume your subscription to keep using FamDone.' + : 'Your family access is paused.'} + + {/if} +
+
+ {/if} +
+
+ {#if claimToast} + + {claimToast} — view dashboard → + + {/if} +
+
+ {#if chatStore.open} + + {/if} + + {#if pickerOpen && deviceProfiles.length > 0} + (pickerOpen = false)} + /> {/if} -
- {#if chatStore.open} - - {/if} - -
+{/if} diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts index b9572ba..efc6260 100644 --- a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts @@ -8,6 +8,7 @@ import { sendParentInviteEmail } from '$lib/server/email'; import { slugify, handle, famUsername } from '@shared/slugify'; import { applyAccessCode } from '$lib/server/access'; import { getPlatformFlags } from '$lib/server/platform'; +import { getPin, resetPin as resetChildPin } from '$lib/server/pins'; import { createBillingPortalSession, cancelSubscriptionAtPeriodEnd, @@ -26,7 +27,15 @@ async function isDemoMode(): Promise { export async function load(event: RequestEvent) { if (await isDemoMode()) { - return { members: [], fam: null, seasons: [], accessCode: null, subStatus: null, demoMode: true }; + return { + members: [], + fam: null, + seasons: [], + accessCode: null, + subStatus: null, + demoMode: true, + lockMins: 10 + }; } const famId = famIdOf(event); const pb = pbUser(event); @@ -48,7 +57,13 @@ export async function load(event: RequestEvent) { if (fam?.paymentMode === 'sub' && fam?.stripeCustomerId) { subStatus = await getSubscriptionStatus(fam.stripeCustomerId).catch(() => null); } - return { members, fam, seasons, accessCode, subStatus }; + // Shared-device idle lock (0 = off). Missing row → default 10 min. + const settingsRow = await pbAdmin + .getList('settings', `famId='${famId}'`) + .then((rows: any[]) => rows?.[0] || null) + .catch(() => null); + const lockMins = settingsRow && settingsRow.lockMins != null ? Number(settingsRow.lockMins) : 10; + return { members, fam, seasons, accessCode, subStatus, lockMins }; } export const actions = { @@ -159,6 +174,59 @@ export const actions = { return { ok: true }; }, + // Parent reads a child's shared-device PIN (children forget theirs often). + revealPin: async (event: RequestEvent) => { + const famId = famIdOf(event); + const fd = await event.request.formData(); + const id = (fd.get('id') || '').toString(); + if (!id) return { error: 'Member ID required' }; + try { + const member = await pbAdmin.getOne('users', id).catch(() => null); + if (!member || member.famId !== famId) return { error: 'No such member' }; + const pin = await getPin(id); + if (!pin) return { ok: true, pin: '', name: member.name || '', unset: true }; + return { ok: true, pin, name: member.name || '' }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to reveal PIN' }; + } + }, + + // Parent hands out a fresh PIN when a child forgets theirs. + resetPin: async (event: RequestEvent) => { + const famId = famIdOf(event); + const fd = await event.request.formData(); + const id = (fd.get('id') || '').toString(); + if (!id) return { error: 'Member ID required' }; + try { + const member = await pbAdmin.getOne('users', id).catch(() => null); + if (!member || member.famId !== famId) return { error: 'No such member' }; + const pin = await resetChildPin(famId, id); + return { ok: true, pin, name: member.name || '' }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to reset PIN' }; + } + }, + + // Shared-computer idle lock: off / 2 / 10 minutes before returning to the + // profile picker (children only). Missing settings row → created. + updateLock: async (event: RequestEvent) => { + const famId = famIdOf(event); + const fd = await event.request.formData(); + const mins = parseInt((fd.get('mins') || '').toString(), 10); + if (![0, 2, 10].includes(mins)) return { error: 'Invalid value' }; + const pb = await createSuperClient(); + let row = await pb + .collection('settings') + .getFirstListItem(`famId='${famId}'`) + .catch(() => null); + if (row) { + await pb.collection('settings').update(row.id, { lockMins: mins }); + } else { + await pb.collection('settings').create({ famId, lockMins: mins }); + } + return { ok: true, lockMins: mins }; + }, + updatePayday: async (event: RequestEvent) => { const famId = famIdOf(event); const fd = await event.request.formData(); diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.svelte b/frontend/src/routes/[fam]/[username]/settings/+page.svelte index c45c689..5ed9aa1 100644 --- a/frontend/src/routes/[fam]/[username]/settings/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/settings/+page.svelte @@ -89,6 +89,8 @@ let members = $derived(famStore.initialized ? famStore.members : data.members || []); let deletingSeason = $state(null); let issueModal = $state<{ otp: string; joinUrl: string; name: string } | null>(null); + let pinModal = $state<{ id: string; name: string; pin: string; unset?: boolean } | null>(null); + let lockMins = $state(String(data.lockMins ?? 10)); let seasonColor = $state('#6366f1'); const seasonColors = [ '#6366f1', @@ -363,6 +365,32 @@
{/if} + + +

+ If kids share this computer, FamDone returns to the profile picker after a break so nobody + lands on the wrong chores. Children pick a 3-digit PIN when they join. +

+
{ + return async ({ result }) => { + if (result.type !== 'success') return; + const d = result.data as any; + if (d?.ok) lockMins = String(d.lockMins); + else if (d?.error) alert(d.error); + }; + }} + > + + +
+
@@ -478,6 +506,32 @@ /{famSlug}/{handleOf(m.username)} +
{ + return async ({ formData, result }) => { + if (result.type !== 'success') return; + const d = result.data as any; + if (d?.ok) { + pinModal = { + id: String(formData.get('id') || ''), + name: d.name || m.name, + pin: d.pin || '', + unset: !!d.unset + }; + } else if (d?.error) { + alert(d.error); + } + }; + }} + class="inline" + > + + +
+
+ {:else} +

{pinModal.pin}

+

+ Read it out if they've forgotten. They can change it themselves in Preferences. +

+
{ + return async ({ result }) => { + if (result.type !== 'success') return; + const d = result.data as any; + if (d?.ok) pinModal = { ...pinModal!, pin: d.pin, unset: false }; + else if (d?.error) alert(d.error); + }; + }} + class="inline" + > + + +
+ {/if} + + + + {/if} +

Send an email invitation for another parent to join as an admin.

diff --git a/frontend/src/routes/[fam]/join/+page.server.ts b/frontend/src/routes/[fam]/join/+page.server.ts index dc5a357..e14c52f 100644 --- a/frontend/src/routes/[fam]/join/+page.server.ts +++ b/frontend/src/routes/[fam]/join/+page.server.ts @@ -1,7 +1,12 @@ -import { fail, redirect } from '@sveltejs/kit'; +import { fail } from '@sveltejs/kit'; import { redeemOtp } from '$lib/server/member-otp'; import { handle } from '@shared/slugify'; -import { setSessionCookie, clearLegacyCookies } from '$lib/server/session'; +import { + clearLegacyCookies, + setChildSessionCookie, + setActiveChild, + scanChildSessions +} from '$lib/server/session'; export const actions = { default: async (event) => { @@ -14,11 +19,14 @@ export const actions = { if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', name, otp }); try { - // redeemOtp derives the username from the handle internally; pass the - // raw name so it resolves the same {famSlug}:{handle} identity. - const token = await redeemOtp({ famSlug: fam, username: name, otp }); + // Children join onto a shared-device session (pb_token_ + + // pb_active) so siblings' sessions on this computer survive. + const { token, userId } = await redeemOtp({ famSlug: fam, username: name, otp }); clearLegacyCookies(event.cookies); - setSessionCookie(event.cookies, token); + setChildSessionCookie(event.cookies, userId, token); + setActiveChild(event.cookies, userId); + const hasOtherKids = scanChildSessions(event.cookies).length > 1; + return { joined: true, famSlug: fam, username: handle(name), hasOtherKids }; } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Join failed', @@ -26,8 +34,5 @@ export const actions = { otp }); } - - const handleName = handle(name); - throw redirect(303, `/${fam}/${encodeURIComponent(handleName)}`); } }; diff --git a/frontend/src/routes/[fam]/join/+page.svelte b/frontend/src/routes/[fam]/join/+page.svelte index c922fe2..0df8166 100644 --- a/frontend/src/routes/[fam]/join/+page.svelte +++ b/frontend/src/routes/[fam]/join/+page.svelte @@ -1,12 +1,13 @@ @@ -19,51 +20,66 @@ > {@html homeIcon} -

Join {famSlug}

-

- Enter your name and the code your parent gave you to get started. -

- { - return async ({ result, update }) => { - if (result.type === 'failure') { - name = (result.data as any)?.name || ''; - otp = (result.data as any)?.otp || ''; - } - await update(); - }; - }} - > - - - {#if form?.error} -

{form.error}

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

Join {famSlug}

+

+ Enter your name and the code your parent gave you to get started. +

-

- Code is valid for 20 minutes. Ask your parent for a new one if it expires. -

+
{ + return async ({ result, update }) => { + if (result.type === 'failure') { + name = (result.data as any)?.name || ''; + otp = (result.data as any)?.otp || ''; + await update(); + return; + } + const d = (result as any).data; + if (d?.joined) { + joined = { username: d.username, hasOtherKids: !!d.hasOtherKids }; + return; + } + await update(); + }; + }} + > + + + {#if form?.error} +

{form.error}

+ {/if} + +
+ +

+ Code is valid for 20 minutes. Ask your parent for a new one if it expires. +

+ {/if} diff --git a/frontend/src/routes/[fam]/join/[username]/+page.server.ts b/frontend/src/routes/[fam]/join/[username]/+page.server.ts index 8040d46..530d644 100644 --- a/frontend/src/routes/[fam]/join/[username]/+page.server.ts +++ b/frontend/src/routes/[fam]/join/[username]/+page.server.ts @@ -1,7 +1,14 @@ import { fail, redirect } from '@sveltejs/kit'; import { redeemOtp, redeemParentOtp } from '$lib/server/member-otp'; import { createSuperClient } from '$lib/server/pocketbase'; -import { setSessionCookie, clearLegacyCookies } from '$lib/server/session'; +import { + setSessionCookie, + clearLegacyCookies, + setChildSessionCookie, + setActiveChild, + scanChildSessions, + clearActiveChild +} from '$lib/server/session'; import { famUsername, handle } from '@shared/slugify'; export async function load(event) { @@ -58,15 +65,25 @@ export const actions = { } } - const token = isParent - ? await redeemParentOtp({ famSlug: fam, username, otp, password }) - : await redeemOtp({ famSlug: fam, username, otp }); + if (isParent) { + const token = await redeemParentOtp({ famSlug: fam, username, otp, password }); + clearLegacyCookies(event.cookies); + setSessionCookie(event.cookies, token); + // A parent join supersedes kid mode on a shared device. + clearActiveChild(event.cookies); + throw redirect(303, `/${fam}/${encodeURIComponent(username)}`); + } + + // Child: shared-device session (pb_token_ + pb_active) so + // siblings' sessions on this computer survive. + const { token, userId } = await redeemOtp({ famSlug: fam, username, otp }); clearLegacyCookies(event.cookies); - setSessionCookie(event.cookies, token); + setChildSessionCookie(event.cookies, userId, token); + setActiveChild(event.cookies, userId); + const hasOtherKids = scanChildSessions(event.cookies).length > 1; + return { joined: true, famSlug: fam, username, hasOtherKids }; } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Join failed' }); } - - throw redirect(303, `/${fam}/${encodeURIComponent(username)}`); } }; diff --git a/frontend/src/routes/[fam]/join/[username]/+page.svelte b/frontend/src/routes/[fam]/join/[username]/+page.svelte index b118151..2517fd0 100644 --- a/frontend/src/routes/[fam]/join/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/join/[username]/+page.svelte @@ -1,7 +1,7 @@ @@ -13,63 +14,84 @@
-
+
{@html homeIcon}
-

Join the family

-

- Enter your family name and the code your parent gave you to get started. -

-
{ - return async ({ result, update }) => { - if (result.type === 'failure') { - family = (result.data as any)?.family || ''; - name = (result.data as any)?.name || ''; - otp = (result.data as any)?.otp || ''; - } - await update(); - }; - }} - > - - - - {#if form?.error} -

{form.error}

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

Join the family

+

+ Enter your family name and the code your parent gave you to get started. +

-

- Code is valid for 20 minutes. Ask your parent for a new one if it expires. -

+
{ + return async ({ result, update }) => { + if (result.type === 'failure') { + family = (result.data as any)?.family || ''; + name = (result.data as any)?.name || ''; + otp = (result.data as any)?.otp || ''; + await update(); + return; + } + const d = (result as any).data; + if (d?.joined) { + joined = { + famSlug: d.famSlug, + username: d.username, + hasOtherKids: !!d.hasOtherKids + }; + return; + } + await update(); + }; + }} + > + + + + {#if form?.error} +

{form.error}

+ {/if} + +
+ +

+ Code is valid for 20 minutes. Ask your parent for a new one if it expires. +

+ {/if}
diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index eac6d27..9434447 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -1,6 +1,6 @@ import { fail, redirect } from '@sveltejs/kit'; import { createPbClient } from '$lib/server/pocketbase'; -import { setSessionCookie } from '$lib/server/session'; +import { setSessionCookie, clearActiveChild } from '$lib/server/session'; import { pbAdmin } from '$lib/server/pocketbase'; import { handleOf } from '@shared/slugify'; @@ -27,6 +27,8 @@ export const actions = { } setSessionCookie(event.cookies, authResult.token); + // An explicit email/password login supersedes kid mode on a shared device. + clearActiveChild(event.cookies); const fam = await pbAdmin.getOne('fams', user.famId).catch(() => null); const famSlug = fam?.slug || user.famId; diff --git a/frontend/src/routes/logout/+page.server.ts b/frontend/src/routes/logout/+page.server.ts index 04e39c4..4e77439 100644 --- a/frontend/src/routes/logout/+page.server.ts +++ b/frontend/src/routes/logout/+page.server.ts @@ -1,9 +1,11 @@ import { redirect } from '@sveltejs/kit'; -import { clearSessionCookie, clearLegacyCookies } from '$lib/server/session'; +import { clearSessionCookie, clearLegacyCookies, clearDeviceSessions } from '$lib/server/session'; function signOut(event: { cookies: any }) { clearSessionCookie(event.cookies); clearLegacyCookies(event.cookies); + // Shared device: also drop every child session + the active pointer. + clearDeviceSessions(event.cookies); } export const actions = { diff --git a/shared/pb/schema.ts b/shared/pb/schema.ts index 84ee455..5c7856e 100644 --- a/shared/pb/schema.ts +++ b/shared/pb/schema.ts @@ -61,11 +61,19 @@ export function jsonField(name: string): FieldDef { return { name, type: "json" }; } -export function select(name: string, values: string[], required = false): FieldDef { +export function select( + name: string, + values: string[], + required = false, +): FieldDef { return { name, type: "select", required, values, maxSelect: 1 }; } -export function rel(name: string, collectionId: string, required = false): FieldDef { +export function rel( + name: string, + collectionId: string, + required = false, +): FieldDef { return { name, type: "relation", @@ -151,236 +159,292 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ text("accessCodeId"), date("accessCodeEnteredAt"), ], - { listRule: RULE_OWN_FAM, viewRule: RULE_OWN_FAM, updateRule: RULE_OWN_FAM }, + { + listRule: RULE_OWN_FAM, + viewRule: RULE_OWN_FAM, + updateRule: RULE_OWN_FAM, + }, )(ids), }, { name: "bonus_templates", build: (ids) => - col("bonus_templates", [ - rel("famId", ids.fams, false), - text("name", true), - text("description"), - select("target", ["individual", "competitive", "collaborative"], true), - select("type", ["threshold", "count", "manual"], true), - select("thresholdType", ["points", "percent"], false), - select("occurrence", ["recurring", "once"], true), - select("rewardType", ["points", "cash", "prize"], true), - text("rewardValue", false), - number("criteriaValue"), - select("period", ["schedule", "daily", "weekly", "monthly"]), - bool("isPocketMoney"), - bool("global"), - text("icon"), - text("color"), - ], { - listRule: "global = true || famId = @request.auth.famId", - viewRule: "global = true || famId = @request.auth.famId", - createRule: RULE_PARENT_WRITE, - updateRule: RULE_PARENT_SCOPED, - deleteRule: RULE_PARENT_SCOPED, - })(ids), + col( + "bonus_templates", + [ + rel("famId", ids.fams, false), + text("name", true), + text("description"), + select( + "target", + ["individual", "competitive", "collaborative"], + true, + ), + select("type", ["threshold", "count", "manual"], true), + select("thresholdType", ["points", "percent"], false), + select("occurrence", ["recurring", "once"], true), + select("rewardType", ["points", "cash", "prize"], true), + text("rewardValue", false), + number("criteriaValue"), + select("period", ["schedule", "daily", "weekly", "monthly"]), + bool("isPocketMoney"), + bool("global"), + text("icon"), + text("color"), + ], + { + listRule: "global = true || famId = @request.auth.famId", + viewRule: "global = true || famId = @request.auth.famId", + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + }, + )(ids), }, { name: "settings", build: (ids) => - col("settings", [rel("famId", ids.fams, true), text("webhookUrl")], { - createRule: RULE_PARENT_WRITE, - updateRule: RULE_PARENT_SCOPED, - deleteRule: RULE_PARENT_SCOPED, - })(ids), + col( + "settings", + [rel("famId", ids.fams, true), text("webhookUrl"), number("lockMins")], + { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + }, + )(ids), }, { name: "chore_templates", build: (ids) => - col("chore_templates", [ - rel("famId", ids.fams, false), - text("name", true), - text("description"), - select("defaultFrequency", ["daily", "weekly"], true), - select("defaultType", ["points", "money"], true), - number("defaultValue", true), - bool("global"), - text("icon"), - text("color"), - ], { - listRule: "global = true || famId = @request.auth.famId", - viewRule: "global = true || famId = @request.auth.famId", - createRule: RULE_PARENT_WRITE, - updateRule: RULE_PARENT_SCOPED, - deleteRule: RULE_PARENT_SCOPED, - })(ids), + col( + "chore_templates", + [ + rel("famId", ids.fams, false), + text("name", true), + text("description"), + select("defaultFrequency", ["daily", "weekly"], true), + select("defaultType", ["points", "money"], true), + number("defaultValue", true), + bool("global"), + text("icon"), + text("color"), + ], + { + listRule: "global = true || famId = @request.auth.famId", + viewRule: "global = true || famId = @request.auth.famId", + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + }, + )(ids), }, { name: "bonus_configs", build: (ids) => - col("bonus_configs", [ - rel("famId", ids.fams, true), - text("name", true), - text("description"), - select("target", ["individual", "competitive", "collaborative"], true), - select("type", ["threshold", "count", "manual"], true), - select("thresholdType", ["points", "percent"], false), - select("occurrence", ["recurring", "once"], true), - select("rewardType", ["points", "cash", "prize"], true), - text("rewardValue", false), - number("criteriaValue"), - rel("memberId", ids.users), - select("period", ["schedule", "daily", "weekly", "monthly"]), - select("status", ["active", "completed"], true), - text("targetChoreId"), - jsonField("targetChoreIds"), - bool("isPocketMoney"), - // Standalone/one-off reward progress window. `period` stays empty for - // these (they're measured once, not per-cycle). `completeBy` bounds how - // far progress counts: `unlimited` (cumulative since startDate), - // `week` (end of current week), or `custom` (a specific completeByDate). - // `startDate` is when tracking begins — without it "unlimited" would - // count points earned before the reward existed. - select("completeBy", ["unlimited", "week", "custom"]), - text("startDate"), - text("completeByDate"), - ], { - createRule: RULE_PARENT_WRITE, - updateRule: RULE_PARENT_SCOPED, - deleteRule: RULE_PARENT_SCOPED, - })(ids), + col( + "bonus_configs", + [ + rel("famId", ids.fams, true), + text("name", true), + text("description"), + select( + "target", + ["individual", "competitive", "collaborative"], + true, + ), + select("type", ["threshold", "count", "manual"], true), + select("thresholdType", ["points", "percent"], false), + select("occurrence", ["recurring", "once"], true), + select("rewardType", ["points", "cash", "prize"], true), + text("rewardValue", false), + number("criteriaValue"), + rel("memberId", ids.users), + select("period", ["schedule", "daily", "weekly", "monthly"]), + select("status", ["active", "completed"], true), + text("targetChoreId"), + jsonField("targetChoreIds"), + bool("isPocketMoney"), + // Standalone/one-off reward progress window. `period` stays empty for + // these (they're measured once, not per-cycle). `completeBy` bounds how + // far progress counts: `unlimited` (cumulative since startDate), + // `week` (end of current week), or `custom` (a specific completeByDate). + // `startDate` is when tracking begins — without it "unlimited" would + // count points earned before the reward existed. + select("completeBy", ["unlimited", "week", "custom"]), + text("startDate"), + text("completeByDate"), + ], + { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + }, + )(ids), }, { name: "weekly_history", build: (ids) => - col("weekly_history", [ - rel("famId", ids.fams, true), - rel("memberId", ids.users, true), - date("weekStart"), - number("pointsEarned"), - number("moneyEarned"), - number("choresCompleted"), - number("bonusEarned"), - ], { - createRule: RULE_PARENT_WRITE, - updateRule: RULE_PARENT_SCOPED, - deleteRule: RULE_PARENT_SCOPED, - })(ids), + col( + "weekly_history", + [ + rel("famId", ids.fams, true), + rel("memberId", ids.users, true), + date("weekStart"), + number("pointsEarned"), + number("moneyEarned"), + number("choresCompleted"), + number("bonusEarned"), + ], + { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + }, + )(ids), }, { name: "seasons", build: (ids) => - col("seasons", [ - rel("famId", ids.fams, true), - text("name", true), - text("color"), - bool("active"), - date("autoDisable"), - date("autoStart"), - ], { - createRule: RULE_PARENT_WRITE, - updateRule: RULE_PARENT_SCOPED, - deleteRule: RULE_PARENT_SCOPED, - })(ids), + col( + "seasons", + [ + rel("famId", ids.fams, true), + text("name", true), + text("color"), + bool("active"), + date("autoDisable"), + date("autoStart"), + ], + { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + }, + )(ids), }, { name: "messages", build: (ids) => - col("messages", [ - rel("famId", ids.fams, true), - select("authorType", ["admin", "member"], true), - text("authorId", true), - text("authorName", true), - text("authorColor"), - text("content", true), - // Explicit createdAt: PB 0.39 does NOT auto-add createdAt to - // API-created collections (0.25 did). Chat filters/sorts on it. - date("createdAt"), - text("clientId"), - ], { - createRule: RULE_FAM_WRITE, - updateRule: RULE_FAM_SCOPED, - deleteRule: RULE_FAM_SCOPED, - listRule: RULE_FAM_READ, - viewRule: RULE_FAM_READ, - })(ids), + col( + "messages", + [ + rel("famId", ids.fams, true), + select("authorType", ["admin", "member"], true), + text("authorId", true), + text("authorName", true), + text("authorColor"), + text("content", true), + // Explicit createdAt: PB 0.39 does NOT auto-add createdAt to + // API-created collections (0.25 did). Chat filters/sorts on it. + date("createdAt"), + text("clientId"), + ], + { + createRule: RULE_FAM_WRITE, + updateRule: RULE_FAM_SCOPED, + deleteRule: RULE_FAM_SCOPED, + listRule: RULE_FAM_READ, + viewRule: RULE_FAM_READ, + }, + )(ids), }, { name: "chat_typing", build: (ids) => - col("chat_typing", [ - rel("famId", ids.fams, true), - text("actorId", true), - select("actorType", ["admin", "member"], true), - text("authorName", true), - text("authorColor"), - bool("typing"), - ], { - createRule: RULE_FAM_WRITE, - updateRule: RULE_FAM_SCOPED, - deleteRule: RULE_FAM_SCOPED, - listRule: RULE_FAM_READ, - viewRule: RULE_FAM_READ, - })(ids), + col( + "chat_typing", + [ + rel("famId", ids.fams, true), + text("actorId", true), + select("actorType", ["admin", "member"], true), + text("authorName", true), + text("authorColor"), + bool("typing"), + ], + { + createRule: RULE_FAM_WRITE, + updateRule: RULE_FAM_SCOPED, + deleteRule: RULE_FAM_SCOPED, + listRule: RULE_FAM_READ, + viewRule: RULE_FAM_READ, + }, + )(ids), }, { name: "rewards", build: (ids) => - col("rewards", [ - rel("famId", ids.fams, true), - rel("memberId", ids.users, true), - rel("bonusConfigId", ids.bonus_configs), - text("label", true), - number("value", true), - select("rewardType", ["cash", "prize", "points"], true), - select("status", ["unclaimed", "requested", "claimed"], true), - select("claimable", ["immediate", "payday"], false), - text("settleDate"), - date("claimedAt"), - date("requestedAt"), - text("date"), - ], { - createRule: RULE_FAM_WRITE, - updateRule: RULE_FAM_SCOPED, - deleteRule: RULE_FAM_SCOPED, - })(ids), + col( + "rewards", + [ + rel("famId", ids.fams, true), + rel("memberId", ids.users, true), + rel("bonusConfigId", ids.bonus_configs), + text("label", true), + number("value", true), + select("rewardType", ["cash", "prize", "points"], true), + select("status", ["unclaimed", "requested", "claimed"], true), + select("claimable", ["immediate", "payday"], false), + text("settleDate"), + date("claimedAt"), + date("requestedAt"), + text("date"), + ], + { + createRule: RULE_FAM_WRITE, + updateRule: RULE_FAM_SCOPED, + deleteRule: RULE_FAM_SCOPED, + }, + )(ids), }, { name: "assigned_chores", build: (ids) => - col("assigned_chores", [ - rel("famId", ids.fams, true), - rel("memberId", ids.users, false), // null = shared with all members - bool("shared"), // true = initially shared (tracked for revoke) - rel("templateId", ids.chore_templates), - select("frequency", ["daily", "weekly"], true), - select("type", ["points", "money", "emoji"], true), - number("value", false), - text("customName"), - text("description"), - text("icon"), - text("color"), - text("emoji"), - jsonField("seasonIds"), - bool("isTodo"), - text("startDate"), - text("completeBy"), - ], { - createRule: RULE_PARENT_WRITE, - updateRule: RULE_PARENT_SCOPED, - deleteRule: RULE_PARENT_SCOPED, - })(ids), + col( + "assigned_chores", + [ + rel("famId", ids.fams, true), + rel("memberId", ids.users, false), // null = shared with all members + bool("shared"), // true = initially shared (tracked for revoke) + rel("templateId", ids.chore_templates), + select("frequency", ["daily", "weekly"], true), + select("type", ["points", "money", "emoji"], true), + number("value", false), + text("customName"), + text("description"), + text("icon"), + text("color"), + text("emoji"), + jsonField("seasonIds"), + bool("isTodo"), + text("startDate"), + text("completeBy"), + ], + { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + }, + )(ids), }, { name: "completions", build: (ids) => - col("completions", [ - rel("famId", ids.fams, true), - rel("memberId", ids.users, true), - rel("assignedChoreId", ids.assigned_chores, true), - date("date"), - date("completedAt"), - text("rewardId"), - ], { - createRule: RULE_FAM_WRITE, - updateRule: RULE_FAM_SCOPED, - deleteRule: RULE_FAM_SCOPED, - })(ids), + col( + "completions", + [ + rel("famId", ids.fams, true), + rel("memberId", ids.users, true), + rel("assignedChoreId", ids.assigned_chores, true), + date("date"), + date("completedAt"), + text("rewardId"), + ], + { + createRule: RULE_FAM_WRITE, + updateRule: RULE_FAM_SCOPED, + deleteRule: RULE_FAM_SCOPED, + }, + )(ids), }, ];