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
@@ -0,0 +1,32 @@
"""users.google_email — the address Google asserts, kept beside the one the user registered with
Revision ID: 0080_google_email
Revises: 0079_email_canonical
Create Date: 2026-09-04
Since 0079's follow-up a Google sign-in no longer overwrites `users.email`, so a deliberate
`me+siftlode@gmail.com` survives being linked to a Google account whose primary is `me@gmail.com`.
That leaves two true addresses for one account and only one of them stored. This column holds the
other, so the account menu and the Settings sign-in row can show which address Google actually
answers with.
No backfill is possible: Google's answer is only known at sign-in. Accounts linked before this
column existed carry NULL until their owner next signs in with Google — and for the majority the two
addresses are identical anyway, where the UI shows nothing either way.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0080_google_email"
down_revision: str | None = "0079_email_canonical"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("google_email", sa.String(length=320), nullable=True))
def downgrade() -> None:
op.drop_column("users", "google_email")
+6
View File
@@ -66,6 +66,12 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# NOT unique: prod already carries two rows for one mailbox (a password registration and the # NOT unique: prod already carries two rows for one mailbox (a password registration and the
# Google sign-in that failed to find it), and a migration must not merge accounts on its own. # Google sign-in that failed to find it), and a migration must not merge accounts on its own.
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="") email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
# The address Google asserted for this account, recorded at sign-in / link time. NOT the same
# thing as `email`: since E9.5 S3 a Google sign-in no longer overwrites the address the user
# registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other
# half of the truth lives, for the UI to show when the two differ. NULL until the next Google
# sign-in for accounts linked before this column existed.
google_email: Mapped[str | None] = mapped_column(String(320))
# argon2id hash for email+password login; NULL for Google-only / demo accounts. # argon2id hash for email+password login; NULL for Google-only / demo accounts.
password_hash: Mapped[str | None] = mapped_column(String(255)) password_hash: Mapped[str | None] = mapped_column(String(255))
# When SEC-10 dropped a preset password on Google adoption (see the single caller of # When SEC-10 dropped a preset password on Google adoption (see the single caller of
+8
View File
@@ -351,6 +351,11 @@ async def callback(
log.info("Suspended account blocked at Google login: %s", email) log.info("Suspended account blocked at Google login: %s", email)
_notify_suspended(background, user.email) _notify_suspended(background, user.email)
return RedirectResponse(url="/?login=suspended") 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) _sync_google_email(db, user, email)
user.display_name = userinfo.get("name") user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture") 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: if user.google_sub is not None and user.google_sub != sub:
return RedirectResponse(url="/?link=mismatch") return RedirectResponse(url="/?link=mismatch")
user.google_sub = sub 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: if not user.display_name:
user.display_name = userinfo.get("name") user.display_name = userinfo.get("name")
if not user.avatar_url: if not user.avatar_url:
+6
View File
@@ -118,6 +118,12 @@ def get_me(
"role": user.role, "role": user.role,
"is_demo": user.is_demo, "is_demo": user.is_demo,
"has_google": user.google_sub is not None, "has_google": user.google_sub is not None,
# The address Google answers with, when it DIFFERS from the account's own. Same string for
# most people, and then this is None so no surface has to decide whether to show it — the
# comparison lives here, once, instead of in every consumer.
"google_email": (
user.google_email if user.google_email and user.google_email != user.email else None
),
"has_password": user.password_hash is not None, "has_password": user.password_hash is not None,
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides # Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
# YouTube-access affordances and the onboarding nudge when it isn't configured). # YouTube-access affordances and the onboarding nudge when it isn't configured).
+49
View File
@@ -13,6 +13,8 @@ hold for Google Workspace custom domains unless their admin enabled it.
import pytest import pytest
from sqlalchemy import select, text from sqlalchemy import select, text
_UAT_PW = "correct horse battery"
from app.auth import is_allowed from app.auth import is_allowed
from app.models import Invite, User from app.models import Invite, User
from app.utils import canonical_email from app.utils import canonical_email
@@ -293,3 +295,50 @@ def test_a_mailbox_change_is_refused_when_another_account_owns_it(db):
_sync_google_email(db, user, "taken@example.com") _sync_google_email(db, user, "taken@example.com")
assert user.email == "mover@gmail.com" # unique constraint would have 500'd the sign-in assert user.email == "mover@gmail.com" # unique constraint would have 500'd the sign-in
# --- showing BOTH addresses, once there are two -------------------------------------------------
def test_the_google_address_is_recorded_beside_the_registered_one(db):
"""Since the sign-in no longer overwrites the registered address, the account has two true
addresses and only one was stored. This is the other half, for the UI to name."""
from app.routes.auth import _sync_google_email
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
user.google_email = "me@gmail.com"
_sync_google_email(db, user, "me@gmail.com")
db.commit()
assert user.email == "me+siftlode@gmail.com" # still theirs
assert user.google_email == "me@gmail.com" # …and now we know the other one
def test_me_reports_the_google_address_only_when_it_differs(client, db, make_user):
"""The comparison lives in one place so no surface has to repeat it — and so the account menu
stays exactly as it is for the majority, whose two addresses are the same string."""
same = make_user(email="plain@gmail.com", password=_UAT_PW)
same.google_email = "plain@gmail.com"
db.commit()
assert (
client.post(
"/auth/password-login", json={"email": "plain@gmail.com", "password": _UAT_PW}
).status_code
== 200
)
assert client.get("/api/me").json()["google_email"] is None
client.post("/auth/logout")
tagged = make_user(email="me+siftlode@gmail.com", password=_UAT_PW)
tagged.google_email = "me@gmail.com"
db.commit()
assert (
client.post(
"/auth/password-login", json={"email": "me+siftlode@gmail.com", "password": _UAT_PW}
).status_code
== 200
)
assert client.get("/api/me").json()["google_email"] == "me@gmail.com"
+14
View File
@@ -281,6 +281,20 @@ export default function NavSidebar({
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div> <div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div> <div className="text-xs text-muted truncate">{me.email}</div>
{/* The address Google answers with, when it isn't the one above. /api/me sends it
only when the two differ, so for most accounts this line never exists — and for
someone who registered as `me+siftlode@gmail.com` it answers the question their
sign-in screen would otherwise leave open: which address gets me in? A text
label rather than a brand glyph, since the app draws one icon set (lucide) and a
generic mail icon here would say nothing. */}
{me.google_email && (
<div
className="text-2xs text-muted truncate mt-0.5"
title={t("header.account.googleAddress", { email: me.google_email })}
>
<span className="uppercase tracking-wide">Google</span> · {me.google_email}
</div>
)}
</div> </div>
</div> </div>
{me.role === "admin" && ( {me.role === "admin" && (
@@ -413,6 +413,15 @@ function SignInMethods({ me }: { me: Me }) {
? t("settings.account.googleLink.connectedHint") ? t("settings.account.googleLink.connectedHint")
: t("settings.account.googleLink.connectHint")} : t("settings.account.googleLink.connectHint")}
</p> </p>
{/* Which address Google actually answers with — sent only when it differs from the
account's own (see /api/me). "Connected" alone left that unanswerable, and it is
exactly what someone who registered with a `+tag` needs to know. */}
{me.google_email && (
<p className="text-xs text-fg/80 mt-1.5">
<span className="text-muted">{t("settings.account.googleLink.signedInAs")}</span>{" "}
{me.google_email}
</p>
)}
</div> </div>
{me.has_google ? ( {me.has_google ? (
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent"> <span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
+2 -1
View File
@@ -34,7 +34,8 @@
"signOut": "Sign out", "signOut": "Sign out",
"addAccount": "Add another account", "addAccount": "Add another account",
"switchTo": "Switch to {{name}}", "switchTo": "Switch to {{name}}",
"audit": "Audit log" "audit": "Audit log",
"googleAddress": "Google account: {{email}}"
}, },
"sync": { "sync": {
"yours": "yours", "yours": "yours",
+2 -1
View File
@@ -78,7 +78,8 @@
"linked": "Google account linked.", "linked": "Google account linked.",
"conflict": "That Google account is already linked to a different Siftlode account.", "conflict": "That Google account is already linked to a different Siftlode account.",
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.", "mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
"error": "Couldn't link the Google account. Please try again." "error": "Couldn't link the Google account. Please try again.",
"signedInAs": "Signed in with Google as"
}, },
"googleCancelled": "Google sign-in was cancelled.", "googleCancelled": "Google sign-in was cancelled.",
"password": { "password": {
+2 -1
View File
@@ -34,7 +34,8 @@
"signOut": "Kijelentkezés", "signOut": "Kijelentkezés",
"addAccount": "Másik fiók hozzáadása", "addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}", "switchTo": "Váltás erre: {{name}}",
"audit": "Naplózás" "audit": "Naplózás",
"googleAddress": "Google-fiók: {{email}}"
}, },
"sync": { "sync": {
"yours": "tiéd", "yours": "tiéd",
+2 -1
View File
@@ -78,7 +78,8 @@
"linked": "Google-fiók összekapcsolva.", "linked": "Google-fiók összekapcsolva.",
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.", "conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.", "mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra." "error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra.",
"signedInAs": "Google-belépés ezzel a címmel:"
}, },
"googleCancelled": "A Google-belépés megszakadt.", "googleCancelled": "A Google-belépés megszakadt.",
"password": { "password": {
+4
View File
@@ -10,6 +10,10 @@ export interface Me {
role: string; role: string;
is_demo: boolean; is_demo: boolean;
has_google: boolean; has_google: boolean;
/** The address Google answers with, ONLY when it differs from `email` — the backend does that
* comparison, so a surface can render this whenever it is present. Someone who registered as
* `me+siftlode@gmail.com` keeps that address; this is the other one that also signs them in. */
google_email: string | null;
has_password: boolean; has_password: boolean;
google_enabled: boolean; google_enabled: boolean;
plex_enabled: boolean; plex_enabled: boolean;