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:
Ai
2026-05-25 15:17:49 +03:00
parent 2c362c06cd
commit 2fd6b14e1a
2 changed files with 213 additions and 79 deletions

View File

@@ -7,6 +7,22 @@ import { pool } from '../db.js';
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316'];
// ── OTP store ────────────────────────────────────────────────────────────────
interface OtpEntry { code: string; expiresAt: number; attempts: number; sentAt: number }
const otpStore = new Map<string, OtpEntry>();
setInterval(() => { const now = Date.now(); otpStore.forEach((v, k) => { if (v.expiresAt < now) otpStore.delete(k); }); }, 60_000);
async function sendSmscSms(phone: string, message: string) {
const login = process.env.SMSC_LOGIN;
const psw = process.env.SMSC_PASSWORD;
if (!login || !psw) throw new Error('SMSC не настроен');
const digits = phone.replace(/\D/g, '');
const url = `https://smsc.ru/sys/send.php?login=${encodeURIComponent(login)}&psw=${encodeURIComponent(psw)}&phones=${digits}&mes=${encodeURIComponent(message)}&charset=utf-8&fmt=3`;
const res = await fetch(url);
const json = await res.json() as any;
if (json.error_code) throw new Error(json.error || 'Ошибка SMSC');
}
function userDto(u: any) {
return {
id: u.id, username: u.username, displayName: u.display_name,
@@ -16,20 +32,60 @@ function userDto(u: any) {
}
export default async function authRoutes(app: FastifyInstance) {
// Register
// Send SMS verification code
app.post('/send-code', async (req, reply) => {
const { phone } = req.body as { phone: string };
if (!phone) return reply.status(400).send({ error: 'Укажите телефон' });
const now = Date.now();
const existing = otpStore.get(phone);
if (existing && now - existing.sentAt < 60_000) {
const wait = Math.ceil((60_000 - (now - existing.sentAt)) / 1000);
return reply.status(429).send({ error: `Повторная отправка через ${wait} сек.` });
}
const code = String(Math.floor(1000 + Math.random() * 9000));
otpStore.set(phone, { code, expiresAt: now + 5 * 60_000, attempts: 0, sentAt: now });
try {
await sendSmscSms(phone, `JaniChat: ваш код ${code}. Действует 5 минут.`);
} catch (e: any) {
otpStore.delete(phone);
return reply.status(500).send({ error: e.message || 'Ошибка отправки SMS' });
}
return { ok: true };
});
// Register (requires SMS code)
app.post('/register', async (req, reply) => {
const { phone, displayName, password } = req.body as any;
if (!phone || !displayName || !password) {
const { phone, displayName, password, code } = req.body as any;
if (!phone || !displayName || !password || !code) {
return reply.status(400).send({ error: 'Заполните все поля' });
}
if (password.length < 6) {
return reply.status(400).send({ error: 'Пароль минимум 6 символов' });
}
// Verify OTP
const entry = otpStore.get(phone);
if (!entry) return reply.status(400).send({ error: 'Сначала запросите код' });
if (Date.now() > entry.expiresAt) {
otpStore.delete(phone);
return reply.status(400).send({ error: 'Код истёк. Запросите новый' });
}
entry.attempts++;
if (entry.attempts > 5) {
otpStore.delete(phone);
return reply.status(400).send({ error: 'Слишком много попыток. Запросите новый код' });
}
if (entry.code !== String(code)) {
return reply.status(400).send({ error: 'Неверный код' });
}
otpStore.delete(phone);
// Check phone uniqueness
const { rows: [existingPhone] } = await pool.query(
'SELECT id FROM users WHERE phone = $1', [phone]
);
const { rows: [existingPhone] } = await pool.query('SELECT id FROM users WHERE phone = $1', [phone]);
if (existingPhone) return reply.status(400).send({ error: 'Этот номер уже зарегистрирован' });
// Auto-generate username from displayName
@@ -38,7 +94,6 @@ export default async function authRoutes(app: FastifyInstance) {
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 20) || 'user';
// transliterate basic cyrillic
base = base.replace(/[а-яё]/gi, (c: string) => {
const map: Record<string, string> = {а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ж:'zh',з:'z',и:'i',й:'j',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'h',ц:'ts',ч:'ch',ш:'sh',щ:'sh',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya',ё:'e'};
return map[c.toLowerCase()] || '_';
@@ -49,7 +104,6 @@ export default async function authRoutes(app: FastifyInstance) {
const hash = await bcrypt.hash(password, 10);
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
const { rows: [user] } = await pool.query(
`INSERT INTO users (username, display_name, password_hash, phone, avatar_color)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,

View File

@@ -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>
);