358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
|
import axios from 'axios';
|
|
import { Turnstile } from '@marsidev/react-turnstile';
|
|
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
|
import useAuth from '../context/useAuth';
|
|
import { API_URL } from '../config/api';
|
|
import { useToast } from '../hooks/useToast';
|
|
import { useTranslation } from '../i18n';
|
|
import { useLocalePath } from '../middleware';
|
|
import { validateEmail } from '../utils/emailValidation';
|
|
import { validateUsername } from '../utils/usernameBlacklist';
|
|
import { quickEmailCheck, verifyEmailWithAPI } from '../utils/emailVerification';
|
|
|
|
const RegisterPage = () => {
|
|
const { addToast } = useToast();
|
|
const [username, setUsername] = useState('');
|
|
const [usernameError, setUsernameError] = useState<string | null>(null);
|
|
const [email, setEmail] = useState('');
|
|
const [emailError, setEmailError] = useState<string | null>(null);
|
|
const [emailSuggestion, setEmailSuggestion] = useState<string | null>(null);
|
|
const [isVerifyingEmail, setIsVerifyingEmail] = useState(false);
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
|
const turnstileRef = useRef<TurnstileInstance>(null);
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { login } = useAuth();
|
|
const { t, locale } = useTranslation();
|
|
const localePath = useLocalePath();
|
|
|
|
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
|
|
|
// Username validation on blur
|
|
const handleUsernameBlur = useCallback(() => {
|
|
if (!username.trim()) {
|
|
setUsernameError(null);
|
|
return;
|
|
}
|
|
|
|
const result = validateUsername(username, locale);
|
|
setUsernameError(result.isValid ? null : result.error ?? null);
|
|
}, [username, locale]);
|
|
|
|
// Email validation on blur
|
|
const handleEmailBlur = useCallback(async () => {
|
|
if (!email.trim()) {
|
|
setEmailError(null);
|
|
setEmailSuggestion(null);
|
|
return;
|
|
}
|
|
|
|
// Quick client-side check
|
|
const quickCheck = quickEmailCheck(email);
|
|
if (!quickCheck.valid) {
|
|
setEmailError(quickCheck.reason || (locale === 'en' ? 'Invalid email' : 'Недействительный email'));
|
|
setEmailSuggestion(null);
|
|
return;
|
|
}
|
|
|
|
const result = validateEmail(email, locale);
|
|
setEmailError(result.isValid ? null : result.error ?? null);
|
|
setEmailSuggestion(result.suggestion ?? null);
|
|
|
|
// Optional: deep verification with API (only if basic checks pass)
|
|
if (result.isValid) {
|
|
setIsVerifyingEmail(true);
|
|
try {
|
|
const apiResult = await verifyEmailWithAPI(email, { checkMX: true });
|
|
if (!apiResult.valid) {
|
|
setEmailError(apiResult.error || (locale === 'en'
|
|
? 'This email address appears to be invalid'
|
|
: 'Этот email-адрес недействителен'));
|
|
} else if (apiResult.disposable) {
|
|
setEmailError(locale === 'en'
|
|
? 'Temporary email addresses are not allowed'
|
|
: 'Временные email-адреса не допускаются');
|
|
}
|
|
} catch (err) {
|
|
console.warn('Email verification failed:', err);
|
|
} finally {
|
|
setIsVerifyingEmail(false);
|
|
}
|
|
}
|
|
}, [email, locale]);
|
|
|
|
// Apply email suggestion
|
|
const applySuggestion = useCallback(() => {
|
|
if (emailSuggestion) {
|
|
const match = emailSuggestion.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/);
|
|
if (match) {
|
|
setEmail(match[1]);
|
|
setEmailSuggestion(null);
|
|
}
|
|
}
|
|
}, [emailSuggestion]);
|
|
|
|
// Handle OAuth token from URL
|
|
useEffect(() => {
|
|
const handleOAuthLogin = async () => {
|
|
const params = new URLSearchParams(location.search);
|
|
const token = params.get('token');
|
|
const authError = params.get('error');
|
|
|
|
if (token) {
|
|
await login(token);
|
|
navigate(localePath('/dashboard'), { replace: true });
|
|
}
|
|
|
|
if (authError) {
|
|
setError(locale === 'en'
|
|
? 'Social login error. Please try again.'
|
|
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
|
}
|
|
};
|
|
|
|
handleOAuthLogin();
|
|
}, [location, login, navigate, localePath, locale]);
|
|
|
|
const handleRegister = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
|
|
// Validate username
|
|
const usernameValidation = validateUsername(username, locale);
|
|
if (!usernameValidation.isValid) {
|
|
setUsernameError(usernameValidation.error ?? t('auth.register.invalidEmail'));
|
|
return;
|
|
}
|
|
|
|
// Validate email before submit
|
|
const emailValidation = validateEmail(email, locale);
|
|
if (!emailValidation.isValid) {
|
|
setEmailError(emailValidation.error ?? t('auth.register.invalidEmail'));
|
|
return;
|
|
}
|
|
|
|
if (!turnstileToken) {
|
|
setError(t('auth.register.captchaRequired'));
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
await axios.post(`${API_URL}/api/auth/register`, {
|
|
username: username,
|
|
email: email,
|
|
password: password,
|
|
turnstileToken: turnstileToken,
|
|
});
|
|
|
|
addToast(t('auth.register.success'), 'success');
|
|
navigate(localePath('/login'));
|
|
|
|
} catch (err) {
|
|
// Reset captcha on error
|
|
if (turnstileRef.current) {
|
|
turnstileRef.current.reset();
|
|
}
|
|
setTurnstileToken(null);
|
|
|
|
if (axios.isAxiosError(err) && err.response) {
|
|
const errorMsg = err.response.data.message || t('auth.register.unknownError');
|
|
setError(errorMsg);
|
|
} else {
|
|
setError(t('auth.register.networkError'));
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleOAuthLogin = (provider: string) => {
|
|
window.location.href = `${API_URL}/api/auth/${provider}`;
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
|
<div className="bg-white p-8 md:p-10 rounded-3xl shadow-2xl w-full max-w-md text-center">
|
|
<h1 className="text-3xl font-bold text-gray-800 mb-6">{t('auth.register.title')}</h1>
|
|
<form onSubmit={handleRegister}>
|
|
<div className="relative mb-4">
|
|
<input
|
|
type="text"
|
|
value={username}
|
|
onChange={(e) => {
|
|
setUsername(e.target.value);
|
|
setUsernameError(null);
|
|
}}
|
|
onBlur={handleUsernameBlur}
|
|
placeholder={t('auth.register.usernamePlaceholder')}
|
|
className={`w-full px-5 py-3 border rounded-full focus:outline-none focus:ring-2 ${
|
|
usernameError
|
|
? 'border-red-500 focus:ring-red-500'
|
|
: 'border-gray-300 focus:ring-ospab-primary'
|
|
}`}
|
|
required
|
|
disabled={isLoading}
|
|
/>
|
|
{usernameError && (
|
|
<p className="mt-1 text-sm text-red-500 text-left px-3">{usernameError}</p>
|
|
)}
|
|
</div>
|
|
<div className="relative mb-4">
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => {
|
|
setEmail(e.target.value);
|
|
setEmailError(null);
|
|
setEmailSuggestion(null);
|
|
}}
|
|
onBlur={handleEmailBlur}
|
|
placeholder={t('auth.register.emailPlaceholder')}
|
|
className={`w-full px-5 py-3 border rounded-full focus:outline-none focus:ring-2 ${
|
|
emailError
|
|
? 'border-red-500 focus:ring-red-500'
|
|
: isVerifyingEmail
|
|
? 'border-yellow-400 focus:ring-yellow-400'
|
|
: 'border-gray-300 focus:ring-ospab-primary'
|
|
}`}
|
|
required
|
|
disabled={isLoading}
|
|
/>
|
|
{isVerifyingEmail && (
|
|
<p className="mt-1 text-sm text-yellow-600 text-left px-3">
|
|
{locale === 'en' ? 'Verifying email...' : 'Проверка email...'}
|
|
</p>
|
|
)}
|
|
{emailError && (
|
|
<p className="mt-1 text-sm text-red-500 text-left px-3">{emailError}</p>
|
|
)}
|
|
{emailSuggestion && !emailError && (
|
|
<button
|
|
type="button"
|
|
onClick={applySuggestion}
|
|
className="mt-1 text-sm text-blue-600 hover:text-blue-800 text-left px-3 cursor-pointer"
|
|
>
|
|
{emailSuggestion}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder={t('auth.register.passwordPlaceholder')}
|
|
className="w-full px-5 py-3 mb-4 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
|
required
|
|
disabled={isLoading}
|
|
/>
|
|
|
|
{/* Terms and Privacy Checkbox */}
|
|
<div className="mb-6 text-left">
|
|
<label className="flex items-start">
|
|
<input
|
|
type="checkbox"
|
|
required
|
|
className="mt-1 mr-2 h-4 w-4 text-ospab-primary focus:ring-ospab-primary border-gray-300 rounded"
|
|
disabled={isLoading}
|
|
/>
|
|
<span className="text-sm text-gray-600">
|
|
{t('auth.register.terms')}{' '}
|
|
<Link to={localePath('/terms')} target="_blank" className="text-ospab-primary hover:underline">
|
|
{t('auth.register.termsLink')}
|
|
</Link>
|
|
{' '}{t('auth.register.and')}{' '}
|
|
<Link to={localePath('/privacy')} target="_blank" className="text-ospab-primary hover:underline">
|
|
{t('auth.register.privacyLink')}
|
|
</Link>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Cloudflare Turnstile Captcha */}
|
|
<div className="mb-6 flex justify-center">
|
|
<Turnstile
|
|
ref={turnstileRef}
|
|
siteKey={siteKey}
|
|
onSuccess={(token: string) => setTurnstileToken(token)}
|
|
onError={() => {
|
|
setTurnstileToken(null);
|
|
setError(t('auth.register.captchaError'));
|
|
}}
|
|
onExpire={() => setTurnstileToken(null)}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading || !turnstileToken || !!emailError || !!usernameError || isVerifyingEmail}
|
|
className="w-full px-5 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isLoading ? t('auth.register.loading') : t('auth.register.submit')}
|
|
</button>
|
|
</form>
|
|
{error && (
|
|
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
<p className="text-sm text-red-600">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Social networks */}
|
|
<div className="mt-6">
|
|
<div className="relative">
|
|
<div className="absolute inset-0 flex items-center">
|
|
<div className="w-full border-t border-gray-300"></div>
|
|
</div>
|
|
<div className="relative flex justify-center text-sm">
|
|
<span className="px-2 bg-white text-gray-500">{t('auth.register.orRegisterWith')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 grid grid-cols-3 gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOAuthLogin('google')}
|
|
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
|
aria-label={locale === 'en' ? 'Sign up with Google' : 'Регистрация через Google'}
|
|
>
|
|
<img src="/google.png" alt="Google" className="h-6 w-6" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOAuthLogin('github')}
|
|
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
|
aria-label={locale === 'en' ? 'Sign up with GitHub' : 'Регистрация через GitHub'}
|
|
>
|
|
<img src="/github.png" alt="GitHub" className="h-6 w-6" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOAuthLogin('yandex')}
|
|
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
|
aria-label={locale === 'en' ? 'Sign up with Yandex' : 'Регистрация через Yandex'}
|
|
>
|
|
<img src="/yandex.png" alt="" className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="mt-6 text-gray-600">
|
|
{t('auth.register.haveAccount')}{' '}
|
|
<Link to={localePath('/login')} className="text-ospab-primary font-bold hover:underline">
|
|
{t('auth.register.loginLink')}
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RegisterPage; |