feat: SMS verification via SMSC.ru for registration
- POST /api/auth/send-code — generates 4-digit OTP, sends SMS via SMSC.ru - Registration now requires SMS code verification (2-step flow) - Cooldown 60s between resends, 5 min expiry, max 5 attempts - Frontend: step 1 = enter phone → get code, step 2 = code + name + password - Large code input with tracking, countdown timer, resend button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
import { User } from '../types';
|
||||
@@ -28,22 +28,37 @@ function applyPhoneMask(raw: string): string {
|
||||
export default function LoginPage({ onLogin }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<'login' | 'register'>('login');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
|
||||
// Login state
|
||||
const [loginPhone, setLoginPhone] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
|
||||
// Register state — step 1: phone, step 2: code + name + password
|
||||
const [regStep, setRegStep] = useState<1 | 2>(1);
|
||||
const [regPhone, setRegPhone] = useState('');
|
||||
const [regCode, setRegCode] = useState('');
|
||||
const [regName, setRegName] = useState('');
|
||||
const [regPassword, setRegPassword] = useState('');
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function handlePhoneChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setPhone(applyPhoneMask(e.target.value));
|
||||
}
|
||||
// Countdown timer for resend
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const t = setTimeout(() => setCountdown(c => c - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [countdown]);
|
||||
|
||||
const inputCls = 'w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition';
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post('/api/auth/login', { phone, password });
|
||||
const { data } = await api.post('/api/auth/login', { phone: loginPhone, password: loginPassword });
|
||||
onLogin(data.token, data.user);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
@@ -53,12 +68,43 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post('/api/auth/send-code', { phone: regPhone });
|
||||
setRegStep(2);
|
||||
setCountdown(60);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Ошибка отправки SMS');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendCode() {
|
||||
if (countdown > 0) return;
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post('/api/auth/send-code', { phone: regPhone });
|
||||
setCountdown(60);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Ошибка отправки SMS');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post('/api/auth/register', { phone, password, displayName });
|
||||
const { data } = await api.post('/api/auth/register', {
|
||||
phone: regPhone, code: regCode, displayName: regName, password: regPassword,
|
||||
});
|
||||
onLogin(data.token, data.user);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
@@ -68,7 +114,12 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls = 'w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition';
|
||||
function switchTab(t: 'login' | 'register') {
|
||||
setTab(t);
|
||||
setError('');
|
||||
setRegStep(1);
|
||||
setRegCode('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4" style={{ minHeight: '100dvh' }}>
|
||||
@@ -83,77 +134,106 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex bg-gray-100 rounded-xl p-1 mb-6">
|
||||
<button
|
||||
onClick={() => { setTab('login'); setError(''); }}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors ${tab === 'login' ? 'bg-white shadow text-gray-900' : 'text-gray-500'}`}
|
||||
>
|
||||
<button onClick={() => switchTab('login')}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors ${tab === 'login' ? 'bg-white shadow text-gray-900' : 'text-gray-500'}`}>
|
||||
Вход
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setTab('register'); setError(''); }}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors ${tab === 'register' ? 'bg-white shadow text-gray-900' : 'text-gray-500'}`}
|
||||
>
|
||||
<button onClick={() => switchTab('register')}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors ${tab === 'register' ? 'bg-white shadow text-gray-900' : 'text-gray-500'}`}>
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={tab === 'login' ? handleLogin : handleRegister} className="space-y-4">
|
||||
{tab === 'register' && (
|
||||
{/* ── LOGIN ── */}
|
||||
{tab === 'login' && (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Имя</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="Ваше имя"
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Номер телефона</label>
|
||||
<input type="tel" inputMode="numeric" value={loginPhone}
|
||||
onChange={e => setLoginPhone(applyPhoneMask(e.target.value))}
|
||||
className={inputCls} placeholder="+7 (___) ___-__-__" autoComplete="tel" required />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Номер телефона</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={handlePhoneChange}
|
||||
className={inputCls}
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder={tab === 'register' ? 'Минимум 6 символов' : 'Введите пароль'}
|
||||
autoComplete={tab === 'register' ? 'new-password' : 'current-password'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 text-sm px-4 py-3 rounded-xl">
|
||||
{error}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<input type="password" value={loginPassword}
|
||||
onChange={e => setLoginPassword(e.target.value)}
|
||||
className={inputCls} placeholder="Введите пароль" autoComplete="current-password" required />
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="bg-red-50 text-red-600 text-sm px-4 py-3 rounded-xl">{error}</div>}
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition">
|
||||
{loading ? '...' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition"
|
||||
>
|
||||
{loading ? '...' : tab === 'login' ? 'Войти' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</form>
|
||||
{/* ── REGISTER step 1: phone ── */}
|
||||
{tab === 'register' && regStep === 1 && (
|
||||
<form onSubmit={handleSendCode} className="space-y-4">
|
||||
<p className="text-sm text-gray-500 text-center">Введите номер — отправим код подтверждения</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Номер телефона</label>
|
||||
<input type="tel" inputMode="numeric" value={regPhone}
|
||||
onChange={e => setRegPhone(applyPhoneMask(e.target.value))}
|
||||
className={inputCls} placeholder="+7 (___) ___-__-__" autoComplete="tel" required />
|
||||
</div>
|
||||
{error && <div className="bg-red-50 text-red-600 text-sm px-4 py-3 rounded-xl">{error}</div>}
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition">
|
||||
{loading ? 'Отправка...' : 'Получить код по SMS'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ── REGISTER step 2: code + details ── */}
|
||||
{tab === 'register' && regStep === 2 && (
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
Код отправлен на <span className="font-medium text-gray-800">{regPhone}</span>
|
||||
<button type="button" onClick={() => { setRegStep(1); setError(''); }}
|
||||
className="block mx-auto text-blue-500 text-xs mt-1 hover:underline">
|
||||
Изменить номер
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Code input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Код из SMS</label>
|
||||
<input type="text" inputMode="numeric" maxLength={4} value={regCode}
|
||||
onChange={e => setRegCode(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||
className={`${inputCls} text-center text-2xl tracking-[0.5em] font-bold`}
|
||||
placeholder="_ _ _ _" autoComplete="one-time-code" required />
|
||||
<div className="text-center mt-1">
|
||||
{countdown > 0
|
||||
? <span className="text-xs text-gray-400">Повторная отправка через {countdown} сек.</span>
|
||||
: <button type="button" onClick={handleResendCode} disabled={loading}
|
||||
className="text-xs text-blue-500 hover:underline">
|
||||
Отправить ещё раз
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ваше имя</label>
|
||||
<input type="text" value={regName}
|
||||
onChange={e => setRegName(e.target.value)}
|
||||
className={inputCls} placeholder="Имя Фамилия" autoComplete="name" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<input type="password" value={regPassword}
|
||||
onChange={e => setRegPassword(e.target.value)}
|
||||
className={inputCls} placeholder="Минимум 6 символов" autoComplete="new-password" required />
|
||||
</div>
|
||||
|
||||
{error && <div className="bg-red-50 text-red-600 text-sm px-4 py-3 rounded-xl">{error}</div>}
|
||||
<button type="submit" disabled={loading || regCode.length < 4}
|
||||
className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition">
|
||||
{loading ? '...' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user