final flows including emails for passwords

This commit is contained in:
JCEEE
2026-09-01 11:58:43 +01:00
parent f491dbfbf1
commit 40d7bf7398
22 changed files with 1977 additions and 859 deletions
@@ -1,17 +1,24 @@
<script lang="ts">
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>
<div class="accordion-item" class:open>
<button
class="accordion-trigger"
onclick={() => (open = !open)}
aria-expanded={open}
>
<span>{title}</span>
<span class="accordion-arrow">{open ? '▾' : '▸'}</span>
<button class="accordion-trigger" onclick={() => (open = !open)} aria-expanded={open}>
<span class="accordion-title">
{#if icon}
<span class="accordion-icon">{@render icon()}</span>
{/if}
<span>{title}</span>
</span>
<span class="accordion-arrow" class:rotated={open}><ChevronDown /></span>
</button>
{#if open}
<div class="accordion-body">
@@ -21,16 +28,58 @@
</div>
<style>
.accordion-item { border-bottom: 1px solid #f3f4f6; }
.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-item {
border-bottom: 1px solid #f3f4f6;
}
.accordion-trigger:hover { background: #f3f4f6; }
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
.accordion-body { padding: 1rem; }
</style>
.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;
}
</style>
+84 -17
View File
@@ -1,6 +1,13 @@
<script lang="ts">
let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
let {
variant = 'primary',
size = 'md',
href,
onclick,
children,
...rest
}: {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'purple';
size?: 'sm' | 'md' | 'lg';
href?: string;
onclick?: () => void;
@@ -10,7 +17,7 @@
</script>
{#if href}
<a href={href} class="btn btn-{variant} btn-{size}" {...rest}>
<a {href} class="btn btn-{variant} btn-{size}" {...rest}>
{@render children?.()}
</a>
{: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;
}
</style>
+1 -1
View File
@@ -80,7 +80,7 @@
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: 100dvh;
width: 220px;
background: #1e1b4b;
color: #e0e7ff;
+5 -1
View File
@@ -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;
}
+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>
`
});
}
+89 -1
View File
@@ -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;
}
}
File diff suppressed because it is too large Load Diff