import React, { useState, useEffect, useRef } from 'react'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Alert, AlertDescription } from '../ui/alert'; import { AlertCircle, Loader2 } from 'lucide-react'; import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png'; import { Route } from '../../types/routes'; interface TwoFactorAuthProps { onLogin: () => void; onNavigate: (route: Route) => void; } export function TwoFactorAuth({ onLogin, onNavigate }: TwoFactorAuthProps) { const [verificationCode, setVerificationCode] = useState(['', '', '', '', '', '']); const [isLoading, setIsLoading] = useState(false); const [resendCooldown, setResendCooldown] = useState(60); const [error, setError] = useState(''); const [attempts, setAttempts] = useState(0); const [maskedEmail, setMaskedEmail] = useState(''); const otpRefs = useRef<(HTMLInputElement | null)[]>([]); // Initialize refs array useEffect(() => { otpRefs.current = otpRefs.current.slice(0, 6); }, []); // Get masked email from login context useEffect(() => { const loginContext = sessionStorage.getItem('login_context'); if (loginContext) { const { email } = JSON.parse(loginContext); setMaskedEmail(email); } else { setMaskedEmail('ad***@klc.edu'); } }, []); // Cooldown timer effect useEffect(() => { if (resendCooldown > 0) { const timer = setTimeout(() => setResendCooldown(resendCooldown - 1), 1000); return () => clearTimeout(timer); } }, [resendCooldown]); // Focus first input on mount useEffect(() => { const timer = setTimeout(() => { if (otpRefs.current[0]) { otpRefs.current[0].focus(); } }, 100); return () => clearTimeout(timer); }, []); const handleVerifyCode = async (e: React.FormEvent) => { e.preventDefault(); setError(''); const code = verificationCode.join(''); if (code.length !== 6) { setError('Please enter the complete 6-digit code'); return; } setIsLoading(true); setTimeout(() => { if (code === '000000') { setError('Code expired. Request a new one.'); } else if (code === '999999') { setError('Too many attempts. Please wait before retrying.'); } else if (code === '123456') { sessionStorage.removeItem('login_context'); onLogin(); } else { const newAttempts = attempts + 1; setAttempts(newAttempts); if (newAttempts >= 3) { setError('Too many attempts. Please wait before retrying.'); } else { setError('Incorrect code.'); } setVerificationCode(['', '', '', '', '', '']); if (otpRefs.current[0]) { otpRefs.current[0].focus(); } } setIsLoading(false); }, 1200); }; const handleCodeChange = (index: number, value: string) => { // Clear errors when user starts typing if (error) { setError(''); } // Only allow digits if (value && !/^\d+$/.test(value)) { return; } // Handle single digit input if (value.length <= 1) { const newCode = [...verificationCode]; newCode[index] = value; setVerificationCode(newCode); // Auto-focus next input if a digit was entered if (value && index < 5) { const nextInput = otpRefs.current[index + 1]; if (nextInput) { // Use setTimeout to ensure the state update has happened setTimeout(() => { nextInput.focus(); }, 10); } } } }; const handleKeyDown = (index: number, e: React.KeyboardEvent) => { // Handle backspace if (e.key === 'Backspace') { if (!verificationCode[index] && index > 0) { // Move to previous input if current is empty const prevInput = otpRefs.current[index - 1]; if (prevInput) { prevInput.focus(); } } else if (verificationCode[index]) { // Clear current input but stay focused const newCode = [...verificationCode]; newCode[index] = ''; setVerificationCode(newCode); } } // Handle arrow keys else if (e.key === 'ArrowLeft' && index > 0) { const prevInput = otpRefs.current[index - 1]; if (prevInput) { prevInput.focus(); } } else if (e.key === 'ArrowRight' && index < 5) { const nextInput = otpRefs.current[index + 1]; if (nextInput) { nextInput.focus(); } } }; const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault(); const pastedData = e.clipboardData.getData('text'); const digits = pastedData.replace(/\D/g, '').slice(0, 6).split(''); if (digits.length === 6) { const newCode = [...verificationCode]; digits.forEach((digit, i) => { newCode[i] = digit; }); setVerificationCode(newCode); // Focus the last input after paste setTimeout(() => { if (otpRefs.current[5]) { otpRefs.current[5].focus(); } }, 10); } }; const handleResendCode = () => { if (resendCooldown === 0 && attempts < 5) { setResendCooldown(60); setAttempts(0); setError(''); setVerificationCode(['', '', '', '', '', '']); // Focus first input after resend setTimeout(() => { if (otpRefs.current[0]) { otpRefs.current[0].focus(); } }, 100); } }; const isFormValid = verificationCode.join('').length === 6; const tooManyAttempts = attempts >= 3; return (
{/* Brand Bar */}
KLC Admin
{/* Main Content - Centered Auth Card */}
Enter the 6-digit code
We sent a code to {maskedEmail}. It expires in 10 minutes.
{/* Error Alert */} {error && ( {error} )} {/* OTP Input Fields */}
Enter 6-digit verification code {verificationCode.map((digit, index) => ( { otpRefs.current[index] = el; }} type="text" inputMode="numeric" pattern="[0-9]*" maxLength={1} value={digit} onChange={(e) => handleCodeChange(index, e.target.value)} onKeyDown={(e) => handleKeyDown(index, e)} onPaste={index === 0 ? handlePaste : undefined} className="w-12 h-12 text-center font-mono text-lg focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50" aria-label={`Digit ${index + 1} of 6`} aria-describedby={error ? "code-error" : undefined} aria-invalid={!!error} disabled={tooManyAttempts} autoComplete="one-time-code" /> ))}
{/* Inline error display */} {error && ( )}
{/* Verify Button */} {/* Action Links */}
{/* Legal Footer */}
); }