Compare commits

..

2 Commits

Author SHA1 Message Date
JCEEE 87c28cb6ce 1.8.7 2026-09-01 11:58:47 +01:00
JCEEE 40d7bf7398 final flows including emails for passwords 2026-09-01 11:58:43 +01:00
23 changed files with 1978 additions and 860 deletions
+1
View File
@@ -35,6 +35,7 @@
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"pocketbase": "^0.27.0", "pocketbase": "^0.27.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"resend": "^6.25.0",
"stripe": "^22.5.0" "stripe": "^22.5.0"
} }
} }
+5 -3
View File
@@ -1,13 +1,14 @@
import { defineEnvVars } from '@sveltejs/kit/hooks'; import { defineEnvVars } from '@sveltejs/kit/hooks';
// Default when the env var isn't set, so a missing value never crashes startup. // Default when the env var isn't set, so a missing value never crashes startup.
const withDefault = (value: string) => ({ const withDefault = (value: string) =>
({
'~standard': { '~standard': {
version: 1, version: 1,
vendor: 'famchamp', vendor: 'famchamp',
validate: (v: unknown) => ({ value: typeof v === 'string' && v ? v : value }) validate: (v: unknown) => ({ value: typeof v === 'string' && v ? v : value })
} }
} as const); }) as const;
export const variables = defineEnvVars({ export const variables = defineEnvVars({
SERVER_IP: { public: true, schema: withDefault('192.168.1.225') }, SERVER_IP: { public: true, schema: withDefault('192.168.1.225') },
@@ -28,5 +29,6 @@ export const variables = defineEnvVars({
STRIPE_PRICE_MONTHLY: { public: false, schema: withDefault('') }, STRIPE_PRICE_MONTHLY: { public: false, schema: withDefault('') },
STRIPE_PRICE_YEARLY: { public: false, schema: withDefault('') }, STRIPE_PRICE_YEARLY: { public: false, schema: withDefault('') },
// Stripe publishable key (client-side for Checkout redirect). // Stripe publishable key (client-side for Checkout redirect).
PUBLIC_STRIPE_PUBLISHABLE_KEY: { public: true, schema: withDefault('') } PUBLIC_STRIPE_PUBLISHABLE_KEY: { public: true, schema: withDefault('') },
RESEND_API: { public: false, schema: withDefault('') }
}); });
@@ -1,17 +1,24 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
let { title, open = $bindable(false), children }: { title: string; open?: boolean; children: Snippet } = $props(); let {
title,
icon,
open = $bindable(false),
children
}: { title: string; icon?: Snippet; open?: boolean; children: Snippet } = $props();
</script> </script>
<div class="accordion-item" class:open> <div class="accordion-item" class:open>
<button <button class="accordion-trigger" onclick={() => (open = !open)} aria-expanded={open}>
class="accordion-trigger" <span class="accordion-title">
onclick={() => (open = !open)} {#if icon}
aria-expanded={open} <span class="accordion-icon">{@render icon()}</span>
> {/if}
<span>{title}</span> <span>{title}</span>
<span class="accordion-arrow">{open ? '▾' : '▸'}</span> </span>
<span class="accordion-arrow" class:rotated={open}><ChevronDown /></span>
</button> </button>
{#if open} {#if open}
<div class="accordion-body"> <div class="accordion-body">
@@ -21,16 +28,58 @@
</div> </div>
<style> <style>
.accordion-item { border-bottom: 1px solid #f3f4f6; } .accordion-item {
.accordion-item:last-child { border-bottom: none; } border-bottom: 1px solid #f3f4f6;
.accordion-trigger { }
display: flex; justify-content: space-between; align-items: center; .accordion-item:last-child {
width: 100%; padding: 0.7rem 1rem; border-bottom: none;
background: #fafafa; border: none; }
font-size: 0.95rem; font-weight: 600; color: #374151; .accordion-trigger {
cursor: pointer; text-align: left; display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0.7rem 1rem;
background: #fafafa;
border: none;
font-size: 0.95rem;
font-weight: 600;
color: #374151;
cursor: pointer;
text-align: left;
}
.accordion-trigger:hover {
background: #f3f4f6;
}
.accordion-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.accordion-icon {
display: inline-flex;
align-items: center;
color: #6366f1;
}
.accordion-icon :global(svg) {
width: 1.05em;
height: 1.05em;
}
.accordion-arrow {
display: inline-flex;
align-items: center;
color: #9ca3af;
transition: transform 0.15s ease;
transform: rotate(-90deg);
}
.accordion-arrow.rotated {
transform: rotate(0deg);
}
.accordion-arrow :global(svg) {
width: 1.1em;
height: 1.1em;
}
.accordion-body {
padding: 1rem;
} }
.accordion-trigger:hover { background: #f3f4f6; }
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
.accordion-body { padding: 1rem; }
</style> </style>
+84 -17
View File
@@ -1,6 +1,13 @@
<script lang="ts"> <script lang="ts">
let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: { let {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success'; variant = 'primary',
size = 'md',
href,
onclick,
children,
...rest
}: {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'purple';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
href?: string; href?: string;
onclick?: () => void; onclick?: () => void;
@@ -10,7 +17,7 @@
</script> </script>
{#if href} {#if href}
<a href={href} class="btn btn-{variant} btn-{size}" {...rest}> <a {href} class="btn btn-{variant} btn-{size}" {...rest}>
{@render children?.()} {@render children?.()}
</a> </a>
{:else} {:else}
@@ -28,25 +35,85 @@
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: background 0.15s, border-color 0.15s; transition:
background 0.15s,
border-color 0.15s;
border: 1px solid transparent; border: 1px solid transparent;
} }
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; } .btn-sm {
.btn-md { padding: 0.45rem 0.9rem; font-size: 0.85rem; } padding: 0.3rem 0.6rem;
.btn-lg { padding: 0.6rem 1.2rem; font-size: 0.95rem; } font-size: 0.8rem;
}
.btn-md {
padding: 0.45rem 0.9rem;
font-size: 0.85rem;
}
.btn-lg {
padding: 0.6rem 1.2rem;
font-size: 0.95rem;
}
.btn-primary { background: #4338ca; color: #fff; border-color: #4338ca; } .btn-primary {
.btn-primary:hover { background: #3730a3; } background: #4338ca;
color: #fff;
border-color: #4338ca;
}
.btn-primary:hover {
background: #3730a3;
}
.btn-primary:disabled {
background: #a5b4fc;
border-color: #a5b4fc;
color: #fff;
}
.btn-secondary { background: #f3f4f6; color: #374151; border-color: #d1d5db; } .btn-secondary {
.btn-secondary:hover { background: #e5e7eb; } background: #f3f4f6;
color: #374151;
border-color: #d1d5db;
}
.btn-secondary:hover {
background: #e5e7eb;
}
.btn-ghost { background: transparent; color: #6b7280; border-color: transparent; } .btn-ghost {
.btn-ghost:hover { background: #f3f4f6; } background: transparent;
color: #6b7280;
border-color: transparent;
}
.btn-ghost:hover {
background: #f3f4f6;
}
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; } .btn-danger {
.btn-danger:hover { background: #b91c1c; } background: #dc2626;
color: #fff;
border-color: #dc2626;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn-success { background: #059669; color: #fff; border-color: #059669; } .btn-success {
.btn-success:hover { background: #047857; } background: #059669;
color: #fff;
border-color: #059669;
}
.btn-success:hover {
background: #047857;
}
.btn-purple {
background: #7c3aed;
color: #fff;
border-color: #7c3aed;
}
.btn-purple:hover {
background: #6d28d9;
}
.btn-purple:disabled {
background: #c4b5fd;
border-color: #c4b5fd;
color: #fff;
}
</style> </style>
+1 -1
View File
@@ -80,7 +80,7 @@
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
height: 100vh; height: 100dvh;
width: 220px; width: 220px;
background: #1e1b4b; background: #1e1b4b;
color: #e0e7ff; color: #e0e7ff;
+5 -1
View File
@@ -5,11 +5,15 @@ export const pb = new PocketBase(PB_ENDPOINT);
pb.autoCancellation(false); pb.autoCancellation(false);
// Seed the browser PB singleton with the session token so shared stores can // Seed the browser PB singleton with the session token so shared stores can
// do authenticated reads + realtime .subscribe() from the client. // do authenticated reads + realtime .subscribe() from the client. When the
// session is gone (logged out), clear the authStore — the default LocalAuthStore
// persists the token in localStorage, so without this the client keeps an
// authenticated singleton and effectively stays logged in.
export function initRealtimePb(token: string) { export function initRealtimePb(token: string) {
if (token) { if (token) {
pb.authStore.save(token, null); pb.authStore.save(token, null);
return true; return true;
} }
pb.authStore.clear();
return false; return false;
} }
+45
View File
@@ -0,0 +1,45 @@
import { Resend } from 'resend';
import { RESEND_API } from '$app/env/private';
const resend = new Resend(String(RESEND_API));
export async function sendPasswordResetEmail(opts: {
to: string;
resetLink: string;
famName: string;
}) {
const { to, resetLink, famName } = opts;
await resend.emails.send({
from: 'no-reply@famchamp.ai',
to,
subject: `Reset your password for ${famName}`,
html: `
<p>You requested a password reset for your <strong>${famName}</strong> account.</p>
<p><a href="${resetLink}">Click here to reset your password</a></p>
<p>This link expires in 1 hour.</p>
<p>If you didn't request this, you can ignore this email.</p>
`
});
}
export async function sendParentInviteEmail(opts: {
to: string;
inviteLink: string;
famName: string;
otp: string;
}) {
const { to, inviteLink, famName, otp } = opts;
await resend.emails.send({
from: 'no-reply@famchamp.ai',
to,
subject: `You're invited to ${famName} on FamChore`,
html: `
<p>You've been invited as a parent on <strong>${famName}</strong>.</p>
<p><a href="${inviteLink}">Click here to join</a> and set up your password.</p>
<p>Your code is <strong>${otp}</strong> (valid 20 minutes).</p>
<p>If you weren't expecting this, you can ignore this email.</p>
`
});
}
+88
View File
@@ -118,6 +118,94 @@ export async function issueAccess(opts: {
return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(username)}` }; return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(username)}` };
} }
// Admin invites a second parent: creates a role='parent' users record with the
// shared derived password (never known — the invited parent sets their own at
// the join page) + issues an OTP email code. Rejects duplicates in the family.
export async function inviteParent(opts: {
famId: string;
famSlug: string;
name: string;
email: string;
}) {
const { famId, famSlug, name, email } = opts;
const handleName = handle(name);
const username = famUsername(famSlug, handleName);
const password = derivePassword(famSlug, handleName);
const pb = await createSuperClient();
const existing = await pb
.collection('users')
.getFirstListItem(`famId='${famId}' && (username='${username}' || email='${email}')`)
.catch(() => null);
if (existing) {
throw new Error('A user with that name or email already exists in this family');
}
const user = await pb.collection('users').create({
username,
name,
email,
emailVisibility: false,
password,
passwordConfirm: password,
famId,
role: 'parent'
});
const otp = generateOtp();
await pb.collection('otp').create({
famId,
userId: user.id,
otp,
updatedAt: new Date().toISOString()
});
return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(handleName)}` };
}
// Invited parent redeems their OTP at the join page, sets their own password,
// and is logged in. Single-use — the OTP record is deleted on success.
export async function redeemParentOtp(opts: {
famSlug: string;
username: string;
otp: string;
password: string;
}) {
const { famSlug, username, otp, password } = opts;
const handleName = handle(username);
const fullUsername = famUsername(famSlug, handleName);
const pb = await createSuperClient();
const fam = await pb.collection('fams').getFirstListItem(`slug='${famSlug}'`);
if (!fam) throw new Error('Invalid join link');
let user = await pb
.collection('users')
.getFirstListItem(`famId='${fam.id}' && username='${fullUsername}'`)
.catch(() => null);
if (!user || user.role !== 'parent') throw new Error('Invalid join link');
const config = await pb
.collection('otp')
.getFirstListItem(`famId='${fam.id}' && userId='${user.id}'`)
.catch(() => null);
if (!config || config.otp !== otp) throw new Error('Invalid code');
const issued = Date.parse(config.updatedAt || '');
if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired');
await pb.collection('users').update(user.id, { password, passwordConfirm: password });
await pb
.collection('otp')
.delete(config.id)
.catch(() => null);
const authPb = createPbClient();
await authPb.collection('users').authWithPassword(user.email, password);
return authPb.authStore.token;
}
// Child redeems their OTP at /{famSlug}/join/{username}. Verifies the code, // Child redeems their OTP at /{famSlug}/join/{username}. Verifies the code,
// the 20-minute window, and that the account is a child, then authenticates via // the 20-minute window, and that the account is a child, then authenticates via
// authWithPassword and returns a fresh PB JWT. Throws on any failure. // authWithPassword and returns a fresh PB JWT. Throws on any failure.
+308 -226
View File
@@ -6,14 +6,11 @@ let token: string | null = null;
async function auth(): Promise<string> { async function auth(): Promise<string> {
if (token) return token; if (token) return token;
const res = await fetch( const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, method: 'POST',
{ headers: { 'Content-Type': 'application/json' },
method: "POST", body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD })
headers: { "Content-Type": "application/json" }, });
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
},
);
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
token = data.token; token = data.token;
@@ -22,10 +19,9 @@ async function auth(): Promise<string> {
async function getCollection(name: string): Promise<any | null> { async function getCollection(name: string): Promise<any | null> {
const t = await auth(); const t = await auth();
const res = await fetch( const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, {
`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, headers: { Authorization: `Bearer ${t}` }
{ headers: { Authorization: `Bearer ${t}` } }, });
);
const data = await res.json(); const data = await res.json();
return data?.items?.[0] || null; return data?.items?.[0] || null;
} }
@@ -33,9 +29,9 @@ async function getCollection(name: string): Promise<any | null> {
async function createCollection(col: any): Promise<string | null> { async function createCollection(col: any): Promise<string | null> {
const t = await auth(); const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections`, { const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(col), body: JSON.stringify(col)
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
@@ -46,9 +42,9 @@ async function createCollection(col: any): Promise<string | null> {
async function updateCollection(id: string, col: any): Promise<void> { async function updateCollection(id: string, col: any): Promise<void> {
const t = await auth(); const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, { const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, {
method: "PATCH", method: 'PATCH',
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(col), body: JSON.stringify(col)
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`); if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`);
@@ -58,9 +54,9 @@ async function updateCollection(id: string, col: any): Promise<void> {
async function createRecord(collection: string, data: any): Promise<void> { async function createRecord(collection: string, data: any): Promise<void> {
const t = await auth(); const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records`, { const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(data), body: JSON.stringify(data)
}); });
if (!res.ok) { if (!res.ok) {
const d = await res.json(); const d = await res.json();
@@ -75,14 +71,14 @@ async function createRecord(collection: string, data: any): Promise<void> {
// boot. Families that want it can add it themselves. // boot. Families that want it can add it themselves.
// Only seeds when a family has zero seasons, so user deletions stick. // Only seeds when a family has zero seasons, so user deletions stick.
async function ensureDefaultSeasons(): Promise<void> { async function ensureDefaultSeasons(): Promise<void> {
const famsCol = await getCollection("fams"); const famsCol = await getCollection('fams');
if (!famsCol) return; if (!famsCol) return;
const t = await auth(); const t = await auth();
const famsRes = await fetch(`${PB_ENDPOINT}/api/collections/fams/records?perPage=200`, { const famsRes = await fetch(`${PB_ENDPOINT}/api/collections/fams/records?perPage=200`, {
headers: { Authorization: `Bearer ${t}` }, headers: { Authorization: `Bearer ${t}` }
}); });
const fams = (await famsRes.json()).items || []; const fams = (await famsRes.json()).items || [];
const defaults = [{ name: "Holidays", color: "#f59e0b", active: true }]; const defaults = [{ name: 'Holidays', color: '#f59e0b', active: true }];
for (const fam of fams) { for (const fam of fams) {
const sRes = await fetch( const sRes = await fetch(
`${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`, `${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`,
@@ -95,7 +91,7 @@ async function ensureDefaultSeasons(): Promise<void> {
// at least one season remains. // at least one season remains.
if (have.length === 0) { if (have.length === 0) {
for (const d of defaults) { for (const d of defaults) {
await createRecord("seasons", { famId: fam.id, ...d }); await createRecord('seasons', { famId: fam.id, ...d });
console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`); console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`);
} }
} }
@@ -107,74 +103,100 @@ async function ensureDefaultSeasons(): Promise<void> {
// role='child'; username is a password-auth identity so the server can // role='child'; username is a password-auth identity so the server can
// authWithPassword(derivedPassword) at OTP join time. // authWithPassword(derivedPassword) at OTP join time.
async function ensureUsers(ids: Record<string, string>): Promise<void> { async function ensureUsers(ids: Record<string, string>): Promise<void> {
const usersCol = await getCollection("users"); const usersCol = await getCollection('users');
if (!usersCol) throw new Error("users collection not found"); if (!usersCol) throw new Error('users collection not found');
const famsId = ids.fams || (await getCollection("fams"))?.id; const famsId = ids.fams || (await getCollection('fams'))?.id;
if (!famsId) throw new Error("fams collection not found"); if (!famsId) throw new Error('fams collection not found');
const has = (n: string) => usersCol.fields.some((f: any) => f.name === n); const has = (n: string) => usersCol.fields.some((f: any) => f.name === n);
let changed = false; let changed = false;
const emailField = usersCol.fields.find((f: any) => f.name === "email"); const emailField = usersCol.fields.find((f: any) => f.name === 'email');
if (emailField && emailField.required) { if (emailField && emailField.required) {
emailField.required = false; emailField.required = false;
changed = true; changed = true;
} }
if (!has("famId")) { if (!has('famId')) {
usersCol.fields.push({ usersCol.fields.push({
name: "famId", type: "relation", required: false, name: 'famId',
collectionId: famsId, maxSelect: 1, cascadeDelete: false, type: 'relation',
required: false,
collectionId: famsId,
maxSelect: 1,
cascadeDelete: false
}); });
changed = true; changed = true;
} }
if (!has("role")) { if (!has('role')) {
usersCol.fields.push({ name: "role", type: "select", required: false, values: ["parent", "child"], maxSelect: 1 }); usersCol.fields.push({
name: 'role',
type: 'select',
required: false,
values: ['parent', 'child'],
maxSelect: 1
});
changed = true; changed = true;
} }
if (!has("username")) { if (!has('username')) {
usersCol.fields.push({ name: "username", type: "text", required: true }); usersCol.fields.push({ name: 'username', type: 'text', required: true });
changed = true; changed = true;
} }
if (!has("color")) { if (!has('color')) {
usersCol.fields.push({ name: "color", type: "text", required: false }); usersCol.fields.push({ name: 'color', type: 'text', required: false });
changed = true;
}
if (!has('passwordResetToken')) {
usersCol.fields.push({ name: 'passwordResetToken', type: 'text', required: false });
changed = true;
}
if (!has('passwordResetExpiry')) {
usersCol.fields.push({ name: 'passwordResetExpiry', type: 'text', required: false });
changed = true; changed = true;
} }
let indexes = usersCol.indexes || []; let indexes = usersCol.indexes || [];
if (!indexes.some((i: string) => /username/i.test(i))) { if (!indexes.some((i: string) => /username/i.test(i))) {
indexes = [...indexes, "CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''"]; indexes = [
...indexes,
"CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''"
];
changed = true; changed = true;
} }
const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ["email"] }; const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ['email'] };
const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ["email"]; const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ['email'];
if (!identityFields.includes("username")) { if (!identityFields.includes('username')) {
identityFields.push("username"); identityFields.push('username');
changed = true; changed = true;
} }
const listRule = "famId = @request.auth.famId"; const listRule = 'famId = @request.auth.famId';
const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'"; const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'";
// Members can edit their own record (name/colour); parents can edit any // Members can edit their own record (name/colour); parents can edit any
// family member. Delete stays parent-only. // family member. Delete stays parent-only.
const selfOrParentWrite = "@request.auth.id = id || (famId = @request.auth.famId && @request.auth.role = 'parent')"; const selfOrParentWrite =
if (usersCol.listRule !== listRule || usersCol.viewRule !== listRule || "@request.auth.id = id || (famId = @request.auth.famId && @request.auth.role = 'parent')";
usersCol.updateRule !== selfOrParentWrite || usersCol.deleteRule !== parentWrite) { if (
usersCol.listRule !== listRule ||
usersCol.viewRule !== listRule ||
usersCol.updateRule !== selfOrParentWrite ||
usersCol.deleteRule !== parentWrite
) {
changed = true; changed = true;
} }
if (changed) { if (changed) {
await updateCollection(usersCol.id, { await updateCollection(usersCol.id, {
name: "users", name: 'users',
type: "auth", type: 'auth',
listRule, listRule,
viewRule: listRule, viewRule: listRule,
createRule: usersCol.createRule || "", createRule: usersCol.createRule || '',
updateRule: selfOrParentWrite, updateRule: selfOrParentWrite,
deleteRule: parentWrite, deleteRule: parentWrite,
fields: usersCol.fields, fields: usersCol.fields,
indexes, indexes,
passwordAuth: { enabled: true, identityFields }, passwordAuth: { enabled: true, identityFields }
}); });
} }
} }
@@ -183,24 +205,38 @@ async function ensureUsers(ids: Record<string, string>): Promise<void> {
// its issue timestamp (20-min window). Not public — read/written via the // its issue timestamp (20-min window). Not public — read/written via the
// superuser client only. // superuser client only.
async function ensureOtp(ids: Record<string, string>): Promise<void> { async function ensureOtp(ids: Record<string, string>): Promise<void> {
if (await getCollection("otp")) return; if (await getCollection('otp')) return;
const famsId = ids.fams || (await getCollection("fams"))?.id; const famsId = ids.fams || (await getCollection('fams'))?.id;
const usersId = ids.users || (await getCollection("users"))?.id; const usersId = ids.users || (await getCollection('users'))?.id;
if (!famsId || !usersId) throw new Error("fams/users collection not found"); if (!famsId || !usersId) throw new Error('fams/users collection not found');
await createCollection({ await createCollection({
name: "otp", name: 'otp',
type: "base", type: 'base',
listRule: null, listRule: null,
viewRule: null, viewRule: null,
createRule: null, createRule: null,
updateRule: null, updateRule: null,
deleteRule: null, deleteRule: null,
fields: [ 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: 'famId',
{ name: "otp", type: "text", required: false }, type: 'relation',
{ name: "updatedAt", type: "text", required: false }, required: true,
], collectionId: famsId,
maxSelect: 1,
cascadeDelete: false
},
{
name: 'userId',
type: 'relation',
required: true,
collectionId: usersId,
maxSelect: 1,
cascadeDelete: false
},
{ name: 'otp', type: 'text', required: false },
{ name: 'updatedAt', type: 'text', required: false }
]
}); });
} }
@@ -211,43 +247,43 @@ async function ensureOtp(ids: Record<string, string>): Promise<void> {
// (0 = never expires); `active` is a failsafe toggle. Entered at create-family // (0 = never expires); `active` is a failsafe toggle. Entered at create-family
// and in admin settings. // and in admin settings.
async function ensureAccessCodes(): Promise<void> { async function ensureAccessCodes(): Promise<void> {
if (await getCollection("accesscodes")) { if (await getCollection('accesscodes')) {
await ensureAccessCodeFields(); await ensureAccessCodeFields();
await seedAccessCodes(); await seedAccessCodes();
return; return;
} }
await createCollection({ await createCollection({
name: "accesscodes", name: 'accesscodes',
type: "base", type: 'base',
listRule: null, listRule: null,
viewRule: null, viewRule: null,
createRule: null, createRule: null,
updateRule: null, updateRule: null,
deleteRule: null, deleteRule: null,
fields: [ fields: [
{ name: "value", type: "text", required: true, unique: true }, { name: 'value', type: 'text', required: true, unique: true },
{ name: "name", type: "text", required: true }, { name: 'name', type: 'text', required: true },
{ name: "duration", type: "number", required: false }, { name: 'duration', type: 'number', required: false },
{ name: "expiry", type: "number", required: false }, { name: 'expiry', type: 'number', required: false },
{ name: "active", type: "bool", required: false }, { name: 'active', type: 'bool', required: false },
// When set (>0) this code is a TRIAL code: maps to Stripe // When set (>0) this code is a TRIAL code: maps to Stripe
// trial_period_days at checkout instead of platform access. // trial_period_days at checkout instead of platform access.
{ name: "trialDays", type: "number", required: false }, { name: 'trialDays', type: 'number', required: false },
{ name: "createdAt", type: "date", required: false }, { name: 'createdAt', type: 'date', required: false }
], ]
}); });
await seedAccessCodes(); await seedAccessCodes();
} }
// Idempotent field-add for installs where accesscodes predates a field. // Idempotent field-add for installs where accesscodes predates a field.
async function ensureAccessCodeFields(): Promise<void> { async function ensureAccessCodeFields(): Promise<void> {
const col = await getCollection("accesscodes"); const col = await getCollection('accesscodes');
if (!col) return; if (!col) return;
const has = (n: string) => col.fields.some((f: any) => f.name === n); const has = (n: string) => col.fields.some((f: any) => f.name === n);
if (!has("trialDays")) { if (!has('trialDays')) {
await updateCollection(col.id, { await updateCollection(col.id, {
...col, ...col,
fields: [...col.fields, { name: "trialDays", type: "number", required: false }], fields: [...col.fields, { name: 'trialDays', type: 'number', required: false }]
}); });
} }
} }
@@ -256,20 +292,27 @@ async function ensureAccessCodeFields(): Promise<void> {
async function seedAccessCodes(): Promise<void> { async function seedAccessCodes(): Promise<void> {
const t = await auth(); const t = await auth();
const seeds = [ const seeds = [
{ value: "dev123", name: "developer", duration: 0, expiry: 0, active: true, trialDays: null }, { value: 'dev123', name: 'developer', duration: 0, expiry: 0, active: true, trialDays: null },
{ value: "FAM3MONTHS", name: "3-month trial", duration: null, expiry: null, active: true, trialDays: 90 }, {
value: 'FAM3MONTHS',
name: '3-month trial',
duration: null,
expiry: null,
active: true,
trialDays: 90
}
]; ];
for (const seed of seeds) { for (const seed of seeds) {
const res = await fetch( const res = await fetch(
`${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='${seed.value}'`, `${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='${seed.value}'`,
{ headers: { Authorization: `Bearer ${t}` } }, { headers: { Authorization: `Bearer ${t}` } }
); );
const data = await res.json(); const data = await res.json();
if (data?.items?.length) continue; if (data?.items?.length) continue;
const created = await fetch(`${PB_ENDPOINT}/api/collections/accesscodes/records`, { const created = await fetch(`${PB_ENDPOINT}/api/collections/accesscodes/records`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...seed, createdAt: new Date().toISOString() }), body: JSON.stringify({ ...seed, createdAt: new Date().toISOString() })
}); });
const c = await created.json(); const c = await created.json();
if (!created.ok) throw new Error(`Seed accesscode failed: ${JSON.stringify(c)}`); if (!created.ok) throw new Error(`Seed accesscode failed: ${JSON.stringify(c)}`);
@@ -283,19 +326,19 @@ async function seedAccessCodes(): Promise<void> {
// superuser-only (null rules), so like otp/accesscodes this lives in // superuser-only (null rules), so like otp/accesscodes this lives in
// migrate.ts rather than SCHEMA_PLAN. // migrate.ts rather than SCHEMA_PLAN.
async function ensurePlatform(): Promise<void> { async function ensurePlatform(): Promise<void> {
if (!(await getCollection("platform"))) { if (!(await getCollection('platform'))) {
await createCollection({ await createCollection({
name: "platform", name: 'platform',
type: "base", type: 'base',
listRule: "", listRule: '',
viewRule: "", viewRule: '',
createRule: null, createRule: null,
updateRule: null, updateRule: null,
deleteRule: null, deleteRule: null,
fields: [ fields: [
{ name: "label", type: "text", required: true }, { name: 'label', type: 'text', required: true },
{ name: "flags", type: "json", required: false }, { name: 'flags', type: 'json', required: false }
], ]
}); });
} }
await seedPlatform(); await seedPlatform();
@@ -304,34 +347,33 @@ async function ensurePlatform(): Promise<void> {
// Idempotent seed — create the singleton 'global' settings record if missing. // Idempotent seed — create the singleton 'global' settings record if missing.
async function seedPlatform(): Promise<void> { async function seedPlatform(): Promise<void> {
const t = await auth(); const t = await auth();
const res = await fetch( const res = await fetch(`${PB_ENDPOINT}/api/collections/platform/records?filter=label='global'`, {
`${PB_ENDPOINT}/api/collections/platform/records?filter=label='global'`, headers: { Authorization: `Bearer ${t}` }
{ headers: { Authorization: `Bearer ${t}` } }, });
);
const data = await res.json(); const data = await res.json();
if (data?.items?.length) return; if (data?.items?.length) return;
const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, { const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ label: "global", flags: { debug: false } }), body: JSON.stringify({ label: 'global', flags: { debug: false } })
}); });
const c = await created.json(); const c = await created.json();
if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`); if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`);
console.log(" ✓ Seeded platform settings (global)"); console.log(' ✓ Seeded platform settings (global)');
} }
// Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if // Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if
// `fams` already exists (data is disposable; there is no incremental migration // `fams` already exists (data is disposable; there is no incremental migration
// history). // history).
async function ensureSchema(): Promise<void> { async function ensureSchema(): Promise<void> {
if (await getCollection("fams")) { if (await getCollection('fams')) {
console.log("[migrate] Schema already present — skipping bootstrap."); console.log('[migrate] Schema already present — skipping bootstrap.');
return; return;
} }
console.log("[migrate] Bootstrapping schema on fresh PocketBase..."); console.log('[migrate] Bootstrapping schema on fresh PocketBase...');
const ids: Record<string, string> = {}; const ids: Record<string, string> = {};
const nativeUsers = await getCollection("users"); const nativeUsers = await getCollection('users');
if (nativeUsers) ids.users = nativeUsers.id; if (nativeUsers) ids.users = nativeUsers.id;
for (const entry of SCHEMA_PLAN) { for (const entry of SCHEMA_PLAN) {
const createdId = await createCollection(entry.build(ids)); const createdId = await createCollection(entry.build(ids));
@@ -340,28 +382,34 @@ async function ensureSchema(): Promise<void> {
await ensureUsers(ids); await ensureUsers(ids);
await ensureOtp(ids); await ensureOtp(ids);
console.log("[migrate] Schema bootstrapped."); console.log('[migrate] Schema bootstrapped.');
} }
// Add the access-gating fields to `fams` on installs where it already exists // Add the access-gating fields to `fams` on installs where it already exists
// (fresh installs get them via SCHEMA_PLAN). Idempotent — only adds missing // (fresh installs get them via SCHEMA_PLAN). Idempotent — only adds missing
// fields. // fields.
async function ensureFamFields(): Promise<void> { async function ensureFamFields(): Promise<void> {
const famsCol = await getCollection("fams"); const famsCol = await getCollection('fams');
if (!famsCol) return; if (!famsCol) return;
const has = (n: string) => famsCol.fields.some((f: any) => f.name === n); const has = (n: string) => famsCol.fields.some((f: any) => f.name === n);
const needed: any[] = []; const needed: any[] = [];
if (!has("active")) { if (!has('active')) {
needed.push({ name: "active", type: "bool", required: false }); needed.push({ name: 'active', type: 'bool', required: false });
} }
if (!has("paymentMode")) { if (!has('paymentMode')) {
needed.push({ name: "paymentMode", type: "select", required: false, values: ["none", "code", "sub", "canceled"], maxSelect: 1 }); needed.push({
name: 'paymentMode',
type: 'select',
required: false,
values: ['none', 'code', 'sub', 'canceled'],
maxSelect: 1
});
} }
if (!has("accessCodeId")) { if (!has('accessCodeId')) {
needed.push({ name: "accessCodeId", type: "text", required: false }); needed.push({ name: 'accessCodeId', type: 'text', required: false });
} }
if (!has("accessCodeEnteredAt")) { if (!has('accessCodeEnteredAt')) {
needed.push({ name: "accessCodeEnteredAt", type: "date", required: false }); needed.push({ name: 'accessCodeEnteredAt', type: 'date', required: false });
} }
if (needed.length) { if (needed.length) {
await updateCollection(famsCol.id, { ...famsCol, fields: [...famsCol.fields, ...needed] }); await updateCollection(famsCol.id, { ...famsCol, fields: [...famsCol.fields, ...needed] });
@@ -369,7 +417,7 @@ async function ensureFamFields(): Promise<void> {
} }
export async function migrate(): Promise<void> { export async function migrate(): Promise<void> {
console.log("[migrate] Checking PB collection schemas..."); console.log('[migrate] Checking PB collection schemas...');
await ensureSchema(); await ensureSchema();
// Runs even when the schema already exists (unlike ensureSchema's early // Runs even when the schema already exists (unlike ensureSchema's early
// return) so new platform collections/fields/seed land on existing installs. // return) so new platform collections/fields/seed land on existing installs.
@@ -380,29 +428,29 @@ export async function migrate(): Promise<void> {
await ensureAccessCodes(); await ensureAccessCodes();
await ensurePlatform(); await ensurePlatform();
await ensureDefaultSeasons(); await ensureDefaultSeasons();
console.log("[migrate] Done"); console.log('[migrate] Done');
} }
// Add colour / icon / description to `assigned_chores` on existing installs so // Add colour / icon / description to `assigned_chores` on existing installs so
// family-admins can override a template's look per assignment. Idempotent. // family-admins can override a template's look per assignment. Idempotent.
async function ensureAssignedChoreFields(): Promise<void> { async function ensureAssignedChoreFields(): Promise<void> {
const col = await getCollection("assigned_chores"); const col = await getCollection('assigned_chores');
if (!col) return; if (!col) return;
const has = (n: string) => col.fields.some((f: any) => f.name === n); const has = (n: string) => col.fields.some((f: any) => f.name === n);
const needed: any[] = []; const needed: any[] = [];
if (!has("description")) needed.push({ name: "description", type: "text", required: false }); if (!has('description')) needed.push({ name: 'description', type: 'text', required: false });
if (!has("icon")) needed.push({ name: "icon", type: "text", required: false }); if (!has('icon')) needed.push({ name: 'icon', type: 'text', required: false });
if (!has("color")) needed.push({ name: "color", type: "text", required: false }); if (!has('color')) needed.push({ name: 'color', type: 'text', required: false });
if (!has("emoji")) needed.push({ name: "emoji", type: "text", required: false }); if (!has('emoji')) needed.push({ name: 'emoji', type: 'text', required: false });
// Todos can be celebration-only (emoji) as well as points/money. // Todos can be celebration-only (emoji) as well as points/money.
let changed = !!needed.length; let changed = !!needed.length;
const typeField = col.fields.find((f: any) => f.name === "type"); const typeField = col.fields.find((f: any) => f.name === 'type');
if (typeField && Array.isArray(typeField.values) && !typeField.values.includes("emoji")) { if (typeField && Array.isArray(typeField.values) && !typeField.values.includes('emoji')) {
typeField.values = [...typeField.values, "emoji"]; typeField.values = [...typeField.values, 'emoji'];
changed = true; changed = true;
} }
// Direct todos (created by a parent, not from a template) have no templateId. // Direct todos (created by a parent, not from a template) have no templateId.
const tplField = col.fields.find((f: any) => f.name === "templateId"); const tplField = col.fields.find((f: any) => f.name === 'templateId');
if (tplField && tplField.required) { if (tplField && tplField.required) {
tplField.required = false; tplField.required = false;
changed = true; changed = true;
@@ -410,11 +458,11 @@ async function ensureAssignedChoreFields(): Promise<void> {
if (changed) { if (changed) {
await updateCollection(col.id, { ...col, fields: [...col.fields] }); await updateCollection(col.id, { ...col, fields: [...col.fields] });
} }
const comp = await getCollection("completions"); const comp = await getCollection('completions');
if (comp && !comp.fields.some((f: any) => f.name === "rewardId")) { if (comp && !comp.fields.some((f: any) => f.name === 'rewardId')) {
await updateCollection(comp.id, { await updateCollection(comp.id, {
...comp, ...comp,
fields: [...comp.fields, { name: "rewardId", type: "text", required: false }], fields: [...comp.fields, { name: 'rewardId', type: 'text', required: false }]
}); });
} }
} }
@@ -423,44 +471,50 @@ async function ensureAssignedChoreFields(): Promise<void> {
// backfill a default pocket-money droplet (£10 / 50% chores) for any child that // backfill a default pocket-money droplet (£10 / 50% chores) for any child that
// doesn't yet have one. Idempotent. // doesn't yet have one. Idempotent.
async function ensureBonusFields(): Promise<void> { async function ensureBonusFields(): Promise<void> {
for (const name of ["bonus_templates", "bonus_configs"]) { for (const name of ['bonus_templates', 'bonus_configs']) {
const col = await getCollection(name); const col = await getCollection(name);
if (!col) continue; if (!col) continue;
const has = (n: string) => col.fields.some((f: any) => f.name === n); const has = (n: string) => col.fields.some((f: any) => f.name === n);
let changed = false; let changed = false;
const fields = [...col.fields]; const fields = [...col.fields];
if (!has("thresholdType")) { if (!has('thresholdType')) {
fields.push({ fields.push({
name: "thresholdType", type: "select", required: false, name: 'thresholdType',
values: ["points", "percent"], maxSelect: 1, type: 'select',
required: false,
values: ['points', 'percent'],
maxSelect: 1
}); });
changed = true; changed = true;
} }
if (!has("isPocketMoney")) { if (!has('isPocketMoney')) {
fields.push({ name: "isPocketMoney", type: "bool", required: false }); fields.push({ name: 'isPocketMoney', type: 'bool', required: false });
changed = true; changed = true;
} }
// Standalone-reward progress window (bonus_configs only): how far progress // Standalone-reward progress window (bonus_configs only): how far progress
// counts (`completeBy`) and the window's start/custom-end dates. // counts (`completeBy`) and the window's start/custom-end dates.
if (name === "bonus_configs") { if (name === 'bonus_configs') {
if (!has("completeBy")) { if (!has('completeBy')) {
fields.push({ fields.push({
name: "completeBy", type: "select", required: false, name: 'completeBy',
values: ["unlimited", "week", "custom"], maxSelect: 1, type: 'select',
required: false,
values: ['unlimited', 'week', 'custom'],
maxSelect: 1
}); });
changed = true; changed = true;
} }
if (!has("startDate")) { if (!has('startDate')) {
fields.push({ name: "startDate", type: "text", required: false }); fields.push({ name: 'startDate', type: 'text', required: false });
changed = true; changed = true;
} }
if (!has("completeByDate")) { if (!has('completeByDate')) {
fields.push({ name: "completeByDate", type: "text", required: false }); fields.push({ name: 'completeByDate', type: 'text', required: false });
changed = true; changed = true;
} }
} }
// rewardValue must be optional so an unset pocket-money droplet can exist. // rewardValue must be optional so an unset pocket-money droplet can exist.
const rv = fields.find((f: any) => f.name === "rewardValue"); const rv = fields.find((f: any) => f.name === 'rewardValue');
if (rv && rv.required) { if (rv && rv.required) {
rv.required = false; rv.required = false;
changed = true; changed = true;
@@ -482,9 +536,9 @@ async function backfillPocketMoney(): Promise<void> {
); );
for (const rec of (await legacyRes.json())?.items || []) { for (const rec of (await legacyRes.json())?.items || []) {
await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, { await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, {
method: "PATCH", method: 'PATCH',
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ isPocketMoney: true }), body: JSON.stringify({ isPocketMoney: true })
}).catch(() => {}); }).catch(() => {});
} }
const childrenRes = await fetch( const childrenRes = await fetch(
@@ -499,26 +553,26 @@ async function backfillPocketMoney(): Promise<void> {
); );
if ((await existingRes.json())?.items?.length) continue; if ((await existingRes.json())?.items?.length) continue;
await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, { await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ body: JSON.stringify({
famId: c.famId, famId: c.famId,
name: "Pocket Money", name: 'Pocket Money',
target: "individual", target: 'individual',
type: "threshold", type: 'threshold',
thresholdType: "percent", thresholdType: 'percent',
occurrence: "recurring", occurrence: 'recurring',
rewardType: "cash", rewardType: 'cash',
rewardValue: 10, rewardValue: 10,
criteriaValue: 50, criteriaValue: 50,
memberId: c.id, memberId: c.id,
period: "weekly", period: 'weekly',
status: "active", status: 'active',
isPocketMoney: true, isPocketMoney: true
}), })
}).catch(() => {}); }).catch(() => {});
} }
console.log("[migrate] Pocket-money droplets ensured."); console.log('[migrate] Pocket-money droplets ensured.');
} }
// Promote chore_templates / bonus_templates to platform-owned: add global/icon/ // Promote chore_templates / bonus_templates to platform-owned: add global/icon/
@@ -526,24 +580,24 @@ async function backfillPocketMoney(): Promise<void> {
// global (platform) templates as assignable droplets. Idempotent. Seeds a // global (platform) templates as assignable droplets. Idempotent. Seeds a
// starter set of global templates on first run for out-of-the-box usage. // starter set of global templates on first run for out-of-the-box usage.
async function ensureTemplateFields(): Promise<void> { async function ensureTemplateFields(): Promise<void> {
const readRule = "global = true || famId = @request.auth.famId"; const readRule = 'global = true || famId = @request.auth.famId';
for (const name of ["chore_templates", "bonus_templates"]) { for (const name of ['chore_templates', 'bonus_templates']) {
const col = await getCollection(name); const col = await getCollection(name);
if (!col) continue; if (!col) continue;
const has = (n: string) => col.fields.some((f: any) => f.name === n); const has = (n: string) => col.fields.some((f: any) => f.name === n);
let changed = false; let changed = false;
const fields = [...col.fields]; const fields = [...col.fields];
for (const f of [ for (const f of [
{ name: "global", type: "bool", required: false }, { name: 'global', type: 'bool', required: false },
{ name: "icon", type: "text", required: false }, { name: 'icon', type: 'text', required: false },
{ name: "color", type: "text", required: false }, { name: 'color', type: 'text', required: false }
]) { ]) {
if (!has(f.name)) { if (!has(f.name)) {
fields.push(f); fields.push(f);
changed = true; changed = true;
} }
} }
const famId = fields.find((f: any) => f.name === "famId"); const famId = fields.find((f: any) => f.name === 'famId');
if (famId && famId.required) { if (famId && famId.required) {
famId.required = false; famId.required = false;
changed = true; changed = true;
@@ -559,24 +613,24 @@ async function ensureTemplateFields(): Promise<void> {
} }
const GLOBAL_TEMPLATE_ICONS = [ const GLOBAL_TEMPLATE_ICONS = [
"Bed", 'Bed',
"Sparkles", 'Sparkles',
"BookOpen", 'BookOpen',
"Utensils", 'Utensils',
"Wallet", 'Wallet',
"Trophy", 'Trophy',
"Star", 'Star',
"Moon", 'Moon',
"Sun", 'Sun',
"Apple", 'Apple',
"Car", 'Car',
"Gamepad2", 'Gamepad2',
"Music", 'Music',
"Shirt", 'Shirt',
"Leaf", 'Leaf',
"Heart", 'Heart',
"Gift", 'Gift',
"Timer", 'Timer'
]; ];
async function seedGlobalTemplates(): Promise<void> { async function seedGlobalTemplates(): Promise<void> {
@@ -586,76 +640,104 @@ async function seedGlobalTemplates(): Promise<void> {
`${PB_ENDPOINT}/api/collections/${name}/records?filter=global=true&perPage=1`, `${PB_ENDPOINT}/api/collections/${name}/records?filter=global=true&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } } { headers: { Authorization: `Bearer ${t}` } }
); );
return ((await res.json())?.items?.length) || 0; return (await res.json())?.items?.length || 0;
}; };
if ((await countGlobal("chore_templates")) > 0 || (await countGlobal("bonus_templates")) > 0) { if ((await countGlobal('chore_templates')) > 0 || (await countGlobal('bonus_templates')) > 0) {
console.log("[migrate] Global templates already present — skipping seed."); console.log('[migrate] Global templates already present — skipping seed.');
return; return;
} }
const choreSeeds = [ const choreSeeds = [
{ name: "Make Bed", defaultFrequency: "daily", defaultType: "points", defaultValue: 2, icon: "Bed", color: "#6366f1" }, {
{ name: "Tidy Room", defaultFrequency: "weekly", defaultType: "points", defaultValue: 10, icon: "Sparkles", color: "#8b5cf6" }, name: 'Make Bed',
{ name: "Homework", defaultFrequency: "daily", defaultType: "points", defaultValue: 5, icon: "BookOpen", color: "#0ea5e9" }, defaultFrequency: 'daily',
{ name: "Set Table", defaultFrequency: "daily", defaultType: "money", defaultValue: 1, icon: "Utensils", color: "#10b981" }, defaultType: 'points',
defaultValue: 2,
icon: 'Bed',
color: '#6366f1'
},
{
name: 'Tidy Room',
defaultFrequency: 'weekly',
defaultType: 'points',
defaultValue: 10,
icon: 'Sparkles',
color: '#8b5cf6'
},
{
name: 'Homework',
defaultFrequency: 'daily',
defaultType: 'points',
defaultValue: 5,
icon: 'BookOpen',
color: '#0ea5e9'
},
{
name: 'Set Table',
defaultFrequency: 'daily',
defaultType: 'money',
defaultValue: 1,
icon: 'Utensils',
color: '#10b981'
}
]; ];
const bonusSeeds = [ const bonusSeeds = [
{ {
name: "Pocket Money", name: 'Pocket Money',
target: "individual", target: 'individual',
type: "threshold", type: 'threshold',
thresholdType: "percent", thresholdType: 'percent',
occurrence: "recurring", occurrence: 'recurring',
rewardType: "cash", rewardType: 'cash',
rewardValue: "", rewardValue: '',
criteriaValue: 50, criteriaValue: 50,
period: "weekly", period: 'weekly',
isPocketMoney: true, isPocketMoney: true,
icon: "Wallet", icon: 'Wallet',
color: "#22c55e", color: '#22c55e'
}, },
{ {
name: "Reading Bonus", name: 'Reading Bonus',
target: "individual", target: 'individual',
type: "count", type: 'count',
occurrence: "recurring", occurrence: 'recurring',
rewardType: "points", rewardType: 'points',
rewardValue: "50", rewardValue: '50',
criteriaValue: 5, criteriaValue: 5,
period: "weekly", period: 'weekly',
icon: "BookOpen", icon: 'BookOpen',
color: "#f59e0b", color: '#f59e0b'
}, },
{ {
name: "Star of the Week", name: 'Star of the Week',
target: "competitive", target: 'competitive',
type: "threshold", type: 'threshold',
thresholdType: "points", thresholdType: 'points',
occurrence: "weekly", occurrence: 'weekly',
rewardType: "prize", rewardType: 'prize',
rewardValue: "Treat", rewardValue: 'Treat',
criteriaValue: 100, criteriaValue: 100,
period: "weekly", period: 'weekly',
icon: "Trophy", icon: 'Trophy',
color: "#ef4444", color: '#ef4444'
}, }
]; ];
for (const s of choreSeeds) { for (const s of choreSeeds) {
await fetch(`${PB_ENDPOINT}/api/collections/chore_templates/records`, { await fetch(`${PB_ENDPOINT}/api/collections/chore_templates/records`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...s, global: true }), body: JSON.stringify({ ...s, global: true })
}).catch(() => {}); }).catch(() => {});
} }
for (const s of bonusSeeds) { for (const s of bonusSeeds) {
await fetch(`${PB_ENDPOINT}/api/collections/bonus_templates/records`, { await fetch(`${PB_ENDPOINT}/api/collections/bonus_templates/records`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...s, global: true }), body: JSON.stringify({ ...s, global: true })
}).catch(() => {}); }).catch(() => {});
} }
console.log("[migrate] Seeded global templates."); console.log('[migrate] Seeded global templates.');
} }
export { GLOBAL_TEMPLATE_ICONS }; export { GLOBAL_TEMPLATE_ICONS };
-59
View File
@@ -1,59 +0,0 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase';
import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session';
import type { SessionUser } from '$lib/server/types';
const PUBLIC_PATHS = ['/login', '/signup', '/pair'];
const ADMIN_ONLY_PREFIXES = ['/admin'];
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
event.locals.pbToken = null;
const token = event.cookies.get(SESSION_COOKIE);
if (token) {
const pb = createPbClient(token);
try {
// authRefresh() does two jobs in one call:
// 1. Verifies the token. PocketBase 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 — which is the only way to get
// username/role/famId, since PB deliberately 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,
role: record.role,
famId: record.famId
} satisfies SessionUser;
event.locals.pbToken = freshToken;
if (freshToken !== token) {
setSessionCookie(event.cookies, freshToken);
}
} catch {
// Expired, malformed, or revoked (password/deviceToken changed
// since this token was issued) — drop it and treat as logged out.
clearSessionCookie(event.cookies);
}
}
const path = event.url.pathname;
const isPublic = PUBLIC_PATHS.some((p) => path.startsWith(p));
if (!isPublic && !event.locals.user) {
throw redirect(303, '/login');
}
if (ADMIN_ONLY_PREFIXES.some((p) => path.startsWith(p)) && !event.locals.user) {
console.log("rejecting",event.locals.user);
throw redirect(303, '/');
}
return resolve(event);
};
+141 -39
View File
@@ -40,14 +40,19 @@
<span class="pill">For busy families</span> <span class="pill">For busy families</span>
<h1>Chores done. <span class="accent">Allowance earned.</span></h1> <h1>Chores done. <span class="accent">Allowance earned.</span></h1>
<p class="lede"> <p class="lede">
FamChore turns everyday household chores into points and pocket money. Assign the FamChore turns everyday household chores into points and pocket money. Assign the chores,
chores, let your kids see their progress live, and FamChore calculates the allowance let your kids see their progress live, and FamChore calculates the allowance automatically
automatically — no spreadsheets, no nagging. — no spreadsheets, no nagging.
</p> </p>
<ul class="hero-ticks"> <ul class="hero-ticks">
<li><span class="tick">{@html checkCircleIcon}</span>Set up in under a minute</li> <li><span class="tick">{@html checkCircleIcon}</span>Set up in under a minute</li>
<li><span class="tick">{@html checkCircleIcon}</span>Kids join with a simple invite code</li> <li>
<li><span class="tick">{@html checkCircleIcon}</span>Allowance lands on payday, automatically</li> <span class="tick">{@html checkCircleIcon}</span>Kids join with a simple invite code
</li>
<li>
<span class="tick">{@html checkCircleIcon}</span>Allowance lands on payday,
automatically
</li>
</ul> </ul>
</div> </div>
@@ -56,7 +61,9 @@
<p class="card-sub">Free to get going. Takes about a minute.</p> <p class="card-sub">Free to get going. Takes about a minute.</p>
<a class="submit" href="/signup">Create my family</a> <a class="submit" href="/signup">Create my family</a>
<p class="card-alt"> <p class="card-alt">
<a href="/pricing">See pricing {@html chevronRightIcon}</a> <a class="flex gap-2 self-center align-center justify-center" href="/pricing"
>See pricing {@html chevronRightIcon}</a
>
</p> </p>
<p class="card-alt"> <p class="card-alt">
Already have a family? <a href="/login">Log in</a> Already have a family? <a href="/login">Log in</a>
@@ -72,17 +79,26 @@
<article class="step"> <article class="step">
<span class="step-num">1</span> <span class="step-num">1</span>
<h3>Create your family</h3> <h3>Create your family</h3>
<p>Set up chores and how much each one is worth. Your kids join in seconds with an invite code.</p> <p>
Set up chores and how much each one is worth. Your kids join in seconds with an invite
code.
</p>
</article> </article>
<article class="step"> <article class="step">
<span class="step-num">2</span> <span class="step-num">2</span>
<h3>Kids do the work</h3> <h3>Kids do the work</h3>
<p>They see today's chores as a simple card board and tick them off as they go — points are added instantly.</p> <p>
They see today's chores as a simple card board and tick them off as they go — points are
added instantly.
</p>
</article> </article>
<article class="step"> <article class="step">
<span class="step-num">3</span> <span class="step-num">3</span>
<h3>Allowance pays out</h3> <h3>Allowance pays out</h3>
<p>Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep it fun.</p> <p>
Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep it
fun.
</p>
</article> </article>
</div> </div>
</section> </section>
@@ -97,7 +113,10 @@
</article> </article>
<article class="feature"> <article class="feature">
<h3>Live progress</h3> <h3>Live progress</h3>
<p>Realtime sync across the whole family — see points and progress update the moment a chore is done.</p> <p>
Realtime sync across the whole family — see points and progress update the moment a chore
is done.
</p>
</article> </article>
<article class="feature"> <article class="feature">
<h3>Automatic allowance</h3> <h3>Automatic allowance</h3>
@@ -109,17 +128,27 @@
</article> </article>
<article class="feature"> <article class="feature">
<h3>Kid-friendly</h3> <h3>Kid-friendly</h3>
<p>A simple, colourful interface kids love — big buttons, clear feedback, their own space.</p> <p>
A simple, colourful interface kids love — big buttons, clear feedback, their own space.
</p>
</article> </article>
<article class="feature"> <article class="feature">
<h3>Private by design</h3> <h3>Private by design</h3>
<p>Everything is scoped to your family. Kids join with an invite code and stay in your family.</p> <p>
Everything is scoped to your family. Kids join with an invite code and stay in your
family.
</p>
</article> </article>
</div> </div>
<div class="cta"> <div class="cta">
<p>Ready to make chores painless?</p> <p>Ready to make chores painless?</p>
<a class="cta-btn" href="/signup">Create your family</a> <a class="cta-btn" href="/signup">Create your family</a>
<p class="card-alt"><a href="/pricing">or see pricing {@html chevronRightIcon}</a></p> <p class="card-alt">
<a
class="hover:underline self-center items-center justify-center flex gap-2"
href="/pricing">or see pricing {@html chevronRightIcon}</a
>
</p>
</div> </div>
</section> </section>
</main> </main>
@@ -128,7 +157,10 @@
<style> <style>
.landing { .landing {
font-family: system-ui, -apple-system, sans-serif; font-family:
system-ui,
-apple-system,
sans-serif;
color: #1f2937; color: #1f2937;
} }
@@ -149,7 +181,9 @@
text-decoration: none; text-decoration: none;
color: #1f2937; color: #1f2937;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: border-color 0.15s, box-shadow 0.15s; transition:
border-color 0.15s,
box-shadow 0.15s;
} }
.return-chip:hover { .return-chip:hover {
border-color: #c7d2fe; border-color: #c7d2fe;
@@ -214,7 +248,9 @@
line-height: 1.1; line-height: 1.1;
margin: 0 0 1rem; margin: 0 0 1rem;
} }
.accent { color: #4338ca; } .accent {
color: #4338ca;
}
.lede { .lede {
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1.6; line-height: 1.6;
@@ -230,7 +266,7 @@
gap: 0.6rem; gap: 0.6rem;
} }
.hero-ticks li::before { .hero-ticks li::before {
content: ""; content: '';
} }
.hero-ticks .tick { .hero-ticks .tick {
display: inline-flex; display: inline-flex;
@@ -254,9 +290,19 @@
box-shadow: 0 12px 30px rgba(67, 56, 202, 0.08); box-shadow: 0 12px 30px rgba(67, 56, 202, 0.08);
padding: 1.75rem; padding: 1.75rem;
} }
.signup-card h2 { margin: 0 0 0.25rem; font-size: 1.35rem; } .signup-card h2 {
.card-sub { margin: 0 0 1.25rem; color: #6b7280; font-size: 0.9rem; } margin: 0 0 0.25rem;
.signup-card form { display: grid; gap: 0.9rem; } font-size: 1.35rem;
}
.card-sub {
margin: 0 0 1.25rem;
color: #6b7280;
font-size: 0.9rem;
}
.signup-card form {
display: grid;
gap: 0.9rem;
}
.signup-card label { .signup-card label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -285,7 +331,7 @@
font-size: 0.85rem; font-size: 0.85rem;
margin: 0; margin: 0;
} }
.submit { .submit {
display: block; display: block;
text-align: center; text-align: center;
margin-top: 0.25rem; margin-top: 0.25rem;
@@ -298,19 +344,39 @@
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
.submit:hover { background: #3730a3; } .submit:hover {
.submit:disabled { opacity: 0.6; cursor: not-allowed; } background: #3730a3;
.card-alt { margin: 1rem 0 0; font-size: 0.85rem; color: #6b7280; text-align: center; } }
.card-alt a { color: #4338ca; text-decoration: none; font-weight: 500; } .submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.card-alt {
margin: 1rem 0 0;
font-size: 0.85rem;
color: #6b7280;
text-align: center;
}
.card-alt a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
white-space: nowrap;
}
.card-alt a :global(svg) {
vertical-align: -0.2em;
}
/* EXPLAINER */ /* EXPLAINER */
.explainer, .features { .explainer,
.features {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: 4rem 1.5rem; padding: 4rem 1.5rem;
} }
.explainer h2, .features h2 { .explainer h2,
.features h2 {
text-align: center; text-align: center;
font-size: 1.9rem; font-size: 1.9rem;
margin: 0 0 2rem; margin: 0 0 2rem;
@@ -338,11 +404,22 @@
font-weight: 700; font-weight: 700;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.step h3 { margin: 0 0 0.5rem; font-size: 1.05rem; } .step h3 {
.step p { margin: 0; color: #4b5563; font-size: 0.92rem; line-height: 1.55; } margin: 0 0 0.5rem;
font-size: 1.05rem;
}
.step p {
margin: 0;
color: #4b5563;
font-size: 0.92rem;
line-height: 1.55;
}
/* FEATURES */ /* FEATURES */
.features { background: #f5f6fa; border-radius: 0; } .features {
background: #f5f6fa;
border-radius: 0;
}
.feature-grid { .feature-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -354,10 +431,26 @@
border-radius: 12px; border-radius: 12px;
padding: 1.25rem; padding: 1.25rem;
} }
.feature h3 { margin: 0 0 0.4rem; font-size: 1rem; color: #111827; } .feature h3 {
.feature p { margin: 0; color: #4b5563; font-size: 0.9rem; line-height: 1.55; } margin: 0 0 0.4rem;
.cta { text-align: center; margin-top: 2.5rem; } font-size: 1rem;
.cta p { font-size: 1.15rem; font-weight: 600; margin: 0 0 1rem; } color: #111827;
}
.feature p {
margin: 0;
color: #4b5563;
font-size: 0.9rem;
line-height: 1.55;
}
.cta {
text-align: center;
margin-top: 2.5rem;
}
.cta p {
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 1rem;
}
.cta-btn { .cta-btn {
display: inline-block; display: inline-block;
background: #4338ca; background: #4338ca;
@@ -367,12 +460,21 @@
padding: 0.7rem 1.5rem; padding: 0.7rem 1.5rem;
border-radius: 8px; border-radius: 8px;
} }
.cta-btn:hover { background: #3730a3; } .cta-btn:hover {
background: #3730a3;
}
/* RESPONSIVE */ /* RESPONSIVE */
@media (max-width: 860px) { @media (max-width: 860px) {
.hero-inner { grid-template-columns: 1fr; } .hero-inner {
.steps, .feature-grid { grid-template-columns: 1fr; } grid-template-columns: 1fr;
h1 { font-size: 2rem; } }
.steps,
.feature-grid {
grid-template-columns: 1fr;
}
h1 {
font-size: 2rem;
}
} }
</style> </style>
+1 -1
View File
@@ -22,7 +22,7 @@
let accessReason = $derived(data.famAccess?.reason || ''); let accessReason = $derived(data.famAccess?.reason || '');
// Settings stays usable while paused so admins can apply a code / manage billing. // Settings stays usable while paused so admins can apply a code / manage billing.
let hasAuth = $derived(!!data.session); let hasAuth = $derived(!!data.session);
let locked = $derived((disabled && !page.url.pathname.endsWith('/settings')) || !hasAuth); let locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
// ── Post-checkout activation (event-driven) ── // ── Post-checkout activation (event-driven) ──
// Landing with ?checkout=return: if the webhook has already landed we show // Landing with ?checkout=return: if the webhook has already landed we show
@@ -1,12 +1,17 @@
import { redirect, fail } from '@sveltejs/kit'; import { redirect, fail } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
import { pbUser } from '$lib/server/pocketbase'; import { pbUser } from '$lib/server/pocketbase';
import { pbAdmin } from '$lib/server/pocketbase'; import { pbAdmin, createSuperClient } from '$lib/server/pocketbase';
import { servicesFor } from '$lib/server/servicesFor'; import { servicesFor } from '$lib/server/servicesFor';
import { issueAccess, createChild } from '$lib/server/member-otp'; import { issueAccess, createChild, inviteParent } from '$lib/server/member-otp';
import { slugify } from '@shared/slugify'; import { sendParentInviteEmail } from '$lib/server/email';
import { slugify, handle, famUsername } from '@shared/slugify';
import { applyAccessCode } from '$lib/server/access'; import { applyAccessCode } from '$lib/server/access';
import { createBillingPortalSession, cancelSubscriptionAtPeriodEnd, getSubscriptionStatus } from '$lib/server/stripe'; import {
createBillingPortalSession,
cancelSubscriptionAtPeriodEnd,
getSubscriptionStatus
} from '$lib/server/stripe';
function famIdOf(event: RequestEvent): string { function famIdOf(event: RequestEvent): string {
if (!event.locals.user) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
@@ -67,6 +72,64 @@ export const actions = {
} }
}, },
inviteParent: async (event: RequestEvent) => {
const famId = famIdOf(event);
const famSlug: string = event.params.fam as string;
const fd = await event.request.formData();
const name = (fd.get('name') || '').toString().trim();
const email = (fd.get('email') || '').toString().trim().toLowerCase();
if (!name || !email) return { error: 'Name and email required' };
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'Invalid email' };
try {
const { otp, joinUrl } = await inviteParent({ famId, famSlug, name, email });
const fam = await pbAdmin.getOne('fams', famId);
await sendParentInviteEmail({
to: email,
inviteLink: `${event.url.origin}${joinUrl}?code=${otp}`,
famName: fam?.name || famSlug,
otp
});
return { ok: true };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to invite parent' };
}
},
currentAccess: async (event: RequestEvent) => {
const famId = famIdOf(event);
const famSlug: string = event.params.fam as string;
const fd = await event.request.formData();
const name = (fd.get('name') || '').toString().trim();
if (!name) return { error: 'Select a child to reveal' };
try {
const pb = await createSuperClient();
const username = famUsername(famSlug, handle(name));
const child = await pb
.collection('users')
.getFirstListItem(`famId='${famId}' && username='${username}'`)
.catch(() => null);
if (!child) return { error: 'No such child' };
const otpRec = await pb
.collection('otp')
.getFirstListItem(`famId='${famId}' && userId='${child.id}'`)
.catch(() => null);
if (!otpRec?.otp) return { error: 'No active code yet — issue one first' };
const ageMs = Date.now() - new Date(otpRec.updatedAt).getTime();
if (ageMs > 20 * 60 * 1000) {
return { error: 'Current code has expired — issue a new one' };
}
const handleName = handle(name);
return {
ok: true,
otp: otpRec.otp as string,
joinUrl: `/${famSlug}/join/${encodeURIComponent(handleName)}`,
name
};
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to reveal current code' };
}
},
renameFam: async (event: RequestEvent) => { renameFam: async (event: RequestEvent) => {
const famId = famIdOf(event); const famId = famIdOf(event);
const fd = await event.request.formData(); const fd = await event.request.formData();
@@ -144,9 +207,7 @@ export const actions = {
const id = fd.get('id') as string; const id = fd.get('id') as string;
const active = fd.get('active') === '1'; const active = fd.get('active') === '1';
if (!id) return { error: 'Season ID required' }; if (!id) return { error: 'Season ID required' };
await pbUser(event) await pbUser(event).collection('seasons').update(id, { active });
.collection('seasons')
.update(id, { active });
return { ok: true }; return { ok: true };
}, },
@@ -17,6 +17,10 @@
import { handleOf } from '@shared/slugify'; import { handleOf } from '@shared/slugify';
import { addMonthsUTC, formatShortDate } from '$lib/format'; import { addMonthsUTC, formatShortDate } from '$lib/format';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import House from '@lucide/svelte/icons/house';
import UserPlus from '@lucide/svelte/icons/user-plus';
import Shield from '@lucide/svelte/icons/shield';
import Smartphone from '@lucide/svelte/icons/smartphone';
let { data } = $props(); let { data } = $props();
@@ -77,14 +81,25 @@
} }
} }
}); });
let showQR = $state(false);
let qrDataUrl = $state(''); let qrDataUrl = $state('');
let copied = $state(false); let copied = $state(false);
let parentInviteName = $state('');
let parentInviteEmail = $state(''); let parentInviteEmail = $state('');
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 issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null); let issueModal = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
let seasonColor = $state('#6366f1');
const seasonColors = [
'#6366f1',
'#ec4899',
'#f59e0b',
'#10b981',
'#3b82f6',
'#ef4444',
'#8b5cf6',
'#14b8a6'
];
// Optimistic season active toggle: flip immediately in the shared famStore // Optimistic season active toggle: flip immediately in the shared famStore
// (which drives both this list and the TopNav), then persist via the // (which drives both this list and the TopNav), then persist via the
@@ -108,8 +123,7 @@
} }
} }
let invitePath = $derived(issued ? `${issued.joinUrl}?code=${issued.otp}` : ''); let invitePath = $derived(issueModal ? `${issueModal.joinUrl}?code=${issueModal.otp}` : '');
let inviteUrl = $derived(issued ? `${page.url.origin}${invitePath}` : '');
function copy(url: string) { function copy(url: string) {
navigator.clipboard.writeText(url); navigator.clipboard.writeText(url);
@@ -117,20 +131,38 @@
setTimeout(() => (copied = false), 2000); setTimeout(() => (copied = false), 2000);
} }
function openIssueModal(data: { otp: string; joinUrl: string; name: string }) {
issueModal = data;
qrDataUrl = '';
generateQR(`${page.url.origin}${data.joinUrl}?code=${data.otp}`);
}
async function generateQR(url: string) { async function generateQR(url: string) {
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 }); qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
} }
function toggleQR() {
showQR = !showQR;
if (!showQR) qrDataUrl = '';
else generateQR(inviteUrl);
}
function handleParentInvite() { function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail); alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
} }
function handleInviteResult(result: any) {
if (result.type === 'success') {
const d = result.data as { ok?: boolean; error?: string } | undefined;
if (d?.ok) {
notices.success(
'Invite sent',
`Email sent to ${parentInviteEmail} with a join link and code.`
);
parentInviteName = '';
parentInviteEmail = '';
} else if (d?.error) {
notices.error('Could not invite parent', d.error);
}
} else if (result.type === 'failure') {
notices.error('Could not invite parent', (result.data as any)?.error || 'Please try again.');
}
}
function timeUntil(iso: string): string { function timeUntil(iso: string): string {
const diffMs = new Date(iso).getTime() - Date.now(); const diffMs = new Date(iso).getTime() - Date.now();
if (diffMs <= 0) return 'expired'; if (diffMs <= 0) return 'expired';
@@ -158,9 +190,14 @@
<ViewHeader title="Settings" hero /> <ViewHeader title="Settings" hero />
{#snippet famIcon()}<House />{/snippet}
{#snippet inviteIcon()}<UserPlus />{/snippet}
{#snippet accountIcon()}<Shield />{/snippet}
{#snippet appIcon()}<Smartphone />{/snippet}
<Accordion> <Accordion>
<!-- Family --> <!-- Family -->
<AccordionItem title="Family" open> <AccordionItem title="Family" icon={famIcon} open>
<CardGrid> <CardGrid>
<Card title="Family Name"> <Card title="Family Name">
<p class="hint"> <p class="hint">
@@ -232,35 +269,53 @@
</p> </p>
</Card> </Card>
<Card title="Seasons" cols={1}> <Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Tick a season on to make it available for assignment; untick to disable it.</p> <p class="hint">
Group chores into seasons. Tick a season on to make it available for assignment; untick to
disable it.
</p>
<form method="POST" action="?/createSeason" use:enhance class="season-form"> <form method="POST" action="?/createSeason" use:enhance class="season-form">
<label class="field-label" for="season-name">New season</label> <label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required /> <input id="season-name" name="name" placeholder="Season name" required />
<div class="color-row"> <div class="season-color-row">
<label for="season-color">Colour</label> <label>Colour</label>
<input <div class="season-swatches">
id="season-color" {#each seasonColors as color}
name="color" <button
type="color" type="button"
value="#6366f1" class="season-swatch"
class="color-input" class:selected={color === seasonColor}
/> style="background:{color}"
aria-label={color}
onclick={() => (seasonColor = color)}
></button>
{/each}
</div>
<input type="hidden" name="color" value={seasonColor} />
</div> </div>
<Button type="submit" size="sm">Add</Button> <Button type="submit" size="sm">Add</Button>
</form> </form>
<ul> <ul class="season-list">
{#each (famStore.initialized ? famStore.seasons : data.seasons) as s} <li class="season-head" aria-hidden="true">
<span class="season-name">Season</span>
<span>Active</span>
<span>Action</span>
</li>
{#each famStore.initialized ? famStore.seasons : data.seasons as s}
<li> <li>
<span class="season-cell">
<span class="dot" style="background:{s.color}"></span> <span class="dot" style="background:{s.color}"></span>
<span class="season-name">{s.name}</span> <span class="season-name">{s.name}</span>
</span>
<button <button
type="button" type="button"
class="season-check" class="season-check"
class:on={s.active !== false} class:on={s.active !== false}
role="switch" role="switch"
aria-checked={s.active !== false} aria-checked={s.active !== false}
title={s.active !== false ? 'Active — click to disable' : 'Disabled — click to enable'} title={s.active !== false
? 'Active — click to disable'
: 'Disabled — click to enable'}
onclick={() => toggleSeasonActive(s, !(s.active !== false))} onclick={() => toggleSeasonActive(s, !(s.active !== false))}
> >
{#if s.active !== false} {#if s.active !== false}
@@ -272,12 +327,15 @@
stroke="currentColor" stroke="currentColor"
stroke-width="3.5" stroke-width="3.5"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12" /></svg
><polyline points="20 6 9 17 4 12" /></svg
> >
{/if} {/if}
</button> </button>
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button> <span class="season-action">
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}
>Remove</Button
>
</span>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -312,11 +370,9 @@
</CardGrid> </CardGrid>
</AccordionItem> </AccordionItem>
<AccordionItem title="Invites"> <AccordionItem title="Invites" icon={inviteIcon}>
<CardGrid> <CardGrid>
<Card title="Members ({members.length})" cols={2}> <Card title="New member" cols={1}>
<div class="members-grid">
<div class="members-add">
<p class="hint">Add a child. They'll pick their own colour after joining.</p> <p class="hint">Add a child. They'll pick their own colour after joining.</p>
<form method="POST" action="?/addMember" use:enhance> <form method="POST" action="?/addMember" use:enhance>
<label class="field-label" for="new-child">New child</label> <label class="field-label" for="new-child">New child</label>
@@ -329,8 +385,93 @@
/> />
<Button type="submit" size="sm">Add child</Button> <Button type="submit" size="sm">Add child</Button>
</form> </form>
</div> </Card>
<Card title="Child access codes" cols={1} class="access-card">
<div class="access-body">
<p class="hint">
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
</p>
<label class="field-label" for="invite-child">Child</label>
<select id="invite-child" bind:value={inviteChild}>
<option value="">— Select a child —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
<div class="access-cta-row">
<form
method="POST"
action="?/issueAccess"
use:enhance={() => {
return async ({ formData, result }) => {
if (result.type !== 'success') return;
const d = result.data as
| {
ok?: boolean;
otp?: string;
joinUrl?: string;
error?: string;
}
| undefined;
if (d?.ok && d.otp && d.joinUrl) {
openIssueModal({
otp: d.otp,
joinUrl: d.joinUrl,
name: String(formData.get('name') || '')
});
} else if (d?.error) {
alert(d.error);
}
};
}}
class="inline"
>
<input type="hidden" name="name" value={inviteChild} />
<Button type="submit" size="sm" variant="primary" disabled={!inviteChild}
>Issue new code</Button
>
</form>
<form
method="POST"
action="?/currentAccess"
use:enhance={() => {
return async ({ formData, result }) => {
if (result.type !== 'success') return;
const d = result.data as
| {
ok?: boolean;
otp?: string;
joinUrl?: string;
error?: string;
}
| undefined;
if (d?.ok && d.otp && d.joinUrl) {
openIssueModal({
otp: d.otp,
joinUrl: d.joinUrl,
name: String(formData.get('name') || '')
});
} else if (d?.error) {
alert(d.error);
}
};
}}
class="inline"
>
<input type="hidden" name="name" value={inviteChild} />
<Button type="submit" size="sm" variant="purple" disabled={!inviteChild}
>Reveal current</Button
>
</form>
</div>
</div>
</Card>
<Card title="All members ({members.length})" cols={1}>
<ul class="members-list"> <ul class="members-list">
{#each members as m} {#each members as m}
<li> <li>
@@ -341,10 +482,6 @@
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span> <span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
</span> </span>
</span> </span>
<span class="member-actions">
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm"
>Preview</Button
>
<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
@@ -354,87 +491,69 @@
onclick={() => confirm('Remove {m.name}?')}>Remove</Button onclick={() => confirm('Remove {m.name}?')}>Remove</Button
> >
</form> </form>
</span>
</li> </li>
{/each} {/each}
</ul> </ul>
</div>
</Card>
<Card title="Invite Children" cols={1}>
<form
method="POST"
action="?/issueAccess"
use:enhance={() => {
return async ({ formData, result }) => {
if (result.type === 'success' && result.data?.ok) {
showQR = false;
qrDataUrl = '';
issued = {
otp: result.data.otp,
joinUrl: result.data.joinUrl,
name: String(formData.get('name') || '')
};
} else if (result.type === 'success' && result.data?.error) {
alert(result.data.error);
}
};
}}
class="invite-form"
>
<label class="field-label" for="invite-child">Child</label>
<select id="invite-child" bind:value={inviteChild} name="name" required>
<option value="">— Select a child —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
</form>
<p class="hint">
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
</p>
{#if issued?.otp}
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p>
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
{issued.otp}
</p>
<p class="invite-url">{invitePath}</p>
<div class="actions justify-center">
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}
>{copied ? 'Copied!' : 'Copy URL'}</Button
>
<Button variant="secondary" size="sm" onclick={toggleQR}
>{showQR ? 'Hide QR' : 'Show QR'}</Button
>
</div>
{#if showQR && qrDataUrl}
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
{/if}
</div>
{/if}
</Card> </Card>
<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>
<div class="invite-form"> <form
method="POST"
action="?/inviteParent"
use:enhance={() => {
return async ({ result }) => handleInviteResult(result);
}}
>
<label class="field-label" for="parent-name">Parent name</label>
<input
id="parent-name"
name="name"
bind:value={parentInviteName}
placeholder="e.g. Alex"
required
/>
<label class="field-label" for="parent-email">Parent email</label> <label class="field-label" for="parent-email">Parent email</label>
<input <input
id="parent-email" id="parent-email"
name="email"
type="email" type="email"
bind:value={parentInviteEmail} bind:value={parentInviteEmail}
placeholder="parent@example.com" placeholder="parent@example.com"
required
/> />
<Button onclick={handleParentInvite} size="sm">Send invite</Button> <Button type="submit" size="sm">Send invite</Button>
</div> </form>
<p class="hint">They will set up their own password on first login.</p> <p class="hint">They'll receive a link with a code to set up their own password.</p>
</Card> </Card>
</CardGrid> </CardGrid>
{#if issueModal}
<div class="overlay" onclick={() => (issueModal = null)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Join code for {issueModal.name}</h3>
<p class="hint">Valid 20 minutes — the child enters it at the join link.</p>
<p class="code-display">{issueModal.otp}</p>
<p class="invite-url">{invitePath}</p>
<div class="qr-wrap">
{#if qrDataUrl}
<img src={qrDataUrl} alt="QR Code" class="qr" />
{/if}
</div>
<div class="actions justify-center">
<Button variant="secondary" size="sm" onclick={() => copy(invitePath)}
>{copied ? 'Copied!' : 'Copy URL'}</Button
>
</div>
<div class="modal-actions">
<button type="button" onclick={() => (issueModal = null)}>Done</button>
</div>
</div>
</div>
{/if}
</AccordionItem> </AccordionItem>
<AccordionItem title="Account"> <AccordionItem title="Account" icon={accountIcon}>
<CardGrid> <CardGrid>
<!-- Access (codes) only relevant when NOT on a subscription --> <!-- Access (codes) only relevant when NOT on a subscription -->
{#if fam?.paymentMode !== 'sub'} {#if fam?.paymentMode !== 'sub'}
@@ -515,8 +634,9 @@
<!-- Cancel requested: no CTA — just the countdown to expiry --> <!-- Cancel requested: no CTA — just the countdown to expiry -->
<p class="hint"> <p class="hint">
<strong>No active subscription.</strong> You have <strong>No active subscription.</strong> You have
<strong>{timeUntil(data.subStatus.endsAt)}</strong> of access left <strong>{timeUntil(data.subStatus.endsAt)}</strong> of access left (until {formatShortDate(
(until {formatShortDate(String(data.subStatus.endsAt))}). No further charges. String(data.subStatus.endsAt)
)}). No further charges.
</p> </p>
{:else if fam?.paymentMode === 'sub'} {:else if fam?.paymentMode === 'sub'}
<div class="actions"> <div class="actions">
@@ -560,7 +680,7 @@
</CardGrid> </CardGrid>
</AccordionItem> </AccordionItem>
<AccordionItem title="App"> <AccordionItem title="App" icon={appIcon}>
<CardGrid> <CardGrid>
{#if page.data.platformFlags?.debug} {#if page.data.platformFlags?.debug}
<Card title="Debug Tools" cols={1} accent="#f59e0b"> <Card title="Debug Tools" cols={1} accent="#f59e0b">
@@ -703,25 +823,40 @@
} }
/* ── Colour rows (seasons) ── */ /* ── Colour rows (seasons) ── */
.color-row { .season-color-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap;
} }
.color-row label { .season-color-row label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: #6b7280; color: #6b7280;
flex-shrink: 0; flex-shrink: 0;
} }
.color-input { .season-swatches {
width: 100%; display: flex;
max-width: 160px; align-items: center;
height: 36px; gap: 0.4rem;
padding: 2px; flex-wrap: wrap;
border: 1px solid #d1d5db; }
border-radius: 8px; .season-swatch {
width: 1.65rem;
height: 1.65rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer; cursor: pointer;
padding: 0;
transition:
border-color 0.12s ease,
transform 0.12s ease;
}
.season-swatch.selected {
border-color: #111827;
}
.season-swatch:hover {
transform: scale(1.08);
} }
/* ── Lists ── */ /* ── Lists ── */
@@ -742,9 +877,45 @@
li:last-child { li:last-child {
border-bottom: none; border-bottom: none;
} }
/* ── Seasons table ── */
.season-list {
margin-top: 0.75rem;
}
.season-list li {
display: grid;
grid-template-columns: 1fr 3rem 5rem;
align-items: center;
gap: 0.6rem;
}
.season-list .season-head {
padding: 0.4rem 0;
border-bottom: 2px solid #e5e7eb;
font-size: 0.72rem;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.season-list .season-head .season-name {
padding-left: 0;
}
.season-cell {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.season-action {
display: flex;
justify-content: flex-start;
}
.season-name { .season-name {
flex: 1; flex: 1;
min-width: 80px; min-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.season-check { .season-check {
width: 26px; width: 26px;
@@ -792,25 +963,20 @@
margin-top: 0.75rem; margin-top: 0.75rem;
} }
/* ── Members: two-column (add | list) ── */ /* ── Members ── */
.members-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.members-add form {
margin-bottom: 0;
}
.members-list { .members-list {
border-left: 1px solid #f3f4f6; display: flex;
padding-left: 1.25rem; flex-direction: column;
} }
.member-left, .members-list li {
.member-actions { justify-content: space-between;
gap: 1rem;
}
.member-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
min-width: 0;
} }
.member-color { .member-color {
width: 22px; width: 22px;
@@ -834,21 +1000,6 @@
font-size: 0.72rem; font-size: 0.72rem;
color: #9ca3af; color: #9ca3af;
} }
.member-actions :global(.btn) {
flex: none;
width: auto;
}
@container (max-width: 380px) {
.members-grid {
grid-template-columns: 1fr;
}
.members-list {
border-left: none;
padding-left: 0;
border-top: 1px solid #f3f4f6;
padding-top: 0.5rem;
}
}
/* ── Action rows ── */ /* ── Action rows ── */
.actions { .actions {
@@ -873,6 +1024,43 @@
border-radius: 8px; border-radius: 8px;
} }
/* ── Child access codes (hierarchy-level card) ── */
.access-card {
background: linear-gradient(160deg, #ddd6fe 0%, #ede9fe 55%, #f5f3ff 100%) !important;
border: 1px solid #c4b5fd !important;
}
.access-card :global(.card-header) {
background: rgba(255, 255, 255, 0.45);
border-bottom-color: rgba(196, 181, 253, 0.5);
}
.access-body {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.access-cta-row {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.access-cta-row form.inline {
margin: 0;
flex: 1;
min-width: 150px;
}
.access-cta-row form.inline :global(.btn) {
width: 100%;
}
.code-display {
font-size: 2.6rem;
font-weight: 800;
letter-spacing: 0.28em;
text-align: center;
color: #4338ca;
margin: 0.75rem 0;
font-family: var(--font-mono, ui-monospace, monospace);
}
/* ── Narrow cards: stack rows / actions full width (container query) ── */ /* ── Narrow cards: stack rows / actions full width (container query) ── */
@container (max-width: 380px) { @container (max-width: 380px) {
.payday-row { .payday-row {
@@ -882,7 +1070,7 @@
.payday-row select { .payday-row select {
width: 100%; width: 100%;
} }
.color-row { .season-color-row {
align-items: stretch; align-items: stretch;
} }
.actions { .actions {
@@ -1,6 +1,30 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { redeemOtp } from '$lib/server/member-otp'; import { redeemOtp, redeemParentOtp } from '$lib/server/member-otp';
import { createSuperClient } from '$lib/server/pocketbase';
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session'; import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
import { famUsername, handle } from '@shared/slugify';
export async function load(event) {
const famSlug = event.params.fam;
const username = event.params.username;
const pb = await createSuperClient();
let isParent = false;
let famName = '';
try {
const fam = await pb.collection('fams').getFirstListItem(`slug='${famSlug}'`);
famName = fam?.name || famSlug;
const user = await pb
.collection('users')
.getFirstListItem(`famId='${fam.id}' && username='${famUsername(famSlug, handle(username))}'`)
.catch(() => null);
isParent = user?.role === 'parent';
} catch {
// Invalid fam slug — render as a child join, the action will reject.
}
return { isParent, famName };
}
export const actions = { export const actions = {
default: async (event) => { default: async (event) => {
@@ -8,11 +32,35 @@ export const actions = {
const username = event.params.username; const username = event.params.username;
const fd = await event.request.formData(); const fd = await event.request.formData();
const otp = (fd.get('otp') || '').toString().trim(); const otp = (fd.get('otp') || '').toString().trim();
const password = (fd.get('password') || '').toString();
const confirmPassword = (fd.get('confirmPassword') || '').toString();
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.' }); if (!otp) return fail(400, { error: 'Enter the code shown by your parent.' });
try { try {
const token = await redeemOtp({ famSlug: fam, username, otp }); // Parents set their own password on join; children use the derived one.
const pb = await createSuperClient();
const famRecord = await pb.collection('fams').getFirstListItem(`slug='${fam}'`);
const user = await pb
.collection('users')
.getFirstListItem(
`famId='${famRecord.id}' && username='${famUsername(fam, handle(username))}'`
)
.catch(() => null);
const isParent = user?.role === 'parent';
if (isParent) {
if (!password || password.length < 10) {
return fail(400, { error: 'Password must be at least 10 characters' });
}
if (password !== confirmPassword) {
return fail(400, { error: 'Passwords do not match' });
}
}
const token = isParent
? 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);
} catch (e) { } catch (e) {
@@ -7,21 +7,36 @@
const famSlug = page.params.fam; const famSlug = page.params.fam;
const username = page.params.username; const username = page.params.username;
let otp = $state(page.url.searchParams.get('code') || ''); let otp = $state(page.url.searchParams.get('code') || '');
let { form } = $props(); let password = $state('');
let confirmPassword = $state('');
let { data, form } = $props();
const isParent = $derived(data?.isParent);
const famName = $derived(data?.famName || famSlug);
</script> </script>
<svelte:head><title>Join {famSlug}</title></svelte:head> <svelte:head><title>{isParent ? 'Join as parent' : `Join ${famSlug}`}</title></svelte:head>
<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 isParent}
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
<p class="mt-1 text-sm text-slate-500">
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code from your
email and create a password to join <strong>{famName}</strong> as a parent.
</p>
{: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 Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent gave
gave you to get started. you to get started.
</p> </p>
{/if}
<form <form
class="mt-6 flex flex-col gap-3" class="mt-6 flex flex-col gap-3"
@@ -45,10 +60,32 @@
autocomplete="one-time-code" autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/> />
{#if isParent}
<input
type="password"
name="password"
bind:value={password}
placeholder="Create a password"
autocomplete="new-password"
minlength="10"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="password"
name="confirmPassword"
bind:value={confirmPassword}
placeholder="Confirm password"
autocomplete="new-password"
minlength="10"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{/if}
{#if form?.error} {#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p> <p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if} {/if}
<Button type="submit" variant="primary" size="lg">Join</Button> <Button type="submit" variant="primary" size="lg"
>{isParent ? 'Set up my account' : 'Join'}</Button
>
</form> </form>
<p class="mt-6 text-xs text-slate-400"> <p class="mt-6 text-xs text-slate-400">
@@ -56,6 +93,7 @@
</p> </p>
</section> </section>
</main> </main>
<style> <style>
.home-badge :global(svg) { .home-badge :global(svg) {
width: 1.75rem; width: 1.75rem;
+62
View File
@@ -0,0 +1,62 @@
import { json } from '@sveltejs/kit';
import { RESEND_API } from '$app/env/private';
import { createSuperClient } from '$lib/server/pocketbase';
import { Resend } from 'resend';
export async function POST({ request, url }) {
const fd = await request.formData();
const email = (fd.get('email') || '').toString().trim().toLowerCase();
if (!email) {
return json({ ok: true }); // Generic response to avoid enumeration
}
try {
const pb = await createSuperClient();
// Find user by email
let user;
try {
user = await pb.collection('users').getFirstListItem(`email = '${email}'`);
} catch {
// User not found — still return success
return json({ ok: true });
}
// Only allow password reset for parents (they have emails)
if (user.role !== 'parent') {
return json({ ok: true });
}
// Generate token and expiry (1 hour)
const token = globalThis.crypto.randomUUID();
const expiry = new Date(Date.now() + 60 * 60 * 1000).toISOString();
// Store token on user record
await pb.collection('users').update(user.id, {
passwordResetToken: token,
passwordResetExpiry: expiry
});
// Send email via Resend
const resend = new Resend(String(RESEND_API));
const resetUrl = `${url.origin}/login/verify/${token}`;
await resend.emails.send({
from: 'no-reply@famchamp.ai',
to: email,
subject: 'Reset your FamChore password',
html: `
<p>You requested a password reset for your FamChore account.</p>
<p><a href="${resetUrl}">Click here to reset your password</a></p>
<p>This link expires in 1 hour.</p>
<p>If you didn't request this, you can ignore this email.</p>
`
});
} catch {
// Swallow all errors to avoid information leakage
}
// Always return success
return json({ ok: true });
}
+92
View File
@@ -4,9 +4,66 @@
let { form } = $props(); let { form } = $props();
let email = $state(''); let email = $state('');
let password = $state(''); let password = $state('');
let showForgot = $state(false);
let forgotEmail = $state('');
let forgotLoading = $state(false);
let forgotSuccess = $state(false);
async function handleForgot() {
if (!forgotEmail || forgotLoading) return;
forgotLoading = true;
try {
const fd = new FormData();
fd.set('email', forgotEmail);
const res = await fetch('/api/email', { method: 'POST', body: fd });
if (res.ok) {
forgotSuccess = true;
}
} catch {
// Silent — generic response
} finally {
forgotLoading = false;
}
}
</script> </script>
<AuthShell title="Log in" subtitle="Welcome back to FamChore."> <AuthShell title="Log in" subtitle="Welcome back to FamChore.">
{#if showForgot}
{#if forgotSuccess}
<p class="form-ok">
If an account exists for <strong>{forgotEmail}</strong>, a reset link is on its way.
</p>
<button
class="row-btn centered"
type="button"
onclick={() => {
showForgot = false;
forgotSuccess = false;
forgotEmail = '';
}}
>
Back to log in
</button>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
handleForgot();
}}
>
<label>
Email
<input type="email" bind:value={forgotEmail} placeholder="you@email.com" required />
</label>
<button type="submit" disabled={forgotLoading}>
{forgotLoading ? 'Sending...' : 'Send reset link'}
</button>
</form>
<button class="row-btn centered" type="button" onclick={() => (showForgot = false)}>
Back to log in
</button>
{/if}
{:else}
{#if form?.error} {#if form?.error}
<p class="form-error">{form.error}</p> <p class="form-error">{form.error}</p>
{/if} {/if}
@@ -20,12 +77,17 @@
Password Password
<input type="password" name="password" bind:value={password} required /> <input type="password" name="password" bind:value={password} required />
</label> </label>
<button class="row-btn" type="button" onclick={() => (showForgot = true)}>
Forgot password?
</button>
<button type="submit">Log in</button> <button type="submit">Log in</button>
</form> </form>
<p class="alt"> <p class="alt">
Don't have a family yet? <a href="/signup">Create one</a> Don't have a family yet? <a href="/signup">Create one</a>
</p> </p>
<p>Trying to join as a child? Ask your parent to issue your QR code.</p> <p>Trying to join as a child? Ask your parent to issue your QR code.</p>
{/if}
</AuthShell> </AuthShell>
<style> <style>
@@ -66,6 +128,36 @@
button:hover { button:hover {
background: #3730a3; background: #3730a3;
} }
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.row-btn {
background: none;
color: #4338ca;
font-size: 0.85rem;
font-weight: 500;
padding: 0;
text-decoration: underline;
cursor: pointer;
}
.row-btn:hover {
background: none;
}
.row-btn.centered {
display: block;
margin: 1.25rem auto 0;
width: fit-content;
}
.form-ok {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.form-error { .form-error {
background: #fef2f2; background: #fef2f2;
color: #b91c1c; color: #b91c1c;
@@ -0,0 +1,95 @@
import { fail, redirect } from '@sveltejs/kit';
import { createSuperClient } from '$lib/server/pocketbase';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const { token } = params;
try {
const pb = await createSuperClient();
// Find user with this reset token
const user = await pb
.collection('users')
.getFirstListItem(`passwordResetToken = '${token}'`)
.catch(() => null);
if (!user || !user.passwordResetExpiry) {
throw redirect(303, '/login');
}
// Check expiry (1 hour)
const expiryDate = new Date(user.passwordResetExpiry);
if (expiryDate < new Date()) {
// Token expired — clear it
await pb.collection('users').update(user.id, {
passwordResetToken: '',
passwordResetExpiry: ''
});
throw redirect(303, '/login');
}
return { valid: true };
} catch (e) {
// Redirect errors propagate; anything else → login
if (e instanceof Response) throw e;
throw redirect(303, '/login');
}
};
export const actions = {
default: async ({ params, request }) => {
const { token } = params;
const fd = await request.formData();
const password = fd.get('password') as string;
const confirmPassword = fd.get('confirmPassword') as string;
if (!password || !confirmPassword) {
return fail(400, { error: 'Both fields are required' });
}
if (password !== confirmPassword) {
return fail(400, { error: 'Passwords do not match' });
}
if (password.length < 10) {
return fail(400, { error: 'Password must be at least 10 characters' });
}
try {
const pb = await createSuperClient();
// Find user with this reset token
const user = await pb
.collection('users')
.getFirstListItem(`passwordResetToken = '${token}'`)
.catch(() => null);
if (!user || !user.passwordResetExpiry) {
return fail(400, { error: 'Invalid or expired reset link' });
}
// Check expiry (1 hour)
const expiryDate = new Date(user.passwordResetExpiry);
if (expiryDate < new Date()) {
// Token expired — clear it
await pb.collection('users').update(user.id, {
passwordResetToken: '',
passwordResetExpiry: ''
});
return fail(400, { error: 'Reset link has expired. Please request a new one.' });
}
// Update password and clear token
await pb.collection('users').update(user.id, {
password,
passwordResetToken: '',
passwordResetExpiry: ''
});
return { success: true };
} catch {
return fail(400, { error: 'Something went wrong. Please try again.' });
}
}
};
@@ -0,0 +1,114 @@
<script lang="ts">
import AuthShell from '$lib/components/AuthShell.svelte';
let { form } = $props();
let password = $state('');
let confirmPassword = $state('');
</script>
<AuthShell title="Reset password" subtitle="Enter your new password below.">
{#if form?.success}
<p class="form-ok">Your password has been reset. You can now log in.</p>
<p class="alt"><a href="/login">Back to log in</a></p>
{:else}
{#if form?.error}
<p class="form-error">{form.error}</p>
{/if}
<form method="POST">
<label>
New password
<input
type="password"
name="password"
bind:value={password}
placeholder="At least 10 characters"
required
minlength="10"
/>
</label>
<label>
Confirm password
<input
type="password"
name="confirmPassword"
bind:value={confirmPassword}
placeholder="Re-enter your password"
required
minlength="10"
/>
</label>
<button type="submit">Reset password</button>
</form>
<p class="alt"><a href="/login">Back to log in</a></p>
{/if}
</AuthShell>
<style>
form {
display: grid;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: #374151;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
}
input:focus {
outline: none;
border-color: #4338ca;
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.15);
}
button {
margin-top: 0.25rem;
background: #4338ca;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover {
background: #3730a3;
}
.form-ok {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.form-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.alt {
margin: 1.25rem 0 0;
font-size: 0.85rem;
color: #6b7280;
text-align: center;
}
.alt a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
</style>
@@ -3,7 +3,6 @@ import { clearSessionCookie, clearLegacyCookies } from '$lib/server/session';
function signOut(event: { cookies: any }) { function signOut(event: { cookies: any }) {
clearSessionCookie(event.cookies); clearSessionCookie(event.cookies);
event.cookies.delete('session', { path: '/' });
clearLegacyCookies(event.cookies); clearLegacyCookies(event.cookies);
} }
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild" "esbuild"
] ]
}, },
"version": "1.8.6" "version": "1.8.7"
} }
+40
View File
@@ -29,6 +29,9 @@ importers:
qrcode: qrcode:
specifier: ^1.5.4 specifier: ^1.5.4
version: 1.5.4 version: 1.5.4
resend:
specifier: ^6.25.0
version: 6.25.0
stripe: stripe:
specifier: ^22.5.0 specifier: ^22.5.0
version: 22.5.0(@types/node@26.0.0) version: 22.5.0(@types/node@26.0.0)
@@ -484,6 +487,9 @@ packages:
'@rolldown/pluginutils@1.0.1': '@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -726,6 +732,9 @@ packages:
'@typescript-eslint/types': '@typescript-eslint/types':
optional: true optional: true
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -893,6 +902,9 @@ packages:
pocketbase@0.27.0: pocketbase@0.27.0:
resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==} resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==}
postal-mime@2.7.5:
resolution: {integrity: sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==}
postcss@8.5.15: postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -980,6 +992,15 @@ packages:
require-main-filename@2.0.0: require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resend@6.25.0:
resolution: {integrity: sha512-iptUEycs+6Hu+W8mExK708LrDiMYOuGgnCP+wTD5L4Zrqqd2B86h1oyAmNe0khZWQw0ccI7jz8OgAaplEsyaIA==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
peerDependenciesMeta:
'@react-email/render':
optional: true
rolldown@1.0.3: rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1005,6 +1026,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1396,6 +1420,8 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {} '@rolldown/pluginutils@1.0.1': {}
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@stripe/stripe-js@9.13.0': {} '@stripe/stripe-js@9.13.0': {}
@@ -1609,6 +1635,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
fast-sha256@1.3.0: {}
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -1720,6 +1748,8 @@ snapshots:
pocketbase@0.27.0: {} pocketbase@0.27.0: {}
postal-mime@2.7.5: {}
postcss@8.5.15: postcss@8.5.15:
dependencies: dependencies:
nanoid: 3.3.15 nanoid: 3.3.15
@@ -1751,6 +1781,11 @@ snapshots:
require-main-filename@2.0.0: {} require-main-filename@2.0.0: {}
resend@6.25.0:
dependencies:
postal-mime: 2.7.5
standardwebhooks: 1.0.0
rolldown@1.0.3: rolldown@1.0.3:
dependencies: dependencies:
'@oxc-project/types': 0.133.0 '@oxc-project/types': 0.133.0
@@ -1807,6 +1842,11 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0