diff --git a/components/AuthCard.tsx b/components/AuthCard.tsx index 16e4864..be0697b 100644 --- a/components/AuthCard.tsx +++ b/components/AuthCard.tsx @@ -1,15 +1,16 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; -import { signInWithGoogle, signInWithGitHub } from "../src/lib/firebase/auth"; +import { signInWithGoogle, signInWithGitHub, signInWithEmail, signUpWithEmail, resetPassword, logout } from "../src/lib/firebase/auth"; +import { useAuth } from "../src/lib/firebase/AuthContext"; import { User } from "firebase/auth"; import { Spinner } from "./ui/Spinner"; // Sync Firebase user with MongoDB - throws on failure -async function syncUserWithDB(user: User, provider: 'google' | 'github') { +async function syncUserWithDB(user: User, provider: 'google' | 'github' | 'email') { // Need to get fresh token after sign-in const token = await user.getIdToken(); @@ -40,9 +41,34 @@ interface AuthCardProps { export default function AuthCard({ mode = 'login' }: AuthCardProps) { const router = useRouter(); + const { user: currentUser, isLoading: authLoading } = useAuth(); const [isLoadingGoogle, setIsLoadingGoogle] = useState(false); const [isLoadingGitHub, setIsLoadingGitHub] = useState(false); + const [isLoadingEmail, setIsLoadingEmail] = useState(false); const [signInError, setSignInError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [userSynced, setUserSynced] = useState(false); + + // Track whether a sign-in flow is actively running (prevents premature redirect) + const signingInRef = useRef(false); + + // Email/password form state + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [showResetPassword, setShowResetPassword] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const [syncFailed, setSyncFailed] = useState(false); + + // If user is already logged in (not mid-sign-in, no sync failure) OR sync completed, redirect + useEffect(() => { + if (!authLoading && currentUser && !signingInRef.current && !syncFailed) { + router.push('/dashboard'); + } + if (userSynced && !syncFailed) { + router.push('/dashboard'); + } + }, [authLoading, currentUser, userSynced, syncFailed, router]); const handleProviderSignIn = useCallback(async ( signInFn: () => Promise, @@ -50,7 +76,9 @@ export default function AuthCard({ mode = 'login' }: AuthCardProps) { provider: 'google' | 'github' ) => { setSignInError(null); + setSuccessMessage(null); setLoading(true); + signingInRef.current = true; try { const user = await signInFn(); if (!user) { @@ -58,7 +86,7 @@ export default function AuthCard({ mode = 'login' }: AuthCardProps) { return; } await syncUserWithDB(user, provider); - router.push('/dashboard'); + setUserSynced(true); } catch (error) { setSignInError( error instanceof Error @@ -66,21 +94,113 @@ export default function AuthCard({ mode = 'login' }: AuthCardProps) { : `Failed to sign in with ${provider}. Please try again.` ); } finally { + signingInRef.current = false; setLoading(false); } - }, [router]); + }, []); + + const handleEmailSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + setSignInError(null); + setSuccessMessage(null); + setSyncFailed(false); + setIsLoadingEmail(true); + signingInRef.current = true; + + let createdUser: User | null = null; + + try { + if (mode === 'signup') { + if (!displayName.trim()) { + setSignInError('Please enter your name.'); + return; + } + createdUser = await signUpWithEmail(email, password, displayName.trim()); + } else { + createdUser = await signInWithEmail(email, password); + } + + if (!createdUser) { + setSignInError('Authentication failed. Please try again.'); + return; + } + + await syncUserWithDB(createdUser, 'email'); + setUserSynced(true); + } catch (error) { + // If signup created a Firebase user but DB sync failed, clean up + if (mode === 'signup' && createdUser) { + try { + await logout(); + } catch { + // Best-effort cleanup + } + } + setSyncFailed(true); + setSignInError( + error instanceof Error + ? error.message + : 'Authentication failed. Please try again.' + ); + } finally { + signingInRef.current = false; + setIsLoadingEmail(false); + } + }, [mode, email, password, displayName]); + + const handlePasswordReset = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + setSignInError(null); + setSuccessMessage(null); + + if (!email.trim()) { + setSignInError('Please enter your email address.'); + return; + } - const isLoading = isLoadingGoogle || isLoadingGitHub; + setIsResetting(true); + try { + await resetPassword(email); + setSuccessMessage('Password reset email sent! Check your inbox.'); + setShowResetPassword(false); + } catch (error) { + setSignInError( + error instanceof Error + ? error.message + : 'Failed to send reset email.' + ); + } finally { + setIsResetting(false); + } + }, [email]); + + const isLoading = isLoadingGoogle || isLoadingGitHub || isLoadingEmail || isResetting; + + // Show nothing while checking auth (prevents flash) + if (authLoading) { + return ( +
+ +
+ ); + } + + // If already logged in and synced (or was already logged in), don't render the card + if (currentUser && !syncFailed && !signingInRef.current) return null; return (

- {mode === 'signup' ? 'Create an Account' : 'Welcome Back'} + {showResetPassword + ? 'Reset Password' + : mode === 'signup' ? 'Create an Account' : 'Welcome Back'}

- {mode === 'signup' - ? 'Get started designing system architectures for free.' - : 'Sign in to start designing system architectures.'} + {showResetPassword + ? 'Enter your email to receive a reset link.' + : mode === 'signup' + ? 'Get started designing system architectures for free.' + : 'Sign in to start designing system architectures.'}

