import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; import { GoogleAuthProvider, createUserWithEmailAndPassword, onAuthStateChanged, signInWithEmailAndPassword, signInWithPopup, signInWithRedirect, signOut, type UserCredential, type User } from 'firebase/auth'; import { auth } from '../lib/firebase'; interface AuthContextType { user: User | null; loading: boolean; codeVerified: boolean; verificationLoading: boolean; hasProfile: boolean; profileLoading: boolean; loginWithEmail: (email: string, password: string) => Promise; registerWithEmail: (email: string, password: string) => Promise; checkUsernameAvailability: (username: string) => Promise; registerProfile: (params: { email: string; firebaseUid: string; username: string }) => Promise; refreshProfileStatus: () => Promise; loginWithGoogle: () => Promise; sendLoginCode: () => Promise; verifyLoginCode: (code: string) => Promise; logout: () => Promise; } const AuthContext = createContext(undefined); const provider = new GoogleAuthProvider(); provider.setCustomParameters({ prompt: 'select_account' }); const VERIFICATION_STORAGE_KEY = 'auth_code_session_v1'; const ABSOLUTE_MAX_SESSION_MS = 24 * 60 * 60 * 1000; const PASSWORD_PROVIDER_ID = 'password'; const GOOGLE_PROVIDER_ID = 'google.com'; const readStoredSession = (): { email: string; token: string; expiresAt?: number; createdAt?: number } | null => { const rawValue = localStorage.getItem(VERIFICATION_STORAGE_KEY); if (!rawValue) return null; try { const parsed = JSON.parse(rawValue) as { email?: string; token?: string; expiresAt?: number; createdAt?: number }; if (!parsed.email || !parsed.token) return null; return { email: parsed.email, token: parsed.token, expiresAt: parsed.expiresAt, createdAt: parsed.createdAt }; } catch { return null; } }; export const AuthProvider = ({ children }: { children: ReactNode }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [codeVerified, setCodeVerified] = useState(false); const [verificationLoading, setVerificationLoading] = useState(true); const [hasProfile, setHasProfile] = useState(false); const [profileLoading, setProfileLoading] = useState(true); const isGoogleUser = (nextUser: User | null) => nextUser?.providerData.some((entry) => entry.providerId === GOOGLE_PROVIDER_ID) ?? false; const usesPasswordAuth = (nextUser: User | null) => nextUser?.providerData.some((entry) => entry.providerId === PASSWORD_PROVIDER_ID) ?? false; const revokeServerSession = async (email: string, token: string) => { await fetch('/api/auth/logout-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, token }) }).catch(() => null); }; const hardLogout = async () => { const storedSession = readStoredSession(); const email = user?.email || storedSession?.email; if (email && storedSession?.token) { await revokeServerSession(email, storedSession.token); } await signOut(auth); localStorage.removeItem(VERIFICATION_STORAGE_KEY); setCodeVerified(false); setVerificationLoading(false); setHasProfile(false); setProfileLoading(false); }; useEffect(() => { const unsubscribe = onAuthStateChanged(auth, (nextUser) => { setUser(nextUser); setLoading(false); }); return unsubscribe; }, []); useEffect(() => { const validateStoredSession = async () => { if (!user?.email) { localStorage.removeItem(VERIFICATION_STORAGE_KEY); setCodeVerified(false); setVerificationLoading(false); return; } // Google auth skips the email code challenge. if (isGoogleUser(user) && !usesPasswordAuth(user)) { localStorage.removeItem(VERIFICATION_STORAGE_KEY); setCodeVerified(true); setVerificationLoading(false); return; } const storedSession = readStoredSession(); if (!storedSession || storedSession.email !== user.email.toLowerCase()) { setCodeVerified(false); setVerificationLoading(false); return; } const expiresAt = Number(storedSession.expiresAt || 0); const createdAt = Number(storedSession.createdAt || 0); const now = Date.now(); const absoluteExpiry = createdAt > 0 ? createdAt + ABSOLUTE_MAX_SESSION_MS : 0; const effectiveExpiry = expiresAt > 0 && absoluteExpiry > 0 ? Math.min(expiresAt, absoluteExpiry) : 0; if (effectiveExpiry > 0 && now >= effectiveExpiry) { await hardLogout(); return; } try { const response = await fetch('/api/auth/check-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: user.email, token: storedSession.token }) }); if (!response.ok) { localStorage.removeItem(VERIFICATION_STORAGE_KEY); await hardLogout(); return; } setCodeVerified(true); } catch { setCodeVerified(false); } finally { setVerificationLoading(false); } }; setVerificationLoading(true); void validateStoredSession(); }, [user]); useEffect(() => { if (!user?.email) return; const interval = window.setInterval(() => { const storedSession = readStoredSession(); if (!storedSession?.token || !storedSession.email || storedSession.email !== user.email?.toLowerCase()) { return; } const now = Date.now(); const expiresAt = Number(storedSession.expiresAt || 0); const createdAt = Number(storedSession.createdAt || 0); const absoluteExpiry = createdAt > 0 ? createdAt + ABSOLUTE_MAX_SESSION_MS : 0; const effectiveExpiry = expiresAt > 0 && absoluteExpiry > 0 ? Math.min(expiresAt, absoluteExpiry) : 0; if (effectiveExpiry > 0 && now >= effectiveExpiry) { void hardLogout(); } }, 30 * 1000); return () => window.clearInterval(interval); }, [user]); const refreshProfileStatus = async () => { if (isGoogleUser(user) && !usesPasswordAuth(user)) { // Google-only accounts bypass the email-code/profile lookup on auth DB. setHasProfile(true); setProfileLoading(false); return; } if (!user?.email || !codeVerified) { setHasProfile(false); setProfileLoading(false); return; } try { setProfileLoading(true); const response = await fetch(`/api/auth/profile-status?email=${encodeURIComponent(user.email)}`); const payload = (await response.json().catch(() => null)) as { hasProfile?: boolean } | null; if (!response.ok) { setHasProfile(false); return; } setHasProfile(payload?.hasProfile === true); } catch { setHasProfile(false); } finally { setProfileLoading(false); } }; useEffect(() => { void refreshProfileStatus(); }, [user, codeVerified]); const loginWithGoogle = async () => { try { await signInWithPopup(auth, provider); } catch (caught) { const errorCode = typeof caught === 'object' && caught !== null && 'code' in caught ? String((caught as { code?: string }).code) : ''; // Fallback for browsers that block popups. if (errorCode === 'auth/popup-blocked') { await signInWithRedirect(auth, provider); return; } throw caught; } }; const loginWithEmail = async (email: string, password: string) => { await signInWithEmailAndPassword(auth, email, password); }; const registerWithEmail = async (email: string, password: string) => { return createUserWithEmailAndPassword(auth, email, password); }; const checkUsernameAvailability = async (username: string) => { const response = await fetch(`/api/auth/username-available?username=${encodeURIComponent(username)}`); const payload = (await response.json().catch(() => null)) as { available?: boolean; message?: string } | null; if (!response.ok) { throw new Error(payload?.message || 'Unable to check username.'); } return payload?.available === true; }; const registerProfile = async (params: { email: string; firebaseUid: string; username: string }) => { const response = await fetch('/api/auth/register-profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params) }); if (!response.ok) { const payload = (await response.json().catch(() => null)) as { message?: string } | null; throw new Error(payload?.message || 'Unable to register profile.'); } setHasProfile(true); }; const sendLoginCode = async () => { if (!user?.email) { throw new Error('No authenticated user.'); } if (isGoogleUser(user) && !usesPasswordAuth(user)) { throw new Error('Email code is only required for email/password accounts.'); } const response = await fetch('/api/auth/send-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: user.email }) }); if (!response.ok) { const payload = (await response.json().catch(() => null)) as { message?: string } | null; throw new Error(payload?.message || 'Unable to send code.'); } }; const verifyLoginCode = async (code: string) => { if (!user?.email) { throw new Error('No authenticated user.'); } if (isGoogleUser(user) && !usesPasswordAuth(user)) { throw new Error('Email code is not required for Google accounts.'); } const response = await fetch('/api/auth/verify-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: user.email, code }) }); const payload = (await response.json().catch(() => null)) as { token?: string; message?: string; expiresAt?: number } | null; if (!response.ok || !payload?.token) { throw new Error(payload?.message || 'Unable to verify code.'); } localStorage.setItem( VERIFICATION_STORAGE_KEY, JSON.stringify({ email: user.email.toLowerCase(), token: payload.token, expiresAt: payload.expiresAt, createdAt: Date.now() }) ); setCodeVerified(true); }; const logout = async () => { await hardLogout(); }; const value = useMemo( () => ({ user, loading, codeVerified, verificationLoading, hasProfile, profileLoading, loginWithEmail, registerWithEmail, checkUsernameAvailability, registerProfile, refreshProfileStatus, loginWithGoogle, sendLoginCode, verifyLoginCode, logout }), [codeVerified, hasProfile, loading, profileLoading, user, verificationLoading] ); return {children}; }; export const useAuth = () => { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; };