diff --git a/backend/app/auth.py b/backend/app/auth.py index 269264a..71d99a1 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -249,10 +249,11 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None: # Local-dev origins allowed to drive the OAuth redirect_uri from the request Host. The Vite dev # server (:5173) proxies /auth to the backend (:8080) preserving the Host header, and the session # cookie is port-agnostic — so a flow started on :5173 can come back to :5173 instead of always -# landing on the fixed :8080 callback. Both must also be registered in the Google console. -_DEV_OAUTH_HOSTS = frozenset( - {"localhost:5173", "localhost:8080", "127.0.0.1:5173", "127.0.0.1:8080"} -) +# landing on the fixed :8080 callback. Only the localhost variants are listed: they must ALSO be +# registered as redirect URIs in the Google console, and Google rejects an unregistered one with +# redirect_uri_mismatch. A 127.0.0.1 origin isn't registered, so it deliberately falls through to +# the fixed configured callback (which is registered) rather than producing a mismatch. +_DEV_OAUTH_HOSTS = frozenset({"localhost:5173", "localhost:8080"}) def _oauth_redirect_uri(request: Request) -> str: @@ -309,12 +310,14 @@ async def callback( token = await client.authorize_access_token(request) except OAuthError as exc: # The user cancelled or denied consent at Google — land back with a friendly notice instead - # of a raw 400 JSON error page. A link/upgrade cancel is an ALREADY-signed-in user who never - # sees the pre-auth login banner, so route it through the ?link= channel App.tsx surfaces as - # a toast; a plain sign-in cancel lands on the login page with the oauth_cancelled banner. + # of a raw 400 JSON error page. An already-signed-in user (adding another account via + # /auth/login, or linking/upgrading via /auth/link|upgrade) never sees the pre-auth login + # banner, so surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on + # the login page with the oauth_cancelled banner instead. Keyed on the live session, not on + # link_uid, so the add-account path (no marker) is covered too. log.info("Google OAuth cancelled/failed: %s", exc.error) - if link_uid is not None: - return RedirectResponse(url="/?link=cancelled") + if request.session.get("user_id") is not None: + return RedirectResponse(url="/?oauth=cancelled") return RedirectResponse(url="/?login=oauth_cancelled") userinfo = token.get("userinfo") diff --git a/backend/tests/test_oauth_redirect.py b/backend/tests/test_oauth_redirect.py index fe05bff..15d61ad 100644 --- a/backend/tests/test_oauth_redirect.py +++ b/backend/tests/test_oauth_redirect.py @@ -30,7 +30,6 @@ def test_dev_known_host_returns_to_that_origin(monkeypatch): monkeypatch.setattr(auth, "settings", _dev_settings()) assert auth._oauth_redirect_uri(_req("localhost:5173")) == "http://localhost:5173/auth/callback" assert auth._oauth_redirect_uri(_req("localhost:8080")) == "http://localhost:8080/auth/callback" - assert auth._oauth_redirect_uri(_req("127.0.0.1:5173")) == "http://127.0.0.1:5173/auth/callback" def test_dev_host_is_case_insensitive(monkeypatch): @@ -41,6 +40,9 @@ def test_dev_host_is_case_insensitive(monkeypatch): def test_dev_unknown_or_missing_host_falls_back_to_configured(monkeypatch): monkeypatch.setattr(auth, "settings", _dev_settings()) # An origin not on the allowlist (or a request with no Host) must not drive the redirect. + # 127.0.0.1 is deliberately NOT allowlisted (not registered in the Google console), so it falls + # back to the fixed configured callback rather than producing a redirect_uri_mismatch. + assert auth._oauth_redirect_uri(_req("127.0.0.1:5173")) == "http://localhost:8080/auth/callback" assert auth._oauth_redirect_uri(_req("evil.example.com")) == "http://localhost:8080/auth/callback" assert auth._oauth_redirect_uri(_req(None)) == "http://localhost:8080/auth/callback" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e27b40..934dac3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -192,9 +192,6 @@ export default function App() { void meQuery.refetch(); setAccountRaw(LS.settingsTab, "account"); setPage("settings"); - } else if (result === "cancelled") { - // The user backed out at Google's consent screen — nothing changed, just acknowledge it. - notify({ level: "info", message: t("settings.account.googleLink.cancelled") }); } else { const key = result === "conflict" || result === "mismatch" ? result : "error"; notify({ level: "error", message: t(`settings.account.googleLink.${key}`) }); @@ -202,6 +199,15 @@ export default function App() { stripUrlParams(); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was + // cancelled at Google's consent screen. The backend only emits this for a live session, so a + // neutral toast is the right feedback — the pre-auth login banner would never render here. + useEffect(() => { + if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return; + notify({ level: "info", message: t("settings.account.googleCancelled") }); + stripUrlParams(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to // the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't // drop it; act once `me` is known, and only for an admin (others just land on their default page). diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index c097ad9..313c3ff 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -346,6 +346,12 @@ function AuthCard() { setLogin(null); }; + // Shared by the expired-link banner CTA and the sign-in "didn't get the email?" link. + const goResend = () => { + reset(); + setMode("resend"); + }; + async function onSubmit(e: React.FormEvent) { e.preventDefault(); reset(); @@ -423,13 +429,7 @@ function AuthCard() { {verify === "invalid" && ( {t("welcome.auth.verifyInvalid")}{" "} - @@ -530,22 +530,16 @@ function AuthCard() { )} - {/* A verification email can be lost or never arrive, so offer a resend path from sign-in - too — not only from the expired-link banner (which needs a broken link to appear). */} - {mode === "signin" && ( - - )} - {mode === "signin" && ( <> + {/* A verification email can be lost or never arrive, so offer a resend path from + sign-in too — not only from the expired-link banner (needs a broken link to show). */} +
{t("welcome.auth.or")} diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index bc4d336..48edcae 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -78,9 +78,9 @@ "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.", - "cancelled": "Google account linking was cancelled." + "error": "Couldn't link the Google account. Please try again." }, + "googleCancelled": "Google sign-in was cancelled.", "password": { "title": "Password", "setHint": "A password is set. You can sign in with your email and password.", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index d8eeed2..7aad5df 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -78,9 +78,9 @@ "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.", - "cancelled": "A Google-fiók összekapcsolása megszakadt." + "error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra." }, + "googleCancelled": "A Google-belépés megszakadt.", "password": { "title": "Jelszó", "setHint": "Van beállított jelszó. Bejelentkezhetsz e-mail-címmel és jelszóval.",