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
2 changes: 2 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -30,6 +31,7 @@ export const App = () => {
<Route path="/requests" element={<RequestsPage />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/operational" element={<OperatingPage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
Expand Down
110 changes: 82 additions & 28 deletions src/components/features/auth/AuthProvider.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
signInWithPopup,
signInWithRedirect,
Expand All @@ -7,28 +7,61 @@ 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);
const [validationError, setValidationError] = useState(null);
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);
}
}
Expand All @@ -40,30 +73,29 @@ 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);
});

return unsubscribe;
}, []);

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 =
Expand All @@ -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' ||
Expand All @@ -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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
Expand Down
36 changes: 35 additions & 1 deletion src/components/layout/Header.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);

Expand Down Expand Up @@ -119,6 +120,27 @@ export const Header = () => {
EN
</button>
</div>
<button
type="button"
onClick={() => navigate('/change-password')}
className="hidden md:inline-flex items-center justify-center rounded-lg border border-gray-200 p-2 text-gray-600 hover:text-primary hover:border-primary transition-colors"
title={t('auth.changePassword.title')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"
/>
</svg>
</button>
<Button
onClick={logout}
variant="outline"
Expand Down Expand Up @@ -155,6 +177,18 @@ export const Header = () => {
))}
</nav>

<Link
to="/change-password"
className={`rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
location.pathname === '/change-password'
? 'bg-primary/10 text-primary'
: 'text-gray-700 hover:bg-gray-50 hover:text-primary'
}`}
onClick={() => setIsMobileMenuOpen(false)}
>
{t('auth.changePassword.title')}
</Link>

<div className="mt-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-1 rounded-lg border border-gray-200 p-1">
<button
Expand Down
38 changes: 38 additions & 0 deletions src/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,35 @@ const resources = {
},
auth: {
logout: 'Salir',
signIn: 'Ingresar',
signInWithGoogle: 'Ingresar con Google',
signingIn: 'Ingresando...',
failedToSignIn: 'No se pudo iniciar sesión. Intentá de nuevo.',
unauthorizedDomain: 'Este dominio no está autorizado para iniciar sesión.',
operationNotAllowed: 'El inicio de sesión con Google está deshabilitado.',
emailPassword: 'Email y contraseña',
email: 'Email',
emailPlaceholder: 'tu@email.com',
password: 'Contraseña',
passwordPlaceholder: 'Tu contraseña',
login: {
subtitle: 'Gestión de Portafolio',
disclaimer:
'Al iniciar sesión, aceptás acceder a tu información de forma segura. Solo inversores registrados pueden acceder a la plataforma.',
},
changePassword: {
title: 'Cambiar contraseña',
subtitle: 'Actualizá tu contraseña de acceso a la plataforma.',
current: 'Contraseña actual',
new: 'Nueva contraseña',
confirm: 'Confirmar nueva contraseña',
submit: 'Cambiar contraseña',
success: 'Tu contraseña fue actualizada correctamente.',
mismatch: 'Las contraseñas no coinciden.',
tooShort: 'La contraseña debe tener al menos 6 caracteres.',
googleInfo:
'Iniciaste sesión con Google. Si querés usar contraseña, pedile al administrador que te configure una.',
},
},
footer: {
rights: 'Todos los derechos reservados.',
Expand Down Expand Up @@ -281,16 +300,35 @@ const resources = {
},
auth: {
logout: 'Logout',
signIn: 'Sign in',
signInWithGoogle: 'Sign in with Google',
signingIn: 'Signing in...',
failedToSignIn: 'Failed to sign in. Please try again.',
unauthorizedDomain: 'This domain is not authorized for sign-in.',
operationNotAllowed: 'Google sign-in is disabled for this project.',
emailPassword: 'Email & password',
email: 'Email',
emailPlaceholder: 'your@email.com',
password: 'Password',
passwordPlaceholder: 'Your password',
login: {
subtitle: 'Portfolio Management',
disclaimer:
'By signing in, you agree to access your information securely. Only registered investors can access the platform.',
},
changePassword: {
title: 'Change password',
subtitle: 'Update your platform access password.',
current: 'Current password',
new: 'New password',
confirm: 'Confirm new password',
submit: 'Change password',
success: 'Your password has been updated successfully.',
mismatch: 'Passwords do not match.',
tooShort: 'Password must be at least 6 characters.',
googleInfo:
'You signed in with Google. If you want to use a password, ask the administrator to set one up for you.',
},
},
footer: {
rights: 'All rights reserved.',
Expand Down
Loading