The dashboard edits any group, not whichever one it found

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 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-08-12 21:54:12 +02:00
parent 8f2cb994ec
commit 79b615a02c
2 changed files with 181 additions and 67 deletions

View file

@ -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 the editor wander onto it, and cannot make account creation preload the wrong
group. group.
**The one-click preload on account creation is family-only.** Other groups are ### The dashboard edits any group (added same day)
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 A `skupina` dropdown above the fields, listing every row plus **+ nová skupina**.
not done rather than half-built. 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.
Switching groups drops unsaved edits, which is the honest behaviour: they belonged
to the group you just left.
`name` and `code` are editable here too, and `code` is masked with the other
secrets behind *zobrazit hodnoty*, 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 put us back to the original bug: which credentials the
old code hands out would depend on edit order. Saving with the box ticked clears
the flag on every other row and says so in the confirmation.
Account creation takes a **sdílené nastavení** dropdown instead of the old
family-only checkbox: every group, plus *žádné* for someone bringing their own
accounts. It defaults to the `isDefault` row, so the previous behaviour is what you
get by not touching it. `preloadFamilyConfig` became `preloadSharedConfig(userId,
name, password, tplId)` and fetches the chosen row by id.
Fixed in passing: the save confirmation was written and then destroyed by `load()`
re-rendering the section, so it had always flashed and vanished. It matters now
because the message reports that the default group moved.
### Verified locally (2026-08-12) ### Verified locally (2026-08-12)
@ -258,3 +280,20 @@ used to break:
`familyTemplate()` returned `family` while `-updated` returned `friends`, which is `familyTemplate()` returned `family` while `-updated` returned `friends`, which is
the divergence the change exists to create. Rate limiting still allows ten failures the divergence the change exists to create. Rate limiting still allows ten failures
and answers 429 on the eleventh. and answers 429 on the eleventh.
### Dashboard verified locally (2026-08-12)
Driven through the page's own handlers against three rows with deliberately
different credentials. Screenshots were unavailable in this session (the browser
pane was not displayed), so this was checked at the DOM level rather than by eye.
- the picker listed `family (výchozí)`, `friends`, `kolegove`, `+ nová skupina`,
and the create form listed all three plus *žádné*, with the default preselected;
- switching to `friends` loaded the friends' code and TorBox URL, not the family's;
- `+ nová skupina` cleared every field rather than inheriting the previous group's;
- a nameless save was refused; reusing another group's code reported
`code: Value must be unique.` rather than a bare 400;
- saving a new group with `výchozí` ticked moved the marker off `family`, and
putting it back reported "Výchozí skupina je teď „family“, ne „kolegove“";
- afterwards all four codes resolved to their own credentials (three group codes
plus `AMBER_INVITE_CODE` to family) and exactly one row held `isDefault`.

View file

