feat: Add 2FA frontend components (TwoFactorSetup, TwoFactorVerifyModal, TwoFactorSettings)
Created three complete 2FA components integrating with existing backend endpoints: 1. TwoFactorSetup.tsx (auth/components) - 3-step wizard for 2FA setup - QR code display with manual entry fallback - TOTP code verification - Backup codes display with copy functionality - Integrates with POST /auth/2fa/setup and /auth/2fa/enable 2. TwoFactorVerifyModal.tsx (auth/components) - Modal for 2FA verification during login - Support for both TOTP (6 digits) and backup codes (8 digits) - Auto-submit for TOTP codes - Switch between code types - Auto-focus input field 3. TwoFactorSettings.tsx (settings/components) - Management panel for 2FA in settings - Enable/disable 2FA with confirmation - Regenerate backup codes - Status display with activation date - Integrates with all 2FA endpoints All components follow project patterns: - Tailwind CSS styling matching existing components - lucide-react icons - Centralized apiClient with auto-refresh - Complete TypeScript types - No placeholders or TODOs Related: GAP-P1-004 2FA Frontend Flow Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
190decd498
commit
261dc4c71c
364
src/modules/auth/components/TwoFactorSetup.tsx
Normal file
364
src/modules/auth/components/TwoFactorSetup.tsx
Normal file
@ -0,0 +1,364 @@
|
||||
/**
|
||||
* TwoFactorSetup Component
|
||||
* Wizard for setting up 2FA with QR code and backup codes
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Shield, Smartphone, Copy, CheckCircle, AlertCircle, Loader2, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||
import { apiClient } from '../../../lib/apiClient';
|
||||
|
||||
interface TwoFactorSetupProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface SetupData {
|
||||
secret: string;
|
||||
qrCode: string;
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
type Step = 'qr' | 'verify' | 'backup';
|
||||
|
||||
export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [step, setStep] = useState<Step>('qr');
|
||||
const [setupData, setSetupData] = useState<SetupData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [copiedCodes, setCopiedCodes] = useState(false);
|
||||
|
||||
// Fetch QR code when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchSetupData();
|
||||
setStep('qr');
|
||||
setTotpCode('');
|
||||
setError(null);
|
||||
setCopiedCodes(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchSetupData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.post('/auth/2fa/setup');
|
||||
if (response.data.success && response.data.data) {
|
||||
setSetupData(response.data.data);
|
||||
} else {
|
||||
throw new Error('Invalid setup response');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch 2FA setup data:', err);
|
||||
setError('Error al generar codigo QR. Intenta de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
if (totpCode.length !== 6) {
|
||||
setError('Ingresa un codigo de 6 digitos');
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifying(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/auth/2fa/enable', {
|
||||
token: totpCode,
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setStep('backup');
|
||||
} else {
|
||||
throw new Error('Invalid verification response');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to verify 2FA code:', err);
|
||||
const errorMessage = err?.response?.data?.message || 'Codigo invalido. Verifica el codigo en tu app de autenticacion.';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyBackupCodes = () => {
|
||||
if (setupData?.backupCodes) {
|
||||
const codesText = setupData.backupCodes.join('\n');
|
||||
navigator.clipboard.writeText(codesText);
|
||||
setCopiedCodes(true);
|
||||
setTimeout(() => setCopiedCodes(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinish = () => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||
setTotpCode(value);
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800 rounded-xl w-full max-w-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Configurar Autenticacion 2FA</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
{step === 'qr' && 'Paso 1 de 3: Escanea el codigo QR'}
|
||||
{step === 'verify' && 'Paso 2 de 3: Verifica el codigo'}
|
||||
{step === 'backup' && 'Paso 3 de 3: Guarda tus codigos de respaldo'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
|
||||
<p className="text-slate-400">Generando codigo QR...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Step 1: QR Code */}
|
||||
{step === 'qr' && setupData && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-slate-900 rounded-lg p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<Smartphone className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">Configura tu app de autenticacion</h3>
|
||||
<ol className="text-sm text-slate-400 space-y-2 list-decimal list-inside">
|
||||
<li>Descarga una app de autenticacion (Google Authenticator, Authy, etc.)</li>
|
||||
<li>Abre la app y selecciona "Agregar cuenta"</li>
|
||||
<li>Escanea el codigo QR a continuacion</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div className="flex flex-col items-center justify-center py-6 bg-white rounded-lg">
|
||||
<img
|
||||
src={setupData.qrCode}
|
||||
alt="QR Code para 2FA"
|
||||
className="w-64 h-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Manual Entry */}
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<p className="text-xs text-slate-400 mb-2">
|
||||
O ingresa manualmente este codigo:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white font-mono break-all">
|
||||
{setupData.secret}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(setupData.secret);
|
||||
}}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="Copiar codigo"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-900/20 border border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('verify')}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
Siguiente
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Verify Code */}
|
||||
{step === 'verify' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-slate-900 rounded-lg p-6 text-center">
|
||||
<div className="w-16 h-16 bg-blue-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-white font-medium mb-2">Verifica tu configuracion</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Ingresa el codigo de 6 digitos que aparece en tu app de autenticacion
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-2">Codigo TOTP</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={handleCodeChange}
|
||||
placeholder="000000"
|
||||
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg text-white text-center text-2xl font-mono tracking-widest focus:outline-none focus:border-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-900/20 border border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('qr')}
|
||||
className="flex-1 py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
Atras
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVerifyCode}
|
||||
disabled={verifying || totpCode.length !== 6}
|
||||
className="flex-1 py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{verifying ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Verificando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Verificar
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Backup Codes */}
|
||||
{step === 'backup' && setupData && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-emerald-900/20 border border-emerald-800 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-1">2FA activado exitosamente</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Guarda estos codigos de respaldo en un lugar seguro. Los necesitaras si pierdes acceso a tu app de autenticacion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-white font-medium">Codigos de Respaldo</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyBackupCodes}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-sm transition-colors"
|
||||
>
|
||||
{copiedCodes ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-emerald-400" />
|
||||
Copiado
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4" />
|
||||
Copiar todos
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{setupData.backupCodes.map((code, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg"
|
||||
>
|
||||
<code className="text-sm text-white font-mono">{code}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-amber-900/20 border border-amber-800 rounded-lg">
|
||||
<p className="text-xs text-amber-400">
|
||||
<strong>Importante:</strong> Cada codigo solo puede usarse una vez. Guardalo en un lugar seguro y accesible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFinish}
|
||||
className="w-full py-3 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
Finalizar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!loading && (
|
||||
<div className="px-6 pb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full py-2 text-slate-400 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorSetup;
|
||||
234
src/modules/auth/components/TwoFactorVerifyModal.tsx
Normal file
234
src/modules/auth/components/TwoFactorVerifyModal.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
/**
|
||||
* TwoFactorVerifyModal Component
|
||||
* Modal for verifying 2FA code during login
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Shield, AlertCircle, Loader2, Key } from 'lucide-react';
|
||||
import { apiClient } from '../../../lib/apiClient';
|
||||
|
||||
interface TwoFactorVerifyModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onVerify: (token: string) => Promise<void>;
|
||||
sessionToken: string;
|
||||
}
|
||||
|
||||
type InputMode = 'totp' | 'backup';
|
||||
|
||||
export const TwoFactorVerifyModal: React.FC<TwoFactorVerifyModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onVerify,
|
||||
sessionToken,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<InputMode>('totp');
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMode('totp');
|
||||
setCode('');
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-submit when TOTP code is complete
|
||||
useEffect(() => {
|
||||
if (mode === 'totp' && code.length === 6 && !loading) {
|
||||
handleSubmit();
|
||||
}
|
||||
}, [code, mode]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const expectedLength = mode === 'totp' ? 6 : 8;
|
||||
|
||||
if (code.length !== expectedLength) {
|
||||
setError(`Ingresa un codigo de ${expectedLength} digitos`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onVerify(code);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to verify 2FA:', err);
|
||||
const errorMessage = err?.response?.data?.message ||
|
||||
mode === 'totp'
|
||||
? 'Codigo TOTP invalido. Intenta de nuevo.'
|
||||
: 'Codigo de respaldo invalido o ya usado.';
|
||||
setError(errorMessage);
|
||||
setCode('');
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const maxLength = mode === 'totp' ? 6 : 8;
|
||||
const value = e.target.value.replace(/\D/g, '').slice(0, maxLength);
|
||||
setCode(value);
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const switchMode = () => {
|
||||
setMode(mode === 'totp' ? 'backup' : 'totp');
|
||||
setCode('');
|
||||
setError(null);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800 rounded-xl w-full max-w-md overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-6 border-b border-slate-700">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Verificacion de Dos Factores</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
{mode === 'totp'
|
||||
? 'Ingresa el codigo de tu app de autenticacion'
|
||||
: 'Ingresa uno de tus codigos de respaldo'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-blue-500/20 rounded-full flex items-center justify-center">
|
||||
{mode === 'totp' ? (
|
||||
<Shield className="w-8 h-8 text-blue-400" />
|
||||
) : (
|
||||
<Key className="w-8 h-8 text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Input */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-2 text-center">
|
||||
{mode === 'totp' ? 'Codigo TOTP (6 digitos)' : 'Codigo de Respaldo (8 digitos)'}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={mode === 'totp' ? 6 : 8}
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={mode === 'totp' ? '000000' : '00000000'}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-4 bg-slate-900 border border-slate-700 rounded-lg text-white text-center text-2xl font-mono tracking-widest focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-900/20 border border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 text-blue-400">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-sm">Verificando...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button (for backup code mode) */}
|
||||
{mode === 'backup' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || code.length !== 8}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Verificando...
|
||||
</>
|
||||
) : (
|
||||
'Verificar'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Switch Mode Link */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchMode}
|
||||
disabled={loading}
|
||||
className="text-sm text-blue-400 hover:text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{mode === 'totp'
|
||||
? 'Usar codigo de respaldo'
|
||||
: 'Usar codigo de autenticacion'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
{mode === 'totp' ? (
|
||||
<>
|
||||
El codigo cambia cada 30 segundos en tu app de autenticacion (Google Authenticator, Authy, etc.)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Los codigos de respaldo se generaron cuando activaste 2FA. Cada codigo solo puede usarse una vez.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 pb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="w-full py-2 text-slate-400 hover:text-white text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorVerifyModal;
|
||||
406
src/modules/settings/components/TwoFactorSettings.tsx
Normal file
406
src/modules/settings/components/TwoFactorSettings.tsx
Normal file
@ -0,0 +1,406 @@
|
||||
/**
|
||||
* TwoFactorSettings Component
|
||||
* Panel for managing 2FA settings in user settings page
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Shield, AlertCircle, CheckCircle, Loader2, RefreshCw, ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||
import { apiClient } from '../../../lib/apiClient';
|
||||
import TwoFactorSetup from '../../auth/components/TwoFactorSetup';
|
||||
|
||||
interface TwoFactorStatus {
|
||||
enabled: boolean;
|
||||
enabledAt?: string;
|
||||
}
|
||||
|
||||
export const TwoFactorSettings: React.FC = () => {
|
||||
const [status, setStatus] = useState<TwoFactorStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSetup, setShowSetup] = useState(false);
|
||||
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
const [disabling, setDisabling] = useState(false);
|
||||
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [regeneratedCodes, setRegeneratedCodes] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get('/auth/2fa/status');
|
||||
if (response.data.success && response.data.data) {
|
||||
setStatus(response.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch 2FA status:', err);
|
||||
setError('Error al cargar el estado de 2FA');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnableSuccess = () => {
|
||||
setShowSetup(false);
|
||||
fetchStatus();
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (disableCode.length !== 6) {
|
||||
setError('Ingresa un codigo de 6 digitos');
|
||||
return;
|
||||
}
|
||||
|
||||
setDisabling(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/auth/2fa/disable', {
|
||||
token: disableCode,
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setShowDisableConfirm(false);
|
||||
setDisableCode('');
|
||||
await fetchStatus();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to disable 2FA:', err);
|
||||
const errorMessage = err?.response?.data?.message || 'Error al desactivar 2FA. Verifica el codigo.';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setDisabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateBackupCodes = async () => {
|
||||
setRegenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/auth/2fa/backup-codes');
|
||||
if (response.data.success && response.data.data?.backupCodes) {
|
||||
setRegeneratedCodes(response.data.data.backupCodes);
|
||||
setShowRegenerateConfirm(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to regenerate backup codes:', err);
|
||||
const errorMessage = err?.response?.data?.message || 'Error al regenerar codigos de respaldo';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyRegeneratedCodes = () => {
|
||||
if (regeneratedCodes) {
|
||||
const codesText = regeneratedCodes.join('\n');
|
||||
navigator.clipboard.writeText(codesText);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseRegeneratedCodes = () => {
|
||||
setRegeneratedCodes(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Autenticacion de Dos Factores</h2>
|
||||
<p className="text-sm text-slate-400">Cargando configuracion...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Autenticacion de Dos Factores</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
Agrega una capa extra de seguridad a tu cuenta
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status?.enabled && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-500/20 rounded-full">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-sm font-medium text-emerald-400">Activo</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-900/20 border border-red-800 rounded-lg mb-4">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.enabled ? (
|
||||
<div className="space-y-4">
|
||||
{/* Enabled Status */}
|
||||
<div className="bg-emerald-900/20 border border-emerald-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">2FA esta activo</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
Tu cuenta esta protegida con autenticacion de dos factores.
|
||||
{status.enabledAt && (
|
||||
<> Activado el {new Date(status.enabledAt).toLocaleDateString()}.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="grid gap-3">
|
||||
{/* Regenerate Backup Codes */}
|
||||
{!showRegenerateConfirm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRegenerateConfirm(true)}
|
||||
className="flex items-center justify-between p-4 bg-slate-700 hover:bg-slate-600 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<RefreshCw className="w-5 h-5 text-blue-400" />
|
||||
<div className="text-left">
|
||||
<p className="text-white font-medium">Regenerar Codigos de Respaldo</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
Genera nuevos codigos si perdiste los anteriores
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="p-4 bg-amber-900/20 border border-amber-800 rounded-lg space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">Confirmar regeneracion</p>
|
||||
<p className="text-sm text-amber-400">
|
||||
Esto invalidara todos tus codigos de respaldo anteriores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
disabled={regenerating}
|
||||
className="flex-1 py-2 bg-amber-600 hover:bg-amber-500 disabled:bg-slate-600 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{regenerating ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Regenerando...
|
||||
</span>
|
||||
) : (
|
||||
'Confirmar'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRegenerateConfirm(false)}
|
||||
disabled={regenerating}
|
||||
className="flex-1 py-2 bg-slate-700 hover:bg-slate-600 disabled:cursor-not-allowed text-slate-300 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable 2FA */}
|
||||
{!showDisableConfirm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDisableConfirm(true)}
|
||||
className="flex items-center justify-between p-4 bg-red-900/20 border border-red-800 hover:bg-red-900/30 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldAlert className="w-5 h-5 text-red-400" />
|
||||
<div className="text-left">
|
||||
<p className="text-white font-medium">Desactivar 2FA</p>
|
||||
<p className="text-sm text-red-400">
|
||||
Reduce la seguridad de tu cuenta
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="p-4 bg-red-900/20 border border-red-800 rounded-lg space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">Confirmar desactivacion</p>
|
||||
<p className="text-sm text-red-400">
|
||||
Ingresa tu codigo TOTP actual para desactivar 2FA
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||
setDisableCode(value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder="000000"
|
||||
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg text-white text-center text-lg font-mono tracking-widest focus:outline-none focus:border-red-500"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisable}
|
||||
disabled={disabling || disableCode.length !== 6}
|
||||
className="flex-1 py-2 bg-red-600 hover:bg-red-500 disabled:bg-slate-600 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{disabling ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Desactivando...
|
||||
</span>
|
||||
) : (
|
||||
'Desactivar'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowDisableConfirm(false);
|
||||
setDisableCode('');
|
||||
setError(null);
|
||||
}}
|
||||
disabled={disabling}
|
||||
className="flex-1 py-2 bg-slate-700 hover:bg-slate-600 disabled:cursor-not-allowed text-slate-300 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Disabled Status */}
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldAlert className="w-5 h-5 text-slate-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">2FA no esta activo</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
Protege tu cuenta con autenticacion de dos factores. Necesitaras una app de autenticacion como Google Authenticator o Authy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enable Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSetup(true)}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
Activar 2FA
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-400">
|
||||
<strong>Recomendado:</strong> La autenticacion de dos factores agrega una capa adicional de seguridad requiriendo un codigo temporal ademas de tu contrasena.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Setup Modal */}
|
||||
<TwoFactorSetup
|
||||
isOpen={showSetup}
|
||||
onClose={() => setShowSetup(false)}
|
||||
onSuccess={handleEnableSuccess}
|
||||
/>
|
||||
|
||||
{/* Regenerated Codes Modal */}
|
||||
{regeneratedCodes && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800 rounded-xl w-full max-w-md overflow-hidden">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">Nuevos Codigos Generados</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Guarda estos codigos en un lugar seguro. Los anteriores ya no funcionan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{regeneratedCodes.map((code, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg"
|
||||
>
|
||||
<code className="text-sm text-white font-mono">{code}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyRegeneratedCodes}
|
||||
className="flex-1 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Copiar Todos
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseRegeneratedCodes}
|
||||
className="flex-1 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorSettings;
|
||||
Loading…
Reference in New Issue
Block a user