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" && (