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>
This commit is contained in:
Claude 2026-07-22 18:23:05 +02:00
parent a957cc59f8
commit 0b65fd7f49
7 changed files with 724 additions and 1 deletions

View file

@ -26,6 +26,9 @@ COPY pb_migrations /pb_migrations
# from --hooksDir on serve.
COPY pb_hooks /pb_hooks
# Static distribution/onboarding site (landing + account wizard) served at /.
COPY pb_public /pb_public
# Persist DB, uploaded files, and any admin-created migrations here. Runs as root
# so it can always write the volume Coolify attaches — Coolify's persistent mount
# is root-owned, and a non-root user hitting it fails with SQLite "unable to open
@ -37,4 +40,4 @@ EXPOSE 8090
# Coolify terminates TLS at Traefik; PocketBase listens plain on 8090 behind it.
ENTRYPOINT ["pocketbase"]
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations", "--hooksDir=/pb_hooks"]
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations", "--hooksDir=/pb_hooks", "--publicDir=/pb_public"]

View file

@ -8,6 +8,10 @@ services:
PB_VERSION: 0.39.6
ports:
- "8090:8090"
environment:
# Family invite code (onboarding.pb.js). Unset → registration + template
# route fail closed. Locally: AMBER_INVITE_CODE=test docker compose up.
AMBER_INVITE_CODE: ${AMBER_INVITE_CODE:-}
volumes:
- pb_data:/pb_data
restart: unless-stopped

107
docs/onboarding-contract.md Normal file
View file

