From c73ced78948bf354cc593cd2bef3bf1500a80a17 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Sun, 16 Aug 2026 10:11:39 +0100 Subject: [PATCH] migrate auth v2 code --- .env.example | 3 + AGENTS.md | 84 +-- MEMORY.md | 24 +- auth-v2.md | 159 +++-- frontend/src/app.d.ts | 16 +- frontend/src/env.ts | 5 +- frontend/src/hooks.server.ts | 43 +- frontend/src/lib/client/api.ts | 5 +- frontend/src/lib/components/Card.svelte | 24 +- frontend/src/lib/components/CardGrid.svelte | 17 +- frontend/src/lib/pocketbase.ts | 7 +- frontend/src/lib/server/auth.ts | 74 +-- frontend/src/lib/server/hono.ts | 11 +- frontend/src/lib/server/pb-admin.ts | 57 -- frontend/src/lib/stores/chat.svelte.ts | 22 +- frontend/src/lib/stores/fam.svelte.ts | 31 +- frontend/src/lib/types.ts | 5 +- frontend/src/routes/+page.svelte | 77 +-- frontend/src/routes/[fam]/+layout.server.ts | 61 +- frontend/src/routes/[fam]/+layout.svelte | 6 +- frontend/src/routes/[fam]/+page.server.ts | 2 +- frontend/src/routes/[fam]/+page.svelte | 3 +- .../routes/[fam]/[username]/+page.server.ts | 144 ++--- .../src/routes/[fam]/[username]/+page.svelte | 34 +- .../[fam]/[username]/bonuses/+page.server.ts | 52 +- .../[fam]/[username]/chores/+page.server.ts | 92 +-- .../[fam]/[username]/ledger/+page.server.ts | 8 +- .../[username]/preferences/+page.server.ts | 10 +- .../[fam]/[username]/settings/+page.server.ts | 120 ++-- .../[fam]/[username]/settings/+page.svelte | 474 ++++++++++----- frontend/src/routes/admin/+page.server.ts | 12 +- frontend/src/routes/chat/+server.ts | 16 +- .../src/routes/join/[code]/+page.server.ts | 24 - frontend/src/routes/join/[code]/+page.svelte | 67 --- .../join/[code]/[member]/+page.server.ts | 24 - .../routes/join/[code]/[member]/+page.svelte | 45 -- frontend/src/routes/login/+page.server.ts | 50 +- frontend/src/routes/logout/+page.server.ts | 16 +- frontend/src/routes/signup/+page.server.ts | 97 ++- frontend/src/routes/signup/+page.svelte | 210 +++++-- frontend/src/routes/signup/new.page.svelte | 159 ----- proxy/src/env.ts | 5 + proxy/src/index.ts | 489 ++++++--------- proxy/src/migrate.ts | 567 ++++++++++++++++++ shared/pb/schema.ts | 33 +- 45 files changed, 1965 insertions(+), 1519 deletions(-) delete mode 100644 frontend/src/lib/server/pb-admin.ts delete mode 100644 frontend/src/routes/join/[code]/+page.server.ts delete mode 100644 frontend/src/routes/join/[code]/+page.svelte delete mode 100644 frontend/src/routes/join/[code]/[member]/+page.server.ts delete mode 100644 frontend/src/routes/join/[code]/[member]/+page.svelte delete mode 100644 frontend/src/routes/signup/new.page.svelte diff --git a/.env.example b/.env.example index 590cabf..8dcb1da 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ # PB superuser (server-side only). Defaults in code: debug@famchamp.dev / debug123. PB_EMAIL= PB_PASSWORD= +# Server-only secret used to derive a child member's PB password from +# (famSlug + username). Never expose client-side. OTP is the access gate. +MEMBER_SECRET= # Public: the dev machine's IP where PB + the dev proxy run. Change this when # your remote IP changes — the browser (pocketbase.ts) and pb-admin read it. # Prod ignores this (uses /pb via nginx). Default: 192.168.1.225. diff --git a/AGENTS.md b/AGENTS.md index 9460d76..f82f862 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,69 +12,71 @@ - SvelteKit (SSR frontend, internal :2080) + Hono proxy (internal :3456) + nginx (container :3001) - PocketBase (separate Coolify service at `pb.chores.app.com`, :8090) -- Stripe one-time donations -- Coolify CRON → `GET /api/weekly-cron` +- Stripe one-time donations — **not implemented** (only `settings.webhookUrl` exists) +- Coolify CRON → `GET /api/weekly-cron` — **not implemented** (weekly settlement is manual via `complete-week`/`simulateEow`) - Deployment: Coolify, Cloudflare DNS ## Auth -| Role | Auth | Session | Record in | -| -------------- | -------------------------- | -------------------------- | ------------ | -| Admin (parent) | PB email+pass | 24hr JWT | `fam_admins` | -| Member (child) | Invite code + device token | `device_token` cookie only | `members` | +| Role | Auth | Session | Record in | +| -------------- | --------------------------------------------- | -------------------------- | ------------------------- | +| Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) | +| Member (child) | Invite OTP + server-derived password | httpOnly `pb_token` cookie | `users` (role `child`) | +| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — | -- **Admins** (parents) have a PB auth record + `fam_admins` record. They authenticate via email/password login, get a session cookie (`session`) with `{ famId, userId, famSlug, memberName, role: "parent" }`. -- **Members** (children) exist only in the `members` collection. They authenticate via invite code + device token (SHA-256 hashed). The `device_token` cookie is set on join; no session cookie. -- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard). Not an app role. -- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session cookie — child pages use `page.data.isParent` or `page.data.role` from `$app/state`. +- **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 `user_configs`, then `authWithPassword`. They get the same httpOnly `pb_token` cookie. There is **no `members` collection**. +- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard) and OTP/signup writes. Not an app role. +- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`. +- Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`). -## PB Collections (all scoped by `famId`) +## PB Collections (all scoped by `famId`; child/member = `users` row) -- `fams` — name, slug, inviteCode, stripeCustomerId, featureFlags -- `members` — famId, name, color, deviceToken(hashed), deviceTokenHint +- `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only) +- `user_configs` — famId, userId, otp, colour, updatedAt (OTP gate for child join) +- `fams` — name, slug, stripeCustomerId, featureFlags - `chore_templates` — famId, name, defaultValue, defaultFrequency -- `assigned_chores` — famId, memberId, templateId, frequency, value -- `completions` — famId, memberId, assignedChoreId, date -- `weekly_history` — famId, memberId, weekStart, pointsEarned, moneyEarned -- `rewards` — famId, memberId, source, label, value, claimed, claimedAt -- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerMemberId +- `assigned_chores` — famId, userId, templateId, frequency, value +- `completions` — famId, userId, assignedChoreId, date +- `weekly_history` — famId, userId, weekStart, pointsEarned, moneyEarned +- `rewards` — famId, userId, source, label, value, claimed, claimedAt, claimable, settleDate +- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId - `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl ## Routes ``` / Landing (SaaS marketing) -/admin Admin panel - statistic dashboard, and any donations made -/join/:code Member invite code -/join/:code/:member Member invite with pre-selected member +/admin Platform super-admin stats dashboard (and any donations) +/login · /logout Parent email/password login / logout +/signup Parent + family signup +/{famSlug}/join/{username} Member invite (OTP join), auto-fills from ?code= /{fam} Fam dashboard -/{fam}/admin Admin panel -/{fam}/:username Member kanban & admin dashboard (role determined by session) -/{fam}/:username/preferences User preferences (admin→fam_admins, member→members) -/{fam}/:username/settings Family admin settings (session required) -/{fam}/:username/chores Chore templates & assignment grid -/{fam}/:username/rewards Rewards overview -/{fam}/:username/bonuses Bonus configs & evaluation -/api/* Hono proxy (webhooks, CRON) +/{fam}/{username} Parent → admin overview, Child → member kanban (role from session) +/{fam}/{username}/chores Chore templates & assignment grid +/{fam}/{username}/ledger Rewards / chores / todos ledger +/{fam}/{username}/bonuses Bonus configs & evaluation +/{fam}/{username}/preferences User preferences (parent→users, member→users) +/{fam}/{username}/settings Family admin settings (parent only) +/api/* Hono proxy (data layer; webhooks/CRON not implemented) ``` ## Data Flow ### Reads (both roles) -- **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via `pb_token` cookie). -- **Child (member):** `famStore.init()` fetches all collections via PB SDK — **unauthenticated/anonymous**. All family-scoped collections have public `listRule` / `viewRule` (empty string = allow all), so reads work without any auth. PB SDK `.subscribe()` also works anonymously for public collections. +- **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via the `pb_token` cookie / seeded `initPb(token)`). +- **Child (member):** `famStore.init()` fetches all collections via PB SDK — authenticated via their `pb_token` (role `child`). Family-scoped collections have public `listRule` / `viewRule`, so reads work regardless; `.subscribe()` works for both roles. - **TopNav season pills:** Read from `famStore.seasons` — reactive, no extra fetches needed. -### Writes (both roles go through Hono proxy) +### Writes -- **Chore toggle:** Browser → Hono proxy → PB (auth via device token or admin JWT) -- **Admin CRUD:** Form actions → Hono proxy → PB (admin JWT via `sessionHeaders`) -- **Member updates:** Browser → Hono proxy → PB (auth via `x-device-token` + `x-device-famid`) +- **Chore toggle:** Browser → Hono proxy → PB (member auth via `Authorization: Bearer `) +- **Admin CRUD:** Form actions / `hono.admin.*` → Hono proxy → PB (admin JWT via `sessionHeaders`) +- **Member updates:** Browser → Hono proxy → PB (auth via `Bearer `) - **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met -- **Weekly CRON:** Coolify → `GET /api/weekly-cron` on Hono → Hono queries PB, computes summaries, upserts weekly_history -- **Stripe donate:** Browser → Hono `/api/stripe/create-checkout` → Stripe → Hono webhook → update fam -- **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface +- **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) is not implemented. +- **Stripe / WhatsApp:** not implemented — only the `settings.webhookUrl` field exists. ### UI reactivity @@ -150,8 +152,8 @@ Two patterns based on who's acting: | Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth | | ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- | -| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `x-device-token` header | -| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `session` cookie | +| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `Authorization: Bearer ` | +| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `pb_token` cookie | **Member direct fetch** — optimistic UI via local state mutation, reconciled on response: @@ -195,7 +197,7 @@ All admin and member pages use the following pattern: - Every collection query includes `famId = @request.auth.famId` filter - Super admin bypasses famId filter (access via PB admin API) -- `deviceToken` stored as SHA-256 hash; never log raw tokens +- Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `user_configs`. No device tokens. Never log raw tokens/secrets. - **Admin → Proxy**: `hono.admin.*` in `$lib/server/hono.ts` — uses `sessionHeaders(event)` (server-side only, requires `RequestEvent`) - **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server - **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono diff --git a/MEMORY.md b/MEMORY.md index b9bb036..f15e3b4 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -47,7 +47,9 @@ - `/api/admin/signup` now creates a member record for the parent with `role: 'parent'` - `/api/admin/login` returns `memberName`, `memberColor`, `role` alongside session info - `/api/members/verify-token` returns `role` for the frontend -- `requireAdmin` middleware unchanged (still checks `fam_admins`) +- `requireAdmin` middleware checks `users` (`role='parent' && id = userId`, scoped by `famId`) +- **Removed `fam_admins` collection (Aug 2026):** parent identity fully lives on the `users` record (`famId`, `role`, `name`, `color`, `email`). `requireAdmin`, `authorizeFamReq`, `resolveChatActor`, admin `/profile` get/patch, and the legacy proxy `/signup`/`/login` now read/write `users` instead. Signup no longer creates a `fam_admins` row; superadmin stats derive `parentEmail` from `users role='parent'`. Migrate step 34 drops the collection. +- **Removed `fams.inviteCode` (Aug 2026):** join flow is OTP-based (`user_configs.otp`), so the stored/displayed/regenerated invite code was never consumed. Removed `randomCode()` at signup, the `/regen-invite` endpoint + `hono.admin.regenInvite` action, the `inviteCode` type/field, and added migrate step 34 to drop the field. - Frontend sidebar is role-aware: `isParent = session !== null` - **No more `/admin` prefix** — admin pages live under `/{fam}/{parent-username}/chores` etc. @@ -221,3 +223,23 @@ - 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. - The 0.39 prod `messages` drift (missing `createdAt`, then `id` "Cannot be blank" after a raw field PATCH) came from schema mismatch during the bump. When migrating 0.25→0.39, reconcile the schema scripts against 0.39's field semantics (incl. system `id` `autogeneratePattern`) instead of patching collections by hand. + +### 2026-08-15 — Members→users migration, OTP child login, cookie httpOnly, slugify + +- **Migration (members → users, run live + verified):** deleted the `members` collection and repointed the 5 `memberId` relations (`assigned_chores`, `completions`, `rewards`, `weekly_history`, `bonus_configs`) → `users`. Added `users.name` + `users.color`, backfilled child name/color; backfilled parent `role` (`''` → `'parent'`). Scoped `users` rules: list/view = `famId = @request.auth.famId`, update/delete = `famId = @request.auth.famId && @request.auth.role = 'parent'`. Re-runs are idempotent. +- **PB relation quirk:** PB forbids changing a relation's target collection in place (`validation_field_relation_change`) — you must **drop the field and re-add it** targeting the new collection in two separate collection updates. +- **Child (member) auth:** children are `users` records with `role='child'`, PB `username = {famSlug}:{handle}` (composite, globally unique), `name` = raw display name. Server derives the PB password = `MEMBER_SECRET + famSlug + handle`; the join gate is a 20-min OTP in `user_configs`, then `authWithPassword`. Children now hold a `pb_token` cookie (previously a `device_token` cookie with no session). `SessionUser` gained `username` (set in `hooks.server.ts`, stores `handleOf(record.username)`); the kanban child redirect compares `session.username`, not `session.name`. +- **Cookie httpOnly:** `pb_token` is now `httpOnly:true`. The browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` (layout onMount) — **not** from `document.cookie` (the client can no longer read it). Logout is server-side only. Note: the JWT is still shipped to the client in SSR HTML via the `pbToken` prop. +- **Typecheck baselines:** frontend `svelte-check` = 20 pre-existing canary errors (chat `json(status)` ResponseInit, `$types` Action/SubmitFunction, qrcode decl, vite.config, RewardType/Frequency casts, implicitly-any); proxy `tsc --noEmit` = pre-existing record-typing + implicit-any errors. No new errors in edited files. +- **`crypto.randomUUID()` unavailable over plain HTTP** (non-secure context) → added `genClientId()` fallback (randomUUID if available, else `Date.now().toString(36)+random`) in `chat.svelte.ts`. +- **Shared slugify util:** `shared/slugify.ts` (`@shared/slugify` alias) — used by proxy signup + fam rename, frontend signup + settings `renameFam`, and `member-otp.createChild` (child username + password gen). Removed the 3 local duplicate `slugify` helpers. +- **Settings UI:** CardGrid/Card are now responsive (media queries + `--grid-cols`/`--card-cols`; Cards use `container-type: inline-size`). Members card split into two columns: add-child form | member list (space-between rows, larger 22px colour circle, "Preview" CTA → `/{famSlug}/{m.username}`, Remove). Family Name card gained an explainer + mono `/{fam.slug}` slug line. +- **Hono audit:** the proxy is the live data layer — admin CRUD/reads via `hono.admin.*` (kanban load, bonuses ~17 calls, ledger 6, preferences 2, fam dash 4, settings `complete-week`/`debug/generate-data`), member actions via `memberApi.*` (toggleCompletion/claimReward/payday) + direct `/api` fetches (chores assigned-chores, kanban `/api/members/me`). **Not implemented:** `/api/weekly-cron` CRON, Stripe (`create-checkout` + webhook), WhatsApp — weekly settlement is manual via `complete-week`/`simulateEow`. Chat endpoints exist in the proxy but the frontend talks to PB directly. + +### 2026-08-15 — Signup: multi-step flow restored + family-creation bug fixed + +- **Bug (blocker):** new family creation failed with PB `{"username":{"code":"validation_required","message":"Cannot be blank."}}` — the `users.create` in `/signup` omitted the auth `username` field (PB 0.39 requires it). Reproduced directly against PB: create WITHOUT `username` → `validation_required`; WITH `username` → succeeds. +- **Fix:** `signup/+page.server.ts` now includes `username` on the parent `users` create. +- **Username convention (composite + handle):** PB `users.username` is the composite `{famSlug}:{handle}` for BOTH parents and children — globally unique (PB auth-identity needs a single-column unique index) even though the URL segment is per-family. `handle(name)` = lowercase, strips all non-`[a-z0-9]` (`"Jakey Boy"` → `jakeyboy`); `slugify()` (hyphenated) is kept only for fam slugs. URL segment = `handleOf(username)` (part after the last `:`) → `/{famSlug}/{handle}`. `name` keeps the raw display name, read from DB via `authRefresh` (not plucked into the cookie). Parent's handle captured at signup step 1 (`yourName`) → `username = famUsername(famSlug, handle(yourName))`; parents authenticate email+password and land on the fam dashboard `/{famSlug}` (not username-routed). Children authenticate via OTP → `authWithPassword(famUsername(...), derivePassword(famSlug, handle))`. Redirects in `login/+page.server.ts`, `[fam]/[username]/+page.server.ts` (parent + child branches) and `preferences/+page.server.ts` use `session.username` (the handle). Member-list URLs in `settings`, `[fam]/+page.svelte`, `[fam]/[username]/+page.svelte` build `/{famSlug}/{handleOf(m.username)}`. `handle`/`handleOf`/`famUsername` live in `shared/slugify.ts` (`@shared/slugify`). +- **Restored intended multi-step signup** (from the guide, adapted to current OTP model): `/signup` steps — (1) `?/signup` familyName/yourName/email/password → create fam + parent (name=yourName, username=famUsername(famSlug, handle(yourName))) + settings, set `pb_token`; (2) `?/child` optional child → `issueAccess` returns `{ code, joinUrl }`; (3) show OTP join code + "Go to dashboard" link. Uses named actions + `use:enhance` (callback typed `any` to avoid the pre-existing canary `$types` SubmitFunction error). +- **Typecheck:** frontend `svelte-check` stays at 20 pre-existing errors (no new in edited files). diff --git a/auth-v2.md b/auth-v2.md index b6c2767..e54da3b 100644 --- a/auth-v2.md +++ b/auth-v2.md @@ -7,31 +7,31 @@ still points at it, but no auth traffic flows through it. ``` ┌────────────────────────────────────────────────────────┐ │ BROWSER (Svelte) │ - │ /login /signup forms · $app/forms · use:enhance │ + │ /login /signup /join forms · $app/forms · enhance │ └──────────────────────────┬─────────────────────────────┘ - │ 1. form action POST /login - │ /signup (returns result) + │ 1. form action POST + │ /login · /signup · /join ▼ ┌────────────────────────────────────────────────────────┐ │ SVELTEKIT SERVER (Node) │ │ │ │ hooks.server.ts (runs once per request, first) │ - │ · read httpOnly cookie pb_session │ + │ · read httpOnly cookie pb_token │ │ · createPbClient(token) → pb.authRefresh() │ - │ · → { id, username, role, famId } │ + │ · → { id, name, username, role, famId, color } │ │ · event.locals.user / event.locals.pbToken │ - │ · route guards: public vs authed vs /admin │ + │ · route guards: public vs authed vs admin-only │ │ │ │ +page.server.ts (actions / loads) │ - │ login.ts / signup.ts / session.ts │ - │ · createPbClient / createSuperPbClient │ - │ (ABSOLUTE PB URL - server-to-server, no proxy) │ + │ signup.ts / login.ts / member-otp.ts / session.ts │ + │ · createSuperClient (signup / OTP, superuser) │ + │ · createPbClient(token) (server-to-server) │ └──────────────────────────┬─────────────────────────────┘ │ 2. PB API (REST) │ ▼ ┌────────────────────────────────────────────────────────┐ - │ POCKETBASE (100.103.22.104:8090) │ - │ collections: users (auth) · fams · members · ... │ + │ POCKETBASE (SERVER_IP:8090) │ + │ collections: users (auth) · fams · ... │ │ │ │ PB is the SOURCE OF TRUTH: │ │ · password hashing (bcrypt-style) │ @@ -43,96 +43,93 @@ still points at it, but no auth traffic flows through it. Future (NOT auth): SvelteKit → /api proxy → Hono → email & other services ``` +## Roles (in the `users` collection) + +| Role | Auth | Session cookie | Record in | +| -------- | --------------------------------------------- | ------------------------- | ---------- | +| Admin | PB email + password | `pb_token` (24hr JWT) | `users` (role `parent`) | +| Member | Invite OTP + server-derived password | `pb_token` (httpOnly) | `users` (role `child`) | +| Superuser| PB `_superusers` (server-side only, `pb-admin`) | — | — | + +- **Admins (parents)** authenticate via email/password → PB JWT in an httpOnly `pb_token` + cookie. The session user is `{ id, name, username, role: 'parent', famId, color }`. +- **Members (children)** are `users` records with `role='child'`; their PB `username` is the composite `{famSlug}:{handle}` (globally unique auth identity) where `handle` is the whitespace-free lowercase form of their name, and `name` keeps the raw display name. Their PB password is + **derived** server-side as `MEMBER_SECRET + famSlug + username` (they never know or type it). + Access is gated by a 20-minute OTP in `user_configs`. On join they `authWithPassword` and get + the same httpOnly `pb_token` cookie. There is no separate `members` collection anymore. +- **Platform superuser** (`_superusers`) is used only server-side by `pb-admin.ts` for + cross-family / signup / OTP writes. Not an app role. + ## Request lifecycle (authenticated) -1. Browser sends request; sends cookie `pb_session` (httpOnly, sameSite=lax, secure in prod). +1. Browser sends request; sends cookie `pb_token` (httpOnly, sameSite=lax, secure in prod). 2. `hooks.server.ts` extracts the token. 3. `createPbClient(token)` builds a PB client pre-authenticated as that user. 4. `pb.collection('users').authRefresh()`: - validates the token (PB JWTs can't be checked offline), - - returns the fresh record → `event.locals.user = { id, username, role, famId }`, + - returns the fresh record → `event.locals.user = { id, name, username, role, famId, color }`, - returns a fresh token; if it changed, the cookie is rolled forward. 5. Route guard runs (public / authed / admin-only). 6. `+page.server.ts` / `+server.ts` use `locals.user` for identity; use the token-backed PB client for any CRUD so PB's collection rules apply. -## Key decisions to replicate in another project +Because `pb_token` is httpOnly, the browser PB SDK cannot read it from `document.cookie`. +It is instead seeded from the SSR `page.data.pbToken` prop via `initPb(token)` in the +`[fam]/+layout.svelte` `onMount`. -- **Server-only PB client** lives in `src/lib/server/`; never imported by browser code. - Secrets (superuser creds) stay server-side. -- **Absolute PB base URL** in the SDK (e.g. `http://host:8090`), NOT a relative `/pb`. - All calls run in Node where relative URLs fail. The Vite `/pb` proxy is a browser-only - convenience and is unnecessary for server-side calls. -- **Env vars**: read via `$app/env/private` for private vars (declared in `src/env.ts`), - never `$app/env/public`. -- **`createSuperPbClient`** = anonymous client + `_superusers` auth, used for admin-style - creates (signup). Superusers bypass PB collection rules. -- **Session cookie**: PB JWT in `pb_session`; expiry governed by the token's `exp`, cookie - `maxAge` is just a ceiling; `authRefresh` rolls it forward. -- **Authz boundary** lives in PocketBase collection rules (famId scoping), not just app code. - ---- - -# Signup flow (`/signup`) +## Signup flow (`/signup`) Multi-step form on a single route. All steps run as **form actions** on the server; the `use:enhance` handler only advances the step on a non-failure result. ``` - ADMIN USER - +-----------------------------------------+ 1. POST ?/signup {familyName,email,password} - | +-----------+ +-----------+ +--------+ | ─────────────────────────────► - | | step 1 | | step 2 | | step 3 | | (use:enhance advances step on success) - | | family+ | | username | | child | | - | | email+pass| | | | name | | - | +-----------+ +-----------+ +--------+ | - +-----------------------------------------+ - ▲ 3. set httpOnly cookie pb_session - │ return {success:true} - ▼ - ┌────────────────────────────────────────────────────────────────────────┐ - │ /signup/+page.server.ts actions: signup · username · child │ - └────────────────────────────────────────────────────────────────────────┘ - │ - ?/signup │ ?/username ?/child - ─────────────────────┼───────────────────┬─────────────────────── - signupAdmin() │ setUsername() │ createChild() - ─────────────────────┼───────────────────┴─────────────────────── - │ ▼ (famId from locals.user) - ▼ - createSuperPbClient() ── superuser-authenticated PB client - │ - ├─(1) fams.create({ name, slug }) → family.id - ├─(2) users.create({ email,password,name, → user, role not set - │ famId: family.id }) - ├─(3) users.authWithPassword(email,password) → token - └─(4) setSessionCookie(cookies, token) - │ - ▼ - hooks.server.ts picks up the cookie next request - → authRefresh → event.locals.user populated → user is "logged in" + Step 1 (?/signup) familyName + yourName + email + password + · create fams → fam (slug = slugify(familyName)) + · create users → parent (role 'parent', name = yourName, username = `{famSlug}:{handle(yourName)}`) + · create settings + · authWithPassword(email, password) → set httpOnly pb_token cookie → step 2 - Failure path: any PB error → throw SignupError(msg) - → action returns fail(status, { message }) - → FE renders form.message, does NOT advance step + Step 2 (?/child) child's first name (optional) + · issueAccess() → upserts child user + OTP → returns { code, joinUrl } → step 3 + · or "Skip for now" → dashboard + + Step 3 show OTP join code + joinUrl (+ "Go to dashboard" link) ``` -## Signup step detail +All create calls use the superuser client (`createSuperClient` / `pbAdmin`), which bypasses +PB collection rules. PB requires a `username` on `users` auth records — for both parents and +children it's the composite `{famSlug}:{handle}` (globally unique so PB's auth-identity +unique index holds, even though the URL segment is only per-family), where `handle` is the +whitespace-free lowercase form of the name (`"Joe Edhook"` → `joeedhook`). `name` keeps the +raw human-entered display name. The URL segment is `handleOf(username)` (part after the last +`:`) → `/{famSlug}/{handle}`; parents still authenticate with email+password and land on the +fam dashboard `/{famSlug}`. -| Step | Action | Server fn | Writes | Returns | -| ---- | ------------ | ------------- | --------------------- | ---------------------------- | -| 1 | `?/signup` | `signupAdmin` | fams + users | cookie set, `{success:true}` | -| 2 | `?/username` | `setUsername` | users.name | `{success:true, username}` | -| 3 | `?/child` | `createChild` | members (name, famId) | `{success:true, code}` | +## Child join flow (`/{famSlug}/join/{username}?code=…`) -## Notes / caveats in the current code +1. Admin issues a child in Settings → `issueAccess` (`member-otp.ts`): + `createChild({ name, famId, famSlug })` upserts the `users` record + (`username = {famSlug}:{handle(name)}`, `name` = display, password = derived), and upserts a + `user_configs` row with a 20-min `otp`. Returns `{ otp, joinUrl }`. +2. The join page auto-fills the OTP from the `?code=` query param; the child submits the form. +3. `redeemOtp` verifies the fam slug, the child role, the OTP + its TTL, then + `authWithPassword(famUsername(famSlug, handle), derivePassword(famSlug, handle))` and returns a fresh JWT, + which is set as the `pb_token` cookie. +4. Child is redirected to their own kanban `/{famSlug}/{username}`. -- `users` collection has **no `role`/`username`** fields yet — `setUsername` writes to - `name`, and `role` isn't persisted (hooks reads it as `undefined`). Add `role` + - `username` to `users` if you need role-based guards (`/admin` relies on `role==='admin'`). -- `createChild` writes a `members` record with `inviteCode`, but `members` has no - `inviteCode` field (it's dropped). The member is created without a `users` auth record, - so it can't log in yet — see "Extending — Child / device accounts (Option C)" in the - root `readme.md`. -- `fams.createRule` is `null` (admin-only), `users.createRule` is `""` (public); signup - uses a superuser client, so both work regardless. +## Key decisions to replicate in another project + +- **Server-only PB client** lives in `src/lib/server/`; never imported by browser code. + Secrets (superuser creds, `MEMBER_SECRET`) stay server-side. +- **Absolute PB base URL** in the SDK (e.g. `http://host:8090`), NOT a relative `/pb`. + All calls run in Node where relative URLs fail. The Vite `/pb` proxy is a browser-only + convenience and is unnecessary for server-side calls. +- **Env vars**: read via `$app/env/private` for private vars (declared in `src/env.ts`), + never `$app/env/public`. +- **`createSuperClient`** = anonymous client + `_superusers` auth, used for signup and OTP + writes. Superusers bypass PB collection rules. +- **Session cookie**: PB JWT in `pb_token`, `httpOnly:true`; expiry governed by the token's + `exp`, cookie `maxAge` is just a ceiling; `authRefresh` rolls it forward. +- **Authz boundary** lives in PocketBase collection rules (famId scoping), not just app code. +- **Child identity** is a `users` record; `username` (slug) is used for URLs and the derived + password, `name` for display. Never store raw `deviceToken`; the OTP gate is transient. \ No newline at end of file diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index c749e75..4651f49 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,20 +1,12 @@ -import { PocketBase } from 'pocketbase'; -import type { Session } from './lib/types'; - -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces +import type { SessionUser } from './lib/server/types'; declare global { namespace App { - // interface Error {} interface Locals { - pb: PocketBase; - session?: Session | null; + user: SessionUser | null; + pbToken: string | null; } - // interface PageData {} - // interface PageState {} - // interface Platform {} } } -export {}; +export {}; \ No newline at end of file diff --git a/frontend/src/env.ts b/frontend/src/env.ts index 3333744..c05d112 100644 --- a/frontend/src/env.ts +++ b/frontend/src/env.ts @@ -15,5 +15,8 @@ export const variables = defineEnvVars({ SERVER_IP: { public: true, schema: withDefault('192.168.1.225') }, // PB superuser creds (server-only). PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') }, - PB_PASSWORD: { public: false, schema: withDefault('debug123') } + PB_PASSWORD: { public: false, schema: withDefault('debug123') }, + // Server-only secret used to derive a child member's PB password from + // (famSlug + username). Never expose client-side. OTP is the access gate. + MEMBER_SECRET: { public: false, schema: withDefault('famchamp-member-secret') } }); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index da5c37f..1fe8e04 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,16 +1,45 @@ import type { Handle } from '@sveltejs/kit'; - -const COOKIE_NAME = 'session'; +import { createPbClient } from '$lib/server/pocketbase'; +import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session'; +import type { SessionUser } from '$lib/server/types'; +import { handleOf } from '@shared/slugify'; export const handle: Handle = async ({ event, resolve }) => { - const raw = event.cookies.get(COOKIE_NAME); - if (raw) { + event.locals.user = null; + event.locals.pbToken = null; + + const token = event.cookies.get(SESSION_COOKIE); + + if (token) { + const pb = createPbClient(token); try { - event.locals.session = JSON.parse(raw); + // authRefresh() does two jobs in one call: + // 1. Verifies the token (PB JWTs can't be checked offline — the + // signing secret is per-record and never leaves PB), so this + // round trip IS the verification step. + // 2. Returns the current record — the only way to get + // name/role/famId, since PB doesn't embed custom fields in the + // token itself. + const { record, token: freshToken } = await pb.collection('users').authRefresh(); + + event.locals.user = { + id: record.id, + name: record.name || record.username || '', + username: handleOf(record.username || ''), + role: record.role || 'parent', + famId: record.famId, + color: record.color || '' + } satisfies SessionUser; + event.locals.pbToken = freshToken; + + if (freshToken !== token) { + setSessionCookie(event.cookies, freshToken); + } } catch { - event.cookies.delete(COOKIE_NAME, { path: '/' }); + // Expired, malformed, or revoked — drop it and treat as logged out. + clearSessionCookie(event.cookies); } } return resolve(event); -}; +}; \ No newline at end of file diff --git a/frontend/src/lib/client/api.ts b/frontend/src/lib/client/api.ts index aa67da2..9784711 100644 --- a/frontend/src/lib/client/api.ts +++ b/frontend/src/lib/client/api.ts @@ -6,12 +6,11 @@ async function memberFetch( method: string, path: string, token: string, - famId: string, + _famId?: string, body?: unknown, ): Promise { const headers: Record = { - 'x-device-token': token, - 'x-device-famid': famId, + Authorization: `Bearer ${token}`, }; if (body !== undefined) headers['Content-Type'] = 'application/json'; const res = await fetch(`${BASE_URL}${path}`, { diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte index b3c1f1f..84ff417 100644 --- a/frontend/src/lib/components/Card.svelte +++ b/frontend/src/lib/components/Card.svelte @@ -10,7 +10,8 @@
@@ -26,10 +27,14 @@ + \ No newline at end of file diff --git a/frontend/src/lib/components/CardGrid.svelte b/frontend/src/lib/components/CardGrid.svelte index 7160619..e49ed11 100644 --- a/frontend/src/lib/components/CardGrid.svelte +++ b/frontend/src/lib/components/CardGrid.svelte @@ -2,7 +2,7 @@ let { cols = 3, children }: { cols?: number; children?: any } = $props(); -
+
{@render children?.()}
@@ -10,5 +10,18 @@ .card-grid { display: grid; gap: 1rem; + grid-template-columns: repeat(var(--grid-cols, 3), 1fr); } - + /* Tablet: settle to 2 columns */ + @media (min-width: 640px) and (max-width: 1023px) { + .card-grid { + --grid-cols: 2; + } + } + /* Mobile: single column */ + @media (max-width: 639px) { + .card-grid { + --grid-cols: 1; + } + } + \ No newline at end of file diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts index 44af859..6c5b056 100644 --- a/frontend/src/lib/pocketbase.ts +++ b/frontend/src/lib/pocketbase.ts @@ -4,10 +4,9 @@ const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`; export const pb = new PocketBase(PB_ENDPOINT); pb.autoCancellation(false); -export function initPbFromCookie() { - const match = document.cookie.match(/(?:^|;\s*)pb_token=([^;]*)/); - if (match) { - pb.authStore.save(match[1], null); +export function initPb(token: string) { + if (token) { + pb.authStore.save(token, null); return true; } return false; diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index 9dae4dc..d44f79f 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -1,9 +1,9 @@ import { redirect } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { PROXY_URL } from '$app/env/public'; +import { pbAdmin } from '$lib/server/pocketbase'; export function getSession(event: RequestEvent) { - return event.locals.session; + return event.locals.user; } export function requireAuth(event: RequestEvent) { @@ -14,72 +14,14 @@ export function requireAuth(event: RequestEvent) { return session; } -export async function signup(email: string, password: string, famName: string, parentName?: string) { - const res = await fetch(`${PROXY_URL}/api/admin/signup`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password, famName, parentName }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Signup failed'); - return data; -} - -export async function login(email: string, password: string) { - console.log(email); - const res = await fetch(`${PROXY_URL}/api/admin/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Login failed'); - return data; -} - -export async function joinMember(inviteCode: string, name: string, deviceToken: string) { - const res = await fetch(`${PROXY_URL}/api/members/join`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ inviteCode, name, deviceToken }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Join failed'); - return data; -} - -export function setSessionCookie(event: RequestEvent, session: { famId: string; userId: string; famSlug: string }) { - event.cookies.set('session', JSON.stringify(session), { - httpOnly: true, - sameSite: 'lax', - path: '/', - maxAge: 60 * 60 * 24 * 30, - secure: false, - }); -} - -export function setDeviceTokenCookie(event: RequestEvent, token: string) { - event.cookies.set('device_token', token, { - httpOnly: true, - sameSite: 'lax', - path: '/', - maxAge: 60 * 60 * 24 * 365, - secure: false, - }); -} - -export function setPbTokenCookie(event: RequestEvent, token: string) { - event.cookies.set('pb_token', token, { - httpOnly: false, - sameSite: 'lax', - path: '/', - maxAge: 60 * 60 * 24, - secure: false, - }); -} - export function clearSession(event: RequestEvent) { event.cookies.delete('session', { path: '/' }); event.cookies.delete('pb_token', { path: '/' }); event.cookies.delete('device_token', { path: '/' }); } + +// Resolve the fam slug + admin display name used for the post-login redirect. +export async function getFamContext(famId: string) { + const fam = await pbAdmin.getOne('fams', famId).catch(() => null); + return { famSlug: fam?.slug || famId, famName: fam?.name || '' }; +} \ No newline at end of file diff --git a/frontend/src/lib/server/hono.ts b/frontend/src/lib/server/hono.ts index be3974d..66034b8 100644 --- a/frontend/src/lib/server/hono.ts +++ b/frontend/src/lib/server/hono.ts @@ -2,11 +2,11 @@ import type { RequestEvent } from '@sveltejs/kit'; import { PROXY_URL } from '$app/env/public'; function sessionHeaders(event: RequestEvent): Record { - const s = event.locals.session; - if (!s) return {}; + const u = event.locals.user; + if (!u) return {}; return { - 'x-session-famid': s.famId, - 'x-session-userid': s.userId, + 'x-session-famid': u.famId, + 'x-session-userid': u.id, 'Content-Type': 'application/json' }; } @@ -93,9 +93,6 @@ export const hono = { async verify(event: RequestEvent, famId: string) { return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event)); }, - async regenInvite(event: RequestEvent, famId: string) { - return request('POST', `/api/admin/${famId}/regen-invite`, undefined, sessionHeaders(event)); - }, async weeklySummary(event: RequestEvent, famId: string) { return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event)); }, diff --git a/frontend/src/lib/server/pb-admin.ts b/frontend/src/lib/server/pb-admin.ts deleted file mode 100644 index 91582f2..0000000 --- a/frontend/src/lib/server/pb-admin.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; -import { SERVER_IP } from '$app/env/public'; -export const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`; - -let token: string | null = null; -let tokenExpiry = 0; - -async function ensureToken(): Promise { - if (token && Date.now() < tokenExpiry) return token; - const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(`PB admin auth failed: ${JSON.stringify(data)}`); - token = data.token; - tokenExpiry = Date.now() + 23 * 60 * 60 * 1000; - return token!; -} - -export const pbAdmin = { - async getList(collection: string, filter = '') { - const t = await ensureToken(); - const params = new URLSearchParams(); - if (filter) params.set('filter', filter); - params.set('perPage', '200'); - const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records?${params}`, { - headers: { Authorization: `Bearer ${t}` }, - }); - const data = await res.json(); - if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(data)}`); - return data.items || []; - }, - - async getOne(collection: string, id: string) { - const t = await ensureToken(); - const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, { - headers: { Authorization: `Bearer ${t}` }, - }); - const data = await res.json(); - if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`); - return data; - }, - - async update(collection: string, id: string, data: Record) { - const t = await ensureToken(); - const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, - body: JSON.stringify(data), - }); - const result = await res.json(); - if (!res.ok) throw new Error(`PB update ${collection}/${id}: ${JSON.stringify(result)}`); - return result; - }, -}; diff --git a/frontend/src/lib/stores/chat.svelte.ts b/frontend/src/lib/stores/chat.svelte.ts index ca11107..ed04ef7 100644 --- a/frontend/src/lib/stores/chat.svelte.ts +++ b/frontend/src/lib/stores/chat.svelte.ts @@ -7,8 +7,18 @@ interface ChatInit { actorType: 'admin' | 'member'; actorName: string; actorColor: string; - deviceToken?: string; - memberId?: string; + pbToken?: string; + +} + +// Client id for optimistic chat messages. `crypto.randomUUID()` requires a +// secure context (HTTPS/localhost) — over plain HTTP on a LAN it's undefined, +// so fall back to a time+random string that's still unique enough per session. +function genClientId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return Date.now().toString(36) + Math.random().toString(36).slice(2); } class ChatStore { @@ -23,8 +33,7 @@ class ChatStore { actorType = $state<'admin' | 'member'>('member'); actorName = $state(''); actorColor = $state(''); - deviceToken = $state(''); - memberId = $state(''); + pbToken = $state(''); private unsubs: (() => void)[] = []; private destroyed = false; @@ -46,8 +55,7 @@ class ChatStore { this.actorType = opts.actorType; this.actorName = opts.actorName; this.actorColor = opts.actorColor; - this.deviceToken = opts.deviceToken || ''; - this.memberId = opts.memberId || ''; + this.pbToken = opts.pbToken || ''; if (this.initialized && this.famId === opts.famId) return; if (this.initPromise) { @@ -185,7 +193,7 @@ class ChatStore { async send(content: string) { const text = content.trim(); if (!text || !this.famId) return; - const clientId = crypto.randomUUID(); + const clientId = genClientId(); const temp: ChatMessage = { id: 'temp-' + clientId, famId: this.famId, diff --git a/frontend/src/lib/stores/fam.svelte.ts b/frontend/src/lib/stores/fam.svelte.ts index fc30ec7..6b1a369 100644 --- a/frontend/src/lib/stores/fam.svelte.ts +++ b/frontend/src/lib/stores/fam.svelte.ts @@ -13,7 +13,7 @@ import type { } from '$lib/types'; type CollectionName = - | 'members' + | 'users' | 'chore_templates' | 'assigned_chores' | 'completions' @@ -96,9 +96,9 @@ class FamStore { rewardsRes, seasonsRes ] = await Promise.all([ - pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise< - Member[] - >, + pb.collection('users').getFullList({ + filter: `famId = '${famId}' && role = 'child'` + }) as Promise, pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise< ChoreTemplate[] >, @@ -144,19 +144,18 @@ class FamStore { } private async subscribe() { - const subs: { collection: CollectionName; filter?: string }[] = [ - { collection: 'members', filter: this.famId }, - { collection: 'chore_templates', filter: this.famId }, - { collection: 'assigned_chores', filter: this.famId }, - { collection: 'completions', filter: this.famId }, - { collection: 'bonus_configs', filter: this.famId }, - { collection: 'bonus_templates', filter: this.famId }, - { collection: 'rewards', filter: this.famId }, - { collection: 'seasons', filter: this.famId } + const subs: { collection: CollectionName; filter: string }[] = [ + { collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` }, + { collection: 'chore_templates', filter: `famId = '${this.famId}'` }, + { collection: 'assigned_chores', filter: `famId = '${this.famId}'` }, + { collection: 'completions', filter: `famId = '${this.famId}'` }, + { collection: 'bonus_configs', filter: `famId = '${this.famId}'` }, + { collection: 'bonus_templates', filter: `famId = '${this.famId}'` }, + { collection: 'rewards', filter: `famId = '${this.famId}'` }, + { collection: 'seasons', filter: `famId = '${this.famId}'` } ]; const promises = subs.map(({ collection, filter }) => { - const filterStr = filter ? `famId = '${filter}'` : ''; return pb .collection(collection) .subscribe( @@ -165,7 +164,7 @@ class FamStore { if (this.destroyed) return; this.handleRealtime(collection, data.action, data.record); }, - { filter: filterStr || undefined } + { filter: filter || undefined } ) .then((unsub) => { if (this.destroyed) { @@ -194,7 +193,7 @@ class FamStore { }; switch (collection) { - case 'members': + case 'users': this.members = apply(this.members); break; case 'chore_templates': diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f97b31c..02ddd51 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -40,7 +40,6 @@ export interface Fam { id: string; name: string; slug: string; - inviteCode: string; stripeCustomerId?: string; featureFlags: Record; payday?: number; @@ -54,10 +53,10 @@ export interface Fam { export interface Member { id: string; famId: string; + username: string; name: string; color: string; - deviceToken: string; - deviceTokenHint: string; + role: 'child'; created: string; updated: string; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index f0971a1..45fb7cb 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,25 +1,5 @@ @@ -52,37 +32,7 @@