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:
2026-02-12 15:24:32 -03:00
parent 8c8c49894a
commit e096ed1590
10 changed files with 891 additions and 169 deletions

View File

@@ -16,6 +16,7 @@ import SeguridadPage from './pages/SeguridadPage';
import { FaHome, FaSearch, FaCar, FaUser, FaShieldAlt } from 'react-icons/fa';
import { initMercadoPago } from '@mercadopago/sdk-react';
import { AuthProvider, useAuth } from './context/AuthContext';
import ConfirmEmailChangePage from './pages/ConfirmEmailChangePage';
function AdminGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
@@ -305,6 +306,7 @@ function MainLayout() {
<Route path="/verificar-email" element={<VerifyEmailPage />} />
<Route path="/perfil" element={<PerfilPage />} />
<Route path="/seguridad" element={<SeguridadPage />} />
<Route path="/confirmar-cambio-email" element={<ConfirmEmailChangePage />} />
<Route path="/admin" element={
<AdminGuard>
<AdminPage />

View File

@@ -1,41 +1,81 @@
import { useState } from 'react';
import { AuthService, type UserSession } from '../services/auth.service';
import { QRCodeSVG } from 'qrcode.react';
import ChangePasswordModal from './ChangePasswordModal';
import { useAuth } from '../context/AuthContext';
import { useState } from "react";
import { AuthService, type UserSession } from "../services/auth.service";
import { QRCodeSVG } from "qrcode.react";
import ChangePasswordModal from "./ChangePasswordModal";
import { useAuth } from "../context/AuthContext";
// 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 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>);
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 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 }) {
const { refreshSession } = useAuth(); // Para actualizar si isMFAEnabled cambia en el contexto
const { refreshSession } = useAuth();
const [showPasswordModal, setShowPasswordModal] = useState(false);
// Estados MFA
const [mfaStep, setMfaStep] = useState<'IDLE' | 'QR'>('IDLE');
const [qrUri, setQrUri] = useState('');
const [secretKey, setSecretKey] = useState(''); // El código manual
const [mfaCode, setMfaCode] = useState('');
const [msgMfa, setMsgMfa] = useState({ text: '', type: '' }); // type: 'success' | 'error'
const [mfaStep, setMfaStep] = useState<"IDLE" | "QR">("IDLE");
const [qrUri, setQrUri] = useState("");
const [secretKey, setSecretKey] = useState("");
const [mfaCode, setMfaCode] = useState("");
const [msgMfa, setMsgMfa] = useState({ text: "", type: "" });
const [copied, setCopied] = 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 handleInitMfa = async () => {
if (isMfaActive) {
if (!window.confirm("Al reconfigurar, el código anterior dejará de funcionar en tu otro dispositivo. ¿Continuar?")) return;
}
// Lógica para obtener el QR (Se llama directo para activar o con token para reconfigurar)
const handleInitMfa = async (securityCode?: string) => {
setLoading(true);
setMsgMfa({ text: '', type: '' });
setMsgMfa({ text: "", type: "" });
try {
const data = await AuthService.initMFA();
const data = await AuthService.initMFA(securityCode);
setQrUri(data.qrUri);
setSecretKey(data.secret);
setMfaStep('QR');
} catch {
setMsgMfa({ text: "Error iniciando configuración.", type: 'error' });
setMfaStep("QR");
setReconfigureStep("IDLE");
setReconfigureCode("");
} catch (err: any) {
setMsgMfa({
text:
err.response?.data?.message || "Error al generar nuevo código QR.",
type: "error",
});
} finally {
setLoading(false);
}
@@ -45,27 +85,75 @@ export default function ConfigPanel({ user }: { user: UserSession }) {
setLoading(true);
try {
await AuthService.verifyMFA(user.username, mfaCode);
setMsgMfa({ text: "¡MFA Activado correctamente!", type: 'success' });
setMfaStep('IDLE');
setMfaCode('');
await refreshSession(); // Actualizar estado global
setMsgMfa({ text: "¡MFA configurado con éxito!", type: "success" });
setMfaStep("IDLE");
setMfaCode("");
await refreshSession();
} catch {
setMsgMfa({ text: "Código incorrecto. Intenta nuevamente.", type: 'error' });
setMsgMfa({ text: "Código incorrecto.", type: "error" });
} finally {
setLoading(false);
}
};
const handleDisableMfa = async () => {
if (!window.confirm("¿Seguro que deseas desactivar la protección de dos factores? Tu cuenta será menos segura.")) return;
// Flujo Desactivar
const handleInitiateDisable = async () => {
if (
!window.confirm(
"Se enviará un código a tu correo para confirmar la desactivación. ¿Continuar?",
)
)
return;
setLoading(true);
try {
await AuthService.disableMFA();
setMsgMfa({ text: "MFA Desactivado.", type: 'success' });
await AuthService.initiateMfaDisable();
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();
} catch {
setMsgMfa({ text: "Error al desactivar MFA.", type: 'error' });
} catch (err: any) {
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 {
setLoading(false);
}
@@ -80,14 +168,15 @@ export default function ConfigPanel({ user }: { user: UserSession }) {
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* 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">
<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>
<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">
Mantén tu cuenta segura actualizando tu contraseña periódicamente.
</p>
@@ -100,19 +189,25 @@ export default function ConfigPanel({ user }: { user: UserSession }) {
</section>
{/* 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'}`}>
{/* 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'}`}>
{isMfaActive ? 'Protegido' : 'No Activo'}
<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"}`}
>
<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"}`}
>
{isMfaActive ? "Protegido" : "No Activo"}
</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>
<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">
<p className="text-sm text-gray-400 mb-6 max-w-xs mx-auto leading-relaxed">
{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."}
</p>
<div className="mt-auto space-y-3">
<div className="mt-auto space-y-3 w-full">
{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">
{loading ? '...' : 'Desactivar'}
</button>
<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">
Reconfigurar
</button>
</div>
<>
{disableStep === "VERIFY" ? (
/* Verificación para Desactivar */
<div className="animate-fade-in bg-black/30 p-4 rounded-xl border border-red-500/20">
<p className="text-[10px] text-gray-300 mb-2 font-bold uppercase tracking-widest">
Código para Desactivar:
</p>
<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]">
{loading ? 'Cargando...' : 'Activar MFA'}
<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]"
>
{loading ? "Cargando..." : "Activar MFA"}
</button>
)}
{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}
</p>
)}
</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="bg-white p-3 rounded-2xl inline-block mb-4 shadow-xl border-4 border-white">
<QRCodeSVG value={qrUri} size={140} />
</div>
<p className="text-xs text-gray-300 font-bold mb-2">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>
<p className="text-xs text-gray-300 font-bold mb-2">
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">
<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">
<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
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"
title="Copiar"
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
</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
type="text"
placeholder="000 000"
maxLength={6}
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"
/>
<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 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 ? '...' : 'Activar'}
<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
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>
</div>
</div>
@@ -193,4 +397,4 @@ export default function ConfigPanel({ user }: { user: UserSession }) {
)}
</>
);
}
}

