fix(auth): confirm an emailed verification link wherever it is opened (E9.5 S2)

The token was only ever consumed inside Welcome.tsx, which App renders ONLY when
there is no session. Opening the emailed link in a browser that already held a
Siftlode session posted nothing, the fragment was then stripped, and the user got
no signal at all -- the link simply did nothing. Incognito worked, which is why it
survived. Confirmation now happens in App, above that branch: signed out the outcome
still drives the auth card's banners, signed in it drives a top-of-app bar.

The entry URL is snapshotted once at import time (lib/entryUrl) instead of per
component, so no future URL writer can strip a token before whoever needs it has
read it -- that ordering race is what broke this in the first place. POST /auth/verify
now names the address it confirmed, which matters when the browser holds a session
for a different account; the same reasoning as password_reset_required already
covers the disclosure.

Also here, from the 2026-09-02 review tail: the dead GET /auth/verify goes (both
senders emit the fragment form, and the last query-token link expired 24h after the
0.78.7 deploy), a spent #verify=/#reset= fragment is cleared while signed in, and
the auth-card banners get role=status. The verifyOkReset copy no longer blames
admin approval, which E9.5 S1 made false.
This commit is contained in:
2026-09-03 23:45:31 +02:00
parent 243869401a
commit c134b514c1
15 changed files with 337 additions and 73 deletions
+6 -15
View File
@@ -678,8 +678,13 @@ def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
)
user.email_verified = True
needs_password = user.password_hash is None and user.password_revoked_at is not None
email = user.email
db.commit()
return {"ok": True, "password_reset_required": needs_password}
# Naming the address lets the SPA say WHICH mailbox it just confirmed — the same reasoning as
# password_reset_required above: holding a valid single-use verify token for this account already
# proves that much, so echoing it back leaks nothing. It matters in a browser that holds a session
# for a different account, where an unqualified "email confirmed" would be actively misleading.
return {"ok": True, "password_reset_required": needs_password, "email": email}
@router.post("/verify/resend")
@@ -715,20 +720,6 @@ def _resend_verify_if_eligible(email: str) -> None:
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
@router.get("/verify")
def verify_email(token: str, db: Session = Depends(get_db)):
"""Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
fragment + POST flow above; this bounces to a friendly status for older links until they expire."""
user = _consume_token(db, token, "verify")
if user is None:
return RedirectResponse(url="/?verify=invalid")
user.email_verified = True
needs_password = user.password_hash is None and user.password_revoked_at is not None
db.commit()
# Same two outcomes as the POST flow above, so an old link in flight lands on the same guidance.
return RedirectResponse(url="/?verify=" + ("ok_reset" if needs_password else "ok"))
@router.post("/password-login")
def password_login(
body: EmailPasswordIn,
+15 -3
View File
@@ -157,7 +157,11 @@ def test_verifying_afterwards_lets_the_original_password_in(client, db, make_use
verified = client.post("/auth/verify", json={"token": raw})
assert verified.status_code == 200
assert verified.json() == {"ok": True, "password_reset_required": False}
assert verified.json() == {
"ok": True,
"password_reset_required": False,
"email": "ordering@example.com",
}
assert _login(client, "ordering@example.com", "chosen at registration").status_code == 200
@@ -515,7 +519,11 @@ def test_verify_tells_a_revoked_account_that_it_needs_a_new_password(client, db,
r = client.post("/auth/verify", json={"token": raw})
assert r.status_code == 200
assert r.json() == {"ok": True, "password_reset_required": True}
assert r.json() == {
"ok": True,
"password_reset_required": True,
"email": "needspw@example.com",
}
def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_user):
@@ -528,4 +536,8 @@ def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_u
r = client.post("/auth/verify", json={"token": raw})
assert r.status_code == 200
assert r.json() == {"ok": True, "password_reset_required": False}
assert r.json() == {
"ok": True,
"password_reset_required": False,
"email": "intact@example.com",
}