{/* Error message */} @@ -90,45 +210,172 @@ export default function AuthCard({ mode = 'login' }: AuthCardProps) {
)} - {/* Google */} - - - {/* GitHub */} - + {/* Success message */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {showResetPassword ? ( + /* Password Reset Form */ +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + disabled={isLoading} + className="w-full rounded-lg bg-[#1f1b33] border border-white/10 px-4 py-3 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition disabled:opacity-50" + /> +
+ + +
+ ) : ( + <> + {/* Email/Password Form */} +
+ {mode === 'signup' && ( +
+ + setDisplayName(e.target.value)} + placeholder="John Doe" + required + disabled={isLoading} + className="w-full rounded-lg bg-[#1f1b33] border border-white/10 px-4 py-3 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition disabled:opacity-50" + /> +
+ )} +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + disabled={isLoading} + className="w-full rounded-lg bg-[#1f1b33] border border-white/10 px-4 py-3 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition disabled:opacity-50" + /> +
+
+
+ + {mode === 'login' && ( + + )} +
+ setPassword(e.target.value)} + placeholder={mode === 'signup' ? 'At least 6 characters' : '••••••••'} + required + minLength={6} + disabled={isLoading} + className="w-full rounded-lg bg-[#1f1b33] border border-white/10 px-4 py-3 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition disabled:opacity-50" + /> +
+ +
+ + {/* Divider */} +
+
+ or continue with +
+
+ + {/* Google */} + + + {/* GitHub */} + + + )} {/* Footer text */}

diff --git a/components/Navbar.tsx b/components/Navbar.tsx index f1d2c04..e83cec2 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -1,12 +1,14 @@ "use client"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { useAuth } from "../src/lib/firebase/AuthContext"; import { logout } from "../src/lib/firebase/auth"; export default function Navbar() { const { user, isLoading } = useAuth(); - + const pathname = usePathname(); + const isAuthPage = pathname === '/login' || pathname === '/signup'; return (

@@ -48,7 +50,7 @@ export default function Navbar() {
- ) : !user ? ( + ) : isAuthPage ? null : !user ? ( <> Get Started diff --git a/src/lib/db/models/User.ts b/src/lib/db/models/User.ts index 40eb3af..f9b1a9b 100644 --- a/src/lib/db/models/User.ts +++ b/src/lib/db/models/User.ts @@ -5,7 +5,7 @@ export interface IUser extends Document { email: string; displayName: string; photoURL?: string; - provider: 'google' | 'github'; + provider: 'google' | 'github' | 'email'; createdAt: Date; updatedAt: Date; lastLoginAt: Date; @@ -42,7 +42,7 @@ const UserSchema = new Schema( }, provider: { type: String, - enum: ['google', 'github'], + enum: ['google', 'github', 'email'], required: true, }, lastLoginAt: { diff --git a/src/lib/firebase/auth.ts b/src/lib/firebase/auth.ts index da68fda..02c7df0 100644 --- a/src/lib/firebase/auth.ts +++ b/src/lib/firebase/auth.ts @@ -5,6 +5,9 @@ import { signOut, AuthError, updateProfile, + createUserWithEmailAndPassword, + signInWithEmailAndPassword, + sendPasswordResetEmail, } from "firebase/auth"; import { auth } from "./firebaseClient"; @@ -72,3 +75,70 @@ export const updateUserProfile = async (displayName?: string, photoURL?: string) throw new Error(authError.message || "Failed to update profile"); } }; + +export const signUpWithEmail = async (email: string, password: string, displayName: string) => { + if (!auth) throw new Error("Firebase Auth is not initialized."); + let createdUser = null; + try { + const res = await createUserWithEmailAndPassword(auth, email, password); + createdUser = res.user; + + try { + await updateProfile(createdUser, { displayName }); + } catch (profileError) { + // Rollback: delete the partially created account + await createdUser.delete(); + throw profileError; + } + + return createdUser; + } catch (error) { + const authError = error as AuthError; + console.error("Email sign-up error:", { + code: authError.code, + message: authError.message, + }); + if (authError.code === 'auth/email-already-in-use') { + throw new Error("An account with this email already exists."); + } + if (authError.code === 'auth/weak-password') { + throw new Error("Password must be at least 6 characters."); + } + throw new Error(authError.message || "Failed to create account"); + } +}; + +export const signInWithEmail = async (email: string, password: string) => { + if (!auth) throw new Error("Firebase Auth is not initialized."); + try { + const res = await signInWithEmailAndPassword(auth, email, password); + return res.user; + } catch (error) { + const authError = error as AuthError; + console.error("Email sign-in error:", { + code: authError.code, + message: authError.message, + }); + if (authError.code === 'auth/user-not-found' || authError.code === 'auth/wrong-password' || authError.code === 'auth/invalid-credential') { + throw new Error("Invalid email or password."); + } + throw new Error(authError.message || "Failed to sign in"); + } +}; + +export const resetPassword = async (email: string) => { + if (!auth) throw new Error("Firebase Auth is not initialized."); + try { + await sendPasswordResetEmail(auth, email); + } catch (error) { + const authError = error as AuthError; + console.error("Password reset error:", { + code: authError.code, + message: authError.message, + }); + if (authError.code === 'auth/user-not-found') { + throw new Error("No account found with this email."); + } + throw new Error(authError.message || "Failed to send reset email"); + } +};