add shared login pin mechansim

This commit is contained in:
JCEEE
2026-09-12 08:45:13 +01:00
parent 70a38ee95b
commit 7003609afe
24 changed files with 1302 additions and 493 deletions
+58 -21
View File
@@ -2,8 +2,12 @@ import type { Handle } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase';
import {
SESSION_COOKIE,
ACTIVE_COOKIE,
childSessionCookie,
setSessionCookie,
clearSessionCookie,
setChildSessionCookie,
clearActiveChild,
PLATFORM_SESSION_COOKIE,
clearPlatformSession
} from '$lib/server/session';
@@ -14,6 +18,21 @@ import { migrateOnBoot } from '$lib/server/migrate-boot';
// Run the PB schema migration once at server boot (idempotent).
void migrateOnBoot();
function sessionFrom(record: any, freshToken: string) {
return {
id: record.id,
name: record.name || record.username || '',
username: handleOf(record.username || ''),
role: record.role || 'parent',
famId: record.famId,
color: record.color || '',
pattern: record.pattern || '',
themeSize: record.themeSize || '',
themeOpacity: record.themeOpacity || '',
token: freshToken
} satisfies SessionUser & { token: string };
}
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
event.locals.pbToken = null;
@@ -36,32 +55,50 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// Fam-user session: pb_token JWT → authRefresh → locals.user.
// Shared-device sessions: `pb_active` names the child whose cookie is the
// current session. Resolved FIRST so a kid switch on a family computer
// supersedes any shadowed single pb_token (parent) session.
const activeId = event.cookies.get(ACTIVE_COOKIE);
if (activeId) {
const childToken = event.cookies.get(childSessionCookie(activeId));
if (!childToken) {
// Stale active pointer (cookie removed) — clean it up and fall through.
clearActiveChild(event.cookies);
} else {
try {
const pb = createPbClient(childToken);
// 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).
// 2. Returns the current record — the only way to get
// name/role/famId, since PB doesn't embed custom fields.
const { record, token: freshToken } = await pb.collection('users').authRefresh();
if (record.role === 'child') {
const session = sessionFrom(record, freshToken);
event.locals.user = session;
event.locals.pbToken = session.token;
if (freshToken !== childToken) {
setChildSessionCookie(event.cookies, activeId, freshToken);
}
return resolve(event);
}
} catch {
// Expired/revoked/malformed — leave the cookie; the picker switch
// re-mints it server-side. Fall through to the single session.
}
}
}
// Single session: pb_token JWT (parents, or children from before the
// multi-session scheme) → authRefresh → locals.user.
const token = event.cookies.get(SESSION_COOKIE);
if (token) {
const pb = createPbClient(token);
try {
// 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 || '',
pattern: record.pattern || '',
themeSize: record.themeSize || '',
themeOpacity: record.themeOpacity || ''
} satisfies SessionUser;
event.locals.pbToken = freshToken;
const session = sessionFrom(record, freshToken);
event.locals.user = session;
event.locals.pbToken = session.token;
if (freshToken !== token) {
setSessionCookie(event.cookies, freshToken);
}
+3
View File
@@ -14,3 +14,6 @@ export { default as NoticeDialog } from './NoticeDialog.svelte';
export { default as PricingPlans } from './PricingPlans.svelte';
export { default as CheckboxGrid } from './CheckboxGrid.svelte';
export { default as PatternPicker } from './PatternPicker.svelte';
export { default as PinPad } from './PinPad.svelte';
export { default as SharedPicker } from './SharedPicker.svelte';
export { default as JoinPinFlow } from './JoinPinFlow.svelte';
+2 -2
View File
@@ -235,8 +235,8 @@ export async function redeemOtp(opts: { famSlug: string; username: string; otp:
if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired');
const authPb = createPbClient();
await authPb
const { record } = await authPb
.collection('users')
.authWithPassword(fullUsername, derivePassword(famSlug, handleName));
return authPb.authStore.token;
return { token: authPb.authStore.token, userId: record.id };
}
+54
View File
@@ -262,6 +262,54 @@ async function ensureOtp(ids: Record<string, string>): Promise<void> {
});
}
// Child shared-device PINs. Superuser-only rules (like `otp`) — all access
// goes through server endpoints with session-role checks, so children can
// never read siblings' pins via PB rules.
async function ensurePins(ids: Record<string, string>): Promise<void> {
if (await getCollection('pins')) return;
const famsId = ids.fams || (await getCollection('fams'))?.id;
const usersId = ids.users || (await getCollection('users'))?.id;
if (!famsId || !usersId) throw new Error('fams/users collection not found');
await createCollection({
name: 'pins',
type: 'base',
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: 'famId',
type: 'relation',
required: true,
collectionId: famsId,
maxSelect: 1,
cascadeDelete: false
},
{
name: 'userId',
type: 'relation',
required: true,
collectionId: usersId,
maxSelect: 1,
cascadeDelete: false
},
{ name: 'pin', type: 'text', required: false }
]
});
}
// Idempotent field-add for the shared-device idle lock (0 = off, else mins).
async function ensureSettingsFields(): Promise<void> {
const settingsCol = await getCollection('settings');
if (!settingsCol) return;
const has = (n: string) => settingsCol.fields.some((f: any) => f.name === n);
if (has('lockMins')) return;
settingsCol.fields.push({ name: 'lockMins', type: 'number', required: false });
await updateCollection(settingsCol.id, { fields: settingsCol.fields });
}
// Platform access codes — the codes that enable access to the platform. They're
// global (not fam-scoped) and managed via the platform admin page (superuser
// only), so all rules are null like `otp`. A code grants a family a subscription
@@ -404,6 +452,7 @@ async function ensureSchema(): Promise<void> {
await ensureUsers(ids);
await ensureOtp(ids);
await ensurePins(ids);
console.log('[migrate] Schema bootstrapped.');
}
@@ -473,11 +522,16 @@ export async function migrate(): Promise<void> {
// return) so new platform collections/fields/seed land on existing installs.
await ensureUserFields();
await ensureFamFields();
await ensureSettingsFields();
await ensureBonusFields();
await ensureTemplateFields();
await ensureAssignedChoreFields();
await ensureAccessCodes();
await ensurePlatform();
// Superuser-only collections also land on EXISTING installs (ensureSchema's
// early return skips them). Like ensureOtp before it, ensurePins no-ops when
// the collection already exists.
await ensurePins({});
await ensureDefaultSeasons();
if (await isDemo()) {
await seedDemoFamily();
+51 -7
View File
@@ -1,22 +1,66 @@
import type { Cookies } from '@sveltejs/kit';
// The PocketBase JWT lives in a single cookie shared by:
// PocketBase JWTs live in cookies shared by:
// - the server hooks (authRefresh -> locals.user)
// - the client SDK (seeded from page.data.pbToken -> authenticated famStore reads/subscribe)
// httpOnly keeps the token out of reach of browser JS/XSS; the client receives
// httpOnly keeps tokens out of reach of browser JS/XSS; the client receives
// the token server-side via the layout load (pbToken) and seeds pb.authStore.
// Secure flag is set in prod so it's only sent over HTTPS.
export const SESSION_COOKIE = 'pb_token';
// Shared-device multi-session: children hold ONE cookie per account
// (`pb_token_<userId>`); `pb_active` names which one is the current session.
// Parents stay on the single `pb_token`.
export const CHILD_COOKIE_PREFIX = 'pb_token_';
export const ACTIVE_COOKIE = 'pb_active';
const MAX_AGE = 60 * 60 * 24 * 5; // 5 days — matches the PB users auth token duration
export function setSessionCookie(cookies: Cookies, token: string) {
cookies.set(SESSION_COOKIE, token, {
function cookieOpts() {
return {
httpOnly: true,
sameSite: 'lax',
sameSite: 'lax' as const,
path: '/',
maxAge: MAX_AGE,
secure: import.meta.env.PROD
});
};
}
export function setSessionCookie(cookies: Cookies, token: string) {
cookies.set(SESSION_COOKIE, token, cookieOpts());
}
export function childSessionCookie(userId: string) {
return `${CHILD_COOKIE_PREFIX}${userId}`;
}
export function setChildSessionCookie(cookies: Cookies, userId: string, token: string) {
cookies.set(childSessionCookie(userId), token, cookieOpts());
}
export function clearChildSession(cookies: Cookies, userId: string) {
cookies.delete(childSessionCookie(userId), { path: '/' });
}
export function setActiveChild(cookies: Cookies, userId: string) {
cookies.set(ACTIVE_COOKIE, userId, cookieOpts());
}
export function clearActiveChild(cookies: Cookies) {
cookies.delete(ACTIVE_COOKIE, { path: '/' });
}
// Ids of every child that has a session cookie on this device.
export function scanChildSessions(cookies: Cookies): string[] {
return cookies
.getAll()
.filter((c) => c.name.startsWith(CHILD_COOKIE_PREFIX))
.map((c) => c.name.slice(CHILD_COOKIE_PREFIX.length))
.filter(Boolean);
}
// Remove every child session on this device (logout-all).
export function clearDeviceSessions(cookies: Cookies) {
for (const id of scanChildSessions(cookies)) clearChildSession(cookies, id);
clearActiveChild(cookies);
}
export function clearSessionCookie(cookies: Cookies) {
@@ -50,4 +94,4 @@ export function setPlatformSession(cookies: Cookies, token: string) {
export function clearPlatformSession(cookies: Cookies) {
cookies.delete(PLATFORM_SESSION_COOKIE, { path: '/' });
}
}
+7 -2
View File
@@ -1,8 +1,13 @@
import type { LayoutServerLoad } from './$types';
import { getPlatformFlags } from '$lib/server/platform';
import { scanChildSessions } from '$lib/server/session';
// Platform settings are public (read-only): feature flags ride along with
// every page's data so any component can deduce them via page.data.platformFlags.
export const load: LayoutServerLoad = async () => {
return { platformFlags: await getPlatformFlags() };
export const load: LayoutServerLoad = async (event) => {
return {
platformFlags: await getPlatformFlags(),
// Children with session cookies on THIS device (shared-computer picker).
deviceChildIds: scanChildSessions(event.cookies)
};
};
+78 -5
View File
@@ -1,9 +1,10 @@
import { redirect } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase';
import { createPbClient, createSuperClient } from '$lib/server/pocketbase';
import { createServices, type ChatActor } from '$lib/server/services';
import { ensureFamAccess } from '$lib/server/access';
import { seedDemoCompletions } from '$lib/server/migrate';
import { getPlatformFlags } from '$lib/server/platform';
import { scanChildSessions } from '$lib/server/session';
async function paydayCheck(famId: string, pbToken: string) {
try {
@@ -56,7 +57,62 @@ export async function load(event) {
const session = event.locals.user;
const role = session?.role || 'child';
const isParent = role === 'parent';
const pbToken = event.cookies.get('pb_token') || '';
const pbToken = event.locals.pbToken || '';
const deviceChildIds = scanChildSessions(event.cookies);
// ── Shared-device picker mode ──
// The device holds child sessions but none is active (per-profile logout).
// Anything except the join flow lands on the standalone picker.
const paramFam = event.params.fam;
const isJoinPage = (event.url.pathname || '').split('/').includes('join');
if (!session && deviceChildIds.length > 0 && !isJoinPage) {
if (!(event.url.pathname || '').endsWith('/switch')) {
throw redirect(303, `/${encodeURIComponent(paramFam)}/switch`);
}
let pickerFamName = paramFam || '';
let pickerChildren: {
id: string;
name: string;
color: string;
username: string;
}[] = [];
try {
const pb = await createSuperClient();
const fam = await pb
.collection('fams')
.getFirstListItem(`slug='${paramFam}'`)
.catch(() => null);
if (fam) {
pickerFamName = fam.name || fam.slug;
const users = await pb.collection('users').getFullList({
filter: `id in ('${deviceChildIds.join("','")}') && role='child'`
});
pickerChildren = (users || []).map((u: any) => ({
id: u.id,
name: u.name || u.username || '',
color: u.color || '#6366f1',
username: u.username || ''
}));
}
} catch {}
return {
famSlug: paramFam || '',
session: null,
isParent,
role,
famId: '',
chat: null,
pbToken: '',
fam: null,
famAccess: { disabled: false, mode: 'none', reason: '' },
demoMode: (await getPlatformFlags()).demo,
picker: true,
pickerFamName,
pickerChildren,
deviceChildIds,
lockMins: 0
};
}
let famId = '';
let chat: {
@@ -70,6 +126,7 @@ export async function load(event) {
mode: 'none' as 'none' | 'code' | 'sub' | 'canceled',
reason: ''
};
let lockMins = 0;
if (session && pbToken) {
famId = session.famId;
@@ -82,11 +139,22 @@ export async function load(event) {
fam = res.fam;
famAccess = res.access;
}
// Shared-device idle lock setting (0 = off; missing row defaults to 10
// min). Superuser read — the settings rules are parent-oriented and
// children must see it too.
try {
const pb = await createSuperClient();
const settings = await pb
.collection('settings')
.getFullList({ filter: `famId='${famId}'` })
.catch(() => []);
const row = (settings as any[])?.[0];
lockMins = row && row.lockMins != null ? Number(row.lockMins) : 10;
} catch {}
// Canonical URL: the [fam] segment must be the family SLUG, never the PB
// id. If someone lands on /{famId}/... (a stale shortcut, bookmark, or a
// login that fell back to the id), rewrite the first path segment to the
// slug so the id is replaced everywhere it'd otherwise persist.
const paramFam = event.params.fam;
const canonicalSlug = fam?.slug;
if (canonicalSlug && paramFam && paramFam !== canonicalSlug) {
const rest = event.url.pathname.replace(`/${paramFam}`, '') || '/';
@@ -100,7 +168,7 @@ export async function load(event) {
return {
// Canonical fam slug — from the URL param ([fam] routes). Client code
// reads page.data.famSlug; never copy it into local $state.
famSlug: event.params.fam || '',
famSlug: paramFam || '',
session: session
? {
famId: session.famId,
@@ -121,6 +189,11 @@ export async function load(event) {
pbToken,
fam,
famAccess,
demoMode
demoMode,
picker: false,
pickerFamName: '',
pickerChildren: [],
deviceChildIds,
lockMins
};
}
+151 -57
View File
@@ -6,9 +6,10 @@
import { famStore } from '$lib/stores/fam.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { notices } from '$lib/stores/notices.svelte';
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
import { Sidebar, TopNav, Footer, Chat, SharedPicker } from '$lib/components';
import { chatIcon } from '$lib/components/icons';
import { recordShortcut } from '$lib/shortcut';
import { installLockTracking, lockDue } from '$lib/client/lock';
import { themeShades } from '$lib/theme';
import '$lib/theme-patterns.css';
import { themeDraft } from '$lib/stores/theme.svelte';
@@ -29,6 +30,41 @@
// Pattern size in vw units ('' = untouched → pattern class default).
let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || ''));
let bgSizeVw = $derived(Number(bgSize) > 0 ? bgSize : '');
// Shared-device picker: standalone when the device has child sessions but
// none is active (layout load redirects here); overlay when the idle lock
// fires or the top-nav switcher is opened.
const pickerMode = $derived(!!data.picker);
let pickerOpen = $state(false);
let lockTimer: ReturnType<typeof setInterval> | null = null;
let deviceProfiles = $derived(
(data.deviceChildIds || [])
.map((id) => famStore.members.find((m) => m.id === id))
.filter(Boolean)
.map((m: any) => ({ id: m.id, name: m.name, color: m.color, username: m.username }))
);
// Idle lock (children only): return to the picker after `lockMins` of no
// interaction. localStorage-backed (see lib/client/lock.ts) so a closed
// browser still trips the lock on next launch.
$effect(() => {
const isChild = data.session?.role === 'child';
const lockMins = Number(data.lockMins) || 0;
if (!isChild || lockMins <= 0 || data.picker) return;
installLockTracking();
if (lockDue(lockMins)) pickerOpen = true;
if (!lockTimer) {
lockTimer = setInterval(() => {
if (lockDue(lockMins)) pickerOpen = true;
}, 15_000);
}
return () => {
if (lockTimer) {
clearInterval(lockTimer);
lockTimer = null;
}
};
});
let session = $state<Session | null>(data.session);
let isParent = $state(data.isParent);
let role = $state(data.role);
@@ -194,66 +230,97 @@
});
</script>
<div class="layout-stage" class:chat-open={chatStore.open} class:has-pattern={!!bgPattern}>
<div
class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}"
style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`}
>
<Sidebar {famName} session={data.session} {isParent} {role} {isDemo} />
<TopNav
role={data.session ? role : ''}
seasons={famStore.seasons.filter((s) => s.active !== false)}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
<!-- layout.svelte picker-mode branch -->
{#if pickerMode}
<main class="picker-stage">
<SharedPicker
standalone
famSlug={data.famSlug}
famName={data.pickerFamName}
profiles={data.pickerChildren || []}
/>
</main>
{:else}
<div class="layout-stage" class:chat-open={chatStore.open} class:has-pattern={!!bgPattern}>
<div
class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}"
style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`}
>
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
{@html chatIcon}
{#if chatStore.unread > 0}
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
{/if}
</button>
</TopNav>
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
<div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div>
{#if locked}
<div class="disabled-overlay">
<div class="disabled-card">
{#if !hasAuth}
<h2>You need to be logged in to view this page</h2>
{:else if activating}
<div class="spinner" aria-hidden="true"></div>
<strong>Activating your subscription…</strong>
<span>Payment received — this usually only takes a few seconds.</span>
{:else}
<strong>Access paused</strong>
<span>
{isParent
? 'Add an access code or resume your subscription to keep using FamDone.'
: 'Your family access is paused.'}
</span>
{/if}
</div>
</div>
{/if}
</div>
</main>
{#if claimToast}
<a
class="claim-toast"
href={`/${page.params.fam}/${data.session?.username || page.params.username || ''}`}
<Sidebar {famName} session={data.session} {isParent} {role} {isDemo} />
<TopNav
role={data.session ? role : ''}
seasons={famStore.seasons.filter((s) => s.active !== false)}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
>
{claimToast} — view dashboard →
</a>
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
{@html chatIcon}
{#if chatStore.unread > 0}
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
{/if}
</button>
{#if data.session && (data.deviceChildIds?.length || 0) > 0}
<button
class="kid-switch"
onclick={() => (pickerOpen = true)}
aria-label="Switch user (shared device)"
title="Switch user"
>
<span class="kid-switch-dot" style="background:{data.session.memberColor || '#6366f1'}"
></span>
</button>
{/if}
</TopNav>
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
<div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div>
{#if locked}
<div class="disabled-overlay">
<div class="disabled-card">
{#if !hasAuth}
<h2>You need to be logged in to view this page</h2>
{:else if activating}
<div class="spinner" aria-hidden="true"></div>
<strong>Activating your subscription…</strong>
<span>Payment received — this usually only takes a few seconds.</span>
{:else}
<strong>Access paused</strong>
<span>
{isParent
? 'Add an access code or resume your subscription to keep using FamDone.'
: 'Your family access is paused.'}
</span>
{/if}
</div>
</div>
{/if}
</div>
</main>
{#if claimToast}
<a
class="claim-toast"
href={`/${page.params.fam}/${data.session?.username || page.params.username || ''}`}
>
{claimToast} — view dashboard →
</a>
{/if}
<Footer sidebar />
</div>
{#if chatStore.open}
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
></button>
{/if}
<Chat {role} />
{#if pickerOpen && deviceProfiles.length > 0}
<SharedPicker
profiles={deviceProfiles}
activeId={data.session?.userId}
famSlug={data.famSlug}
oncancel={() => (pickerOpen = false)}
/>
{/if}
<Footer sidebar />
</div>
{#if chatStore.open}
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
></button>
{/if}
<Chat {role} />
</div>
{/if}
<style>
.claim-toast {
@@ -402,6 +469,33 @@
justify-content: center;
cursor: pointer;
}
.kid-switch {
position: relative;
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.kid-switch:hover {
background: #e5e7eb;
}
.kid-switch-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e2e8f0;
}
.picker-stage {
min-height: 100vh;
width: 100%;
background: whitesmoke;
}
.chat-toggle:hover {
background: #e5e7eb;
color: #4338ca;
@@ -70,12 +70,13 @@ export async function load(event) {
}
}
const pbToken = event.cookies.get('pb_token') || '';
const pbToken = event.locals.pbToken || '';
const famId = session?.famId || '';
// When a demo parent visits a child's route, session.id is the parent's PB
// record. Look up the actual child user by username so chores load correctly.
const childId = demoParentVisitingChild
? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]?.id || ''
? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]
?.id || ''
: session?.id || '';
const empty = {
@@ -183,4 +184,4 @@ export const actions = {
return { error: e instanceof Error ? e.message : 'Failed to trigger' };
}
}
};
};
@@ -1,6 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { servicesFor } from '$lib/server/servicesFor';
import { getPlatformFlags } from '$lib/server/platform';
import { hasPin } from '$lib/server/pins';
async function isDemoMode(): Promise<boolean> {
return (await getPlatformFlags()).demo;
@@ -8,7 +9,20 @@ async function isDemoMode(): Promise<boolean> {
export async function load(event) {
if (await isDemoMode()) {
return { verified: false, role: '', token: '', memberId: '', famId: '', memberName: '', memberColor: '', pattern: '', themeSize: '', themeOpacity: '', email: '', demoMode: true };
return {
verified: false,
role: '',
token: '',
memberId: '',
famId: '',
memberName: '',
memberColor: '',
pattern: '',
themeSize: '',
themeOpacity: '',
email: '',
demoMode: true
};
}
const session = event.locals.user;
@@ -50,6 +64,7 @@ export async function load(event) {
themeSize: me.themeSize || '',
themeOpacity: me.themeOpacity || '',
email: me.email || '',
hasPin: false,
session: true
};
} catch {
@@ -61,7 +76,8 @@ export async function load(event) {
famId: '',
memberName: '',
memberColor: '',
email: ''
email: '',
hasPin: false
};
}
}
@@ -70,7 +86,7 @@ export async function load(event) {
return {
verified: true,
role: 'child',
token: event.cookies.get('pb_token') || '',
token: event.locals.pbToken || '',
memberId: session.id,
famId,
memberName: session.name || '',
@@ -79,6 +95,7 @@ export async function load(event) {
themeSize: session.themeSize || '',
themeOpacity: session.themeOpacity || '',
email: '',
hasPin: await hasPin(session.id).catch(() => false),
session: true
};
}
@@ -7,6 +7,50 @@
let { data, form } = $props();
let pinMsg = $state('');
async function pinRequest(action: 'set' | 'change', pin: string, currentPin = '') {
pinMsg = '';
try {
const res = await fetch('/api/pins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, pin, currentPin })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Could not save PIN');
pinMsg = 'Saved — try it on the shared computer!';
await invalidateAll();
} catch (e) {
pinMsg = e instanceof Error ? e.message : 'Could not save PIN';
}
}
function setPin(e: SubmitEvent) {
const fd = new FormData(e.currentTarget as HTMLFormElement);
const pin = String(fd.get('pin') || '').trim();
const confirm = String(fd.get('confirm') || '').trim();
e.preventDefault();
if (pin !== confirm) {
pinMsg = 'PINs do not match';
return;
}
pinRequest('set', pin);
}
function changePin(e: SubmitEvent) {
const fd = new FormData(e.currentTarget as HTMLFormElement);
const current = String(fd.get('current') || '').trim();
const pin = String(fd.get('pin') || '').trim();
const confirm = String(fd.get('confirm') || '').trim();
e.preventDefault();
if (pin !== confirm) {
pinMsg = 'PINs do not match';
return;
}
pinRequest('change', pin, current);
}
let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1');
let memberEmail = $state(data.email || '');
@@ -160,6 +204,70 @@
</Card>
</form>
{#if data.role === 'child'}
<Card cols={1} title="My PIN">
{#if data.hasPin}
<p class="theme-hint">
Your 3-digit PIN switches you back to your chores on a shared computer. Forgot it? Your
parent can read it out — Settings → Invites → PIN.
</p>
<form class="pin-form" onsubmit={changePin}>
<input
name="current"
inputmode="numeric"
maxlength="3"
required
placeholder="Current PIN"
class="pin-input"
/>
<input
name="pin"
inputmode="numeric"
maxlength="3"
required
placeholder="New PIN"
class="pin-input"
/>
<input
name="confirm"
inputmode="numeric"
maxlength="3"
required
placeholder="Confirm"
class="pin-input"
/>
<Button type="submit" size="sm">Change PIN</Button>
</form>
{:else}
<p class="theme-hint">
Set a 3-digit PIN so you can switch back to your chores quickly on a shared computer.
</p>
<form class="pin-form" onsubmit={setPin}>
<input
name="pin"
inputmode="numeric"
maxlength="3"
required
placeholder="Your PIN"
class="pin-input"
/>
<input
name="confirm"
inputmode="numeric"
maxlength="3"
required
placeholder="Confirm"
class="pin-input"
/>
<Button type="submit" size="sm">Set PIN</Button>
</form>
{/if}
{#if pinMsg}
<p class="pin-msg">{pinMsg}</p>
{/if}
</Card>
{/if}
<Card cols={1} title="Account">
<p style="font-size:0.85rem;color:#6b7280">
To change your device or sign in on a new device, use your invite link.
@@ -182,4 +290,25 @@
color: #6b7280;
margin: 0 0 0.75rem;
}
.pin-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.pin-input {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
width: 100%;
box-sizing: border-box;
text-align: center;
letter-spacing: 0.3em;
}
.pin-msg {
font-size: 0.85rem;
font-weight: 600;
color: #059669;
}
</style>
@@ -8,6 +8,7 @@ import { sendParentInviteEmail } from '$lib/server/email';
import { slugify, handle, famUsername } from '@shared/slugify';
import { applyAccessCode } from '$lib/server/access';
import { getPlatformFlags } from '$lib/server/platform';
import { getPin, resetPin as resetChildPin } from '$lib/server/pins';
import {
createBillingPortalSession,
cancelSubscriptionAtPeriodEnd,
@@ -26,7 +27,15 @@ async function isDemoMode(): Promise<boolean> {
export async function load(event: RequestEvent) {
if (await isDemoMode()) {
return { members: [], fam: null, seasons: [], accessCode: null, subStatus: null, demoMode: true };
return {
members: [],
fam: null,
seasons: [],
accessCode: null,
subStatus: null,
demoMode: true,
lockMins: 10
};
}
const famId = famIdOf(event);
const pb = pbUser(event);
@@ -48,7 +57,13 @@ export async function load(event: RequestEvent) {
if (fam?.paymentMode === 'sub' && fam?.stripeCustomerId) {
subStatus = await getSubscriptionStatus(fam.stripeCustomerId).catch(() => null);
}
return { members, fam, seasons, accessCode, subStatus };
// Shared-device idle lock (0 = off). Missing row → default 10 min.
const settingsRow = await pbAdmin
.getList('settings', `famId='${famId}'`)
.then((rows: any[]) => rows?.[0] || null)
.catch(() => null);
const lockMins = settingsRow && settingsRow.lockMins != null ? Number(settingsRow.lockMins) : 10;
return { members, fam, seasons, accessCode, subStatus, lockMins };
}
export const actions = {
@@ -159,6 +174,59 @@ export const actions = {
return { ok: true };
},
// Parent reads a child's shared-device PIN (children forget theirs often).
revealPin: async (event: RequestEvent) => {
const famId = famIdOf(event);
const fd = await event.request.formData();
const id = (fd.get('id') || '').toString();
if (!id) return { error: 'Member ID required' };
try {
const member = await pbAdmin.getOne('users', id).catch(() => null);
if (!member || member.famId !== famId) return { error: 'No such member' };
const pin = await getPin(id);
if (!pin) return { ok: true, pin: '', name: member.name || '', unset: true };
return { ok: true, pin, name: member.name || '' };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to reveal PIN' };
}
},
// Parent hands out a fresh PIN when a child forgets theirs.
resetPin: async (event: RequestEvent) => {
const famId = famIdOf(event);
const fd = await event.request.formData();
const id = (fd.get('id') || '').toString();
if (!id) return { error: 'Member ID required' };
try {
const member = await pbAdmin.getOne('users', id).catch(() => null);
if (!member || member.famId !== famId) return { error: 'No such member' };
const pin = await resetChildPin(famId, id);
return { ok: true, pin, name: member.name || '' };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to reset PIN' };
}
},
// Shared-computer idle lock: off / 2 / 10 minutes before returning to the
// profile picker (children only). Missing settings row → created.
updateLock: async (event: RequestEvent) => {
const famId = famIdOf(event);
const fd = await event.request.formData();
const mins = parseInt((fd.get('mins') || '').toString(), 10);
if (![0, 2, 10].includes(mins)) return { error: 'Invalid value' };
const pb = await createSuperClient();
let row = await pb
.collection('settings')
.getFirstListItem(`famId='${famId}'`)
.catch(() => null);
if (row) {
await pb.collection('settings').update(row.id, { lockMins: mins });
} else {
await pb.collection('settings').create({ famId, lockMins: mins });
}
return { ok: true, lockMins: mins };
},
updatePayday: async (event: RequestEvent) => {
const famId = famIdOf(event);
const fd = await event.request.formData();
@@ -89,6 +89,8 @@
let members = $derived(famStore.initialized ? famStore.members : data.members || []);
let deletingSeason = $state<any>(null);
let issueModal = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
let pinModal = $state<{ id: string; name: string; pin: string; unset?: boolean } | null>(null);
let lockMins = $state(String(data.lockMins ?? 10));
let seasonColor = $state('#6366f1');
const seasonColors = [
'#6366f1',
@@ -363,6 +365,32 @@
</div>
</div>
{/if}
<Card title="Shared computer" cols={1}>
<p class="hint">
If kids share this computer, FamDone returns to the profile picker after a break so nobody
lands on the wrong chores. Children pick a 3-digit PIN when they join.
</p>
<form
method="POST"
action="?/updateLock"
use:enhance={() => {
return async ({ result }) => {
if (result.type !== 'success') return;
const d = result.data as any;
if (d?.ok) lockMins = String(d.lockMins);
else if (d?.error) alert(d.error);
};
}}
>
<select name="mins" bind:value={lockMins}>
<option value="0">Off — keep the last profile</option>
<option value="2">After 2 minutes of no use</option>
<option value="10">After 10 minutes of no use</option>
</select>
<Button type="submit" size="sm">Save</Button>
</form>
</Card>
</CardGrid>
</AccordionItem>
@@ -478,6 +506,32 @@
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
</span>
</span>
<form
method="POST"
action="?/revealPin"
use:enhance={() => {
return async ({ formData, result }) => {
if (result.type !== 'success') return;
const d = result.data as any;
if (d?.ok) {
pinModal = {
id: String(formData.get('id') || ''),
name: d.name || m.name,
pin: d.pin || '',
unset: !!d.unset
};
} else if (d?.error) {
alert(d.error);
}
};
}}
class="inline"
>
<input type="hidden" name="id" value={m.id} />
<Button type="submit" variant="ghost" size="sm" title="Shared-computer PIN"
>PIN</Button
>
</form>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button
@@ -492,6 +546,58 @@
</ul>
</Card>
{#if pinModal}
<div class="overlay" onclick={() => (pinModal = null)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>{pinModal.name}'s PIN</h3>
{#if pinModal.unset}
<p class="hint">
No PIN set yet. Set one so {pinModal.name} can pick their profile on a shared computer.
</p>
<form
method="POST"
action="?/resetPin"
use:enhance={() => {
return async ({ result }) => {
if (result.type !== 'success') return;
const d = result.data as any;
if (d?.ok) pinModal = { ...pinModal!, pin: d.pin, unset: false };
else if (d?.error) alert(d.error);
};
}}
>
<input type="hidden" name="id" value={pinModal.id} />
<Button type="submit" size="sm" variant="primary">Set a new PIN</Button>
</form>
{:else}
<p class="code-display">{pinModal.pin}</p>
<p class="hint">
Read it out if they've forgotten. They can change it themselves in Preferences.
</p>
<form
method="POST"
action="?/resetPin"
use:enhance={() => {
return async ({ result }) => {
if (result.type !== 'success') return;
const d = result.data as any;
if (d?.ok) pinModal = { ...pinModal!, pin: d.pin, unset: false };
else if (d?.error) alert(d.error);
};
}}
class="inline"
>
<input type="hidden" name="id" value={pinModal.id} />
<Button type="submit" size="sm" variant="secondary">Give a new PIN</Button>
</form>
{/if}
<div class="modal-actions">
<button type="button" onclick={() => (pinModal = null)}>Close</button>
</div>
</div>
</div>
{/if}
<Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
<form
@@ -1162,6 +1268,8 @@
margin-top: 0.5rem;
}
@media (max-width: 639px) {
.code-display { font-size: 1.5em; }
.code-display {
font-size: 1.5em;
}
}
</style>
+14 -9
View File
@@ -1,7 +1,12 @@
import { fail, redirect } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import { redeemOtp } from '$lib/server/member-otp';
import { handle } from '@shared/slugify';
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
import {
clearLegacyCookies,
setChildSessionCookie,
setActiveChild,
scanChildSessions
} from '$lib/server/session';
export const actions = {
default: async (event) => {
@@ -14,11 +19,14 @@ export const actions = {
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', name, otp });
try {
// redeemOtp derives the username from the handle internally; pass the
// raw name so it resolves the same {famSlug}:{handle} identity.
const token = await redeemOtp({ famSlug: fam, username: name, otp });
// Children join onto a shared-device session (pb_token_<id> +
// pb_active) so siblings' sessions on this computer survive.
const { token, userId } = await redeemOtp({ famSlug: fam, username: name, otp });
clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token);
setChildSessionCookie(event.cookies, userId, token);
setActiveChild(event.cookies, userId);
const hasOtherKids = scanChildSessions(event.cookies).length > 1;
return { joined: true, famSlug: fam, username: handle(name), hasOtherKids };
} catch (e) {
return fail(400, {
error: e instanceof Error ? e.message : 'Join failed',
@@ -26,8 +34,5 @@ export const actions = {
otp
});
}
const handleName = handle(name);
throw redirect(303, `/${fam}/${encodeURIComponent(handleName)}`);
}
};
+59 -43
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { Button } from '$lib/components';
import { Button, JoinPinFlow } from '$lib/components';
import { homeIcon } from '$lib/components/icons';
const famSlug = page.params.fam;
let name = $state('');
let otp = $state(page.url.searchParams.get('code') || '');
let joined = $state<{ username: string; hasOtherKids: boolean } | null>(null);
let { form } = $props();
</script>
@@ -19,51 +20,66 @@
>
<span class="home-badge">{@html homeIcon}</span>
</div>
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
<p class="mt-1 text-sm text-slate-500">
Enter your name and the code your parent gave you to get started.
</p>
<form
class="mt-6 flex flex-col gap-3"
method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || '';
}
await update();
};
}}
>
<input
type="text"
name="name"
bind:value={name}
placeholder="Your name"
autocomplete="name"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
{#if joined}
<JoinPinFlow
targetUrl={`/${famSlug}/${encodeURIComponent(joined.username)}`}
force={joined.hasOtherKids}
/>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg">Join</Button>
</form>
{:else}
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
<p class="mt-1 text-sm text-slate-500">
Enter your name and the code your parent gave you to get started.
</p>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p>
<form
class="mt-6 flex flex-col gap-3"
method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || '';
await update();
return;
}
const d = (result as any).data;
if (d?.joined) {
joined = { username: d.username, hasOtherKids: !!d.hasOtherKids };
return;
}
await update();
};
}}
>
<input
type="text"
name="name"
bind:value={name}
placeholder="Your name"
autocomplete="name"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg">Join</Button>
</form>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p>
{/if}
</section>
</main>
@@ -1,7 +1,14 @@
import { fail, redirect } from '@sveltejs/kit';
import { redeemOtp, redeemParentOtp } from '$lib/server/member-otp';
import { createSuperClient } from '$lib/server/pocketbase';
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
import {
setSessionCookie,
clearLegacyCookies,
setChildSessionCookie,
setActiveChild,
scanChildSessions,
clearActiveChild
} from '$lib/server/session';
import { famUsername, handle } from '@shared/slugify';
export async function load(event) {
@@ -58,15 +65,25 @@ export const actions = {
}
}
const token = isParent
? await redeemParentOtp({ famSlug: fam, username, otp, password })
: await redeemOtp({ famSlug: fam, username, otp });
if (isParent) {
const token = await redeemParentOtp({ famSlug: fam, username, otp, password });
clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token);
// A parent join supersedes kid mode on a shared device.
clearActiveChild(event.cookies);
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
}
// Child: shared-device session (pb_token_<id> + pb_active) so
// siblings' sessions on this computer survive.
const { token, userId } = await redeemOtp({ famSlug: fam, username, otp });
clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token);
setChildSessionCookie(event.cookies, userId, token);
setActiveChild(event.cookies, userId);
const hasOtherKids = scanChildSessions(event.cookies).length > 1;
return { joined: true, famSlug: fam, username, hasOtherKids };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Join failed' });
}
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
}
};
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { Button } from '$lib/components';
import { Button, JoinPinFlow } from '$lib/components';
import { homeIcon } from '$lib/components/icons';
const famSlug = page.params.fam;
@@ -9,6 +9,7 @@
let otp = $state(page.url.searchParams.get('code') || '');
let password = $state('');
let confirmPassword = $state('');
let joined = $state<{ hasOtherKids: boolean } | null>(null);
let { data, form } = $props();
const isParent = $derived(data?.isParent);
@@ -24,73 +25,88 @@
>
<span class="home-badge">{@html homeIcon}</span>
</div>
{#if isParent}
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
<p class="mt-1 text-sm text-slate-500">
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code from your
email and create a password to join <strong>{famName}</strong> as a parent.
</p>
{#if joined}
<JoinPinFlow
targetUrl={`/${famSlug}/${encodeURIComponent(username || '')}`}
force={joined.hasOtherKids}
/>
{:else}
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
<p class="mt-1 text-sm text-slate-500">
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent gave
you to get started.
{#if isParent}
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
<p class="mt-1 text-sm text-slate-500">
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code from your
email and create a password to join <strong>{famName}</strong> as a parent.
</p>
{:else}
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
<p class="mt-1 text-sm text-slate-500">
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent
gave you to get started.
</p>
{/if}
<form
class="mt-6 flex flex-col gap-3"
method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
otp = (result as any).data?.otp || '';
await update();
return;
}
const d = (result as any).data;
if (d?.joined) {
joined = { hasOtherKids: !!d.hasOtherKids };
return;
}
// Parent joins redirect server-side (303) — nothing to do here.
await update();
};
}}
>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if isParent}
<input
type="password"
name="password"
bind:value={password}
placeholder="Create a password"
autocomplete="new-password"
minlength="10"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="password"
name="confirmPassword"
bind:value={confirmPassword}
placeholder="Confirm password"
autocomplete="new-password"
minlength="10"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{/if}
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg"
>{isParent ? 'Set up my account' : 'Join'}</Button
>
</form>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p>
{/if}
<form
class="mt-6 flex flex-col gap-3"
method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
otp = (result.data as any)?.otp || '';
}
await update();
};
}}
>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if isParent}
<input
type="password"
name="password"
bind:value={password}
placeholder="Create a password"
autocomplete="new-password"
minlength="10"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="password"
name="confirmPassword"
bind:value={confirmPassword}
placeholder="Confirm password"
autocomplete="new-password"
minlength="10"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{/if}
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg"
>{isParent ? 'Set up my account' : 'Join'}</Button
>
</form>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p>
</section>
</main>
+16 -8
View File
@@ -1,7 +1,12 @@
import { fail, redirect } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import { redeemOtp } from '$lib/server/member-otp';
import { slugify, handle } from '@shared/slugify';
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
import {
clearLegacyCookies,
setChildSessionCookie,
setActiveChild,
scanChildSessions
} from '$lib/server/session';
export const actions = {
default: async (event) => {
@@ -12,13 +17,19 @@ export const actions = {
if (!famName) return fail(400, { error: 'Enter your family name.', famName, name, otp });
if (!name) return fail(400, { error: 'Enter your name.', famName, name, otp });
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', famName, name, otp });
if (!otp)
return fail(400, { error: 'Enter the code shown by your parent.', famName, name, otp });
const famSlug = slugify(famName);
try {
const token = await redeemOtp({ famSlug, username: name, otp });
// Children join onto a shared-device session (pb_token_<id> +
// pb_active) so siblings' sessions on this computer survive.
const { token, userId } = await redeemOtp({ famSlug, username: name, otp });
clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token);
setChildSessionCookie(event.cookies, userId, token);
setActiveChild(event.cookies, userId);
const hasOtherKids = scanChildSessions(event.cookies).length > 1;
return { joined: true, famSlug, username: handle(name), hasOtherKids };
} catch (e) {
return fail(400, {
error: e instanceof Error ? e.message : 'Join failed',
@@ -27,8 +38,5 @@ export const actions = {
otp
});
}
const handleName = handle(name);
throw redirect(303, `/${famSlug}/${encodeURIComponent(handleName)}`);
}
};
+75 -53
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components';
import { Button, JoinPinFlow } from '$lib/components';
import { homeIcon } from '$lib/components/icons';
let name = $state('');
let family = $state('');
let otp = $state('');
let joined = $state<{ famSlug: string; username: string; hasOtherKids: boolean } | null>(null);
let { form } = $props();
</script>
@@ -13,63 +14,84 @@
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl"
>
<span class="home-badge">{@html homeIcon}</span>
</div>
<h1 class="text-xl font-bold text-slate-900">Join the family</h1>
<p class="mt-1 text-sm text-slate-500">
Enter your family name and the code your parent gave you to get started.
</p>
<form
class="mt-6 flex flex-col gap-3"
method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
family = (result.data as any)?.family || '';
name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || '';
}
await update();
};
}}
>
<input
type="text"
name="family"
bind:value={family}
placeholder="Family name"
autocomplete="organization"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
{#if joined}
<JoinPinFlow
targetUrl={`/${joined.famSlug}/${encodeURIComponent(joined.username)}`}
force={joined.hasOtherKids}
/>
<input
type="text"
name="name"
bind:value={name}
placeholder="Your name"
autocomplete="name"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg">Join</Button>
</form>
{:else}
<h1 class="text-xl font-bold text-slate-900">Join the family</h1>
<p class="mt-1 text-sm text-slate-500">
Enter your family name and the code your parent gave you to get started.
</p>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p>
<form
class="mt-6 flex flex-col gap-3"
method="POST"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
family = (result.data as any)?.family || '';
name = (result.data as any)?.name || '';
otp = (result.data as any)?.otp || '';
await update();
return;
}
const d = (result as any).data;
if (d?.joined) {
joined = {
famSlug: d.famSlug,
username: d.username,
hasOtherKids: !!d.hasOtherKids
};
return;
}
await update();
};
}}
>
<input
type="text"
name="family"
bind:value={family}
placeholder="Family name"
autocomplete="organization"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="text"
name="name"
bind:value={name}
placeholder="Your name"
autocomplete="name"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<input
type="text"
name="otp"
bind:value={otp}
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
{#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if}
<Button type="submit" variant="primary" size="lg">Join</Button>
</form>
<p class="mt-6 text-xs text-slate-400">
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
</p>
{/if}
</section>
</main>
+3 -1
View File
@@ -1,6 +1,6 @@
import { fail, redirect } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase';
import { setSessionCookie } from '$lib/server/session';
import { setSessionCookie, clearActiveChild } from '$lib/server/session';
import { pbAdmin } from '$lib/server/pocketbase';
import { handleOf } from '@shared/slugify';
@@ -27,6 +27,8 @@ export const actions = {
}
setSessionCookie(event.cookies, authResult.token);
// An explicit email/password login supersedes kid mode on a shared device.
clearActiveChild(event.cookies);
const fam = await pbAdmin.getOne('fams', user.famId).catch(() => null);
const famSlug = fam?.slug || user.famId;
+3 -1
View File
@@ -1,9 +1,11 @@
import { redirect } from '@sveltejs/kit';
import { clearSessionCookie, clearLegacyCookies } from '$lib/server/session';
import { clearSessionCookie, clearLegacyCookies, clearDeviceSessions } from '$lib/server/session';
function signOut(event: { cookies: any }) {
clearSessionCookie(event.cookies);
clearLegacyCookies(event.cookies);
// Shared device: also drop every child session + the active pointer.
clearDeviceSessions(event.cookies);
}
export const actions = {