@ -223,24 +223,14 @@ async function records(coll, params){
return api("/api/collections/" + coll + "/records?" + q); return api("/api/collections/" + coll + "/records?" + q);
} }
/* The FAMILY template row, chosen by its flag rather than by being newest. /* Every group, for the picker and for the create form's dropdown. Failure is an
There are several rows now, one per group the owner shares accounts with, and empty list rather than a throw: a dashboard that renders nothing because one
"-updated" would mean this page silently followed whichever group was edited collection is missing is worse than one that says there are no groups yet. */
last: you would open the editor to fix a family URL and overwrite the friends' async function allTemplates(){
credentials with it, or create a friend's account and preload the family's. The try {
page had a comment warning about exactly this ("never create a second"). var r = await records("onboarding_template", { perPage: 50, sort: "name" });
This page stays the FAMILY editor. Other groups are maintained in the PocketBase return r.items || [];
admin UI, and their members type their own code on the account page. */ } catch (_) { return []; }
async function familyTemplate(){
var r = await records("onboarding_template",
{ perPage: 1, sort: "-updated", filter: "isDefault = true" });
var row = (r.items || [])[0];
if (row) return row;
/* A database with no flagged row yet: the single row this page has always
edited. The migration flags the newest one, so this only fires on an instance
that has never had a template at all. */
var any = await records("onboarding_template", { perPage: 1, sort: "-updated" });
return (any.items || [])[0] || null;
} }
function logout(){ function logout(){
@ -608,7 +598,7 @@ function viewDevices(rows){
signup and no invite code behind this; `users.createRule` is null, so only a signup and no invite code behind this; `users.createRule` is null, so only a
superuser can do it, which is exactly who is looking at this screen. */ superuser can do it, which is exactly who is looking at this screen. */
function viewAdmin(users){ function viewAdmin(users, tpls){
var rows = (users || []).map(function(u){ var rows = (users || []).map(function(u){
return "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" + return "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" +
"<td>" + (u.nsfwEnabled ? '<span class="pill wn">18+</span>' "<td>" + (u.nsfwEnabled ? '<span class="pill wn">18+</span>'
@ -630,11 +620,15 @@ function viewAdmin(users){
'<label class="row span" style="color:var(--muted);font-size:13px">' + '<label class="row span" style="color:var(--muted);font-size:13px">' +
'<input type="checkbox" id="nNsfw" style="width:auto;accent-color:var(--accent)"> ' + '<input type="checkbox" id="nNsfw" style="width:auto;accent-color:var(--accent)"> ' +
"povolit 18+ (jinak dostane clean build)</label>" + "povolit 18+ (jinak dostane clean build)</label>" +
'<label class="row span" style="color:var(--muted);font-size:13px">' + '<div class="span"><div class="lbl">sdílené nastavení</div>' +
'<input type="checkbox" id="nFamily" checked ' + '<select id="nGroup" style="width:100%">' +
'style="width:auto;accent-color:var(--accent)"> ' + '<option value="">žádné — zdroje si zadá sám</option>' +
"použít rodinné nastavení — zdroje i TMDB klíč nastavím hned, " + (tpls || []).map(function(t){
"nemusí zadávat rodinný kód</label>" + return '<option value="' + esc(t.id) + '"' + (t.isDefault ? " selected" : "") +
">" + esc(t.name || "(bez názvu)") + "</option>"; }).join("") +
"</select>" +
'<p class="note">Zdroje i TMDB klíč nastavím hned a nemusí zadávat kód. ' +
"Kdo si nese vlastní účty, dostane „žádné“.</p></div>" +
'<div><div class="lbl">věkový strop</div><select id="nRating" style="width:100%">' + '<div><div class="lbl">věkový strop</div><select id="nRating" style="width:100%">' +
'<option value="">bez omezení</option><option value="kids">kids</option>' + '<option value="">bez omezení</option><option value="kids">kids</option>' +
'<option value="twelve">12+</option><option value="fifteen">15+</option>' + '<option value="twelve">12+</option><option value="fifteen">15+</option>' +
@ -655,20 +649,50 @@ function viewAdmin(users){
"vyžádat.</p></section>"; "vyžádat.</p></section>";
} }
/* ── Rodinné nastavení (onboarding_template) ────────────────────────────────── /* ── Sdílená nastavení (onboarding_template) ──────────────────────────────────
The shared config a new family member's first setup pulls down, unlocked by The shared config a new member's first setup pulls down, unlocked by the code of
AMBER_INVITE_CODE. One record, superuser-only on every rule, so the dashboard their group. One row per group the owner shares accounts with, so the family and
reads and writes it directly. friends never land on each other's subscriptions; superuser-only on every rule,
so the dashboard reads and writes them directly.
The picker below is the only place these are edited. It used to edit "the"
template, chosen by recency, which is the bug that made a second group unsafe --
see pb_migrations/1791000000_onboarding_template_codes.js.
These are PLAINTEXT CREDENTIALS -- the addon "tokens" carry a prehraj.to login These are PLAINTEXT CREDENTIALS -- the addon "tokens" carry a prehraj.to login
and a TorBox key. They are masked until you ask to see them, and nothing here and a TorBox key. They are masked until you ask to see them, and nothing here
logs or echoes them. That is also why this lives behind a superuser login on a logs or echoes them. That is also why this lives behind a superuser login on a
noindex page and nowhere else. */ noindex page and nowhere else. */
function viewTemplate(tpl){ /* Which group the editor is pointed at: a record id, or "new". Held here rather
var v = tpl || {}; than read off the <select> because `load()` re-renders the section from scratch
return '<section class="wide"><h2>Rodinné nastavení — co dostane nový člen</h2>' + 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 '<option value="' + esc(r.id) + '"' +
(sel && r.id === sel.id ? " selected" : "") + ">" +
esc(r.name || "(bez názvu)") + (r.isDefault ? " (výchozí)" : "") + "</option>";
}).join("") + '<option value="new"' + (tplSel === "new" ? " selected" : "") +
">+ nová skupina</option>";
return '<section class="wide"><h2>Sdílené nastavení — co dostane nový člen</h2>' +
'<div class="form">' + '<div class="form">' +
'<div class="span"><div class="lbl">skupina</div>' +
'<select id="tGroup" style="width:100%">' + opts + "</select></div>" +
'<div><div class="lbl">název skupiny</div><input id="tName" ' +
'autocomplete="off" placeholder="rodina, přátelé…" value="' +
esc(v.name || "") + '"></div>' +
'<div><div class="lbl">kód téhle skupiny</div><input id="tCode" ' +
'type="password" autocomplete="off" value="' + esc(v.code || "") + '"></div>' +
'<div class="span"><div class="lbl">TorBox doplněk (addonUrl)</div>' + '<div class="span"><div class="lbl">TorBox doplněk (addonUrl)</div>' +
'<input id="tAddon" type="password" autocomplete="off" value="' + '<input id="tAddon" type="password" autocomplete="off" value="' +
esc(v.addonUrl || "") + '"></div>' + esc(v.addonUrl || "") + '"></div>' +
@ -679,6 +703,10 @@ function viewTemplate(tpl){
'autocomplete="off" value="' + esc(v.tmdbKey || "") + '"></div>' + 'autocomplete="off" value="' + esc(v.tmdbKey || "") + '"></div>' +
'<div><div class="lbl">poznámka (jen pro tebe)</div><input id="tNote" ' + '<div><div class="lbl">poznámka (jen pro tebe)</div><input id="tNote" ' +
'autocomplete="off" value="' + esc(v.note || "") + '"></div>' + 'autocomplete="off" value="' + esc(v.note || "") + '"></div>' +
'<label class="row span" style="color:var(--muted);font-size:13px">' +
'<input type="checkbox" id="tDefault"' + (v.isDefault ? " checked" : "") +
' style="width:auto;accent-color:var(--accent)"> ' +
"výchozí skupina — tuhle otevře starý kód z AMBER_INVITE_CODE</label>" +
'<label class="row span" style="color:var(--muted);font-size:13px">' + '<label class="row span" style="color:var(--muted);font-size:13px">' +
'<input type="checkbox" id="tShow" style="width:auto;accent-color:var(--accent)"> ' + '<input type="checkbox" id="tShow" style="width:auto;accent-color:var(--accent)"> ' +
"zobrazit hodnoty</label>" + "zobrazit hodnoty</label>" +
@ -688,42 +716,82 @@ function viewTemplate(tpl){
"</div><div id=\"tOut\"></div>" + "</div><div id=\"tOut\"></div>" +
'<p class="note">V těchhle adresách jsou přihlašovací údaje k prehraj.to a ' + '<p class="note">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. " + "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.</p>" + "Kdo dostane kód téhle skupiny, stáhne si přesně tyhle zdroje.</p>" +
'<p class="note">Tohle je nastavení <b>rodiny</b>. 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.</p>' + '<p class="note">Každá skupina má vlastní údaje a vlastní kód, takže přátelé ' +
(tpl ? "" : '<div class="warnbox">Žádné nastavení ještě není uložené — ' + "nesdílí účty s rodinou. Kód musí být unikátní; dvě skupiny se stejným kódem " +
"první uložení ho vytvoří.</div>") + "</section>"; "databáze odmítne.</p>" +
(rows.length ? "" : '<div class="warnbox">Žádná skupina ještě není uložená — ' +
"první uložení ji vytvoří.</div>") + "</section>";
} }
async function saveTemplate(){ async function saveTemplate(){
var out = el("tOut"); var out = el("tOut");
var name = el("tName").value.trim();
if (!name) {
out.innerHTML = '<div class="warnbox">Skupina musí mít název — jinak ji ' +
"v seznamu nerozeznáš od ostatních.</div>";
return;
}
out.innerHTML = '<p class="note">ukládám…</p>'; out.innerHTML = '<p class="note">ukládám…</p>';
var body = { var body = {
name: name,
code: el("tCode").value.trim(),
isDefault: el("tDefault").checked,
addonUrl: el("tAddon").value.trim(), addonUrl: el("tAddon").value.trim(),
czechAddonUrl: el("tCzech").value.trim(), czechAddonUrl: el("tCzech").value.trim(),
tmdbKey: el("tTmdb").value.trim(), tmdbKey: el("tTmdb").value.trim(),
note: el("tNote").value.trim(), note: el("tNote").value.trim(),
}; };
try { try {
// The family row specifically. Several rows exist now (one per group), so var creating = (!tplSel || tplSel === "new");
// "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 r = await fetch("/api/collections/onboarding_template/records" + var r = await fetch("/api/collections/onboarding_template/records" +
(rec ? "/" + rec.id : ""), { (creating ? "" : "/" + tplSel), {
method: rec ? "PATCH" : "POST", method: creating ? "POST" : "PATCH",
headers: { "Content-Type": "application/json", Authorization: tok }, headers: { "Content-Type": "application/json", Authorization: tok },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!r.ok) { if (!r.ok) {
var d = await r.json(); 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 = '<div class="okbox">Uloženo. Nový člen to dostane při ' + var saved = await r.json();
"prvním nastavení; už nastavené účty se tím nemění.</div>"; tplSel = saved.id;
load();
// **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 = '<div class="okbox">Uloženo. Nový člen to dostane při prvním ' +
"nastavení; už nastavené účty se tím nemění." + moved + "</div>";
await load();
if (el("tOut")) el("tOut").innerHTML = msg;
} catch (e) { } catch (e) {
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>"; out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
} }
@ -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 If they later change their password the blob stops opening and the app flips
`addonConfigNeedsReentry`, which is the documented, already-handled path. */ `addonConfigNeedsReentry`, which is the documented, already-handled path. */
async function preloadFamilyConfig(userId, name, password){ async function preloadSharedConfig(userId, name, password, tplId){
var tpl = await familyTemplate(); // By id, because the dashboard now asks WHICH group. Reading "the" template here
if (!tpl) throw new Error("rodinné nastavení není uložené"); // 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", { var pr = await fetch("/api/collections/profiles/records", {
method: "POST", method: "POST",
@ -849,17 +921,18 @@ async function createAccount(){
throw new Error(why); throw new Error(why);
} }
var extra = ""; 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 // 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 // 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. // account you were not told about is the worst of the three.
try { try {
await preloadFamilyConfig(d.id, name, pass); await preloadSharedConfig(d.id, name, pass, gid);
extra = " Rodinné zdroje jsou nastavené — po přihlášení je uvidí sám."; extra = " Sdílené zdroje jsou nastavené — po přihlášení je uvidí sám.";
} catch (e2) { } catch (e2) {
extra = ' <b style="color:var(--warn)">Účet je založený, ale rodinné ' + extra = ' <b style="color:var(--warn)">Účet je založený, ale sdílené ' +
"nastavení se nepodařilo přiřadit (" + esc(e2.message) + "nastavení se nepodařilo přiřadit (" + esc(e2.message) +
"). Zdroje si zadá sám rodinným kódem.</b>"; "). Zdroje si zadá sám kódem své skupiny.</b>";
} }
} }
out.innerHTML = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) + out.innerHTML = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) +
@ -867,7 +940,6 @@ async function createAccount(){
"." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>"; "." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = ""; el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = "";
el("nNsfw").checked = false; el("nRating").value = ""; el("nNsfw").checked = false; el("nRating").value = "";
el("nFamily").checked = true;
load(); load();
} catch (e) { } catch (e) {
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>"; out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
@ -898,9 +970,15 @@ function wire(){
}); });
if (el("createBtn")) el("createBtn").onclick = createAccount; if (el("createBtn")) el("createBtn").onclick = createAccount;
if (el("tSave")) el("tSave").onclick = saveTemplate; 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(){ if (el("tShow")) el("tShow").onchange = function(){
var ty = el("tShow").checked ? "text" : "password"; 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(){ if (el("days")) el("days").onchange = function(){
prefs.days = Number(el("days").value); savePrefs(); load(); }; prefs.days = Number(el("days").value); savePrefs(); load(); };
@ -926,11 +1004,8 @@ async function load(){
if (prefs.view === "admin") { if (prefs.view === "admin") {
var us = await records("users", { sort: "-created", perPage: 200 }); var us = await records("users", { sort: "-created", perPage: 200 });
var tp = null; var tps = await allTemplates();
try { el("main").innerHTML = viewAdmin(us.items || [], tps) + viewTemplate(tps);
tp = await familyTemplate();
} catch (_) { /* no template yet, or the collection is missing */ }
el("main").innerHTML = viewAdmin(us.items || []) + viewTemplate(tp);
wire(); wire();
return; return;
} }