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