amber-backend/pb_public/settings.html

316 lines
14 KiB
HTML
Raw Normal View History

<!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 — nastavení účtu</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:14px 0 5px; }
input[type=email], input[type=password], input[type=text], select { width:100%;
padding:12px; border-radius:10px; border:1px solid var(--line);
background:var(--bg); color:var(--fg); font-size:15px; }
input:focus, select:focus { outline:2px solid var(--amber); border-color:transparent; }
.hint { font-size:12px; color:var(--muted); margin:4px 0 0; line-height:1.4; }
.check { display:flex; gap:10px; align-items:flex-start; margin-top:16px;
font-size:14px; color:var(--fg); 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; }
button.link { background:none; border:none; color:var(--muted); font-size:13px;
cursor:pointer; margin-top:14px; text-decoration:underline; padding:0; }
.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); } }
.who { font-size:13px; color:var(--muted); margin:0 0 6px; }
.who b { color:var(--fg); }
.sep { height:1px; background:var(--line); margin:20px 0 4px; border:0; }
.note { margin-top:18px; font-size:12px; color:var(--muted); line-height:1.5; }
</style>
</head>
<body>
<div class="card">
<h1>Nastavení účtu</h1>
<p class="sub" id="lead">Přihlas se a uprav zdroje filmů a seriálů —
změny se do aplikace propíšou samy.</p>
<!-- Step 1: login -->
<div id="login">
<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="current-password">
<button id="loginBtn" class="btn primary">Přihlásit se</button>
</div>
<!-- Step 2: settings -->
<div id="settings" class="hidden">
<p class="who">Přihlášen jako <b id="whoEmail"></b></p>
<div id="profilePick" class="hidden">
<label for="profileSel">Profil</label>
<select id="profileSel"></select>
</div>
<label for="addonUrl">Zdroj filmů a seriálů (TorBox)</label>
<input id="addonUrl" type="text" spellcheck="false" autocapitalize="off"
placeholder="https://…">
<label for="czUrl">Český zdroj (dabing / titulky)</label>
<input id="czUrl" type="text" spellcheck="false" autocapitalize="off"
placeholder="https://…">
<label for="tmdbKey">TMDB klíč</label>
<input id="tmdbKey" type="text" spellcheck="false" autocapitalize="off"
placeholder="klíč z themoviedb.org">
<p class="hint">Necháš-li pole prázdné, daný zdroj se z účtu odebere.</p>
<!-- adult fields — rendered only for an 18+ account -->
<div id="adultBox" class="hidden">
<hr class="sep">
<label for="adultUrl">Zdroj 18+</label>
<input id="adultUrl" type="text" spellcheck="false" autocapitalize="off"
placeholder="https://…">
<div class="check">
<input id="adultEnabled" type="checkbox">
<label for="adultEnabled" style="margin:0">Zobrazit sekci 18+ v aplikaci</label>
</div>
</div>
<button id="saveBtn" class="btn primary">Uložit nastavení</button>
<button id="logoutBtn" class="link">Odhlásit se</button>
</div>
<div id="msg" class="msg"></div>
<p class="note">Heslo se použije jen ve tvém prohlížeči k dešifrování a opětovnému
zašifrování nastavení — na server se heslo ani klíč nikdy neposílají.</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". Same helpers as the onboarding page, plus a
// decrypt path (to read the existing config) and a fixed-salt encrypt.
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 b64d(s){var bin=atob(s);var u8=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)u8[i]=bin.charCodeAt(i);return u8;}
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","decrypt"]);
}
async function decryptBlob(blobB64, password, saltB64){
var key=await deriveKey(password, b64d(saltB64));
var packed=b64d(blobB64);
var nonce=packed.slice(0,12);
var body=packed.slice(12); // WebCrypto expects ciphertext‖tag together
var clear=await subtle.decrypt({name:"AES-GCM",iv:nonce,tagLength:128},key,body);
return new TextDecoder().decode(clear);
}
// Encrypt reusing the record's EXISTING salt, so the key stays identical to the
// one the user's devices already cached — an already-logged-in TV then decrypts
// the edited blob with no password re-entry. A brand-new record gets a fresh salt.
async function encryptWithSalt(plaintext, password, saltB64){
var salt=b64d(saltB64);
var key=await deriveKey(password, salt);
var nonce=crypto.getRandomValues(new Uint8Array(12));
var ct=new Uint8Array(await subtle.encrypt(
{name:"AES-GCM",iv:nonce,tagLength:128},key,new TextEncoder().encode(plaintext)));
return b64e(concat(nonce, ct));
}
function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); }
// ── state ────────────────────────────────────────────────────────────────────
var TOKEN=null, UID=null, NSFW=false, PASSWORD=null;
var PROFILES=[]; // [{id,name}]
var CONFIGS=[]; // addon_config rows the user owns
var recordId=null, saltB64=null, cfg=null; // active config being edited
var msgEl=document.getElementById("msg");
function setMsg(t, cls){ msgEl.className="msg "+(cls||""); msgEl.textContent=t||""; }
function busy(btn, on, label){ btn.disabled=on;
btn.innerHTML = on ? '<span class="spin"></span>'+label : btn.dataset.label; }
async function api(method, path, body, token){
var opt={ method:method, 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 };
}
// ── login ────────────────────────────────────────────────────────────────────
var loginBtn=document.getElementById("loginBtn");
loginBtn.dataset.label="Přihlásit se";
loginBtn.onclick=async function(){
var email=document.getElementById("email").value.trim();
var pass=document.getElementById("pass").value;
if (!email || !pass){ setMsg("Vyplň e-mail i heslo.","err"); return; }
setMsg(""); busy(loginBtn, true, "Přihlašuji…");
var res=await api("POST","/api/collections/users/auth-with-password",
{ identity:email, password:pass });
if (!res.ok){
busy(loginBtn, false);
setMsg(res.status===400 ? "Špatný e-mail nebo heslo." :
"Přihlášení selhalo. Zkus to prosím znovu.", "err");
return;
}
TOKEN=res.data.token; UID=res.data.record.id;
NSFW=res.data.record.nsfwEnabled===true; PASSWORD=pass;
document.getElementById("whoEmail").textContent=res.data.record.email||email;
await loadConfigs();
};
async function loadConfigs(){
// Both scoped to the owner by collection rules (profile.user = auth.id).
var profs=await api("GET",
"/api/collections/profiles/records?perPage=200&sort=created", null, TOKEN);
var cfgs=await api("GET",
"/api/collections/addon_config/records?perPage=200", null, TOKEN);
if (!profs.ok || !cfgs.ok){
busy(loginBtn, false);
setMsg("Nepodařilo se načíst nastavení účtu.","err"); return;
}
PROFILES=(profs.data.items||[]).map(function(p){ return {id:p.id, name:p.name||"Profil"}; });
CONFIGS=cfgs.data.items||[];
if (!PROFILES.length){
busy(loginBtn, false);
setMsg("Účet nemá žádný profil.","err"); return;
}
// A selector only when there's genuinely more than one config to choose from.
var sel=document.getElementById("profileSel");
if (CONFIGS.length>1){
sel.innerHTML="";
CONFIGS.forEach(function(c, i){
var name=(PROFILES.filter(function(p){return p.id===c.profile;})[0]||{}).name||"Profil";
var o=document.createElement("option"); o.value=String(i); o.textContent=name;
sel.appendChild(o);
});
sel.onchange=function(){ selectConfig(parseInt(sel.value,10)); };
document.getElementById("profilePick").className="";
} else {
document.getElementById("profilePick").className="hidden";
}
await selectConfig(0);
document.getElementById("login").className="hidden";
document.getElementById("settings").className="";
busy(loginBtn, false);
}
// Load config row [i] (or a fresh one for the first profile if none exist).
async function selectConfig(i){
setMsg("");
if (CONFIGS.length){
var rec=CONFIGS[i]||CONFIGS[0];
recordId=rec.id; saltB64=rec.salt;
try {
cfg=JSON.parse(await decryptBlob(rec.blob, PASSWORD, rec.salt));
} catch(_){
cfg=null;
setMsg("Nastavení se nepodařilo dešifrovat — heslo možná bylo změněno " +
"resetem. Ulož znovu a vytvoří se čerstvé.", "err");
cfg={ v:1, adultEnabled:false };
recordId=null; saltB64=newSaltB64();
}
} else {
// No config yet — a fresh record attached to the first profile on save.
cfg={ v:1, adultEnabled:false };
recordId=null; saltB64=newSaltB64();
}
renderCfg();
}
function renderCfg(){
document.getElementById("addonUrl").value=cfg.addonUrl||"";
document.getElementById("czUrl").value=cfg.czechAddonUrl||"";
document.getElementById("tmdbKey").value=cfg.tmdbKey||"";
// 18+ fields exist only for an 18+ account — a clean account never sees them,
// and their stored values are carried through untouched on save.
if (NSFW){
document.getElementById("adultUrl").value=cfg.adultAddonUrl||"";
document.getElementById("adultEnabled").checked=cfg.adultEnabled===true;
document.getElementById("adultBox").className="";
} else {
document.getElementById("adultBox").className="hidden";
}
}
// ── save ─────────────────────────────────────────────────────────────────────
var saveBtn=document.getElementById("saveBtn");
saveBtn.dataset.label="Uložit nastavení";
saveBtn.onclick=async function(){
setMsg(""); busy(saveBtn, true, "Ukládám…");
var t=function(id){ var v=document.getElementById(id).value.trim(); return v||null; };
// Start from the decrypted object so unknown keys (e.g. version) survive.
var out=Object.assign({}, cfg||{ v:1 });
out.addonUrl=t("addonUrl");
out.czechAddonUrl=t("czUrl");
out.tmdbKey=t("tmdbKey");
if (NSFW){
out.adultAddonUrl=t("adultUrl");
out.adultEnabled=document.getElementById("adultEnabled").checked;
} // else: adultAddonUrl / adultEnabled preserved from `cfg` untouched.
try {
var blob=await encryptWithSalt(JSON.stringify(out), PASSWORD, saltB64);
var body={ blob:blob, salt:saltB64, kdf:KDF_ID, updatedAt:new Date().toISOString() };
var res;
if (recordId){
res=await api("PATCH","/api/collections/addon_config/records/"+recordId, body, TOKEN);
} else {
// Attach a new record to the profile whose config we're editing (or the
// first profile when the account had none yet).
var pid=CONFIGS.length ? (CONFIGS[0].profile) : PROFILES[0].id;
body.profile=pid;
res=await api("POST","/api/collections/addon_config/records", body, TOKEN);
}
if (!res.ok){ busy(saveBtn, false); setMsg("Uložení selhalo. Zkus to znovu.","err"); return; }
recordId=res.data.id; cfg=out;
busy(saveBtn, false);
setMsg("✓ Uloženo. V aplikaci se změna projeví do chvíle (po synchronizaci).","ok");
} catch(e){
busy(saveBtn, false);
setMsg("Nepodařilo se zašifrovat nastavení.","err");
}
};
document.getElementById("logoutBtn").onclick=function(){
TOKEN=UID=PASSWORD=null; NSFW=false; recordId=saltB64=cfg=null;
document.getElementById("pass").value="";
document.getElementById("settings").className="hidden";
document.getElementById("login").className="";
setMsg("");
};
</script>
</body>
</html>