Feat: Seguridad avanzada para cambio de email y gestión de MFA
- Backend: Implementada lógica de tokens para cambio de mail y desactivación de 2FA. - Frontend: Nuevos flujos de verificación en Perfil y Panel de Seguridad.
This commit is contained in:
@@ -215,7 +215,7 @@ public class AuthController : ControllerBase
|
|||||||
// Permite a un usuario logueado iniciar el proceso de MFA voluntariamente
|
// Permite a un usuario logueado iniciar el proceso de MFA voluntariamente
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpPost("init-mfa")]
|
[HttpPost("init-mfa")]
|
||||||
public async Task<IActionResult> InitMFA()
|
public async Task<IActionResult> InitMFA([FromBody] ConfirmSecurityActionRequest request)
|
||||||
{
|
{
|
||||||
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
||||||
@@ -223,13 +223,22 @@ public class AuthController : ControllerBase
|
|||||||
var user = await _context.Users.FindAsync(int.Parse(userIdStr));
|
var user = await _context.Users.FindAsync(int.Parse(userIdStr));
|
||||||
if (user == null) return NotFound();
|
if (user == null) return NotFound();
|
||||||
|
|
||||||
// Generar secreto si no tiene
|
// Si el MFA ya está activo, exigimos el token de seguridad
|
||||||
if (string.IsNullOrEmpty(user.MFASecret))
|
if (user.IsMFAEnabled)
|
||||||
{
|
{
|
||||||
user.MFASecret = _tokenService.GenerateBase32Secret();
|
if (user.SecurityActionToken != request.Token || user.SecurityActionTokenExpiresAt < DateTime.UtcNow)
|
||||||
await _context.SaveChangesAsync();
|
{
|
||||||
|
return BadRequest(new { message = "El código de seguridad es inválido o ha expirado." });
|
||||||
|
}
|
||||||
|
// Limpiamos el token una vez usado
|
||||||
|
user.SecurityActionToken = null;
|
||||||
|
user.SecurityActionTokenExpiresAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generar secreto si no tiene (o si se está reconfigurando)
|
||||||
|
user.MFASecret = _tokenService.GenerateBase32Secret();
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
// 📝 AUDITORÍA
|
// 📝 AUDITORÍA
|
||||||
_context.AuditLogs.Add(new AuditLog
|
_context.AuditLogs.Add(new AuditLog
|
||||||
{
|
{
|
||||||
@@ -243,11 +252,7 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
var qrUri = _tokenService.GetQrCodeUri(user.Email, user.MFASecret);
|
var qrUri = _tokenService.GetQrCodeUri(user.Email, user.MFASecret);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new { qrUri, secret = user.MFASecret });
|
||||||
{
|
|
||||||
qrUri = qrUri,
|
|
||||||
secret = user.MFASecret
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("verify-mfa")]
|
[HttpPost("verify-mfa")]
|
||||||
@@ -312,33 +317,58 @@ public class AuthController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAMBIO DE EMAIL
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpPost("disable-mfa")]
|
[HttpPost("initiate-email-change")]
|
||||||
public async Task<IActionResult> DisableMFA()
|
public async Task<IActionResult> InitiateEmailChange([FromBody] InitiateEmailChangeRequest request)
|
||||||
{
|
{
|
||||||
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||||
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
var (success, message) = await _identityService.InitiateEmailChangeAsync(userId, request.NewEmail, request.MfaCode);
|
||||||
|
|
||||||
var user = await _context.Users.FindAsync(int.Parse(userIdStr));
|
if (!success) return BadRequest(new { message });
|
||||||
if (user == null) return NotFound();
|
return Ok(new { message });
|
||||||
|
}
|
||||||
|
|
||||||
// Desactivamos MFA y limpiamos el secreto por seguridad
|
[HttpPost("confirm-email-change")]
|
||||||
user.IsMFAEnabled = false;
|
public async Task<IActionResult> ConfirmEmailChange([FromBody] ConfirmEmailChangeRequest request)
|
||||||
user.MFASecret = null;
|
{
|
||||||
|
var (success, message) = await _identityService.ConfirmEmailChangeAsync(request.Token);
|
||||||
|
if (!success) return BadRequest(new { message });
|
||||||
|
return Ok(new { message });
|
||||||
|
}
|
||||||
|
|
||||||
// 📝 AUDITORÍA
|
// DESACTIVAR MFA
|
||||||
_context.AuditLogs.Add(new AuditLog
|
[Authorize]
|
||||||
{
|
[HttpPost("initiate-mfa-disable")]
|
||||||
Action = "MFA_DISABLED",
|
public async Task<IActionResult> InitiateMFADisable()
|
||||||
Entity = "User",
|
{
|
||||||
EntityID = user.UserID,
|
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||||
UserID = user.UserID,
|
var (success, message) = await _identityService.InitiateMFADisableAsync(userId);
|
||||||
Details = "Autenticación de dos factores desactivada por el usuario."
|
|
||||||
});
|
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
if (!success) return BadRequest(new { message });
|
||||||
|
return Ok(new { message });
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new { message = "MFA Desactivado correctamente." });
|
[Authorize]
|
||||||
|
[HttpPost("confirm-mfa-disable")]
|
||||||
|
public async Task<IActionResult> ConfirmMFADisable([FromBody] ConfirmSecurityActionRequest request)
|
||||||
|
{
|
||||||
|
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||||
|
var (success, message) = await _identityService.ConfirmMFADisableAsync(userId, request.Token);
|
||||||
|
|
||||||
|
if (!success) return BadRequest(new { message });
|
||||||
|
return Ok(new { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("initiate-mfa-reconfigure")]
|
||||||
|
public async Task<IActionResult> InitiateMFAReconfigure()
|
||||||
|
{
|
||||||
|
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
|
||||||
|
var (success, message) = await _identityService.InitiateMFAReconfigureAsync(userId);
|
||||||
|
|
||||||
|
if (!success) return BadRequest(new { message });
|
||||||
|
return Ok(new { message });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("migrate-password")]
|
[HttpPost("migrate-password")]
|
||||||
|
|||||||
17
Backend/MotoresArgentinosV2.Core/DTOs/SecurityDtos.cs
Normal file
17
Backend/MotoresArgentinosV2.Core/DTOs/SecurityDtos.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace MotoresArgentinosV2.Core.DTOs;
|
||||||
|
|
||||||
|
public class InitiateEmailChangeRequest
|
||||||
|
{
|
||||||
|
public string NewEmail { get; set; } = string.Empty;
|
||||||
|
public string MfaCode { get; set; } = string.Empty; // Código de Google Authenticator
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfirmEmailChangeRequest
|
||||||
|
{
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfirmSecurityActionRequest
|
||||||
|
{
|
||||||
|
public string Token { get; set; } = string.Empty; // Código numérico enviado por mail
|
||||||
|
}
|
||||||
@@ -61,6 +61,15 @@ public class User
|
|||||||
public DateTime? PasswordResetTokenExpiresAt { get; set; }
|
public DateTime? PasswordResetTokenExpiresAt { get; set; }
|
||||||
public DateTime? LastPasswordResetEmailSentAt { get; set; }
|
public DateTime? LastPasswordResetEmailSentAt { get; set; }
|
||||||
|
|
||||||
|
// Para cambio de email
|
||||||
|
public string? NewEmailCandidate { get; set; }
|
||||||
|
public string? EmailChangeToken { get; set; }
|
||||||
|
public DateTime? EmailChangeTokenExpiresAt { get; set; }
|
||||||
|
|
||||||
|
// Para reset/desactivación de MFA
|
||||||
|
public string? SecurityActionToken { get; set; }
|
||||||
|
public DateTime? SecurityActionTokenExpiresAt { get; set; }
|
||||||
|
|
||||||
// Bloqueo de usuario
|
// Bloqueo de usuario
|
||||||
public bool IsBlocked { get; set; }
|
public bool IsBlocked { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -16,5 +16,13 @@ public interface IIdentityService
|
|||||||
Task<(bool Success, string Message)> ForgotPasswordAsync(string email);
|
Task<(bool Success, string Message)> ForgotPasswordAsync(string email);
|
||||||
Task<(bool Success, string Message)> ResetPasswordAsync(string token, string newPassword);
|
Task<(bool Success, string Message)> ResetPasswordAsync(string token, string newPassword);
|
||||||
Task<(bool Success, string Message)> ChangePasswordAsync(int userId, string current, string newPwd);
|
Task<(bool Success, string Message)> ChangePasswordAsync(int userId, string current, string newPwd);
|
||||||
|
// Cambio de Email Seguro
|
||||||
|
Task<(bool Success, string Message)> InitiateEmailChangeAsync(int userId, string newEmail, string mfaCode);
|
||||||
|
Task<(bool Success, string Message)> ConfirmEmailChangeAsync(string token);
|
||||||
|
|
||||||
|
// Gestión MFA Segura
|
||||||
|
Task<(bool Success, string Message)> InitiateMFADisableAsync(int userId);
|
||||||
|
Task<(bool Success, string Message)> ConfirmMFADisableAsync(int userId, string token);
|
||||||
|
Task<(bool Success, string Message)> InitiateMFAReconfigureAsync(int userId);
|
||||||
Task<User> CreateGhostUserAsync(string email, string firstName, string lastName, string phone);
|
Task<User> CreateGhostUserAsync(string email, string firstName, string lastName, string phone);
|
||||||
}
|
}
|
||||||
@@ -15,16 +15,19 @@ public class IdentityService : IIdentityService
|
|||||||
private readonly IPasswordService _passwordService;
|
private readonly IPasswordService _passwordService;
|
||||||
private readonly IEmailService _emailService;
|
private readonly IEmailService _emailService;
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
|
private readonly ITokenService _tokenService;
|
||||||
|
|
||||||
public IdentityService(
|
public IdentityService(
|
||||||
MotoresV2DbContext v2Context,
|
MotoresV2DbContext v2Context,
|
||||||
IPasswordService passwordService,
|
IPasswordService passwordService,
|
||||||
IEmailService emailService,
|
IEmailService emailService,
|
||||||
|
ITokenService tokenService,
|
||||||
IConfiguration config)
|
IConfiguration config)
|
||||||
{
|
{
|
||||||
_v2Context = v2Context;
|
_v2Context = v2Context;
|
||||||
_passwordService = passwordService;
|
_passwordService = passwordService;
|
||||||
_emailService = emailService;
|
_emailService = emailService;
|
||||||
|
_tokenService = tokenService;
|
||||||
_config = config;
|
_config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,4 +336,187 @@ public class IdentityService : IIdentityService
|
|||||||
await _v2Context.SaveChangesAsync();
|
await _v2Context.SaveChangesAsync();
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. INICIAR CAMBIO DE EMAIL (Requiere MFA activo y código válido)
|
||||||
|
public async Task<(bool Success, string Message)> InitiateEmailChangeAsync(int userId, string newEmail, string mfaCode)
|
||||||
|
{
|
||||||
|
var user = await _v2Context.Users.FindAsync(userId);
|
||||||
|
if (user == null) return (false, "Usuario no encontrado.");
|
||||||
|
|
||||||
|
// Validar formato básico de email
|
||||||
|
if (!new System.ComponentModel.DataAnnotations.EmailAddressAttribute().IsValid(newEmail))
|
||||||
|
return (false, "El formato del correo electrónico no es válido.");
|
||||||
|
|
||||||
|
// REGLA DE ORO: Solo si tiene MFA activo
|
||||||
|
if (!user.IsMFAEnabled || string.IsNullOrEmpty(user.MFASecret))
|
||||||
|
return (false, "Por seguridad, debes tener la Autenticación de Dos Factores (2FA) activada para cambiar tu email.");
|
||||||
|
|
||||||
|
// Validar código TOTP (Google Authenticator)
|
||||||
|
bool isCodeValid = _tokenService.ValidateTOTP(user.MFASecret, mfaCode);
|
||||||
|
if (!isCodeValid)
|
||||||
|
return (false, "El código de autenticación (2FA) es incorrecto.");
|
||||||
|
|
||||||
|
// Verificar que el mail no esté usado por otro
|
||||||
|
bool emailExists = await _v2Context.Users.AnyAsync(u => u.Email == newEmail);
|
||||||
|
if (emailExists)
|
||||||
|
return (false, "El nuevo correo electrónico ya está registrado en otra cuenta.");
|
||||||
|
|
||||||
|
// Generar Token
|
||||||
|
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
user.NewEmailCandidate = newEmail;
|
||||||
|
user.EmailChangeToken = token;
|
||||||
|
user.EmailChangeTokenExpiresAt = DateTime.UtcNow.AddMinutes(30);
|
||||||
|
|
||||||
|
await _v2Context.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Enviar Email al NUEVO correo
|
||||||
|
var frontendUrl = _config["AppSettings:FrontendUrl"]?.Split(',')[0] ?? "http://localhost:5173";
|
||||||
|
var link = $"{frontendUrl}/confirmar-cambio-email?token={token}";
|
||||||
|
|
||||||
|
var body = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
|
||||||
|
<h2 style='color: #0051ff;'>Confirma tu nuevo correo</h2>
|
||||||
|
<p>Hemos recibido una solicitud para transferir tu cuenta de Motores Argentinos a esta dirección.</p>
|
||||||
|
<p>Si fuiste tú, haz clic en el siguiente enlace para confirmar el cambio:</p>
|
||||||
|
<div style='margin: 25px 0;'>
|
||||||
|
<a href='{link}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>CONFIRMAR EMAIL</a>
|
||||||
|
</div>
|
||||||
|
<p style='font-size: 12px; color: #666;'>El enlace expira en 30 minutos.</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _emailService.SendEmailAsync(newEmail, "Confirmar cambio de correo", body);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return (false, "No se pudo enviar el correo de confirmación. Verifica que la dirección sea correcta.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, "Se ha enviado un enlace de confirmación a tu NUEVO correo electrónico. Revísalo para finalizar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. CONFIRMAR CAMBIO (Click en el link)
|
||||||
|
public async Task<(bool Success, string Message)> ConfirmEmailChangeAsync(string token)
|
||||||
|
{
|
||||||
|
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.EmailChangeToken == token);
|
||||||
|
|
||||||
|
if (user == null) return (false, "El enlace es inválido o ya fue utilizado.");
|
||||||
|
|
||||||
|
if (user.EmailChangeTokenExpiresAt < DateTime.UtcNow) return (false, "El enlace ha expirado. Debes iniciar el trámite nuevamente.");
|
||||||
|
|
||||||
|
string oldEmail = user.Email;
|
||||||
|
|
||||||
|
// Aplicar cambio
|
||||||
|
user.Email = user.NewEmailCandidate!;
|
||||||
|
user.UserName = user.NewEmailCandidate!; // Opcional: Si usas el email como username
|
||||||
|
|
||||||
|
// Limpiar
|
||||||
|
user.NewEmailCandidate = null;
|
||||||
|
user.EmailChangeToken = null;
|
||||||
|
user.EmailChangeTokenExpiresAt = null;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
_v2Context.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
Action = "SECURITY_EMAIL_CHANGED",
|
||||||
|
Entity = "User",
|
||||||
|
EntityID = user.UserID,
|
||||||
|
UserID = user.UserID,
|
||||||
|
Details = $"Email principal cambiado de {oldEmail} a {user.Email}"
|
||||||
|
});
|
||||||
|
|
||||||
|
await _v2Context.SaveChangesAsync();
|
||||||
|
return (true, "Tu correo electrónico ha sido actualizado correctamente. Por favor inicia sesión con tu nuevo email.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. INICIAR DESACTIVACIÓN MFA (Envía código al mail actual)
|
||||||
|
public async Task<(bool Success, string Message)> InitiateMFADisableAsync(int userId)
|
||||||
|
{
|
||||||
|
var user = await _v2Context.Users.FindAsync(userId);
|
||||||
|
if (user == null) return (false, "Usuario no encontrado.");
|
||||||
|
|
||||||
|
if (!user.IsMFAEnabled) return (false, "La autenticación de dos factores no está activa.");
|
||||||
|
|
||||||
|
// Generar Token Numérico simple (6 dígitos)
|
||||||
|
var token = new Random().Next(100000, 999999).ToString();
|
||||||
|
|
||||||
|
user.SecurityActionToken = token;
|
||||||
|
user.SecurityActionTokenExpiresAt = DateTime.UtcNow.AddMinutes(10);
|
||||||
|
|
||||||
|
await _v2Context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var body = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
|
||||||
|
<h2 style='color: #ef4444;'>Alerta de Seguridad</h2>
|
||||||
|
<p>Has solicitado <strong>DESACTIVAR</strong> la seguridad de dos factores (2FA) en tu cuenta.</p>
|
||||||
|
<p>Tu código de seguridad para confirmar esta acción es:</p>
|
||||||
|
<h1 style='letter-spacing: 5px; background: #f3f4f6; padding: 10px; display: inline-block; border-radius: 8px;'>{token}</h1>
|
||||||
|
<p>Si no fuiste tú, cambia tu contraseña inmediatamente.</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
await _emailService.SendEmailAsync(user.Email, "Código de Seguridad: Desactivar MFA", body);
|
||||||
|
|
||||||
|
return (true, "Por seguridad, hemos enviado un código de confirmación a tu correo electrónico.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. CONFIRMAR DESACTIVACIÓN
|
||||||
|
public async Task<(bool Success, string Message)> ConfirmMFADisableAsync(int userId, string token)
|
||||||
|
{
|
||||||
|
var user = await _v2Context.Users.FindAsync(userId);
|
||||||
|
if (user == null) return (false, "Usuario no encontrado.");
|
||||||
|
|
||||||
|
// Validar token
|
||||||
|
if (user.SecurityActionToken != token) return (false, "El código ingresado es incorrecto.");
|
||||||
|
if (user.SecurityActionTokenExpiresAt < DateTime.UtcNow) return (false, "El código ha expirado. Solicita uno nuevo.");
|
||||||
|
|
||||||
|
// Desactivar
|
||||||
|
user.IsMFAEnabled = false;
|
||||||
|
user.MFASecret = null;
|
||||||
|
user.SecurityActionToken = null;
|
||||||
|
user.SecurityActionTokenExpiresAt = null;
|
||||||
|
|
||||||
|
_v2Context.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
Action = "SECURITY_MFA_DISABLED",
|
||||||
|
Entity = "User",
|
||||||
|
EntityID = user.UserID,
|
||||||
|
UserID = user.UserID,
|
||||||
|
Details = "MFA Desactivado mediante validación por correo."
|
||||||
|
});
|
||||||
|
|
||||||
|
await _v2Context.SaveChangesAsync();
|
||||||
|
return (true, "La autenticación de dos factores ha sido desactivada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. INICIAR RECONFIGURACIÓN MFA (Envía código al mail actual)
|
||||||
|
public async Task<(bool Success, string Message)> InitiateMFAReconfigureAsync(int userId)
|
||||||
|
{
|
||||||
|
var user = await _v2Context.Users.FindAsync(userId);
|
||||||
|
if (user == null) return (false, "Usuario no encontrado.");
|
||||||
|
|
||||||
|
if (!user.IsMFAEnabled) return (false, "La seguridad 2FA no está activa para reconfigurar.");
|
||||||
|
|
||||||
|
// Reutilizamos el mismo token de seguridad
|
||||||
|
var token = new Random().Next(100000, 999999).ToString();
|
||||||
|
|
||||||
|
user.SecurityActionToken = token;
|
||||||
|
user.SecurityActionTokenExpiresAt = DateTime.UtcNow.AddMinutes(10);
|
||||||
|
|
||||||
|
await _v2Context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var body = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; padding: 20px;'>
|
||||||
|
<h2 style='color: #f59e0b;'>Alerta de Seguridad</h2>
|
||||||
|
<p>Has solicitado <strong>RECONFIGURAR</strong> la seguridad de dos factores (2FA) en tu cuenta.</p>
|
||||||
|
<p>Tu código de seguridad para autorizar esta acción es:</p>
|
||||||
|
<h1 style='letter-spacing: 5px;'>{token}</h1>
|
||||||
|
<p>Si no fuiste tú, cambia tu contraseña inmediatamente.</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
await _emailService.SendEmailAsync(user.Email, "Código de Seguridad: Reconfigurar 2FA", body);
|
||||||
|
|
||||||
|
return (true, "Hemos enviado un código a tu correo para autorizar la reconfiguración.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import SeguridadPage from './pages/SeguridadPage';
|
|||||||
import { FaHome, FaSearch, FaCar, FaUser, FaShieldAlt } from 'react-icons/fa';
|
import { FaHome, FaSearch, FaCar, FaUser, FaShieldAlt } from 'react-icons/fa';
|
||||||
import { initMercadoPago } from '@mercadopago/sdk-react';
|
import { initMercadoPago } from '@mercadopago/sdk-react';
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
import ConfirmEmailChangePage from './pages/ConfirmEmailChangePage';
|
||||||
|
|
||||||
function AdminGuard({ children }: { children: React.ReactNode }) {
|
function AdminGuard({ children }: { children: React.ReactNode }) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -305,6 +306,7 @@ function MainLayout() {
|
|||||||
<Route path="/verificar-email" element={<VerifyEmailPage />} />
|
<Route path="/verificar-email" element={<VerifyEmailPage />} />
|
||||||
<Route path="/perfil" element={<PerfilPage />} />
|
<Route path="/perfil" element={<PerfilPage />} />
|
||||||
<Route path="/seguridad" element={<SeguridadPage />} />
|
<Route path="/seguridad" element={<SeguridadPage />} />
|
||||||
|
<Route path="/confirmar-cambio-email" element={<ConfirmEmailChangePage />} />
|
||||||
<Route path="/admin" element={
|
<Route path="/admin" element={
|
||||||
<AdminGuard>
|
<AdminGuard>
|
||||||
<AdminPage />
|
<AdminPage />
|
||||||
|
|||||||
@@ -1,41 +1,81 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { AuthService, type UserSession } from '../services/auth.service';
|
import { AuthService, type UserSession } from "../services/auth.service";
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import ChangePasswordModal from './ChangePasswordModal';
|
import ChangePasswordModal from "./ChangePasswordModal";
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
// Iconos
|
// Iconos
|
||||||
const CopyIcon = () => (<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 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.381a9.06 9.06 0 001.5-.124A9.06 9.06 0 0021 15m-7.5-10.381V7.5a1.125 1.125 0 001.125 1.125h3.375" /></svg>);
|
const CopyIcon = () => (
|
||||||
const CheckIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4"><path fillRule="evenodd" d="M19.916 4.626a.75.75 0 01.208 1.04l-9 13.5a.75.75 0 01-1.154.114l-6-6a.75.75 0 011.06-1.06l5.353 5.353 8.493-12.739a.75.75 0 011.04-.208z" clipRule="evenodd" /></svg>);
|
<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 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.381a9.06 9.06 0 001.5-.124A9.06 9.06 0 0021 15m-7.5-10.381V7.5a1.125 1.125 0 001.125 1.125h3.375"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="w-4 h-4"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M19.916 4.626a.75.75 0 01.208 1.04l-9 13.5a.75.75 0 01-1.154.114l-6-6a.75.75 0 011.06-1.06l5.353 5.353 8.493-12.739a.75.75 0 011.04-.208z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function ConfigPanel({ user }: { user: UserSession }) {
|
export default function ConfigPanel({ user }: { user: UserSession }) {
|
||||||
const { refreshSession } = useAuth(); // Para actualizar si isMFAEnabled cambia en el contexto
|
const { refreshSession } = useAuth();
|
||||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
|
|
||||||
// Estados MFA
|
// Estados MFA
|
||||||
const [mfaStep, setMfaStep] = useState<'IDLE' | 'QR'>('IDLE');
|
const [mfaStep, setMfaStep] = useState<"IDLE" | "QR">("IDLE");
|
||||||
const [qrUri, setQrUri] = useState('');
|
const [qrUri, setQrUri] = useState("");
|
||||||
const [secretKey, setSecretKey] = useState(''); // El código manual
|
const [secretKey, setSecretKey] = useState("");
|
||||||
const [mfaCode, setMfaCode] = useState('');
|
const [mfaCode, setMfaCode] = useState("");
|
||||||
const [msgMfa, setMsgMfa] = useState({ text: '', type: '' }); // type: 'success' | 'error'
|
const [msgMfa, setMsgMfa] = useState({ text: "", type: "" });
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Estados para flujos de verificación por Email
|
||||||
|
const [disableStep, setDisableStep] = useState<"IDLE" | "VERIFY">("IDLE");
|
||||||
|
const [disableCode, setDisableCode] = useState("");
|
||||||
|
const [reconfigureStep, setReconfigureStep] = useState<"IDLE" | "VERIFY">(
|
||||||
|
"IDLE",
|
||||||
|
);
|
||||||
|
const [reconfigureCode, setReconfigureCode] = useState("");
|
||||||
|
|
||||||
const isMfaActive = (user as any).isMFAEnabled;
|
const isMfaActive = (user as any).isMFAEnabled;
|
||||||
|
|
||||||
const handleInitMfa = async () => {
|
// Lógica para obtener el QR (Se llama directo para activar o con token para reconfigurar)
|
||||||
if (isMfaActive) {
|
const handleInitMfa = async (securityCode?: string) => {
|
||||||
if (!window.confirm("Al reconfigurar, el código anterior dejará de funcionar en tu otro dispositivo. ¿Continuar?")) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsgMfa({ text: '', type: '' });
|
setMsgMfa({ text: "", type: "" });
|
||||||
try {
|
try {
|
||||||
const data = await AuthService.initMFA();
|
const data = await AuthService.initMFA(securityCode);
|
||||||
setQrUri(data.qrUri);
|
setQrUri(data.qrUri);
|
||||||
setSecretKey(data.secret);
|
setSecretKey(data.secret);
|
||||||
setMfaStep('QR');
|
setMfaStep("QR");
|
||||||
} catch {
|
setReconfigureStep("IDLE");
|
||||||
setMsgMfa({ text: "Error iniciando configuración.", type: 'error' });
|
setReconfigureCode("");
|
||||||
|
} catch (err: any) {
|
||||||
|
setMsgMfa({
|
||||||
|
text:
|
||||||
|
err.response?.data?.message || "Error al generar nuevo código QR.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -45,27 +85,75 @@ export default function ConfigPanel({ user }: { user: UserSession }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await AuthService.verifyMFA(user.username, mfaCode);
|
await AuthService.verifyMFA(user.username, mfaCode);
|
||||||
setMsgMfa({ text: "¡MFA Activado correctamente!", type: 'success' });
|
setMsgMfa({ text: "¡MFA configurado con éxito!", type: "success" });
|
||||||
setMfaStep('IDLE');
|
setMfaStep("IDLE");
|
||||||
setMfaCode('');
|
setMfaCode("");
|
||||||
await refreshSession(); // Actualizar estado global
|
await refreshSession();
|
||||||
} catch {
|
} catch {
|
||||||
setMsgMfa({ text: "Código incorrecto. Intenta nuevamente.", type: 'error' });
|
setMsgMfa({ text: "Código incorrecto.", type: "error" });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisableMfa = async () => {
|
// Flujo Desactivar
|
||||||
if (!window.confirm("¿Seguro que deseas desactivar la protección de dos factores? Tu cuenta será menos segura.")) return;
|
const handleInitiateDisable = async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Se enviará un código a tu correo para confirmar la desactivación. ¿Continuar?",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await AuthService.disableMFA();
|
await AuthService.initiateMfaDisable();
|
||||||
setMsgMfa({ text: "MFA Desactivado.", type: 'success' });
|
setMsgMfa({ text: "Código enviado a tu email.", type: "success" });
|
||||||
|
setDisableStep("VERIFY");
|
||||||
|
setReconfigureStep("IDLE"); // Resetear el otro flujo por si acaso
|
||||||
|
} catch (err: any) {
|
||||||
|
setMsgMfa({
|
||||||
|
text: err.response?.data?.message || "Error al iniciar.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDisable = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await AuthService.confirmMfaDisable(disableCode);
|
||||||
|
setMsgMfa({ text: "MFA Desactivado.", type: "success" });
|
||||||
|
setDisableStep("IDLE");
|
||||||
|
setDisableCode("");
|
||||||
await refreshSession();
|
await refreshSession();
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
setMsgMfa({ text: "Error al desactivar MFA.", type: 'error' });
|
setMsgMfa({ text: "Código incorrecto.", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flujo Reconfigurar
|
||||||
|
const handleInitiateReconfigure = async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Se enviará un código a tu correo para autorizar la reconfiguración. ¿Continuar?",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await AuthService.initiateMfaReconfigure();
|
||||||
|
setMsgMfa({ text: "Código de autorización enviado.", type: "success" });
|
||||||
|
setReconfigureStep("VERIFY");
|
||||||
|
setDisableStep("IDLE"); // Resetear el otro flujo
|
||||||
|
} catch (err: any) {
|
||||||
|
setMsgMfa({
|
||||||
|
text: err.response?.data?.message || "Error al iniciar.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -80,14 +168,15 @@ export default function ConfigPanel({ user }: { user: UserSession }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
|
||||||
{/* SECCIÓN CONTRASEÑA */}
|
{/* SECCIÓN CONTRASEÑA */}
|
||||||
<section className="glass p-8 rounded-[2rem] border border-white/5 flex flex-col items-center justify-center text-center relative overflow-hidden">
|
<section className="glass p-8 rounded-[2rem] border border-white/5 flex flex-col items-center justify-center text-center relative overflow-hidden">
|
||||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-600 to-transparent opacity-50"></div>
|
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-600 to-transparent opacity-50"></div>
|
||||||
<div className="w-16 h-16 bg-white/5 rounded-2xl flex items-center justify-center text-3xl mb-4 shadow-inner border border-white/5">
|
<div className="w-16 h-16 bg-white/5 rounded-2xl flex items-center justify-center text-3xl mb-4 shadow-inner border border-white/5">
|
||||||
🔑
|
🔑
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold uppercase mb-2 text-white">Contraseña</h3>
|
<h3 className="text-xl font-bold uppercase mb-2 text-white">
|
||||||
|
Contraseña
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-gray-400 mb-6 max-w-xs leading-relaxed">
|
<p className="text-sm text-gray-400 mb-6 max-w-xs leading-relaxed">
|
||||||
Mantén tu cuenta segura actualizando tu contraseña periódicamente.
|
Mantén tu cuenta segura actualizando tu contraseña periódicamente.
|
||||||
</p>
|
</p>
|
||||||
@@ -100,19 +189,25 @@ export default function ConfigPanel({ user }: { user: UserSession }) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* SECCIÓN MFA */}
|
{/* SECCIÓN MFA */}
|
||||||
<section className={`glass p-8 rounded-[2rem] border relative overflow-hidden flex flex-col items-center transition-all ${isMfaActive ? 'border-green-500/20 bg-green-900/5' : 'border-white/5'}`}>
|
<section
|
||||||
|
className={`glass p-8 rounded-[2rem] border relative overflow-hidden flex flex-col items-center transition-all ${isMfaActive ? "border-green-500/20 bg-green-900/5" : "border-white/5"}`}
|
||||||
{/* Indicador de Estado */}
|
>
|
||||||
<div className={`absolute top-4 right-4 px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border ${isMfaActive ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-gray-500/10 text-gray-500 border-white/10'}`}>
|
<div
|
||||||
{isMfaActive ? 'Protegido' : 'No Activo'}
|
className={`absolute top-4 right-4 px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border ${isMfaActive ? "bg-green-500/10 text-green-400 border-green-500/20" : "bg-gray-500/10 text-gray-500 border-white/10"}`}
|
||||||
|
>
|
||||||
|
{isMfaActive ? "Protegido" : "No Activo"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl mb-4 transition-colors ${isMfaActive ? 'bg-green-500/20 text-green-400 shadow-[0_0_20px_rgba(34,197,94,0.2)]' : 'bg-blue-600/10 text-blue-500'}`}>
|
<div
|
||||||
|
className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl mb-4 transition-colors ${isMfaActive ? "bg-green-500/20 text-green-400 shadow-[0_0_20px_rgba(34,197,94,0.2)]" : "bg-blue-600/10 text-blue-500"}`}
|
||||||
|
>
|
||||||
🛡️
|
🛡️
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold uppercase mb-2 text-white">Doble Factor (2FA)</h3>
|
<h3 className="text-xl font-bold uppercase mb-2 text-white">
|
||||||
|
Doble Factor (2FA)
|
||||||
|
</h3>
|
||||||
|
|
||||||
{mfaStep === 'IDLE' ? (
|
{mfaStep === "IDLE" ? (
|
||||||
<div className="text-center w-full flex-1 flex flex-col">
|
<div className="text-center w-full flex-1 flex flex-col">
|
||||||
<p className="text-sm text-gray-400 mb-6 max-w-xs mx-auto leading-relaxed">
|
<p className="text-sm text-gray-400 mb-6 max-w-xs mx-auto leading-relaxed">
|
||||||
{isMfaActive
|
{isMfaActive
|
||||||
@@ -120,67 +215,176 @@ export default function ConfigPanel({ user }: { user: UserSession }) {
|
|||||||
: "Añade una capa extra de seguridad. Requerirá un código de tu celular al iniciar sesión."}
|
: "Añade una capa extra de seguridad. Requerirá un código de tu celular al iniciar sesión."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-auto space-y-3">
|
<div className="mt-auto space-y-3 w-full">
|
||||||
{isMfaActive ? (
|
{isMfaActive ? (
|
||||||
<div className="flex gap-3">
|
<>
|
||||||
<button onClick={handleDisableMfa} disabled={loading} className="flex-1 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-4 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all">
|
{disableStep === "VERIFY" ? (
|
||||||
{loading ? '...' : 'Desactivar'}
|
/* Verificación para Desactivar */
|
||||||
</button>
|
<div className="animate-fade-in bg-black/30 p-4 rounded-xl border border-red-500/20">
|
||||||
<button onClick={handleInitMfa} disabled={loading} className="flex-1 bg-white/5 hover:bg-white/10 text-white border border-white/10 px-4 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all">
|
<p className="text-[10px] text-gray-300 mb-2 font-bold uppercase tracking-widest">
|
||||||
Reconfigurar
|
Código para Desactivar:
|
||||||
</button>
|
</p>
|
||||||
</div>
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000000"
|
||||||
|
value={disableCode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDisableCode(e.target.value.replace(/\D/g, ""))
|
||||||
|
}
|
||||||
|
className="w-full bg-black/50 border border-white/10 rounded-lg px-3 py-3 text-center text-white text-xl font-mono tracking-[0.3em] outline-none mb-3 focus:border-red-500 transition-all"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDisableStep("IDLE");
|
||||||
|
setMsgMfa({ text: "", type: "" });
|
||||||
|
}}
|
||||||
|
className="flex-1 text-[10px] font-bold uppercase text-gray-500 hover:text-white py-2 bg-white/5 rounded-lg"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmDisable}
|
||||||
|
disabled={loading || disableCode.length < 6}
|
||||||
|
className="flex-1 bg-red-600 hover:bg-red-500 text-white rounded-lg text-[10px] font-bold uppercase py-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Confirmar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : reconfigureStep === "VERIFY" ? (
|
||||||
|
/* Verificación para Reconfigurar */
|
||||||
|
<div className="animate-fade-in bg-black/30 p-4 rounded-xl border border-blue-500/20">
|
||||||
|
<p className="text-[10px] text-gray-300 mb-2 font-bold uppercase tracking-widest">
|
||||||
|
Código para Reconfigurar:
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000000"
|
||||||
|
value={reconfigureCode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setReconfigureCode(
|
||||||
|
e.target.value.replace(/\D/g, ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full bg-black/50 border border-white/10 rounded-lg px-3 py-3 text-center text-white text-xl font-mono tracking-[0.3em] outline-none mb-3 focus:border-blue-500 transition-all"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setReconfigureStep("IDLE");
|
||||||
|
setMsgMfa({ text: "", type: "" });
|
||||||
|
}}
|
||||||
|
className="flex-1 text-[10px] font-bold uppercase text-gray-500 hover:text-white py-2 bg-white/5 rounded-lg"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleInitMfa(reconfigureCode)}
|
||||||
|
disabled={loading || reconfigureCode.length < 6}
|
||||||
|
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-[10px] font-bold uppercase py-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Continuar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Botones Principales */
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleInitiateDisable}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-4 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
|
||||||
|
>
|
||||||
|
{loading ? "..." : "Desactivar"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleInitiateReconfigure}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-white/5 hover:bg-white/10 text-white border border-white/10 px-4 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
|
||||||
|
>
|
||||||
|
Reconfigurar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={handleInitMfa} disabled={loading} className="bg-blue-600 hover:bg-blue-500 text-white px-8 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest w-full shadow-lg shadow-blue-600/20 transition-all hover:scale-[1.02]">
|
<button
|
||||||
{loading ? 'Cargando...' : 'Activar MFA'}
|
onClick={() => handleInitMfa()}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-500 text-white px-8 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest w-full shadow-lg shadow-blue-600/20 transition-all hover:scale-[1.02]"
|
||||||
|
>
|
||||||
|
{loading ? "Cargando..." : "Activar MFA"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{msgMfa.text && (
|
{msgMfa.text && (
|
||||||
<p className={`text-[10px] font-bold uppercase tracking-wide mt-4 animate-fade-in ${msgMfa.type === 'error' ? 'text-red-400' : 'text-green-400'}`}>
|
<p
|
||||||
|
className={`text-[10px] font-bold uppercase tracking-wide mt-4 animate-fade-in ${msgMfa.type === "error" ? "text-red-400" : "text-green-400"}`}
|
||||||
|
>
|
||||||
{msgMfa.text}
|
{msgMfa.text}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
/* PASO QR (Solo se llega aquí tras validar código o activar por primera vez) */
|
||||||
<div className="text-center w-full animate-fade-in">
|
<div className="text-center w-full animate-fade-in">
|
||||||
<div className="bg-white p-3 rounded-2xl inline-block mb-4 shadow-xl border-4 border-white">
|
<div className="bg-white p-3 rounded-2xl inline-block mb-4 shadow-xl border-4 border-white">
|
||||||
<QRCodeSVG value={qrUri} size={140} />
|
<QRCodeSVG value={qrUri} size={140} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-300 font-bold mb-2">1. Escanea el código</p>
|
<p className="text-xs text-gray-300 font-bold mb-2">
|
||||||
<p className="text-[10px] text-gray-500 mb-4 max-w-[200px] mx-auto">Usa Google Authenticator o Authy en tu celular.</p>
|
1. Escanea el código
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-500 mb-4 max-w-[200px] mx-auto">
|
||||||
|
Usa Google Authenticator o Authy en tu celular.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* CÓDIGO MANUAL */}
|
|
||||||
<div className="bg-black/40 border border-white/10 rounded-xl p-3 mb-6 relative group w-full overflow-hidden">
|
<div className="bg-black/40 border border-white/10 rounded-xl p-3 mb-6 relative group w-full overflow-hidden">
|
||||||
<p className="text-[8px] text-gray-500 uppercase font-bold tracking-widest mb-1">O ingresa el código manual</p>
|
<p className="text-[8px] text-gray-500 uppercase font-bold tracking-widest mb-1">
|
||||||
|
Código manual
|
||||||
|
</p>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<code className="text-blue-400 font-mono text-sm tracking-wider select-all break-all text-left flex-1">{secretKey}</code>
|
<code className="text-blue-400 font-mono text-xs tracking-wider select-all break-all text-left flex-1">
|
||||||
|
{secretKey}
|
||||||
|
</code>
|
||||||
<button
|
<button
|
||||||
onClick={copyToClipboard}
|
onClick={copyToClipboard}
|
||||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-all shrink-0"
|
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-all shrink-0"
|
||||||
title="Copiar"
|
|
||||||
>
|
>
|
||||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-300 font-bold mb-2">2. Ingresa el token</p>
|
<p className="text-xs text-gray-300 font-bold mb-2">
|
||||||
|
2. Ingresa el token
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="000 000"
|
placeholder="000 000"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={mfaCode}
|
value={mfaCode}
|
||||||
onChange={e => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ""))}
|
||||||
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-center text-2xl font-black text-white mb-4 tracking-[0.4em] outline-none focus:border-blue-500 transition-colors placeholder:opacity-20"
|
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-center text-2xl font-black text-white mb-4 tracking-[0.4em] outline-none focus:border-blue-500 transition-colors placeholder:opacity-20"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => setMfaStep('IDLE')} className="flex-1 bg-white/5 hover:text-white text-gray-500 py-3 rounded-xl text-[10px] font-bold uppercase transition-colors">Cancelar</button>
|
<button
|
||||||
<button onClick={handleVerifyMfa} disabled={loading || mfaCode.length < 6} className="flex-1 bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-xl text-[10px] font-bold uppercase transition-colors disabled:opacity-50 shadow-lg shadow-blue-600/20">
|
onClick={() => setMfaStep("IDLE")}
|
||||||
{loading ? '...' : 'Activar'}
|
className="flex-1 bg-white/5 hover:text-white text-gray-500 py-3 rounded-xl text-[10px] font-bold uppercase transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleVerifyMfa}
|
||||||
|
disabled={loading || mfaCode.length < 6}
|
||||||
|
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-xl text-[10px] font-bold uppercase transition-colors disabled:opacity-50 shadow-lg shadow-blue-600/20"
|
||||||
|
>
|
||||||
|
{loading ? "..." : "Confirmar"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
85
Frontend/src/pages/ConfirmEmailChangePage.tsx
Normal file
85
Frontend/src/pages/ConfirmEmailChangePage.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||||
|
import { AuthService } from "../services/auth.service";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
export default function ConfirmEmailChangePage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { logout } = useAuth(); // Importante: Desloguear para forzar login con nuevo email
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<"LOADING" | "SUCCESS" | "ERROR">(
|
||||||
|
"LOADING",
|
||||||
|
);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus("ERROR");
|
||||||
|
setMessage("Enlace inválido.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
try {
|
||||||
|
await AuthService.confirmEmailChange(token);
|
||||||
|
setStatus("SUCCESS");
|
||||||
|
// Forzar cierre de sesión por seguridad
|
||||||
|
setTimeout(() => {
|
||||||
|
logout();
|
||||||
|
navigate("/");
|
||||||
|
}, 4000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus("ERROR");
|
||||||
|
setMessage(err.response?.data?.message || "Error al confirmar cambio.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
confirm();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-6 bg-[#0a0c10]">
|
||||||
|
<div className="glass p-10 rounded-[2.5rem] border border-white/10 text-center max-w-md w-full shadow-2xl">
|
||||||
|
{status === "LOADING" && (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-6"></div>
|
||||||
|
<h2 className="text-xl font-bold text-white">
|
||||||
|
Verificando cambio...
|
||||||
|
</h2>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "SUCCESS" && (
|
||||||
|
<>
|
||||||
|
<div className="text-5xl mb-6">✅</div>
|
||||||
|
<h2 className="text-2xl font-black uppercase text-green-400 mb-4">
|
||||||
|
¡Email Actualizado!
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Tu dirección de correo ha sido cambiada correctamente.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-400">
|
||||||
|
Cerrando sesión por seguridad...
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "ERROR" && (
|
||||||
|
<>
|
||||||
|
<div className="text-5xl mb-6">❌</div>
|
||||||
|
<h2 className="text-2xl font-black uppercase text-red-400 mb-4">
|
||||||
|
Error
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 text-sm">{message}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
className="mt-6 bg-white/5 px-6 py-2 rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-white/10 transition-all text-white"
|
||||||
|
>
|
||||||
|
Volver al inicio
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { ProfileService } from '../services/profile.service';
|
import { ProfileService } from "../services/profile.service";
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { AuthService } from "../services/auth.service";
|
||||||
|
|
||||||
export default function PerfilPage() {
|
export default function PerfilPage() {
|
||||||
const { user, refreshSession } = useAuth();
|
const { user, refreshSession } = useAuth();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [showEmailModal, setShowEmailModal] = useState(false);
|
||||||
|
const [newEmail, setNewEmail] = useState("");
|
||||||
|
const [authCode, setAuthCode] = useState(""); // Código Google Authenticator
|
||||||
|
const [loadingEmail, setLoadingEmail] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: '',
|
firstName: "",
|
||||||
lastName: '',
|
lastName: "",
|
||||||
phoneNumber: ''
|
phoneNumber: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -21,9 +27,9 @@ export default function PerfilPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await ProfileService.getProfile();
|
const data = await ProfileService.getProfile();
|
||||||
setFormData({
|
setFormData({
|
||||||
firstName: data.firstName || '',
|
firstName: data.firstName || "",
|
||||||
lastName: data.lastName || '',
|
lastName: data.lastName || "",
|
||||||
phoneNumber: data.phoneNumber || ''
|
phoneNumber: data.phoneNumber || "",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading profile", err);
|
console.error("Error loading profile", err);
|
||||||
@@ -37,10 +43,10 @@ export default function PerfilPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await ProfileService.updateProfile(formData);
|
await ProfileService.updateProfile(formData);
|
||||||
alert('Perfil actualizado con éxito');
|
alert("Perfil actualizado con éxito");
|
||||||
if (refreshSession) refreshSession();
|
if (refreshSession) refreshSession();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error al actualizar el perfil');
|
alert("Error al actualizar el perfil");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -57,8 +63,12 @@ export default function PerfilPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-6 py-12 max-w-4xl">
|
<div className="container mx-auto px-6 py-12 max-w-4xl">
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h1 className="text-5xl font-black tracking-tighter uppercase mb-2">Mi <span className="text-blue-500">Perfil</span></h1>
|
<h1 className="text-5xl font-black tracking-tighter uppercase mb-2">
|
||||||
<p className="text-gray-500 font-bold tracking-widest uppercase text-xs">Gestiona tu información personal</p>
|
Mi <span className="text-blue-500">Perfil</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 font-bold tracking-widest uppercase text-xs">
|
||||||
|
Gestiona tu información personal
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
@@ -68,12 +78,20 @@ export default function PerfilPage() {
|
|||||||
<div className="w-24 h-24 bg-blue-600/20 rounded-full flex items-center justify-center text-4xl text-blue-400 font-bold mx-auto mb-4 border border-blue-500/20 shadow-lg shadow-blue-500/10">
|
<div className="w-24 h-24 bg-blue-600/20 rounded-full flex items-center justify-center text-4xl text-blue-400 font-bold mx-auto mb-4 border border-blue-500/20 shadow-lg shadow-blue-500/10">
|
||||||
{user?.username?.[0].toUpperCase()}
|
{user?.username?.[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-black text-white uppercase tracking-tight">{user?.username}</h2>
|
<h2 className="text-xl font-black text-white uppercase tracking-tight">
|
||||||
<p className="text-xs text-gray-500 font-medium mb-6">{user?.email}</p>
|
{user?.username}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 font-medium mb-6">
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className={`px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest ${user?.userType === 3 ? 'bg-amber-500/10 text-amber-500 border border-amber-500/20' : 'bg-blue-600/10 text-blue-400 border border-blue-600/20'}`}>
|
<span
|
||||||
{user?.userType === 3 ? '🛡️ Administrador' : '👤 Usuario Particular'}
|
className={`px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest ${user?.userType === 3 ? "bg-amber-500/10 text-amber-500 border border-amber-500/20" : "bg-blue-600/10 text-blue-400 border border-blue-600/20"}`}
|
||||||
|
>
|
||||||
|
{user?.userType === 3
|
||||||
|
? "🛡️ Administrador"
|
||||||
|
: "👤 Usuario Particular"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,49 +99,83 @@ export default function PerfilPage() {
|
|||||||
|
|
||||||
{/* Edit Form */}
|
{/* Edit Form */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<form onSubmit={handleSubmit} className="glass p-8 rounded-[2.5rem] border border-white/5 space-y-8">
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="glass p-8 rounded-[2.5rem] border border-white/5 space-y-8"
|
||||||
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Nombre</label>
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">
|
||||||
|
Nombre
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.firstName}
|
value={formData.firstName}
|
||||||
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, firstName: e.target.value })
|
||||||
|
}
|
||||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
|
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
|
||||||
placeholder="Tu nombre"
|
placeholder="Tu nombre"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Apellido</label>
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">
|
||||||
|
Apellido
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.lastName}
|
value={formData.lastName}
|
||||||
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, lastName: e.target.value })
|
||||||
|
}
|
||||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
|
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
|
||||||
placeholder="Tu apellido"
|
placeholder="Tu apellido"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Teléfono de Contacto</label>
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">
|
||||||
|
Teléfono de Contacto
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.phoneNumber}
|
value={formData.phoneNumber}
|
||||||
onChange={e => setFormData({ ...formData, phoneNumber: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, phoneNumber: e.target.value })
|
||||||
|
}
|
||||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
|
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
|
||||||
placeholder="Ej: +54 9 11 1234 5678"
|
placeholder="Ej: +54 9 11 1234 5678"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Email <span className="text-[8px] text-gray-600 font-normal">(No editable)</span></label>
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">
|
||||||
<input
|
Email
|
||||||
type="email"
|
</label>
|
||||||
value={user?.email}
|
<div className="flex gap-2">
|
||||||
disabled
|
<input
|
||||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-gray-500 outline-none cursor-not-allowed font-medium"
|
type="email"
|
||||||
/>
|
value={user?.email}
|
||||||
|
disabled
|
||||||
|
className="flex-1 bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-gray-500 outline-none cursor-not-allowed font-medium"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!user?.isMFAEnabled) {
|
||||||
|
alert(
|
||||||
|
"⚠️ Acción restringida.\n\nPara cambiar tu email, primero debes activar la Autenticación de Dos Factores (2FA) en la sección de Seguridad.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowEmailModal(true);
|
||||||
|
}}
|
||||||
|
className="bg-white/5 hover:bg-blue-600 hover:text-white border border-white/10 text-gray-300 px-6 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all"
|
||||||
|
>
|
||||||
|
Cambiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,12 +185,100 @@ export default function PerfilPage() {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full md:w-auto bg-blue-600 hover:bg-blue-500 text-white py-4 px-12 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95 disabled:opacity-50"
|
className="w-full md:w-auto bg-blue-600 hover:bg-blue-500 text-white py-4 px-12 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? 'Guardando...' : 'Guardar Cambios'}
|
{saving ? "Guardando..." : "Guardar Cambios"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showEmailModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-fade-in">
|
||||||
|
<div className="glass p-8 rounded-[2.5rem] border border-white/10 max-w-md w-full relative shadow-2xl animate-scale-up">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEmailModal(false)}
|
||||||
|
className="absolute top-6 right-6 text-gray-500 hover:text-white font-bold"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="w-16 h-16 bg-blue-600/10 rounded-2xl flex items-center justify-center text-2xl mx-auto mb-4 border border-blue-500/20 text-blue-400">
|
||||||
|
📧
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-black text-white uppercase tracking-tighter">
|
||||||
|
Cambiar Email
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-2 font-medium">
|
||||||
|
Ingresa tu nueva dirección y valida con tu autenticador.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-2 ml-1">
|
||||||
|
Nuevo Correo Electrónico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="nuevo@email.com"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white text-sm outline-none focus:border-blue-500 transition-all placeholder:text-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-2 ml-1">
|
||||||
|
Código Authenticator (2FA)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000 000"
|
||||||
|
value={authCode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAuthCode(e.target.value.replace(/\D/g, ""))
|
||||||
|
}
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white text-center font-mono text-xl tracking-[0.3em] outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 placeholder:tracking-normal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (newEmail === user?.email) {
|
||||||
|
alert("El nuevo email debe ser diferente al actual.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLoadingEmail(true);
|
||||||
|
const res = await AuthService.initiateEmailChange(
|
||||||
|
newEmail,
|
||||||
|
authCode,
|
||||||
|
);
|
||||||
|
alert(res.message); // "Se ha enviado un enlace..."
|
||||||
|
setShowEmailModal(false);
|
||||||
|
setNewEmail("");
|
||||||
|
setAuthCode("");
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(
|
||||||
|
err.response?.data?.message ||
|
||||||
|
"Error al solicitar cambio.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingEmail(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
loadingEmail || newEmail.length < 5 || authCode.length < 6
|
||||||
|
}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-bold uppercase tracking-widest text-xs shadow-lg shadow-blue-600/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed hover:scale-[1.02]"
|
||||||
|
>
|
||||||
|
{loadingEmail ? "Procesando..." : "Enviar Confirmación"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import apiClient from './axios.client';
|
import apiClient from "./axios.client";
|
||||||
|
|
||||||
export interface UserSession {
|
export interface UserSession {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,76 +13,82 @@ export interface UserSession {
|
|||||||
|
|
||||||
export const AuthService = {
|
export const AuthService = {
|
||||||
async login(username: string, password: string) {
|
async login(username: string, password: string) {
|
||||||
const response = await apiClient.post('/auth/login', { username, password });
|
const response = await apiClient.post("/auth/login", {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
if (response.data.status === 'MIGRATION_REQUIRED') {
|
if (response.data.status === "MIGRATION_REQUIRED") {
|
||||||
return { status: 'MIGRATION_REQUIRED', username: response.data.username };
|
return { status: "MIGRATION_REQUIRED", username: response.data.username };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.data.status === 'MFA_SETUP_REQUIRED') {
|
if (response.data.status === "MFA_SETUP_REQUIRED") {
|
||||||
return {
|
return {
|
||||||
status: 'MFA_SETUP_REQUIRED',
|
status: "MFA_SETUP_REQUIRED",
|
||||||
username: response.data.username,
|
username: response.data.username,
|
||||||
qrUri: response.data.qrUri,
|
qrUri: response.data.qrUri,
|
||||||
secret: response.data.secret
|
secret: response.data.secret,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.data.status === 'TOTP_REQUIRED') {
|
if (response.data.status === "TOTP_REQUIRED") {
|
||||||
return {
|
return {
|
||||||
status: 'TOTP_REQUIRED',
|
status: "TOTP_REQUIRED",
|
||||||
username: response.data.username,
|
username: response.data.username,
|
||||||
recommendMfa: response.data.recommendMfa
|
recommendMfa: response.data.recommendMfa,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.data.status === 'SUCCESS') {
|
if (response.data.status === "SUCCESS") {
|
||||||
localStorage.setItem('userProfile', JSON.stringify(response.data.user));
|
localStorage.setItem("userProfile", JSON.stringify(response.data.user));
|
||||||
return {
|
return {
|
||||||
status: 'SUCCESS',
|
status: "SUCCESS",
|
||||||
user: response.data.user,
|
user: response.data.user,
|
||||||
recommendMfa: response.data.recommendMfa
|
recommendMfa: response.data.recommendMfa,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Respuesta inesperada del servidor');
|
throw new Error("Respuesta inesperada del servidor");
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
await apiClient.post('/auth/logout');
|
await apiClient.post("/auth/logout");
|
||||||
localStorage.removeItem('userProfile');
|
localStorage.removeItem("userProfile");
|
||||||
},
|
},
|
||||||
|
|
||||||
async register(data: any) {
|
async register(data: any) {
|
||||||
const response = await apiClient.post('/auth/register', data);
|
const response = await apiClient.post("/auth/register", data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async verifyEmail(token: string) {
|
async verifyEmail(token: string) {
|
||||||
const response = await apiClient.post('/auth/verify-email', { token });
|
const response = await apiClient.post("/auth/verify-email", { token });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async verifyMFA(username: string, code: string) {
|
async verifyMFA(username: string, code: string) {
|
||||||
const response = await apiClient.post('/auth/verify-mfa', { username, code });
|
const response = await apiClient.post("/auth/verify-mfa", {
|
||||||
if (response.data.status === 'SUCCESS') {
|
username,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
if (response.data.status === "SUCCESS") {
|
||||||
this.setSession(response.data.user, response.data.token);
|
this.setSession(response.data.user, response.data.token);
|
||||||
return response.data.user;
|
return response.data.user;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setSession(user: UserSession, token: string) {
|
setSession(user: UserSession, token: string) {
|
||||||
localStorage.setItem('session', JSON.stringify(user));
|
localStorage.setItem("session", JSON.stringify(user));
|
||||||
localStorage.setItem('token', token);
|
localStorage.setItem("token", token);
|
||||||
},
|
},
|
||||||
|
|
||||||
async migratePassword(username: string, newPassword: string) {
|
async migratePassword(username: string, newPassword: string) {
|
||||||
await apiClient.post('/auth/migrate-password', { username, newPassword });
|
await apiClient.post("/auth/migrate-password", { username, newPassword });
|
||||||
},
|
},
|
||||||
|
|
||||||
async checkSession() {
|
async checkSession() {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/auth/me');
|
const response = await apiClient.get("/auth/me");
|
||||||
|
|
||||||
// Si el backend devuelve 200 pero el body es null, no hay sesión.
|
// Si el backend devuelve 200 pero el body es null, no hay sesión.
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
@@ -91,53 +97,88 @@ export const AuthService = {
|
|||||||
|
|
||||||
// Sincronizar localStorage con la sesión real del servidor
|
// Sincronizar localStorage con la sesión real del servidor
|
||||||
// Esto asegura que 'MisAvisosPage' siempre tenga el ID correcto para consultar.
|
// Esto asegura que 'MisAvisosPage' siempre tenga el ID correcto para consultar.
|
||||||
localStorage.setItem('userProfile', JSON.stringify(response.data));
|
localStorage.setItem("userProfile", JSON.stringify(response.data));
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Si falla (token expirado), limpiamos
|
// Si falla (token expirado), limpiamos
|
||||||
localStorage.removeItem('userProfile');
|
localStorage.removeItem("userProfile");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getCurrentUser() {
|
getCurrentUser() {
|
||||||
// Solo para visualización rápida, la verdad está en el backend
|
// Solo para visualización rápida, la verdad está en el backend
|
||||||
const session = localStorage.getItem('userProfile');
|
const session = localStorage.getItem("userProfile");
|
||||||
return session ? JSON.parse(session) : null;
|
return session ? JSON.parse(session) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
getToken(): string | null {
|
getToken(): string | null {
|
||||||
return localStorage.getItem('token');
|
return localStorage.getItem("token");
|
||||||
},
|
},
|
||||||
|
|
||||||
async resendVerification(email: string) {
|
async resendVerification(email: string) {
|
||||||
const response = await apiClient.post('/auth/resend-verification', { email });
|
const response = await apiClient.post("/auth/resend-verification", {
|
||||||
|
email,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async forgotPassword(email: string) {
|
async forgotPassword(email: string) {
|
||||||
const response = await apiClient.post('/auth/forgot-password', { email });
|
const response = await apiClient.post("/auth/forgot-password", { email });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async resetPassword(token: string, newPassword: string) {
|
async resetPassword(token: string, newPassword: string) {
|
||||||
const response = await apiClient.post('/auth/reset-password', { token, newPassword });
|
const response = await apiClient.post("/auth/reset-password", {
|
||||||
|
token,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async initMFA() {
|
async initiateMfaReconfigure() {
|
||||||
const response = await apiClient.post('/auth/init-mfa');
|
const response = await apiClient.post("/auth/initiate-mfa-reconfigure");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async initMFA(token?: string) {
|
||||||
|
const response = await apiClient.post("/auth/init-mfa", { token });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async changePassword(currentPassword: string, newPassword: string) {
|
async changePassword(currentPassword: string, newPassword: string) {
|
||||||
const response = await apiClient.post('/auth/change-password', { currentPassword, newPassword });
|
const response = await apiClient.post("/auth/change-password", {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async disableMFA() {
|
async initiateEmailChange(newEmail: string, mfaCode: string) {
|
||||||
const response = await apiClient.post('/auth/disable-mfa');
|
const response = await apiClient.post("/auth/initiate-email-change", {
|
||||||
|
newEmail,
|
||||||
|
mfaCode,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmEmailChange(token: string) {
|
||||||
|
const response = await apiClient.post("/auth/confirm-email-change", {
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async initiateMfaDisable() {
|
||||||
|
const response = await apiClient.post("/auth/initiate-mfa-disable");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmMfaDisable(token: string) {
|
||||||
|
const response = await apiClient.post("/auth/confirm-mfa-disable", {
|
||||||
|
token,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user