From 40d7bf73982b72b277512382ea99cca5e01f02b5 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Tue, 1 Sep 2026 11:58:43 +0100 Subject: [PATCH] final flows including emails for passwords --- frontend/package.json | 1 + frontend/src/env.ts | 20 +- .../src/lib/components/AccordionItem.svelte | 89 +- frontend/src/lib/components/Button.svelte | 101 +- frontend/src/lib/components/Sidebar.svelte | 2 +- frontend/src/lib/pocketbase.ts | 6 +- frontend/src/lib/server/email.ts | 45 + frontend/src/lib/server/member-otp.ts | 90 +- frontend/src/lib/server/migrate.ts | 1068 +++++++++-------- frontend/src/new-hooks.ts | 59 - frontend/src/routes/+page.svelte | 204 +++- frontend/src/routes/[fam]/+layout.svelte | 2 +- .../[fam]/[username]/settings/+page.server.ts | 75 +- .../[fam]/[username]/settings/+page.svelte | 522 +++++--- .../[fam]/join/[username]/+page.server.ts | 54 +- .../routes/[fam]/join/[username]/+page.svelte | 56 +- frontend/src/routes/api/email/+server.ts | 62 + frontend/src/routes/login/+page.svelte | 128 +- .../login/verify/[token]/+page.server.ts | 95 ++ .../routes/login/verify/[token]/+page.svelte | 114 ++ frontend/src/routes/logout/+page.server.ts | 3 +- pnpm-lock.yaml | 40 + 22 files changed, 1977 insertions(+), 859 deletions(-) create mode 100644 frontend/src/lib/server/email.ts delete mode 100644 frontend/src/new-hooks.ts create mode 100644 frontend/src/routes/api/email/+server.ts create mode 100644 frontend/src/routes/login/verify/[token]/+page.server.ts create mode 100644 frontend/src/routes/login/verify/[token]/+page.svelte diff --git a/frontend/package.json b/frontend/package.json index 5fa09a9..d35ee34 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "chart.js": "^4.4.0", "pocketbase": "^0.27.0", "qrcode": "^1.5.4", + "resend": "^6.25.0", "stripe": "^22.5.0" } } diff --git a/frontend/src/env.ts b/frontend/src/env.ts index fdb3a00..bcfd3e1 100644 --- a/frontend/src/env.ts +++ b/frontend/src/env.ts @@ -1,16 +1,17 @@ import { defineEnvVars } from '@sveltejs/kit/hooks'; // Default when the env var isn't set, so a missing value never crashes startup. -const withDefault = (value: string) => ({ - '~standard': { - version: 1, - vendor: 'famchamp', - validate: (v: unknown) => ({ value: typeof v === 'string' && v ? v : value }) - } -} as const); +const withDefault = (value: string) => + ({ + '~standard': { + version: 1, + vendor: 'famchamp', + validate: (v: unknown) => ({ value: typeof v === 'string' && v ? v : value }) + } + }) as const; export const variables = defineEnvVars({ - SERVER_IP: { public: true, schema: withDefault('192.168.1.225') }, + SERVER_IP: { public: true, schema: withDefault('192.168.1.225') }, // PB superuser creds (server-only). PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') }, PB_PASSWORD: { public: false, schema: withDefault('debug123') }, @@ -28,5 +29,6 @@ export const variables = defineEnvVars({ STRIPE_PRICE_MONTHLY: { public: false, schema: withDefault('') }, STRIPE_PRICE_YEARLY: { public: false, schema: withDefault('') }, // 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('') } }); diff --git a/frontend/src/lib/components/AccordionItem.svelte b/frontend/src/lib/components/AccordionItem.svelte index a95f800..8fd2f8d 100644 --- a/frontend/src/lib/components/AccordionItem.svelte +++ b/frontend/src/lib/components/AccordionItem.svelte @@ -1,17 +1,24 @@
- {#if open}
@@ -21,16 +28,58 @@
\ No newline at end of file + .accordion-item:last-child { + border-bottom: none; + } + .accordion-trigger { + 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; + } + diff --git a/frontend/src/lib/components/Button.svelte b/frontend/src/lib/components/Button.svelte index 66c7d2b..4571edf 100644 --- a/frontend/src/lib/components/Button.svelte +++ b/frontend/src/lib/components/Button.svelte @@ -1,6 +1,13 @@ {#if href} - + {@render children?.()} {:else} @@ -28,25 +35,85 @@ border-radius: 6px; cursor: pointer; text-decoration: none; - transition: background 0.15s, border-color 0.15s; + transition: + background 0.15s, + border-color 0.15s; border: 1px solid transparent; } - .btn-sm { padding: 0.3rem 0.6rem; 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-sm { + padding: 0.3rem 0.6rem; + 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:hover { background: #3730a3; } + .btn-primary { + 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:hover { background: #e5e7eb; } + .btn-secondary { + background: #f3f4f6; + color: #374151; + border-color: #d1d5db; + } + .btn-secondary:hover { + background: #e5e7eb; + } - .btn-ghost { background: transparent; color: #6b7280; border-color: transparent; } - .btn-ghost:hover { background: #f3f4f6; } + .btn-ghost { + background: transparent; + color: #6b7280; + border-color: transparent; + } + .btn-ghost:hover { + background: #f3f4f6; + } - .btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; } - .btn-danger:hover { background: #b91c1c; } + .btn-danger { + background: #dc2626; + color: #fff; + border-color: #dc2626; + } + .btn-danger:hover { + background: #b91c1c; + } - .btn-success { background: #059669; color: #fff; border-color: #059669; } - .btn-success:hover { background: #047857; } + .btn-success { + 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; + } diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index c06d467..e7a75fb 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -80,7 +80,7 @@ position: fixed; top: 0; left: 0; - height: 100vh; + height: 100dvh; width: 220px; background: #1e1b4b; color: #e0e7ff; diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts index 78ae119..0f67aa7 100644 --- a/frontend/src/lib/pocketbase.ts +++ b/frontend/src/lib/pocketbase.ts @@ -5,11 +5,15 @@ export const pb = new PocketBase(PB_ENDPOINT); pb.autoCancellation(false); // 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) { if (token) { pb.authStore.save(token, null); return true; } + pb.authStore.clear(); return false; } diff --git a/frontend/src/lib/server/email.ts b/frontend/src/lib/server/email.ts new file mode 100644 index 0000000..3ad9e56 --- /dev/null +++ b/frontend/src/lib/server/email.ts @@ -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: ` +

You requested a password reset for your ${famName} account.

+

Click here to reset your password

+

This link expires in 1 hour.

+

If you didn't request this, you can ignore this email.

+ ` + }); +} + +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: ` +

You've been invited as a parent on ${famName}.

+

Click here to join and set up your password.

+

Your code is ${otp} (valid 20 minutes).

+

If you weren't expecting this, you can ignore this email.

+ ` + }); +} diff --git a/frontend/src/lib/server/member-otp.ts b/frontend/src/lib/server/member-otp.ts index e6286da..a6f5272 100644 --- a/frontend/src/lib/server/member-otp.ts +++ b/frontend/src/lib/server/member-otp.ts @@ -118,6 +118,94 @@ export async function issueAccess(opts: { 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, // 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. @@ -151,4 +239,4 @@ export async function redeemOtp(opts: { famSlug: string; username: string; otp: .collection('users') .authWithPassword(fullUsername, derivePassword(famSlug, handleName)); return authPb.authStore.token; -} \ No newline at end of file +} diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index 1016afe..2ac4474 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -5,67 +5,63 @@ import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; let token: string | null = null; async function auth(): Promise { - if (token) return token; - const res = await fetch( - `${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }), - }, - ); - const data = await res.json(); - if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); - token = data.token; - return token!; + if (token) return token; + const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); + token = data.token; + return token!; } async function getCollection(name: string): Promise { - const t = await auth(); - const res = await fetch( - `${PB_ENDPOINT}/api/collections?filter=name='${name}'`, - { headers: { Authorization: `Bearer ${t}` } }, - ); - const data = await res.json(); - return data?.items?.[0] || null; + const t = await auth(); + const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, { + headers: { Authorization: `Bearer ${t}` } + }); + const data = await res.json(); + return data?.items?.[0] || null; } async function createCollection(col: any): Promise { - const t = await auth(); - const res = await fetch(`${PB_ENDPOINT}/api/collections`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, - body: JSON.stringify(col), - }); - const data = await res.json(); - if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); - console.log(` ✓ Created collection: ${col.name}`); - return data?.id || null; + const t = await auth(); + const res = await fetch(`${PB_ENDPOINT}/api/collections`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify(col) + }); + const data = await res.json(); + if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); + console.log(` ✓ Created collection: ${col.name}`); + return data?.id || null; } async function updateCollection(id: string, col: any): Promise { - const t = await auth(); - const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, - body: JSON.stringify(col), - }); - const data = await res.json(); - if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`); - console.log(` ✓ Updated collection: ${col.name || id}`); + const t = await auth(); + const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify(col) + }); + const data = await res.json(); + if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`); + console.log(` ✓ Updated collection: ${col.name || id}`); } async function createRecord(collection: string, data: any): Promise { - const t = await auth(); - const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, - body: JSON.stringify(data), - }); - if (!res.ok) { - const d = await res.json(); - throw new Error(`Create record ${collection} failed: ${JSON.stringify(d)}`); - } + const t = await auth(); + const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify(data) + }); + if (!res.ok) { + const d = await res.json(); + throw new Error(`Create record ${collection} failed: ${JSON.stringify(d)}`); + } } // Seed a default season (Holidays) per family so the family-admin season @@ -75,31 +71,31 @@ async function createRecord(collection: string, data: any): Promise { // boot. Families that want it can add it themselves. // Only seeds when a family has zero seasons, so user deletions stick. async function ensureDefaultSeasons(): Promise { - const famsCol = await getCollection("fams"); - if (!famsCol) return; - const t = await auth(); - const famsRes = await fetch(`${PB_ENDPOINT}/api/collections/fams/records?perPage=200`, { - headers: { Authorization: `Bearer ${t}` }, - }); - const fams = (await famsRes.json()).items || []; - const defaults = [{ name: "Holidays", color: "#f59e0b", active: true }]; - for (const fam of fams) { - const sRes = await fetch( - `${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`, - { headers: { Authorization: `Bearer ${t}` } } - ); - const have = ((await sRes.json()).items || []) as any[]; - // Only seed defaults for families that have no seasons at all. Seeding - // per-name (idempotent) forced a deleted default (e.g. "Term time") back - // on every boot; now a user who removes one keeps it removed as long as - // at least one season remains. - if (have.length === 0) { - for (const d of defaults) { - await createRecord("seasons", { famId: fam.id, ...d }); - console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`); - } - } - } + const famsCol = await getCollection('fams'); + if (!famsCol) return; + const t = await auth(); + const famsRes = await fetch(`${PB_ENDPOINT}/api/collections/fams/records?perPage=200`, { + headers: { Authorization: `Bearer ${t}` } + }); + const fams = (await famsRes.json()).items || []; + const defaults = [{ name: 'Holidays', color: '#f59e0b', active: true }]; + for (const fam of fams) { + const sRes = await fetch( + `${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`, + { headers: { Authorization: `Bearer ${t}` } } + ); + const have = ((await sRes.json()).items || []) as any[]; + // Only seed defaults for families that have no seasons at all. Seeding + // per-name (idempotent) forced a deleted default (e.g. "Term time") back + // on every boot; now a user who removes one keeps it removed as long as + // at least one season remains. + if (have.length === 0) { + for (const d of defaults) { + await createRecord('seasons', { famId: fam.id, ...d }); + console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`); + } + } + } } // Apply the custom fields + rules the app relies on to PB's native `users` @@ -107,74 +103,100 @@ async function ensureDefaultSeasons(): Promise { // role='child'; username is a password-auth identity so the server can // authWithPassword(derivedPassword) at OTP join time. async function ensureUsers(ids: Record): Promise { - const usersCol = await getCollection("users"); - if (!usersCol) throw new Error("users collection not found"); - const famsId = ids.fams || (await getCollection("fams"))?.id; - if (!famsId) throw new Error("fams collection not found"); + const usersCol = await getCollection('users'); + if (!usersCol) throw new Error('users collection not found'); + const famsId = ids.fams || (await getCollection('fams'))?.id; + if (!famsId) throw new Error('fams collection not found'); - const has = (n: string) => usersCol.fields.some((f: any) => f.name === n); - let changed = false; + const has = (n: string) => usersCol.fields.some((f: any) => f.name === n); + let changed = false; - const emailField = usersCol.fields.find((f: any) => f.name === "email"); - if (emailField && emailField.required) { - emailField.required = false; - changed = true; - } - if (!has("famId")) { - usersCol.fields.push({ - name: "famId", type: "relation", required: false, - collectionId: famsId, maxSelect: 1, cascadeDelete: false, - }); - changed = true; - } - if (!has("role")) { - usersCol.fields.push({ name: "role", type: "select", required: false, values: ["parent", "child"], maxSelect: 1 }); - changed = true; - } - if (!has("username")) { - usersCol.fields.push({ name: "username", type: "text", required: true }); - changed = true; - } - if (!has("color")) { - usersCol.fields.push({ name: "color", type: "text", required: false }); - changed = true; - } + const emailField = usersCol.fields.find((f: any) => f.name === 'email'); + if (emailField && emailField.required) { + emailField.required = false; + changed = true; + } + if (!has('famId')) { + usersCol.fields.push({ + name: 'famId', + type: 'relation', + required: false, + collectionId: famsId, + maxSelect: 1, + cascadeDelete: false + }); + changed = true; + } + if (!has('role')) { + usersCol.fields.push({ + name: 'role', + type: 'select', + required: false, + values: ['parent', 'child'], + maxSelect: 1 + }); + changed = true; + } + if (!has('username')) { + usersCol.fields.push({ name: 'username', type: 'text', required: true }); + changed = true; + } + if (!has('color')) { + 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; + } - let indexes = usersCol.indexes || []; - if (!indexes.some((i: string) => /username/i.test(i))) { - indexes = [...indexes, "CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''"]; - changed = true; - } + let indexes = usersCol.indexes || []; + if (!indexes.some((i: string) => /username/i.test(i))) { + indexes = [ + ...indexes, + "CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''" + ]; + changed = true; + } - const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ["email"] }; - const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ["email"]; - if (!identityFields.includes("username")) { - identityFields.push("username"); - changed = true; - } + const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ['email'] }; + const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ['email']; + if (!identityFields.includes('username')) { + identityFields.push('username'); + changed = true; + } - const listRule = "famId = @request.auth.famId"; + const listRule = 'famId = @request.auth.famId'; const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'"; // Members can edit their own record (name/colour); parents can edit any // family member. Delete stays parent-only. - const selfOrParentWrite = "@request.auth.id = id || (famId = @request.auth.famId && @request.auth.role = 'parent')"; - if (usersCol.listRule !== listRule || usersCol.viewRule !== listRule || - usersCol.updateRule !== selfOrParentWrite || usersCol.deleteRule !== parentWrite) { + const selfOrParentWrite = + "@request.auth.id = id || (famId = @request.auth.famId && @request.auth.role = 'parent')"; + if ( + usersCol.listRule !== listRule || + usersCol.viewRule !== listRule || + usersCol.updateRule !== selfOrParentWrite || + usersCol.deleteRule !== parentWrite + ) { changed = true; } if (changed) { await updateCollection(usersCol.id, { - name: "users", - type: "auth", + name: 'users', + type: 'auth', listRule, viewRule: listRule, - createRule: usersCol.createRule || "", + createRule: usersCol.createRule || '', updateRule: selfOrParentWrite, deleteRule: parentWrite, fields: usersCol.fields, indexes, - passwordAuth: { enabled: true, identityFields }, + passwordAuth: { enabled: true, identityFields } }); } } @@ -183,25 +205,39 @@ async function ensureUsers(ids: Record): Promise { // its issue timestamp (20-min window). Not public — read/written via the // superuser client only. async function ensureOtp(ids: Record): Promise { - if (await getCollection("otp")) return; - const famsId = ids.fams || (await getCollection("fams"))?.id; - const usersId = ids.users || (await getCollection("users"))?.id; - if (!famsId || !usersId) throw new Error("fams/users collection not found"); - await createCollection({ - name: "otp", - type: "base", - listRule: null, - viewRule: null, - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { name: "famId", type: "relation", required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false }, - { name: "userId", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, - { name: "otp", type: "text", required: false }, - { name: "updatedAt", type: "text", required: false }, - ], - }); + if (await getCollection('otp')) return; + const famsId = ids.fams || (await getCollection('fams'))?.id; + const usersId = ids.users || (await getCollection('users'))?.id; + if (!famsId || !usersId) throw new Error('fams/users collection not found'); + await createCollection({ + name: 'otp', + type: 'base', + listRule: null, + viewRule: null, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { + name: 'famId', + type: 'relation', + required: true, + collectionId: famsId, + maxSelect: 1, + cascadeDelete: false + }, + { + name: 'userId', + type: 'relation', + required: true, + collectionId: usersId, + maxSelect: 1, + cascadeDelete: false + }, + { name: 'otp', type: 'text', required: false }, + { name: 'updatedAt', type: 'text', required: false } + ] + }); } // Platform access codes — the codes that enable access to the platform. They're @@ -211,70 +247,77 @@ async function ensureOtp(ids: Record): Promise { // (0 = never expires); `active` is a failsafe toggle. Entered at create-family // and in admin settings. async function ensureAccessCodes(): Promise { - if (await getCollection("accesscodes")) { - await ensureAccessCodeFields(); - await seedAccessCodes(); - return; - } - await createCollection({ - name: "accesscodes", - type: "base", - listRule: null, - viewRule: null, - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { name: "value", type: "text", required: true, unique: true }, - { name: "name", type: "text", required: true }, - { name: "duration", type: "number", required: false }, - { name: "expiry", type: "number", required: false }, - { name: "active", type: "bool", required: false }, - // When set (>0) this code is a TRIAL code: maps to Stripe - // trial_period_days at checkout instead of platform access. - { name: "trialDays", type: "number", required: false }, - { name: "createdAt", type: "date", required: false }, - ], - }); - await seedAccessCodes(); + if (await getCollection('accesscodes')) { + await ensureAccessCodeFields(); + await seedAccessCodes(); + return; + } + await createCollection({ + name: 'accesscodes', + type: 'base', + listRule: null, + viewRule: null, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: 'value', type: 'text', required: true, unique: true }, + { name: 'name', type: 'text', required: true }, + { name: 'duration', type: 'number', required: false }, + { name: 'expiry', type: 'number', required: false }, + { name: 'active', type: 'bool', required: false }, + // When set (>0) this code is a TRIAL code: maps to Stripe + // trial_period_days at checkout instead of platform access. + { name: 'trialDays', type: 'number', required: false }, + { name: 'createdAt', type: 'date', required: false } + ] + }); + await seedAccessCodes(); } // Idempotent field-add for installs where accesscodes predates a field. async function ensureAccessCodeFields(): Promise { - const col = await getCollection("accesscodes"); - if (!col) return; - const has = (n: string) => col.fields.some((f: any) => f.name === n); - if (!has("trialDays")) { - await updateCollection(col.id, { - ...col, - fields: [...col.fields, { name: "trialDays", type: "number", required: false }], - }); - } + const col = await getCollection('accesscodes'); + if (!col) return; + const has = (n: string) => col.fields.some((f: any) => f.name === n); + if (!has('trialDays')) { + await updateCollection(col.id, { + ...col, + fields: [...col.fields, { name: 'trialDays', type: 'number', required: false }] + }); + } } // Idempotent seeds — developer code + an example trial code. async function seedAccessCodes(): Promise { - const t = await auth(); - const seeds = [ - { 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 }, - ]; - for (const seed of seeds) { - const res = await fetch( - `${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='${seed.value}'`, - { headers: { Authorization: `Bearer ${t}` } }, - ); - const data = await res.json(); - if (data?.items?.length) continue; - const created = await fetch(`${PB_ENDPOINT}/api/collections/accesscodes/records`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, - body: JSON.stringify({ ...seed, createdAt: new Date().toISOString() }), - }); - const c = await created.json(); - if (!created.ok) throw new Error(`Seed accesscode failed: ${JSON.stringify(c)}`); - console.log(` ✓ Seeded access code: ${seed.name} (${seed.value})`); - } + const t = await auth(); + const seeds = [ + { 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 + } + ]; + for (const seed of seeds) { + const res = await fetch( + `${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='${seed.value}'`, + { headers: { Authorization: `Bearer ${t}` } } + ); + const data = await res.json(); + if (data?.items?.length) continue; + const created = await fetch(`${PB_ENDPOINT}/api/collections/accesscodes/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ ...seed, createdAt: new Date().toISOString() }) + }); + const c = await created.json(); + if (!created.ok) throw new Error(`Seed accesscode failed: ${JSON.stringify(c)}`); + console.log(` ✓ Seeded access code: ${seed.name} (${seed.value})`); + } } // Platform settings — a single global record holding the platform feature @@ -283,93 +326,98 @@ async function seedAccessCodes(): Promise { // superuser-only (null rules), so like otp/accesscodes this lives in // migrate.ts rather than SCHEMA_PLAN. async function ensurePlatform(): Promise { - if (!(await getCollection("platform"))) { - await createCollection({ - name: "platform", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { name: "label", type: "text", required: true }, - { name: "flags", type: "json", required: false }, - ], - }); - } - await seedPlatform(); + if (!(await getCollection('platform'))) { + await createCollection({ + name: 'platform', + type: 'base', + listRule: '', + viewRule: '', + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: 'label', type: 'text', required: true }, + { name: 'flags', type: 'json', required: false } + ] + }); + } + await seedPlatform(); } // Idempotent seed — create the singleton 'global' settings record if missing. async function seedPlatform(): Promise { - const t = await auth(); - const res = await fetch( - `${PB_ENDPOINT}/api/collections/platform/records?filter=label='global'`, - { headers: { Authorization: `Bearer ${t}` } }, - ); - const data = await res.json(); - if (data?.items?.length) return; - const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, - body: JSON.stringify({ label: "global", flags: { debug: false } }), - }); - const c = await created.json(); - if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`); - console.log(" ✓ Seeded platform settings (global)"); + const t = await auth(); + const res = await fetch(`${PB_ENDPOINT}/api/collections/platform/records?filter=label='global'`, { + headers: { Authorization: `Bearer ${t}` } + }); + const data = await res.json(); + if (data?.items?.length) return; + const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ label: 'global', flags: { debug: false } }) + }); + const c = await created.json(); + if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`); + console.log(' ✓ Seeded platform settings (global)'); } // Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if // `fams` already exists (data is disposable; there is no incremental migration // history). async function ensureSchema(): Promise { - if (await getCollection("fams")) { - console.log("[migrate] Schema already present — skipping bootstrap."); - return; - } - console.log("[migrate] Bootstrapping schema on fresh PocketBase..."); + if (await getCollection('fams')) { + console.log('[migrate] Schema already present — skipping bootstrap.'); + return; + } + console.log('[migrate] Bootstrapping schema on fresh PocketBase...'); - const ids: Record = {}; - const nativeUsers = await getCollection("users"); - if (nativeUsers) ids.users = nativeUsers.id; - for (const entry of SCHEMA_PLAN) { - const createdId = await createCollection(entry.build(ids)); - if (createdId) ids[entry.name] = createdId; - } + const ids: Record = {}; + const nativeUsers = await getCollection('users'); + if (nativeUsers) ids.users = nativeUsers.id; + for (const entry of SCHEMA_PLAN) { + const createdId = await createCollection(entry.build(ids)); + if (createdId) ids[entry.name] = createdId; + } - await ensureUsers(ids); - await ensureOtp(ids); - console.log("[migrate] Schema bootstrapped."); + await ensureUsers(ids); + await ensureOtp(ids); + console.log('[migrate] Schema bootstrapped.'); } // Add the access-gating fields to `fams` on installs where it already exists // (fresh installs get them via SCHEMA_PLAN). Idempotent — only adds missing // fields. async function ensureFamFields(): Promise { - const famsCol = await getCollection("fams"); - if (!famsCol) return; - const has = (n: string) => famsCol.fields.some((f: any) => f.name === n); - const needed: any[] = []; - if (!has("active")) { - needed.push({ name: "active", type: "bool", required: false }); - } - if (!has("paymentMode")) { - needed.push({ name: "paymentMode", type: "select", required: false, values: ["none", "code", "sub", "canceled"], maxSelect: 1 }); - } - if (!has("accessCodeId")) { - needed.push({ name: "accessCodeId", type: "text", required: false }); - } - if (!has("accessCodeEnteredAt")) { - needed.push({ name: "accessCodeEnteredAt", type: "date", required: false }); - } - if (needed.length) { - await updateCollection(famsCol.id, { ...famsCol, fields: [...famsCol.fields, ...needed] }); - } + const famsCol = await getCollection('fams'); + if (!famsCol) return; + const has = (n: string) => famsCol.fields.some((f: any) => f.name === n); + const needed: any[] = []; + if (!has('active')) { + needed.push({ name: 'active', type: 'bool', required: false }); + } + if (!has('paymentMode')) { + needed.push({ + name: 'paymentMode', + type: 'select', + required: false, + values: ['none', 'code', 'sub', 'canceled'], + maxSelect: 1 + }); + } + if (!has('accessCodeId')) { + needed.push({ name: 'accessCodeId', type: 'text', required: false }); + } + if (!has('accessCodeEnteredAt')) { + needed.push({ name: 'accessCodeEnteredAt', type: 'date', required: false }); + } + if (needed.length) { + await updateCollection(famsCol.id, { ...famsCol, fields: [...famsCol.fields, ...needed] }); + } } export async function migrate(): Promise { - console.log("[migrate] Checking PB collection schemas..."); + console.log('[migrate] Checking PB collection schemas...'); await ensureSchema(); // Runs even when the schema already exists (unlike ensureSchema's early // return) so new platform collections/fields/seed land on existing installs. @@ -380,29 +428,29 @@ export async function migrate(): Promise { await ensureAccessCodes(); await ensurePlatform(); await ensureDefaultSeasons(); - console.log("[migrate] Done"); + console.log('[migrate] Done'); } // Add colour / icon / description to `assigned_chores` on existing installs so // family-admins can override a template's look per assignment. Idempotent. async function ensureAssignedChoreFields(): Promise { - const col = await getCollection("assigned_chores"); + const col = await getCollection('assigned_chores'); if (!col) return; const has = (n: string) => col.fields.some((f: any) => f.name === n); const needed: any[] = []; - if (!has("description")) needed.push({ name: "description", 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("emoji")) needed.push({ name: "emoji", 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('color')) needed.push({ name: 'color', 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. let changed = !!needed.length; - const typeField = col.fields.find((f: any) => f.name === "type"); - if (typeField && Array.isArray(typeField.values) && !typeField.values.includes("emoji")) { - typeField.values = [...typeField.values, "emoji"]; + const typeField = col.fields.find((f: any) => f.name === 'type'); + if (typeField && Array.isArray(typeField.values) && !typeField.values.includes('emoji')) { + typeField.values = [...typeField.values, 'emoji']; changed = true; } // 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) { tplField.required = false; changed = true; @@ -410,11 +458,11 @@ async function ensureAssignedChoreFields(): Promise { if (changed) { await updateCollection(col.id, { ...col, fields: [...col.fields] }); } - const comp = await getCollection("completions"); - if (comp && !comp.fields.some((f: any) => f.name === "rewardId")) { + const comp = await getCollection('completions'); + if (comp && !comp.fields.some((f: any) => f.name === 'rewardId')) { await updateCollection(comp.id, { ...comp, - fields: [...comp.fields, { name: "rewardId", type: "text", required: false }], + fields: [...comp.fields, { name: 'rewardId', type: 'text', required: false }] }); } } @@ -423,102 +471,108 @@ async function ensureAssignedChoreFields(): Promise { // backfill a default pocket-money droplet (£10 / 50% chores) for any child that // doesn't yet have one. Idempotent. async function ensureBonusFields(): Promise { - for (const name of ["bonus_templates", "bonus_configs"]) { - const col = await getCollection(name); - if (!col) continue; - const has = (n: string) => col.fields.some((f: any) => f.name === n); - let changed = false; - const fields = [...col.fields]; - if (!has("thresholdType")) { - fields.push({ - name: "thresholdType", type: "select", required: false, - values: ["points", "percent"], maxSelect: 1, - }); - changed = true; - } - if (!has("isPocketMoney")) { - fields.push({ name: "isPocketMoney", type: "bool", required: false }); - changed = true; - } - // Standalone-reward progress window (bonus_configs only): how far progress - // counts (`completeBy`) and the window's start/custom-end dates. - if (name === "bonus_configs") { - if (!has("completeBy")) { - fields.push({ - name: "completeBy", type: "select", required: false, - values: ["unlimited", "week", "custom"], maxSelect: 1, - }); - changed = true; - } - if (!has("startDate")) { - fields.push({ name: "startDate", type: "text", required: false }); - changed = true; - } - if (!has("completeByDate")) { - fields.push({ name: "completeByDate", type: "text", required: false }); - changed = true; - } - } - // rewardValue must be optional so an unset pocket-money droplet can exist. - const rv = fields.find((f: any) => f.name === "rewardValue"); - if (rv && rv.required) { - rv.required = false; - changed = true; - } - if (changed) await updateCollection(col.id, { ...col, fields }); - } - await backfillPocketMoney(); + for (const name of ['bonus_templates', 'bonus_configs']) { + const col = await getCollection(name); + if (!col) continue; + const has = (n: string) => col.fields.some((f: any) => f.name === n); + let changed = false; + const fields = [...col.fields]; + if (!has('thresholdType')) { + fields.push({ + name: 'thresholdType', + type: 'select', + required: false, + values: ['points', 'percent'], + maxSelect: 1 + }); + changed = true; + } + if (!has('isPocketMoney')) { + fields.push({ name: 'isPocketMoney', type: 'bool', required: false }); + changed = true; + } + // Standalone-reward progress window (bonus_configs only): how far progress + // counts (`completeBy`) and the window's start/custom-end dates. + if (name === 'bonus_configs') { + if (!has('completeBy')) { + fields.push({ + name: 'completeBy', + type: 'select', + required: false, + values: ['unlimited', 'week', 'custom'], + maxSelect: 1 + }); + changed = true; + } + if (!has('startDate')) { + fields.push({ name: 'startDate', type: 'text', required: false }); + changed = true; + } + if (!has('completeByDate')) { + fields.push({ name: 'completeByDate', type: 'text', required: false }); + changed = true; + } + } + // rewardValue must be optional so an unset pocket-money droplet can exist. + const rv = fields.find((f: any) => f.name === 'rewardValue'); + if (rv && rv.required) { + rv.required = false; + changed = true; + } + if (changed) await updateCollection(col.id, { ...col, fields }); + } + await backfillPocketMoney(); } async function backfillPocketMoney(): Promise { - const t = await auth(); - // Flag legacy pocket-money configs that predate the isPocketMoney field so - // the dashboard recognises them (and we don't create duplicates below). - const legacyRes = await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records?filter=${encodeURIComponent( - "isPocketMoney!=true && name~'pocket' && thresholdType='percent'" - )}&perPage=500`, - { headers: { Authorization: `Bearer ${t}` } } - ); - for (const rec of (await legacyRes.json())?.items || []) { - await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, - body: JSON.stringify({ isPocketMoney: true }), - }).catch(() => {}); - } - const childrenRes = await fetch( - `${PB_ENDPOINT}/api/collections/users/records?filter=role='child'&perPage=500`, - { headers: { Authorization: `Bearer ${t}` } } - ); - const children: any[] = (await childrenRes.json())?.items || []; - for (const c of children) { - const existingRes = await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records?filter=famId='${c.famId}' && memberId='${c.id}' && isPocketMoney=true`, - { headers: { Authorization: `Bearer ${t}` } } - ); - if ((await existingRes.json())?.items?.length) continue; - await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, - body: JSON.stringify({ - famId: c.famId, - name: "Pocket Money", - target: "individual", - type: "threshold", - thresholdType: "percent", - occurrence: "recurring", - rewardType: "cash", - rewardValue: 10, - criteriaValue: 50, - memberId: c.id, - period: "weekly", - status: "active", - isPocketMoney: true, - }), - }).catch(() => {}); - } - console.log("[migrate] Pocket-money droplets ensured."); + const t = await auth(); + // Flag legacy pocket-money configs that predate the isPocketMoney field so + // the dashboard recognises them (and we don't create duplicates below). + const legacyRes = await fetch( + `${PB_ENDPOINT}/api/collections/bonus_configs/records?filter=${encodeURIComponent( + "isPocketMoney!=true && name~'pocket' && thresholdType='percent'" + )}&perPage=500`, + { headers: { Authorization: `Bearer ${t}` } } + ); + for (const rec of (await legacyRes.json())?.items || []) { + await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ isPocketMoney: true }) + }).catch(() => {}); + } + const childrenRes = await fetch( + `${PB_ENDPOINT}/api/collections/users/records?filter=role='child'&perPage=500`, + { headers: { Authorization: `Bearer ${t}` } } + ); + const children: any[] = (await childrenRes.json())?.items || []; + for (const c of children) { + const existingRes = await fetch( + `${PB_ENDPOINT}/api/collections/bonus_configs/records?filter=famId='${c.famId}' && memberId='${c.id}' && isPocketMoney=true`, + { headers: { Authorization: `Bearer ${t}` } } + ); + if ((await existingRes.json())?.items?.length) continue; + await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ + famId: c.famId, + name: 'Pocket Money', + target: 'individual', + type: 'threshold', + thresholdType: 'percent', + occurrence: 'recurring', + rewardType: 'cash', + rewardValue: 10, + criteriaValue: 50, + memberId: c.id, + period: 'weekly', + status: 'active', + isPocketMoney: true + }) + }).catch(() => {}); + } + console.log('[migrate] Pocket-money droplets ensured.'); } // Promote chore_templates / bonus_templates to platform-owned: add global/icon/ @@ -526,136 +580,164 @@ async function backfillPocketMoney(): Promise { // global (platform) templates as assignable droplets. Idempotent. Seeds a // starter set of global templates on first run for out-of-the-box usage. async function ensureTemplateFields(): Promise { - const readRule = "global = true || famId = @request.auth.famId"; - for (const name of ["chore_templates", "bonus_templates"]) { - const col = await getCollection(name); - if (!col) continue; - const has = (n: string) => col.fields.some((f: any) => f.name === n); - let changed = false; - const fields = [...col.fields]; - for (const f of [ - { name: "global", type: "bool", required: false }, - { name: "icon", type: "text", required: false }, - { name: "color", type: "text", required: false }, - ]) { - if (!has(f.name)) { - fields.push(f); - changed = true; - } - } - const famId = fields.find((f: any) => f.name === "famId"); - if (famId && famId.required) { - famId.required = false; - changed = true; - } - if (col.listRule !== readRule || col.viewRule !== readRule) { - col.listRule = readRule; - col.viewRule = readRule; - changed = true; - } - if (changed) await updateCollection(col.id, { ...col, fields }); - } - await seedGlobalTemplates(); + const readRule = 'global = true || famId = @request.auth.famId'; + for (const name of ['chore_templates', 'bonus_templates']) { + const col = await getCollection(name); + if (!col) continue; + const has = (n: string) => col.fields.some((f: any) => f.name === n); + let changed = false; + const fields = [...col.fields]; + for (const f of [ + { name: 'global', type: 'bool', required: false }, + { name: 'icon', type: 'text', required: false }, + { name: 'color', type: 'text', required: false } + ]) { + if (!has(f.name)) { + fields.push(f); + changed = true; + } + } + const famId = fields.find((f: any) => f.name === 'famId'); + if (famId && famId.required) { + famId.required = false; + changed = true; + } + if (col.listRule !== readRule || col.viewRule !== readRule) { + col.listRule = readRule; + col.viewRule = readRule; + changed = true; + } + if (changed) await updateCollection(col.id, { ...col, fields }); + } + await seedGlobalTemplates(); } const GLOBAL_TEMPLATE_ICONS = [ - "Bed", - "Sparkles", - "BookOpen", - "Utensils", - "Wallet", - "Trophy", - "Star", - "Moon", - "Sun", - "Apple", - "Car", - "Gamepad2", - "Music", - "Shirt", - "Leaf", - "Heart", - "Gift", - "Timer", + 'Bed', + 'Sparkles', + 'BookOpen', + 'Utensils', + 'Wallet', + 'Trophy', + 'Star', + 'Moon', + 'Sun', + 'Apple', + 'Car', + 'Gamepad2', + 'Music', + 'Shirt', + 'Leaf', + 'Heart', + 'Gift', + 'Timer' ]; async function seedGlobalTemplates(): Promise { - const t = await auth(); - const countGlobal = async (name: string) => { - const res = await fetch( - `${PB_ENDPOINT}/api/collections/${name}/records?filter=global=true&perPage=1`, - { headers: { Authorization: `Bearer ${t}` } } - ); - return ((await res.json())?.items?.length) || 0; - }; - if ((await countGlobal("chore_templates")) > 0 || (await countGlobal("bonus_templates")) > 0) { - console.log("[migrate] Global templates already present — skipping seed."); - return; - } + const t = await auth(); + const countGlobal = async (name: string) => { + const res = await fetch( + `${PB_ENDPOINT}/api/collections/${name}/records?filter=global=true&perPage=1`, + { headers: { Authorization: `Bearer ${t}` } } + ); + return (await res.json())?.items?.length || 0; + }; + if ((await countGlobal('chore_templates')) > 0 || (await countGlobal('bonus_templates')) > 0) { + console.log('[migrate] Global templates already present — skipping seed.'); + return; + } - 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: "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 = [ - { - name: "Pocket Money", - target: "individual", - type: "threshold", - thresholdType: "percent", - occurrence: "recurring", - rewardType: "cash", - rewardValue: "", - criteriaValue: 50, - period: "weekly", - isPocketMoney: true, - icon: "Wallet", - color: "#22c55e", - }, - { - name: "Reading Bonus", - target: "individual", - type: "count", - occurrence: "recurring", - rewardType: "points", - rewardValue: "50", - criteriaValue: 5, - period: "weekly", - icon: "BookOpen", - color: "#f59e0b", - }, - { - name: "Star of the Week", - target: "competitive", - type: "threshold", - thresholdType: "points", - occurrence: "weekly", - rewardType: "prize", - rewardValue: "Treat", - criteriaValue: 100, - period: "weekly", - icon: "Trophy", - color: "#ef4444", - }, - ]; + 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: '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 = [ + { + name: 'Pocket Money', + target: 'individual', + type: 'threshold', + thresholdType: 'percent', + occurrence: 'recurring', + rewardType: 'cash', + rewardValue: '', + criteriaValue: 50, + period: 'weekly', + isPocketMoney: true, + icon: 'Wallet', + color: '#22c55e' + }, + { + name: 'Reading Bonus', + target: 'individual', + type: 'count', + occurrence: 'recurring', + rewardType: 'points', + rewardValue: '50', + criteriaValue: 5, + period: 'weekly', + icon: 'BookOpen', + color: '#f59e0b' + }, + { + name: 'Star of the Week', + target: 'competitive', + type: 'threshold', + thresholdType: 'points', + occurrence: 'weekly', + rewardType: 'prize', + rewardValue: 'Treat', + criteriaValue: 100, + period: 'weekly', + icon: 'Trophy', + color: '#ef4444' + } + ]; - for (const s of choreSeeds) { - await fetch(`${PB_ENDPOINT}/api/collections/chore_templates/records`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, - body: JSON.stringify({ ...s, global: true }), - }).catch(() => {}); - } - for (const s of bonusSeeds) { - await fetch(`${PB_ENDPOINT}/api/collections/bonus_templates/records`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, - body: JSON.stringify({ ...s, global: true }), - }).catch(() => {}); - } - console.log("[migrate] Seeded global templates."); + for (const s of choreSeeds) { + await fetch(`${PB_ENDPOINT}/api/collections/chore_templates/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ ...s, global: true }) + }).catch(() => {}); + } + for (const s of bonusSeeds) { + await fetch(`${PB_ENDPOINT}/api/collections/bonus_templates/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ ...s, global: true }) + }).catch(() => {}); + } + console.log('[migrate] Seeded global templates.'); } -export { GLOBAL_TEMPLATE_ICONS }; \ No newline at end of file +export { GLOBAL_TEMPLATE_ICONS }; diff --git a/frontend/src/new-hooks.ts b/frontend/src/new-hooks.ts deleted file mode 100644 index 707a7da..0000000 --- a/frontend/src/new-hooks.ts +++ /dev/null @@ -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); -}; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index b007cb4..785fcc0 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -40,14 +40,19 @@ For busy families

