feat: Implement a comprehensive two-factor authentication system including setup, verification, backup codes, and integration into the login flow.

This commit is contained in:
pradeepkumar
2026-02-06 09:28:58 +05:30
parent 719b784dfe
commit 5eb0934570
10 changed files with 946 additions and 14 deletions

View File

@@ -35,6 +35,17 @@ export default function LoginPage() {
return;
}
// Check if 2FA is required (response is wrapped in { success, data })
if (validateData.data?.requiresTwoFactor) {
// Redirect to 2FA verification page with tempToken
const params = new URLSearchParams({
token: validateData.data.tempToken,
email: email,
});
router.push(`/login/verify-2fa?${params.toString()}`);
return;
}
// If validation passed, use NextAuth to create session
const result = await signIn('credentials', {
email,

View File

@@ -0,0 +1,266 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { signIn } from 'next-auth/react';
import Link from 'next/link';
import { twoFactorService } from '@/services';
export default function VerifyTwoFactorPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [code, setCode] = useState(['', '', '', '', '', '']);
const [backupCode, setBackupCode] = useState('');
const [useBackup, setUseBackup] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
// Get tempToken and email from URL params
const tempToken = searchParams.get('token') || '';
const userEmail = searchParams.get('email') || '';
useEffect(() => {
// If no token, redirect to login
if (!tempToken) {
router.push('/login');
}
}, [tempToken, router]);
const handleCodeChange = (index: number, value: string) => {
// Only allow single digit
if (value.length > 1) {
value = value.slice(-1);
}
// Only allow numbers
if (value && !/^\d$/.test(value)) {
return;
}
const newCode = [...code];
newCode[index] = value;
setCode(newCode);
// Auto-focus next input
if (value && index < 5) {
inputRefs.current[index + 1]?.focus();
}
// Auto-submit when all digits are entered
if (value && index === 5 && newCode.every((digit) => digit !== '')) {
handleSubmit(newCode.join(''));
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
if (pastedData.length === 6) {
const newCode = pastedData.split('');
setCode(newCode);
inputRefs.current[5]?.focus();
// Auto-submit after paste
setTimeout(() => handleSubmit(pastedData), 100);
}
};
const handleSubmit = async (codeValue?: string) => {
const verificationCode = codeValue || code.join('');
if (!useBackup && verificationCode.length !== 6) {
setError('Please enter a 6-digit code');
return;
}
if (useBackup && !backupCode.trim()) {
setError('Please enter a backup code');
return;
}
setLoading(true);
setError('');
try {
let response;
if (useBackup) {
response = await twoFactorService.verifyBackupCode({
tempToken,
backupCode: backupCode.trim(),
});
} else {
response = await twoFactorService.verifyLogin({
tempToken,
token: verificationCode,
});
}
// Now sign in with NextAuth using the tokens we received
// We need to call the NextAuth credentials provider with the tokens
const result = await signIn('credentials', {
email: userEmail,
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
redirect: false,
});
if (result?.error) {
setError('Failed to complete login. Please try again.');
setLoading(false);
} else {
router.push('/');
router.refresh();
}
} catch (err) {
setLoading(false);
if (err instanceof Error) {
setError(err.message);
} else {
setError('Invalid code. Please try again.');
}
// Clear code on error
if (!useBackup) {
setCode(['', '', '', '', '', '']);
inputRefs.current[0]?.focus();
}
}
};
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleSubmit();
};
if (!tempToken) {
return null; // Will redirect in useEffect
}
return (
<>
{/* Heading */}
<div className="text-center mb-6">
<div className="w-16 h-16 mx-auto mb-4 bg-[#f0f5fc] rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-[#e58625]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h2 className="text-[30px] font-normal text-[#00293d] mb-3">Two-Factor Authentication</h2>
<p className="text-sm text-[#00293d] px-4 leading-relaxed">
{useBackup
? 'Enter one of your backup codes to continue'
: 'Enter the 6-digit code from your authenticator app'}
</p>
</div>
{/* Form */}
<form onSubmit={handleFormSubmit} className="space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-[20px] text-sm">
{error}
</div>
)}
{!useBackup ? (
/* 6-digit code input */
<div className="flex justify-center gap-3" onPaste={handlePaste}>
{code.map((digit, index) => (
<input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
className="w-12 h-14 text-center text-xl font-bold bg-[#f0f5fc] rounded-[15px] text-[#00293d] focus:outline-none focus:ring-2 focus:ring-[#e58625] transition-all"
disabled={loading}
/>
))}
</div>
) : (
/* Backup code input */
<div>
<input
type="text"
placeholder="Enter backup code"
value={backupCode}
onChange={(e) => setBackupCode(e.target.value.toUpperCase())}
className="w-full px-6 py-6 bg-[#f0f5fc] rounded-[20px] text-[#00293d] text-sm font-light placeholder:text-[#00293d] focus:outline-none focus:ring-2 focus:ring-[#00293d]/20 transition-all text-center tracking-widest"
disabled={loading}
/>
</div>
)}
{/* Switch between code and backup */}
<div className="text-center">
<button
type="button"
onClick={() => {
setUseBackup(!useBackup);
setError('');
setCode(['', '', '', '', '', '']);
setBackupCode('');
}}
className="text-[#00293d] text-sm font-light hover:underline"
>
{useBackup ? 'Use authenticator code instead' : 'Use a backup code'}
</button>
</div>
{/* Verify Button */}
<button
type="submit"
disabled={loading}
className="w-full py-6 bg-[#e58625] hover:bg-[#d47a1f] text-[#00293d] font-bold text-sm rounded-[20px] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Verifying...' : 'Verify'}
</button>
</form>
{/* Back to Login */}
<div className="text-center mt-8">
<Link href="/login" className="text-[#00293d] text-sm font-light hover:underline">
Back to Login
</Link>
</div>
{/* Help text */}
<div className="mt-6 p-4 bg-[#f0f5fc] rounded-[15px]">
<p className="text-xs text-[#00293d]/70 text-center">
{useBackup ? (
<>
Backup codes are single-use. After using one, it will be invalidated. If you&apos;ve
used all your backup codes, you&apos;ll need to generate new ones from your account
settings.
</>
) : (
<>
Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit
code shown for Re-Quest.
</>
)}
</p>
</div>
</>
);
}