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:
parent
8f2cb994ec
commit
79b615a02c
2 changed files with 181 additions and 67 deletions
|
|
@ -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 `<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)
|
||||
|
||||
|
|
@ -258,3 +280,20 @@ used to break:
|
|||
`familyTemplate()` returned `family` while `-updated` returned `friends`, which is
|
||||
the divergence the change exists to create. Rate limiting still allows ten failures
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -223,24 +223,14 @@ async function records(coll, params){
|
|||
return api("/api/collections/" + coll + "/records?" + q);
|
||||
}
|
||||
|
||||
/* The FAMILY template row, chosen by its flag rather than by being newest.
|
||||
There are several rows now, one per group the owner shares accounts with, and
|
||||
"-updated" would mean this page silently followed whichever group was edited
|
||||
last: you would open the editor to fix a family URL and overwrite the friends'
|
||||
credentials with it, or create a friend's account and preload the family's. The
|
||||
page had a comment warning about exactly this ("never create a second").
|
||||
This page stays the FAMILY editor. Other groups are maintained in the PocketBase
|
||||
admin UI, and their members type their own code on the account page. */
|
||||
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;
|
||||
/* Every group, for the picker and for the create form's dropdown. Failure is an
|
||||
empty list rather than a throw: a dashboard that renders nothing because one
|
||||
collection is missing is worse than one that says there are no groups yet. */
|
||||
async function allTemplates(){
|
||||
try {
|
||||
var r = await records("onboarding_template", { perPage: 50, sort: "name" });
|
||||
return r.items || [];
|
||||
} catch (_) { return []; }
|
||||
}
|
||||
|
||||
function logout(){
|
||||
|
|
@ -608,7 +598,7 @@ function viewDevices(rows){
|
|||
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. */
|
||||
|
||||
function viewAdmin(users){
|
||||
function viewAdmin(users, tpls){
|
||||
var rows = (users || []).map(function(u){
|
||||
return "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" +
|
||||
"<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">' +
|
||||
'<input type="checkbox" id="nNsfw" style="width:auto;accent-color:var(--accent)"> ' +
|
||||
"povolit 18+ (jinak dostane clean build)</label>" +
|
||||
'<label class="row span" style="color:var(--muted);font-size:13px">' +
|
||||
'<input type="checkbox" id="nFamily" checked ' +
|
||||
'style="width:auto;accent-color:var(--accent)"> ' +
|
||||
"použít rodinné nastavení — zdroje i TMDB klíč nastavím hned, " +
|
||||
"nemusí zadávat rodinný kód</label>" +
|
||||
'<div class="span"><div class="lbl">sdílené nastavení</div>' +
|
||||
'<select id="nGroup" style="width:100%">' +
|
||||
'<option value="">žádné — zdroje si zadá sám</option>' +
|
||||
(tpls || []).map(function(t){
|
||||
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%">' +
|
||||
'<option value="">bez omezení</option><option value="kids">kids</option>' +
|
||||
'<option value="twelve">12+</option><option value="fifteen">15+</option>' +
|
||||
|
|
@ -655,20 +649,50 @@ function viewAdmin(users){
|
|||
"vyžádat.</p></section>";
|
||||
}
|
||||
|
||||
/* ── Rodinné nastavení (onboarding_template) ──────────────────────────────────
|
||||
The shared config a new family member's first setup pulls down, unlocked by
|
||||
AMBER_INVITE_CODE. One record, superuser-only on every rule, so the dashboard
|
||||
reads and writes it directly.
|
||||
/* ── Sdílená nastavení (onboarding_template) ──────────────────────────────────
|
||||
The shared config a new member's first setup pulls down, unlocked by the code of
|
||||
their group. One row per group the owner shares accounts with, so the family and
|
||||
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
|
||||
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
|
||||
noindex page and nowhere else. */
|
||||
|
||||
function viewTemplate(tpl){
|
||||
var v = tpl || {};
|
||||
return '<section class="wide"><h2>Rodinné nastavení — co dostane nový člen</h2>' +
|
||||
/* Which group the editor is pointed at: a record id, or "new". Held here rather
|
||||
than read off the <select> 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 '<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="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>' +
|
||||
'<input id="tAddon" type="password" autocomplete="off" value="' +
|
||||
esc(v.addonUrl || "") + '"></div>' +
|
||||
|
|
@ -679,6 +703,10 @@ function viewTemplate(tpl){
|
|||
'autocomplete="off" value="' + esc(v.tmdbKey || "") + '"></div>' +
|
||||
'<div><div class="lbl">poznámka (jen pro tebe)</div><input id="tNote" ' +
|
||||
'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">' +
|
||||
'<input type="checkbox" id="tShow" style="width:auto;accent-color:var(--accent)"> ' +
|
||||
"zobrazit hodnoty</label>" +
|
||||
|
|
@ -688,42 +716,82 @@ function viewTemplate(tpl){
|
|||
"</div><div id=\"tOut\"></div>" +
|
||||
'<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. " +
|
||||
"Nový člen si je stáhne při prvním nastavení pomocí rodinného kódu.</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>' +
|
||||
(tpl ? "" : '<div class="warnbox">Žádné nastavení ještě není uložené — ' +
|
||||
"první uložení ho vytvoří.</div>") + "</section>";
|
||||
"Kdo dostane kód téhle skupiny, stáhne si přesně tyhle zdroje.</p>" +
|
||||
'<p class="note">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.</p>" +
|
||||
(rows.length ? "" : '<div class="warnbox">Žádná skupina ještě není uložená — ' +
|
||||
"první uložení ji vytvoří.</div>") + "</section>";
|
||||
}
|
||||
|
||||
async function saveTemplate(){
|
||||
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>';
|
||||
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 = '<div class="okbox">Uloženo. Nový člen to dostane při ' +
|
||||
"prvním nastavení; už nastavené účty se tím nemění.</div>";
|
||||
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 = '<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) {
|
||||
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
|
||||
`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 = ' <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) +
|
||||
"). 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) +
|
||||
|
|
@ -867,7 +940,6 @@ async function createAccount(){
|
|||
"." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
|
||||
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 = '<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("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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue