295 lines
11 KiB
JavaScript
295 lines
11 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import { useBranding } from '../contexts/BrandingContext';
|
|
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn, ShieldCheck } from 'lucide-react';
|
|
import BrandLogo from '../components/BrandLogo';
|
|
import api from '../services/api';
|
|
import toast from 'react-hot-toast';
|
|
|
|
export default function Login() {
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [needsVerification, setNeedsVerification] = useState(false);
|
|
const [resendCooldown, setResendCooldown] = useState(0);
|
|
const [resending, setResending] = useState(false);
|
|
// 2FA state
|
|
const [needs2FA, setNeeds2FA] = useState(false);
|
|
const [tempToken, setTempToken] = useState('');
|
|
const [totpCode, setTotpCode] = useState('');
|
|
const [verifying2FA, setVerifying2FA] = useState(false);
|
|
const totpInputRef = useRef(null);
|
|
|
|
const { login, verify2FA } = useAuth();
|
|
const { t } = useLanguage();
|
|
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
if (resendCooldown <= 0) return;
|
|
const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000);
|
|
return () => clearTimeout(timer);
|
|
}, [resendCooldown]);
|
|
|
|
// Auto-focus TOTP input when 2FA screen appears
|
|
useEffect(() => {
|
|
if (needs2FA && totpInputRef.current) {
|
|
totpInputRef.current.focus();
|
|
}
|
|
}, [needs2FA]);
|
|
|
|
const handleResend = async () => {
|
|
if (resendCooldown > 0 || resending) return;
|
|
setResending(true);
|
|
try {
|
|
await api.post('/auth/resend-verification', { email });
|
|
toast.success(t('auth.emailVerificationResendSuccess'));
|
|
setResendCooldown(60);
|
|
} catch (err) {
|
|
const wait = err.response?.data?.waitSeconds;
|
|
if (wait) {
|
|
setResendCooldown(wait);
|
|
}
|
|
toast.error(err.response?.data?.error || t('auth.emailVerificationResendFailed'));
|
|
} finally {
|
|
setResending(false);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
try {
|
|
const result = await login(email, password);
|
|
if (result?.requires2FA) {
|
|
setTempToken(result.tempToken);
|
|
setNeeds2FA(true);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
toast.success(t('auth.loginSuccess'));
|
|
navigate('/dashboard');
|
|
} catch (err) {
|
|
if (err.response?.data?.needsVerification) {
|
|
setNeedsVerification(true);
|
|
} else {
|
|
toast.error(err.response?.data?.error || t('auth.loginFailed'));
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handle2FASubmit = async (e) => {
|
|
e.preventDefault();
|
|
setVerifying2FA(true);
|
|
try {
|
|
await verify2FA(tempToken, totpCode);
|
|
toast.success(t('auth.loginSuccess'));
|
|
navigate('/dashboard');
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('auth.2fa.verifyFailed'));
|
|
setTotpCode('');
|
|
} finally {
|
|
setVerifying2FA(false);
|
|
}
|
|
};
|
|
|
|
const handleBack = () => {
|
|
setNeeds2FA(false);
|
|
setTempToken('');
|
|
setTotpCode('');
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
|
{/* Animated background */}
|
|
<div className="absolute inset-0 bg-th-bg">
|
|
<div className="absolute inset-0 opacity-30">
|
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
|
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Login card */}
|
|
<div className="relative w-full max-w-md">
|
|
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
|
{/* Logo */}
|
|
<div className="flex justify-center mb-8">
|
|
<BrandLogo size="lg" />
|
|
</div>
|
|
|
|
{needs2FA ? (
|
|
<>
|
|
{/* 2FA verification step */}
|
|
<div className="mb-8 text-center">
|
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-th-accent/10 mb-4">
|
|
<ShieldCheck size={28} className="text-th-accent" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.2fa.title')}</h2>
|
|
<p className="text-th-text-s text-sm">
|
|
{t('auth.2fa.prompt')}
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={handle2FASubmit} className="space-y-5">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.2fa.codeLabel')}</label>
|
|
<div className="relative">
|
|
<ShieldCheck size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
ref={totpInputRef}
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
value={totpCode}
|
|
onChange={e => setTotpCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
|
className="input-field pl-11 text-center text-lg tracking-[0.3em] font-mono"
|
|
placeholder="000 000"
|
|
required
|
|
maxLength={7}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={verifying2FA || totpCode.replace(/\s/g, '').length < 6}
|
|
className="btn-primary w-full py-3"
|
|
>
|
|
{verifying2FA ? (
|
|
<Loader2 size={18} className="animate-spin" />
|
|
) : (
|
|
<>
|
|
{t('auth.2fa.verify')}
|
|
<ArrowRight size={18} />
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
<button
|
|
onClick={handleBack}
|
|
className="block mt-4 w-full text-center text-sm text-th-text-s hover:text-th-text transition-colors"
|
|
>
|
|
{t('auth.2fa.backToLogin')}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="mb-8">
|
|
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
|
<p className="text-th-text-s">
|
|
{t('auth.loginSubtitle')}
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
|
<div className="relative">
|
|
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={e => setEmail(e.target.value)}
|
|
className="input-field pl-11"
|
|
placeholder={t('auth.emailPlaceholder')}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
|
<div className="relative">
|
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={e => setPassword(e.target.value)}
|
|
className="input-field pl-11"
|
|
placeholder={t('auth.passwordPlaceholder')}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="btn-primary w-full py-3"
|
|
>
|
|
{loading ? (
|
|
<Loader2 size={18} className="animate-spin" />
|
|
) : (
|
|
<>
|
|
{t('auth.login')}
|
|
<ArrowRight size={18} />
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
{oauthEnabled && (
|
|
<>
|
|
<div className="relative my-6">
|
|
<div className="absolute inset-0 flex items-center">
|
|
<div className="w-full border-t border-th-border" />
|
|
</div>
|
|
<div className="relative flex justify-center text-xs">
|
|
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
|
|
</div>
|
|
</div>
|
|
<a
|
|
href="/api/oauth/authorize"
|
|
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
|
|
>
|
|
<LogIn size={18} />
|
|
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
|
|
</a>
|
|
</>
|
|
)}
|
|
|
|
{needsVerification && (
|
|
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
|
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
|
|
</div>
|
|
<button
|
|
onClick={handleResend}
|
|
disabled={resendCooldown > 0 || resending}
|
|
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
|
|
>
|
|
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
|
|
{resendCooldown > 0
|
|
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
|
|
: t('auth.emailVerificationResend')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{registrationMode !== 'invite' && (
|
|
<p className="mt-6 text-center text-sm text-th-text-s">
|
|
{t('auth.noAccount')}{' '}
|
|
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
|
{t('auth.signUpNow')}
|
|
</Link>
|
|
</p>
|
|
)}
|
|
|
|
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
|
{t('auth.backToHome')}
|
|
</Link>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|