From a614ee2435c857dee55376be284e081bb96062bf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 4 Sep 2026 01:23:11 +0200 Subject: [PATCH] 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. --- backend/alembic/versions/0080_google_email.py | 32 ++++++++++++ backend/app/models.py | 6 +++ backend/app/routes/auth.py | 8 +++ backend/app/routes/me.py | 6 +++ backend/tests/test_email_canonical.py | 49 +++++++++++++++++++ frontend/src/components/NavSidebar.tsx | 14 ++++++ frontend/src/components/SettingsPanel.tsx | 9 ++++ frontend/src/i18n/locales/en/header.json | 3 +- frontend/src/i18n/locales/en/settings.json | 3 +- frontend/src/i18n/locales/hu/header.json | 3 +- frontend/src/i18n/locales/hu/settings.json | 3 +- frontend/src/lib/api/account.ts | 4 ++ 12 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/0080_google_email.py 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({
{name}
{me.email}
+ {/* 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 && ( +
+ Google · {me.google_email} +
+ )}
{me.role === "admin" && ( diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index c11d7cf4..8645b5cb 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -413,6 +413,15 @@ function SignInMethods({ me }: { me: Me }) { ? t("settings.account.googleLink.connectedHint") : t("settings.account.googleLink.connectHint")}

+ {/* 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 && ( +

+ {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;