From 4dc3f27ac417aed15807af72d2dffd9da2a8f8e0 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Tue, 18 Aug 2026 21:57:39 -0400 Subject: [PATCH] Fix the no-org redirect loop and swallowed Google sign-in errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redirect chain traced across middleware.ts, ChromeSwitcher.tsx and AuthProvider.tsx before touching anything, per the ask. Those three were already correct as of c7f985d/2a1d52b/83416fe (middleware exempts /onboarding and /signup from the drb_session cookie gate, ChromeSwitcher sends any signed-in no-org user to /onboarding, AuthProvider only sets the cookie once an org_id claim exists). The actual loop was one file upstream of all three: app/login/page.tsx hardcoded `router.push("/dashboard")` after both the email/password and Google handlers resolved. That push races AuthProvider's async onAuthStateChanged -> getIdTokenResult -> cookie decision. For a no-org account the cookie never gets set, so middleware bounces the very next request back to /login with no explanation — the ping-pong the coordinator saw live. Fix: login page no longer navigates from the handlers. It waits on AuthProvider's own `loading`/`orgId` and redirects once claims are settled (/dashboard with org_id, /onboarding without). This also fixes a second case: a user who lands on /login already signed in (e.g. bounced there by middleware while their Firebase session was still valid) now gets routed the same way instead of sitting inert on a login form with no feedback. /onboarding itself (org-name form, single action) was already adequate as the "explain the state" screen once the loop stopped recreating it. Also, live tonight: Google sign-in was failing outright in prod with no console/network trace. app/login/page.tsx's Google handler did `catch { setError("Google sign-in failed. Try again.") }` — no binding, error discarded. Added lib/authErrors.ts: logs the raw error, and maps Firebase codes to messages that distinguish two categories — the user's own situation (popup blocked/closed, bad password, network) says "try again"; deployment misconfiguration (auth/unauthorized-domain, auth/operation-not-allowed) says so explicitly and does not suggest retrying, since retrying can't fix a missing authorized-domain entry or a disabled provider. Applied to both handlers in login/page.tsx and both in signup/page.tsx (same swallowing pattern, same fix). Per the coordinator's steer: this is diagnosis only — no popup-to-redirect fallback, no auth method change. If production is hitting auth/unauthorized-domain, that's a Firebase Console fix (drb.cusano.net -> Authorized domains), not a code fix. Nav.tsx: sign-out was only reachable from /profile. Added a profile dropdown (desktop) and drawer entries (mobile) with Profile / Refresh access / Sign out, so sign-out is reachable from anywhere in the app. "Refresh access" calls AuthProvider.refreshClaims() (already existed, already used by /onboarding after signup) so a user whose role or org was just changed server-side can pick it up without a full logout. Decision on unknown Google accounts (point 4): kept self-serve org creation via /onboarding rather than a "request access" pending state. BUSINESS_MODEL.md #2.1 already answers this for the owner: "a limited free public tier *and* full paid access without contributing... cash is the primary revenue line from day one." A pending-approval gate would contradict that — it would make org creation itself the thing being gated, when the model explicitly does not want contribution (or approval) to be the only door. Self-serve org provisioning via POST /auth/signup was already built for this (2a1d52b) and needed no further gating decision, just for the loop in front of it to stop. Reversible: no schema change, no new gating, no billing/Stripe touched. Bench: rsync'd to the WSL-native ~/drb-frontend workspace and ran `npx tsc --noEmit` there (per CLAUDE.md — the H: drive install path is not viable) — exit 0, no errors. No Python touched this pass. --- drb-frontend/app/login/page.tsx | 58 ++++++++++++++----- drb-frontend/app/signup/page.tsx | 27 +++++---- drb-frontend/components/Nav.tsx | 95 +++++++++++++++++++++++++++----- drb-frontend/lib/authErrors.ts | 55 ++++++++++++++++++ 4 files changed, 194 insertions(+), 41 deletions(-) create mode 100644 drb-frontend/lib/authErrors.ts diff --git a/drb-frontend/app/login/page.tsx b/drb-frontend/app/login/page.tsx index f63eb32..abb5a10 100644 --- a/drb-frontend/app/login/page.tsx +++ b/drb-frontend/app/login/page.tsx @@ -1,48 +1,74 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth"; import { auth } from "@/lib/firebase"; import { c2api } from "@/lib/c2api"; import { useRouter } from "next/navigation"; +import { useAuth } from "@/components/AuthProvider"; +import { describeAuthError } from "@/lib/authErrors"; export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); + const [misconfigured, setMisconfigured] = useState(false); + const [submitting, setSubmitting] = useState(false); const router = useRouter(); + const { user, loading: authLoading, orgId } = useAuth(); + + // Do NOT navigate straight from the sign-in handlers below: signInWith* + // resolves before AuthProvider's onAuthStateChanged listener has fetched + // claims and set/cleared the drb_session cookie. Pushing to /dashboard + // immediately races that — for a no-org account the cookie never gets + // set, so middleware.ts bounces the very next request straight back to + // /login, which is the ping-pong this screen used to cause. Instead, + // react to AuthProvider's own settled state: this also covers a user who + // arrives here already signed in (e.g. redirected from a protected route + // by middleware while their Firebase session was still valid) — same + // destination logic, no separate code path, no bounce. + useEffect(() => { + if (authLoading) return; + if (!user) return; + router.replace(orgId ? "/dashboard" : "/onboarding"); + }, [authLoading, user, orgId, router]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - setLoading(true); + setSubmitting(true); setError(null); + setMisconfigured(false); try { await signInWithEmailAndPassword(auth, email, password); c2api.recordSession().catch(() => {}); - router.push("/dashboard"); - } catch { - setError("Invalid email or password."); - } finally { - setLoading(false); + // Redirect happens via the effect above once claims are settled. + } catch (err) { + const info = describeAuthError(err, "Invalid email or password."); + setError(info.message); + setMisconfigured(info.misconfiguration); + setSubmitting(false); } } async function handleGoogle() { - setLoading(true); + setSubmitting(true); setError(null); + setMisconfigured(false); try { await signInWithPopup(auth, new GoogleAuthProvider()); c2api.recordSession().catch(() => {}); - router.push("/dashboard"); - } catch { - setError("Google sign-in failed. Try again."); - } finally { - setLoading(false); + // Redirect happens via the effect above once claims are settled. + } catch (err) { + const info = describeAuthError(err, "Google sign-in failed. Try again."); + setError(info.message); + setMisconfigured(info.misconfiguration); + setSubmitting(false); } } + const loading = submitting || !!user; + return (
@@ -75,7 +101,9 @@ export default function LoginPage() { className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" />
- {error &&

{error}

} + {error && ( +

{error}

+ )} - {/* Profile avatar (desktop) */} - + {/* Profile avatar + dropdown (desktop) */} +
+ + + {profileMenuOpen && ( + <> + {/* Click-away backdrop */} +
setProfileMenuOpen(false)} /> +
+ setProfileMenuOpen(false)} + className="block px-3 py-2 text-gray-300 hover:bg-gray-800 hover:text-white transition-colors" + > + Profile + + +
+ +
+ + )} +
{/* Hamburger (mobile) */} +
)} diff --git a/drb-frontend/lib/authErrors.ts b/drb-frontend/lib/authErrors.ts new file mode 100644 index 0000000..294a3e4 --- /dev/null +++ b/drb-frontend/lib/authErrors.ts @@ -0,0 +1,55 @@ +/** + * Maps Firebase Auth error codes to user-facing messages instead of + * discarding them. Two categories: + * + * - misconfiguration: something is wrong with *our* deployment (a domain + * not on the authorized list, a sign-in provider not enabled in the + * Firebase console). Retrying can never fix these — the message says so + * instead of "try again", which would send a user into a retry loop + * against a config problem only we can fix. + * - everything else: the user's own situation (blocked/closed popup, bad + * password, a network blip) — retrying might well work. + * + * Always logs the raw error so it isn't silently discarded — the point of + * this file is to stop swallowing that information, not just relabel it. + */ + +export interface AuthErrorInfo { + message: string; + misconfiguration: boolean; +} + +const MISCONFIGURATION_MESSAGES: Record = { + "auth/unauthorized-domain": + "This domain isn't authorized for sign-in yet. That's a configuration issue on our end (Firebase Console → Authentication → Settings → Authorized domains) — retrying won't fix it. Please report this.", + "auth/operation-not-allowed": + "This sign-in method isn't enabled for this app yet. That's a configuration issue on our end (Firebase Console → Authentication → Sign-in method) — retrying won't fix it. Please report this.", +}; + +const USER_MESSAGES: Record = { + "auth/popup-blocked": "Your browser blocked the sign-in popup. Allow popups for this site and try again.", + "auth/popup-closed-by-user": "Sign-in window was closed before finishing. Try again.", + "auth/cancelled-popup-request": "Sign-in was interrupted by another sign-in attempt. Try again.", + "auth/network-request-failed": "Network error — check your connection and try again.", + "auth/invalid-credential": "Invalid email or password.", + "auth/wrong-password": "Invalid email or password.", + "auth/user-not-found": "Invalid email or password.", + "auth/too-many-requests": "Too many attempts. Wait a few minutes and try again.", + "auth/email-already-in-use": "An account with this email already exists. Try signing in instead.", + "auth/weak-password": "Password is too weak — use at least 6 characters.", +}; + +export function describeAuthError(err: unknown, fallback: string): AuthErrorInfo { + const code = (err as { code?: string } | null | undefined)?.code; + + // eslint-disable-next-line no-console + console.error("[auth]", code ?? "(no error code)", err); + + if (code && MISCONFIGURATION_MESSAGES[code]) { + return { message: MISCONFIGURATION_MESSAGES[code], misconfiguration: true }; + } + if (code && USER_MESSAGES[code]) { + return { message: USER_MESSAGES[code], misconfiguration: false }; + } + return { message: fallback, misconfiguration: false }; +}