+ {authMode === 'email' ? (
+
+ ) : (
+
+ )}
{displayError && (
diff --git a/src/pages/LoginPage.test.jsx b/src/pages/LoginPage.test.jsx
index 325df34..12912da 100644
--- a/src/pages/LoginPage.test.jsx
+++ b/src/pages/LoginPage.test.jsx
@@ -6,6 +6,15 @@ import { useAuth } from '../hooks/useAuth';
vi.mock('../hooks/useAuth');
+const defaultMock = {
+ user: null,
+ loading: false,
+ loginWithGoogle: vi.fn().mockResolvedValue({ user: {}, error: null }),
+ loginWithEmail: vi.fn().mockResolvedValue({ user: {}, error: null }),
+ clearValidationError: vi.fn(),
+ validationError: null,
+};
+
const renderAt = (path) => {
return render(
@@ -17,38 +26,69 @@ const renderAt = (path) => {
);
};
+const switchToGoogleTab = () => {
+ fireEvent.click(screen.getByText('Google'));
+};
+
describe('LoginPage', () => {
it('shows spinner while auth is loading', () => {
- useAuth.mockReturnValue({
- user: null,
- loading: true,
- loginWithGoogle: vi.fn(),
- });
+ useAuth.mockReturnValue({ ...defaultMock, loading: true });
renderAt('/login');
expect(screen.queryByText('Ingresar con Google')).not.toBeInTheDocument();
});
it('redirects to dashboard when already logged in', () => {
- useAuth.mockReturnValue({
- user: { email: 'test@example.com' },
- loading: false,
- loginWithGoogle: vi.fn(),
- });
+ useAuth.mockReturnValue({ ...defaultMock, user: { email: 'test@example.com' } });
renderAt('/login');
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
- it('calls loginWithGoogle when clicking button', async () => {
- const loginWithGoogle = vi.fn().mockResolvedValue({ user: {}, error: null });
- useAuth.mockReturnValue({
+ it('shows email/password form by default', () => {
+ useAuth.mockReturnValue(defaultMock);
+
+ renderAt('/login');
+ expect(screen.getByLabelText('Email')).toBeInTheDocument();
+ expect(screen.getByLabelText(/Contraseña/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Ingresar' })).toBeInTheDocument();
+ });
+
+ it('calls loginWithEmail on email form submit', async () => {
+ const loginWithEmail = vi.fn().mockResolvedValue({ user: {}, error: null });
+ useAuth.mockReturnValue({ ...defaultMock, loginWithEmail });
+
+ renderAt('/login');
+ fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'test@example.com' } });
+ fireEvent.change(screen.getByLabelText(/Contraseña/), { target: { value: 'secret123' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Ingresar' }));
+
+ await waitFor(() => {
+ expect(loginWithEmail).toHaveBeenCalledWith('test@example.com', 'secret123');
+ });
+ });
+
+ it('shows email login error', async () => {
+ const loginWithEmail = vi.fn().mockResolvedValue({
user: null,
- loading: false,
- loginWithGoogle,
+ error: { code: 'auth/invalid-credentials', message: 'Credenciales inválidas' },
});
+ useAuth.mockReturnValue({ ...defaultMock, loginWithEmail });
renderAt('/login');
+ fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'test@example.com' } });
+ fireEvent.change(screen.getByLabelText(/Contraseña/), { target: { value: 'wrong' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Ingresar' }));
+
+ expect(await screen.findByText('Credenciales inválidas')).toBeInTheDocument();
+ });
+
+ it('calls loginWithGoogle when clicking Google button', async () => {
+ const loginWithGoogle = vi.fn().mockResolvedValue({ user: {}, error: null });
+ useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle });
+
+ renderAt('/login');
+ switchToGoogleTab();
fireEvent.click(screen.getByText('Ingresar con Google'));
await waitFor(() => {
@@ -61,13 +101,10 @@ describe('LoginPage', () => {
user: null,
error: { code: 'auth/unauthorized-domain' },
});
- useAuth.mockReturnValue({
- user: null,
- loading: false,
- loginWithGoogle,
- });
+ useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle });
renderAt('/login');
+ switchToGoogleTab();
fireEvent.click(screen.getByText('Ingresar con Google'));
expect(await screen.findByText(/Este dominio no está autorizado/)).toBeInTheDocument();
@@ -79,16 +116,12 @@ describe('LoginPage', () => {
user: null,
error: { code: 'auth/popup-closed-by-user', message: 'Popup closed' },
});
- useAuth.mockReturnValue({
- user: null,
- loading: false,
- loginWithGoogle,
- });
+ useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle });
renderAt('/login');
+ switchToGoogleTab();
fireEvent.click(screen.getByText('Ingresar con Google'));
- // App displays generic Spanish message + error code (match the actual error code)
expect(
await screen.findByText(/No se pudo iniciar sesión.*auth\/popup-closed-by-user/),
).toBeInTheDocument();
@@ -99,16 +132,12 @@ describe('LoginPage', () => {
user: null,
error: { code: 'auth/cancelled-popup-request' },
});
- useAuth.mockReturnValue({
- user: null,
- loading: false,
- loginWithGoogle,
- });
+ useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle });
renderAt('/login');
+ switchToGoogleTab();
fireEvent.click(screen.getByText('Ingresar con Google'));
- // App displays generic Spanish message + error code
expect(
await screen.findByText(/No se pudo iniciar sesión.*auth\/cancelled-popup-request/),
).toBeInTheDocument();
@@ -119,35 +148,24 @@ describe('LoginPage', () => {
user: null,
error: { code: 'auth/unknown-error', message: 'Something went wrong' },
});
- useAuth.mockReturnValue({
- user: null,
- loading: false,
- loginWithGoogle,
- });
+ useAuth.mockReturnValue({ ...defaultMock, loginWithGoogle });
renderAt('/login');
+ switchToGoogleTab();
fireEvent.click(screen.getByText('Ingresar con Google'));
- // App displays Spanish message + error code
expect(
await screen.findByText(/No se pudo iniciar sesión.*auth\/unknown-error/),
).toBeInTheDocument();
});
it('shows unauthorized investor message', async () => {
- const loginWithGoogle = vi.fn().mockResolvedValue({
- user: null,
- error: { code: 'auth/unauthorized', message: 'Not an investor' },
- });
useAuth.mockReturnValue({
- user: null,
- loading: false,
- loginWithGoogle,
- validationError: 'Not an investor', // Should be a string, not an object
+ ...defaultMock,
+ validationError: 'Not an investor',
});
renderAt('/login');
-
expect(screen.getByText(/Not an investor/)).toBeInTheDocument();
});
});
diff --git a/src/services/api.js b/src/services/api.js
index 06a2711..fa161a0 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -220,6 +220,64 @@ export const createInvestorRequest = async (requestData) => {
}
};
+/**
+ * Login con email y contraseña
+ * @param {string} email
+ * @param {string} password
+ * @returns {Promise<{data: object | null, error: string | null}>}
+ */
+export const loginWithEmailPassword = async (email, password) => {
+ try {
+ const url = `${API_BASE_URL}/api/public/auth/login`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ return { data: null, error: errorData.error || `Error: ${response.status}` };
+ }
+
+ const result = await response.json();
+ return { data: result.investor, error: null };
+ } catch (error) {
+ return { data: null, error: error.message };
+ }
+};
+
+/**
+ * Cambiar contraseña del inversor
+ * @param {string} email
+ * @param {string} currentPassword
+ * @param {string} newPassword
+ * @returns {Promise<{success: boolean, error: string | null}>}
+ */
+export const changeInvestorPassword = async (email, currentPassword, newPassword) => {
+ try {
+ const url = `${API_BASE_URL}/api/public/auth/change_password`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email,
+ current_password: currentPassword,
+ new_password: newPassword,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ return { success: false, error: errorData.error || `Error: ${response.status}` };
+ }
+
+ return { success: true, error: null };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+};
+
/**
* Valida si un inversor existe y está activo
* @param {string} email - Email del inversor