Dashboard: the family config, set once and handed to new accounts

Three things, and the third one I first said was impossible.

Nový účet takes the full width — a 420px column inside a full-width panel
left two thirds of the row empty. Three across on a desktop, one on a
phone.

Rodinné nastavení edits `onboarding_template`: the shared sources a new
member's first setup pulls down. The values are plaintext credentials — a
prehraj.to login and a TorBox key — so they are masked behind a reveal
toggle, and this stays a superuser-only noindex page. It PATCHes the one
record and never creates a second; a second row would make the hook's
`-updated` ordering silently decide which config the family gets.

"Použít rodinné nastavení" pre-loads that config onto the account being
created, so the new person never types a family code. I claimed this could
not work because `addon_config` is keyed by profile and a fresh account has
none. The owner pushed back, correctly: the old wizard needed a profile for
the same reason and simply could not make one, whereas this page can — and
`CloudSyncService._findOrCreateDefaultProfile` looks for an existing
profile BEFORE creating one, explicitly "so a second device signing into an
account with data doesn't make a duplicate". A profile made here is adopted
as the default on first sign-in. The other two obstacles were never real:
the template is superuser-readable, and the password is available because
this page just chose it.

The crypto is lifted unchanged from index.html and verified rather than
trusted: the page's own functions, run against
amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture
addon_config_crypto_interop_test pins the Dart side to — derive the
identical key, and a sealed blob round-trips. One wrong iteration count
would have made a new member's app fail to decrypt, which presents as a
password reset rather than as a bug.

The account is created BEFORE the pre-load, and a failed pre-load says so
in warning colour rather than reporting success or rolling back: a
half-made account you were not told about is the worst outcome of the
three.
This commit is contained in:
Claude 2026-08-09 01:23:53 +02:00
parent 80c4bd498c
commit 27ce0f5b60

View file

