final flows including emails for passwords
This commit is contained in:
@@ -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>
|
||||
`
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+575
-493
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user