feat(auth): show the Google address beside the one the account registered with (E9.5 S3)

An account can now hold two true addresses: the one its owner typed, which a Google
sign-in no longer overwrites, and the one Google answers with. Only the first was
stored, so Settings could say 'Connected' without saying connected AS WHOM -- exactly
what someone who registered with a +tag needs to know.

users.google_email (migration 0080) records Google's answer at sign-in and at an
in-app link. /api/me exposes it ONLY when it differs from the account address, so the
comparison lives in one place and the majority, whose two addresses are the same
string, see no change at all. Rendered in the account menu as a muted third line under
the identity, and in the Settings sign-in row under the existing hint.

A short 'Google' label rather than a brand glyph: the app draws one icon set (lucide),
and a generic mail icon next to an email address says nothing. It also reads to a
screen reader, which an unlabelled logo would not.

No backfill is possible -- Google's answer is only known at sign-in -- so accounts
linked earlier stay NULL until their owner next signs in with Google.
This commit is contained in:
2026-09-04 01:23:11 +02:00
parent c74487fbb3
commit a614ee2435
12 changed files with 136 additions and 4 deletions
+8
View File
@@ -351,6 +351,11 @@ async def callback(
log.info("Suspended account blocked at Google login: %s", email)
_notify_suspended(background, user.email)
return RedirectResponse(url="/?login=suspended")
# Record what Google actually answers with, beside the address the user registered with. The two
# are the same string for most people; where they differ (a deliberate `+tag`, a different dot
# pattern) the UI shows both, so nobody has to guess which one signs them in.
if email:
user.google_email = email
_sync_google_email(db, user, email)
user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture")
@@ -474,6 +479,9 @@ def _complete_link(
if user.google_sub is not None and user.google_sub != sub:
return RedirectResponse(url="/?link=mismatch")
user.google_sub = sub
linked_email = (userinfo.get("email") or "").strip().lower()
if linked_email:
user.google_email = linked_email # see the sign-in callback: the other half of the identity
if not user.display_name:
user.display_name = userinfo.get("name")
if not user.avatar_url: