From 79b615a02cb477292b5057f4eb764339be89e2b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:54:12 +0200 Subject: [PATCH] The dashboard edits any group, not whichever one it found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template editor was pointed at a single row and the account-creation checkbox preloaded that same row, so with several groups the dashboard could only maintain the family and a friend created there had to type their own code. Both now ask which group. A dropdown above the fields lists every row plus "+ nová skupina", and name and code are editable here too. The selection lives in tplSel rather than being read off the select, because load() re-renders the section from scratch and the choice has to survive that. The code is masked with the other secrets, because it is one. Ticking "výchozí" unticks it everywhere else. familyTemplate() is gone, but the route's fallback for the old AMBER_INVITE_CODE still takes "the" isDefault row, so two of them would restore the original bug: which credentials the old code hands out would depend on edit order. Saving with the box ticked clears the flag on the others and says so. preloadFamilyConfig becomes preloadSharedConfig(userId, name, password, tplId) and fetches the chosen row by id. The create form's checkbox becomes a dropdown of every group plus "žádné", defaulting to the isDefault row, so not touching it gives the old behaviour. Fixed while here: the save confirmation was written and then destroyed by load() re-rendering the section, so it had always flashed and vanished. That matters now, because the message is what tells you the default group moved. Driven through the page's own handlers against three rows with different credentials: switching loaded each group's own values, "+ nová skupina" cleared the fields, a nameless save was refused, a reused code reported "code: Value must be unique." rather than a bare 400, ticking výchozí moved the marker and reported it, and afterwards all four codes resolved to their own credentials with exactly one isDefault row. Screenshots were unavailable this session, so this was checked at the DOM level. Co-Authored-By: Claude Opus 5 --- docs/onboarding-contract.md | 47 ++++++++- pb_public/status.html | 201 +++++++++++++++++++++++++----------- 2 files changed, 181 insertions(+), 67 deletions(-) diff --git a/docs/onboarding-contract.md b/docs/onboarding-contract.md index 32e6640..65888c8 100644 --- a/docs/onboarding-contract.md +++ b/docs/onboarding-contract.md @@ -232,10 +232,32 @@ only on an instance that never had a template. So adding a friends row cannot ma the editor wander onto it, and cannot make account creation preload the wrong group. -**The one-click preload on account creation is family-only.** Other groups are -maintained in the PocketBase admin UI and their members type their own code on the -account page. A group picker in the dashboard is a further piece of work, listed as -not done rather than half-built. +### The dashboard edits any group (added same day) + +A `skupina` dropdown above the fields, listing every row plus **+ nová skupina**. +The selection lives in `tplSel` rather than being read off the ` ' + "povolit 18+ (jinak dostane clean build)" + - '" + + '
sdílené nastavení
' + + '" + + '

Zdroje i TMDB klíč nastavím hned a nemusí zadávat kód. ' + + "Kdo si nese vlastní účty, dostane „žádné“.

