build-container / build (push) Successful in 3m52s
- 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>
76 lines
7.1 KiB
JavaScript
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|