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