From bb2b1759dc457e83eea0581166002148f13dd117 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Sun, 23 Aug 2026 15:12:19 +0100 Subject: [PATCH] add platform pages --- MEMORY.md | 11 + TODO.md | 4 +- frontend/src/app.d.ts | 4 + frontend/src/hooks.server.ts | 27 +- .../src/lib/components/PricingPlans.svelte | 208 +++--- frontend/src/lib/server/session.ts | 21 + frontend/src/lib/types.ts | 1 - frontend/src/routes/+page.svelte | 4 + frontend/src/routes/admin/+page.server.ts | 231 +++++-- frontend/src/routes/admin/+page.svelte | 595 +++++++++++++++--- frontend/src/routes/pricing/+page.svelte | 183 ++++-- 11 files changed, 1044 insertions(+), 245 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 82d007c..ade0441 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -326,3 +326,14 @@ - `[fam]/+layout.svelte` owns the `?checkout=return` flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → `activating` state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and `invalidateAll()` revalidates every 1.5s (max 12 tries). The `$effect` watching `activating && !disabled` cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning. - Mechanics: `pollToken` guards against stale loops; `history.replaceState` scrubs the query cosmetically + `returnHandled` flag prevents double-handling. Webhook remains the sole source of truth for `paymentMode`/`active`. - **Upgrade (same day): activation is event-driven, not polled.** `startActivating` subscribes the browser PB client to its own `fams` record (`pb.collection('fams').subscribe(famId)` — allowed by viewRule `id = @request.auth.famId`). Webhook (superuser) writes → PB SSE push → single `invalidateAll()`; unlock `$effect` stops the subscription + fires success. 20s timer kept purely as a degrade-gracefully fallback. Rejected: onComplete-as-source-of-truth (untrusted); optional future hardening = server-side session verification on return. + +### 2026-08-22 — Platform admin: access-code management + richer stats + +- `/admin` extended (same route/embedded login — env-var check + `platform_session` cookie, no PB auth or hooks involved): + - **Access Codes card**: issue codes via `?/createCode` — blank value auto-generates `XXXX-XXXX` (unambiguous charset); fields name/duration/expiry/trialDays (`trialDays > 0` = Stripe trial code). Table shows type badge, active/disabled, **used-by count** (from fams.accessCodeId map), copy-to-clipboard. `?/toggleCode` flips active; `?/deleteCode` **blocked while in use** (must disable). + - **Overview**: added chores completed (completions length), active subs (`paymentMode='sub'`), paused/gated (`!active || none/canceled`), active codes. Families table gained plan+paused badges. + - All actions guarded by `requirePlatform(cookies)`; data still via `pbAdmin` superuser facade. +- Note: `svelte-kit sync` needed after changing load return shapes or `$types` staleness doubles the error count. +- **/admin login pattern:** action sets `platform_session` cookie + returns `{success:true}`; the form's `use:enhance` callback flips a local `authed` view state — no redirect, no reliance on inline invalidation or fetch-time Set-Cookie behavior (which proved flaky in-browser despite curl proving both response paths carried it). Cookie still covers subsequent loads; load errors surface as `data.loadError` on the login card instead of silently masquerading as logged-out. +- **Platform-admin auth via hooks:** `hooks.server.ts` resolves `locals.platformAdmin` from the `platform_session` cookie (=== 'authenticated') on every request; `/admin` load/actions consume `event.locals.platformAdmin` (`requirePlatform(event)`) instead of raw cookie reads. Same central pattern as `pb_token` → `locals.user`. +- **Platform-admin auth hardened (supersedes the constant-cookie version):** `/admin` login now does a real `_superusers.authWithPassword` via PB; the minted superuser JWT goes in `platform_session` (`setPlatformSession` in session.ts). `hooks.server.ts` deviates on `/admin`: verifies the token with `_superusers.authRefresh` → `locals.platformAdmin` (forged values fail authRefresh and get cleared); fam-user pb_token flow skipped on that route. FINAL login shape (user-amended, working): action returns `{success:true}` (no redirect); form's enhance callback does `goto('/admin', { invalidateAll: true })` on success — forcing the load re-run with the fresh cookie; view branches on `data.authenticated`. Verified: real token renders dashboard, forged cookie gets login. diff --git a/TODO.md b/TODO.md index aaa17b3..e5a3c85 100644 --- a/TODO.md +++ b/TODO.md @@ -9,8 +9,8 @@ - [ ] Optional hardening: server-side session verification on checkout return (`checkout.sessions.retrieve` reusing webhook apply logic) — only if SSE/webhook lag ever becomes a real problem (currently event-driven via PB realtime, see MEMORY 2026-08-22) ### Platform admin -- [ ] Platform-admin UI for managing `accesscodes` globally (create/disable/delete access codes AND trial codes) — currently superuser/DB only. Natural home: `/admin` (has Platform Flags card already). -- [ ] `/admin` Families table: name link and "Dashboard"/"View" are duplicates after the broken-link fix — tidy up. +- [x] Platform-admin UI shipped on `/admin`: issue codes (auto-gen XXXX-XXXX values), enable/disable/delete (delete blocked while in use), usage counts, trial vs access typing; stats now include chores completed / active subs / paused-gated / active codes; families table shows plan+paused state. Remaining polish: table link duplication. +- [ ] `/admin` Families table: name link and "View" are duplicates after the broken-link fix — tidy up. ### App / UX - [ ] Signup wizard: `?plan=` param only pre-highlights the tier at step 4 — confirm whether it should auto-scroll/pulse instead diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 4651f49..d9e938e 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -5,6 +5,10 @@ declare global { interface Locals { user: SessionUser | null; pbToken: string | null; + // Platform-admin (superuser dashboard) session — set from the + // platform_session cookie in hooks.server.ts. Separate from the + // fam user session; only /admin consumes it. + platformAdmin: boolean; } } } diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index f94b1ca..24cea5a 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,6 +1,12 @@ import type { Handle } from '@sveltejs/kit'; import { createPbClient } from '$lib/server/pocketbase'; -import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session'; +import { + SESSION_COOKIE, + setSessionCookie, + clearSessionCookie, + PLATFORM_SESSION_COOKIE, + clearPlatformSession +} from '$lib/server/session'; import type { SessionUser } from '$lib/server/types'; import { handleOf } from '@shared/slugify'; import { migrateOnBoot } from '$lib/server/migrate-boot'; @@ -11,7 +17,26 @@ void migrateOnBoot(); export const handle: Handle = async ({ event, resolve }) => { event.locals.user = null; event.locals.pbToken = null; + event.locals.platformAdmin = false; + // Platform-admin routes authenticate via the superuser JWT in + // platform_session, verified against PB (authRefresh) — the cookie value + // is a real signed token, so forging it gains nothing. + if (event.url.pathname.startsWith('/admin')) { + const suToken = event.cookies.get(PLATFORM_SESSION_COOKIE); + if (suToken) { + try { + await createPbClient(suToken).collection('_superusers').authRefresh(); + event.locals.platformAdmin = true; + } catch { + // Expired/revoked/forged token — drop it and treat as logged out. + clearPlatformSession(event.cookies); + } + } + return resolve(event); + } + + // Fam-user session: pb_token JWT → authRefresh → locals.user. const token = event.cookies.get(SESSION_COOKIE); if (token) { diff --git a/frontend/src/lib/components/PricingPlans.svelte b/frontend/src/lib/components/PricingPlans.svelte index ad7b82b..4e888fa 100644 --- a/frontend/src/lib/components/PricingPlans.svelte +++ b/frontend/src/lib/components/PricingPlans.svelte @@ -1,13 +1,13 @@ - +
{#each tiers.filter((t) => !(hideTrial && t.id === 'trial')) as tier} - +
+ {#if tier.featured} + Most popular + {/if} +

{tier.name}

{tier.price} {tier.period}

-

{tier.desc}

+

{tier.blurb}

- {#if tier.id === 'trial'} -
- - - - -
- {:else} -
- - -
- {/if} +
+ + {#if tier.id === 'trial'} + + {/if} + +
- {#if error} -

{error}

- {/if} - +
    + {#each tier.features as f} +
  • ✓{f}
  • + {/each} +
+
{/each} - +
+ +{#if error} +

{error}

+{/if} \ No newline at end of file diff --git a/frontend/src/lib/server/session.ts b/frontend/src/lib/server/session.ts index bc6f7d5..0a06f64 100644 --- a/frontend/src/lib/server/session.ts +++ b/frontend/src/lib/server/session.ts @@ -29,4 +29,25 @@ const LEGACY_DEVICE_COOKIE = 'device_token'; export function clearLegacyCookies(cookies: Cookies) { cookies.delete(LEGACY_DEVICE_COOKIE, { path: '/' }); +} + +// ── Platform-admin session (/admin) ── +// Holds a REAL PocketBase superuser JWT (minted by _superusers.authWithPassword +// at login) — verified per-request in hooks via authRefresh, so forging the +// cookie value gains nothing. Separate from the fam-user pb_token. +export const PLATFORM_SESSION_COOKIE = 'platform_session'; +const PLATFORM_MAX_AGE = 60 * 60 * 24; // 24h; PB token expiry is the ceiling + +export function setPlatformSession(cookies: Cookies, token: string) { + cookies.set(PLATFORM_SESSION_COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: PLATFORM_MAX_AGE, + secure: import.meta.env.PROD + }); +} + +export function clearPlatformSession(cookies: Cookies) { + cookies.delete(PLATFORM_SESSION_COOKIE, { path: '/' }); } \ No newline at end of file diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index adc0dfe..15b3fa8 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -195,7 +195,6 @@ export interface TypingRow { export interface Session { famId: string; userId: string; - famSlug: string; memberName?: string; role?: string; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 45fb7cb..b5fc235 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -33,6 +33,9 @@

Start your family

Free to get going. Takes about a minute.

Create my family +

+ See pricing → +

Already have a family? Log in

@@ -94,6 +97,7 @@

Ready to make chores painless?

Create your family +

or see pricing →

diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts index 886a6c9..8acde5f 100644 --- a/frontend/src/routes/admin/+page.server.ts +++ b/frontend/src/routes/admin/+page.server.ts @@ -1,39 +1,117 @@ -import { pbAdmin } from '$lib/server/pocketbase'; +import { pbAdmin, createPbClient } from '$lib/server/pocketbase'; import { redirect, fail } from '@sveltejs/kit'; -import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; +import type { RequestEvent } from '@sveltejs/kit'; +import { setPlatformSession, clearPlatformSession } from '$lib/server/session'; import { getPlatformFlags, setPlatformFlag } from '$lib/server/platform'; import type { Actions, PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ cookies }) => { - const session = cookies.get('platform_session'); - if (!session) { - return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} }; +function requirePlatform(event: RequestEvent) { + if (!event.locals.platformAdmin) throw redirect(303, '/admin'); +} + +// Random human-friendly code value: XXXX-XXXX (unambiguous charset). +function genCodeValue(): string { + const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + const pick = () => chars[Math.floor(Math.random() * chars.length)]; + return `${Array.from({ length: 4 }, pick).join('')}-${Array.from({ length: 4 }, pick).join('')}`; +} + +export const load: PageServerLoad = async (event) => { + const { cookies } = event; + if (!event.locals.platformAdmin) { + return { + authenticated: false, + fams: [], + codes: [], + platformFlags: {}, + totalFams: 0, + totalMembers: 0, + totalRewards: 0, + totalChores: 0, + subCount: 0, + gatedCount: 0, + codeCount: 0, + loadError: undefined + }; } try { - const [fams, platformFlags] = await Promise.all([pbAdmin.getList('fams'), getPlatformFlags()]); - const famsWithStats = await Promise.all(fams.map(async (fam: any) => { - const [members, rewards, parents] = await Promise.all([ - pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`), - pbAdmin.getList('rewards', `famId = '${fam.id}'`), - pbAdmin.getList('users', `famId = '${fam.id}' && role = 'parent'`), - ]); - return { - id: fam.id, name: fam.name, slug: fam.slug, - memberCount: members.length, - requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length, - totalRewards: rewards.length, - parentEmail: (parents as any[])?.[0]?.email || '', - }; - })); + const [fams, codes, completions] = await Promise.all([ + pbAdmin.getList('fams'), + pbAdmin.getList('accesscodes'), + pbAdmin.getList('completions') + ]); + const famsWithStats = await Promise.all( + fams.map(async (fam: any) => { + const [members, rewards, parents] = await Promise.all([ + pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`), + pbAdmin.getList('rewards', `famId = '${fam.id}'`), + pbAdmin.getList('users', `famId = '${fam.id}' && role = 'parent'`) + ]); + return { + id: fam.id, + name: fam.name, + slug: fam.slug, + memberCount: members.length, + requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length, + totalRewards: rewards.length, + parentEmail: (parents as any[])?.[0]?.email || '', + paymentMode: fam.paymentMode || 'none', + active: fam.active !== false + }; + }) + ); - const totalFams = fams.length; - const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0); - const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0); + // Usage map — which fams applied each code. + const usage: Record = {}; + for (const fam of fams as any[]) { + if (fam.paymentMode === 'code' && fam.accessCodeId) { + (usage[fam.accessCodeId] ||= []).push(fam.name); + } + } + const codeList = (codes as any[]) + .sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')) + .map((c) => ({ + id: c.id, + value: c.value, + name: c.name, + duration: Number(c.duration) || 0, + expiry: Number(c.expiry) || 0, + trialDays: Number(c.trialDays) || 0, + active: c.active !== false, + usedBy: usage[c.id] || [] + })); - return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards, platformFlags }; - } catch { - return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} }; + return { + authenticated: true, + fams: famsWithStats, + codes: codeList, + totalFams: fams.length, + totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0), + totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0), + totalChores: completions.length, + subCount: famsWithStats.filter((f) => f.paymentMode === 'sub').length, + gatedCount: famsWithStats.filter( + (f) => !f.active || f.paymentMode === 'none' || f.paymentMode === 'canceled' + ).length, + codeCount: codeList.filter((c) => c.active).length + }; + } catch (e) { + // Never swallow silently — a failed load must not masquerade as logged-out. + console.error('[admin] load failed:', e); + return { + authenticated: false, + fams: [], + codes: [], + totalFams: 0, + totalMembers: 0, + totalRewards: 0, + totalChores: 0, + subCount: 0, + gatedCount: 0, + codeCount: 0, + loadError: e instanceof Error ? e.message : 'Failed to load platform data' + }; } }; @@ -43,29 +121,32 @@ export const actions: Actions = { const email = fd.get('email') as string; const password = fd.get('password') as string; - if (email === PB_EMAIL && password === PB_PASSWORD) { - cookies.set('platform_session', 'authenticated', { - path: '/', - httpOnly: true, - sameSite: 'lax', - maxAge: 60 * 60 * 24, // 24 hours - }); - return { success: true }; + if (!email || !password) return fail(400, { error: 'Email and password required' }); + + // Real PB superuser auth — the minted JWT goes in the cookie and is + // verified per-request in hooks (authRefresh). Forging the cookie + // value gains nothing. + try { + const auth = await createPbClient() + .collection('_superusers') + .authWithPassword(email, password); + setPlatformSession(cookies, auth.token); + } catch { + return fail(400, { error: 'Invalid credentials' }); } - return fail(400, { error: 'Invalid credentials' }); + return { success: true }; }, logout: async ({ cookies }) => { - cookies.delete('platform_session', { path: '/' }); + clearPlatformSession(cookies); throw redirect(303, '/admin'); }, // Toggles a platform-level feature flag on the singleton platform record. - togglePlatformFlag: async ({ request, cookies }) => { - const session = cookies.get('platform_session'); - if (!session) return fail(401, { error: 'Not authenticated' }); + togglePlatformFlag: async (event) => { + requirePlatform(event); - const fd = await request.formData(); + const fd = await event.request.formData(); const flag = fd.get('flag') as string; if (!flag) return fail(400, { error: 'Flag required' }); @@ -77,4 +158,72 @@ export const actions: Actions = { return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' }); } }, + + // Issue a new access/trial code. Blank value → auto-generated. trialDays > 0 + // makes it a trial code (maps to Stripe trial_period_days at checkout); + // otherwise it's a platform-access code (duration months, 0 = continuous). + createCode: async (event) => { + requirePlatform(event); + + const fd = await event.request.formData(); + const name = ((fd.get('name') as string) || '').trim(); + const value = ((fd.get('value') as string) || '').trim().toUpperCase() || genCodeValue(); + const duration = Math.max(0, parseInt(fd.get('duration') as string, 10) || 0); + const expiry = Math.max(0, parseInt(fd.get('expiry') as string, 10) || 0); + const trialDays = Math.max(0, parseInt(fd.get('trialDays') as string, 10) || 0); + + if (!name) return fail(400, { error: 'Name required' }); + + try { + const existing = await pbAdmin.getList('accesscodes', `value = '${value}'`); + if (existing.length) return fail(400, { error: `Code "${value}" already exists` }); + await pbAdmin.create('accesscodes', { + value, + name, + duration, + expiry, + trialDays, + active: true, + createdAt: new Date().toISOString() + }); + return { success: true, createdValue: value }; + } catch (e) { + return fail(500, { error: e instanceof Error ? e.message : 'Failed to create code' }); + } + }, + + toggleCode: async (event) => { + requirePlatform(event); + + const fd = await event.request.formData(); + const id = fd.get('id') as string; + try { + const rec = (await pbAdmin.getOne('accesscodes', id)) as any; + await pbAdmin.update('accesscodes', id, { active: rec.active === false }); + return { success: true }; + } catch (e) { + return fail(500, { error: e instanceof Error ? e.message : 'Failed to update code' }); + } + }, + + deleteCode: async (event) => { + requirePlatform(event); + + const fd = await event.request.formData(); + const id = fd.get('id') as string; + + // Guard: a code still applied to a fam must be disabled, not deleted. + const inUse = await pbAdmin.getList('fams', `accessCodeId = '${id}'`); + if (inUse.length) { + return fail(400, { + error: `In use by ${inUse.length} fam${inUse.length > 1 ? 's' : ''} — disable it instead.` + }); + } + try { + await pbAdmin.remove('accesscodes', id); + return { success: true }; + } catch (e) { + return fail(500, { error: e instanceof Error ? e.message : 'Failed to delete code' }); + } + } }; diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index b80d734..988b793 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -1,12 +1,43 @@ +
+
+ 🏠 FamChore + +
+ +
{#if !data.authenticated} - + - + + + {#if form?.error} +

{form.error}

+ {/if} + + +
+
+ + + + + +
+
+ + Duration 0 = continuous. Trial days > 0 makes it a Stripe trial code instead of + platform access. + + +
+
+ +
+ + + + + + + + + + + + + {#each data.codes as c} + + + + + + + + + {:else} + + {/each} + +
CodeNameTypeStatusUsed byActions
+ + {#if copied === c.value}copied!{/if} + {c.name} + {#if c.trialDays} + {c.trialDays}-day trial + {:else if c.duration} + {c.duration} mo + {:else} + continuous + {/if} + + {c.active ? 'active' : 'disabled'} + + {#if c.usedBy.length} + {c.usedBy.length} fam{c.usedBy.length > 1 ? 's' : ''} + {:else} + — + {/if} + +
+ + +
+ {#if !c.usedBy.length} +
+ + +
+ {/if} +
No codes yet — issue one above.
+
+
+
+ + + +
+ - + {#each data.fams as fam} - - - - + + + - + {/each} @@ -100,103 +258,332 @@ - - - {/if} + + +
+ diff --git a/frontend/src/routes/pricing/+page.svelte b/frontend/src/routes/pricing/+page.svelte index 6b8b2b9..801cea4 100644 --- a/frontend/src/routes/pricing/+page.svelte +++ b/frontend/src/routes/pricing/+page.svelte @@ -3,7 +3,8 @@ import { enhance } from '$app/forms'; import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public'; import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js'; - import { ViewHeader, CardGrid, Card, Button, PricingPlans } from '$lib/components'; + import { Button, PricingPlans } from '$lib/components'; + import Footer from '$lib/components/Footer.svelte'; let { data, form } = $props(); @@ -45,55 +46,149 @@ } - +Pricing — FamChore -{#if !showCheckout} - -{:else} - - -
- +
+
+ 🏠 FamChore + +
+ +
+ {#if !showCheckout} +
+

Simple pricing for every family

+

One price, the whole family. Start free — upgrade whenever you're ready.

-
-

- You can close and go to your dashboard any time — access unlocks once payment completes. -

- Go to dashboard - - -{/if} + + {:else} +
+
+
+

Checkout — {checkoutTitle}

+ +
+
+

+ You can close and go to your dashboard any time — access unlocks once payment completes. +

+ Go to dashboard → +
+
+ {/if} +
+ +
+
\ No newline at end of file
Name Parent MembersPlan ClaimsActionsActions
- {fam.name} + + {fam.name} /{fam.slug} {fam.parentEmail || '—'}{fam.memberCount} + {fam.parentEmail || '—'}{fam.memberCount} + + {modeLabel[fam.paymentMode] || fam.paymentMode}{fam.active ? '' : ' · paused'} + + {#if fam.requestedRewards > 0} {fam.requestedRewards} pending {:else} None {/if} - Dashboard - View - View →