Parent controls for children, name-tile sign-in, restricted Learn search, simple mode
build-container / build (push) Successful in 3m52s
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>
This commit is contained in:
1 parent
aac6f6efc2
commit
301e8017d9
34 files changed
+466
-51
No files matched your search
+33
-1
@@ -6,10 +6,42 @@ async function signIn() {
|
||||
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() {
|
||||
if (!window.PublicKeyCredential) { $('#sub').textContent = ''; return fail(new Error('This browser does not support passkeys.')); }
|
||||
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>';
|
||||
|
||||
Reference in new issue
Block a user