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
+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;
}
}