" + '
věkový strop
because `load()` re-renders the section from scratch + and the choice has to survive that. */ +var tplSel = null; + +function viewTemplate(list){ + var rows = list || []; + var sel = tplSel ? rows.filter(function(r){ return r.id === tplSel; })[0] : null; + if (!sel && tplSel !== "new") { + // Default to the family row, so opening the page lands where it always did. + sel = rows.filter(function(r){ return r.isDefault; })[0] || rows[0] || null; + tplSel = sel ? sel.id : "new"; + } + var v = sel || {}; + var opts = rows.map(function(r){ + return '"; + }).join("") + '"; + return '

Sdílené nastavení — co dostane nový člen

' + '
' + + '
skupina
' + + '
" + + '
název skupiny
' + + '
kód téhle skupiny
' + '
TorBox doplněk (addonUrl)
' + '
' + @@ -679,6 +703,10 @@ function viewTemplate(tpl){ 'autocomplete="off" value="' + esc(v.tmdbKey || "") + '">
' + '
poznámka (jen pro tebe)
' + + '" + '" + @@ -688,42 +716,82 @@ function viewTemplate(tpl){ "
" + '

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.

" + - '

Tohle je nastavení rodiny. Další skupiny (třeba přátelé) mají svoje vlastní řádky s vlastními údaji a vlastním kódem — ty se spravují v PocketBase adminu a jejich lidi si kód zadají sami na svém účtu.

' + - (tpl ? "" : '
Žádné nastavení ještě není uložené — ' + - "první uložení ho vytvoří.
") + ""; + "Kdo dostane kód téhle skupiny, stáhne si přesně tyhle zdroje.

" + + '

Každá skupina má vlastní údaje a vlastní kód, takže přátelé ' + + "nesdílí účty s rodinou. Kód musí být unikátní; dvě skupiny se stejným kódem " + + "databáze odmítne.

" + + (rows.length ? "" : '
Žádná skupina ještě není uložená — ' + + "první uložení ji vytvoří.
") + ""; } async function saveTemplate(){ var out = el("tOut"); + var name = el("tName").value.trim(); + if (!name) { + out.innerHTML = '
Skupina musí mít název — jinak ji ' + + "v seznamu nerozeznáš od ostatních.
"; + return; + } out.innerHTML = '

ukládám…

'; var body = { + name: name, + code: el("tCode").value.trim(), + isDefault: el("tDefault").checked, addonUrl: el("tAddon").value.trim(), czechAddonUrl: el("tCzech").value.trim(), tmdbKey: el("tTmdb").value.trim(), note: el("tNote").value.trim(), }; try { - // The family row specifically. Several rows exist now (one per group), so - // "whichever was updated last" would let this editor wander onto the friends' - // credentials -- see familyTemplate(). A first-ever save creates the family row - // and flags it, so both this page and the route's legacy-code fallback agree on - // which row that is. - var rec = await familyTemplate(); - if (!rec) { body.isDefault = true; body.name = "family"; } + var creating = (!tplSel || tplSel === "new"); var r = await fetch("/api/collections/onboarding_template/records" + - (rec ? "/" + rec.id : ""), { - method: rec ? "PATCH" : "POST", + (creating ? "" : "/" + tplSel), { + method: creating ? "POST" : "PATCH", 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)); + // Per-field, because the one that actually happens here is a duplicate code + // hitting the unique index, and a bare "400" would not say so. + var why = d && d.data && Object.keys(d.data).length + ? Object.keys(d.data).map(function(k){ + return k + ": " + (d.data[k].message || "?"); }).join("; ") + : (d && d.message) || ("HTTP " + r.status); + throw new Error(why); } - out.innerHTML = '
Uloženo. Nový člen to dostane při ' + - "prvním nastavení; už nastavené účty se tím nemění.
"; - load(); + var saved = await r.json(); + tplSel = saved.id; + + // **Exactly one default.** The route's fallback for the old AMBER_INVITE_CODE + // takes "the" isDefault row, so two of them would + // put us straight back to the bug this whole change fixed: which credentials the + // old code hands out would depend on edit order. Ticking the box therefore + // unticks it everywhere else rather than being quietly ignored. + var moved = ""; + if (body.isDefault) { + var all = await allTemplates(); + for (var i = 0; i < all.length; i++) { + if (all[i].id !== saved.id && all[i].isDefault) { + await fetch("/api/collections/onboarding_template/records/" + all[i].id, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: tok }, + body: JSON.stringify({ isDefault: false }), + }); + moved = " Výchozí skupina je teď „" + esc(name) + "“, ne „" + + esc(all[i].name || "(bez názvu)") + "“."; + } + } + } + // `load()` replaces the whole section, so writing the message first and calling + // it after meant the confirmation was destroyed a moment later -- it has always + // flashed and vanished here. That is worse now than it was: `moved` reports that + // the default group changed, which is exactly the sentence somebody needs to + // read. So re-render first, then put the message into the fresh element. + var msg = '
Uloženo. Nový člen to dostane při prvním ' + + "nastavení; už nastavené účty se tím nemění." + moved + "
"; + await load(); + if (el("tOut")) el("tOut").innerHTML = msg; } catch (e) { out.innerHTML = '
nepodařilo se: ' + esc(e.message) + "
"; } @@ -780,9 +848,13 @@ function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); 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 tpl = await familyTemplate(); - if (!tpl) throw new Error("rodinné nastavení není uložené"); +async function preloadSharedConfig(userId, name, password, tplId){ + // By id, because the dashboard now asks WHICH group. Reading "the" template here + // is what made this preload family-only. + var tpl = null; + try { tpl = await api("/api/collections/onboarding_template/records/" + tplId); } + catch (_) { tpl = null; } + if (!tpl) throw new Error("sdílené nastavení téhle skupiny se nepodařilo načíst"); var pr = await fetch("/api/collections/profiles/records", { method: "POST", @@ -849,17 +921,18 @@ async function createAccount(){ throw new Error(why); } var extra = ""; - if (el("nFamily").checked) { + var gid = el("nGroup") ? el("nGroup").value : ""; + if (gid) { // 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."; + await preloadSharedConfig(d.id, name, pass, gid); + extra = " Sdílené zdroje jsou nastavené — po přihlášení je uvidí sám."; } catch (e2) { - extra = ' Účet je založený, ale rodinné ' + + extra = ' Účet je založený, ale sdílené ' + "nastavení se nepodařilo přiřadit (" + esc(e2.message) + - "). Zdroje si zadá sám rodinným kódem."; + "). Zdroje si zadá sám kódem své skupiny."; } } out.innerHTML = '
Hotovo. Účet ' + esc(email) + @@ -867,7 +940,6 @@ async function createAccount(){ "." + extra + " Předej heslo, tady už ho znovu neuvidíš.
"; 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 = '
nepodařilo se: ' + esc(e.message) + "
"; @@ -898,9 +970,15 @@ function wire(){ }); if (el("createBtn")) el("createBtn").onclick = createAccount; if (el("tSave")) el("tSave").onclick = saveTemplate; + if (el("tGroup")) el("tGroup").onchange = function(){ + // Re-render from the selection. Unsaved edits in the fields are dropped, which + // is the honest behaviour: they belonged to the group you just left. + tplSel = el("tGroup").value; load(); + }; if (el("tShow")) el("tShow").onchange = function(){ var ty = el("tShow").checked ? "text" : "password"; - ["tAddon", "tCzech", "tTmdb"].forEach(function(id){ el(id).type = ty; }); + // The code is a shared secret too, so it hides and reveals with the rest. + ["tAddon", "tCzech", "tTmdb", "tCode"].forEach(function(id){ el(id).type = ty; }); }; if (el("days")) el("days").onchange = function(){ prefs.days = Number(el("days").value); savePrefs(); load(); }; @@ -926,11 +1004,8 @@ async function load(){ if (prefs.view === "admin") { var us = await records("users", { sort: "-created", perPage: 200 }); - var tp = null; - try { - tp = await familyTemplate(); - } catch (_) { /* no template yet, or the collection is missing */ } - el("main").innerHTML = viewAdmin(us.items || []) + viewTemplate(tp); + var tps = await allTemplates(); + el("main").innerHTML = viewAdmin(us.items || [], tps) + viewTemplate(tps); wire(); return; }