@ -119,7 +119,11 @@
.facts .v.bad{color:var(--bad);font-weight:600} .facts .v.bad{color:var(--bad);font-weight:600}
details.raw{margin-top:8px} details.raw{margin-top:8px}
details.raw summary{color:var(--muted);font-size:12px;cursor:pointer} details.raw summary{color:var(--muted);font-size:12px;cursor:pointer}
.form{display:grid;gap:10px;max-width:420px} /* Three across on a wide screen, one on a phone. A 420px column inside a
full-width panel left two thirds of the row empty. */
.form{display:grid;gap:10px 18px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.form .span{grid-column:1/-1}
.form button{justify-self:start;padding:9px 18px}
.form .lbl{color:var(--muted);font-size:11.5px;text-transform:uppercase; .form .lbl{color:var(--muted);font-size:11.5px;text-transform:uppercase;
letter-spacing:.06em;margin-bottom:3px} letter-spacing:.06em;margin-bottom:3px}
/* The address takes the slack; nothing else may wrap. A wrapped date reads as /* The address takes the slack; nothing else may wrap. A wrapped date reads as
@ -596,21 +600,26 @@ function viewAdmin(users){
(u.nsfwEnabled ? "zakázat 18+" : "povolit 18+") + "</button></td></tr>"; (u.nsfwEnabled ? "zakázat 18+" : "povolit 18+") + "</button></td></tr>";
}).join(""); }).join("");
return '<section><h2>Nový účet</h2>' + return '<section class="wide"><h2>Nový účet</h2>' +
'<div class="form">' + '<div class="form">' +
'<div><div class="lbl">e-mail</div><input id="nEmail" type="email" ' + '<div><div class="lbl">e-mail</div><input id="nEmail" type="email" ' +
'autocomplete="off" placeholder="jmeno@example.com"></div>' + 'autocomplete="off" placeholder="jmeno@example.com"></div>' +
'<div><div class="lbl">jméno (nepovinné)</div><input id="nName" autocomplete="off"></div>' + '<div><div class="lbl">jméno (nepovinné)</div><input id="nName" autocomplete="off"></div>' +
'<div><div class="lbl">heslo</div><input id="nPass" type="text" autocomplete="off" ' + '<div><div class="lbl">heslo</div><input id="nPass" type="text" autocomplete="off" ' +
'placeholder="nejméně 8 znaků"></div>' + 'placeholder="nejméně 8 znaků"></div>' +
'<label class="row" style="color:var(--muted);font-size:13px">' + '<label class="row span" style="color:var(--muted);font-size:13px">' +
'<input type="checkbox" id="nNsfw" style="width:auto;accent-color:var(--accent)"> ' + '<input type="checkbox" id="nNsfw" style="width:auto;accent-color:var(--accent)"> ' +
"povolit 18+ (jinak dostane clean build)</label>" + "povolit 18+ (jinak dostane clean build)</label>" +
'<label class="row span" style="color:var(--muted);font-size:13px">' +
'<input type="checkbox" id="nFamily" checked ' +
'style="width:auto;accent-color:var(--accent)"> ' +
"použít rodinné nastavení — zdroje i TMDB klíč nastavím hned, " +
"nemusí zadávat rodinný kód</label>" +
'<div><div class="lbl">věkový strop</div><select id="nRating" style="width:100%">' + '<div><div class="lbl">věkový strop</div><select id="nRating" style="width:100%">' +
'<option value="">bez omezení</option><option value="kids">kids</option>' + '<option value="">bez omezení</option><option value="kids">kids</option>' +
'<option value="twelve">12+</option><option value="fifteen">15+</option>' + '<option value="twelve">12+</option><option value="fifteen">15+</option>' +
"</select></div>" + "</select></div>" +
'<button class="primary" id="createBtn">Založit účet</button>' + '<div class="span"><button class="primary" id="createBtn">Založit účet</button></div>' +
"</div>" + "</div>" +
'<div id="createOut"></div>' + '<div id="createOut"></div>' +
'<p class="note">Heslo je vidět schválně — vytváříš ho pro někoho jiného a ' + '<p class="note">Heslo je vidět schválně — vytváříš ho pro někoho jiného a ' +
@ -626,6 +635,160 @@ function viewAdmin(users){
"vyžádat.</p></section>"; "vyžádat.</p></section>";
} }
/* ── Rodinné nastavení (onboarding_template) ──────────────────────────────────
The shared config a new family member's first setup pulls down, unlocked by
AMBER_INVITE_CODE. One record, superuser-only on every rule, so the dashboard
reads and writes it directly.
These are PLAINTEXT CREDENTIALS -- the addon "tokens" carry a prehraj.to login
and a TorBox key. They are masked until you ask to see them, and nothing here
logs or echoes them. That is also why this lives behind a superuser login on a
noindex page and nowhere else. */
function viewTemplate(tpl){
var v = tpl || {};
return '<section class="wide"><h2>Rodinné nastavení — co dostane nový člen</h2>' +
'<div class="form">' +
'<div class="span"><div class="lbl">TorBox doplněk (addonUrl)</div>' +
'<input id="tAddon" type="password" autocomplete="off" value="' +
esc(v.addonUrl || "") + '"></div>' +
'<div class="span"><div class="lbl">Český doplněk (czechAddonUrl)</div>' +
'<input id="tCzech" type="password" autocomplete="off" value="' +
esc(v.czechAddonUrl || "") + '"></div>' +
'<div><div class="lbl">TMDB klíč</div><input id="tTmdb" type="password" ' +
'autocomplete="off" value="' + esc(v.tmdbKey || "") + '"></div>' +
'<div><div class="lbl">poznámka (jen pro tebe)</div><input id="tNote" ' +
'autocomplete="off" value="' + esc(v.note || "") + '"></div>' +
'<label class="row span" style="color:var(--muted);font-size:13px">' +
'<input type="checkbox" id="tShow" style="width:auto;accent-color:var(--accent)"> ' +
"zobrazit hodnoty</label>" +
'<div class="span"><button class="primary" id="tSave">Uložit nastavení</button>' +
(v.updated ? '<span class="note" style="margin-left:12px">naposledy ' +
esc(String(v.updated).substring(0, 16)) + "</span>" : "") + "</div>" +
"</div><div id=\"tOut\"></div>" +
'<p class="note">V těchhle adresách jsou přihlašovací údaje k prehraj.to a ' +
"klíč k TorBoxu — proto jsou schované a proto je tahle stránka jen pro tebe. " +
"Nový člen si je stáhne při prvním nastavení pomocí rodinného kódu.</p>" +
(tpl ? "" : '<div class="warnbox">Žádné nastavení ještě není uložené — ' +
"první uložení ho vytvoří.</div>") + "</section>";
}
async function saveTemplate(){
var out = el("tOut");
out.innerHTML = '<p class="note">ukládám…</p>';
var body = {
addonUrl: el("tAddon").value.trim(),
czechAddonUrl: el("tCzech").value.trim(),
tmdbKey: el("tTmdb").value.trim(),
note: el("tNote").value.trim(),
};
try {
// One record by design. PATCH it when it exists, POST the first one -- and
// never create a second, or `-updated` ordering in the hook would start
// deciding which config the family gets.
var cur = await records("onboarding_template", { perPage: 1, sort: "-updated" });
var rec = (cur.items || [])[0];
var r = await fetch("/api/collections/onboarding_template/records" +
(rec ? "/" + rec.id : ""), {
method: rec ? "PATCH" : "POST",
headers: { "Content-Type": "application/json", Authorization: tok },
body: JSON.stringify(body),
});
if (!r.ok) {
var d = await r.json();
throw new Error((d && d.message) || ("HTTP " + r.status));
}
out.innerHTML = '<div class="okbox">Uloženo. Nový člen to dostane při ' +
"prvním nastavení; už nastavené účty se tím nemění.</div>";
load();
} catch (e) {
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
}
}
/* ── vault crypto: MUST match the app byte-for-byte ───────────────────────────
Lifted unchanged from index.html, which is itself pinned against
addon_config_crypto.dart by addon_config_crypto_interop_test. Do not "tidy"
the iteration count, the kdf id or the blob layout — a device that already
cached a key would stop being able to open the blob, and the failure looks
like a password reset rather than a bug.
key = PBKDF2-HMAC-SHA256(password, salt, 210000, 256 bit)
blob = base64( nonce[12] ‖ AES-256-GCM ciphertext ‖ tag[16] ) */
var PBKDF2_ITERS = 210000;
var KDF_ID = "pbkdf2-sha256-" + PBKDF2_ITERS;
function b64e(u8){ var s = ""; for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); }
function b64d(s){ var b = atob(s), u = new Uint8Array(b.length);
for (var i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u; }
function concatBytes(a, b){ var o = new Uint8Array(a.length + b.length);
o.set(a, 0); o.set(b, a.length); return o; }
async function deriveVaultKey(password, salt){
var base = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
var bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt: salt, iterations: PBKDF2_ITERS }, base, 256);
return crypto.subtle.importKey("raw", bits, "AES-GCM", false, ["encrypt", "decrypt"]);
}
async function sealConfig(plaintext, password, saltB64){
var key = await deriveVaultKey(password, b64d(saltB64));
var nonce = crypto.getRandomValues(new Uint8Array(12));
var ct = new Uint8Array(await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce, tagLength: 128 }, key,
new TextEncoder().encode(plaintext)));
return b64e(concatBytes(nonce, ct));
}
function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); }
/* Pre-load the family config for an account being created RIGHT NOW.
Three steps, and each one is why this could not be done before:
1. the account has no profile yet, and `addon_config` is keyed by profile.
`CloudSyncService._findOrCreateDefaultProfile` looks for an existing
profile before creating one -- explicitly so a second device does not
make a duplicate -- so a profile made here is adopted as the default on
first sign-in rather than fought over.
2. the blob is encrypted with a key derived from the USER'S password, which
the server never has. This page has it, once, because it just chose it.
3. the template is superuser-only, so no family code is needed here.
If they later change their password the blob stops opening and the app flips
`addonConfigNeedsReentry`, which is the documented, already-handled path. */
async function preloadFamilyConfig(userId, name, password){
var tr = await records("onboarding_template", { perPage: 1, sort: "-updated" });
var tpl = (tr.items || [])[0];
if (!tpl) throw new Error("rodinné nastavení není uložené");
var pr = await fetch("/api/collections/profiles/records", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: tok },
body: JSON.stringify({ user: userId, name: name || "Me" }),
});
if (!pr.ok) throw new Error("profil se nepodařilo vytvořit");
var prof = await pr.json();
var cfg = {
addonUrl: tpl.addonUrl || "",
czechAddonUrl: tpl.czechAddonUrl || "",
tmdbKey: tpl.tmdbKey || "",
};
var salt = newSaltB64();
var body = {
blob: await sealConfig(JSON.stringify(cfg), password, salt),
salt: salt, kdf: KDF_ID, profile: prof.id,
updatedAt: new Date().toISOString(),
};
var cr = await fetch("/api/collections/addon_config/records", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: tok },
body: JSON.stringify(body),
});
if (!cr.ok) throw new Error("zdroje se nepodařilo uložit");
}
async function createAccount(){ async function createAccount(){
var out = el("createOut"); var out = el("createOut");
var email = el("nEmail").value.trim(); var email = el("nEmail").value.trim();
@ -663,11 +826,26 @@ async function createAccount(){
: (d && d.message) || ("HTTP " + r.status); : (d && d.message) || ("HTTP " + r.status);
throw new Error(why); throw new Error(why);
} }
var extra = "";
if (el("nFamily").checked) {
// The account EXISTS at this point. If pre-loading fails, say so instead
// of reporting a clean success or rolling the account back — a half-made
// account you were not told about is the worst of the three.
try {
await preloadFamilyConfig(d.id, name, pass);
extra = " Rodinné zdroje jsou nastavené — po přihlášení je uvidí sám.";
} catch (e2) {
extra = ' <b style="color:var(--warn)">Účet je založený, ale rodinné ' +
"nastavení se nepodařilo přiřadit (" + esc(e2.message) +
"). Zdroje si zadá sám rodinným kódem.</b>";
}
}
out.innerHTML = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) + out.innerHTML = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) +
"</code> je založený" + (el("nNsfw").checked ? " s 18+" : " jako clean") + "</code> je založený" + (el("nNsfw").checked ? " s 18+" : " jako clean") +
". Předej heslo, tady už ho znovu neuvidíš.</div>"; "." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = ""; el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = "";
el("nNsfw").checked = false; el("nRating").value = ""; el("nNsfw").checked = false; el("nRating").value = "";
el("nFamily").checked = true;
load(); load();
} catch (e) { } catch (e) {
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>"; out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
@ -697,6 +875,11 @@ function wire(){
b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); }; b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); };
}); });
if (el("createBtn")) el("createBtn").onclick = createAccount; if (el("createBtn")) el("createBtn").onclick = createAccount;
if (el("tSave")) el("tSave").onclick = saveTemplate;
if (el("tShow")) el("tShow").onchange = function(){
var ty = el("tShow").checked ? "text" : "password";
["tAddon", "tCzech", "tTmdb"].forEach(function(id){ el(id).type = ty; });
};
if (el("days")) el("days").onchange = function(){ if (el("days")) el("days").onchange = function(){
prefs.days = Number(el("days").value); savePrefs(); load(); }; prefs.days = Number(el("days").value); savePrefs(); load(); };
if (el("showTests")) el("showTests").onchange = function(){ if (el("showTests")) el("showTests").onchange = function(){
@ -721,7 +904,12 @@ async function load(){
if (prefs.view === "admin") { if (prefs.view === "admin") {
var us = await records("users", { sort: "-created", perPage: 200 }); var us = await records("users", { sort: "-created", perPage: 200 });
el("main").innerHTML = viewAdmin(us.items || []); var tp = null;
try {
var tr = await records("onboarding_template", { perPage: 1, sort: "-updated" });
tp = (tr.items || [])[0] || null;
} catch (_) { /* no template yet, or the collection is missing */ }
el("main").innerHTML = viewAdmin(us.items || []) + viewTemplate(tp);
wire(); wire();
return; return;
} }