296 lines
13 KiB
HTML
296 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>
|