Nový účet
' +
+ return 'Nový účet
' +
'" +
'' +
'Heslo je vidět schválně — vytváříš ho pro někoho jiného a ' +
@@ -626,6 +635,160 @@ function viewAdmin(users){
"vyžádat.
";
}
+/* ── 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 'Rodinné nastavení — co dostane nový člen
' +
+ '" +
+ '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.
" +
+ (tpl ? "" : 'Žádné nastavení ještě není uložené — ' +
+ "první uložení ho vytvoří.
") + "";
+}
+
+async function saveTemplate(){
+ var out = el("tOut");
+ out.innerHTML = 'ukládám…
';
+ 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 = 'Uloženo. Nový člen to dostane při ' +
+ "prvním nastavení; už nastavené účty se tím nemění.
";
+ load();
+ } catch (e) {
+ out.innerHTML = 'nepodařilo se: ' + esc(e.message) + "
";
+ }
+}
+
+
+/* ── 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(){
var out = el("createOut");
var email = el("nEmail").value.trim();
@@ -663,11 +826,26 @@ async function createAccount(){
: (d && d.message) || ("HTTP " + r.status);
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 = ' Úč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.";
+ }
+ }
out.innerHTML = 'Hotovo. Účet ' + esc(email) +
" je založený" + (el("nNsfw").checked ? " s 18+" : " jako clean") +
- ". Předej heslo, tady už ho znovu neuvidíš.
";
+ "." + extra + " Předej heslo, tady už ho znovu neuvidíš.";
el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = "";
el("nNsfw").checked = false; el("nRating").value = "";
+ el("nFamily").checked = true;
load();
} catch (e) {
out.innerHTML = 'nepodařilo se: ' + esc(e.message) + "
";
@@ -697,6 +875,11 @@ function wire(){
b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); };
});
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(){
prefs.days = Number(el("days").value); savePrefs(); load(); };
if (el("showTests")) el("showTests").onchange = function(){
@@ -721,7 +904,12 @@ async function load(){
if (prefs.view === "admin") {
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();
return;
}