Czech account emails, and a reset that finishes on our own domain

The reset email works — confirmed by a delivered message, after every attempt on
record had failed at the lookup and the mail path had never once been exercised.
What the delivered message showed was a different problem: it was in English,
and its button pointed at `pb.petruzalekr.cz/_/#/auth/confirm-password-reset/…`,
which is PocketBase's admin console. Every other surface of this app is Czech on
purpose, because the people using it are the owner's parents and his children —
and the one moment they are most likely to be stuck is the one moment it started
speaking English and sent them to what reads like a developer tool.

Found by looking at a delivered email rather than at the settings. The templates
were simply PocketBase's defaults, and a default is invisible until somebody
receives one.

The reset link now lands on amber.petruzalekr.cz, which hosts the form: two
fields, in Czech, on the domain people already know. A reset link wins over a
stored session, because somebody arriving with one is trying to fix their
account and dropping them into a signed-in page hides the thing they came to do.
The token is stripped from the address bar once spent — a URL carrying a
credential is one that ends up in a bookmark or a screenshot.

A failed confirm names the likely cause. These links expire, and "something went
wrong" gives somebody who opened yesterday's email no way to know that.

Verification and email-change keep PocketBase's own confirm pages: nobody has
used those, they are not part of the report, and pointing them at a page that
does not exist would be worse than English.

As a migration rather than a click in the admin UI, so restoring the backend
from migrations does not quietly put English back.
This commit is contained in:
Claude 2026-09-05 19:04:50 +02:00
parent 2b458fb9f6
commit fb59f504fe
2 changed files with 144 additions and 0 deletions

View file

@ -0,0 +1,83 @@
/// <reference path="../pb_data/types.d.ts" />
// The account emails were in English, and pointed at PocketBase's admin console.
//
// **Why it matters here.** Every other surface of this app is Czech, on purpose:
// the people using it are the owner's parents and his children. The one moment
// they are most likely to be stuck — locked out, asking for a new password — was
// the one moment the product started speaking English and sent them to
// `pb.petruzalekr.cz/_/#/auth/confirm-password-reset/…`, which is the admin UI's
// own page and reads to anybody's parent like a developer tool opened by
// mistake.
//
// Found by looking at a delivered message rather than at the settings: the
// templates were simply PocketBase's defaults, and defaults are invisible until
// somebody receives one.
//
// The links now point at amber.petruzalekr.cz, which hosts the confirm form in
// Czech (see pb_public/index.html, `?reset=`). Verification and email-change
// keep PocketBase's own confirm pages: nobody has hit those yet, they are not
// part of the reported problem, and pointing them somewhere that does not exist
// would be worse than English.
const RESET_SUBJECT = "Nové heslo k Amberu";
const RESET_BODY = `<p>Ahoj,</p>
<p>Někdo (snad ty) požádal o nové heslo k Amberu. Nastavíš si ho tímhle odkazem:</p>
<p>
<a class="btn" href="https://amber.petruzalekr.cz/?reset={TOKEN}" target="_blank" rel="noopener">Nastavit nové heslo</a>
</p>
<p><i>Pokud jsi o nic nežádal, tenhle e-mail klidně smaž. Nic se nestalo a heslo
zůstává, jaké bylo.</i></p>
<p>Amber</p>`;
const VERIFY_SUBJECT = "Ověření e-mailu pro Amber";
const VERIFY_BODY = `<p>Ahoj,</p>
<p>Potvrď prosím tímhle odkazem, že tenhle e-mail patří tobě:</p>
<p>
<a class="btn" href="{APP_URL}/_/#/auth/confirm-verification/{TOKEN}" target="_blank" rel="noopener">Potvrdit e-mail</a>
</p>
<p>Amber</p>`;
const CHANGE_SUBJECT = "Změna e-mailu u Amberu";
const CHANGE_BODY = `<p>Ahoj,</p>
<p>Potvrď prosím změnu e-mailu u svého účtu:</p>
<p>
<a class="btn" href="{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}" target="_blank" rel="noopener">Potvrdit změnu</a>
</p>
<p><i>Pokud jsi o změnu nežádal, ozvi se Richardovi někdo se ti hrabe v účtu.</i></p>
<p>Amber</p>`;
// The defaults, so the down migration restores exactly what was there.
const EN = {
reset: {
subject: "Reset your {APP_NAME} password",
body: `<p>Hello,</p>
<p>Click on the button below to reset your password.</p>
<p>
<a class="btn" href="{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}" target="_blank" rel="noopener">Reset password</a>
</p>
<p><i>If you didn't ask to reset your password, please ignore this email.</i></p>
<p>
Thanks,<br/>
{APP_NAME} team
</p>`,
},
};
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
users.resetPasswordTemplate.subject = RESET_SUBJECT;
users.resetPasswordTemplate.body = RESET_BODY;
users.verificationTemplate.subject = VERIFY_SUBJECT;
users.verificationTemplate.body = VERIFY_BODY;
users.confirmEmailChangeTemplate.subject = CHANGE_SUBJECT;
users.confirmEmailChangeTemplate.body = CHANGE_BODY;
app.save(users);
},
(app) => {
const users = app.findCollectionByNameOrId("users");
users.resetPasswordTemplate.subject = EN.reset.subject;
users.resetPasswordTemplate.body = EN.reset.body;
app.save(users);
},
);