@ -0,0 +1,107 @@
# Family onboarding & distribution site
The distribution site (landing + account wizard) lives in `pb_public/` and is
served by PocketBase itself at `/` — same origin as the API, no extra service.
It exists so a family member can be fully set up **from a browser**: create an
account, receive the family addon config (encrypted client-side), and log the
TV in via the existing device sign-in flow — without ever installing Amber on a
PC or typing addon URLs.
## Pages
| Path | What |
|--------------------|-------------------------------------------------------------|
| `/` | Landing: create-account CTA, TV sign-in steps, PC downloads (login-gated) |
| `/onboarding.html` | The wizard: invite + account + profile + template config |
| `/device` | Existing device-approve page (issue #12), linked from both |
| `/get/tv` | Public direct download of the latest **clean** Android APK |
## Environment
| Var | Meaning |
|---------------------|-----------------------------------------------------------|
| `AMBER_INVITE_CODE` | The single family invite code. **Unset → registration and the template route are disabled (fail closed).** Set it in Coolify. |
## Routes (pb_hooks/onboarding.pb.js)
### users create gate
Creating a `users` record requires the invite code — header `X-Amber-Invite`
(what the wizard sends) or `?invite=` query fallback. Superusers bypass.
**Note:** this also closes the previously-open in-app registration; the app's
register screen will refuse without a code until it grows an invite field
(family onboarding happens through the site, so this is acceptable).
### GET /api/amber/onboarding-template?code=CODE
Returns the admin-maintained plaintext family config:
```json
{ "addonUrl": "…", "czechAddonUrl": "…", "tmdbKey": "…" }
```
- `403` bad code · `429` after 10 bad codes / 5 min / IP · `404` no template
record · `503` env unset.
- **Deliberately no adult fields** — the template is family config by
definition (mirrors the #14/#20 child stripping). Adult stays per-account.
- Plaintext travels once over TLS to the wizard, which encrypts it under the
*new user's* password (PBKDF2-HMAC-SHA256 210k → AES-256-GCM, blob =
`base64(nonce[12] ‖ ct ‖ tag[16])`, `kdf = pbkdf2-sha256-210000`) and pushes
it as the account's `addon_config` row — the server still stores only
ciphertext per account. Interop with the app's `AddonConfigCrypto` is
verified (a wizard-produced blob decrypts in the app's Dart crypto).
### GET /get/tv
Streams the newest `releases` row with `platform=android && variant=clean` as
`amber-tv.apk` (attachment). Public by design: the TV-sideload path (the
Downloader app needs a plain URL, a fresh TV has no account, and the APK is
useless without one). Adult builds remain account-gated, unchanged.
## Template maintenance (admin)
One record in the `onboarding_template` collection, edited via the PocketBase
admin UI (`/_/`). Fields: `addonUrl`, `czechAddonUrl`, `tmdbKey` (+ free-form
`note`). Same value shapes the app's Settings store (base URLs without
`/manifest.json`). The hook serves the newest record if several exist — keep
just one. No client can read the collection directly (all rules null).
## Wizard flow (onboarding.html)
1. `POST /api/collections/users/records` with `X-Amber-Invite` (email +
password).
2. `auth-with-password` → token.
3. `POST /api/collections/profiles/records` — the default profile, named by
the user ("Kdo se bude dívat?"). The app adopts the earliest-created
profile on first login (`CloudSyncService._ensureProfile`), so this *is*
their profile.
4. Template fetch → client-side encrypt → `POST /api/collections/addon_config/records`
(`profile`, `blob`, `salt` (fresh 16 B), `kdf`, `updatedAt`).
5. Success: TV instructions (`/get/tv` + "Přihlásit z jiného zařízení" →
`/device`), plus login-token-gated Windows/Linux download buttons (existing
`/api/update/manifest` + `/api/files/token`).
Template application is optional (checkbox, default on); every failure past
account creation degrades gracefully — the account still works, config can be
entered in-app.
## Coolify deploy
1. DNS: `amber.petruzalekr.cz` → the server.
2. The service's **Domains** field becomes a comma-separated list:
`https://pb.petruzalekr.cz,https://amber.petruzalekr.cz` (save, don't
"Generate Domain", then restart). The site is host-agnostic (relative API
paths), so both domains work; hand the family the `amber.` one.
3. Env: add `AMBER_INVITE_CODE=<code>` and redeploy.
4. Admin UI → `onboarding_template` → create the one record with the real
values.
5. Publish releases (`scripts/publish-release.sh`) so `/get/tv` and the
download buttons have something to serve.
## Local verification (2026-07-22)
Native `pocketbase serve` against the repo dirs + `AMBER_INVITE_CODE`:
signup refused without/with wrong code (Czech error) and accepted via header
and query · template route 403/200/429(10 per 5 min)/404 paths · full wizard
run in a real browser produced the user, the named profile, and an
`addon_config` blob that **decrypted with the app's Dart `AddonConfigCrypto`**
with every field intact (adult fields null/false) · `/get/tv` streamed a
published dummy APK byte-identical with the attachment header · landing's
download login handled the no-releases state.

107
pb_hooks/onboarding.pb.js Normal file
View file

@ -0,0 +1,107 @@
/// <reference path="../pb_data/types.d.ts" />
// Family onboarding: invite-gated registration + template config (distribution
// site). The instance is private — family only — so BOTH doors are keyed by a
// single invite code from the AMBER_INVITE_CODE env var (set in Coolify):
//
// 1. Creating a `users` record requires the code (header `X-Amber-Invite`,
// or `?invite=` as a fallback). Without it, registration is refused — this
// also closes the previously-open in-app register endpoint. Superusers
// (admin UI) bypass the gate.
// 2. GET /api/amber/onboarding-template?code=… returns the admin-maintained
// plaintext family config (onboarding_template collection) so the
// onboarding page can encrypt it client-side under the new user's
// password. Rate-limited per IP so the code can't be brute-forced.
//
// Fail-closed: with AMBER_INVITE_CODE unset, registration and the template
// route are both disabled (a private instance must not silently fall open).
// ── users create gate ─────────────────────────────────────────────────────────
onRecordCreateRequest((e) => {
if (e.hasSuperuserAuth()) return e.next()
const code = $os.getenv("AMBER_INVITE_CODE")
const info = e.requestInfo()
// requestInfo() normalizes header keys to snake_case and exposes query params
// as a plain map (e.request.url.query() is NOT available on record-request
// events — it throws, which read as a generic 400).
const given =
((info.headers["x_amber_invite"] || info.query["invite"] || "") + "").trim()
if (!code) {
throw new BadRequestError("Registrace je uzavřená (server nemá nastavený AMBER_INVITE_CODE).")
}
if (given !== code) {
throw new BadRequestError("Registrace vyžaduje platný kód pozvánky.")
}
e.next()
}, "users")
// ── GET /api/amber/onboarding-template ────────────────────────────────────────
// Query: ?code=<invite>. → { addonUrl, czechAddonUrl, tmdbKey } (values may be
// empty strings when the template record isn't filled yet). 403 on a bad code,
// 429 when rate-limited, 404 when no template record exists.
routerAdd("GET", "/api/amber/onboarding-template", (e) => {
const MAX_FAILS = 10
const WINDOW_SECONDS = 300
const code = $os.getenv("AMBER_INVITE_CODE")
if (!code) return e.json(503, { error: "onboarding disabled" })
// Fixed-window per-IP limit, same shape as verify-pin's (app store — file
// scope state does not survive the isolated handler JSVMs).
const store = $app.store()
const key = "amber.onboardFails." + e.realIP()
const now = Math.floor(Date.now() / 1000)
let st = store.get(key)
if (st && st.resetAt <= now) st = null
if (st && st.count >= MAX_FAILS) {
return e.json(429, { error: "too many attempts", retryAfter: st.resetAt - now })
}
const given = (e.request.url.query().get("code") || "").trim()
if (given !== code) {
store.set(key, {
count: (st ? st.count : 0) + 1,
resetAt: st ? st.resetAt : now + WINDOW_SECONDS,
})
return e.json(403, { error: "invalid invite code" })
}
store.set(key, null)
let rec = null
try {
const rows = $app.findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0)
if (rows && rows.length) rec = rows[0]
} catch (_) { /* collection empty */ }
if (!rec) return e.json(404, { error: "no template configured" })
return e.json(200, {
addonUrl: rec.getString("addonUrl"),
czechAddonUrl: rec.getString("czechAddonUrl"),
tmdbKey: rec.getString("tmdbKey"),
})
})
// ── GET /get/tv ───────────────────────────────────────────────────────────────
// Public direct download of the latest CLEAN Android APK — the TV-sideload path
// (the Downloader app needs a plain URL; a fresh TV has no account yet, and the
// APK alone is useless without one, so serving the clean build openly is fine).
// Adult builds stay account-gated (manifest + protected file), unchanged.
routerAdd("GET", "/get/tv", (e) => {
let rec = null
try {
const rows = $app.findRecordsByFilter(
"releases",
"platform = 'android' && variant = 'clean'",
"-buildNumber",
1, 0
)
if (rows && rows.length) rec = rows[0]
} catch (_) { /* none published yet */ }
if (!rec) return e.json(404, { error: "no release published yet" })
const filename = rec.getString("file")
// Local storage layout: <dataDir>/storage/<collectionId>/<recordId>/<file>.
const path = $app.dataDir() + "/storage/" + rec.collection().id + "/" + rec.id + "/" + filename
e.response.header().set("Content-Disposition", 'attachment; filename="amber-tv.apk"')
return e.fileFS($os.dirFS($filepath.dir(path)), $filepath.base(path))
})

