Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 45 additions & 55 deletions src/components/features/auth/AuthProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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;
});
Expand All @@ -104,56 +107,43 @@ 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);
return { user: null, error: { code: 'auth/unauthorized', message: errorMessage } };
}
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',
},
};
Expand Down
82 changes: 22 additions & 60 deletions src/components/features/auth/AuthProvider.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
<AuthProvider>
<ContextConsumer />
</AuthProvider>,
);

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',
Expand All @@ -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 () => {};
Expand All @@ -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 () => {};
});

Expand All @@ -338,20 +302,17 @@ describe('AuthProvider', () => {
</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 () => {};
Expand All @@ -363,10 +324,11 @@ describe('AuthProvider', () => {
</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();
});
});
2 changes: 1 addition & 1 deletion src/pages/LoginPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading