243 lines
7.6 KiB
TypeScript
243 lines
7.6 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
|
import { MEMBER_SECRET } from '$app/env/private';
|
|
import { createSuperClient, createPbClient } from '$lib/server/pocketbase';
|
|
import { famUsername, handle } from '@shared/slugify';
|
|
|
|
const OTP_TTL_MS = 20 * 60 * 1000; // 20 minutes
|
|
|
|
// A child member's PB password is derived from (secret + famSlug + handle), so
|
|
// the server can authWithPassword at join time. The user never sees or types it;
|
|
// OTP is the access gate.
|
|
export function derivePassword(famSlug: string, handleName: string) {
|
|
return `${String(MEMBER_SECRET)}${famSlug}${handleName}`;
|
|
}
|
|
|
|
function generateOtp() {
|
|
const n = randomBytes(3).readUIntBE(0, 3) % 1_000_000;
|
|
return n.toString().padStart(6, '0');
|
|
}
|
|
|
|
// Create (or fetch existing) a child users record. The PB username is the
|
|
// composite `{famSlug}:{handle}` (globally unique auth identity); `name` keeps
|
|
// the raw display name. The password is derived from (secret + famSlug + handle)
|
|
// so the server can authWithPassword at join time.
|
|
export async function createChild(opts: {
|
|
famId: string;
|
|
famSlug: string;
|
|
name: string;
|
|
colour?: string;
|
|
}) {
|
|
const pb = await createSuperClient();
|
|
const handleName = handle(opts.name);
|
|
const username = famUsername(opts.famSlug, handleName);
|
|
const password = derivePassword(opts.famSlug, handleName);
|
|
|
|
let user = await pb
|
|
.collection('users')
|
|
.getFirstListItem(`famId='${opts.famId}' && username='${username}'`)
|
|
.catch(() => null);
|
|
|
|
if (!user) {
|
|
user = await pb.collection('users').create({
|
|
username,
|
|
name: opts.name,
|
|
color: opts.colour || '#6366f1',
|
|
password,
|
|
passwordConfirm: password,
|
|
famId: opts.famId,
|
|
role: 'child'
|
|
});
|
|
} else if (!user.name || !user.color) {
|
|
user = await pb.collection('users').update(user.id, {
|
|
name: user.name || opts.name,
|
|
color: user.color || opts.colour || '#6366f1'
|
|
});
|
|
}
|
|
|
|
if (!user) throw new Error('Failed to create child');
|
|
|
|
// Auto-create the child's pocket-money droplet. The amount (rewardValue)
|
|
// starts empty — the parent sets it on the Bonuses page. This keeps pocket
|
|
// money a first-class, always-present feature without a separate field.
|
|
const pm = await pb
|
|
.collection('bonus_configs')
|
|
.getFirstListItem(`famId='${opts.famId}' && memberId='${user.id}' && isPocketMoney=true`)
|
|
.catch(() => null);
|
|
if (!pm) {
|
|
await pb
|
|
.collection('bonus_configs')
|
|
.create({
|
|
famId: opts.famId,
|
|
name: 'Pocket Money',
|
|
target: 'individual',
|
|
type: 'threshold',
|
|
thresholdType: 'percent',
|
|
occurrence: 'recurring',
|
|
rewardType: 'cash',
|
|
rewardValue: '',
|
|
criteriaValue: 50,
|
|
memberId: user.id,
|
|
period: 'weekly',
|
|
status: 'active',
|
|
isPocketMoney: true
|
|
})
|
|
.catch(() => null);
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
// Admin grants access to a child: creates the users auth record (or re-issues
|
|
// OTP if they already exist) + upserts their otp. Returns the OTP and
|
|
// shareable join link (using the whitespace-free handle) for QR display.
|
|
export async function issueAccess(opts: {
|
|
famId: string;
|
|
famSlug: string;
|
|
name: string;
|
|
colour?: string;
|
|
}) {
|
|
const { famId, famSlug, name } = opts;
|
|
const username = handle(name);
|
|
const otp = generateOtp();
|
|
const updatedAt = new Date().toISOString();
|
|
|
|
const user = await createChild({ famId, famSlug, name, colour: opts.colour });
|
|
|
|
const pb = await createSuperClient();
|
|
let config = await pb
|
|
.collection('otp')
|
|
.getFirstListItem(`famId='${famId}' && userId='${user.id}'`)
|
|
.catch(() => null);
|
|
|
|
if (config) {
|
|
await pb.collection('otp').update(config.id, { otp, updatedAt });
|
|
} else {
|
|
await pb.collection('otp').create({ famId, userId: user.id, otp, updatedAt });
|
|
}
|
|
|
|
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.
|
|
export async function redeemOtp(opts: { famSlug: string; username: string; otp: string }) {
|
|
const { famSlug, username, otp } = opts;
|
|
const handleName = handle(username); // normalize whatever was in the URL
|
|
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 !== 'child') throw new Error('Invalid join link');
|
|
|
|
let 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');
|
|
|
|
const authPb = createPbClient();
|
|
await authPb
|
|
.collection('users')
|
|
.authWithPassword(fullUsername, derivePassword(famSlug, handleName));
|
|
return authPb.authStore.token;
|
|
}
|