View file

@ -0,0 +1,46 @@
/// <reference path="../pb_data/types.d.ts" />
// Family onboarding template (distribution site).
//
// One admin-maintained record holding the *plaintext* addon config that new
// family accounts start from: the onboarding page (pb_public) fetches it
// through an invite-code-gated route, encrypts it in the browser under the new
// user's password, and pushes the result as their addon_config blob — so the
// server still only ever stores ciphertext per account, and family members get
// a working setup without typing addon URLs.
//
// Deliberately NO adult fields: the template is family config by definition
// (mirrors the #14/#20 child-profile stripping). Adult stays per-account,
// configured manually.
//
// All rules are null → superusers only. Clients never read this collection
// directly; the invite-gated hook (onboarding.pb.js) is the sole reader.
// Maintain the single record via the PocketBase admin UI.
migrate((app) => {
const tpl = new Collection({
type: "base",
name: "onboarding_template",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
// Same value shapes SettingsRepository stores (base URLs, no trailing
// /manifest.json). All optional so the record can be filled gradually.
{ type: "text", name: "addonUrl", max: 2000 },
{ type: "text", name: "czechAddonUrl", max: 2000 },
{ type: "text", name: "tmdbKey", max: 200 },
// Free-form admin note ("updated after TorBox key rotation" etc.) —
// never served to clients.
{ type: "text", name: "note", max: 1000 },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
})
app.save(tpl)
}, (app) => {
try {
app.delete(app.findCollectionByNameOrId("onboarding_template"))
} catch (_) { /* already gone */ }
})

161
pb_public/index.html Normal file
View file

