migrate auth v2 code

This commit is contained in:
JCEEE
2026-08-16 10:11:39 +01:00
parent 3dd94b8a7c
commit c73ced7894
45 changed files with 1965 additions and 1519 deletions
+4 -12
View File
@@ -1,20 +1,12 @@
import { PocketBase } from 'pocketbase';
import type { Session } from './lib/types';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { SessionUser } from './lib/server/types';
declare global {
namespace App {
// interface Error {}
interface Locals {
pb: PocketBase;
session?: Session | null;
user: SessionUser | null;
pbToken: string | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
export {};
+4 -1
View File
@@ -15,5 +15,8 @@ export const variables = defineEnvVars({
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') }
PB_PASSWORD: { public: false, schema: withDefault('debug123') },
// Server-only secret used to derive a child member's PB password from
// (famSlug + username). Never expose client-side. OTP is the access gate.
MEMBER_SECRET: { public: false, schema: withDefault('famchamp-member-secret') }
});
+36 -7
View File
@@ -1,16 +1,45 @@
import type { Handle } from '@sveltejs/kit';
const COOKIE_NAME = 'session';
import { createPbClient } from '$lib/server/pocketbase';
import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session';
import type { SessionUser } from '$lib/server/types';
import { handleOf } from '@shared/slugify';
export const handle: Handle = async ({ event, resolve }) => {
const raw = event.cookies.get(COOKIE_NAME);
if (raw) {
event.locals.user = null;
event.locals.pbToken = null;
const token = event.cookies.get(SESSION_COOKIE);
if (token) {
const pb = createPbClient(token);
try {
event.locals.session = JSON.parse(raw);
// authRefresh() does two jobs in one call:
// 1. Verifies the token (PB 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 — the only way to get
// name/role/famId, since PB 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 || record.username || '',
username: handleOf(record.username || ''),
role: record.role || 'parent',
famId: record.famId,
color: record.color || ''
} satisfies SessionUser;
event.locals.pbToken = freshToken;
if (freshToken !== token) {
setSessionCookie(event.cookies, freshToken);
}
} catch {
event.cookies.delete(COOKIE_NAME, { path: '/' });
// Expired, malformed, or revoked — drop it and treat as logged out.
clearSessionCookie(event.cookies);
}
}
return resolve(event);
};
};
+2 -3
View File
@@ -6,12 +6,11 @@ async function memberFetch<T = unknown>(
method: string,
path: string,
token: string,
famId: string,
_famId?: string,
body?: unknown,
): Promise<T> {
const headers: Record<string, string> = {
'x-device-token': token,
'x-device-famid': famId,
Authorization: `Bearer ${token}`,
};
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE_URL}${path}`, {
+22 -2
View File
@@ -10,7 +10,8 @@
<div
class="card"
style="grid-column: span {cols}; {accent ? `--card-accent: ${accent}` : ''}"
data-cols={cols}
style="--card-cols: {cols}; {accent ? `--card-accent: ${accent}` : ''}"
class:has-accent={!!accent}
class:scroll-x={scrollX}
>
@@ -26,10 +27,14 @@
<style>
.card {
grid-column: span var(--card-cols, 1);
/* Container so card contents can react to how wide the card actually is */
container-type: inline-size;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
overflow: hidden;
min-width: 0;
}
.card.scroll-x {
overflow: visible;
@@ -37,6 +42,21 @@
.card.has-accent {
border-top: 3px solid var(--card-accent, #6366f1);
}
/* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */
@media (min-width: 640px) and (max-width: 1023px) {
.card[data-cols='3'],
.card[data-cols='4'],
.card[data-cols='5'],
.card[data-cols='6'] {
grid-column: span 2;
}
}
/* Mobile (1-col grid): every card is a full row */
@media (max-width: 639px) {
.card {
grid-column: span 1;
}
}
.card-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6;
@@ -54,4 +74,4 @@
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
</style>
</style>
+15 -2
View File
@@ -2,7 +2,7 @@
let { cols = 3, children }: { cols?: number; children?: any } = $props();
</script>
<div class="card-grid" style="grid-template-columns:repeat({cols}, 1fr)">
<div class="card-grid" style="--grid-cols: {cols}">
{@render children?.()}
</div>
@@ -10,5 +10,18 @@
.card-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
}
</style>
/* Tablet: settle to 2 columns */
@media (min-width: 640px) and (max-width: 1023px) {
.card-grid {
--grid-cols: 2;
}
}
/* Mobile: single column */
@media (max-width: 639px) {
.card-grid {
--grid-cols: 1;
}
}
</style>
+3 -4
View File
@@ -4,10 +4,9 @@ const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
export const pb = new PocketBase(PB_ENDPOINT);
pb.autoCancellation(false);
export function initPbFromCookie() {
const match = document.cookie.match(/(?:^|;\s*)pb_token=([^;]*)/);
if (match) {
pb.authStore.save(match[1], null);
export function initPb(token: string) {
if (token) {
pb.authStore.save(token, null);
return true;
}
return false;
+8 -66
View File
@@ -1,9 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { PROXY_URL } from '$app/env/public';
import { pbAdmin } from '$lib/server/pocketbase';
export function getSession(event: RequestEvent) {
return event.locals.session;
return event.locals.user;
}
export function requireAuth(event: RequestEvent) {
@@ -14,72 +14,14 @@ export function requireAuth(event: RequestEvent) {
return session;
}
export async function signup(email: string, password: string, famName: string, parentName?: string) {
const res = await fetch(`${PROXY_URL}/api/admin/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, famName, parentName }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Signup failed');
return data;
}
export async function login(email: string, password: string) {
console.log(email);
const res = await fetch(`${PROXY_URL}/api/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
return data;
}
export async function joinMember(inviteCode: string, name: string, deviceToken: string) {
const res = await fetch(`${PROXY_URL}/api/members/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode, name, deviceToken }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Join failed');
return data;
}
export function setSessionCookie(event: RequestEvent, session: { famId: string; userId: string; famSlug: string }) {
event.cookies.set('session', JSON.stringify(session), {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 30,
secure: false,
});
}
export function setDeviceTokenCookie(event: RequestEvent, token: string) {
event.cookies.set('device_token', token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
secure: false,
});
}
export function setPbTokenCookie(event: RequestEvent, token: string) {
event.cookies.set('pb_token', token, {
httpOnly: false,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24,
secure: false,
});
}
export function clearSession(event: RequestEvent) {
event.cookies.delete('session', { path: '/' });
event.cookies.delete('pb_token', { path: '/' });
event.cookies.delete('device_token', { path: '/' });
}
// Resolve the fam slug + admin display name used for the post-login redirect.
export async function getFamContext(famId: string) {
const fam = await pbAdmin.getOne('fams', famId).catch(() => null);
return { famSlug: fam?.slug || famId, famName: fam?.name || '' };
}
+4 -7
View File
@@ -2,11 +2,11 @@ import type { RequestEvent } from '@sveltejs/kit';
import { PROXY_URL } from '$app/env/public';
function sessionHeaders(event: RequestEvent): Record<string, string> {
const s = event.locals.session;
if (!s) return {};
const u = event.locals.user;
if (!u) return {};
return {
'x-session-famid': s.famId,
'x-session-userid': s.userId,
'x-session-famid': u.famId,
'x-session-userid': u.id,
'Content-Type': 'application/json'
};
}
@@ -93,9 +93,6 @@ export const hono = {
async verify(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event));
},
async regenInvite(event: RequestEvent, famId: string) {
return request('POST', `/api/admin/${famId}/regen-invite`, undefined, sessionHeaders(event));
},
async weeklySummary(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event));
},
-57
View File
@@ -1,57 +0,0 @@
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
import { SERVER_IP } from '$app/env/public';
export const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
let token: string | null = null;
let tokenExpiry = 0;
async function ensureToken(): Promise<string> {
if (token && Date.now() < tokenExpiry) 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 admin auth failed: ${JSON.stringify(data)}`);
token = data.token;
tokenExpiry = Date.now() + 23 * 60 * 60 * 1000;
return token!;
}
export const pbAdmin = {
async getList(collection: string, filter = '') {
const t = await ensureToken();
const params = new URLSearchParams();
if (filter) params.set('filter', filter);
params.set('perPage', '200');
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records?${params}`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(data)}`);
return data.items || [];
},
async getOne(collection: string, id: string) {
const t = await ensureToken();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`);
return data;
},
async update(collection: string, id: string, data: Record<string, unknown>) {
const t = await ensureToken();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(data),
});
const result = await res.json();
if (!res.ok) throw new Error(`PB update ${collection}/${id}: ${JSON.stringify(result)}`);
return result;
},
};
+15 -7
View File
@@ -7,8 +7,18 @@ interface ChatInit {
actorType: 'admin' | 'member';
actorName: string;
actorColor: string;
deviceToken?: string;
memberId?: string;
pbToken?: string;
}
// Client id for optimistic chat messages. `crypto.randomUUID()` requires a
// secure context (HTTPS/localhost) — over plain HTTP on a LAN it's undefined,
// so fall back to a time+random string that's still unique enough per session.
function genClientId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
class ChatStore {
@@ -23,8 +33,7 @@ class ChatStore {
actorType = $state<'admin' | 'member'>('member');
actorName = $state('');
actorColor = $state('');
deviceToken = $state('');
memberId = $state('');
pbToken = $state('');
private unsubs: (() => void)[] = [];
private destroyed = false;
@@ -46,8 +55,7 @@ class ChatStore {
this.actorType = opts.actorType;
this.actorName = opts.actorName;
this.actorColor = opts.actorColor;
this.deviceToken = opts.deviceToken || '';
this.memberId = opts.memberId || '';
this.pbToken = opts.pbToken || '';
if (this.initialized && this.famId === opts.famId) return;
if (this.initPromise) {
@@ -185,7 +193,7 @@ class ChatStore {
async send(content: string) {
const text = content.trim();
if (!text || !this.famId) return;
const clientId = crypto.randomUUID();
const clientId = genClientId();
const temp: ChatMessage = {
id: 'temp-' + clientId,
famId: this.famId,
+15 -16
View File
@@ -13,7 +13,7 @@ import type {
} from '$lib/types';
type CollectionName =
| 'members'
| 'users'
| 'chore_templates'
| 'assigned_chores'
| 'completions'
@@ -96,9 +96,9 @@ class FamStore {
rewardsRes,
seasonsRes
] = await Promise.all([
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<
Member[]
>,
pb.collection('users').getFullList({
filter: `famId = '${famId}' && role = 'child'`
}) as Promise<Member[]>,
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
ChoreTemplate[]
>,
@@ -144,19 +144,18 @@ class FamStore {
}
private async subscribe() {
const subs: { collection: CollectionName; filter?: string }[] = [
{ collection: 'members', filter: this.famId },
{ collection: 'chore_templates', filter: this.famId },
{ collection: 'assigned_chores', filter: this.famId },
{ collection: 'completions', filter: this.famId },
{ collection: 'bonus_configs', filter: this.famId },
{ collection: 'bonus_templates', filter: this.famId },
{ collection: 'rewards', filter: this.famId },
{ collection: 'seasons', filter: this.famId }
const subs: { collection: CollectionName; filter: string }[] = [
{ collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` },
{ collection: 'chore_templates', filter: `famId = '${this.famId}'` },
{ collection: 'assigned_chores', filter: `famId = '${this.famId}'` },
{ collection: 'completions', filter: `famId = '${this.famId}'` },
{ collection: 'bonus_configs', filter: `famId = '${this.famId}'` },
{ collection: 'bonus_templates', filter: `famId = '${this.famId}'` },
{ collection: 'rewards', filter: `famId = '${this.famId}'` },
{ collection: 'seasons', filter: `famId = '${this.famId}'` }
];
const promises = subs.map(({ collection, filter }) => {
const filterStr = filter ? `famId = '${filter}'` : '';
return pb
.collection(collection)
.subscribe(
@@ -165,7 +164,7 @@ class FamStore {
if (this.destroyed) return;
this.handleRealtime(collection, data.action, data.record);
},
{ filter: filterStr || undefined }
{ filter: filter || undefined }
)
.then((unsub) => {
if (this.destroyed) {
@@ -194,7 +193,7 @@ class FamStore {
};
switch (collection) {
case 'members':
case 'users':
this.members = apply(this.members);
break;
case 'chore_templates':
+2 -3
View File
@@ -40,7 +40,6 @@ export interface Fam {
id: string;
name: string;
slug: string;
inviteCode: string;
stripeCustomerId?: string;
featureFlags: Record<string, boolean>;
payday?: number;
@@ -54,10 +53,10 @@ export interface Fam {
export interface Member {
id: string;
famId: string;
username: string;
name: string;
color: string;
deviceToken: string;
deviceTokenHint: string;
role: 'child';
created: string;
updated: string;
}
+15 -62
View File
@@ -1,25 +1,5 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Footer from '$lib/components/Footer.svelte';
import type { Action, SubmitFunction } from './$types';
let email = $state('');
let password = $state('');
let famName = $state('');
let parentName = $state('');
let error = $state('');
let submitting = $state(false);
const submit: SubmitFunction = () => {
error = '';
submitting = true;
return async ({ result, update }) => {
if (result.type === 'failure') {
error = (result.data as any)?.error || 'Something went wrong. Please try again.';
}
submitting = false;
};
};
</script>
<svelte:head>
@@ -52,37 +32,7 @@
<div class="signup-card">
<h2>Start your family</h2>
<p class="card-sub">Free to get going. Takes about a minute.</p>
<form method="POST" action="/signup" use:enhance={submit}>
{#if error}
<p class="form-error">{error}</p>
{/if}
<label>
Family name
<input name="famName" bind:value={famName} placeholder="The Smiths" required />
</label>
<label>
Your name
<input name="parentName" bind:value={parentName} placeholder="Mum / Dad" required />
</label>
<label>
Email
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
</label>
<label>
Password
<input
type="password"
name="password"
bind:value={password}
placeholder="8+ characters"
minlength={8}
required
/>
</label>
<button class="submit" type="submit" disabled={submitting}>
{submitting ? 'Creating your family…' : 'Create my family'}
</button>
</form>
<a class="submit" href="/signup">Create my family</a>
<p class="card-alt">
Already have a family? <a href="/login">Log in</a>
</p>
@@ -247,17 +197,20 @@
font-size: 0.85rem;
margin: 0;
}
.submit {
margin-top: 0.25rem;
background: #4338ca;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.submit {
display: block;
text-align: center;
margin-top: 0.25rem;
background: #4338ca;
color: #fff;
text-decoration: none;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.submit:hover { background: #3730a3; }
.submit:disabled { opacity: 0.6; cursor: not-allowed; }
.card-alt { margin: 1rem 0 0; font-size: 0.85rem; color: #6b7280; text-align: center; }
+28 -33
View File
@@ -1,5 +1,5 @@
import { PROXY_URL } from '$app/env/public';
import { pbAdmin } from '$lib/server/pb-admin';
import { pbAdmin } from '$lib/server/pocketbase';
const HONO_URL = PROXY_URL;
@@ -20,19 +20,17 @@ async function paydayCheck(famId: string, headers: Record<string, string>) {
async function resolveChatIdentity(
api: 'admin' | 'member',
opts: {
session?: { famId: string; userId: string };
deviceToken?: string;
famId?: string;
session?: { famId: string; id: string };
pbToken?: string;
}
) {
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (api === 'admin' && opts.session) {
headers['x-session-famid'] = opts.session.famId;
headers['x-session-userid'] = opts.session.userId;
} else if (api === 'member' && opts.deviceToken && opts.famId) {
headers['x-device-token'] = opts.deviceToken;
headers['x-device-famid'] = opts.famId;
headers['x-session-userid'] = opts.session.id;
} else if (api === 'member' && opts.pbToken) {
headers['Authorization'] = `Bearer ${opts.pbToken}`;
} else {
return null;
}
@@ -45,46 +43,43 @@ async function resolveChatIdentity(
}
export async function load(event) {
const session = event.locals.session || null;
const isParent = session !== null;
const deviceToken = event.cookies.get('device_token') || '';
const session = event.locals.user;
const role = session?.role || 'child';
const isParent = role === 'parent';
const pbToken = event.cookies.get('pb_token') || '';
let famId = '';
let chat: { famId: string; actor: any } | null = null;
if (isParent) {
if (session && isParent) {
famId = session.famId;
await paydayCheck(famId, {
'x-session-famid': session.famId,
'x-session-userid': session.userId
'x-session-userid': session.id
});
chat = await resolveChatIdentity('admin', { session });
} else if (deviceToken) {
try {
const res = await fetch(`${HONO_URL}/api/members/seasons`, {
headers: { 'x-device-token': deviceToken }
});
if (res.ok) {
const body = await res.json();
famId = body.famId || '';
}
} catch {}
if (famId) {
await paydayCheck(famId, {
'x-device-token': deviceToken,
'x-device-famid': famId
});
chat = await resolveChatIdentity('member', { deviceToken, famId });
}
} else if (role === 'child' && pbToken && session) {
famId = session.famId;
await paydayCheck(famId, { Authorization: `Bearer ${pbToken}` });
chat = await resolveChatIdentity('member', { pbToken });
}
return {
session,
session: session
? {
famId: session.famId,
userId: session.id,
famSlug: event.params.fam,
memberName: session.name,
memberColor: session.color || '',
role: session.role
}
: null,
isParent,
role: session?.role || 'child',
role,
famId,
chat,
deviceToken,
pbToken,
// fams is superadmin-only (non-realtime). Fetched server-side for both roles.
fam: famId
? await pbAdmin.getOne('fams', famId).catch(() => null)
+3 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { initPbFromCookie } from '$lib/pocketbase';
import { initPb } from '$lib/pocketbase';
import { famStore } from '$lib/stores/fam.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
@@ -45,7 +45,7 @@
});
onMount(() => {
initPbFromCookie();
initPb(page.data.pbToken || '');
if (page.data.famId) famStore.init(page.data.famId, page.data.fam);
const chat = page.data.chat;
if (chat?.famId && chat?.actor) {
@@ -55,7 +55,7 @@
actorType: chat.actor.type,
actorName: chat.actor.name,
actorColor: chat.actor.color || '#6366f1',
deviceToken: page.data.deviceToken || ''
pbToken: page.data.pbToken || ''
});
}
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { hono } from '$lib/server/hono';
export async function load(event) {
const session = event.locals.session;
const session = event.locals.user;
if (!session) return {};
const famId = session.famId;
+2 -1
View File
@@ -4,6 +4,7 @@
import { Chart, registerables } from 'chart.js';
import { ViewHeader, CardGrid, Card } from '$lib/components';
import { formatDDMMYY } from '$lib/format';
import { handleOf } from '@shared/slugify';
Chart.register(...registerables);
@@ -138,7 +139,7 @@
<span class="dot" style="background:{s.memberColor}"></span>
<span class="member-name">{s.memberName}</span>
{#if m}
<a href="/{famSlug}/{m.name}" class="kanban-link">Kanban →</a>
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
{/if}
</div>
<div class="donut-wrap">
@@ -5,15 +5,17 @@ import { PROXY_URL } from '$app/env/public';
const HONO_URL = PROXY_URL;
export async function load(event) {
const session = event.locals.session;
const session = event.locals.user;
// Parent (session auth) → admin overview
if (session) {
// Parent (role=parent, session auth) → admin overview.
// Branch on role, NOT presence: a child who OTP-logged-in also has a
// `users` session (locals.user) and must not hit the parent path.
if (session && session.role === 'parent') {
const famId = session.famId;
const famSlug = event.params.fam;
const username = event.params.username;
if (session.memberName && session.memberName !== username) {
throw redirect(303, `/${famSlug}/${session.memberName}`);
if (session.name && session.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}`);
}
const [
members,
@@ -51,70 +53,55 @@ export async function load(event) {
};
}
// Child (device token) → kanban
const deviceToken =
event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
// Child (users auth, role=child) → kanban
const famSlug = event.params.fam;
const username = event.params.username;
if (!deviceToken) {
return {
role: 'child',
token: '',
memberId: '',
verified: false,
famId: '',
templates: [],
assigned: [],
completions: [],
rewards: [],
bonusConfigs: [],
tallies: {}
};
// A child should always land on their own kanban route.
if (session?.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}`);
}
const pbToken = event.cookies.get('pb_token') || '';
const famId = session?.famId || '';
const childId = session?.id || '';
const empty = {
role: 'child' as const,
token: pbToken,
memberId: childId,
famId,
verified: false,
memberName: session?.name || '',
memberColor: session?.color || '',
templates: [] as never[],
assigned: [] as never[],
completions: [] as never[],
rewards: [] as never[],
bonusConfigs: [] as never[],
tallies: {} as Record<string, unknown>,
payday: 1,
paydayTime: '18:00',
timezone: 'auto'
};
if (!pbToken || !famId) return empty;
try {
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceToken, famSlug })
headers: { Authorization: `Bearer ${pbToken}` }
});
const data = await res.json();
if (!res.ok || data.name !== username) {
return {
role: 'child',
token: deviceToken,
memberId: '',
verified: false,
famId: '',
memberName: '',
memberColor: '',
templates: [],
assigned: [],
completions: [],
rewards: [],
bonusConfigs: [],
tallies: {}
};
}
let chores: any = {};
try {
const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, {
method: 'POST',
headers: { 'x-device-token': deviceToken, 'x-device-famid': data.famId }
});
chores = await choresRes.json();
} catch {}
if (!choresRes.ok) return empty;
const chores = await choresRes.json();
return {
role: 'child',
token: deviceToken,
memberId: data.memberId,
famId: data.famId,
token: pbToken,
memberId: childId,
famId,
verified: true,
memberName: data.name,
memberColor: data.color,
memberName: session?.name || '',
memberColor: session?.color || '',
templates: chores.templates || [],
assigned: chores.assigned || [],
completions: chores.completions || [],
@@ -127,29 +114,14 @@ export async function load(event) {
simulateEow: !!chores.simulateEow
};
} catch {
return {
role: 'child',
token: deviceToken,
memberId: '',
verified: false,
famId: '',
templates: [],
assigned: [],
completions: [],
rewards: [],
bonusConfigs: [],
tallies: {},
payday: 1,
paydayTime: '18:00',
timezone: 'auto'
};
return empty;
}
}
export const actions = {
setEow: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const on = fd.get('on') === 'true';
try {
@@ -161,8 +133,8 @@ export const actions = {
},
previewEow: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
try {
const preview = await hono.admin.eowPreview(event, famId);
await hono.admin.updateSettings(event, famId, { simulateEow: true });
@@ -173,8 +145,8 @@ export const actions = {
},
claim: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const rewardId = fd.get('id') as string;
try {
@@ -186,8 +158,8 @@ export const actions = {
},
issueAll: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const memberId = fd.get('memberId') as string;
try {
@@ -199,8 +171,8 @@ export const actions = {
},
revoke: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const completionId = fd.get('id') as string;
try {
@@ -212,8 +184,8 @@ export const actions = {
},
trigger: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const configId = fd.get('configId') as string;
const memberId = fd.get('memberId') as string;
@@ -7,6 +7,7 @@
import { formatDDMMYY, formatHumanDate } from '$lib/format';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import { handleOf } from '@shared/slugify';
import {
weekStart as tzWeekStart,
addDaysStr,
@@ -149,7 +150,7 @@
// ─── Child View (kanban) ───
let memberId = $state(data.memberId || '');
let deviceToken = $state(data.token || '');
let pbToken = $state(data.token || '');
let famId = $state(data.famId || '');
let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1');
@@ -209,9 +210,9 @@
$effect(() => {
if (role !== 'child' || !isPaydayToday) return;
if (secondsLeft > 0 || eowFired) return;
if (!deviceToken || !famId) return;
if (!pbToken || !famId) return;
eowFired = true;
memberApi.payday(deviceToken, famId).catch(() => {});
memberApi.payday(pbToken, famId).catch(() => {});
});
function paydayWeekStart(): string {
@@ -476,26 +477,13 @@
onMount(async () => {
if (role === 'parent') return;
if (!deviceToken) {
deviceToken = localStorage.getItem('deviceToken') || '';
} else {
localStorage.setItem('deviceToken', deviceToken);
}
if (!deviceToken) {
error = 'No device token found. Use the link from your invite.';
loading = false;
return;
}
if (data.verified && data.famId && data.memberId) {
memberId = data.memberId;
famId = data.famId;
loading = false;
} else {
error = 'Invalid device token. Use the link from your invite.';
error = 'Please open your invite link to get access.';
loading = false;
return;
}
});
@@ -558,7 +546,7 @@
}
try {
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild);
await memberApi.toggleCompletion(pbToken, famId, chore.id, todayChild);
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete');
@@ -644,7 +632,7 @@
<div class="card-header">
<span class="dot" style="background:{m.color}"></span>
<span class="member-name">{m.name}</span>
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
<a href="/{famSlug}/{handleOf(m.username)}" class="link">Kanban</a>
</div>
<div class="stats">
<span>Points: {s?.pointsEarned ?? 0}</span>
@@ -976,8 +964,7 @@
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-device-token': deviceToken,
'x-device-famid': famId
Authorization: `Bearer ${pbToken}`
},
body: JSON.stringify({ name: nameInput })
});
@@ -1039,8 +1026,7 @@
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-device-token': deviceToken,
'x-device-famid': famId
Authorization: `Bearer ${pbToken}`
},
body: JSON.stringify({ color })
});
@@ -1267,7 +1253,7 @@
class="wr-cta"
onclick={async () => {
try {
await memberApi.claimReward(deviceToken, famId, r.id);
await memberApi.claimReward(pbToken, famId, r.id);
} catch (e) {
claimError = e instanceof Error ? e.message : 'Claim failed';
}
@@ -2,8 +2,8 @@ import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const [configs, templates, members, progress, rewards] = await Promise.all([
hono.admin.bonusConfigs(event, famId),
hono.admin.list(event, 'bonus-templates', famId),
@@ -16,8 +16,8 @@ export async function load(event) {
export const actions = {
createTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const data: Record<string, unknown> = {
name: fd.get('name'),
@@ -42,8 +42,8 @@ export const actions = {
},
updateTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const data: Record<string, unknown> = {};
@@ -75,8 +75,8 @@ export const actions = {
},
deleteTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
@@ -88,8 +88,8 @@ export const actions = {
},
createConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const startMode = fd.get('startMode') as string;
const status = startMode === 'disabled' ? 'disabled' : 'active';
@@ -120,8 +120,8 @@ export const actions = {
},
updateConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const data: Record<string, unknown> = {};
@@ -155,8 +155,8 @@ export const actions = {
},
deleteConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
@@ -168,8 +168,8 @@ export const actions = {
},
createFromTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const startMode = fd.get('startMode') as string;
const status = startMode === 'disabled' ? 'disabled' : 'active';
@@ -198,8 +198,8 @@ export const actions = {
},
assignConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const target = fd.get('target') as string;
@@ -216,8 +216,8 @@ export const actions = {
},
completeConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
@@ -229,8 +229,8 @@ export const actions = {
},
destroyConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
@@ -242,8 +242,8 @@ export const actions = {
},
toggleConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const currentStatus = fd.get('currentStatus') as string;
@@ -259,8 +259,8 @@ export const actions = {
},
evaluate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
try {
await hono.admin.evaluateBonusConfig(event, famId);
} catch (e) {
@@ -1,35 +1,45 @@
import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { pbUser } from '$lib/server/pocketbase';
import type { ChoreTemplate, Member, AssignedChore, Completion, Season } from '$lib/types';
export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const pb = pbUser(event);
const [templates, members, assigned, seasons, completions] = await Promise.all([
hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'assigned-chores', famId),
hono.admin.list(event, 'seasons', famId),
hono.admin.completions(event, famId),
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('users').getFullList({
filter: `famId = '${famId}' && role = 'child'`
}),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
]);
return { templates, members, assigned, seasons, completions };
return {
templates: templates as unknown as ChoreTemplate[],
members: members as unknown as Member[],
assigned: assigned as unknown as AssignedChore[],
seasons: seasons as unknown as Season[],
completions: completions as unknown as Completion[]
};
}
export const actions = {
createTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const data = {
name: fd.get('name'),
defaultFrequency: fd.get('defaultFrequency'),
defaultType: fd.get('defaultType'),
defaultValue: parseFloat(fd.get('defaultValue') as string) || 0,
defaultValue: parseFloat(fd.get('defaultValue') as string) || 0
};
if (!data.name || !data.defaultFrequency || !data.defaultType) {
return fail(400, { error: 'Name, frequency, and type are required' });
}
try {
const record = await hono.admin.create(event, 'chore-templates', famId, data);
const record = await pbUser(event).collection('chore_templates').create({ famId, ...data });
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
@@ -37,8 +47,8 @@ export const actions = {
},
updateTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const defaultFrequency = fd.get('defaultFrequency') as string;
@@ -48,20 +58,25 @@ export const actions = {
name: fd.get('name'),
defaultFrequency,
defaultType,
defaultValue,
defaultValue
};
try {
const record = await hono.admin.update(event, 'chore-templates', famId, id, data);
const allAssigned = await hono.admin.list(event, 'assigned-chores', famId);
const pb = pbUser(event);
const record = await pb.collection('chore_templates').update(id, data);
const allAssigned = await pb
.collection('assigned_chores')
.getFullList({ filter: `famId = '${famId}'` });
if (Array.isArray(allAssigned)) {
const toUpdate = allAssigned.filter((a: any) => a.templateId === id);
await Promise.all(toUpdate.map((a: any) =>
hono.admin.update(event, 'assigned-chores', famId, a.id, {
frequency: defaultFrequency,
type: defaultType,
value: defaultValue,
})
));
await Promise.all(
toUpdate.map((a: any) =>
pb.collection('assigned_chores').update(a.id, {
frequency: defaultFrequency,
type: defaultType,
value: defaultValue
})
)
);
}
return { record };
} catch (e) {
@@ -70,21 +85,21 @@ export const actions = {
},
deleteTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
const record = await hono.admin.remove(event, 'chore-templates', famId, id);
return { record };
await pbUser(event).collection('chore_templates').delete(id);
return {};
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
}
},
updateAssignedChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const isTodo = fd.get('isTodo') === '1';
@@ -116,7 +131,7 @@ export const actions = {
}
}
try {
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
const record = await pbUser(event).collection('assigned_chores').update(id, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' });
@@ -124,8 +139,8 @@ export const actions = {
},
createTodo: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const memberId = fd.get('memberId') as string;
const name = fd.get('name') as string;
@@ -167,15 +182,14 @@ export const actions = {
customName: name,
isTodo: true,
startDate,
completeBy,
completeBy
};
try {
const record = await hono.admin.create(event, 'assigned-chores', famId, data);
const record = await pbUser(event).collection('assigned_chores').create({ famId, ...data });
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create todo' });
}
},
};
}
};
@@ -2,8 +2,8 @@ import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const [rewards, members, assigned, templates, completions] = await Promise.all([
hono.admin.rewards(event, famId),
hono.admin.list(event, 'members', famId),
@@ -16,8 +16,8 @@ export async function load(event) {
export const actions = {
claim: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const fd = await event.request.formData();
const rewardId = fd.get('id') as string;
try {
@@ -5,15 +5,15 @@ import { PROXY_URL } from '$app/env/public';
const HONO_URL = PROXY_URL;
export async function load(event) {
const session = event.locals.session;
const session = event.locals.user;
const famSlug = event.params.fam;
const username = event.params.username;
// Parent (session auth) — profile lives in fam_admins
// Parent (session auth) — profile lives on the users record
if (session) {
const famId = session.famId;
if (session.memberName && session.memberName !== username) {
throw redirect(303, `/${famSlug}/${session.memberName}/preferences`);
if (session.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}/preferences`);
}
try {
const me = await hono.admin.getProfile(event, famId);
@@ -54,7 +54,7 @@ export async function load(event) {
export const actions = {
update: async (event) => {
const session = event.locals.session;
const session = event.locals.user;
const fd = await event.request.formData();
const name = fd.get('name') as string;
const color = fd.get('color') as string;
@@ -1,55 +1,81 @@
import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import type { RequestEvent } from '@sveltejs/kit';
import { pbUser } from '$lib/server/pocketbase';
import { pbAdmin } from '$lib/server/pocketbase';
import { hono } from '$lib/server/hono';
import { issueAccess, createChild } from '$lib/server/member-otp';
import { slugify } from '@shared/slugify';
function famIdOf(event: RequestEvent): string {
if (!event.locals.user) throw redirect(303, '/login');
return event.locals.user.famId;
}
export async function load(event: RequestEvent) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const famId = famIdOf(event);
const pb = pbUser(event);
const [members, fam, seasons] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.fam(event, famId),
hono.admin.list(event, 'seasons', famId),
pb.collection('users').getFullList({
filter: `famId = '${famId}' && role = 'child'`
}),
pbAdmin.getOne('fams', famId),
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` })
]);
return { members, fam, seasons };
}
export const actions = {
regenInvite: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
return { newCode: (await hono.admin.regenInvite(event, famId)).inviteCode };
addMember: 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') as string) || '';
const colour = (fd.get('colour') as string) || '#6366f1';
if (!name) return { error: 'Name required' };
try {
await createChild({ famId, famSlug, name, colour });
return { ok: true };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to add member' };
}
},
addMember: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
issueAccess: 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') as string;
if (!name) return { error: 'Name required' };
await hono.admin.create(event, 'members', famId, { name, color: '#6366f1' });
const name = (fd.get('name') || '').toString().trim();
if (!name) return { error: 'Select a child to invite' };
try {
const result = await issueAccess({ famId, famSlug, name });
return { ok: true, ...result };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to issue access' };
}
},
renameFam: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const famId = famIdOf(event);
const fd = await event.request.formData();
const name = fd.get('name') as string;
if (!name) return { error: 'Name required' };
return await hono.admin.renameFam(event, famId, name);
const record = await pbUser(event)
.collection('fams')
.update(famId, { name, slug: slugify(name) });
return { name: record.name, slug: record.slug };
},
deleteMember: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const famId = famIdOf(event);
const fd = await event.request.formData();
const id = fd.get('id') as string;
if (!id) return { error: 'Member ID required' };
await hono.admin.remove(event, 'members', famId, id);
await pbUser(event).collection('users').delete(id);
return { ok: true };
},
updatePayday: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const famId = famIdOf(event);
const fd = await event.request.formData();
const payday = parseInt(fd.get('payday') as string, 10);
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
@@ -59,43 +85,48 @@ export const actions = {
if (timezone && timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) {
return { error: 'Timezone must be an IANA name or auto' };
}
return await hono.admin.updatePayday(event, famId, payday, paydayTime, timezone || undefined);
const patch: Record<string, unknown> = { payday };
if (paydayTime) patch.paydayTime = paydayTime;
if (timezone) patch.timezone = timezone;
const record = await pbUser(event).collection('fams').update(famId, patch);
return { payday: record.payday, paydayTime: record.paydayTime, timezone: record.timezone };
},
createSeason: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const famId = famIdOf(event);
const fd = await event.request.formData();
const name = fd.get('name') as string;
const color = fd.get('color') as string;
if (!name) return { error: 'Name required' };
return await hono.admin.create(event, 'seasons', famId, { name, color: color || '#6366f1', active: true });
return await pbUser(event)
.collection('seasons')
.create({ famId, name, color: color || '#6366f1', active: true });
},
deleteSeason: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const famId = famIdOf(event);
const fd = await event.request.formData();
const id = fd.get('id') as string;
if (!id) return { error: 'Season ID required' };
const assigned = await hono.admin.list(event, 'assigned-chores', famId);
const toDelete = (Array.isArray(assigned) ? assigned : [])
.filter((a: any) => a.seasonIds?.includes(id));
const pb = pbUser(event);
const assigned = await pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` });
const toDelete = (Array.isArray(assigned) ? assigned : []).filter(
(a: any) => a.seasonIds?.includes(id)
);
const deletedIds = toDelete.map((a: any) => a.id);
await Promise.all(toDelete.map((a: any) =>
hono.admin.remove(event, 'assigned-chores', famId, a.id)
));
await hono.admin.remove(event, 'seasons', famId, id);
await Promise.all(
toDelete.map((a: any) => pb.collection('assigned_chores').delete(a.id))
);
await pb.collection('seasons').delete(id);
return { deletedChoreIds: deletedIds };
},
// Compute endpoints — still proxied to Hono.
completeWeek: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const famId = famIdOf(event);
try {
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`);
return { success: true, result };
@@ -105,15 +136,14 @@ export const actions = {
},
generateData: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const famId = famIdOf(event);
const fd = await event.request.formData();
const days = parseInt(fd.get('days') as string || '7', 10);
const days = parseInt((fd.get('days') as string) || '7', 10);
try {
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days });
return { success: true, result };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
}
},
};
}
};
@@ -4,20 +4,20 @@
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { COMMON_TIMEZONES } from '@shared/timezone';
import { handleOf } from '@shared/slugify';
import QRCode from 'qrcode';
let { data } = $props();
// fam is sensitive (inviteCode, stripeCustomerId, featureFlags) — never in the
// fam is sensitive (stripeCustomerId, featureFlags) — never in the
// public famStore stream. It is superadmin-only, fetched server-side by the
// layout load. Writes go through form actions; no live fam subscription.
let fam = $state(data.fam);
let famSlug = $state(page.params.fam);
let childInviteUrl = $derived(`${page.url.origin}/join/${fam?.inviteCode}`);
let addName = $state('');
let rename = $state('');
let selectedMember = $state('');
let inviteChild = $state('');
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
let paydayTime = $state(fam?.paydayTime || '18:00');
let paydayTimes = $state([
@@ -56,12 +56,16 @@
let copied = $state(false);
let parentInviteEmail = $state('');
let selectedInviteUrl = $derived(
selectedMember ? `${page.url.origin}/join/${fam?.inviteCode}/${selectedMember}` : childInviteUrl
);
let members = $state(famStore.initialized ? famStore.members : data.members || []);
let deletingSeason = $state<any>(null);
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
let invitePath = $derived(
issued ? `${issued.joinUrl}?code=${issued.otp}` : ''
);
let inviteUrl = $derived(
issued ? `${page.url.origin}${invitePath}` : ''
);
function copy(url: string) {
navigator.clipboard.writeText(url);
@@ -73,6 +77,15 @@
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);
}
@@ -82,37 +95,60 @@
<CardGrid>
<Card title="Family Name">
<p class="hint">
This is the name shown to your family. The address stays at
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
working.
</p>
<form method="POST" action="?/renameFam" use:enhance>
<input name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<label class="field-label" for="fam-name">Display name</label>
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<Button type="submit" size="sm">Rename</Button>
</form>
{#if fam?.slug}
<p class="hint slug-line">
Family page: <code class="slug-inline">/{fam.slug}</code>
</p>
{/if}
</Card>
<Card title="Members ({members.length})" cols={2}>
<form method="POST" action="?/addMember" use:enhance>
<input name="name" bind:value={addName} placeholder="Member name" required />
<Button type="submit" size="sm">Add</Button>
</form>
<div class="members-grid">
<div class="members-add">
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
<form method="POST" action="?/addMember" use:enhance>
<label class="field-label" for="new-child">New child</label>
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
<Button type="submit" size="sm">Add child</Button>
</form>
</div>
<ul>
{#each members as m}
<li>
<span class="dot" style="background:{m.color}"></span>
{m.name}
{m.deviceToken ? '' : ' (pending join)'}
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button
type="submit"
variant="danger"
size="sm"
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
>
</form>
</li>
{/each}
</ul>
<ul class="members-list">
{#each members as m}
<li>
<span class="member-left">
<span class="member-color" style="background:{m.color}"></span>
<span class="member-info">
<span class="member-name">{m.name}</span>
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
</span>
</span>
<span class="member-actions">
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button
type="submit"
variant="danger"
size="sm"
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
>
</form>
</span>
</li>
{/each}
</ul>
</div>
</Card>
<Card title="Payday" cols={1}>
@@ -166,54 +202,70 @@
</Card>
<Card title="Invite Children" cols={1}>
<p class="code">{fam?.inviteCode || '...'}</p>
<div class="invite-row">
<label>Member:</label>
<select bind:value={selectedMember}>
<option value="">— General link —</option>
<form
method="POST"
action="?/issueAccess"
use:enhance={() => {
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"
>
<label class="field-label" for="invite-child">Child</label>
<select id="invite-child" bind:value={inviteChild} name="name" required>
<option value="">— Select a child —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
</div>
<p class="hint">Re-joining a member will disconnect their old device.</p>
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
</form>
<p class="hint">
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
</p>
<p class="invite-url">{selectedInviteUrl}</p>
<div class="actions">
<Button onclick={() => copy(selectedInviteUrl)} size="sm">
{copied ? 'Copied!' : 'Copy link'}
</Button>
<Button
onclick={async () => {
showQR = !showQR;
if (!showQR) {
qrDataUrl = '';
} else {
await generateQR(selectedInviteUrl);
}
}}
size="sm"
>
{showQR ? 'Hide QR' : 'Show QR'}
</Button>
<form method="POST" action="?/regenInvite" use:enhance class="inline">
<Button type="submit" size="sm">Renew code</Button>
</form>
</div>
{#if showQR && qrDataUrl}
<img src={qrDataUrl} alt="QR Code" class="qr" />
{#if issued?.otp}
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
<p class="text-xs text-slate-500">
Code for {issued.name} (valid 20 min):
</p>
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
{issued.otp}
</p>
<p class="invite-url">{invitePath}</p>
<div class="actions justify-center">
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>
{copied ? 'Copied!' : 'Copy URL'}
</Button>
<Button variant="secondary" size="sm" onclick={toggleQR}>
{showQR ? 'Hide QR' : 'Show QR'}
</Button>
</div>
{#if showQR && qrDataUrl}
<div class="qr-wrap">
<img src={qrDataUrl} alt="QR Code" class="qr" />
</div>
{/if}
</div>
{/if}
</Card>
<Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
<div class="invite-row">
<input type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
<div class="invite-form">
<label class="field-label" for="parent-email">Parent email</label>
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
</div>
<p class="hint">They will set up their own password on first login.</p>
@@ -222,9 +274,13 @@
<Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
<form method="POST" action="?/createSeason" use:enhance class="inline">
<input name="name" placeholder="Season name" required />
<input name="color" type="color" value="#6366f1" class="color-input" />
<form method="POST" action="?/createSeason" use:enhance class="season-form">
<label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required />
<div class="color-row">
<label for="season-color">Colour</label>
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
</div>
<Button type="submit" size="sm">Add</Button>
</form>
@@ -310,54 +366,74 @@
</CardGrid>
<style>
.section {
margin: 0.5rem 0;
}
.code {
font-family: monospace;
font-size: 1.2rem;
padding: 0.5rem;
background: #f3f4f6;
border-radius: 4px;
display: inline-block;
}
.invite-url {
font-family: monospace;
font-size: 0.9rem;
word-break: break-all;
background: #f9fafb;
padding: 0.4rem;
border-radius: 4px;
}
.invite-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0.5rem 0;
}
.invite-row select {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
flex: 1;
}
.invite-row input {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
flex: 1;
}
.hint {
font-size: 0.85rem;
color: #9ca3af;
line-height: 1.4;
}
.payday-form {
.invite-url {
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
background: #fff;
border: 1px solid #e5e7eb;
padding: 0.5rem 0.6rem;
border-radius: 8px;
}
/* ── Forms: full-width, balanced fields ── */
form {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.6rem;
margin-bottom: 1rem;
}
.payday-form button[type='submit'] {
align-self: flex-start;
form.inline {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: #6b7280;
}
input,
select {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
font-size: 0.9rem;
}
input:focus,
select:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
border-color: #6366f1;
}
/* Buttons are rendered by <Button> (scoped in its own component) — reach
them with :global so primary CTAs stretch to full width. */
form :global(.btn) {
width: 100%;
}
form.inline :global(.btn) {
width: auto;
}
.invite-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.invite-form :global(.btn) {
width: 100%;
}
/* ── Payday ── */
.payday-row {
display: flex;
gap: 0.5rem;
@@ -373,31 +449,51 @@
font-size: 0.8rem;
color: #6b7280;
flex-shrink: 0;
font-weight: 500;
}
.payday-row + .payday-row {
margin-top: 0.5rem;
}
.actions {
/* ── Colour rows (seasons) ── */
.color-row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.qr {
margin-top: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
.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;
cursor: pointer;
}
/* ── Lists ── */
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
padding: 0.3rem 0;
padding: 0.45rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: space-between;
border-bottom: 1px solid #f3f4f6;
}
li:last-child {
border-bottom: none;
}
.dot {
display: inline-block;
@@ -410,18 +506,121 @@
font-size: 0.85rem;
color: #6366f1;
}
form {
.slug-inline {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.85em;
background: #f3f4f6;
border-radius: 4px;
padding: 0.1em 0.35em;
color: #374151;
}
.slug-line {
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-list {
border-left: 1px solid #f3f4f6;
padding-left: 1.25rem;
}
.member-left,
.member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-color {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.member-name {
font-weight: 500;
color: #374151;
}
.member-info {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.member-handle {
font-family: var(--font-mono, ui-monospace, monospace);
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 {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
input,
select,
button {
padding: 0.4rem 0.7rem;
border: 1px solid #ccc;
border-radius: 4px;
.actions.justify-center {
justify-content: center;
}
.actions :global(.btn) {
flex: 1;
}
.qr-wrap {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.qr {
border: 1px solid #e5e7eb;
border-radius: 8px;
}
/* ── Narrow cards: stack rows / actions full width (container query) ── */
@container (max-width: 380px) {
.payday-row {
flex-direction: column;
align-items: stretch;
}
.payday-row select {
width: 100%;
}
.color-row {
align-items: stretch;
}
.actions {
flex-direction: column;
}
.actions :global(.btn) {
flex: none;
width: 100%;
}
}
/* ── Overlay / modal (plain buttons live in this template) ── */
button {
background: #6366f1;
color: white;
@@ -437,18 +636,6 @@
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
}
.inline {
display: inline;
margin: 0;
}
.color-input {
width: 40px;
height: 34px;
padding: 0;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
.overlay {
position: fixed;
inset: 0;
@@ -462,8 +649,9 @@
background: white;
border-radius: 12px;
padding: 1.5rem;
min-width: 320px;
width: 100%;
max-width: 440px;
margin: 0 1rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.modal h3 {
+6 -6
View File
@@ -1,4 +1,4 @@
import { pbAdmin } from '$lib/server/pb-admin';
import { pbAdmin } from '$lib/server/pocketbase';
import { redirect, fail } from '@sveltejs/kit';
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
import type { Actions, PageServerLoad } from './$types';
@@ -12,17 +12,17 @@ export const load: PageServerLoad = async ({ cookies }) => {
try {
const fams = await pbAdmin.getList('fams');
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
const [members, rewards, famAdmins] = await Promise.all([
pbAdmin.getList('members', `famId = '${fam.id}'`),
const [members, rewards, parents] = await Promise.all([
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`),
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
pbAdmin.getList('fam_admins', `famId = '${fam.id}'`),
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'parent'`),
]);
return {
id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode,
id: fam.id, name: fam.name, slug: fam.slug,
memberCount: members.length,
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
totalRewards: rewards.length,
parentEmail: (famAdmins as any[])?.[0]?.email || '',
parentEmail: (parents as any[])?.[0]?.email || '',
featureFlags: fam.featureFlags || {},
};
}));
+8 -8
View File
@@ -16,21 +16,21 @@ export async function POST(event: RequestEvent) {
const body = (await event.request.json().catch(() => null)) as Body | null;
if (!body || !body.action) return json({ error: 'missing action' }, 400);
const session = event.locals.session;
const deviceToken = event.cookies.get('device_token') || '';
const session = event.locals.user;
const pbToken = event.cookies.get('pb_token') || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
let famId = body.famId || '';
if (session?.famId && session?.userId) {
if (session?.role === 'parent' && session?.famId && session?.id) {
// Admin (parent) — trust the verified session server-side.
headers['x-session-famid'] = session.famId;
headers['x-session-userid'] = session.userId;
headers['x-session-userid'] = session.id;
famId = session.famId;
} else if (session?.role === 'child' && pbToken && session?.famId) {
// Member (child) — forward the pb_token; the proxy re-validates.
headers['Authorization'] = `Bearer ${pbToken}`;
famId = session.famId;
} else if (deviceToken) {
// Member (child) — forward the device token; the proxy re-validates.
headers['x-device-token'] = deviceToken;
headers['x-device-famid'] = famId;
} else {
return json({ error: 'Unauthorized' }, 401);
}
@@ -1,24 +0,0 @@
import { fail, redirect } from '@sveltejs/kit';
import { joinMember, setDeviceTokenCookie } from '$lib/server/auth';
export const actions = {
default: async (event) => {
const code = event.params.code;
const fd = await event.request.formData();
const name = fd.get('name') as string;
if (!name) {
return fail(400, { error: 'Name is required' });
}
const deviceToken = crypto.randomUUID();
try {
const result = await joinMember(code, name, deviceToken);
setDeviceTokenCookie(event, deviceToken);
throw redirect(303, `/${result.famSlug}/${result.name}`);
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Invalid invite code' });
}
},
};
@@ -1,67 +0,0 @@
<script lang="ts">
import AuthShell from '$lib/components/AuthShell.svelte';
let { form } = $props();
let name = $state('');
</script>
<AuthShell title="Join your family" subtitle="Enter your name to join. It must match a member slot created by your admin.">
{#if form?.error}
<p class="form-error">{form.error}</p>
{/if}
<form method="POST">
<label>
Your name
<input name="name" bind:value={name} placeholder="Your exact name" required />
</label>
<button type="submit">Join</button>
</form>
</AuthShell>
<style>
form {
display: grid;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: #374151;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
}
input:focus {
outline: none;
border-color: #4338ca;
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.15);
}
button {
margin-top: 0.25rem;
background: #4338ca;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover { background: #3730a3; }
.form-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
</style>
@@ -1,24 +0,0 @@
import { fail, redirect } from '@sveltejs/kit';
import { setDeviceTokenCookie } from '$lib/server/auth';
import { PROXY_URL } from '$app/env/public';
const HONO_URL = PROXY_URL;
export const actions = {
default: async (event) => {
const code = event.params.code;
const name = event.params.member;
const res = await fetch(`${HONO_URL}/api/members/direct-join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode: code, name }),
});
const data = await res.json();
if (!res.ok) return fail(400, { error: data.error || 'Join failed' });
setDeviceTokenCookie(event, data.deviceToken);
throw redirect(303, `/${data.famSlug}/${data.name}`);
},
};
@@ -1,45 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
import AuthShell from '$lib/components/AuthShell.svelte';
let { form } = $props();
let memberName = $derived(page.params.member);
</script>
<AuthShell
title="Join as {memberName}"
subtitle="You've been invited to your family's chore board. One tap and you're in."
>
{#if form?.error}
<p class="form-error">{form.error}</p>
{/if}
<form method="POST" use:enhance>
<button type="submit">Join as {memberName}</button>
</form>
</AuthShell>
<style>
button {
width: 100%;
background: #4338ca;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover { background: #3730a3; }
.form-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
</style>
+31 -19
View File
@@ -1,10 +1,12 @@
import { fail, redirect } from '@sveltejs/kit';
import { login, setSessionCookie, setPbTokenCookie } from '$lib/server/auth';
import { createPbClient } from '$lib/server/pocketbase';
import { setSessionCookie } from '$lib/server/session';
import { pbAdmin } from '$lib/server/pocketbase';
import { handleOf } from '@shared/slugify';
export const actions = {
default: async (event) => {
const fd = await event.request.formData();
default: async (event) => {
const fd = await event.request.formData();
const email = fd.get('email') as string;
const password = fd.get('password') as string;
@@ -12,22 +14,32 @@ export const actions = {
return fail(400, { error: 'Email and password required', email });
}
let result: any;
let authResult: { token: string; record: any };
try {
result = await login(email, password);
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Login failed', email });
authResult = await createPbClient()
.collection('users')
.authWithPassword(email, password);
} catch {
return fail(400, { error: 'Invalid email or password', email });
}
setSessionCookie(event, {
famId: result.famId,
userId: result.userId,
famSlug: result.famSlug,
memberName: result.memberName,
role: result.role,
});
setPbTokenCookie(event, result.token);
const user = authResult.record;
if (!user.famId) {
return fail(400, { error: 'No family linked to this account', email });
}
throw redirect(303, `/${result.famSlug}/${result.memberName}`);
},
};
setSessionCookie(event.cookies, authResult.token);
const fam = await pbAdmin.getOne('fams', user.famId).catch(() => null);
const famSlug = fam?.slug || user.famId;
// Parents (admins) land on the fam dashboard — no username in the URL.
if (user.role === 'parent') {
throw redirect(303, `/${famSlug}`);
}
// Children don't log in via email; this is just a safe fallback.
const handle = handleOf(user.username || '') || user.name || email.split('@')[0];
throw redirect(303, `/${famSlug}/${handle}`);
}
};
+11 -5
View File
@@ -1,14 +1,20 @@
import { redirect } from '@sveltejs/kit';
import { clearSession } from '$lib/server/auth';
import { clearSessionCookie } from '$lib/server/session';
function signOut(event: { cookies: any }) {
clearSessionCookie(event.cookies);
event.cookies.delete('session', { path: '/' });
event.cookies.delete('device_token', { path: '/' });
}
export function load(event) {
clearSession(event);
signOut(event);
throw redirect(303, '/');
}
export const actions = {
default: (event) => {
clearSession(event);
signOut(event);
throw redirect(303, '/');
},
};
}
};
+78 -19
View File
@@ -1,35 +1,94 @@
import { fail, isRedirect, redirect } from '@sveltejs/kit';
import { signup, setSessionCookie, setPbTokenCookie } from '$lib/server/auth';
import { fail, redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { pbAdmin } from '$lib/server/pocketbase';
import { createPbClient } from '$lib/server/pocketbase';
import { setSessionCookie } from '$lib/server/session';
import { issueAccess } from '$lib/server/member-otp';
import { slugify, handle, famUsername, handleOf } from '@shared/slugify';
class SignupError extends Error {}
export const actions = {
default: async (event) => {
// Step 1 — create the family + parent (admin) user, mint their session.
// The parent's human-entered name is kept as the display `name`; their PB
// `username` is `{famSlug}:{handle}` (globally unique, handle = no whitespace).
signup: async (event) => {
const fd = await event.request.formData();
const famName = fd.get('familyName') as string;
const yourName = (fd.get('yourName') as string) || '';
const email = fd.get('email') as string;
const password = fd.get('password') as string;
const famName = fd.get('famName') as string;
const parentName = fd.get('parentName') as string;
if (!email || !password || !famName) {
return fail(400, { error: 'All fields required', email, famName });
if (!famName || !yourName || !email || !password) {
return fail(400, { message: 'All fields required', famName, email });
}
if (password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters', email, famName });
return fail(400, { message: 'Password must be at least 8 characters', famName, email });
}
const parentName = yourName.trim();
const slug = slugify(famName);
const handleName = handle(parentName) || 'admin';
const username = famUsername(slug, handleName);
try {
const result = await signup(email, password, famName, parentName || email.split('@')[0]);
setSessionCookie(event, {
famId: result.famId,
userId: result.userId,
famSlug: result.famSlug,
memberName: result.memberName,
role: result.role,
const fam = await pbAdmin.create('fams', {
name: famName,
slug,
timezone: 'auto'
});
setPbTokenCookie(event, result.token);
throw redirect(303, `/${result.famSlug}/${result.memberName}`);
const user = await pbAdmin.create('users', {
username,
name: parentName,
email,
password,
passwordConfirm: password,
emailVisibility: false,
famId: fam.id,
role: 'parent'
});
await pbAdmin.create('settings', { famId: fam.id });
} catch (e) {
if (isRedirect(e)) throw e;
return fail(400, { error: e instanceof Error ? e.message : 'Signup failed', email, famName });
throw new SignupError(
`Could not create account — ${e instanceof Error ? e.message : 'please try again'}`
);
}
// Auth as the new parent to mint their JWT, then move to the child step.
const authResult = await createPbClient()
.collection('users')
.authWithPassword(email, password)
.catch(() => null);
if (authResult?.token) setSessionCookie(event.cookies, authResult.token);
// `username` here is the handle (URL segment), not the composite.
return { success: true, famSlug: slug, username: handleName };
},
// Step 2 — optionally add a child now; issues their OTP join code.
child: async (event: RequestEvent) => {
const user = requireUser(event);
const fd = await event.request.formData();
const name = ((fd.get('member') as string) || '').trim();
const fam = await pbAdmin.getOne('fams', user.famId);
const famSlug = fam?.slug || user.famId;
if (!name) {
return { success: true, famSlug, username: handleOf(user.username) };
}
const { otp, joinUrl } = await issueAccess({
famId: user.famId,
famSlug,
name
});
return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username) };
}
};
function requireUser(event: RequestEvent) {
if (!event.locals.user) throw redirect(303, '/signup');
return event.locals.user;
}
+176 -34
View File
@@ -1,48 +1,124 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AuthShell from '$lib/components/AuthShell.svelte';
import { slugify, handle } from '@shared/slugify';
let { form } = $props();
let step = $state(1);
let famName = $state('');
let yourName = $state('');
let email = $state('');
let password = $state('');
let famName = $state('');
let parentName = $state('');
let childName = $state('');
let submitting = $state(false);
let localError = $state('');
let famSlugPreview = $derived(slugify(famName) || 'your-family');
let handlePreview = $derived(handle(yourName) || 'your-name');
const enhanceForm = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- canary $types lacks SubmitFunction
return () =>
async ({ update, result }: any) => {
submitting = true;
localError = '';
try {
await update();
} catch (e) {
localError = e instanceof Error ? e.message : 'Something went wrong. Please try again.';
}
submitting = false;
if (result.type !== 'failure' && result.type !== 'error') step++;
};
};
</script>
<AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going.">
{#if form?.error}
<p class="form-error">{form.error}</p>
{#if form?.message}
<p class="form-error">{form.message}</p>
{/if}
{#if localError}
<p class="form-error">{localError}</p>
{/if}
<form method="POST">
<label>
Family name
<input name="famName" bind:value={famName} placeholder="The Smiths" required />
</label>
<label>
Your name
<input name="parentName" bind:value={parentName} placeholder="Mum / Dad" required />
</label>
<label>
Email
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
</label>
<label>
Password
<input
type="password"
name="password"
bind:value={password}
placeholder="8+ characters"
minlength={8}
required
/>
</label>
<button type="submit">Create my family</button>
</form>
{#if step === 1}
<form method="POST" action="?/signup" use:enhance={enhanceForm()}>
<label>
Family name
<input name="familyName" bind:value={famName} placeholder="The Smiths" required />
{#if famName}
<span class="preview">Family page address: <code>/</code><code class="inline-code">{famSlugPreview}</code></span>
{/if}
</label>
<label>
Your name
<input name="yourName" bind:value={yourName} placeholder="Mum / Dad" required />
{#if yourName}
<span class="preview">
Your address: <code>/</code><code class="inline-code">{famSlugPreview}/{handlePreview}</code>
<small class="preview-hint">(no spaces — {yourName.trim()} → {handlePreview})</small>
</span>
{:else}
<span class="preview-hint">No spaces in your address — e.g. “Joe Edhook” → <code>joeedhook</code></span>
{/if}
</label>
<label>
Email
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
</label>
<label>
Password
<input
type="password"
name="password"
bind:value={password}
placeholder="8+ characters"
minlength={8}
required
/>
</label>
<button type="submit" disabled={submitting}>Create my family</button>
</form>
<p class="alt">Already have a family? <a href="/login">Log in</a></p>
{/if}
<p class="alt">
Already have a family? <a href="/login">Log in</a>
</p>
{#if step === 2}
<h3 class="step-title">Add a child now?</h3>
<p class="step-note">We'll create a shareable join code so they can jump in on any device.</p>
<form method="POST" action="?/child" use:enhance={enhanceForm()}>
<label>
Child's name
<input
type="text"
name="member"
bind:value={childName}
placeholder="Their first name"
/>
</label>
<button type="submit" disabled={submitting}>Create join code</button>
</form>
<p class="alt">
<a href="/{form?.famSlug}">Skip for now →</a>
</p>
{/if}
{#if step === 3}
<h3 class="step-title">{childName ? `Nice — share this code with ${childName}:` : 'Your family is ready!'}</h3>
{#if form?.code}
<div class="code">
<span class="code-text">{form.code}</span>
</div>
<p class="step-note">
They open <code class="inline-code">{form?.joinUrl}</code> and enter this code.
</p>
{:else}
<p class="step-note">You can add kids and share join codes any time from Family Settings.</p>
{/if}
<div class="actions">
<a href="/{form?.famSlug}" class="btn-primary">Go to dashboard</a>
</div>
{/if}
</AuthShell>
<style>
@@ -81,6 +157,17 @@
cursor: pointer;
}
button:hover { background: #3730a3; }
button:disabled { opacity: 0.6; cursor: default; }
.step-title {
margin: 0 0 0.25rem;
font-size: 1.1rem;
color: #111827;
}
.step-note {
margin: 0 0 1rem;
font-size: 0.85rem;
color: #6b7280;
}
.form-error {
background: #fef2f2;
color: #b91c1c;
@@ -90,6 +177,23 @@
font-size: 0.85rem;
margin: 0 0 1rem;
}
.preview {
font-size: 0.78rem;
color: #6b7280;
font-weight: 400;
}
.preview-hint {
font-size: 0.75rem;
color: #9ca3af;
font-weight: 400;
}
.preview .inline-code, .preview-hint .inline-code {
font-family: ui-monospace, monospace;
background: #f3f4f6;
border-radius: 4px;
padding: 0.05em 0.3em;
color: #374151;
}
.alt {
margin: 1.25rem 0 0;
font-size: 0.85rem;
@@ -97,4 +201,42 @@
text-align: center;
}
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
</style>
.code {
background: #eef2ff;
border: 1px dashed #a5b4fc;
border-radius: 10px;
padding: 1rem;
text-align: center;
margin: 0 0 0.75rem;
}
.code-text {
font-family: ui-monospace, monospace;
font-size: 1.6rem;
letter-spacing: 0.35em;
font-weight: 700;
color: #4338ca;
}
.inline-code {
font-family: ui-monospace, monospace;
font-size: 0.85em;
background: #f3f4f6;
border-radius: 4px;
padding: 0.1em 0.35em;
color: #374151;
}
.actions {
margin-top: 1.25rem;
}
.btn-primary {
display: block;
text-align: center;
background: #4338ca;
color: #fff;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
text-decoration: none;
}
.btn-primary:hover { background: #3730a3; }
</style>
-159
View File
@@ -1,159 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
import AuthShell from '$lib/layouts/AuthShell.svelte';
let { form }: { form: ActionData } = $props();
let step1 = $state({ familyName: '', email: '', password: '' });
let step2 = $state({ username: '' });
let step3 = $state({ child: '' });
let submitting = $state(false);
let step = $state(1);
const enhanceForm = () => {
return async ({ update, result }: { update: () => Promise<void> }) => {
submitting = true;
await update();
submitting = false;
if (result.type !== 'failure') {
step++;
}
};
};
</script>
<AuthShell title="Signup" subtitle="Create your family.">
{#if form?.message}
<h3 class="form-error">{form.message}</h3>
{/if}
{#if step === 1}
<form method="POST" action="?/signup" use:enhance={enhanceForm}>
<label>
Family Name
<input
type="text"
name="familyName"
bind:value={step1.familyName}
placeholder="Family Name"
required
/>
</label>
<label>
Email
<input
type="email"
name="email"
bind:value={step1.email}
placeholder="you@email.com"
required
/>
</label>
<label>
Password
<input type="password" name="password" bind:value={step1.password} required />
</label>
<button type="submit">Sign up</button>
</form>
<p class="alt">
Don't have a family yet? <a href="/signup">Create one</a>
</p>
{/if}
{#if step === 2}
<h3 class="form-error">Step 2</h3>
<p>Now for something more personal:</p>
<form method="POST" action="?/username" use:enhance={enhanceForm}>
<label
>Username
<input
type="text"
name="username"
bind:value={step2.username}
placeholder="Username"
required
/>
</label>
<button type="submit">Next</button>
</form>
{/if}
{#if step === 3}
<h3 class="form-error">Step 3</h3>
<p>Would you like to add a child device now?</p>
<form method="POST" action="?/child" use:enhance={enhanceForm}>
<label>
Add childs name:
<input type="text" name="member" bind:value={step3.child} />
</label>
<button type="submit">Next</button>
</form>
<p>Or skip straight to admin</p>
<a href="/admin">admin dashboard</a>
{/if}
{#if step === 4}
<h3 class="form-error">Step 4</h3>
<p>Nice - now share this device login code with {step3.child}:</p>
<div class="code">
<span class="code-text">{form.code}</span>
</div>
{/if}
</AuthShell>
<style>
form {
display: grid;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: #374151;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
}
input:focus {
outline: none;
border-color: #4338ca;
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.15);
}
button {
margin-top: 0.25rem;
background: #4338ca;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover {
background: #3730a3;
}
.form-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.alt {
margin: 1.25rem 0 0;
font-size: 0.85rem;
color: #6b7280;
text-align: center;
}
.alt a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
</style>