{tier.name}
{tier.price} {tier.period}
-{tier.desc}
+{tier.blurb}
- {#if tier.id === 'trial'} - - {:else} - - {/if} + - {#if error} -{error}
- {/if} -{tier.price} {tier.period}
-{tier.desc}
+{tier.blurb}
- {#if tier.id === 'trial'} - - {:else} - - {/if} + - {#if error} -{error}
- {/if} -{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 @@Free to get going. Takes about a minute.
Create my family ++ See pricing → +
Already have a family? Log in
@@ -94,6 +97,7 @@ 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: RecordSign in to access the admin dashboard
@@ -14,8 +45,27 @@ {#if form?.error}{form.error}
{/if} + {#if data.loadError} +Data load failed: {data.loadError}
+ {/if} -{form.error}
+ {/if} + + + + +| Code | +Name | +Type | +Status | +Used by | +Actions | +
|---|---|---|---|---|---|
| + + {#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. | |||||
| Name | Parent | Members | +Plan | Claims | -Actions | +Actions | ||||
|---|---|---|---|---|---|---|---|---|---|---|
| - {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 → |