@ -0,0 +1,161 @@
<!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 — filmy a seriály pro rodinu</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; flex-direction:column; align-items:center; }
.wrap { width:100%; max-width:640px; padding:32px 20px 48px; }
.brand { display:flex; align-items:center; gap:12px; margin:18px 0 6px; }
.gem { width:40px; height:40px; }
h1 { font-size:34px; margin:0; color:var(--amber); letter-spacing:.5px; }
p.tag { margin:6px 0 30px; color:var(--muted); font-size:16px; line-height:1.5; }
.card { background:var(--card); border:1px solid var(--line); border-radius:16px;
padding:22px 20px; margin-bottom:18px; box-shadow:0 10px 40px rgba(0,0,0,.25); }
.card h2 { margin:0 0 6px; font-size:18px; }
.card p { margin:0 0 14px; color:var(--muted); font-size:14px; line-height:1.55; }
a.btn, button.btn { display:block; width:100%; text-align:center; padding:14px;
border:none; border-radius:10px; font-size:15px; font-weight:700;
text-decoration:none; cursor:pointer; }
.primary { background:var(--amber); color:#1b1206; }
.secondary { background:transparent; color:var(--amber); border:1px solid var(--amber2) !important; border:1px solid var(--amber2); }
.row { display:flex; gap:10px; }
.row > * { flex:1; }
code.url { display:inline-block; background:var(--bg); border:1px solid var(--line);
border-radius:8px; padding:6px 10px; font-family:ui-monospace,Menlo,Consolas,monospace;
font-size:14px; color:var(--amber); }
label { display:block; font-size:13px; color:var(--muted); margin:10px 0 5px; }
input { 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; }
.msg { margin-top:12px; font-size:14px; min-height:18px; }
.msg.err { color:var(--err); }
.msg.ok { color:var(--ok); }
.hidden { display:none; }
.dl { display:flex; gap:10px; margin-top:12px; }
.dl a { flex:1; }
footer { color:var(--muted); font-size:12px; margin-top:8px; text-align:center;
padding-bottom:24px; }
ol { margin:0; padding-left:20px; color:var(--muted); font-size:14px; line-height:1.7; }
ol b { color:var(--fg); }
</style>
</head>
<body>
<div class="wrap">
<div class="brand">
<svg class="gem" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<polygon points="50,4 90,32 76,92 24,92 10,32" fill="#c9791b"/>
<polygon points="50,4 90,32 50,50" fill="#f0a63c"/>
<polygon points="50,4 10,32 50,50" fill="#e9932a"/>
<polygon points="10,32 24,92 50,50" fill="#b06515"/>
<polygon points="90,32 76,92 50,50" fill="#8f4f0e"/>
<polygon points="24,92 76,92 50,50" fill="#a05a12"/>
</svg>
<h1>Amber</h1>
</div>
<p class="tag">Filmy, seriály a anime pro celou rodinu — česky, na televizi i na počítači.</p>
<div class="card">
<h2>Začínáš?</h2>
<p>Průvodce ti vytvoří účet a rovnou nastaví vše potřebné. Budeš potřebovat
<b>kód pozvánky</b> od Richarda.</p>
<a class="btn primary" href="/onboarding.html">Vytvořit účet</a>
</div>
<div class="card">
<h2>Přihlásit televizi</h2>
<ol>
<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, nebo pokračuj tlačítkem níže a opiš kód</li>
</ol>
<div style="height:14px"></div>
<a class="btn secondary" href="/device">Zadat kód z televize</a>
</div>
<div class="card">
<h2>Stáhnout do počítače</h2>
<p>Přihlas se svým účtem — stažení je vázané na účet.</p>
<div id="dlform">
<label for="dlEmail">E-mail</label>
<input id="dlEmail" type="email" autocomplete="username">
<label for="dlPass">Heslo</label>
<input id="dlPass" type="password" autocomplete="current-password">
<div style="height:12px"></div>
<button class="btn primary" id="dlGo">Zobrazit stažení</button>
</div>
<div id="dlLinks" class="dl hidden"></div>
<div id="dlMsg" class="msg"></div>
</div>
<footer>Amber je soukromá rodinná aplikace.</footer>
</div>
<script>
document.getElementById("tvUrl").textContent = location.host + "/get/tv";
// Detect the visitor's platform to pre-order the download buttons.
function platforms(){
var ua = navigator.userAgent;
if (/Windows/i.test(ua)) return ["windows", "linux"];
if (/Linux|X11/i.test(ua) && !/Android/i.test(ua)) return ["linux", "windows"];
return ["windows", "linux"];
}
var NAMES = { windows: "Windows", linux: "Linux" };
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 };
}
document.getElementById("dlGo").addEventListener("click", async function(){
var msg = document.getElementById("dlMsg");
msg.className = "msg"; msg.textContent = "";
var email = document.getElementById("dlEmail").value.trim();
var pass = document.getElementById("dlPass").value;
if (!email || !pass){ msg.className="msg err"; msg.textContent="Zadej e-mail a heslo."; return; }
var auth = await api("POST", "/api/collections/users/auth-with-password",
{ identity: email, password: pass });
if (!auth.ok || !auth.data || !auth.data.token){
msg.className="msg err"; msg.textContent="Nesprávný e-mail nebo heslo."; return;
}
var token = auth.data.token;
var box = document.getElementById("dlLinks");
box.innerHTML = ""; box.classList.remove("hidden");
var found = 0;
for (const p of platforms()){
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 secondary";
a.href = m.data.downloadPath + "?token=" + encodeURIComponent(t.data.token);
a.textContent = NAMES[p] + " · " + m.data.version;
box.appendChild(a);
found++;
}
if (!found){
box.classList.add("hidden");
msg.className="msg err"; msg.textContent="Zatím není zveřejněná žádná verze.";
} else {
document.getElementById("dlform").classList.add("hidden");
msg.className="msg ok"; msg.textContent="Hotovo — vyber svůj systém.";
}
});
</script>
</body>
</html>

295
pb_public/onboarding.html Normal file
View file

@ -0,0 +1,295 @@
<!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>