Chores done. Allowance earned.

- FamChore turns everyday household chores into points and pocket money. Assign the - chores, let your kids see their progress live, and FamChore calculates the allowance - automatically — no spreadsheets, no nagging. + FamChore turns everyday household chores into points and pocket money. Assign the chores, + let your kids see their progress live, and FamChore calculates the allowance automatically + — no spreadsheets, no nagging.

  • {@html checkCircleIcon}Set up in under a minute
  • -
  • {@html checkCircleIcon}Kids join with a simple invite code
  • -
  • {@html checkCircleIcon}Allowance lands on payday, automatically
  • +
  • + {@html checkCircleIcon}Kids join with a simple invite code +
  • +
  • + {@html checkCircleIcon}Allowance lands on payday, + automatically +
@@ -56,7 +61,9 @@

Free to get going. Takes about a minute.

Create my family

- See pricing {@html chevronRightIcon} + See pricing {@html chevronRightIcon}

Already have a family? Log in @@ -72,17 +79,26 @@

1

Create your family

-

Set up chores and how much each one is worth. Your kids join in seconds with an invite code.

+

+ Set up chores and how much each one is worth. Your kids join in seconds with an invite + code. +

2

Kids do the work

-

They see today's chores as a simple card board and tick them off as they go — points are added instantly.

