Finish confirming an email address on our own site

The verification email still pointed at pb.petruzalekr.cz/_/#/auth/..., the
PocketBase admin console. The previous pass left it there reasoning that nobody
had reached it yet; the accounts say otherwise. Registration fires a verification
email every time (auth_service.dart, fire and forget) and four of the nine
accounts are marked verified, so four family members have already landed in the
admin console and clicked a button in it.

?verify=<token> now confirms in Czech beside ?reset=. It confirms on page load
rather than behind a button: a provider that prefetches links only issues a GET,
the POST comes from the page's own script, and asking someone to press a second
button on a page they reached by pressing one is friction with nothing behind it.

Nothing is gated on verified. The users collection has an empty authRule and five
accounts, the owner's included, work fine without it. The flag stays because it
is the only evidence an address was typed correctly, which is exactly the
question that comes up when somebody reports a missing email. The failure copy
says so plainly, so an expired link reads as harmless rather than as a lockout.

Email-change is deliberately untouched: no surface can trigger it today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-09-05 19:31:51 +02:00
parent eaff4abbca
commit f72c9e7986
2 changed files with 111 additions and 0 deletions

View file

@ -0,0 +1,55 @@
/// <reference path="../pb_data/types.d.ts" />
// Point the verification email at the site instead of the admin console.
//
// 1794000000 translated all three account emails but deliberately left
// verification and email-change aimed at `{APP_URL}/_/#/auth/…`, on the grounds
// that "nobody has hit those yet". That was wrong on the facts: the app fires a
// verification email on every registration (auth_service.dart, fire and forget),
// and four of the nine accounts carry `verified: 1` — four family members opened
// PocketBase's admin console and clicked a button in it.
//
// Nothing is gated on `verified`: the users collection has an empty authRule,
// and five accounts including the owner's work fine unverified. The flag is kept
// because it is the only evidence an address was typed correctly, which is
// exactly the question that comes up when somebody reports a missing email.
//
// amber.petruzalekr.cz/?verify=<token> now hosts the confirm step in Czech, the
// same way `?reset=` does.
//
// Email-change is left alone on purpose: no surface in the app or the site can
// trigger it today, so pointing it at a page nobody reaches would be motion
// without a reader.
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="https://amber.petruzalekr.cz/?verify={TOKEN}" target="_blank" rel="noopener">Potvrdit e-mail</a>
</p>
<p><i>Není to povinné. Bez potvrzení se přihlásíš úplně stejně, jen Richard
uvidí, že adresa opravdu funguje.</i></p>
<p>Amber</p>`;
// What 1794000000 set, so the down migration lands where it started rather than
// on PocketBase's English default.
const PREVIOUS_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>`;
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
users.verificationTemplate.subject = VERIFY_SUBJECT;
users.verificationTemplate.body = VERIFY_BODY;
app.save(users);
},
(app) => {
const users = app.findCollectionByNameOrId("users");
users.verificationTemplate.subject = VERIFY_SUBJECT;
users.verificationTemplate.body = PREVIOUS_BODY;
app.save(users);
},
);

View file

@ -131,6 +131,26 @@
</div> </div>
</div> </div>
<!-- ── confirming an email address from an emailed link ─────────────────── -->
<!-- Same reasoning as the reset card above: the verification email used to
land on pb.petruzalekr.cz/_/#/auth/confirm-verification/…, which is the
admin console. Nothing is gated on `verified` (the users collection has an
empty authRule), so this page exists to be reassuring rather than to
unlock anything.
It confirms on load rather than behind a button. A mail provider that
prefetches links only issues a GET; the POST happens from this script, and
scanners do not run it. Asking a parent to click a second button on a page
they reached by clicking a button is friction with nothing behind it. -->
<div id="verify" hidden>
<div class="card">
<h1>Ověření e-mailu</h1>
<p class="sub" id="vSub">Moment, ověřuji…</p>
<div id="vMsg" class="msg"></div>
<button id="vDone" class="btn primary" hidden>Pokračovat</button>
</div>
</div>
<!-- ── signed out ───────────────────────────────────────────────────────── --> <!-- ── signed out ───────────────────────────────────────────────────────── -->
<div id="anon"> <div id="anon">
<div class="card"> <div class="card">
@ -1417,6 +1437,41 @@ $("rSave").onclick=async function(){
setTimeout(function(){ $("reset").hidden=true; $("anon").hidden=false; }, 2500); setTimeout(function(){ $("reset").hidden=true; $("anon").hidden=false; }, 2500);
}; };
// ── confirming an email address from an emailed link ─────────────────────────
/** The token the verification email put in the address bar, or null. */
function verifyToken(){
try { return new URLSearchParams(location.search).get("verify"); }
catch(_){ return null; }
}
async function showVerify(){
$("anon").hidden = true;
$("verify").hidden = false;
var r = await api("POST","/api/collections/users/confirm-verification",
{ token: verifyToken() }, {anon:true});
// Spend the token out of the address bar either way: it is single use, and a
// URL carrying one is a URL that ends up in a bookmark or a screenshot.
try { history.replaceState(null,"",location.pathname); } catch(_){}
if(!r.ok){
// Worth saying plainly that nothing is broken: verification gates nothing,
// so a burnt or expired link costs the person exactly nothing.
$("vSub").textContent="Odkaz nezabral.";
setMsg($("vMsg"),"Nejspíš už vypršel. Nevadí, k přihlášení ověření "+
"potřeba není.","err");
} else {
$("vSub").textContent="Hotovo, e-mail je ověřený.";
setMsg($("vMsg"),"Můžeš se přihlásit.","ok");
}
$("vDone").hidden=false;
}
$("vDone").onclick=async function(){
$("verify").hidden=true;
if (await restore()) { await enter(); return; }
$("anon").hidden=false;
};
// ── boot ───────────────────────────────────────────────────────────────────── // ── boot ─────────────────────────────────────────────────────────────────────
(async function(){ (async function(){
fillLangs(); fillLangs();
@ -1425,6 +1480,7 @@ $("rSave").onclick=async function(){
// trying to fix their account, and dropping them into a signed-in page they // 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. // did not ask for hides the very thing they came to do.
if (resetToken()) { showReset(); return; } if (resetToken()) { showReset(); return; }
if (verifyToken()) { await showVerify(); return; }
if (await restore()) await enter(); if (await restore()) await enter();
})(); })();
</script> </script>