459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import Image from 'next/image';
|
|
import { twoFactorService, TwoFactorSetupResponse } from '@/services';
|
|
|
|
type Step = 'status' | 'setup' | 'verify' | 'backup-codes' | 'disable';
|
|
|
|
interface TwoFactorSettingsProps {
|
|
onStatusChange?: (enabled: boolean) => void;
|
|
}
|
|
|
|
export function TwoFactorSettings({ onStatusChange }: TwoFactorSettingsProps) {
|
|
const [step, setStep] = useState<Step>('status');
|
|
const [isEnabled, setIsEnabled] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
|
|
// Setup data
|
|
const [setupData, setSetupData] = useState<TwoFactorSetupResponse | null>(null);
|
|
const [verificationCode, setVerificationCode] = useState('');
|
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
const [backupCodesCopied, setBackupCodesCopied] = useState(false);
|
|
|
|
// Disable form
|
|
const [disablePassword, setDisablePassword] = useState('');
|
|
const [showDisablePassword, setShowDisablePassword] = useState(false);
|
|
|
|
// Fetch 2FA status on mount
|
|
useEffect(() => {
|
|
fetchStatus();
|
|
}, []);
|
|
|
|
const fetchStatus = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const response = await twoFactorService.getStatus();
|
|
setIsEnabled(response.data.enabled);
|
|
} catch (err) {
|
|
console.error('Failed to fetch 2FA status:', err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSetup = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const response = await twoFactorService.setup();
|
|
setSetupData(response.data);
|
|
setStep('setup');
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : 'Failed to setup 2FA';
|
|
setError(errorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleVerify = async () => {
|
|
if (verificationCode.length !== 6) {
|
|
setError('Please enter a 6-digit verification code');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const response = await twoFactorService.enable(verificationCode);
|
|
setBackupCodes(response.data.backupCodes);
|
|
setStep('backup-codes');
|
|
setIsEnabled(true);
|
|
onStatusChange?.(true);
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : 'Invalid verification code';
|
|
setError(errorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDisable = async () => {
|
|
if (!disablePassword) {
|
|
setError('Please enter your password');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
await twoFactorService.disable(disablePassword);
|
|
setIsEnabled(false);
|
|
setStep('status');
|
|
setDisablePassword('');
|
|
setSuccess('Two-factor authentication has been disabled');
|
|
onStatusChange?.(false);
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : 'Failed to disable 2FA';
|
|
setError(errorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRegenerateBackupCodes = async () => {
|
|
const password = prompt('Enter your password to regenerate backup codes:');
|
|
if (!password) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const response = await twoFactorService.regenerateBackupCodes(password);
|
|
setBackupCodes(response.data.backupCodes);
|
|
setStep('backup-codes');
|
|
setSuccess('New backup codes generated successfully');
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : 'Failed to regenerate backup codes';
|
|
setError(errorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const copyBackupCodes = () => {
|
|
const codesText = backupCodes.join('\n');
|
|
navigator.clipboard.writeText(codesText);
|
|
setBackupCodesCopied(true);
|
|
setTimeout(() => setBackupCodesCopied(false), 2000);
|
|
};
|
|
|
|
const downloadBackupCodes = () => {
|
|
const codesText = `Re-Quest 2FA Backup Codes\n${'='.repeat(30)}\n\nKeep these codes safe. Each code can only be used once.\n\n${backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}\n\nGenerated: ${new Date().toISOString()}`;
|
|
const blob = new Blob([codesText], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 're-quest-backup-codes.txt';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
// Clear messages after timeout
|
|
useEffect(() => {
|
|
if (success) {
|
|
const timer = setTimeout(() => setSuccess(null), 5000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [success]);
|
|
|
|
if (isLoading && step === 'status') {
|
|
return (
|
|
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
|
|
<div className="animate-pulse">
|
|
<div className="h-5 bg-gray-200 rounded w-48 mb-2"></div>
|
|
<div className="h-4 bg-gray-200 rounded w-64"></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Status view - show current 2FA status
|
|
if (step === 'status') {
|
|
return (
|
|
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-[10px] text-red-700 text-[13px] font-serif">
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-[10px] text-green-700 text-[13px] font-serif">
|
|
{success}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-12 h-12 bg-[#e58625]/10 rounded-[10px] flex items-center justify-center flex-shrink-0">
|
|
<Image
|
|
src="/assets/icons/settings-shield-icon.svg"
|
|
alt="Security"
|
|
width={24}
|
|
height={24}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<h2 className="font-fractul font-bold text-[16px] text-[#00293D] mb-1">
|
|
Two-Factor Authentication
|
|
</h2>
|
|
<p className="font-serif text-[12px] text-[#00293D]/60 mb-2">
|
|
Add an extra layer of security to your account using an authenticator app
|
|
</p>
|
|
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-[11px] font-medium ${
|
|
isEnabled
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
<span className={`w-2 h-2 rounded-full ${isEnabled ? 'bg-green-500' : 'bg-gray-400'}`}></span>
|
|
{isEnabled ? 'Enabled' : 'Not Enabled'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isEnabled ? (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleRegenerateBackupCodes}
|
|
disabled={isLoading}
|
|
className="px-4 py-2 border border-[#00293D]/20 text-[#00293D] rounded-[15px] font-serif text-[12px] hover:bg-gray-50 transition-colors disabled:opacity-50"
|
|
>
|
|
Backup Codes
|
|
</button>
|
|
<button
|
|
onClick={() => setStep('disable')}
|
|
disabled={isLoading}
|
|
className="px-4 py-2 border border-red-300 text-red-600 rounded-[15px] font-serif text-[12px] hover:bg-red-50 transition-colors disabled:opacity-50"
|
|
>
|
|
Disable
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={handleSetup}
|
|
disabled={isLoading}
|
|
className="px-6 py-2 bg-[#e58625] text-white rounded-[15px] font-serif text-[12px] hover:bg-[#d47920] transition-colors disabled:opacity-50"
|
|
>
|
|
{isLoading ? 'Loading...' : 'Enable 2FA'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Setup view - show QR code
|
|
if (step === 'setup' && setupData) {
|
|
return (
|
|
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
|
|
<div className="mb-6">
|
|
<h2 className="font-fractul font-bold text-[16px] text-[#00293D] mb-2">
|
|
Setup Two-Factor Authentication
|
|
</h2>
|
|
<p className="font-serif text-[12px] text-[#00293D]/60">
|
|
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col md:flex-row gap-8">
|
|
{/* QR Code */}
|
|
<div className="flex flex-col items-center">
|
|
<div className="p-4 bg-white border border-gray-200 rounded-[15px] mb-4">
|
|
<img
|
|
src={setupData.qrCode}
|
|
alt="2FA QR Code"
|
|
className="w-48 h-48"
|
|
/>
|
|
</div>
|
|
<p className="text-[11px] text-[#00293D]/50 font-serif text-center max-w-[200px]">
|
|
Can't scan? Enter this code manually:
|
|
</p>
|
|
<code className="mt-2 px-3 py-2 bg-gray-100 rounded-[8px] text-[12px] font-mono text-[#00293D] select-all">
|
|
{setupData.manualEntry}
|
|
</code>
|
|
</div>
|
|
|
|
{/* Verification */}
|
|
<div className="flex-1">
|
|
<div className="mb-6">
|
|
<h3 className="font-fractul font-semibold text-[14px] text-[#00293D] mb-2">
|
|
Verify Setup
|
|
</h3>
|
|
<p className="font-serif text-[12px] text-[#00293D]/60 mb-4">
|
|
Enter the 6-digit code from your authenticator app to verify setup
|
|
</p>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-[10px] text-red-700 text-[13px] font-serif">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 mb-4">
|
|
<input
|
|
type="text"
|
|
value={verificationCode}
|
|
onChange={(e) => {
|
|
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
|
setVerificationCode(value);
|
|
setError(null);
|
|
}}
|
|
placeholder="000000"
|
|
maxLength={6}
|
|
className="w-32 h-[40px] px-4 border border-[#00293D]/20 rounded-[15px] text-[18px] font-mono text-center text-[#00293D] placeholder:text-[#00293D]/30 focus:outline-none focus:border-[#E58625] tracking-[0.3em]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => {
|
|
setStep('status');
|
|
setSetupData(null);
|
|
setVerificationCode('');
|
|
setError(null);
|
|
}}
|
|
className="px-6 py-2 border border-[#00293D]/20 text-[#00293D] rounded-[15px] font-serif text-[12px] hover:bg-gray-50 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleVerify}
|
|
disabled={isLoading || verificationCode.length !== 6}
|
|
className="px-6 py-2 bg-[#e58625] text-white rounded-[15px] font-serif text-[12px] hover:bg-[#d47920] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isLoading ? 'Verifying...' : 'Verify & Enable'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Backup codes view
|
|
if (step === 'backup-codes') {
|
|
return (
|
|
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
|
|
<div className="mb-6">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="text-green-500 text-xl">✓</span>
|
|
<h2 className="font-fractul font-bold text-[16px] text-[#00293D]">
|
|
Two-Factor Authentication Enabled
|
|
</h2>
|
|
</div>
|
|
<p className="font-serif text-[12px] text-[#00293D]/60">
|
|
Save these backup codes in a secure place. You can use them to access your account if you lose your phone.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 border border-gray-200 rounded-[15px] p-4 mb-6">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
{backupCodes.map((code, index) => (
|
|
<div
|
|
key={index}
|
|
className="px-3 py-2 bg-white border border-gray-200 rounded-[8px] text-center font-mono text-[13px] text-[#00293D]"
|
|
>
|
|
{code}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mb-6">
|
|
<p className="text-[11px] text-red-600 font-serif">
|
|
Each code can only be used once. Store them safely!
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={copyBackupCodes}
|
|
className="px-4 py-2 border border-[#00293D]/20 text-[#00293D] rounded-[15px] font-serif text-[12px] hover:bg-gray-50 transition-colors"
|
|
>
|
|
{backupCodesCopied ? 'Copied!' : 'Copy All'}
|
|
</button>
|
|
<button
|
|
onClick={downloadBackupCodes}
|
|
className="px-4 py-2 border border-[#00293D]/20 text-[#00293D] rounded-[15px] font-serif text-[12px] hover:bg-gray-50 transition-colors"
|
|
>
|
|
Download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => {
|
|
setStep('status');
|
|
setBackupCodes([]);
|
|
setSetupData(null);
|
|
setVerificationCode('');
|
|
}}
|
|
className="px-6 py-2 bg-[#e58625] text-white rounded-[15px] font-serif text-[12px] hover:bg-[#d47920] transition-colors"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Disable view
|
|
if (step === 'disable') {
|
|
return (
|
|
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
|
|
<div className="mb-6">
|
|
<h2 className="font-fractul font-bold text-[16px] text-[#00293D] mb-2">
|
|
Disable Two-Factor Authentication
|
|
</h2>
|
|
<p className="font-serif text-[12px] text-[#00293D]/60">
|
|
Enter your password to confirm you want to disable 2FA. This will make your account less secure.
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-[10px] text-red-700 text-[13px] font-serif">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="max-w-md mb-6">
|
|
<label className="block text-[12px] font-medium text-[#00293D]/70 font-serif mb-2">
|
|
Your Password
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showDisablePassword ? 'text' : 'password'}
|
|
value={disablePassword}
|
|
onChange={(e) => {
|
|
setDisablePassword(e.target.value);
|
|
setError(null);
|
|
}}
|
|
placeholder="Enter your password"
|
|
className="w-full h-[40px] px-4 pr-12 border border-[#00293D]/20 rounded-[15px] text-[14px] font-serif text-[#00293D] placeholder:text-[#00293D]/40 focus:outline-none focus:border-[#E58625]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDisablePassword(!showDisablePassword)}
|
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-[#00293D]/50 hover:text-[#00293D] cursor-pointer"
|
|
>
|
|
{showDisablePassword ? '🙈' : '👁️'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => {
|
|
setStep('status');
|
|
setDisablePassword('');
|
|
setError(null);
|
|
}}
|
|
className="px-6 py-2 border border-[#00293D]/20 text-[#00293D] rounded-[15px] font-serif text-[12px] hover:bg-gray-50 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleDisable}
|
|
disabled={isLoading || !disablePassword}
|
|
className="px-6 py-2 bg-red-600 text-white rounded-[15px] font-serif text-[12px] hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isLoading ? 'Disabling...' : 'Disable 2FA'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|