+

+ They see today's chores as a simple card board and tick them off as they go — points are + added instantly. +

3

Allowance pays out

-

Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep it fun.

+

+ Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep it + fun. +

@@ -97,7 +113,10 @@

Live progress

-

Realtime sync across the whole family — see points and progress update the moment a chore is done.

+

+ Realtime sync across the whole family — see points and progress update the moment a chore + is done. +

Automatic allowance

@@ -109,17 +128,27 @@

Kid-friendly

-

A simple, colourful interface kids love — big buttons, clear feedback, their own space.

+

+ A simple, colourful interface kids love — big buttons, clear feedback, their own space. +

Private by design

-

Everything is scoped to your family. Kids join with an invite code and stay in your family.

+

+ Everything is scoped to your family. Kids join with an invite code and stay in your + family. +

Ready to make chores painless?

Create your family -

or see pricing {@html chevronRightIcon}

+

+ or see pricing {@html chevronRightIcon} +

@@ -128,7 +157,10 @@ diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index 802a132..e0a4f10 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -22,7 +22,7 @@ let accessReason = $derived(data.famAccess?.reason || ''); // Settings stays usable while paused so admins can apply a code / manage billing. 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) ── // Landing with ?checkout=return: if the webhook has already landed we show diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts index eb17aba..dddbe88 100644 --- a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts @@ -1,12 +1,17 @@ import { redirect, fail } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; 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 { issueAccess, createChild } from '$lib/server/member-otp'; -import { slugify } from '@shared/slugify'; +import { issueAccess, createChild, inviteParent } from '$lib/server/member-otp'; +import { sendParentInviteEmail } from '$lib/server/email'; +import { slugify, handle, famUsername } from '@shared/slugify'; 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 { 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) => { const famId = famIdOf(event); const fd = await event.request.formData(); @@ -144,9 +207,7 @@ export const actions = { const id = fd.get('id') as string; const active = fd.get('active') === '1'; if (!id) return { error: 'Season ID required' }; - await pbUser(event) - .collection('seasons') - .update(id, { active }); + await pbUser(event).collection('seasons').update(id, { active }); return { ok: true }; }, diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.svelte b/frontend/src/routes/[fam]/[username]/settings/+page.svelte index 7224ea0..ab48f88 100644 --- a/frontend/src/routes/[fam]/[username]/settings/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/settings/+page.svelte @@ -17,6 +17,10 @@ import { handleOf } from '@shared/slugify'; import { addMonthsUTC, formatShortDate } from '$lib/format'; 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(); @@ -77,14 +81,25 @@ } } }); - let showQR = $state(false); let qrDataUrl = $state(''); let copied = $state(false); + let parentInviteName = $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(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 // (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 inviteUrl = $derived(issued ? `${page.url.origin}${invitePath}` : ''); + let invitePath = $derived(issueModal ? `${issueModal.joinUrl}?code=${issueModal.otp}` : ''); function copy(url: string) { navigator.clipboard.writeText(url); @@ -117,20 +131,38 @@ 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) { qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 }); } - function toggleQR() { - showQR = !showQR; - if (!showQR) qrDataUrl = ''; - else generateQR(inviteUrl); - } - function handleParentInvite() { 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 { const diffMs = new Date(iso).getTime() - Date.now(); if (diffMs <= 0) return 'expired'; @@ -158,9 +190,14 @@ +{#snippet famIcon()}{/snippet} +{#snippet inviteIcon()}{/snippet} +{#snippet accountIcon()}{/snippet} +{#snippet appIcon()}{/snippet} + - +

