This repository has been archived on 2026-04-09. You can view files and clone it, but cannot push or open issues or pull requests.
Files
KLC-Admin-Panel-Frontend-Fi…/src/components/auth/TwoFactorAuth.tsx
priyanshuvish bb3c138c25 fix issues
2025-10-31 13:51:16 +05:30

354 lines
12 KiB
TypeScript

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<HTMLInputElement>) => {
// 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<HTMLInputElement>) => {
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 (
<div className="min-h-screen flex flex-col bg-background">
{/* Brand Bar */}
<header className="w-full bg-background border-b" style={{ height: '64px' }}>
<div className="max-w-[1440px] mx-auto px-6 h-full flex items-center">
<img
src={klcLogoLight}
alt="KLC Admin"
className="h-8"
/>
</div>
</header>
{/* Main Content - Centered Auth Card */}
<main className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md">
<Card className="shadow-lg">
<CardHeader className="space-y-1 pb-6">
<CardTitle className="text-center">Enter the 6-digit code</CardTitle>
<div className="text-center text-sm text-muted-foreground">
We sent a code to <strong>{maskedEmail}</strong>. It expires in <strong>10 minutes</strong>.
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleVerifyCode} className="space-y-6">
{/* Error Alert */}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* OTP Input Fields */}
<div className="space-y-2">
<Label className="sr-only">6-digit verification code</Label>
<div className="flex justify-center gap-2" role="group" aria-labelledby="otp-label">
<span id="otp-label" className="sr-only">Enter 6-digit verification code</span>
{verificationCode.map((digit, index) => (
<Input
key={index}
ref={(el) => {
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"
/>
))}
</div>
{/* Inline error display */}
{error && (
<div
id="code-error"
className="text-center"
role="alert"
aria-live="polite"
>
<p className="text-sm text-destructive flex items-center justify-center gap-1">
<AlertCircle className="h-3 w-3" />
{error}
</p>
</div>
)}
</div>
{/* Verify Button */}
<Button
type="submit"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isLoading || !isFormValid || tooManyAttempts}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Verifying...
</>
) : (
'Verify'
)}
</Button>
{/* Action Links */}
<div className="flex justify-between items-center text-sm">
<Button
type="button"
variant="link"
onClick={handleResendCode}
disabled={resendCooldown > 0 || attempts >= 5}
className="p-0 h-auto min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
aria-live="polite"
>
{resendCooldown > 0
? `Resend code (${resendCooldown}s)`
: attempts >= 5
? 'Too many requests'
: 'Resend code'
}
</Button>
<Button
type="button"
variant="link"
onClick={() => onNavigate('/login')}
className="p-0 h-auto min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
>
Back to Sign-in
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</main>
{/* Legal Footer */}
<footer className="border-t bg-muted/30">
<div className="max-w-[1440px] mx-auto px-6 py-4">
<div className="flex justify-center items-center space-x-6 text-sm text-muted-foreground">
<button
className="hover:text-foreground transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50 rounded px-1"
onClick={() => {/* Handle privacy policy */}}
>
Privacy
</button>
<span></span>
<button
className="hover:text-foreground transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50 rounded px-1"
onClick={() => {/* Handle terms */}}
>
Terms
</button>
<span></span>
<span>©</span>
</div>
</div>
</footer>
</div>
);
}