354 lines
12 KiB
TypeScript
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>
|
|
);
|
|
} |