View file

@ -106,6 +106,25 @@
<body>
<div class="wrap">
<!-- ── setting a new password from an emailed link ─────────────────────── -->
<!-- Hosted here rather than letting the email point at PocketBase's own
confirm page: that page lives under /_/ with the admin console, is in
English, and looks to anybody's parents like a developer tool they have
opened by mistake. -->
<div id="reset" hidden>
<div class="card">
<h1>Nové heslo</h1>
<p class="sub">Zadej si nové heslo k účtu. Odkaz z e-mailu platí omezenou
dobu — když nezabere, požádej o nový.</p>
<label for="rNew">Nové heslo</label>
<input id="rNew" type="password" autocomplete="new-password">
<label for="rNew2">Nové heslo znovu</label>
<input id="rNew2" type="password" autocomplete="new-password">
<button id="rSave" class="btn primary">Nastavit heslo</button>
<div id="rMsg" class="msg"></div>
</div>
</div>
<!-- ── signed out ───────────────────────────────────────────────────────── -->
<div id="anon">
<div class="card">
@ -1354,10 +1373,52 @@ $("acChange").onclick=async function(){
await loadAddons();
};
// ── setting a new password from an emailed link ──────────────────────────────
/** The token the reset email put in the address bar, or null. */
function resetToken(){
try { return new URLSearchParams(location.search).get("reset"); }
catch(_){ return null; }
}
function showReset(){
$("anon").hidden = true;
$("reset").hidden = false;
$("rNew").focus();
}
$("rSave").onclick=async function(){
var tok=resetToken();
var a=$("rNew").value, b=$("rNew2").value;
if(a.length<8){ setMsg($("rMsg"),"Heslo musí mít aspoň 8 znaků.","err"); return; }
if(a!==b){ setMsg($("rMsg"),"Hesla se neshodují.","err"); return; }
setMsg($("rMsg"),""); busy($("rSave"),true,"Nastavuji…");
var r=await api("POST","/api/collections/users/confirm-password-reset",
{ token:tok, password:a, passwordConfirm:b }, {anon:true});
busy($("rSave"),false);
if(!r.ok){
// The overwhelmingly likely cause, and the one worth naming: these links
// expire, and a person who tried yesterday's email has no way to know that
// from "something went wrong".
setMsg($("rMsg"),"Nepovedlo se. Odkaz nejspíš vypršel — požádej o nový.","err");
return;
}
$("rNew").value=$("rNew2").value="";
setMsg($("rMsg"),"Hotovo. Přihlas se novým heslem.","ok");
// Drop the token from the address bar: it is spent, and a URL with a
// credential in it is one that ends up in a bookmark or a shared screenshot.
try { history.replaceState(null,"",location.pathname); } catch(_){}
setTimeout(function(){ $("reset").hidden=true; $("anon").hidden=false; }, 2500);
};
// ── boot ─────────────────────────────────────────────────────────────────────
(async function(){
fillLangs();
$("tvUrl").textContent=location.host;
// A reset link wins over a stored session: somebody arriving with one is
// trying to fix their account, and dropping them into a signed-in page they
// did not ask for hides the very thing they came to do.
if (resetToken()) { showReset(); return; }
if (await restore()) await enter();
})();
</script>