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:
parent
80c4bd498c
commit
27ce0f5b60
1 changed files with 194 additions and 6 deletions
|
|
@ -119,7 +119,11 @@
|
|||
.facts .v.bad{color:var(--bad);font-weight:600}
|
||||
details.raw{margin-top:8px}
|
||||
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;
|
||||
letter-spacing:.06em;margin-bottom:3px}
|
||||
/* 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>";
|
||||
}).join("");
|
||||
|
||||
return '<section><h2>Nový účet</h2>' +
|
||||
return '<section class="wide"><h2>Nový účet</h2>' +
|
||||
'<div class="form">' +
|
||||
'<div><div class="lbl">e-mail</div><input id="nEmail" type="email" ' +
|
||||
'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">heslo</div><input id="nPass" type="text" autocomplete="off" ' +
|
||||
'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)"> ' +
|
||||
"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%">' +
|
||||
'<option value="">bez omezení</option><option value="kids">kids</option>' +
|
||||
'<option value="twelve">12+</option><option value="fifteen">15+</option>' +
|
||||
"</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 id="createOut"></div>' +
|
||||
'<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>";
|
||||
}
|
||||
|
||||
/* ── 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(){
|
||||
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 = ' <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) +
|
||||
"</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("nNsfw").checked = false; el("nRating").value = "";
|
||||
el("nFamily").checked = true;
|
||||
load();
|
||||
} catch (e) {
|
||||
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")); };
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue