From 4cecceb541bd1c3efdd2f4139b8489e8f58a25fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Garc=C3=ADa=20M=C3=A9ndez?= Date: Wed, 18 Mar 2026 21:29:27 -0500 Subject: [PATCH] Fix Google Sign-In broken by Service Worker intercepting /__/auth/handler The Workbox SW navigateFallback was catching Firebase reserved /__/ routes and serving index.html instead, preventing the OAuth handler from working. - Add navigateFallbackDenylist to exclude /__/ paths from SW interception - Use signInWithPopup in dev, signInWithRedirect in prod - Extract validateAndReject helper to reduce duplication - Default login tab to Google instead of email/password - Update tests to cover redirect result handling --- src/components/features/auth/AuthProvider.jsx | 100 ++++++++---------- .../features/auth/AuthProvider.test.jsx | 82 ++++---------- src/pages/LoginPage.jsx | 2 +- src/pages/LoginPage.test.jsx | 27 +++-- vite.config.js | 1 + 5 files changed, 87 insertions(+), 125 deletions(-) diff --git a/src/components/features/auth/AuthProvider.jsx b/src/components/features/auth/AuthProvider.jsx index a4e8b68..e3a9c1f 100644 --- a/src/components/features/auth/AuthProvider.jsx +++ b/src/components/features/auth/AuthProvider.jsx @@ -41,6 +41,22 @@ const clearStoredSession = () => { } }; +const validateAndReject = async (email) => { + const validation = await validateInvestor(email); + if (validation.valid) return null; + + let errorMessage = 'No estás autorizado para acceder a este portal.'; + if (validation.error === 'Investor not found in database') { + errorMessage = 'No estás registrado como inversor. Por favor contacta a winbit.cfds@gmail.com'; + } else if (validation.error === 'Investor account is not active') { + errorMessage = + 'Tu cuenta de inversor no está activa. Por favor contacta a winbit.cfds@gmail.com'; + } else if (validation.error) { + errorMessage = `Error de validación: ${validation.error}. Contacta a winbit.cfds@gmail.com`; + } + return errorMessage; +}; + export const AuthProvider = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); @@ -56,32 +72,20 @@ export const AuthProvider = ({ children }) => { setLoading(false); } - const handleRedirectResult = async () => { - try { - const result = await getRedirectResult(auth); + getRedirectResult(auth) + .then(async (result) => { if (result?.user) { - const validation = await validateInvestor(result.user.email); - if (!validation.valid) { - let errorMessage = 'No estás autorizado para acceder a este portal.'; - if (validation.error === 'Investor not found in database') { - errorMessage = - 'No estás registrado como inversor. Por favor contacta a winbit.cfds@gmail.com'; - } else if (validation.error === 'Investor account is not active') { - errorMessage = - 'Tu cuenta de inversor no está activa. Por favor contacta a winbit.cfds@gmail.com'; - } else if (validation.error) { - errorMessage = `Error de validación: ${validation.error}. Contacta a winbit.cfds@gmail.com`; - } + const errorMessage = await validateAndReject(result.user.email); + if (errorMessage) { setValidationError(errorMessage); + setIsValidated(true); await signOut(auth); + } else { + setIsValidated(true); } } - } catch (_error) { - // Intentionally swallow here; UI handles login errors on demand. - } - }; - - handleRedirectResult(); + }) + .catch(() => {}); const unsubscribe = onAuthStateChanged(auth, (currentUser) => { if (currentUser) { @@ -92,7 +96,6 @@ export const AuthProvider = ({ children }) => { } } else if (!getStoredSession()) { setUser((prev) => { - // Preserve email/password user even if localStorage was cleared (e.g. another tab) if (prev?.authMethod === 'email') return prev; return null; }); @@ -104,30 +107,17 @@ export const AuthProvider = ({ children }) => { return unsubscribe; }, []); + const isDev = import.meta.env.DEV; + const loginWithGoogle = async () => { setValidationError(null); setIsValidated(false); - // In development use popup (redirect sends back to firebaseapp.com, not localhost). - // In production use redirect: more reliable on Safari, mobile, and when - // third-party cookies are blocked. - const isDev = import.meta.env.DEV; - - try { - if (isDev) { + if (isDev) { + try { const result = await signInWithPopup(auth, googleProvider); - const validation = await validateInvestor(result.user.email); - if (!validation.valid) { - let errorMessage = 'No estás autorizado para acceder a este portal.'; - if (validation.error === 'Investor not found in database') { - errorMessage = - 'No estás registrado como inversor. Por favor contacta a winbit.cfds@gmail.com'; - } else if (validation.error === 'Investor account is not active') { - errorMessage = - 'Tu cuenta de inversor no está activa. Por favor contacta a winbit.cfds@gmail.com'; - } else if (validation.error) { - errorMessage = `Error de validación: ${validation.error}. Contacta a winbit.cfds@gmail.com`; - } + const errorMessage = await validateAndReject(result.user.email); + if (errorMessage) { setValidationError(errorMessage); setIsValidated(true); await signOut(auth); @@ -135,25 +125,25 @@ export const AuthProvider = ({ children }) => { } setIsValidated(true); return { user: result.user, error: null }; - } else { - await signInWithRedirect(auth, googleProvider); - return { user: null, error: null }; + } catch (error) { + return { + user: null, + error: { + code: error?.code ?? 'auth/unknown', + message: error?.message ?? 'Unknown authentication error', + }, + }; } + } + + try { + await signInWithRedirect(auth, googleProvider); + return { user: null, error: null }; } catch (error) { - const code = error?.code; - if ( - isDev && - (code === 'auth/popup-blocked' || - code === 'auth/popup-closed-by-user' || - code === 'auth/cancelled-popup-request') - ) { - await signInWithRedirect(auth, googleProvider); - return { user: null, error: null }; - } return { user: null, error: { - code: code ?? 'auth/unknown', + code: error?.code ?? 'auth/unknown', message: error?.message ?? 'Unknown authentication error', }, }; diff --git a/src/components/features/auth/AuthProvider.test.jsx b/src/components/features/auth/AuthProvider.test.jsx index 2e85b0b..2d131b3 100644 --- a/src/components/features/auth/AuthProvider.test.jsx +++ b/src/components/features/auth/AuthProvider.test.jsx @@ -4,13 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AuthProvider } from './AuthProvider'; import { AuthContext } from './AuthContext'; -import { - signInWithPopup, - signInWithRedirect, - signOut, - onAuthStateChanged, - getRedirectResult, -} from 'firebase/auth'; +import { signInWithPopup, getRedirectResult, signOut, onAuthStateChanged } from 'firebase/auth'; vi.mock('firebase/auth', () => ({ signInWithPopup: vi.fn(), @@ -30,6 +24,8 @@ vi.mock('../../../services/api', () => ({ loginWithEmailPassword: vi.fn(), })); +import { validateInvestor } from '../../../services/api'; + const ContextConsumer = () => { const { user, loading, loginWithGoogle, loginWithEmail, logout, validationError } = useContext(AuthContext); @@ -48,6 +44,8 @@ const ContextConsumer = () => { describe('AuthProvider', () => { beforeEach(() => { vi.clearAllMocks(); + globalThis.localStorage?.clear(); + getRedirectResult.mockResolvedValue(null); }); it('sets user from onAuthStateChanged and stops loading', async () => { @@ -173,7 +171,6 @@ describe('AuthProvider', () => { }); it('loginWithGoogle sets validationError when investor not found', async () => { - const { validateInvestor } = await import('../../../services/api'); validateInvestor.mockResolvedValueOnce({ valid: false, error: 'Investor not found in database', @@ -201,7 +198,6 @@ describe('AuthProvider', () => { }); it('loginWithGoogle sets validationError when account inactive', async () => { - const { validateInvestor } = await import('../../../services/api'); validateInvestor.mockResolvedValueOnce({ valid: false, error: 'Investor account is not active', @@ -246,36 +242,7 @@ describe('AuthProvider', () => { }); }); - it('handleRedirectResult sets validationError when redirect user is invalid', async () => { - getRedirectResult.mockResolvedValueOnce({ - user: { email: 'redirect@example.com' }, - }); - const { validateInvestor } = await import('../../../services/api'); - validateInvestor.mockResolvedValueOnce({ - valid: false, - error: 'Investor not found in database', - }); - signOut.mockResolvedValueOnce(); - onAuthStateChanged.mockImplementation((_auth, cb) => { - cb(null); - return () => {}; - }); - - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('validation-error')).toHaveTextContent( - /No estás registrado como inversor/, - ); - }); - }); - it('loginWithGoogle sets validationError for generic validation error', async () => { - const { validateInvestor } = await import('../../../services/api'); validateInvestor.mockResolvedValueOnce({ valid: false, error: 'Custom validation error', @@ -301,9 +268,8 @@ describe('AuthProvider', () => { }); }); - it('uses signInWithRedirect in production (DEV=false)', async () => { - const originalDev = import.meta.env.DEV; - import.meta.env.DEV = false; + it('loginWithGoogle returns error when popup throws generic error', async () => { + signInWithPopup.mockRejectedValueOnce(new Error('Network error')); onAuthStateChanged.mockImplementation((_auth, cb) => { cb(null); return () => {}; @@ -318,17 +284,15 @@ describe('AuthProvider', () => { fireEvent.click(screen.getByText('loginGoogle')); await waitFor(() => { - expect(signInWithRedirect).toHaveBeenCalledTimes(1); - expect(signInWithPopup).not.toHaveBeenCalled(); + expect(signInWithPopup).toHaveBeenCalledTimes(1); }); - - import.meta.env.DEV = originalDev; }); - it('loginWithGoogle returns error when popup throws generic error', async () => { - signInWithPopup.mockRejectedValueOnce(new Error('Network error')); + it('handles redirect result and validates investor on mount', async () => { + getRedirectResult.mockResolvedValueOnce({ user: { email: 'redirect@example.com' } }); + validateInvestor.mockResolvedValueOnce({ valid: true }); onAuthStateChanged.mockImplementation((_auth, cb) => { - cb(null); + cb({ email: 'redirect@example.com' }); return () => {}; }); @@ -338,20 +302,17 @@ describe('AuthProvider', () => { , ); - fireEvent.click(screen.getByText('loginGoogle')); - await waitFor(() => { - expect(signInWithPopup).toHaveBeenCalledTimes(1); - expect(signInWithRedirect).not.toHaveBeenCalled(); + expect(validateInvestor).toHaveBeenCalledWith('redirect@example.com'); }); }); - it('loginWithGoogle falls back to redirect when popup is blocked', async () => { - signInWithPopup.mockRejectedValueOnce({ - code: 'auth/popup-blocked', - message: 'Popup blocked', + it('handles redirect result with invalid investor', async () => { + getRedirectResult.mockResolvedValueOnce({ user: { email: 'bad@example.com' } }); + validateInvestor.mockResolvedValueOnce({ + valid: false, + error: 'Investor not found in database', }); - signInWithRedirect.mockResolvedValueOnce(); onAuthStateChanged.mockImplementation((_auth, cb) => { cb(null); return () => {}; @@ -363,10 +324,11 @@ describe('AuthProvider', () => { , ); - fireEvent.click(screen.getByText('loginGoogle')); - await waitFor(() => { - expect(signInWithRedirect).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('validation-error')).toHaveTextContent( + /No estás registrado como inversor/, + ); }); + expect(signOut).toHaveBeenCalled(); }); }); diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index 1d729d9..3d499ac 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -10,7 +10,7 @@ export const LoginPage = () => { useAuth(); const [error, setError] = useState(null); const [loggingIn, setLoggingIn] = useState(false); - const [authMode, setAuthMode] = useState('email'); + const [authMode, setAuthMode] = useState('google'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); diff --git a/src/pages/LoginPage.test.jsx b/src/pages/LoginPage.test.jsx index 7b756a9..1d53586 100644 --- a/src/pages/LoginPage.test.jsx +++ b/src/pages/LoginPage.test.jsx @@ -26,7 +26,12 @@ const renderAt = (path) => { ); }; -const switchToGoogleTab = () => { +const switchToEmailTab = () => { + const matches = screen.getAllByText('Email y contraseña'); + fireEvent.click(matches[0]); +}; + +const _switchToGoogleTab = () => { const matches = screen.getAllByText('Acceder con Google'); fireEvent.click(matches[0]); }; @@ -51,10 +56,19 @@ describe('LoginPage', () => { expect(screen.getByText('Dashboard')).toBeInTheDocument(); }); - it('shows email/password form by default', () => { + it('shows Google sign-in by default', () => { + useAuth.mockReturnValue(defaultMock); + + renderAt('/login'); + expect(screen.queryByLabelText('Correo electrónico')).not.toBeInTheDocument(); + expect(screen.getAllByText('Acceder con Google').length).toBeGreaterThan(0); + }); + + it('shows email/password form when switching to email tab', () => { useAuth.mockReturnValue(defaultMock); renderAt('/login'); + switchToEmailTab(); expect(screen.getByLabelText('Correo electrónico')).toBeInTheDocument(); expect(screen.getByLabelText(/Contraseña/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Ingresar' })).toBeInTheDocument(); @@ -65,6 +79,7 @@ describe('LoginPage', () => { useAuth.mockReturnValue({ ...defaultMock, loginWithEmail }); renderAt('/login'); + switchToEmailTab(); fireEvent.change(screen.getByLabelText('Correo electrónico'), { target: { value: 'test@example.com' }, }); @@ -84,6 +99,7 @@ describe('LoginPage', () => { useAuth.mockReturnValue({ ...defaultMock, loginWithEmail }); renderAt('/login'); + switchToEmailTab(); fireEvent.change(screen.getByLabelText('Correo electrónico'), { target: { value: 'test@example.com' }, }); @@ -98,7 +114,6 @@ describe('LoginPage', () => { useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle }); renderAt('/login'); - switchToGoogleTab(); clickGoogleLoginButton(); await waitFor(() => { @@ -114,7 +129,6 @@ describe('LoginPage', () => { useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle }); renderAt('/login'); - switchToGoogleTab(); clickGoogleLoginButton(); expect(await screen.findByText(/Este dominio no está autorizado/)).toBeInTheDocument(); @@ -129,7 +143,6 @@ describe('LoginPage', () => { useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle }); renderAt('/login'); - switchToGoogleTab(); clickGoogleLoginButton(); expect( @@ -145,7 +158,6 @@ describe('LoginPage', () => { useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle }); renderAt('/login'); - switchToGoogleTab(); clickGoogleLoginButton(); expect( @@ -161,7 +173,6 @@ describe('LoginPage', () => { useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle }); renderAt('/login'); - switchToGoogleTab(); clickGoogleLoginButton(); expect( @@ -177,7 +188,6 @@ describe('LoginPage', () => { useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle }); renderAt('/login'); - switchToGoogleTab(); clickGoogleLoginButton(); expect(await screen.findByText('No estás registrado como inversor')).toBeInTheDocument(); @@ -191,7 +201,6 @@ describe('LoginPage', () => { useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle }); renderAt('/login'); - switchToGoogleTab(); clickGoogleLoginButton(); expect( diff --git a/vite.config.js b/vite.config.js index aeea579..820f898 100644 --- a/vite.config.js +++ b/vite.config.js @@ -43,6 +43,7 @@ export default defineConfig({ clientsClaim: true, cleanupOutdatedCaches: true, navigateFallback: '/index.html', + navigateFallbackDenylist: [/^\/__\//], globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], runtimeCaching: [ {