diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 605b2d7..dca7a6d 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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(); +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 = {а:'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 *`, diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 219dbf1..8099169 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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) { - 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 (
@@ -83,77 +134,106 @@ export default function LoginPage({ onLogin }: Props) { {/* Tabs */}
- -
-
- {tab === 'register' && ( + {/* ── LOGIN ── */} + {tab === 'login' && ( +
- - setDisplayName(e.target.value)} - className={inputCls} - placeholder="Ваше имя" - autoComplete="name" - required - /> + + setLoginPhone(applyPhoneMask(e.target.value))} + className={inputCls} placeholder="+7 (___) ___-__-__" autoComplete="tel" required />
- )} - -
- - -
- -
- - setPassword(e.target.value)} - className={inputCls} - placeholder={tab === 'register' ? 'Минимум 6 символов' : 'Введите пароль'} - autoComplete={tab === 'register' ? 'new-password' : 'current-password'} - required - /> -
- - {error && ( -
- {error} +
+ + setLoginPassword(e.target.value)} + className={inputCls} placeholder="Введите пароль" autoComplete="current-password" required />
- )} + {error &&
{error}
} + + + )} - - + {/* ── REGISTER step 1: phone ── */} + {tab === 'register' && regStep === 1 && ( +
+

Введите номер — отправим код подтверждения

+
+ + setRegPhone(applyPhoneMask(e.target.value))} + className={inputCls} placeholder="+7 (___) ___-__-__" autoComplete="tel" required /> +
+ {error &&
{error}
} + +
+ )} + + {/* ── REGISTER step 2: code + details ── */} + {tab === 'register' && regStep === 2 && ( +
+
+ Код отправлен на {regPhone} + +
+ + {/* Code input */} +
+ + 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 /> +
+ {countdown > 0 + ? Повторная отправка через {countdown} сек. + : + } +
+
+ +
+ + setRegName(e.target.value)} + className={inputCls} placeholder="Имя Фамилия" autoComplete="name" required /> +
+
+ + setRegPassword(e.target.value)} + className={inputCls} placeholder="Минимум 6 символов" autoComplete="new-password" required /> +
+ + {error &&
{error}
} + +
+ )}
);