Files
hub/public/login.js
T
nmemmertandClaude Sonnet 5.5 301e8017d9
build-container / build (push) Successful in 3m52s
Parent controls for children, name-tile sign-in, restricted Learn search, simple mode
- Sign-in page shows name tiles on remembered devices; tap a name, then PIN or passkey
- Music and Movies are separate on the server; Plex can be locked to a child's managed profile
- Activity log, weekly summary and alerts to parents; per-area and weekend limits
- Requests from children (Ask button) answered in Settings; bonus screen time from rewards
- Restricted Learn search (Simple English Wikipedia, blocked words) replaces web search for children
- Simple mode (big-button home) for young children or grandparents
- Child usage kept in memory instead of read from disk on every request
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:49:48 -04:00

76 lines
7.1 KiB
JavaScript

const $ = s => document.querySelector(s);
const fail = e => { $('#err').textContent = e && e.name === 'NotAllowedError' ? 'Cancelled. Try again.' : (e.message || String(e)); };
async function signIn() {
$('#err').textContent = '';
try { await passkeyLogin(); location.replace('/'); } catch (e) { fail(e); }
}
// ---- Name tiles: on a remembered device (a family screen or a person's own device) the sign-in page shows who lives here. Tap your name, then use your PIN or passkey.
const escH = t => String(t == null ? '' : t).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
async function tilesLogin() {
let r; try { r = await fetch('/api/login/people'); } catch { return false; } if (!r.ok) return false;
const { people } = await r.json(); if (!people.length) return false;
document.querySelector('.box').classList.add('wide'); $('#sub').textContent = "Who's signing in? Tap your name";
const av = p => `<span class="av" style="background:${p.color}">${escH((p.name.trim()[0] || '?').toUpperCase())}</span>`;
const home = () => { $('#err').textContent = ''; $('#sub').textContent = "Who's signing in? Tap your name";
$('#ui').innerHTML = `<div class="tiles">${people.map(p => `<button class="tile" data-id="${escH(p.id)}">${av(p)}<b>${escH(p.name.split(' ')[0])}</b><small>${p.kid ? '<span class="kidtag">KID</span>' : ''}</small></button>`).join('')}</div>
<p style="margin-top:6px"><a href="#" id="other" style="color:var(--accent)">Sign in another way (any passkey, invite or recovery code)</a></p>`;
$('#ui').querySelectorAll('.tile').forEach(t => t.onclick = () => who(people.find(p => p.id === t.dataset.id)));
$('#other').onclick = e => { e.preventDefault(); classicLogin(); }; };
const who = p => {
$('#err').textContent = ''; $('#sub').textContent = '';
const tile = `<div class="tile" style="margin:0 auto;border:0;background:none;cursor:default">${av(p)}<b>${escH(p.name)}</b>${p.kid ? '<span class="kidtag">KID</span>' : ''}</div>`;
const back = '<p><a href="#" id="back" style="color:var(--accent)">Not you? Back to the names</a></p>', passkeyBtn = p.passkey ? '<p><button class="btn ghost" id="pk">Use my passkey instead</button></p>' : '';
const done = () => location.replace('/'), pkgo = async () => { $('#err').textContent = ''; try { await passkeyLogin(p.id); done(); } catch (e) { fail(e); } };
const wire = () => { const b = $('#back'); if (b) b.onclick = e => { e.preventDefault(); home(); }; const k = $('#pk'); if (k) k.onclick = pkgo; };
if (p.kid && !p.pin) { $('#ui').innerHTML = `${tile}<p>Sign in as <b>${escH(p.name)}</b>?</p><button class="btn" id="yes">Yes, that's me</button>${passkeyBtn}${back}`; wire(); $('#yes').onclick = async () => { try { await pinPost(p.id); done(); } catch (e) { fail(e); } }; return; }
if (!p.pin) { $('#ui').innerHTML = `${tile}<p>${escH(p.name)} has not set a PIN yet.</p>${p.passkey ? '<button class="btn" id="pk">Sign in with my passkey</button>' : '<p class="sub">Set a PIN or add a passkey in Settings on a device where you are signed in.</p>'}${back}`; wire(); return; }
let pin = ''; $('#ui').innerHTML = `${tile}<p class="sub">Enter your PIN</p><div class="dots" id="dots"></div><div class="pad">${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => `<button data-k="${k}">${k}</button>`).join('')}</div>${passkeyBtn}${back}`; wire();
const paint = () => { $('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => `<i class="${i < pin.length ? 'on' : ''}"></i>`).join(''); }; paint();
$('#ui').querySelectorAll('.pad button').forEach(b => b.onclick = async () => { let k = b.dataset.k; $('#err').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await pinPost(p.id, pin); done(); return; } catch (e) { fail(e); pin = ''; } } paint(); });
};
const pinPost = async (uid, pin) => { const r = await fetch('/api/login/pin', { method: 'POST', body: JSON.stringify({ uid, pin }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not sign in'); };
home(); return true;
}
async function init() {
const st = await (await fetch('/api/auth/state')).json();
if (st.authed) return location.replace('/');
if (st.registered && await tilesLogin()) return; // (a remembered device: name tiles, and the PIN works even where passkeys do not)
if (!window.PublicKeyCredential) { $('#sub').textContent = ''; return fail(new Error('This browser does not support passkeys.')); }
classicLogin(st);
}
async function classicLogin(st) {
if (!st) st = await (await fetch('/api/auth/state')).json();
if (st.registered) {
$('#sub').textContent = 'Sign in with your passkey';
$('#ui').innerHTML = '<button class="btn" id="go">Sign in with passkey</button>';
$('#go').onclick = signIn;
$('#ui').insertAdjacentHTML('beforeend', '<p style="margin-top:18px"><a href="#" id="inv" style="color:var(--accent)">New device or lost your devices? Use an invite or recovery code</a></p>');
$('#inv').onclick = e => {
e.preventDefault(); $('#err').textContent = ''; $('#sub').textContent = 'Add this device';
$('#ui').innerHTML = `<form id="f"><p class="sub">On a device that is already signed in, open Settings and tap <b>Invite a device</b>. Lost every device? Use one of the <b>recovery codes</b> you made in Settings.</p>
<label for="ic">Invite or recovery code</label><input type="text" id="ic" placeholder="ABCD-2345" autocomplete="off" autocapitalize="characters" required>
<label for="nm">Name this passkey</label><input type="text" id="nm" value="${/iPad|iPhone/.test(navigator.userAgent) ? 'iPhone/iPad' : 'This device'}" autocomplete="off">
<div class="bar"><button class="btn">Create passkey</button></div><p style="text-align:center"><a href="/login" style="color:var(--accent)">Back to sign in</a></p></form>`;
$('#f').onsubmit = async ev => {
ev.preventDefault(); $('#err').textContent = '';
try { await passkeyRegister('', $('#nm').value.trim() || 'Passkey', $('#ic').value.trim()); location.replace('/'); } catch (err) { fail(err); }
};
};
// Offer the passkey straight away unless the user came here to use an invite code.
signIn();
} else {
$('#sub').textContent = 'First-time setup: create a passkey for this device';
$('#ui').innerHTML = `<form id="f"><label for="sc">Setup code (from the server log or HUB_SETUP_CODE)</label>
<input type="text" id="sc" autocomplete="off" autocapitalize="off" required>
<label for="nm">Name this passkey</label><input type="text" id="nm" value="${/iPad|iPhone/.test(navigator.userAgent) ? 'iPhone/iPad' : 'This device'}" autocomplete="off">
<div class="bar"><button class="btn">Create passkey</button></div></form>`;
$('#f').onsubmit = async e => {
e.preventDefault(); $('#err').textContent = '';
try { await passkeyRegister($('#sc').value.trim(), $('#nm').value.trim() || 'Passkey'); location.replace('/'); } catch (err) { fail(err); }
};
}
}
init().catch(fail);