diff --git a/backend/alembic/versions/0080_google_email.py b/backend/alembic/versions/0080_google_email.py new file mode 100644 index 00000000..3924fc81 --- /dev/null +++ b/backend/alembic/versions/0080_google_email.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index ea24548f..c9601577 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -66,6 +66,12 @@ class User(Base, TimestampMixin, CanonicalEmailMixin): # 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. 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. password_hash: Mapped[str | None] = mapped_column(String(255)) # When SEC-10 dropped a preset password on Google adoption (see the single caller of diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index bd262239..214813ff 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -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: diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 4761a6f6..ea7fd425 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -118,6 +118,12 @@ def get_me( "role": user.role, "is_demo": user.is_demo, "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, # 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). diff --git a/backend/tests/test_email_canonical.py b/backend/tests/test_email_canonical.py index 814ebe22..483230a9 100644 --- a/backend/tests/test_email_canonical.py +++ b/backend/tests/test_email_canonical.py @@ -13,6 +13,8 @@ hold for Google Workspace custom domains unless their admin enabled it. import pytest from sqlalchemy import select, text +_UAT_PW = "correct horse battery" + from app.auth import is_allowed from app.models import Invite, User 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") 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" diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 6d31f480..9e84fee9 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -281,6 +281,20 @@ export default function NavSidebar({
+ {t("settings.account.googleLink.signedInAs")}{" "} + {me.google_email} +
+ )} {me.has_google ? ( diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index 31044b73..b5f7eb63 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -34,7 +34,8 @@ "signOut": "Sign out", "addAccount": "Add another account", "switchTo": "Switch to {{name}}", - "audit": "Audit log" + "audit": "Audit log", + "googleAddress": "Google account: {{email}}" }, "sync": { "yours": "yours", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 96d12b5d..8bfc564e 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -78,7 +78,8 @@ "linked": "Google account linked.", "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.", - "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.", "password": { diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 05bb5db1..cb3b9333 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -34,7 +34,8 @@ "signOut": "Kijelentkezés", "addAccount": "Másik fiók hozzáadása", "switchTo": "Váltás erre: {{name}}", - "audit": "Naplózás" + "audit": "Naplózás", + "googleAddress": "Google-fiók: {{email}}" }, "sync": { "yours": "tiéd", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 999d59e0..72adf40e 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -78,7 +78,8 @@ "linked": "Google-fiók összekapcsolva.", "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.", - "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.", "password": { diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts index 18f6cfa2..865d62eb 100644 --- a/frontend/src/lib/api/account.ts +++ b/frontend/src/lib/api/account.ts @@ -10,6 +10,10 @@ export interface Me { role: string; is_demo: 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; google_enabled: boolean; plex_enabled: boolean;