add shared login pin mechansim
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user