amber-backend/pb_public/onboarding.html
Claude 0b65fd7f49 Family onboarding site: invite-gated signup, template config, /get/tv
Distribution site served by PocketBase itself (pb_public/, same origin):
- Landing (/): create-account CTA, TV sign-in steps, login-gated PC
  downloads via the existing update manifest + file tokens
- Wizard (/onboarding.html): invite code -> account -> named default
  profile -> family template fetched and encrypted IN THE BROWSER under
  the new user's password (PBKDF2-210k + AES-GCM, byte-compatible with
  the app's AddonConfigCrypto - a wizard blob decrypts in Dart, verified)
  -> pushed as their addon_config ciphertext. Adult fields never included.
- /get/tv: public direct download of the latest clean Android APK
  (Downloader-friendly; adult builds stay account-gated)

Backend:
- onboarding_template collection (admin-only, maintained via admin UI)
- AMBER_INVITE_CODE env gates BOTH users creation (X-Amber-Invite header
  or ?invite=) and the template route (per-IP rate limit 10/5min);
  fail-closed when unset. Note: this closes the previously-open in-app
  registration too.
- Dockerfile ships pb_public + --publicDir; compose passes the env var

Contract + Coolify steps (second domain amber.petruzalekr.cz) in
docs/onboarding-contract.md. Verified locally end-to-end against
pocketbase 0.39.6 (real-browser wizard run + Dart decrypt interop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:23:05 +02:00

295 lines
13 KiB
HTML

<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex">
<title>Amber — vytvořit účet</title>
<style>
:root { color-scheme: dark; --bg:#0e0f13; --card:#191b21; --fg:#f2e9d8;
--muted:#9aa0aa; --amber:#f0a63c; --amber2:#c9791b; --err:#ff6b6b; --ok:#5fd08a;
--line:#2a2d36; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
min-height:100dvh; display:flex; align-items:center; justify-content:center;
padding:20px; }
.card { width:100%; max-width:440px; background:var(--card); border-radius:16px;
padding:26px 22px; border:1px solid var(--line);
box-shadow:0 10px 40px rgba(0,0,0,.25); }
h1 { font-size:22px; margin:0 0 4px; color:var(--amber); }
p.sub { margin:0 0 18px; color:var(--muted); font-size:14px; line-height:1.5; }
label { display:block; font-size:13px; color:var(--muted); margin:12px 0 5px; }
input[type=email], input[type=password], input[type=text] { width:100%;
padding:12px; border-radius:10px; border:1px solid var(--line);
background:var(--bg); color:var(--fg); font-size:15px; }
input:focus { outline:2px solid var(--amber); border-color:transparent; }
.check { display:flex; gap:10px; align-items:flex-start; margin-top:16px;
font-size:14px; color:var(--muted); line-height:1.45; }
.check input { margin-top:3px; }
button.btn { width:100%; margin-top:20px; padding:13px; border:none;
border-radius:10px; font-size:15px; font-weight:700; cursor:pointer; }
.primary { background:var(--amber); color:#1b1206; }
.primary:disabled { opacity:.55; cursor:default; }
a.btn { display:block; width:100%; text-align:center; margin-top:10px; padding:13px;
border-radius:10px; font-size:15px; font-weight:700; text-decoration:none;
background:transparent; color:var(--amber); border:1px solid var(--amber2); }
.msg { margin-top:14px; font-size:14px; min-height:18px; }
.msg.err { color:var(--err); }
.msg.ok { color:var(--ok); }
.hidden { display:none; }
.spin { display:inline-block; width:15px; height:15px; border:2px solid #1b1206;
border-top-color:transparent; border-radius:50%; animation:s .7s linear infinite;
vertical-align:-2px; margin-right:7px; }
@keyframes s { to { transform:rotate(360deg); } }
.steps { margin:14px 0 0; padding:0; list-style:none; font-size:14px;
color:var(--muted); }
.steps li { padding:6px 0; }
.steps li.done::before { content:"✓ "; color:var(--ok); }
.steps li.doing::before { content:"… "; color:var(--amber); }
.steps li.todo::before { content:"· "; }
ol.tv { margin:8px 0 0; padding-left:20px; color:var(--muted); font-size:14px;
line-height:1.7; }
ol.tv b { color:var(--fg); }
code.url { background:var(--bg); border:1px solid var(--line); border-radius:8px;
padding:3px 8px; font-family:ui-monospace,Menlo,Consolas,monospace;
font-size:13px; color:var(--amber); }
.note { margin-top:16px; font-size:12px; color:var(--muted); line-height:1.5; }
.dl { display:flex; gap:10px; margin-top:10px; }
.dl a { flex:1; margin-top:0; }
</style>
</head>
<body>
<div class="card">
<h1>Vytvořit účet</h1>
<p class="sub">Za pár kroků budeš mít Amber připravený ke sledování.</p>
<!-- Step 1: the single form -->
<div id="form">
<label for="invite">Kód pozvánky</label>
<input id="invite" type="text" autocomplete="off" spellcheck="false"
placeholder="dostaneš od Richarda">
<label for="email">E-mail</label>
<input id="email" type="email" autocomplete="username" inputmode="email">
<label for="pass">Heslo</label>
<input id="pass" type="password" autocomplete="new-password">
<label for="pass2">Heslo znovu</label>
<input id="pass2" type="password" autocomplete="new-password">
<label for="pname">Jméno profilu (kdo se bude dívat?)</label>
<input id="pname" type="text" maxlength="100" placeholder="např. Máma">
<div class="check">
<input id="useTpl" type="checkbox" checked>
<label for="useTpl" style="margin:0">Použít rodinné nastavení
(doporučeno — zdroje filmů a seriálů se nastaví samy)</label>
</div>
<button id="go" class="btn primary">Vytvořit účet</button>
</div>
<!-- Step 2: progress -->
<ul id="progress" class="steps hidden">
<li id="stAcc" class="todo">Vytvořit účet</li>
<li id="stProf" class="todo">Založit profil</li>
<li id="stCfg" class="todo">Nastavit zdroje (šifruje se ve tvém prohlížeči)</li>
</ul>
<div id="msg" class="msg"></div>
<!-- Step 3: success -->
<div id="doneBox" class="hidden">
<p class="sub" style="margin-top:14px">🎉 <b style="color:var(--fg)">Hotovo!</b>
Účet je připravený. Teď přihlas televizi:</p>
<ol class="tv">
<li>Na televizi nainstaluj Amber — v prohlížeči nebo v aplikaci
<b>Downloader</b> otevři <code class="url" id="tvUrl">/get/tv</code></li>
<li>Spusť Amber a zvol <b>„Přihlásit z jiného zařízení“</b></li>
<li>Naskenuj QR kód telefonem (přihlásíš se právě vytvořeným účtem),
nebo pokračuj tlačítkem níže a opiš kód z obrazovky</li>
</ol>
<a class="btn" href="/device">Zadat kód z televize</a>
<div id="dlBox" class="hidden">
<p class="sub" style="margin:18px 0 0">Nebo stáhni Amber do počítače:</p>
<div class="dl" id="dlLinks"></div>
</div>
</div>
<p class="note">Heslo se použije jen ve tvém prohlížeči k zašifrování nastavení
— na server se heslo ani klíč nikdy neposílají. Nastavení odemkneš přihlášením
v aplikaci.</p>
</div>
<script>
// ── crypto: MUST match the app byte-for-byte ────────────────────────────────
// addon_config_crypto.dart: key = PBKDF2-HMAC-SHA256(password, salt, 210000,
// 256 bit); blob = base64( nonce[12] ‖ AES-256-GCM ciphertext ‖ tag[16] );
// kdf id "pbkdf2-sha256-210000". Key derivation is the same code the /device
// approve page ships (interop-tested against the app's fixed vectors).
var subtle = crypto.subtle;
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 concat(){var n=0,i;for(i=0;i<arguments.length;i++)n+=arguments[i].length;
var out=new Uint8Array(n),o=0;for(i=0;i<arguments.length;i++){out.set(arguments[i],o);o+=arguments[i].length;}return out;}
async function deriveKey(password, salt){
var base=await subtle.importKey("raw",new TextEncoder().encode(password),"PBKDF2",false,["deriveBits"]);
var bits=await subtle.deriveBits({name:"PBKDF2",hash:"SHA-256",salt:salt,iterations:PBKDF2_ITERS},base,256);
return subtle.importKey("raw",bits,"AES-GCM",false,["encrypt"]);
}
async function encryptBlob(plaintext, password){
var salt=crypto.getRandomValues(new Uint8Array(16));
var key=await deriveKey(password, salt);
var nonce=crypto.getRandomValues(new Uint8Array(12));
// WebCrypto AES-GCM returns ciphertext‖tag — exactly the app's layout.
var ct=new Uint8Array(await subtle.encrypt(
{name:"AES-GCM",iv:nonce,tagLength:128},key,new TextEncoder().encode(plaintext)));
return { blob: b64e(concat(nonce, ct)), salt: b64e(salt), kdf: KDF_ID };
}
// ── wizard ───────────────────────────────────────────────────────────────────
document.getElementById("tvUrl").textContent = location.host + "/get/tv";
var msgEl = document.getElementById("msg");
var goBtn = document.getElementById("go");
function setMsg(t, cls){ msgEl.className="msg "+(cls||""); msgEl.textContent=t; }
function mark(id, cls){ document.getElementById(id).className = cls; }
function busy(on){
goBtn.disabled = on;
goBtn.innerHTML = on ? '<span class="spin"></span>Pracuji…' : "Vytvořit účet";
}
async function api(method, path, body, token, headers){
var opt = { method:method, headers:headers||{} };
if (body){ opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body); }
if (token){ opt.headers["Authorization"]=token; }
var r = await fetch(path, opt);
var data = null; try { data = await r.json(); } catch(_){}
return { ok:r.ok, status:r.status, data:data };
}
var NAMES = { windows: "Windows", linux: "Linux" };
async function fillDownloads(token){
var box = document.getElementById("dlLinks");
var found = 0;
for (const p of ["windows", "linux"]){
var m = await api("GET", "/api/update/manifest?platform=" + p, null, token);
if (!m.ok || !m.data || !m.data.available) continue;
var t = await api("POST", "/api/files/token", null, token);
if (!t.ok || !t.data || !t.data.token) continue;
var a = document.createElement("a");
a.className = "btn";
a.href = m.data.downloadPath + "?token=" + encodeURIComponent(t.data.token);
a.textContent = NAMES[p];
box.appendChild(a);
found++;
}
if (found) document.getElementById("dlBox").classList.remove("hidden");
}
async function run(){
var invite = document.getElementById("invite").value.trim();
var email = document.getElementById("email").value.trim();
var pass = document.getElementById("pass").value;
var pass2 = document.getElementById("pass2").value;
var pname = document.getElementById("pname").value.trim() || "Já";
var useTpl = document.getElementById("useTpl").checked;
if (!invite){ setMsg("Zadej kód pozvánky.", "err"); return; }
if (!email){ setMsg("Zadej e-mail.", "err"); return; }
if (pass.length < 8){ setMsg("Heslo musí mít aspoň 8 znaků.", "err"); return; }
if (pass !== pass2){ setMsg("Hesla se neshodují.", "err"); return; }
busy(true); setMsg("");
document.getElementById("progress").classList.remove("hidden");
try {
// 1) create the account (invite-gated server-side)
mark("stAcc", "doing");
var created = await api("POST", "/api/collections/users/records",
{ email: email, password: pass, passwordConfirm: pass },
null, { "X-Amber-Invite": invite });
if (!created.ok){
var m = (created.data && created.data.message) || "";
if (created.status === 400 && /pozvánk/i.test(m)){
setMsg("Neplatný kód pozvánky.", "err");
} else if (created.data && created.data.data && created.data.data.email){
setMsg("Tento e-mail už je zaregistrovaný — přihlas se v aplikaci.", "err");
} else {
setMsg("Účet se nepodařilo vytvořit. " + m, "err");
}
mark("stAcc", "todo"); busy(false); return;
}
mark("stAcc", "done");
// sign in (everything below is owner-scoped)
var auth = await api("POST", "/api/collections/users/auth-with-password",
{ identity: email, password: pass });
if (!auth.ok || !auth.data || !auth.data.token){
setMsg("Účet vznikl, ale přihlášení selhalo — zkus to v aplikaci.", "err");
busy(false); return;
}
var token = auth.data.token;
var uid = auth.data.record.id;
// 2) default profile (the app adopts the earliest-created one)
mark("stProf", "doing");
var prof = await api("POST", "/api/collections/profiles/records",
{ user: uid, name: pname }, token);
if (!prof.ok){ setMsg("Profil se nepodařilo založit.", "err"); busy(false); return; }
var profileId = prof.data.id;
mark("stProf", "done");
// 3) family template → encrypt in-browser → push as addon_config
if (useTpl){
mark("stCfg", "doing");
var tpl = await api("GET", "/api/amber/onboarding-template?code=" +
encodeURIComponent(invite));
if (!tpl.ok){
// Account + profile exist; config can still be entered in-app later.
mark("stCfg", "todo");
setMsg("Rodinné nastavení se nepodařilo načíst — v aplikaci ho jde doplnit ručně.", "err");
} else {
var cfg = {
v: 1,
addonUrl: tpl.data.addonUrl || null,
adultAddonUrl: null,
czechAddonUrl: tpl.data.czechAddonUrl || null,
adultEnabled: false,
tmdbKey: tpl.data.tmdbKey || null,
};
var enc = await encryptBlob(JSON.stringify(cfg), pass);
var push = await api("POST", "/api/collections/addon_config/records", {
profile: profileId,
blob: enc.blob,
salt: enc.salt,
kdf: enc.kdf,
updatedAt: new Date().toISOString(),
}, token);
if (push.ok){ mark("stCfg", "done"); }
else {
mark("stCfg", "todo");
setMsg("Nastavení se nepodařilo uložit — v aplikaci ho jde doplnit ručně.", "err");
}
}
} else {
document.getElementById("stCfg").textContent = "Nastavení zdrojů přeskočeno (doplníš v aplikaci)";
mark("stCfg", "done");
}
// success
document.getElementById("form").classList.add("hidden");
goBtn.classList.add("hidden");
document.getElementById("doneBox").classList.remove("hidden");
fillDownloads(token);
} catch (err){
setMsg("Došlo k chybě. Zkus to prosím znovu.", "err");
busy(false);
}
}
goBtn.addEventListener("click", run);
</script>
</body>
</html>