@@ -232,35 +269,53 @@

-

Group chores into seasons. Tick a season on to make it available for assignment; untick to disable it.

+

+ Group chores into seasons. Tick a season on to make it available for assignment; untick to + disable it. +

-
- - +
+ +
+ {#each seasonColors as color} + + {/each} +
+
-
    - {#each (famStore.initialized ? famStore.seasons : data.seasons) as s} +
      + + {#each famStore.initialized ? famStore.seasons : data.seasons as s}
    • - - {s.name} + + + {s.name} + - + + +
    • {/each}
    @@ -312,129 +370,190 @@ - + - -
    -
    -

    Add a child. They'll pick their own colour after joining.

    -
    - - - -
    -
    - -
      - {#each members as m} -
    • - - - - {m.name} - /{famSlug}/{handleOf(m.username)} - - - - -
      - - -
      -
      -
    • - {/each} -
    -
    + +

    Add a child. They'll pick their own colour after joining.

    +
    + + + +
    - -
    { - 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" - > + +
    +

    + Generates a 6-digit code valid for 20 minutes. The child enters it at the join link. +

    + - {#each members as m} {/each} - - -

    - Generates a 6-digit code valid for 20 minutes. The child enters it at the join link. -

    - {#if issued?.otp} -
    -

    Code for {issued.name} (valid 20 min):

    -

    - {issued.otp} -

    -

    {invitePath}

    -
    - +
    { + 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" + > + + - + + { + 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" + > + + -
    - {#if showQR && qrDataUrl} -
    QR Code
    - {/if} +
    - {/if} +
    +
    + + +
      + {#each members as m} +
    • + + + + {m.name} + /{famSlug}/{handleOf(m.username)} + + +
      + + +
      +
    • + {/each} +

    Send an email invitation for another parent to join as an admin.

    -
    +
    { + return async ({ result }) => handleInviteResult(result); + }} + > + + - -
    -

    They will set up their own password on first login.

    + + +

    They'll receive a link with a code to set up their own password.

    + + {#if issueModal} +
    (issueModal = null)} role="presentation"> + +
    + {/if}
    - + {#if fam?.paymentMode !== 'sub'} @@ -515,8 +634,9 @@

    No active subscription. You have - {timeUntil(data.subStatus.endsAt)} of access left - (until {formatShortDate(String(data.subStatus.endsAt))}). No further charges. + {timeUntil(data.subStatus.endsAt)} of access left (until {formatShortDate( + String(data.subStatus.endsAt) + )}). No further charges.

    {:else if fam?.paymentMode === 'sub'}
    @@ -560,7 +680,7 @@ - + {#if page.data.platformFlags?.debug} @@ -703,25 +823,40 @@ } /* ── Colour rows (seasons) ── */ - .color-row { + .season-color-row { display: flex; align-items: center; gap: 0.75rem; + flex-wrap: wrap; } - .color-row label { + .season-color-row label { font-size: 0.8rem; font-weight: 500; color: #6b7280; flex-shrink: 0; } - .color-input { - width: 100%; - max-width: 160px; - height: 36px; - padding: 2px; - border: 1px solid #d1d5db; - border-radius: 8px; + .season-swatches { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + } + .season-swatch { + width: 1.65rem; + height: 1.65rem; + border-radius: 50%; + border: 2px solid transparent; 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 ── */ @@ -742,9 +877,45 @@ li:last-child { 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 { flex: 1; min-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .season-check { width: 26px; @@ -792,25 +963,20 @@ margin-top: 0.75rem; } - /* ── Members: two-column (add | list) ── */ - .members-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1.25rem; - align-items: start; - } - .members-add form { - margin-bottom: 0; - } + /* ── Members ── */ .members-list { - border-left: 1px solid #f3f4f6; - padding-left: 1.25rem; + display: flex; + flex-direction: column; } - .member-left, - .member-actions { + .members-list li { + justify-content: space-between; + gap: 1rem; + } + .member-left { display: flex; align-items: center; gap: 0.5rem; + min-width: 0; } .member-color { width: 22px; @@ -834,21 +1000,6 @@ font-size: 0.72rem; 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 ── */ .actions { @@ -873,6 +1024,43 @@ 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) ── */ @container (max-width: 380px) { .payday-row { @@ -882,7 +1070,7 @@ .payday-row select { width: 100%; } - .color-row { + .season-color-row { align-items: stretch; } .actions { diff --git a/frontend/src/routes/[fam]/join/[username]/+page.server.ts b/frontend/src/routes/[fam]/join/[username]/+page.server.ts index aba40fd..8040d46 100644 --- a/frontend/src/routes/[fam]/join/[username]/+page.server.ts +++ b/frontend/src/routes/[fam]/join/[username]/+page.server.ts @@ -1,6 +1,30 @@ 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 { 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 = { default: async (event) => { @@ -8,11 +32,35 @@ export const actions = { const username = event.params.username; const fd = await event.request.formData(); 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.' }); 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); setSessionCookie(event.cookies, token); } catch (e) { @@ -21,4 +69,4 @@ export const actions = { throw redirect(303, `/${fam}/${encodeURIComponent(username)}`); } -}; \ No newline at end of file +}; diff --git a/frontend/src/routes/[fam]/join/[username]/+page.svelte b/frontend/src/routes/[fam]/join/[username]/+page.svelte index 43d8261..6d9f577 100644 --- a/frontend/src/routes/[fam]/join/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/join/[username]/+page.svelte @@ -7,21 +7,36 @@ const famSlug = page.params.fam; const username = page.params.username; 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); -Join {famSlug} +{isParent ? 'Join as parent' : `Join ${famSlug}`}
    -
    +
    {@html homeIcon}
    -

    Welcome to {famSlug}!

    -

    - Hi {username} — enter the code your parent - gave you to get started. -

    + {#if isParent} +

    Join {famSlug}

    +

    + Hi {username} — enter the code from your + email and create a password to join {famName} as a parent. +

    + {:else} +

    Welcome to {famSlug}!

    +

    + Hi {username} — enter the code your parent gave + you to get started. +

    + {/if}
    + {#if isParent} + + + {/if} {#if form?.error}

    {form.error}

    {/if} - +

    @@ -56,6 +93,7 @@

    + diff --git a/frontend/src/routes/logout/+page.server.ts b/frontend/src/routes/logout/+page.server.ts index e5cf63e..25919fb 100644 --- a/frontend/src/routes/logout/+page.server.ts +++ b/frontend/src/routes/logout/+page.server.ts @@ -3,7 +3,6 @@ import { clearSessionCookie, clearLegacyCookies } from '$lib/server/session'; function signOut(event: { cookies: any }) { clearSessionCookie(event.cookies); - event.cookies.delete('session', { path: '/' }); clearLegacyCookies(event.cookies); } @@ -17,4 +16,4 @@ export const actions = { signOut(event); throw redirect(303, '/'); } -}; \ No newline at end of file +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 170aab1..aee3413 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: qrcode: specifier: ^1.5.4 version: 1.5.4 + resend: + specifier: ^6.25.0 + version: 6.25.0 stripe: specifier: ^22.5.0 version: 22.5.0(@types/node@26.0.0) @@ -484,6 +487,9 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -726,6 +732,9 @@ packages: '@typescript-eslint/types': optional: true + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -893,6 +902,9 @@ packages: pocketbase@0.27.0: resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==} + postal-mime@2.7.5: + resolution: {integrity: sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -980,6 +992,15 @@ packages: require-main-filename@2.0.0: 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: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1005,6 +1026,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1396,6 +1420,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@stripe/stripe-js@9.13.0': {} @@ -1609,6 +1635,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + fast-sha256@1.3.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1720,6 +1748,8 @@ snapshots: pocketbase@0.27.0: {} + postal-mime@2.7.5: {} + postcss@8.5.15: dependencies: nanoid: 3.3.15 @@ -1751,6 +1781,11 @@ snapshots: require-main-filename@2.0.0: {} + resend@6.25.0: + dependencies: + postal-mime: 2.7.5 + standardwebhooks: 1.0.0 + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -1807,6 +1842,11 @@ snapshots: 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: dependencies: emoji-regex: 8.0.0