diff --git a/src/App.jsx b/src/App.jsx index 63fa833..793caae 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,7 @@ import { WalletsPage } from './pages/WalletsPage'; import { RequestsPage } from './pages/RequestsPage'; import { HistoryPage } from './pages/HistoryPage'; import { OperatingPage } from './pages/OperatingPage'; +import { ChangePasswordPage } from './pages/ChangePasswordPage'; import { ErrorBoundary } from './components/ErrorBoundary'; export const App = () => { @@ -30,6 +31,7 @@ export const App = () => { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/features/auth/AuthProvider.jsx b/src/components/features/auth/AuthProvider.jsx index f34b6bc..6c479f9 100644 --- a/src/components/features/auth/AuthProvider.jsx +++ b/src/components/features/auth/AuthProvider.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { signInWithPopup, signInWithRedirect, @@ -7,10 +7,40 @@ import { onAuthStateChanged, } from 'firebase/auth'; import { auth, googleProvider } from '../../../services/firebase'; -import { validateInvestor } from '../../../services/api'; +import { validateInvestor, loginWithEmailPassword as apiLoginEmail } from '../../../services/api'; import { AuthContext } from './AuthContext'; +const SESSION_KEY = 'winbit_session'; + +const getStoredSession = () => { + try { + const raw = globalThis?.localStorage?.getItem(SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed?.email && parsed?.authMethod === 'email') return parsed; + return null; + } catch { + return null; + } +}; + +const storeSession = (data) => { + try { + globalThis?.localStorage?.setItem(SESSION_KEY, JSON.stringify(data)); + } catch { + // ignore storage errors + } +}; + +const clearStoredSession = () => { + try { + globalThis?.localStorage?.removeItem(SESSION_KEY); + } catch { + // ignore storage errors + } +}; + export const AuthProvider = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); @@ -18,17 +48,20 @@ export const AuthProvider = ({ children }) => { const [isValidated, setIsValidated] = useState(false); useEffect(() => { - // Handle redirect result and validate investor + const storedSession = getStoredSession(); + + if (storedSession) { + setUser({ email: storedSession.email, displayName: storedSession.name, authMethod: 'email' }); + setIsValidated(true); + setLoading(false); + } + const handleRedirectResult = async () => { try { const result = await getRedirectResult(auth); - - // Si hay un resultado de redirect (usuario acaba de loguearse) if (result?.user) { const validation = await validateInvestor(result.user.email); - if (!validation.valid) { - // Hacer logout si el inversor no es válido await signOut(auth); } } @@ -40,11 +73,13 @@ export const AuthProvider = ({ children }) => { handleRedirectResult(); const unsubscribe = onAuthStateChanged(auth, (currentUser) => { - setUser(currentUser); + if (currentUser) { + setUser(currentUser); + clearStoredSession(); + } else if (!getStoredSession()) { + setUser(null); + } setLoading(false); - // Si hay usuario, significa que Firebase mantiene la sesión (refresh de página) - // Lo marcamos como validado porque ya pasó la validación cuando se logueó - // Si no hay usuario, también está validado (estado no logueado) setIsValidated(true); }); @@ -52,18 +87,15 @@ export const AuthProvider = ({ children }) => { }, []); const loginWithGoogle = async () => { - // Limpiar error anterior y marcar como no validado mientras valida setValidationError(null); setIsValidated(false); try { const result = await signInWithPopup(auth, googleProvider); - // Validar que el inversor existe en la base de datos const validation = await validateInvestor(result.user.email); if (!validation.valid) { - // Preparar mensaje de error let errorMessage = 'No estás autorizado para acceder a este portal.'; if (validation.error === 'Investor not found in database') { errorMessage = @@ -75,32 +107,22 @@ export const AuthProvider = ({ children }) => { errorMessage = `Error de validación: ${validation.error}. Contacta a winbit.cfds@gmail.com`; } - // Guardar el error antes de hacer logout setValidationError(errorMessage); - - // Marcar como validado (aunque falló) para que no se ejecuten los hooks setIsValidated(true); - - // Hacer logout inmediatamente si el inversor no es válido await signOut(auth); return { user: null, - error: { - code: 'auth/unauthorized', - message: errorMessage, - }, + error: { code: 'auth/unauthorized', message: errorMessage }, }; } - // Login exitoso - limpiar cualquier error y marcar como validado setValidationError(null); setIsValidated(true); return { user: result.user, error: null }; } catch (error) { const code = error?.code; - // Common in production/PWA/in-app browsers: popups blocked → redirect flow works. if ( code === 'auth/popup-blocked' || code === 'auth/popup-closed-by-user' || @@ -120,23 +142,55 @@ export const AuthProvider = ({ children }) => { } }; - const logout = async () => { + const loginWithEmail = async (email, password) => { + setValidationError(null); + setIsValidated(false); + + const result = await apiLoginEmail(email, password); + + if (result.error) { + setValidationError(result.error); + setIsValidated(true); + return { + user: null, + error: { code: 'auth/invalid-credentials', message: result.error }, + }; + } + + const investorUser = { + email: result.data.email, + displayName: result.data.name, + authMethod: 'email', + }; + + storeSession({ email: result.data.email, name: result.data.name, authMethod: 'email' }); + setUser(investorUser); + setValidationError(null); + setIsValidated(true); + + return { user: investorUser, error: null }; + }; + + const logout = useCallback(async () => { try { + clearStoredSession(); + setUser(null); await signOut(auth); return { error: null }; } catch (error) { return { error: error.message }; } - }; + }, []); const value = { user, loading, loginWithGoogle, + loginWithEmail, logout, validationError, clearValidationError: () => setValidationError(null), - isValidated, // Indica si el usuario ha sido validado contra el backend + isValidated, }; return {children}; diff --git a/src/components/layout/Header.jsx b/src/components/layout/Header.jsx index 9a9f0ce..d51895f 100644 --- a/src/components/layout/Header.jsx +++ b/src/components/layout/Header.jsx @@ -1,4 +1,4 @@ -import { Link, useLocation } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useEffect, useState } from 'react'; import { useAuth } from '../../hooks/useAuth'; import { Button } from '../ui/Button'; @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'; export const Header = () => { const { user, logout } = useAuth(); const location = useLocation(); + const navigate = useNavigate(); const { t, i18n } = useTranslation(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -119,6 +120,27 @@ export const Header = () => { EN + + + + + ); +}; diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index af73d07..dc9e205 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -6,17 +6,19 @@ import { Spinner } from '../components/ui/Spinner'; import { useTranslation } from 'react-i18next'; export const LoginPage = () => { - const { user, loading, loginWithGoogle, validationError, clearValidationError } = useAuth(); + const { user, loading, loginWithGoogle, loginWithEmail, validationError, clearValidationError } = + useAuth(); const [error, setError] = useState(null); const [loggingIn, setLoggingIn] = useState(false); + const [authMode, setAuthMode] = useState('email'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); const { t } = useTranslation(); useEffect(() => { - // Limpiar errores al montar el componente setError(null); }, []); - // Mostrar el error de validación si existe const displayError = validationError || error; if (loading) { @@ -31,26 +33,22 @@ export const LoginPage = () => { return ; } - const handleLogin = async () => { + const handleGoogleLogin = async () => { setLoggingIn(true); setError(null); - if (clearValidationError) { - clearValidationError(); - } + if (clearValidationError) clearValidationError(); const result = await loginWithGoogle(); if (result.error) { const code = result.error?.code; - // Si es un error de inversor no autorizado, mostrar el mensaje personalizado if (code === 'auth/unauthorized') { setError(result.error.message); setLoggingIn(false); return; } - // User-friendly defaults + keep the real code visible for debugging Firebase deploy issues. let message = t('auth.failedToSignIn'); if (code === 'auth/unauthorized-domain') { message = t('auth.unauthorizedDomain'); @@ -63,6 +61,20 @@ export const LoginPage = () => { } }; + const handleEmailLogin = async (e) => { + e.preventDefault(); + setLoggingIn(true); + setError(null); + if (clearValidationError) clearValidationError(); + + const result = await loginWithEmail(email, password); + + if (result.error) { + setError(result.error.message); + } + setLoggingIn(false); + }; + return (
@@ -71,41 +83,120 @@ export const LoginPage = () => {

{t('auth.login.subtitle')}

-
- + {t('auth.emailPassword')} + + +
+ +
+ {authMode === 'email' ? ( +
+
+ + setEmail(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-primary focus:ring-1 focus:ring-primary outline-none" + placeholder={t('auth.emailPlaceholder')} + autoComplete="email" + /> +
+
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-primary focus:ring-1 focus:ring-primary outline-none" + placeholder={t('auth.passwordPlaceholder')} + minLength={6} + autoComplete="current-password" + /> +
+ +
+ ) : ( + + )} {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