View 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>
);
}

View File

@@ -1,16 +1,22 @@
import { useState, useEffect } from 'react';
import { ProfileService } from '../services/profile.service';
import { useAuth } from '../context/AuthContext';
import { useState, useEffect } from "react";
import { ProfileService } from "../services/profile.service";
import { useAuth } from "../context/AuthContext";
import { AuthService } from "../services/auth.service";
export default function PerfilPage() {
const { user, refreshSession } = useAuth();
const [loading, setLoading] = useState(true);
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({
firstName: '',
lastName: '',
phoneNumber: ''
firstName: "",
lastName: "",
phoneNumber: "",
});
useEffect(() => {
@@ -21,9 +27,9 @@ export default function PerfilPage() {
try {
const data = await ProfileService.getProfile();
setFormData({
firstName: data.firstName || '',
lastName: data.lastName || '',
phoneNumber: data.phoneNumber || ''
firstName: data.firstName || "",
lastName: data.lastName || "",
phoneNumber: data.phoneNumber || "",
});
} catch (err) {
console.error("Error loading profile", err);
@@ -37,10 +43,10 @@ export default function PerfilPage() {
setSaving(true);
try {
await ProfileService.updateProfile(formData);
alert('Perfil actualizado con éxito');
alert("Perfil actualizado con éxito");
if (refreshSession) refreshSession();
} catch (err) {
alert('Error al actualizar el perfil');
alert("Error al actualizar el perfil");
} finally {
setSaving(false);
}
@@ -57,8 +63,12 @@ export default function PerfilPage() {
return (
<div className="container mx-auto px-6 py-12 max-w-4xl">
<div className="mb-12">
<h1 className="text-5xl font-black tracking-tighter uppercase mb-2">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>
<h1 className="text-5xl font-black tracking-tighter uppercase mb-2">
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 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">
{user?.username?.[0].toUpperCase()}
</div>
<h2 className="text-xl font-black text-white uppercase tracking-tight">{user?.username}</h2>
<p className="text-xs text-gray-500 font-medium mb-6">{user?.email}</p>
<h2 className="text-xl font-black text-white uppercase tracking-tight">
{user?.username}
</h2>
<p className="text-xs text-gray-500 font-medium mb-6">
{user?.email}
</p>
<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'}`}>
{user?.userType === 3 ? '🛡️ Administrador' : '👤 Usuario Particular'}
<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"}`}
>
{user?.userType === 3
? "🛡️ Administrador"
: "👤 Usuario Particular"}
</span>
</div>
</div>
@@ -81,49 +99,83 @@ export default function PerfilPage() {
{/* Edit Form */}
<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="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
type="text"
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"
placeholder="Tu nombre"
/>
</div>
<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
type="text"
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"
placeholder="Tu apellido"
/>
</div>
<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
type="text"
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"
placeholder="Ej: +54 9 11 1234 5678"
/>
</div>
<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>
<input
type="email"
value={user?.email}
disabled
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"
/>
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">
Email
</label>
<div className="flex gap-2">
<input
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>
@@ -133,12 +185,100 @@ export default function PerfilPage() {
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"
>
{saving ? 'Guardando...' : 'Guardar Cambios'}
{saving ? "Guardando..." : "Guardar Cambios"}
</button>
</div>
</form>
</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>
);
}

View File

@@ -1,4 +1,4 @@
import apiClient from './axios.client';
import apiClient from "./axios.client";
export interface UserSession {
id: number;
@@ -13,76 +13,82 @@ export interface UserSession {
export const AuthService = {
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') {
return { status: 'MIGRATION_REQUIRED', username: response.data.username };
if (response.data.status === "MIGRATION_REQUIRED") {
return { status: "MIGRATION_REQUIRED", username: response.data.username };
}
if (response.data.status === 'MFA_SETUP_REQUIRED') {
if (response.data.status === "MFA_SETUP_REQUIRED") {
return {
status: 'MFA_SETUP_REQUIRED',
status: "MFA_SETUP_REQUIRED",
username: response.data.username,
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 {
status: 'TOTP_REQUIRED',
status: "TOTP_REQUIRED",
username: response.data.username,
recommendMfa: response.data.recommendMfa
recommendMfa: response.data.recommendMfa,
};
}
if (response.data.status === 'SUCCESS') {
localStorage.setItem('userProfile', JSON.stringify(response.data.user));
if (response.data.status === "SUCCESS") {
localStorage.setItem("userProfile", JSON.stringify(response.data.user));
return {
status: 'SUCCESS',
status: "SUCCESS",
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() {
await apiClient.post('/auth/logout');
localStorage.removeItem('userProfile');
await apiClient.post("/auth/logout");
localStorage.removeItem("userProfile");
},
async register(data: any) {
const response = await apiClient.post('/auth/register', data);
const response = await apiClient.post("/auth/register", data);
return response.data;
},
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;
},
async verifyMFA(username: string, code: string) {
const response = await apiClient.post('/auth/verify-mfa', { username, code });
if (response.data.status === 'SUCCESS') {
const response = await apiClient.post("/auth/verify-mfa", {
username,
code,
});
if (response.data.status === "SUCCESS") {
this.setSession(response.data.user, response.data.token);
return response.data.user;
}
},
setSession(user: UserSession, token: string) {
localStorage.setItem('session', JSON.stringify(user));
localStorage.setItem('token', token);
localStorage.setItem("session", JSON.stringify(user));
localStorage.setItem("token", token);
},
async migratePassword(username: string, newPassword: string) {
await apiClient.post('/auth/migrate-password', { username, newPassword });
await apiClient.post("/auth/migrate-password", { username, newPassword });
},
async checkSession() {
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.
if (!response.data) {
@@ -91,53 +97,88 @@ export const AuthService = {
// Sincronizar localStorage con la sesión real del servidor
// 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;
} catch (error) {
// Si falla (token expirado), limpiamos
localStorage.removeItem('userProfile');
localStorage.removeItem("userProfile");
return null;
}
},
getCurrentUser() {
// 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;
},
getToken(): string | null {
return localStorage.getItem('token');
return localStorage.getItem("token");
},
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;
},
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;
},
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;
},
async initMFA() {
const response = await apiClient.post('/auth/init-mfa');
async initiateMfaReconfigure() {
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;
},
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;
},
async disableMFA() {
const response = await apiClient.post('/auth/disable-mfa');
async initiateEmailChange(newEmail: string, mfaCode: string) {
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;
},
};