fix mcs
This commit is contained in:
@@ -20,13 +20,13 @@
|
|||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
| Role | Auth | Session | Record in |
|
| Role | Auth | Session | Record in |
|
||||||
| -------------- | --------------------------------------------- | -------------------------- | ------------------------- |
|
| -------------- | ----------------------------------------------- | ------------------------------------------------ | ----------------------- |
|
||||||
| Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) |
|
| 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`) |
|
| Member (child) | Invite OTP + server-derived password | Shared-device: `pb_token_<userId>` + `pb_active` | `users` (role `child`) |
|
||||||
| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — |
|
| 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 }`.
|
- **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_<userId>`, 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.
|
- **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`.
|
- 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`).
|
- 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)
|
- `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`)
|
- `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)
|
- `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").
|
- `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
|
- `fams` — name, slug, stripeCustomerId, paymentMode (`none|code|sub|canceled`), active, accessCodeId, accessCodeEnteredAt
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId
|
- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId
|
||||||
- `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl
|
- `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
|
## Routes
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
/login · /logout Parent email/password login / logout
|
/login · /logout Parent email/password login / logout
|
||||||
/signup Parent + family signup (wizard: fam → child → code → plan)
|
/signup Parent + family signup (wizard: fam → child → code → plan)
|
||||||
/{fam}/join/{username} Member invite (OTP join), auto-fills from ?code=
|
/{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} Fam dashboard
|
||||||
/{fam}/{username} Parent → admin overview, Child → member kanban (role from session)
|
/{fam}/{username} Parent → admin overview, Child → member kanban (role from session)
|
||||||
/{fam}/{username}/chores Chore templates & assignment grid
|
/{fam}/{username}/chores Chore templates & assignment grid
|
||||||
@@ -158,7 +160,7 @@ let configs = $derived(
|
|||||||
Two patterns based on who's acting:
|
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 <pb_token>` |
|
| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `Authorization: Bearer <pb_token>` |
|
||||||
| 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 |
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
## UI Component Architecture (Jul 2026)
|
## UI Component Architecture (Jul 2026)
|
||||||
|
|
||||||
### Layout Hierarchy
|
### Layout Hierarchy
|
||||||
|
|
||||||
```
|
```
|
||||||
+layout.svelte ← global styles, meta, favicon
|
+layout.svelte ← global styles, meta, favicon
|
||||||
├── /login, /signup, /join/* ← auth pages (no shell)
|
├── /login, /signup, /join/* ← auth pages (no shell)
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Sidebar (collapsible to mini-mode)
|
### Sidebar (collapsible to mini-mode)
|
||||||
|
|
||||||
- Header: app name (FamDone)
|
- Header: app name (FamDone)
|
||||||
- Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses
|
- Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses
|
||||||
- Member CTAs: Dashboard, Preferences
|
- Member CTAs: Dashboard, Preferences
|
||||||
@@ -41,10 +43,12 @@
|
|||||||
- Role-aware: items differ based on admin vs member route
|
- Role-aware: items differ based on admin vs member route
|
||||||
|
|
||||||
### TopNav
|
### TopNav
|
||||||
|
|
||||||
- Slot `announcement` (center) — system/family messages
|
- Slot `announcement` (center) — system/family messages
|
||||||
- Slot `actions` (right) — user status, claim/message
|
- Slot `actions` (right) — user status, claim/message
|
||||||
|
|
||||||
### Page Content
|
### Page Content
|
||||||
|
|
||||||
- `ViewHeader` — title + subtitle + tool bar (tabs, weeknav, sort)
|
- `ViewHeader` — title + subtitle + tool bar (tabs, weeknav, sort)
|
||||||
- `CardGrid` — 3-column grid, Cards span columns via `cols` prop
|
- `CardGrid` — 3-column grid, Cards span columns via `cols` prop
|
||||||
- `Card` — 1/2/3 col span, micro-layout per page
|
- `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)
|
- `Button` — consistent CTAs with `variant` (primary/secondary/ghost/danger) and `size` (sm/md/lg)
|
||||||
|
|
||||||
### Components (frontend/src/lib/components/)
|
### Components (frontend/src/lib/components/)
|
||||||
|
|
||||||
- `Sidebar.svelte`, `TopNav.svelte`, `Footer.svelte`
|
- `Sidebar.svelte`, `TopNav.svelte`, `Footer.svelte`
|
||||||
- `ViewHeader.svelte`, `Card.svelte`, `CardGrid.svelte`
|
- `ViewHeader.svelte`, `Card.svelte`, `CardGrid.svelte`
|
||||||
- `Button.svelte`, `Accordion.svelte`
|
- `Button.svelte`, `Accordion.svelte`
|
||||||
@@ -177,8 +182,6 @@
|
|||||||
- **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}`.
|
- **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).
|
- **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
|
### 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.
|
- **`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.
|
||||||
@@ -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.
|
- **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
|
### 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/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.
|
- 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/`.
|
- 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.
|
- 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)
|
### 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/...`.
|
- 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`).
|
- 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`).
|
- 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.
|
- 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)
|
### 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).
|
- **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.
|
- **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`.
|
- **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).
|
- **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`)
|
### 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.
|
- **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):
|
- **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).
|
1. Fresh store: `migrate()` bootstraps all 14 `SCHEMA_PLAN` collections + every field migration cleanly (schema field builders are 0.39-compatible).
|
||||||
@@ -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`).
|
- **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)
|
### 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.
|
- **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`.
|
- 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.
|
- **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
|
### 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).
|
- **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`:
|
- **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
|
- `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).
|
- **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.
|
- **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_<childId>` 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_<id>`, 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).
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import type { Handle } from '@sveltejs/kit';
|
|||||||
import { createPbClient } from '$lib/server/pocketbase';
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
import {
|
import {
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
|
ACTIVE_COOKIE,
|
||||||
|
childSessionCookie,
|
||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
|
setChildSessionCookie,
|
||||||
|
clearActiveChild,
|
||||||
PLATFORM_SESSION_COOKIE,
|
PLATFORM_SESSION_COOKIE,
|
||||||
clearPlatformSession
|
clearPlatformSession
|
||||||
} from '$lib/server/session';
|
} 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).
|
// Run the PB schema migration once at server boot (idempotent).
|
||||||
void migrateOnBoot();
|
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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.user = null;
|
event.locals.user = null;
|
||||||
event.locals.pbToken = null;
|
event.locals.pbToken = null;
|
||||||
@@ -36,32 +55,50 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
return resolve(event);
|
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);
|
const token = event.cookies.get(SESSION_COOKIE);
|
||||||
if (token) {
|
if (token) {
|
||||||
const pb = createPbClient(token);
|
const pb = createPbClient(token);
|
||||||
try {
|
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();
|
const { record, token: freshToken } = await pb.collection('users').authRefresh();
|
||||||
event.locals.user = {
|
const session = sessionFrom(record, freshToken);
|
||||||
id: record.id,
|
event.locals.user = session;
|
||||||
name: record.name || record.username || '',
|
event.locals.pbToken = session.token;
|
||||||
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;
|
|
||||||
|
|
||||||
if (freshToken !== token) {
|
if (freshToken !== token) {
|
||||||
setSessionCookie(event.cookies, freshToken);
|
setSessionCookie(event.cookies, freshToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PinPad from './PinPad.svelte';
|
||||||
|
import { resetClock } from '$lib/client/lock';
|
||||||
|
|
||||||
|
// Post-join wizard for children on a shared device:
|
||||||
|
// 1. "Is this computer shared?" (skipped when the device already has
|
||||||
|
// other child sessions — evidence of sharing, PIN is required).
|
||||||
|
// 2. Pick a 3-digit PIN (optional on the question path).
|
||||||
|
// 3. Navigate to the child's dashboard.
|
||||||
|
let {
|
||||||
|
targetUrl = '',
|
||||||
|
force = false
|
||||||
|
}: {
|
||||||
|
targetUrl: string;
|
||||||
|
force?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// force: the device already had other kids' sessions → PIN required now.
|
||||||
|
let step = $state<'q' | 'p1' | 'p2'>(force ? 'p1' : 'q');
|
||||||
|
let pin1 = $state('');
|
||||||
|
let padKey = $state(0);
|
||||||
|
let status = $state('');
|
||||||
|
|
||||||
|
function go() {
|
||||||
|
resetClock();
|
||||||
|
window.location.assign(targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePin(pin: string) {
|
||||||
|
status = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/pins', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'ensure', pin })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not save PIN');
|
||||||
|
go();
|
||||||
|
} catch (e) {
|
||||||
|
status = e instanceof Error ? e.message : 'Could not save PIN';
|
||||||
|
step = 'p1';
|
||||||
|
padKey += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if step === 'q'}
|
||||||
|
<div class="wizard">
|
||||||
|
<h2 class="wizard-title">Is this computer shared?</h2>
|
||||||
|
<p class="wizard-sub">
|
||||||
|
Will your brothers or sisters use this computer too? A 3-digit PIN lets you switch to your
|
||||||
|
chores in a tap.
|
||||||
|
</p>
|
||||||
|
<div class="wizard-actions">
|
||||||
|
<button type="button" class="wizard-btn primary" onclick={() => (step = 'p1')}>Yes</button>
|
||||||
|
<button type="button" class="wizard-btn" onclick={go}>No — just me</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if step === 'p1'}
|
||||||
|
<div class="wizard">
|
||||||
|
<h2 class="wizard-title">Pick a PIN</h2>
|
||||||
|
<p class="wizard-sub">3 numbers that are easy for you to remember — you'll use it to switch.</p>
|
||||||
|
{#key padKey}
|
||||||
|
<PinPad
|
||||||
|
label="New PIN"
|
||||||
|
oncomplete={(pin) => {
|
||||||
|
pin1 = pin;
|
||||||
|
step = 'p2';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{#if status}<p class="wizard-error">{status}</p>{/if}
|
||||||
|
<button type="button" class="wizard-link" onclick={go}>Skip for now</button>
|
||||||
|
</div>
|
||||||
|
{:else if step === 'p2'}
|
||||||
|
<div class="wizard">
|
||||||
|
<h2 class="wizard-title">Confirm your PIN</h2>
|
||||||
|
<p class="wizard-sub">
|
||||||
|
Enter it once more — {pin1 ? `it starts with ${pin1[0]}·` : ''}your parent can always read it
|
||||||
|
out if you forget.
|
||||||
|
</p>
|
||||||
|
{#key padKey}
|
||||||
|
<PinPad label="Confirm PIN" oncomplete={savePin} />
|
||||||
|
{/key}
|
||||||
|
{#if status}<p class="wizard-error">{status}</p>{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="wizard-link"
|
||||||
|
onclick={() => {
|
||||||
|
step = 'p1';
|
||||||
|
padKey += 1;
|
||||||
|
}}>Back</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wizard {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.wizard-title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.wizard-sub {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.wizard-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.wizard-btn {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wizard-btn.primary {
|
||||||
|
background: #6366f1;
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.wizard-btn:hover {
|
||||||
|
filter: brightness(0.97);
|
||||||
|
}
|
||||||
|
.wizard-link {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.wizard-link:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.wizard-error {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
label = 'Enter your PIN',
|
||||||
|
busy = false,
|
||||||
|
oncomplete
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
busy?: boolean;
|
||||||
|
oncomplete: (pin: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let digits = $state<string[]>([]);
|
||||||
|
|
||||||
|
function press(d: string) {
|
||||||
|
if (busy || digits.length >= 3) return;
|
||||||
|
digits = [...digits, d];
|
||||||
|
if (digits.length === 3) oncomplete(digits.join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
digits = digits.slice(0, -1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pin-pad">
|
||||||
|
<p class="pin-label">{label}</p>
|
||||||
|
<div class="pin-dots" aria-hidden="true">
|
||||||
|
{#each [0, 1, 2] as i}
|
||||||
|
<span class="dot" class:filled={digits.length > i}></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="pin-keys">
|
||||||
|
{#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as d}
|
||||||
|
<button type="button" class="key" disabled={busy} onclick={() => press(d)}>{d}</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="key ghost"
|
||||||
|
disabled={busy || digits.length === 0}
|
||||||
|
onclick={() => back()}>⌫</button
|
||||||
|
>
|
||||||
|
<button type="button" class="key" disabled={busy} onclick={() => press('0')}>0</button>
|
||||||
|
<span class="key ghost"> </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pin-pad {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.pin-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.pin-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #cbd5e1;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.dot.filled {
|
||||||
|
background: #6366f1;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
.pin-keys {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 64px);
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.key {
|
||||||
|
height: 56px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.1s,
|
||||||
|
transform 0.05s;
|
||||||
|
}
|
||||||
|
.key:hover:not(:disabled) {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
.key:active:not(:disabled) {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
.key:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.key.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PinPad from './PinPad.svelte';
|
||||||
|
import { resetClock } from '$lib/client/lock';
|
||||||
|
|
||||||
|
export type QuickProfile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
username: string; // `{famSlug}:{handle}`
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
profiles = [],
|
||||||
|
famSlug = '',
|
||||||
|
famName = '',
|
||||||
|
activeId = '',
|
||||||
|
standalone = false,
|
||||||
|
oncancel = () => {}
|
||||||
|
}: {
|
||||||
|
profiles?: QuickProfile[];
|
||||||
|
famSlug?: string;
|
||||||
|
famName?: string;
|
||||||
|
activeId?: string;
|
||||||
|
standalone?: boolean;
|
||||||
|
oncancel?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let selectedId = $state('');
|
||||||
|
let padKey = $state(0);
|
||||||
|
let status = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
const selected = $derived(profiles.find((p) => p.id === selectedId) || null);
|
||||||
|
|
||||||
|
function initial(name: string) {
|
||||||
|
return (name || '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetUrl(p: QuickProfile) {
|
||||||
|
const handle = (p.username || '').split(':').pop() || p.id;
|
||||||
|
return `/${famSlug}/${encodeURIComponent(handle)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchTo(id: string, pin: string) {
|
||||||
|
busy = true;
|
||||||
|
status = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/switch-user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: id, pin })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not switch');
|
||||||
|
// Fresh activation — the mount-time lock check must not trip.
|
||||||
|
resetClock();
|
||||||
|
const target = profiles.find((p) => p.id === id);
|
||||||
|
window.location.assign(targetUrl(target || ({ id, username: '' } as QuickProfile)));
|
||||||
|
} catch (e) {
|
||||||
|
status = e instanceof Error ? e.message : 'Could not switch';
|
||||||
|
busy = false;
|
||||||
|
padKey += 1;
|
||||||
|
selectedId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProfile(id: string) {
|
||||||
|
const p = profiles.find((x) => x.id === id);
|
||||||
|
if (!p || !confirm(`Remove ${p.name} from this computer?`)) return;
|
||||||
|
await fetch('/api/device/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: id })
|
||||||
|
}).catch(() => {});
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="picker-backdrop" class:standalone>
|
||||||
|
<div class="picker-card">
|
||||||
|
{#if selected}
|
||||||
|
<h2 class="picker-title">Hi {selected.name}!</h2>
|
||||||
|
<p class="picker-sub">Enter your 3-digit PIN to switch.</p>
|
||||||
|
{#key padKey}
|
||||||
|
<PinPad {busy} oncomplete={(pin) => switchTo(selected.id, pin)} />
|
||||||
|
{/key}
|
||||||
|
{#if status}
|
||||||
|
<p class="picker-error">{status}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="picker-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link-btn"
|
||||||
|
onclick={() => {
|
||||||
|
selectedId = '';
|
||||||
|
status = '';
|
||||||
|
padKey += 1;
|
||||||
|
}}>Back</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<h2 class="picker-title">
|
||||||
|
{standalone ? famName || 'Welcome' : "Who's using the computer?"}
|
||||||
|
</h2>
|
||||||
|
<p class="picker-sub">
|
||||||
|
{standalone
|
||||||
|
? 'Pick your profile to get to your chores.'
|
||||||
|
: 'Pick a profile and enter its PIN.'}
|
||||||
|
</p>
|
||||||
|
<div class="picker-grid">
|
||||||
|
{#each profiles as p (p.id)}
|
||||||
|
<div
|
||||||
|
class="profile"
|
||||||
|
class:active={p.id === activeId}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
style={`--dot:${p.color}`}
|
||||||
|
onclick={() => (selectedId = p.id)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') selectedId = p.id;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="avatar" style="background:{p.color}">{initial(p.name)}</span>
|
||||||
|
<span class="pname">{p.name}</span>
|
||||||
|
{#if p.id === activeId}<span class="pill">current</span>{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-btn"
|
||||||
|
aria-label={`Remove ${p.name} from this computer`}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeProfile(p.id);
|
||||||
|
}}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if standalone}
|
||||||
|
<div class="picker-footer">
|
||||||
|
<a href={`/${famSlug}/join`}>Add a child with a code</a>
|
||||||
|
<span class="dot-sep">·</span>
|
||||||
|
<a href="/login">Parent login</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="picker-actions">
|
||||||
|
<button type="button" class="link-btn" onclick={oncancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.picker-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 120;
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.picker-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.picker-title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.picker-sub {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.profile {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.12s,
|
||||||
|
transform 0.05s;
|
||||||
|
}
|
||||||
|
.profile:hover {
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
}
|
||||||
|
.profile:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
.profile.active {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pname {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
}
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.remove-btn:hover {
|
||||||
|
background: #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.picker-error {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.picker-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.link-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.link-btn:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.picker-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.picker-footer a {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.picker-footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.dot-sep {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -36,6 +36,8 @@ export const bellIcon =
|
|||||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>';
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>';
|
||||||
export const chatIcon =
|
export const chatIcon =
|
||||||
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>';
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>';
|
||||||
|
export const monitorIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
|
||||||
export const sendIcon =
|
export const sendIcon =
|
||||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
||||||
export const checkCircleIcon =
|
export const checkCircleIcon =
|
||||||
|
|||||||
@@ -14,3 +14,6 @@ export { default as NoticeDialog } from './NoticeDialog.svelte';
|
|||||||
export { default as PricingPlans } from './PricingPlans.svelte';
|
export { default as PricingPlans } from './PricingPlans.svelte';
|
||||||
export { default as CheckboxGrid } from './CheckboxGrid.svelte';
|
export { default as CheckboxGrid } from './CheckboxGrid.svelte';
|
||||||
export { default as PatternPicker } from './PatternPicker.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';
|
||||||
|
|||||||
@@ -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');
|
if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired');
|
||||||
|
|
||||||
const authPb = createPbClient();
|
const authPb = createPbClient();
|
||||||
await authPb
|
const { record } = await authPb
|
||||||
.collection('users')
|
.collection('users')
|
||||||
.authWithPassword(fullUsername, derivePassword(famSlug, handleName));
|
.authWithPassword(fullUsername, derivePassword(famSlug, handleName));
|
||||||
return authPb.authStore.token;
|
return { token: authPb.authStore.token, userId: record.id };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,6 +262,54 @@ async function ensureOtp(ids: Record<string, string>): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<string, string>): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
// 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
|
// 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
|
// only), so all rules are null like `otp`. A code grants a family a subscription
|
||||||
@@ -404,6 +452,7 @@ async function ensureSchema(): Promise<void> {
|
|||||||
|
|
||||||
await ensureUsers(ids);
|
await ensureUsers(ids);
|
||||||
await ensureOtp(ids);
|
await ensureOtp(ids);
|
||||||
|
await ensurePins(ids);
|
||||||
console.log('[migrate] Schema bootstrapped.');
|
console.log('[migrate] Schema bootstrapped.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,11 +522,16 @@ export async function migrate(): Promise<void> {
|
|||||||
// return) so new platform collections/fields/seed land on existing installs.
|
// return) so new platform collections/fields/seed land on existing installs.
|
||||||
await ensureUserFields();
|
await ensureUserFields();
|
||||||
await ensureFamFields();
|
await ensureFamFields();
|
||||||
|
await ensureSettingsFields();
|
||||||
await ensureBonusFields();
|
await ensureBonusFields();
|
||||||
await ensureTemplateFields();
|
await ensureTemplateFields();
|
||||||
await ensureAssignedChoreFields();
|
await ensureAssignedChoreFields();
|
||||||
await ensureAccessCodes();
|
await ensureAccessCodes();
|
||||||
await ensurePlatform();
|
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();
|
await ensureDefaultSeasons();
|
||||||
if (await isDemo()) {
|
if (await isDemo()) {
|
||||||
await seedDemoFamily();
|
await seedDemoFamily();
|
||||||
|
|||||||
@@ -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<string | null> {
|
||||||
|
const row = await getPinRow(userId);
|
||||||
|
return row?.pin || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasPin(userId: string): Promise<boolean> {
|
||||||
|
return (await getPin(userId)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPin(famId: string, userId: string, pin: string): Promise<void> {
|
||||||
|
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<string> {
|
||||||
|
const pin = generatePin();
|
||||||
|
await setPin(famId, userId, pin);
|
||||||
|
return pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPin(userId: string, pin: string): Promise<boolean> {
|
||||||
|
const row = await getPinRow(userId);
|
||||||
|
return !!row && row.pin === pin;
|
||||||
|
}
|
||||||
@@ -1,22 +1,66 @@
|
|||||||
import type { Cookies } from '@sveltejs/kit';
|
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 server hooks (authRefresh -> locals.user)
|
||||||
// - the client SDK (seeded from page.data.pbToken -> authenticated famStore reads/subscribe)
|
// - 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.
|
// 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.
|
// Secure flag is set in prod so it's only sent over HTTPS.
|
||||||
export const SESSION_COOKIE = 'pb_token';
|
export const SESSION_COOKIE = 'pb_token';
|
||||||
|
// Shared-device multi-session: children hold ONE cookie per account
|
||||||
|
// (`pb_token_<userId>`); `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
|
const MAX_AGE = 60 * 60 * 24 * 5; // 5 days — matches the PB users auth token duration
|
||||||
|
|
||||||
export function setSessionCookie(cookies: Cookies, token: string) {
|
function cookieOpts() {
|
||||||
cookies.set(SESSION_COOKIE, token, {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax' as const,
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: MAX_AGE,
|
maxAge: MAX_AGE,
|
||||||
secure: import.meta.env.PROD
|
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) {
|
export function clearSessionCookie(cookies: Cookies) {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
import { getPlatformFlags } from '$lib/server/platform';
|
import { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
import { scanChildSessions } from '$lib/server/session';
|
||||||
|
|
||||||
// Platform settings are public (read-only): feature flags ride along with
|
// 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.
|
// every page's data so any component can deduce them via page.data.platformFlags.
|
||||||
export const load: LayoutServerLoad = async () => {
|
export const load: LayoutServerLoad = async (event) => {
|
||||||
return { platformFlags: await getPlatformFlags() };
|
return {
|
||||||
|
platformFlags: await getPlatformFlags(),
|
||||||
|
// Children with session cookies on THIS device (shared-computer picker).
|
||||||
|
deviceChildIds: scanChildSessions(event.cookies)
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
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 { createServices, type ChatActor } from '$lib/server/services';
|
||||||
import { ensureFamAccess } from '$lib/server/access';
|
import { ensureFamAccess } from '$lib/server/access';
|
||||||
import { seedDemoCompletions } from '$lib/server/migrate';
|
import { seedDemoCompletions } from '$lib/server/migrate';
|
||||||
import { getPlatformFlags } from '$lib/server/platform';
|
import { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
import { scanChildSessions } from '$lib/server/session';
|
||||||
|
|
||||||
async function paydayCheck(famId: string, pbToken: string) {
|
async function paydayCheck(famId: string, pbToken: string) {
|
||||||
try {
|
try {
|
||||||
@@ -56,7 +57,62 @@ export async function load(event) {
|
|||||||
const session = event.locals.user;
|
const session = event.locals.user;
|
||||||
const role = session?.role || 'child';
|
const role = session?.role || 'child';
|
||||||
const isParent = role === 'parent';
|
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 famId = '';
|
||||||
let chat: {
|
let chat: {
|
||||||
@@ -70,6 +126,7 @@ export async function load(event) {
|
|||||||
mode: 'none' as 'none' | 'code' | 'sub' | 'canceled',
|
mode: 'none' as 'none' | 'code' | 'sub' | 'canceled',
|
||||||
reason: ''
|
reason: ''
|
||||||
};
|
};
|
||||||
|
let lockMins = 0;
|
||||||
|
|
||||||
if (session && pbToken) {
|
if (session && pbToken) {
|
||||||
famId = session.famId;
|
famId = session.famId;
|
||||||
@@ -82,11 +139,22 @@ export async function load(event) {
|
|||||||
fam = res.fam;
|
fam = res.fam;
|
||||||
famAccess = res.access;
|
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
|
// 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
|
// 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
|
// 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.
|
// slug so the id is replaced everywhere it'd otherwise persist.
|
||||||
const paramFam = event.params.fam;
|
|
||||||
const canonicalSlug = fam?.slug;
|
const canonicalSlug = fam?.slug;
|
||||||
if (canonicalSlug && paramFam && paramFam !== canonicalSlug) {
|
if (canonicalSlug && paramFam && paramFam !== canonicalSlug) {
|
||||||
const rest = event.url.pathname.replace(`/${paramFam}`, '') || '/';
|
const rest = event.url.pathname.replace(`/${paramFam}`, '') || '/';
|
||||||
@@ -100,7 +168,7 @@ export async function load(event) {
|
|||||||
return {
|
return {
|
||||||
// Canonical fam slug — from the URL param ([fam] routes). Client code
|
// Canonical fam slug — from the URL param ([fam] routes). Client code
|
||||||
// reads page.data.famSlug; never copy it into local $state.
|
// reads page.data.famSlug; never copy it into local $state.
|
||||||
famSlug: event.params.fam || '',
|
famSlug: paramFam || '',
|
||||||
session: session
|
session: session
|
||||||
? {
|
? {
|
||||||
famId: session.famId,
|
famId: session.famId,
|
||||||
@@ -121,6 +189,15 @@ export async function load(event) {
|
|||||||
pbToken,
|
pbToken,
|
||||||
fam,
|
fam,
|
||||||
famAccess,
|
famAccess,
|
||||||
demoMode
|
demoMode,
|
||||||
|
picker: false,
|
||||||
|
pickerFamName: '',
|
||||||
|
pickerChildren: [],
|
||||||
|
deviceChildIds,
|
||||||
|
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'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
import { notices } from '$lib/stores/notices.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 { chatIcon, monitorIcon } from '$lib/components/icons';
|
||||||
import { recordShortcut } from '$lib/shortcut';
|
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 { themeShades } from '$lib/theme';
|
||||||
import '$lib/theme-patterns.css';
|
import '$lib/theme-patterns.css';
|
||||||
import { themeDraft } from '$lib/stores/theme.svelte';
|
import { themeDraft } from '$lib/stores/theme.svelte';
|
||||||
@@ -29,6 +31,87 @@
|
|||||||
// Pattern size in vw units ('' = untouched → pattern class default).
|
// Pattern size in vw units ('' = untouched → pattern class default).
|
||||||
let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || ''));
|
let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || ''));
|
||||||
let bgSizeVw = $derived(Number(bgSize) > 0 ? bgSize : '');
|
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<typeof setInterval> | 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 }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 || !sharedOn || 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<Session | null>(data.session);
|
let session = $state<Session | null>(data.session);
|
||||||
let isParent = $state(data.isParent);
|
let isParent = $state(data.isParent);
|
||||||
let role = $state(data.role);
|
let role = $state(data.role);
|
||||||
@@ -199,10 +282,22 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- layout.svelte picker-mode branch -->
|
||||||
|
{#if pickerMode}
|
||||||
|
<main class="picker-stage">
|
||||||
|
<SharedPicker
|
||||||
|
standalone
|
||||||
|
famSlug={data.famSlug}
|
||||||
|
famName={data.pickerFamName}
|
||||||
|
profiles={data.pickerChildren || []}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
{:else}
|
||||||
<div class="layout-stage" class:chat-open={chatStore.open} class:has-pattern={!!bgPattern}>
|
<div class="layout-stage" class:chat-open={chatStore.open} class:has-pattern={!!bgPattern}>
|
||||||
<div
|
<div
|
||||||
class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}"
|
class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}"
|
||||||
style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`}
|
style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`}
|
||||||
|
>
|
||||||
>
|
>
|
||||||
<Sidebar {famName} session={data.session} {isParent} {role} {isDemo} />
|
<Sidebar {famName} session={data.session} {isParent} {role} {isDemo} />
|
||||||
<TopNav
|
<TopNav
|
||||||
@@ -217,6 +312,27 @@
|
|||||||
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
|
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if data.session && sharedOn && (data.deviceChildIds?.length || 0) > 0}
|
||||||
|
<button
|
||||||
|
class="kid-switch"
|
||||||
|
onclick={() => (pickerOpen = true)}
|
||||||
|
aria-label="Switch user (shared device)"
|
||||||
|
title="Switch user"
|
||||||
|
>
|
||||||
|
<span class="kid-switch-dot" style="background:{data.session.memberColor || '#6366f1'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="shared-toggle"
|
||||||
|
class:on={sharedOn}
|
||||||
|
onclick={toggleShared}
|
||||||
|
aria-label="Toggle shared computer mode"
|
||||||
|
aria-pressed={sharedOn}
|
||||||
|
title={sharedOn ? 'Shared computer mode is ON' : 'Mark this as a shared computer'}
|
||||||
|
>
|
||||||
|
{@html monitorIcon}
|
||||||
|
</button>
|
||||||
</TopNav>
|
</TopNav>
|
||||||
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
|
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
|
||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
@@ -252,8 +368,8 @@
|
|||||||
{claimToast} — view dashboard →
|
{claimToast} — view dashboard →
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if famStore.connectionDown}
|
{#if sharedToast}
|
||||||
<div class="conn-toast" role="status">Reconnecting… live updates paused</div>
|
<div class="shared-toast" role="status">{sharedToast}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<Footer sidebar />
|
<Footer sidebar />
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +378,19 @@
|
|||||||
></button>
|
></button>
|
||||||
{/if}
|
{/if}
|
||||||
<Chat {role} />
|
<Chat {role} />
|
||||||
|
{#if pickerOpen && deviceProfiles.length > 0}
|
||||||
|
<SharedPicker
|
||||||
|
profiles={deviceProfiles}
|
||||||
|
activeId={data.session?.userId}
|
||||||
|
famSlug={data.famSlug}
|
||||||
|
oncancel={() => (pickerOpen = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if famStore.connectionDown}
|
||||||
|
<div class="conn-toast" role="status">Reconnecting… live updates paused</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.claim-toast {
|
.claim-toast {
|
||||||
@@ -426,6 +554,69 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.kid-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kid-switch:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.kid-switch-dot {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
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%;
|
||||||
|
background: whitesmoke;
|
||||||
|
}
|
||||||
.chat-toggle:hover {
|
.chat-toggle:hover {
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
color: #4338ca;
|
color: #4338ca;
|
||||||
|
|||||||
@@ -70,12 +70,13 @@ export async function load(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pbToken = event.cookies.get('pb_token') || '';
|
const pbToken = event.locals.pbToken || '';
|
||||||
const famId = session?.famId || '';
|
const famId = session?.famId || '';
|
||||||
// When a demo parent visits a child's route, session.id is the parent's PB
|
// When a demo parent visits a child's route, session.id is the parent's PB
|
||||||
// record. Look up the actual child user by username so chores load correctly.
|
// record. Look up the actual child user by username so chores load correctly.
|
||||||
const childId = demoParentVisitingChild
|
const childId = demoParentVisitingChild
|
||||||
? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]?.id || ''
|
? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]
|
||||||
|
?.id || ''
|
||||||
: session?.id || '';
|
: session?.id || '';
|
||||||
|
|
||||||
const empty = {
|
const empty = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { servicesFor } from '$lib/server/servicesFor';
|
import { servicesFor } from '$lib/server/servicesFor';
|
||||||
import { getPlatformFlags } from '$lib/server/platform';
|
import { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
import { hasPin } from '$lib/server/pins';
|
||||||
|
|
||||||
async function isDemoMode(): Promise<boolean> {
|
async function isDemoMode(): Promise<boolean> {
|
||||||
return (await getPlatformFlags()).demo;
|
return (await getPlatformFlags()).demo;
|
||||||
@@ -8,7 +9,20 @@ async function isDemoMode(): Promise<boolean> {
|
|||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
if (await isDemoMode()) {
|
if (await isDemoMode()) {
|
||||||
return { verified: false, role: '', token: '', memberId: '', famId: '', memberName: '', memberColor: '', pattern: '', themeSize: '', themeOpacity: '', email: '', demoMode: true };
|
return {
|
||||||
|
verified: false,
|
||||||
|
role: '',
|
||||||
|
token: '',
|
||||||
|
memberId: '',
|
||||||
|
famId: '',
|
||||||
|
memberName: '',
|
||||||
|
memberColor: '',
|
||||||
|
pattern: '',
|
||||||
|
themeSize: '',
|
||||||
|
themeOpacity: '',
|
||||||
|
email: '',
|
||||||
|
demoMode: true
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = event.locals.user;
|
const session = event.locals.user;
|
||||||
@@ -50,6 +64,7 @@ export async function load(event) {
|
|||||||
themeSize: me.themeSize || '',
|
themeSize: me.themeSize || '',
|
||||||
themeOpacity: me.themeOpacity || '',
|
themeOpacity: me.themeOpacity || '',
|
||||||
email: me.email || '',
|
email: me.email || '',
|
||||||
|
hasPin: false,
|
||||||
session: true
|
session: true
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -61,7 +76,8 @@ export async function load(event) {
|
|||||||
famId: '',
|
famId: '',
|
||||||
memberName: '',
|
memberName: '',
|
||||||
memberColor: '',
|
memberColor: '',
|
||||||
email: ''
|
email: '',
|
||||||
|
hasPin: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +86,7 @@ export async function load(event) {
|
|||||||
return {
|
return {
|
||||||
verified: true,
|
verified: true,
|
||||||
role: 'child',
|
role: 'child',
|
||||||
token: event.cookies.get('pb_token') || '',
|
token: event.locals.pbToken || '',
|
||||||
memberId: session.id,
|
memberId: session.id,
|
||||||
famId,
|
famId,
|
||||||
memberName: session.name || '',
|
memberName: session.name || '',
|
||||||
@@ -79,6 +95,7 @@ export async function load(event) {
|
|||||||
themeSize: session.themeSize || '',
|
themeSize: session.themeSize || '',
|
||||||
themeOpacity: session.themeOpacity || '',
|
themeOpacity: session.themeOpacity || '',
|
||||||
email: '',
|
email: '',
|
||||||
|
hasPin: await hasPin(session.id).catch(() => false),
|
||||||
session: true
|
session: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,50 @@
|
|||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
let pinMsg = $state('');
|
||||||
|
|
||||||
|
async function pinRequest(action: 'set' | 'change', pin: string, currentPin = '') {
|
||||||
|
pinMsg = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/pins', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action, pin, currentPin })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not save PIN');
|
||||||
|
pinMsg = 'Saved — try it on the shared computer!';
|
||||||
|
await invalidateAll();
|
||||||
|
} catch (e) {
|
||||||
|
pinMsg = e instanceof Error ? e.message : 'Could not save PIN';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPin(e: SubmitEvent) {
|
||||||
|
const fd = new FormData(e.currentTarget as HTMLFormElement);
|
||||||
|
const pin = String(fd.get('pin') || '').trim();
|
||||||
|
const confirm = String(fd.get('confirm') || '').trim();
|
||||||
|
e.preventDefault();
|
||||||
|
if (pin !== confirm) {
|
||||||
|
pinMsg = 'PINs do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pinRequest('set', pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePin(e: SubmitEvent) {
|
||||||
|
const fd = new FormData(e.currentTarget as HTMLFormElement);
|
||||||
|
const current = String(fd.get('current') || '').trim();
|
||||||
|
const pin = String(fd.get('pin') || '').trim();
|
||||||
|
const confirm = String(fd.get('confirm') || '').trim();
|
||||||
|
e.preventDefault();
|
||||||
|
if (pin !== confirm) {
|
||||||
|
pinMsg = 'PINs do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pinRequest('change', pin, current);
|
||||||
|
}
|
||||||
|
|
||||||
let memberName = $state(data.memberName || '');
|
let memberName = $state(data.memberName || '');
|
||||||
let memberColor = $state(data.memberColor || '#6366f1');
|
let memberColor = $state(data.memberColor || '#6366f1');
|
||||||
let memberEmail = $state(data.email || '');
|
let memberEmail = $state(data.email || '');
|
||||||
@@ -160,6 +204,70 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{#if data.role === 'child'}
|
||||||
|
<Card cols={1} title="My PIN">
|
||||||
|
{#if data.hasPin}
|
||||||
|
<p class="theme-hint">
|
||||||
|
Your 3-digit PIN switches you back to your chores on a shared computer. Forgot it? Your
|
||||||
|
parent can read it out — Settings → Invites → PIN.
|
||||||
|
</p>
|
||||||
|
<form class="pin-form" onsubmit={changePin}>
|
||||||
|
<input
|
||||||
|
name="current"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="3"
|
||||||
|
required
|
||||||
|
placeholder="Current PIN"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="pin"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="3"
|
||||||
|
required
|
||||||
|
placeholder="New PIN"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="confirm"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="3"
|
||||||
|
required
|
||||||
|
placeholder="Confirm"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm">Change PIN</Button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="theme-hint">
|
||||||
|
Set a 3-digit PIN so you can switch back to your chores quickly on a shared computer.
|
||||||
|
</p>
|
||||||
|
<form class="pin-form" onsubmit={setPin}>
|
||||||
|
<input
|
||||||
|
name="pin"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="3"
|
||||||
|
required
|
||||||
|
placeholder="Your PIN"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="confirm"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="3"
|
||||||
|
required
|
||||||
|
placeholder="Confirm"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm">Set PIN</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if pinMsg}
|
||||||
|
<p class="pin-msg">{pinMsg}</p>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card cols={1} title="Account">
|
<Card cols={1} title="Account">
|
||||||
<p style="font-size:0.85rem;color:#6b7280">
|
<p style="font-size:0.85rem;color:#6b7280">
|
||||||
To change your device or sign in on a new device, use your invite link.
|
To change your device or sign in on a new device, use your invite link.
|
||||||
@@ -182,4 +290,25 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
.pin-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.pin-input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
}
|
||||||
|
.pin-msg {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { sendParentInviteEmail } from '$lib/server/email';
|
|||||||
import { slugify, handle, famUsername } from '@shared/slugify';
|
import { slugify, handle, famUsername } from '@shared/slugify';
|
||||||
import { applyAccessCode } from '$lib/server/access';
|
import { applyAccessCode } from '$lib/server/access';
|
||||||
import { getPlatformFlags } from '$lib/server/platform';
|
import { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
import { getPin, resetPin as resetChildPin } from '$lib/server/pins';
|
||||||
import {
|
import {
|
||||||
createBillingPortalSession,
|
createBillingPortalSession,
|
||||||
cancelSubscriptionAtPeriodEnd,
|
cancelSubscriptionAtPeriodEnd,
|
||||||
@@ -26,7 +27,15 @@ async function isDemoMode(): Promise<boolean> {
|
|||||||
|
|
||||||
export async function load(event: RequestEvent) {
|
export async function load(event: RequestEvent) {
|
||||||
if (await isDemoMode()) {
|
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 famId = famIdOf(event);
|
||||||
const pb = pbUser(event);
|
const pb = pbUser(event);
|
||||||
@@ -48,7 +57,13 @@ export async function load(event: RequestEvent) {
|
|||||||
if (fam?.paymentMode === 'sub' && fam?.stripeCustomerId) {
|
if (fam?.paymentMode === 'sub' && fam?.stripeCustomerId) {
|
||||||
subStatus = await getSubscriptionStatus(fam.stripeCustomerId).catch(() => null);
|
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 = {
|
export const actions = {
|
||||||
@@ -159,6 +174,59 @@ export const actions = {
|
|||||||
return { ok: true };
|
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) => {
|
updatePayday: async (event: RequestEvent) => {
|
||||||
const famId = famIdOf(event);
|
const famId = famIdOf(event);
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
|
|||||||
@@ -89,6 +89,8 @@
|
|||||||
let members = $derived(famStore.initialized ? famStore.members : data.members || []);
|
let members = $derived(famStore.initialized ? famStore.members : data.members || []);
|
||||||
let deletingSeason = $state<any>(null);
|
let deletingSeason = $state<any>(null);
|
||||||
let issueModal = $state<{ otp: string; joinUrl: string; name: string } | null>(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');
|
let seasonColor = $state('#6366f1');
|
||||||
const seasonColors = [
|
const seasonColors = [
|
||||||
'#6366f1',
|
'#6366f1',
|
||||||
@@ -363,6 +365,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Card title="Shared computer" cols={1}>
|
||||||
|
<p class="hint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateLock"
|
||||||
|
use:enhance={() => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<select name="mins" bind:value={lockMins}>
|
||||||
|
<option value="0">Off — keep the last profile</option>
|
||||||
|
<option value="2">After 2 minutes of no use</option>
|
||||||
|
<option value="10">After 10 minutes of no use</option>
|
||||||
|
</select>
|
||||||
|
<Button type="submit" size="sm">Save</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
@@ -478,6 +506,32 @@
|
|||||||
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
|
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/revealPin"
|
||||||
|
use:enhance={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={m.id} />
|
||||||
|
<Button type="submit" variant="ghost" size="sm" title="Shared-computer PIN"
|
||||||
|
>PIN</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||||||
<input type="hidden" name="id" value={m.id} />
|
<input type="hidden" name="id" value={m.id} />
|
||||||
<Button
|
<Button
|
||||||
@@ -492,6 +546,58 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{#if pinModal}
|
||||||
|
<div class="overlay" onclick={() => (pinModal = null)} role="presentation">
|
||||||
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||||
|
<h3>{pinModal.name}'s PIN</h3>
|
||||||
|
{#if pinModal.unset}
|
||||||
|
<p class="hint">
|
||||||
|
No PIN set yet. Set one so {pinModal.name} can pick their profile on a shared computer.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/resetPin"
|
||||||
|
use:enhance={() => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={pinModal.id} />
|
||||||
|
<Button type="submit" size="sm" variant="primary">Set a new PIN</Button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="code-display">{pinModal.pin}</p>
|
||||||
|
<p class="hint">
|
||||||
|
Read it out if they've forgotten. They can change it themselves in Preferences.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/resetPin"
|
||||||
|
use:enhance={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={pinModal.id} />
|
||||||
|
<Button type="submit" size="sm" variant="secondary">Give a new PIN</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" onclick={() => (pinModal = null)}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card title="Invite Parent" cols={1}>
|
<Card title="Invite Parent" cols={1}>
|
||||||
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
||||||
<form
|
<form
|
||||||
@@ -1162,6 +1268,8 @@
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
.code-display { font-size: 1.5em; }
|
.code-display {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { redeemOtp } from '$lib/server/member-otp';
|
import { redeemOtp } from '$lib/server/member-otp';
|
||||||
import { handle } from '@shared/slugify';
|
import { handle } from '@shared/slugify';
|
||||||
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
|
import {
|
||||||
|
clearLegacyCookies,
|
||||||
|
setChildSessionCookie,
|
||||||
|
setActiveChild,
|
||||||
|
scanChildSessions
|
||||||
|
} from '$lib/server/session';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async (event) => {
|
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 });
|
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', name, otp });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// redeemOtp derives the username from the handle internally; pass the
|
// Children join onto a shared-device session (pb_token_<id> +
|
||||||
// raw name so it resolves the same {famSlug}:{handle} identity.
|
// pb_active) so siblings' sessions on this computer survive.
|
||||||
const token = await redeemOtp({ famSlug: fam, username: name, otp });
|
const { token, userId } = await redeemOtp({ famSlug: fam, username: name, otp });
|
||||||
clearLegacyCookies(event.cookies);
|
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) {
|
} catch (e) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: e instanceof Error ? e.message : 'Join failed',
|
error: e instanceof Error ? e.message : 'Join failed',
|
||||||
@@ -26,8 +34,5 @@ export const actions = {
|
|||||||
otp
|
otp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleName = handle(name);
|
|
||||||
throw redirect(303, `/${fam}/${encodeURIComponent(handleName)}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Button } from '$lib/components';
|
import { Button, JoinPinFlow } from '$lib/components';
|
||||||
import { homeIcon } from '$lib/components/icons';
|
import { homeIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
const famSlug = page.params.fam;
|
const famSlug = page.params.fam;
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let otp = $state(page.url.searchParams.get('code') || '');
|
let otp = $state(page.url.searchParams.get('code') || '');
|
||||||
|
let joined = $state<{ username: string; hasOtherKids: boolean } | null>(null);
|
||||||
let { form } = $props();
|
let { form } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -19,6 +20,13 @@
|
|||||||
>
|
>
|
||||||
<span class="home-badge">{@html homeIcon}</span>
|
<span class="home-badge">{@html homeIcon}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if joined}
|
||||||
|
<JoinPinFlow
|
||||||
|
targetUrl={`/${famSlug}/${encodeURIComponent(joined.username)}`}
|
||||||
|
force={joined.hasOtherKids}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
Enter your name and the code your parent gave you to get started.
|
Enter your name and the code your parent gave you to get started.
|
||||||
@@ -32,6 +40,13 @@
|
|||||||
if (result.type === 'failure') {
|
if (result.type === 'failure') {
|
||||||
name = (result.data as any)?.name || '';
|
name = (result.data as any)?.name || '';
|
||||||
otp = (result.data as any)?.otp || '';
|
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();
|
await update();
|
||||||
};
|
};
|
||||||
@@ -64,6 +79,7 @@
|
|||||||
<p class="mt-6 text-xs text-slate-400">
|
<p class="mt-6 text-xs text-slate-400">
|
||||||
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { redeemOtp, redeemParentOtp } from '$lib/server/member-otp';
|
import { redeemOtp, redeemParentOtp } from '$lib/server/member-otp';
|
||||||
import { createSuperClient } from '$lib/server/pocketbase';
|
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';
|
import { famUsername, handle } from '@shared/slugify';
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
@@ -58,15 +65,25 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = isParent
|
if (isParent) {
|
||||||
? await redeemParentOtp({ famSlug: fam, username, otp, password })
|
const token = await redeemParentOtp({ famSlug: fam, username, otp, password });
|
||||||
: await redeemOtp({ famSlug: fam, username, otp });
|
|
||||||
clearLegacyCookies(event.cookies);
|
clearLegacyCookies(event.cookies);
|
||||||
setSessionCookie(event.cookies, token);
|
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_<id> + pb_active) so
|
||||||
|
// siblings' sessions on this computer survive.
|
||||||
|
const { token, userId } = await redeemOtp({ famSlug: fam, username, otp });
|
||||||
|
clearLegacyCookies(event.cookies);
|
||||||
|
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) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Join failed' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Join failed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Button } from '$lib/components';
|
import { Button, JoinPinFlow } from '$lib/components';
|
||||||
import { homeIcon } from '$lib/components/icons';
|
import { homeIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
const famSlug = page.params.fam;
|
const famSlug = page.params.fam;
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
let otp = $state(page.url.searchParams.get('code') || '');
|
let otp = $state(page.url.searchParams.get('code') || '');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let confirmPassword = $state('');
|
let confirmPassword = $state('');
|
||||||
|
let joined = $state<{ hasOtherKids: boolean } | null>(null);
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const isParent = $derived(data?.isParent);
|
const isParent = $derived(data?.isParent);
|
||||||
@@ -24,6 +25,12 @@
|
|||||||
>
|
>
|
||||||
<span class="home-badge">{@html homeIcon}</span>
|
<span class="home-badge">{@html homeIcon}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{#if joined}
|
||||||
|
<JoinPinFlow
|
||||||
|
targetUrl={`/${famSlug}/${encodeURIComponent(username || '')}`}
|
||||||
|
force={joined.hasOtherKids}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
{#if isParent}
|
{#if isParent}
|
||||||
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
@@ -33,8 +40,8 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
|
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent gave
|
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent
|
||||||
you to get started.
|
gave you to get started.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -44,8 +51,16 @@
|
|||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async ({ result, update }) => {
|
return async ({ result, update }) => {
|
||||||
if (result.type === 'failure') {
|
if (result.type === 'failure') {
|
||||||
otp = (result.data as any)?.otp || '';
|
otp = (result as any).data?.otp || '';
|
||||||
|
await update();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const d = (result as any).data;
|
||||||
|
if (d?.joined) {
|
||||||
|
joined = { hasOtherKids: !!d.hasOtherKids };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Parent joins redirect server-side (303) — nothing to do here.
|
||||||
await update();
|
await update();
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
@@ -91,6 +106,7 @@
|
|||||||
<p class="mt-6 text-xs text-slate-400">
|
<p class="mt-6 text-xs text-slate-400">
|
||||||
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Shared-device profile picker. The fam layout renders the picker itself in
|
||||||
|
picker mode (no active session, child sessions present) — this page only
|
||||||
|
exists so /{fam}/switch resolves as a route. -->
|
||||||
|
<div></div>
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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_<userId> 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<string, { fails: number; until: number }>();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { redeemOtp } from '$lib/server/member-otp';
|
import { redeemOtp } from '$lib/server/member-otp';
|
||||||
import { slugify, handle } from '@shared/slugify';
|
import { slugify, handle } from '@shared/slugify';
|
||||||
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
|
import {
|
||||||
|
clearLegacyCookies,
|
||||||
|
setChildSessionCookie,
|
||||||
|
setActiveChild,
|
||||||
|
scanChildSessions
|
||||||
|
} from '$lib/server/session';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async (event) => {
|
default: async (event) => {
|
||||||
@@ -12,13 +17,19 @@ export const actions = {
|
|||||||
|
|
||||||
if (!famName) return fail(400, { error: 'Enter your family name.', famName, name, otp });
|
if (!famName) return fail(400, { error: 'Enter your family name.', famName, name, otp });
|
||||||
if (!name) return fail(400, { error: 'Enter your name.', famName, name, otp });
|
if (!name) return fail(400, { error: 'Enter your name.', famName, name, otp });
|
||||||
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', famName, name, otp });
|
if (!otp)
|
||||||
|
return fail(400, { error: 'Enter the code shown by your parent.', famName, name, otp });
|
||||||
|
|
||||||
const famSlug = slugify(famName);
|
const famSlug = slugify(famName);
|
||||||
try {
|
try {
|
||||||
const token = await redeemOtp({ famSlug, username: name, otp });
|
// Children join onto a shared-device session (pb_token_<id> +
|
||||||
|
// pb_active) so siblings' sessions on this computer survive.
|
||||||
|
const { token, userId } = await redeemOtp({ famSlug, username: name, otp });
|
||||||
clearLegacyCookies(event.cookies);
|
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, username: handle(name), hasOtherKids };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: e instanceof Error ? e.message : 'Join failed',
|
error: e instanceof Error ? e.message : 'Join failed',
|
||||||
@@ -27,8 +38,5 @@ export const actions = {
|
|||||||
otp
|
otp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleName = handle(name);
|
|
||||||
throw redirect(303, `/${famSlug}/${encodeURIComponent(handleName)}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Button } from '$lib/components';
|
import { Button, JoinPinFlow } from '$lib/components';
|
||||||
import { homeIcon } from '$lib/components/icons';
|
import { homeIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let family = $state('');
|
let family = $state('');
|
||||||
let otp = $state('');
|
let otp = $state('');
|
||||||
|
let joined = $state<{ famSlug: string; username: string; hasOtherKids: boolean } | null>(null);
|
||||||
let { form } = $props();
|
let { form } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -13,9 +14,18 @@
|
|||||||
|
|
||||||
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
|
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
|
||||||
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
|
<div
|
||||||
|
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl"
|
||||||
|
>
|
||||||
<span class="home-badge">{@html homeIcon}</span>
|
<span class="home-badge">{@html homeIcon}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if joined}
|
||||||
|
<JoinPinFlow
|
||||||
|
targetUrl={`/${joined.famSlug}/${encodeURIComponent(joined.username)}`}
|
||||||
|
force={joined.hasOtherKids}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<h1 class="text-xl font-bold text-slate-900">Join the family</h1>
|
<h1 class="text-xl font-bold text-slate-900">Join the family</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
Enter your family name and the code your parent gave you to get started.
|
Enter your family name and the code your parent gave you to get started.
|
||||||
@@ -30,6 +40,17 @@
|
|||||||
family = (result.data as any)?.family || '';
|
family = (result.data as any)?.family || '';
|
||||||
name = (result.data as any)?.name || '';
|
name = (result.data as any)?.name || '';
|
||||||
otp = (result.data as any)?.otp || '';
|
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();
|
await update();
|
||||||
};
|
};
|
||||||
@@ -70,6 +91,7 @@
|
|||||||
<p class="mt-6 text-xs text-slate-400">
|
<p class="mt-6 text-xs text-slate-400">
|
||||||
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { createPbClient } from '$lib/server/pocketbase';
|
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 { pbAdmin } from '$lib/server/pocketbase';
|
||||||
import { handleOf } from '@shared/slugify';
|
import { handleOf } from '@shared/slugify';
|
||||||
|
|
||||||
@@ -27,6 +27,8 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSessionCookie(event.cookies, authResult.token);
|
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 fam = await pbAdmin.getOne('fams', user.famId).catch(() => null);
|
||||||
const famSlug = fam?.slug || user.famId;
|
const famSlug = fam?.slug || user.famId;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
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 }) {
|
function signOut(event: { cookies: any }) {
|
||||||
clearSessionCookie(event.cookies);
|
clearSessionCookie(event.cookies);
|
||||||
clearLegacyCookies(event.cookies);
|
clearLegacyCookies(event.cookies);
|
||||||
|
// Shared device: also drop every child session + the active pointer.
|
||||||
|
clearDeviceSessions(event.cookies);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
|||||||
@@ -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_<userId>` — 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_<userId>` (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_<userId>` + `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".
|
||||||
+89
-33
@@ -61,11 +61,19 @@ export function jsonField(name: string): FieldDef {
|
|||||||
return { name, type: "json" };
|
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 };
|
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 {
|
return {
|
||||||
name,
|
name,
|
||||||
type: "relation",
|
type: "relation",
|
||||||
@@ -151,17 +159,27 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
text("accessCodeId"),
|
text("accessCodeId"),
|
||||||
date("accessCodeEnteredAt"),
|
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),
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bonus_templates",
|
name: "bonus_templates",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("bonus_templates", [
|
col(
|
||||||
|
"bonus_templates",
|
||||||
|
[
|
||||||
rel("famId", ids.fams, false),
|
rel("famId", ids.fams, false),
|
||||||
text("name", true),
|
text("name", true),
|
||||||
text("description"),
|
text("description"),
|
||||||
select("target", ["individual", "competitive", "collaborative"], true),
|
select(
|
||||||
|
"target",
|
||||||
|
["individual", "competitive", "collaborative"],
|
||||||
|
true,
|
||||||
|
),
|
||||||
select("type", ["threshold", "count", "manual"], true),
|
select("type", ["threshold", "count", "manual"], true),
|
||||||
select("thresholdType", ["points", "percent"], false),
|
select("thresholdType", ["points", "percent"], false),
|
||||||
select("occurrence", ["recurring", "once"], true),
|
select("occurrence", ["recurring", "once"], true),
|
||||||
@@ -173,27 +191,35 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
bool("global"),
|
bool("global"),
|
||||||
text("icon"),
|
text("icon"),
|
||||||
text("color"),
|
text("color"),
|
||||||
], {
|
],
|
||||||
|
{
|
||||||
listRule: "global = true || famId = @request.auth.famId",
|
listRule: "global = true || famId = @request.auth.famId",
|
||||||
viewRule: "global = true || famId = @request.auth.famId",
|
viewRule: "global = true || famId = @request.auth.famId",
|
||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
deleteRule: RULE_PARENT_SCOPED,
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
})(ids),
|
},
|
||||||
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "settings",
|
name: "settings",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("settings", [rel("famId", ids.fams, true), text("webhookUrl")], {
|
col(
|
||||||
|
"settings",
|
||||||
|
[rel("famId", ids.fams, true), text("webhookUrl"), number("lockMins")],
|
||||||
|
{
|
||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
deleteRule: RULE_PARENT_SCOPED,
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
})(ids),
|
},
|
||||||
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chore_templates",
|
name: "chore_templates",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("chore_templates", [
|
col(
|
||||||
|
"chore_templates",
|
||||||
|
[
|
||||||
rel("famId", ids.fams, false),
|
rel("famId", ids.fams, false),
|
||||||
text("name", true),
|
text("name", true),
|
||||||
text("description"),
|
text("description"),
|
||||||
@@ -203,13 +229,15 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
bool("global"),
|
bool("global"),
|
||||||
text("icon"),
|
text("icon"),
|
||||||
text("color"),
|
text("color"),
|
||||||
], {
|
],
|
||||||
|
{
|
||||||
listRule: "global = true || famId = @request.auth.famId",
|
listRule: "global = true || famId = @request.auth.famId",
|
||||||
viewRule: "global = true || famId = @request.auth.famId",
|
viewRule: "global = true || famId = @request.auth.famId",
|
||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
deleteRule: RULE_PARENT_SCOPED,
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
})(ids),
|
},
|
||||||
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bonus_configs",
|
name: "bonus_configs",
|
||||||
@@ -251,7 +279,9 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
{
|
{
|
||||||
name: "weekly_history",
|
name: "weekly_history",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("weekly_history", [
|
col(
|
||||||
|
"weekly_history",
|
||||||
|
[
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
rel("memberId", ids.users, true),
|
rel("memberId", ids.users, true),
|
||||||
date("weekStart"),
|
date("weekStart"),
|
||||||
@@ -259,32 +289,40 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
number("moneyEarned"),
|
number("moneyEarned"),
|
||||||
number("choresCompleted"),
|
number("choresCompleted"),
|
||||||
number("bonusEarned"),
|
number("bonusEarned"),
|
||||||
], {
|
],
|
||||||
|
{
|
||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
deleteRule: RULE_PARENT_SCOPED,
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
})(ids),
|
},
|
||||||
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "seasons",
|
name: "seasons",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("seasons", [
|
col(
|
||||||
|
"seasons",
|
||||||
|
[
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
text("name", true),
|
text("name", true),
|
||||||
text("color"),
|
text("color"),
|
||||||
bool("active"),
|
bool("active"),
|
||||||
date("autoDisable"),
|
date("autoDisable"),
|
||||||
date("autoStart"),
|
date("autoStart"),
|
||||||
], {
|
],
|
||||||
|
{
|
||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
deleteRule: RULE_PARENT_SCOPED,
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
})(ids),
|
},
|
||||||
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "messages",
|
name: "messages",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("messages", [
|
col(
|
||||||
|
"messages",
|
||||||
|
[
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
select("authorType", ["admin", "member"], true),
|
select("authorType", ["admin", "member"], true),
|
||||||
text("authorId", true),
|
text("authorId", true),
|
||||||
@@ -295,36 +333,44 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
// API-created collections (0.25 did). Chat filters/sorts on it.
|
// API-created collections (0.25 did). Chat filters/sorts on it.
|
||||||
date("createdAt"),
|
date("createdAt"),
|
||||||
text("clientId"),
|
text("clientId"),
|
||||||
], {
|
],
|
||||||
|
{
|
||||||
createRule: RULE_FAM_WRITE,
|
createRule: RULE_FAM_WRITE,
|
||||||
updateRule: RULE_FAM_SCOPED,
|
updateRule: RULE_FAM_SCOPED,
|
||||||
deleteRule: RULE_FAM_SCOPED,
|
deleteRule: RULE_FAM_SCOPED,
|
||||||
listRule: RULE_FAM_READ,
|
listRule: RULE_FAM_READ,
|
||||||
viewRule: RULE_FAM_READ,
|
viewRule: RULE_FAM_READ,
|
||||||
})(ids),
|
},
|
||||||
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chat_typing",
|
name: "chat_typing",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("chat_typing", [
|
col(
|
||||||
|
"chat_typing",
|
||||||
|
[
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
text("actorId", true),
|
text("actorId", true),
|
||||||
select("actorType", ["admin", "member"], true),
|
select("actorType", ["admin", "member"], true),
|
||||||
text("authorName", true),
|
text("authorName", true),
|
||||||
text("authorColor"),
|
text("authorColor"),
|
||||||
bool("typing"),
|
bool("typing"),
|
||||||
], {
|
],
|
||||||
|
{
|
||||||
createRule: RULE_FAM_WRITE,
|
createRule: RULE_FAM_WRITE,
|
||||||
updateRule: RULE_FAM_SCOPED,
|
updateRule: RULE_FAM_SCOPED,
|
||||||
deleteRule: RULE_FAM_SCOPED,
|
deleteRule: RULE_FAM_SCOPED,
|
||||||
listRule: RULE_FAM_READ,
|
listRule: RULE_FAM_READ,
|
||||||
viewRule: RULE_FAM_READ,
|
viewRule: RULE_FAM_READ,
|
||||||
})(ids),
|
},
|
||||||
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "rewards",
|
name: "rewards",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("rewards", [
|
col(
|
||||||
|
"rewards",
|
||||||
|
[
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
rel("memberId", ids.users, true),
|
rel("memberId", ids.users, true),
|
||||||
rel("bonusConfigId", ids.bonus_configs),
|
rel("bonusConfigId", ids.bonus_configs),
|
||||||
@@ -337,16 +383,20 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
date("claimedAt"),
|
date("claimedAt"),
|
||||||
date("requestedAt"),
|
date("requestedAt"),
|
||||||
text("date"),
|
text("date"),
|
||||||
], {
|
],
|
||||||
|
{
|
||||||
createRule: RULE_FAM_WRITE,
|
createRule: RULE_FAM_WRITE,
|
||||||
updateRule: RULE_FAM_SCOPED,
|
updateRule: RULE_FAM_SCOPED,
|
||||||
deleteRule: RULE_FAM_SCOPED,
|
deleteRule: RULE_FAM_SCOPED,
|
||||||
})(ids),
|
},
|
||||||
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "assigned_chores",
|
name: "assigned_chores",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("assigned_chores", [
|
col(
|
||||||
|
"assigned_chores",
|
||||||
|
[
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
rel("memberId", ids.users, false), // null = shared with all members
|
rel("memberId", ids.users, false), // null = shared with all members
|
||||||
bool("shared"), // true = initially shared (tracked for revoke)
|
bool("shared"), // true = initially shared (tracked for revoke)
|
||||||
@@ -363,26 +413,32 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
bool("isTodo"),
|
bool("isTodo"),
|
||||||
text("startDate"),
|
text("startDate"),
|
||||||
text("completeBy"),
|
text("completeBy"),
|
||||||
], {
|
],
|
||||||
|
{
|
||||||
createRule: RULE_PARENT_WRITE,
|
createRule: RULE_PARENT_WRITE,
|
||||||
updateRule: RULE_PARENT_SCOPED,
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
deleteRule: RULE_PARENT_SCOPED,
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
})(ids),
|
},
|
||||||
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "completions",
|
name: "completions",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("completions", [
|
col(
|
||||||
|
"completions",
|
||||||
|
[
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
rel("memberId", ids.users, true),
|
rel("memberId", ids.users, true),
|
||||||
rel("assignedChoreId", ids.assigned_chores, true),
|
rel("assignedChoreId", ids.assigned_chores, true),
|
||||||
date("date"),
|
date("date"),
|
||||||
date("completedAt"),
|
date("completedAt"),
|
||||||
text("rewardId"),
|
text("rewardId"),
|
||||||
], {
|
],
|
||||||
|
{
|
||||||
createRule: RULE_FAM_WRITE,
|
createRule: RULE_FAM_WRITE,
|
||||||
updateRule: RULE_FAM_SCOPED,
|
updateRule: RULE_FAM_SCOPED,
|
||||||
deleteRule: RULE_FAM_SCOPED,
|
deleteRule: RULE_FAM_SCOPED,
|
||||||
})(ids),
|
},
|
||||||
|
)(ids),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user