add shared computer feature
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
// Shared-device idle lock (client-side). Tracks the last interaction
|
||||||
|
// timestamp in localStorage and exposes "should the app return to the profile
|
||||||
|
// picker?" checks. localStorage (not cookies/beacons) so the timestamp
|
||||||
|
// survives browser close, sleeps and restarts — the pagehide write is
|
||||||
|
// synchronous and can't be lost on tab close.
|
||||||
|
|
||||||
|
const KEY = 'fam_last_active';
|
||||||
|
const WRITE_THROTTLE_MS = 10_000;
|
||||||
|
|
||||||
|
let installed = false;
|
||||||
|
let lastWrite = 0;
|
||||||
|
|
||||||
|
export function recordActivity(force = false) {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && now - lastWrite < WRITE_THROTTLE_MS) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, String(now));
|
||||||
|
lastWrite = now;
|
||||||
|
} catch {
|
||||||
|
/* private mode etc. — the lock simply has no data yet */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lastActiveAt(): number {
|
||||||
|
if (typeof localStorage === 'undefined') return 0;
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
const n = raw ? Number(raw) : 0;
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockDue(lockMins: number): boolean {
|
||||||
|
if (!lockMins || lockMins <= 0) return false;
|
||||||
|
const last = lastActiveAt();
|
||||||
|
if (!last) return false;
|
||||||
|
return Date.now() - last > lockMins * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installLockTracking() {
|
||||||
|
if (installed || typeof document === 'undefined') return;
|
||||||
|
installed = true;
|
||||||
|
const on = () => recordActivity();
|
||||||
|
document.addEventListener('click', on);
|
||||||
|
document.addEventListener('keydown', on);
|
||||||
|
document.addEventListener('touchstart', on);
|
||||||
|
window.addEventListener('pagehide', () => recordActivity(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh activation (join wizard, picker switch) — write now so the just-opened
|
||||||
|
// session doesn't instantly trip the mount-time lock check.
|
||||||
|
export function resetClock() {
|
||||||
|
recordActivity(true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Per-device "this is a shared computer" flag. localStorage is the source of
|
||||||
|
// truth (shared-ness is a property of THIS browser, not the family — a DB
|
||||||
|
// flag would force PIN mode on every device including a parent's phone).
|
||||||
|
// A plain cookie mirror lets server loads see it too (localStorage never
|
||||||
|
// reaches the server). Neither is a security boundary: the PIN + device
|
||||||
|
// session cookies remain the actual gate (see shared-device.md).
|
||||||
|
const LS_KEY = 'fam_shared_device';
|
||||||
|
const COOKIE = 'fam_shared_device';
|
||||||
|
|
||||||
|
export function isSharedDevice(): boolean {
|
||||||
|
if (typeof localStorage === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LS_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSharedDevice(on: boolean) {
|
||||||
|
try {
|
||||||
|
if (on) localStorage.setItem(LS_KEY, '1');
|
||||||
|
else localStorage.removeItem(LS_KEY);
|
||||||
|
} catch {
|
||||||
|
/* private mode etc. — flag simply doesn't persist */
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.cookie =
|
||||||
|
on
|
||||||
|
? `${COOKIE}=1; path=/; max-age=31536000; SameSite=Lax`
|
||||||
|
: `${COOKIE}=; path=/; max-age=0; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PinPad from './PinPad.svelte';
|
||||||
|
import { resetClock } from '$lib/client/lock';
|
||||||
|
|
||||||
|
// Post-join wizard for children on a shared device:
|
||||||
|
// 1. "Is this computer shared?" (skipped when the device already has
|
||||||
|
// other child sessions — evidence of sharing, PIN is required).
|
||||||
|
// 2. Pick a 3-digit PIN (optional on the question path).
|
||||||
|
// 3. Navigate to the child's dashboard.
|
||||||
|
let {
|
||||||
|
targetUrl = '',
|
||||||
|
force = false
|
||||||
|
}: {
|
||||||
|
targetUrl: string;
|
||||||
|
force?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// force: the device already had other kids' sessions → PIN required now.
|
||||||
|
let step = $state<'q' | 'p1' | 'p2'>(force ? 'p1' : 'q');
|
||||||
|
let pin1 = $state('');
|
||||||
|
let padKey = $state(0);
|
||||||
|
let status = $state('');
|
||||||
|
|
||||||
|
function go() {
|
||||||
|
resetClock();
|
||||||
|
window.location.assign(targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePin(pin: string) {
|
||||||
|
status = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/pins', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'ensure', pin })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not save PIN');
|
||||||
|
go();
|
||||||
|
} catch (e) {
|
||||||
|
status = e instanceof Error ? e.message : 'Could not save PIN';
|
||||||
|
step = 'p1';
|
||||||
|
padKey += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if step === 'q'}
|
||||||
|
<div class="wizard">
|
||||||
|
<h2 class="wizard-title">Is this computer shared?</h2>
|
||||||
|
<p class="wizard-sub">
|
||||||
|
Will your brothers or sisters use this computer too? A 3-digit PIN lets you switch to your
|
||||||
|
chores in a tap.
|
||||||
|
</p>
|
||||||
|
<div class="wizard-actions">
|
||||||
|
<button type="button" class="wizard-btn primary" onclick={() => (step = 'p1')}>Yes</button>
|
||||||
|
<button type="button" class="wizard-btn" onclick={go}>No — just me</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if step === 'p1'}
|
||||||
|
<div class="wizard">
|
||||||
|
<h2 class="wizard-title">Pick a PIN</h2>
|
||||||
|
<p class="wizard-sub">3 numbers that are easy for you to remember — you'll use it to switch.</p>
|
||||||
|
{#key padKey}
|
||||||
|
<PinPad
|
||||||
|
label="New PIN"
|
||||||
|
oncomplete={(pin) => {
|
||||||
|
pin1 = pin;
|
||||||
|
step = 'p2';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{#if status}<p class="wizard-error">{status}</p>{/if}
|
||||||
|
<button type="button" class="wizard-link" onclick={go}>Skip for now</button>
|
||||||
|
</div>
|
||||||
|
{:else if step === 'p2'}
|
||||||
|
<div class="wizard">
|
||||||
|
<h2 class="wizard-title">Confirm your PIN</h2>
|
||||||
|
<p class="wizard-sub">
|
||||||
|
Enter it once more — {pin1 ? `it starts with ${pin1[0]}·` : ''}your parent can always read it
|
||||||
|
out if you forget.
|
||||||
|
</p>
|
||||||
|
{#key padKey}
|
||||||
|
<PinPad label="Confirm PIN" oncomplete={savePin} />
|
||||||
|
{/key}
|
||||||
|
{#if status}<p class="wizard-error">{status}</p>{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="wizard-link"
|
||||||
|
onclick={() => {
|
||||||
|
step = 'p1';
|
||||||
|
padKey += 1;
|
||||||
|
}}>Back</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wizard {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.wizard-title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.wizard-sub {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.wizard-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.wizard-btn {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wizard-btn.primary {
|
||||||
|
background: #6366f1;
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.wizard-btn:hover {
|
||||||
|
filter: brightness(0.97);
|
||||||
|
}
|
||||||
|
.wizard-link {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.wizard-link:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.wizard-error {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
label = 'Enter your PIN',
|
||||||
|
busy = false,
|
||||||
|
oncomplete
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
busy?: boolean;
|
||||||
|
oncomplete: (pin: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let digits = $state<string[]>([]);
|
||||||
|
|
||||||
|
function press(d: string) {
|
||||||
|
if (busy || digits.length >= 3) return;
|
||||||
|
digits = [...digits, d];
|
||||||
|
if (digits.length === 3) oncomplete(digits.join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
digits = digits.slice(0, -1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pin-pad">
|
||||||
|
<p class="pin-label">{label}</p>
|
||||||
|
<div class="pin-dots" aria-hidden="true">
|
||||||
|
{#each [0, 1, 2] as i}
|
||||||
|
<span class="dot" class:filled={digits.length > i}></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="pin-keys">
|
||||||
|
{#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as d}
|
||||||
|
<button type="button" class="key" disabled={busy} onclick={() => press(d)}>{d}</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="key ghost"
|
||||||
|
disabled={busy || digits.length === 0}
|
||||||
|
onclick={() => back()}>⌫</button
|
||||||
|
>
|
||||||
|
<button type="button" class="key" disabled={busy} onclick={() => press('0')}>0</button>
|
||||||
|
<span class="key ghost"> </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pin-pad {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.pin-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.pin-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #cbd5e1;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.dot.filled {
|
||||||
|
background: #6366f1;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
.pin-keys {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 64px);
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.key {
|
||||||
|
height: 56px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.1s,
|
||||||
|
transform 0.05s;
|
||||||
|
}
|
||||||
|
.key:hover:not(:disabled) {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
.key:active:not(:disabled) {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
.key:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.key.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PinPad from './PinPad.svelte';
|
||||||
|
import { resetClock } from '$lib/client/lock';
|
||||||
|
|
||||||
|
export type QuickProfile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
username: string; // `{famSlug}:{handle}`
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
profiles = [],
|
||||||
|
famSlug = '',
|
||||||
|
famName = '',
|
||||||
|
activeId = '',
|
||||||
|
standalone = false,
|
||||||
|
oncancel = () => {}
|
||||||
|
}: {
|
||||||
|
profiles?: QuickProfile[];
|
||||||
|
famSlug?: string;
|
||||||
|
famName?: string;
|
||||||
|
activeId?: string;
|
||||||
|
standalone?: boolean;
|
||||||
|
oncancel?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let selectedId = $state('');
|
||||||
|
let padKey = $state(0);
|
||||||
|
let status = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
const selected = $derived(profiles.find((p) => p.id === selectedId) || null);
|
||||||
|
|
||||||
|
function initial(name: string) {
|
||||||
|
return (name || '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetUrl(p: QuickProfile) {
|
||||||
|
const handle = (p.username || '').split(':').pop() || p.id;
|
||||||
|
return `/${famSlug}/${encodeURIComponent(handle)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchTo(id: string, pin: string) {
|
||||||
|
busy = true;
|
||||||
|
status = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/switch-user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: id, pin })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not switch');
|
||||||
|
// Fresh activation — the mount-time lock check must not trip.
|
||||||
|
resetClock();
|
||||||
|
const target = profiles.find((p) => p.id === id);
|
||||||
|
window.location.assign(targetUrl(target || ({ id, username: '' } as QuickProfile)));
|
||||||
|
} catch (e) {
|
||||||
|
status = e instanceof Error ? e.message : 'Could not switch';
|
||||||
|
busy = false;
|
||||||
|
padKey += 1;
|
||||||
|
selectedId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProfile(id: string) {
|
||||||
|
const p = profiles.find((x) => x.id === id);
|
||||||
|
if (!p || !confirm(`Remove ${p.name} from this computer?`)) return;
|
||||||
|
await fetch('/api/device/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: id })
|
||||||
|
}).catch(() => {});
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="picker-backdrop" class:standalone>
|
||||||
|
<div class="picker-card">
|
||||||
|
{#if selected}
|
||||||
|
<h2 class="picker-title">Hi {selected.name}!</h2>
|
||||||
|
<p class="picker-sub">Enter your 3-digit PIN to switch.</p>
|
||||||
|
{#key padKey}
|
||||||
|
<PinPad {busy} oncomplete={(pin) => switchTo(selected.id, pin)} />
|
||||||
|
{/key}
|
||||||
|
{#if status}
|
||||||
|
<p class="picker-error">{status}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="picker-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link-btn"
|
||||||
|
onclick={() => {
|
||||||
|
selectedId = '';
|
||||||
|
status = '';
|
||||||
|
padKey += 1;
|
||||||
|
}}>Back</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<h2 class="picker-title">
|
||||||
|
{standalone ? famName || 'Welcome' : "Who's using the computer?"}
|
||||||
|
</h2>
|
||||||
|
<p class="picker-sub">
|
||||||
|
{standalone
|
||||||
|
? 'Pick your profile to get to your chores.'
|
||||||
|
: 'Pick a profile and enter its PIN.'}
|
||||||
|
</p>
|
||||||
|
<div class="picker-grid">
|
||||||
|
{#each profiles as p (p.id)}
|
||||||
|
<div
|
||||||
|
class="profile"
|
||||||
|
class:active={p.id === activeId}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
style={`--dot:${p.color}`}
|
||||||
|
onclick={() => (selectedId = p.id)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') selectedId = p.id;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="avatar" style="background:{p.color}">{initial(p.name)}</span>
|
||||||
|
<span class="pname">{p.name}</span>
|
||||||
|
{#if p.id === activeId}<span class="pill">current</span>{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-btn"
|
||||||
|
aria-label={`Remove ${p.name} from this computer`}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeProfile(p.id);
|
||||||
|
}}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if standalone}
|
||||||
|
<div class="picker-footer">
|
||||||
|
<a href={`/${famSlug}/join`}>Add a child with a code</a>
|
||||||
|
<span class="dot-sep">·</span>
|
||||||
|
<a href="/login">Parent login</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="picker-actions">
|
||||||
|
<button type="button" class="link-btn" onclick={oncancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.picker-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 120;
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.picker-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.picker-title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.picker-sub {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.profile {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.12s,
|
||||||
|
transform 0.05s;
|
||||||
|
}
|
||||||
|
.profile:hover {
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
}
|
||||||
|
.profile:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
.profile.active {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pname {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
}
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.remove-btn:hover {
|
||||||
|
background: #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.picker-error {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.picker-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.link-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.link-btn:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.picker-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.picker-footer a {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.picker-footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.dot-sep {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -36,6 +36,8 @@ export const bellIcon =
|
|||||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>';
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>';
|
||||||
export const chatIcon =
|
export const chatIcon =
|
||||||
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>';
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>';
|
||||||
|
export const monitorIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
|
||||||
export const sendIcon =
|
export const sendIcon =
|
||||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
||||||
export const checkCircleIcon =
|
export const checkCircleIcon =
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { createSuperClient } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
// Child shared-device PINs. Superuser-only collection (like `otp`): every
|
||||||
|
// read/write goes through server endpoints with session-role checks, so a
|
||||||
|
// child can never read a sibling's PIN via PB rules.
|
||||||
|
export const PIN_RE = /^\d{3}$/;
|
||||||
|
|
||||||
|
export function generatePin(): string {
|
||||||
|
const n = randomBytes(2).readUIntBE(0, 2) % 1000;
|
||||||
|
return n.toString().padStart(3, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPinRow(userId: string) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
return pb
|
||||||
|
.collection('pins')
|
||||||
|
.getFirstListItem(`userId='${userId}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPin(userId: string): Promise<string | null> {
|
||||||
|
const row = await getPinRow(userId);
|
||||||
|
return row?.pin || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasPin(userId: string): Promise<boolean> {
|
||||||
|
return (await getPin(userId)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPin(famId: string, userId: string, pin: string): Promise<void> {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
const row = await getPinRow(userId);
|
||||||
|
if (row) {
|
||||||
|
await pb.collection('pins').update(row.id, { pin });
|
||||||
|
} else {
|
||||||
|
await pb.collection('pins').create({ famId, userId, pin });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPin(famId: string, userId: string): Promise<string> {
|
||||||
|
const pin = generatePin();
|
||||||
|
await setPin(famId, userId, pin);
|
||||||
|
return pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPin(userId: string, pin: string): Promise<boolean> {
|
||||||
|
const row = await getPinRow(userId);
|
||||||
|
return !!row && row.pin === pin;
|
||||||
|
}
|
||||||
@@ -194,6 +194,10 @@ export async function load(event) {
|
|||||||
pickerFamName: '',
|
pickerFamName: '',
|
||||||
pickerChildren: [],
|
pickerChildren: [],
|
||||||
deviceChildIds,
|
deviceChildIds,
|
||||||
lockMins
|
lockMins,
|
||||||
|
// Per-device shared-computer flag (cookie mirror of the localStorage
|
||||||
|
// flag the TopNav toggle writes). Server can only hint — the client
|
||||||
|
// re-reads localStorage on hydration.
|
||||||
|
sharedDevice: event.cookies.get('fam_shared_device') === '1'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
import { notices } from '$lib/stores/notices.svelte';
|
import { notices } from '$lib/stores/notices.svelte';
|
||||||
import { Sidebar, TopNav, Footer, Chat, SharedPicker } from '$lib/components';
|
import { Sidebar, TopNav, Footer, Chat, SharedPicker } from '$lib/components';
|
||||||
import { chatIcon } from '$lib/components/icons';
|
import { chatIcon, monitorIcon } from '$lib/components/icons';
|
||||||
import { recordShortcut } from '$lib/shortcut';
|
import { recordShortcut } from '$lib/shortcut';
|
||||||
import { installLockTracking, lockDue } from '$lib/client/lock';
|
import { installLockTracking, lockDue } from '$lib/client/lock';
|
||||||
|
import { isSharedDevice, setSharedDevice } from '$lib/client/shared-device';
|
||||||
import { themeShades } from '$lib/theme';
|
import { themeShades } from '$lib/theme';
|
||||||
import '$lib/theme-patterns.css';
|
import '$lib/theme-patterns.css';
|
||||||
import { themeDraft } from '$lib/stores/theme.svelte';
|
import { themeDraft } from '$lib/stores/theme.svelte';
|
||||||
@@ -43,13 +44,59 @@
|
|||||||
.map((m: any) => ({ id: m.id, name: m.name, color: m.color, username: m.username }))
|
.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
|
// Shared-computer mode (per-device flag, NOT a fam setting — see
|
||||||
// interaction. localStorage-backed (see lib/client/lock.ts) so a closed
|
// lib/client/shared-device.ts). Makes the PIN system functional on this
|
||||||
// browser still trips the lock on next launch.
|
// browser: profile switcher + idle lock. Anyone logged in (or not) can
|
||||||
|
// flip it; server sees the cookie mirror as data.sharedDevice.
|
||||||
|
let sharedOn = $state(
|
||||||
|
typeof localStorage !== 'undefined' ? isSharedDevice() : !!(data as any).sharedDevice
|
||||||
|
);
|
||||||
|
let sharedToast = $state('');
|
||||||
|
|
||||||
|
async function toggleShared() {
|
||||||
|
sharedOn = !sharedOn;
|
||||||
|
setSharedDevice(sharedOn);
|
||||||
|
if (!sharedOn) {
|
||||||
|
sharedToast = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Just enabled — remind about PIN state. Kids without a PIN can't be
|
||||||
|
// picked until one is set (picker shows "ask a parent"); kids with one
|
||||||
|
// get a nudge to remember it. Values never exposed — only set/unset.
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/pins/status');
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const s = await res.json();
|
||||||
|
if (Array.isArray(s.children)) {
|
||||||
|
// Parent view: roster of who still needs a PIN.
|
||||||
|
const missing = s.children.filter((c: any) => !c.hasPin).map((c: any) => c.name);
|
||||||
|
const ready = s.children.filter((c: any) => c.hasPin).map((c: any) => c.name);
|
||||||
|
sharedToast =
|
||||||
|
missing.length > 0
|
||||||
|
? `Shared mode on — still need a PIN: ${missing.join(', ')} (set in Settings → Members).` +
|
||||||
|
(ready.length > 0 ? ` Ready: ${ready.join(', ')}.` : '')
|
||||||
|
: `Shared mode on — PINs ready for ${ready.length > 0 ? ready.join(', ') : 'everyone'}. Remind the kids!`;
|
||||||
|
} else if (typeof s.hasPin === 'boolean') {
|
||||||
|
// Child view: only their own state.
|
||||||
|
sharedToast = s.hasPin
|
||||||
|
? 'Shared mode on — remember your 3-digit PIN to switch back in!'
|
||||||
|
: 'Shared mode on — you need a PIN first. Ask a parent to set one up.';
|
||||||
|
} else {
|
||||||
|
sharedToast = 'Shared mode on for this computer.';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sharedToast = 'Shared mode on for this computer.';
|
||||||
|
}
|
||||||
|
setTimeout(() => (sharedToast = ''), 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle lock (children on shared devices 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(() => {
|
$effect(() => {
|
||||||
const isChild = data.session?.role === 'child';
|
const isChild = data.session?.role === 'child';
|
||||||
const lockMins = Number(data.lockMins) || 0;
|
const lockMins = Number(data.lockMins) || 0;
|
||||||
if (!isChild || lockMins <= 0 || data.picker) return;
|
if (!isChild || !sharedOn || lockMins <= 0 || data.picker) return;
|
||||||
installLockTracking();
|
installLockTracking();
|
||||||
if (lockDue(lockMins)) pickerOpen = true;
|
if (lockDue(lockMins)) pickerOpen = true;
|
||||||
if (!lockTimer) {
|
if (!lockTimer) {
|
||||||
@@ -258,7 +305,7 @@
|
|||||||
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
|
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if data.session && (data.deviceChildIds?.length || 0) > 0}
|
{#if data.session && sharedOn && (data.deviceChildIds?.length || 0) > 0}
|
||||||
<button
|
<button
|
||||||
class="kid-switch"
|
class="kid-switch"
|
||||||
onclick={() => (pickerOpen = true)}
|
onclick={() => (pickerOpen = true)}
|
||||||
@@ -269,6 +316,16 @@
|
|||||||
></span>
|
></span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button
|
||||||
|
class="shared-toggle"
|
||||||
|
class:on={sharedOn}
|
||||||
|
onclick={toggleShared}
|
||||||
|
aria-label="Toggle shared computer mode"
|
||||||
|
aria-pressed={sharedOn}
|
||||||
|
title={sharedOn ? 'Shared computer mode is ON' : 'Mark this as a shared computer'}
|
||||||
|
>
|
||||||
|
{@html monitorIcon}
|
||||||
|
</button>
|
||||||
</TopNav>
|
</TopNav>
|
||||||
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
|
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
|
||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
@@ -304,6 +361,9 @@
|
|||||||
{claimToast} — view dashboard →
|
{claimToast} — view dashboard →
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if sharedToast}
|
||||||
|
<div class="shared-toast" role="status">{sharedToast}</div>
|
||||||
|
{/if}
|
||||||
<Footer sidebar />
|
<Footer sidebar />
|
||||||
</div>
|
</div>
|
||||||
{#if chatStore.open}
|
{#if chatStore.open}
|
||||||
@@ -491,6 +551,42 @@
|
|||||||
border: 2px solid #fff;
|
border: 2px solid #fff;
|
||||||
box-shadow: 0 0 0 1px #e2e8f0;
|
box-shadow: 0 0 0 1px #e2e8f0;
|
||||||
}
|
}
|
||||||
|
.shared-toggle {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #9ca3af;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.shared-toggle:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.shared-toggle.on {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.25);
|
||||||
|
}
|
||||||
|
.shared-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #1f2937;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 96;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: min(92vw, 560px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.picker-stage {
|
.picker-stage {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// The fam layout renders the shared-device picker when child sessions exist
|
||||||
|
// but none is active. With an active session this route just sends you home.
|
||||||
|
export async function load(event) {
|
||||||
|
const session = event.locals.user;
|
||||||
|
if (session) {
|
||||||
|
const fam = event.params.fam;
|
||||||
|
if (session.role === 'parent') throw redirect(303, `/${fam}`);
|
||||||
|
throw redirect(303, `/${fam}/${encodeURIComponent(session.username || '')}`);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Shared-device profile picker. The fam layout renders the picker itself in
|
||||||
|
picker mode (no active session, child sessions present) — this page only
|
||||||
|
exists so /{fam}/switch resolves as a route. -->
|
||||||
|
<div></div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
ACTIVE_COOKIE,
|
||||||
|
childSessionCookie,
|
||||||
|
clearChildSession,
|
||||||
|
clearActiveChild
|
||||||
|
} from '$lib/server/session';
|
||||||
|
|
||||||
|
// Remove ONE child profile from this device (picker ✕). Device-local cookie
|
||||||
|
// surgery only — no PB writes, no session required (the picker is reachable
|
||||||
|
// with no active user).
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
const { userId } = body as { userId?: string };
|
||||||
|
if (!userId) throw error(400, 'Missing userId');
|
||||||
|
if (!event.cookies.get(childSessionCookie(userId))) {
|
||||||
|
throw error(400, 'No session on this device');
|
||||||
|
}
|
||||||
|
clearChildSession(event.cookies, userId);
|
||||||
|
if (event.cookies.get(ACTIVE_COOKIE) === userId) clearActiveChild(event.cookies);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { getPin, setPin, verifyPin, hasPin, PIN_RE } from '$lib/server/pins';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
// PIN status for the shared-device toggle reminders. Never reveals values:
|
||||||
|
// - child → whether THEIR OWN pin is set (about self only);
|
||||||
|
// - parent → per-child set/unset roster (names + booleans, no PIN values;
|
||||||
|
// actual values stay behind the settings revealPin action).
|
||||||
|
export async function GET(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
if (u.role === 'child') {
|
||||||
|
return json({ hasPin: await hasPin(u.id) });
|
||||||
|
}
|
||||||
|
if (u.role !== 'parent') throw error(403, 'Forbidden');
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
const kids: any[] = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getFullList({ filter: `famId = '${u.famId}' && role = 'child'` })
|
||||||
|
.catch(() => []);
|
||||||
|
const children = await Promise.all(
|
||||||
|
kids.map(async (k: any) => ({
|
||||||
|
userId: k.id,
|
||||||
|
name: k.name || '?',
|
||||||
|
hasPin: await hasPin(k.id)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return json({ children });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child PIN management. Session-role checked here (PB rules are superuser-only
|
||||||
|
// on `pins`): only the child themselves can set/change their own PIN.
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u) throw error(401, 'Unauthorized');
|
||||||
|
if (u.role !== 'child') throw error(403, 'Only children use PINs');
|
||||||
|
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
const { action, pin, currentPin } = body as {
|
||||||
|
action?: string;
|
||||||
|
pin?: string;
|
||||||
|
currentPin?: string;
|
||||||
|
};
|
||||||
|
if (!action || !pin || !PIN_RE.test(pin)) {
|
||||||
|
throw error(400, 'PIN must be exactly 3 digits');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'set') {
|
||||||
|
if (await getPin(u.id)) throw error(400, 'PIN already set');
|
||||||
|
await setPin(u.famId, u.id, pin);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join wizard: assign the PIN on THIS device without touching an existing
|
||||||
|
// one (a kid joining a second shared device already has a PIN — their pin
|
||||||
|
// works everywhere; nobody can silently reassign it).
|
||||||
|
if (action === 'ensure') {
|
||||||
|
if (!(await getPin(u.id))) await setPin(u.famId, u.id, pin);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'change') {
|
||||||
|
const existing = await getPin(u.id);
|
||||||
|
if (!existing) throw error(400, 'No PIN set yet');
|
||||||
|
if (!currentPin || !(await verifyPin(u.id, currentPin))) {
|
||||||
|
throw error(401, 'Current PIN is wrong');
|
||||||
|
}
|
||||||
|
await setPin(u.famId, u.id, pin);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(400, 'Unknown action');
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient, createSuperClient } from '$lib/server/pocketbase';
|
||||||
|
import { childSessionCookie, setChildSessionCookie, setActiveChild } from '$lib/server/session';
|
||||||
|
import { verifyPin } from '$lib/server/pins';
|
||||||
|
import { derivePassword } from '$lib/server/member-otp';
|
||||||
|
import { famUsername } from '@shared/slugify';
|
||||||
|
|
||||||
|
// Shared-device PIN switch. The PIN *selects* among sessions that already
|
||||||
|
// exist on this device — it is not a credential that mints anything on a
|
||||||
|
// fresh device. The target must have a pb_token_<userId> cookie; the PIN is
|
||||||
|
// verified server-side via the superuser client (children must never read
|
||||||
|
// siblings' pins), with a small per-user rate limit on the 3-digit space.
|
||||||
|
|
||||||
|
const MAX_TRIES = 5;
|
||||||
|
const LOCK_MS = 30_000;
|
||||||
|
const tries = new Map<string, { fails: number; until: number }>();
|
||||||
|
|
||||||
|
function checkRateLimit(userId: string) {
|
||||||
|
const rec = tries.get(userId);
|
||||||
|
if (rec && rec.until > Date.now()) {
|
||||||
|
throw error(429, 'Too many attempts — try again in a few seconds');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteFailure(userId: string) {
|
||||||
|
const rec = tries.get(userId) || { fails: 0, until: 0 };
|
||||||
|
rec.fails += 1;
|
||||||
|
if (rec.fails >= MAX_TRIES) {
|
||||||
|
rec.until = Date.now() + LOCK_MS;
|
||||||
|
rec.fails = 0;
|
||||||
|
}
|
||||||
|
tries.set(userId, rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
const { userId, pin } = body as { userId?: string; pin?: string };
|
||||||
|
if (!userId || !pin) throw error(400, 'Missing userId or pin');
|
||||||
|
if (!event.cookies.get(childSessionCookie(userId))) {
|
||||||
|
throw error(400, 'That profile has no session on this device');
|
||||||
|
}
|
||||||
|
|
||||||
|
checkRateLimit(userId);
|
||||||
|
if (!(await verifyPin(userId, String(pin)))) {
|
||||||
|
noteFailure(userId);
|
||||||
|
throw error(401, 'Wrong PIN — try again');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a usable token: refresh the device cookie; if it has expired,
|
||||||
|
// re-mint via the derived password (server-side only — the child never
|
||||||
|
// knows it, and a stale session heals itself on switch).
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
const user = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getOne(userId)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!user || user.role !== 'child') throw error(400, 'Not a child account');
|
||||||
|
|
||||||
|
let freshToken = '';
|
||||||
|
try {
|
||||||
|
const cookieToken = event.cookies.get(childSessionCookie(userId));
|
||||||
|
if (cookieToken) {
|
||||||
|
const { token } = await createPbClient(cookieToken).collection('users').authRefresh();
|
||||||
|
freshToken = token;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* expired — re-mint below */
|
||||||
|
}
|
||||||
|
if (!freshToken) {
|
||||||
|
// username = `{famSlug}:{handle}` — the server can always re-mint.
|
||||||
|
const [famSlug, handleName] = (user.username || '').split(':');
|
||||||
|
if (!famSlug || !handleName) throw error(400, 'Cannot restore session');
|
||||||
|
const auth = createPbClient();
|
||||||
|
const { token } = await auth
|
||||||
|
.collection('users')
|
||||||
|
.authWithPassword(famUsername(famSlug, handleName), derivePassword(famSlug, handleName));
|
||||||
|
freshToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChildSessionCookie(event.cookies, userId, freshToken);
|
||||||
|
setActiveChild(event.cookies, userId);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Shared-device switching (PIN)
|
||||||
|
|
||||||
|
Design decisions for children sharing a family computer/tablet — recorded
|
||||||
|
2026-09-11. Implements: multi-session cookies, `pins` collection, profile
|
||||||
|
picker, and the idle lock.
|
||||||
|
|
||||||
|
## Model
|
||||||
|
|
||||||
|
- **The PIN is NOT a login.** It selects among sessions that already exist on
|
||||||
|
the device. A PIN alone mints nothing on a fresh device; a stolen cookie
|
||||||
|
alone selects nothing without the PIN. Threat model: sibling mischief on a
|
||||||
|
family device (devtools-level bypass is explicitly accepted — data is
|
||||||
|
family-scoped and actions are reversible).
|
||||||
|
- Per-device state lives in cookies:
|
||||||
|
- `pb_token_<userId>` — one httpOnly cookie per child with a session on
|
||||||
|
this device (the "device is logged into both accounts" property).
|
||||||
|
- `pb_active` — which child session is currently active.
|
||||||
|
- `pb_token` — unchanged single session for parents (and legacy children).
|
||||||
|
Parent login supersedes kid mode by clearing `pb_active`; a kid switch
|
||||||
|
shadows (but does not delete) a parent session; resolving order is
|
||||||
|
`pb_active` first, then `pb_token`.
|
||||||
|
- Sessions are never the security boundary: the OTP gate still guards each
|
||||||
|
child's first-ever join on a device, and switching re-mints an expired token
|
||||||
|
server-side via the derived password (never exposed to the client).
|
||||||
|
|
||||||
|
## `pins` collection
|
||||||
|
|
||||||
|
- Superuser-only rules (like `otp`); all reads/writes via server endpoints
|
||||||
|
with session-role checks. Plaintext 3-digit PIN — deliberately recoverable
|
||||||
|
so a parent can read it out (Q1: View, not reset). Child changes their own
|
||||||
|
PIN in Preferences (requires current PIN).
|
||||||
|
- Fields: `famId` (rel), `userId` (rel), `pin` (text).
|
||||||
|
|
||||||
|
## Switch flow
|
||||||
|
|
||||||
|
`POST /api/switch-user { userId, pin }`:
|
||||||
|
|
||||||
|
1. Request must carry `pb_token_<userId>` (the profile must be on this
|
||||||
|
device). No session required — the picker is reachable with no active user.
|
||||||
|
2. PIN verified server-side via superuser (children must NOT be able to read
|
||||||
|
each other's pins, so collection rules can't be the check).
|
||||||
|
3. In-memory rate limit: 5 fails → 30s lockout per userId (3-digit space).
|
||||||
|
4. Refresh the device cookie's token; if expired, re-auth with the derived
|
||||||
|
password (heals sessions on switch).
|
||||||
|
5. Set `pb_token_<userId>` + `pb_active`; client full-reloads to that child's
|
||||||
|
dashboard URL (`/famSlug/handle`).
|
||||||
|
|
||||||
|
Per-profile removal: `POST /api/device/remove { userId }` — deletes that
|
||||||
|
cookie (+ `pb_active` if it was active). No session required (device-local
|
||||||
|
cookie surgery only).
|
||||||
|
|
||||||
|
## Picker
|
||||||
|
|
||||||
|
One component (`SharedPicker`) used everywhere:
|
||||||
|
|
||||||
|
- TopNav switcher button (always visible when the device has child sessions).
|
||||||
|
- Idle-lock return (overlay).
|
||||||
|
- `/{famSlug}/switch` — standalone landing when the device has child sessions
|
||||||
|
but no active session (e.g. after per-profile logout). The fam layout
|
||||||
|
redirects unauthenticated device requests to it. Links: "Add a child with a
|
||||||
|
code" → `/{famSlug}/join`, "Parent login" → `/login`.
|
||||||
|
|
||||||
|
Every pick (including "resume" as the current kid) requires the PIN.
|
||||||
|
|
||||||
|
## Idle lock
|
||||||
|
|
||||||
|
- Default 10 min; parent-adjustable in Family Settings: `Off / 2 / 10`
|
||||||
|
(`settings.lockMins`, 0 = off; `shared-device.md` settled default 10).
|
||||||
|
- Client-side only, child sessions only (parent sessions never lock).
|
||||||
|
- **Only fires when shared-computer mode is ON for the device** (see below) —
|
||||||
|
a personal device never locks, even with lockMins set.
|
||||||
|
- `localStorage['fam_last_active']` updated on click/key/touch (throttled
|
||||||
|
10s) and force-written on `pagehide` — survives browser close, sleep and
|
||||||
|
restarts. Next launch compares elapsed time; past the timeout → picker.
|
||||||
|
Live timer (15s interval) does the same mid-session.
|
||||||
|
- Resets: join wizard and picker switch write a fresh timestamp before
|
||||||
|
navigating, so freshly-activated sessions don't instantly lock.
|
||||||
|
|
||||||
|
## Shared-computer toggle (TopNav)
|
||||||
|
|
||||||
|
- **Storage: localStorage, NOT the DB.** Shared-ness is a property of *this
|
||||||
|
browser* — a DB flag on `fams`/`settings` would force PIN mode on every
|
||||||
|
device including a parent's phone. `localStorage['fam_shared_device']`
|
||||||
|
is the source of truth; a plain `fam_shared_device=1` cookie mirrors it
|
||||||
|
so server loads see it (`data.sharedDevice`). Neither is a security
|
||||||
|
boundary — the PIN + device session cookies remain the gate.
|
||||||
|
- **Location:** monitor icon in the TopNav (fam layout children slot, next
|
||||||
|
to the profile switcher). Rendered for any role, even logged out —
|
||||||
|
whoever holds the device can mark it shared. Active state: indigo fill.
|
||||||
|
- **What it gates:**
|
||||||
|
- Profile switcher (`kid-switch`) button — only shown when shared mode
|
||||||
|
is ON and the device holds child sessions.
|
||||||
|
- Idle lock — only fires for child sessions on shared devices.
|
||||||
|
- (Server `/switch` redirect unchanged — the picker is harmless on
|
||||||
|
- non-shared devices and the server can't do better without the cookie.)
|
||||||
|
- **PIN reminders on enable** (`GET /api/pins/status`, set/unset only —
|
||||||
|
values never leave the server except via the settings `revealPin`
|
||||||
|
action):
|
||||||
|
- Parent toggling sees the roster: who still needs a PIN (→ Settings →
|
||||||
|
Members) vs who is ready (→ read the PINs out to them).
|
||||||
|
- A child toggling sees only their own state: "remember your PIN" or
|
||||||
|
- "ask a parent to set one up" (no PIN → picker shows "ask a parent").
|
||||||
|
- Client flag helpers: `lib/client/shared-device.ts`
|
||||||
|
(`isSharedDevice` / `setSharedDevice`, writes the cookie mirror too).
|
||||||
|
|
||||||
|
## Join flow (child)
|
||||||
|
|
||||||
|
After OTP redeem:
|
||||||
|
|
||||||
|
1. If the device already has other child sessions → PIN setup (required).
|
||||||
|
2. Else ask "Will this computer be shared with a sibling?" — yes → PIN setup;
|
||||||
|
no → skip (PIN can be added later in Preferences; parents can set it in
|
||||||
|
Family Settings).
|
||||||
|
3. Land on the child's dashboard.
|
||||||
|
|
||||||
|
Parents keep the existing join (set own password, single session).
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- Two tabs share the timestamp → the active tab keeps the other from locking.
|
||||||
|
- Tab switches don't lock (kids alt-tab constantly).
|
||||||
|
- Expired sessions: the picker still shows the profile; switching re-mints.
|
||||||
|
- No PIN set on a profile → picker shows "ask a parent to set it up".
|
||||||
Reference in New Issue
Block a user