diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index dca7a6d..0735984 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -57,43 +57,50 @@ export default async function authRoutes(app: FastifyInstance) { return { ok: true }; }); - // Register (requires SMS code) - app.post('/register', async (req, reply) => { - 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 символов' }); - } + // Unified: verify code → login existing user OR register new user + // If new user and no displayName provided → returns { isNew: true } + app.post('/complete', async (req, reply) => { + const { phone, code, displayName } = req.body as any; + if (!phone || !code) return reply.status(400).send({ error: 'Укажите телефон и код' }); - // 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)) { + entry.attempts++; + if (entry.attempts >= 5) { + otpStore.delete(phone); + return reply.status(400).send({ error: 'Слишком много попыток. Запросите новый код' }); + } return reply.status(400).send({ error: 'Неверный код' }); } + + // Code correct — check if user exists + const { rows: [existing] } = await pool.query( + 'SELECT * FROM users WHERE phone = $1 AND is_active = TRUE', [phone] + ); + + if (existing) { + // Login + otpStore.delete(phone); + await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [existing.id]); + const token = app.jwt.sign({ id: existing.id, isAdmin: false }, { expiresIn: '30d' }); + return { token, user: userDto(existing) }; + } + + // New user — need a name + if (!displayName?.trim()) { + return reply.status(422).send({ isNew: true, error: 'Введите ваше имя' }); + } + otpStore.delete(phone); - // Check phone uniqueness - 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 + // Generate username let base = displayName.toLowerCase() - .replace(/[^a-z0-9а-яё]/gi, '_') - .replace(/_+/g, '_') - .replace(/^_|_$/g, '') - .slice(0, 20) || 'user'; + .replace(/[^a-z0-9а-яё]/gi, '_').replace(/_+/g, '_').replace(/^_|_$/g, '').slice(0, 20) || 'user'; 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()] || '_'; @@ -102,37 +109,16 @@ export default async function authRoutes(app: FastifyInstance) { const { rows: [taken] } = await pool.query('SELECT id FROM users WHERE username = $1', [username]); if (taken) username = base + Math.floor(Math.random() * 9000 + 1000); - const hash = await bcrypt.hash(password, 10); + const randomHash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 4); const color = COLORS[Math.floor(Math.random() * COLORS.length)]; - const { rows: [user] } = await pool.query( + const { rows: [newUser] } = await pool.query( `INSERT INTO users (username, display_name, password_hash, phone, avatar_color) VALUES ($1, $2, $3, $4, $5) RETURNING *`, - [username, displayName, hash, phone, color] + [username, displayName.trim(), randomHash, phone, color] ); - const token = app.jwt.sign({ id: user.id, isAdmin: false }, { expiresIn: '30d' }); - return reply.status(201).send({ token, user: userDto(user) }); - }); - - app.post('/login', async (req, reply) => { - const { username, phone, password } = req.body as { username?: string; phone?: string; password: string }; - const login = phone || username || ''; - - const { rows: [user] } = await pool.query( - `SELECT * FROM users WHERE is_active = TRUE AND ( - (phone != '' AND phone = $1) OR username = $1 - )`, [login] - ); - if (!user) return reply.status(401).send({ error: 'Неверный логин или пароль' }); - - const ok = await bcrypt.compare(password, user.password_hash); - if (!ok) return reply.status(401).send({ error: 'Неверный логин или пароль' }); - - await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [user.id]); - - const token = app.jwt.sign({ id: user.id, isAdmin: user.is_admin }, { expiresIn: '30d' }); - - return { token, user: userDto(user) }; + const token = app.jwt.sign({ id: newUser.id, isAdmin: false }, { expiresIn: '30d' }); + return reply.status(201).send({ token, user: userDto(newUser) }); }); app.get('/me', { preHandler: [app.authenticate] }, async (req) => { @@ -145,15 +131,7 @@ export default async function authRoutes(app: FastifyInstance) { app.put('/me', { preHandler: [app.authenticate] }, async (req, reply) => { const { id } = req.user as { id: string }; - const { displayName, bio, phone, position, password, newPassword, username } = req.body as any; - - if (newPassword) { - const { rows: [user] } = await pool.query('SELECT password_hash FROM users WHERE id = $1', [id]); - const ok = await bcrypt.compare(password, user.password_hash); - if (!ok) return reply.status(400).send({ error: 'Неверный текущий пароль' }); - const hash = await bcrypt.hash(newPassword, 10); - await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]); - } + const { displayName, bio, phone, position, username } = req.body as any; if (username) { if (!/^[a-z0-9_]{3,30}$/.test(username)) { @@ -173,7 +151,7 @@ export default async function authRoutes(app: FastifyInstance) { position = COALESCE($4, position), username = COALESCE($5, username) WHERE id = $6`, - [displayName, bio, phone, position, username || null, id] + [displayName || null, bio || null, phone || null, position || null, username || null, id] ); const { rows: [user] } = await pool.query( diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx index 2d5c869..81d1ccc 100644 --- a/frontend/src/components/ProfileModal.tsx +++ b/frontend/src/components/ProfileModal.tsx @@ -1,5 +1,5 @@ import { useState, useRef } from 'react'; -import { X, Camera, Trash2, Save, Lock, LogOut } from 'lucide-react'; +import { X, Camera, Trash2, Save, LogOut } from 'lucide-react'; import { useStore } from '../store'; import { useNavigate } from 'react-router-dom'; import { wsClient } from '../api/ws'; @@ -36,8 +36,6 @@ export default function ProfileModal({ onClose }: Props) { const [bio, setBio] = useState(user?.bio || ''); const [phone, setPhone] = useState(applyPhoneMask(user?.phone || '')); const [position, setPosition] = useState(user?.position || ''); - const [password, setPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); const [saving, setSaving] = useState(false); const [uploadingAvatar, setUploadingAvatar] = useState(false); const [error, setError] = useState(''); @@ -50,16 +48,9 @@ export default function ProfileModal({ onClose }: Props) { setSuccess(''); try { const body: any = { displayName, bio, phone, position: position || null, username: username !== user?.username ? username : undefined }; - if (newPassword) { - if (!password) { setError('Введите текущий пароль'); setSaving(false); return; } - body.password = password; - body.newPassword = newPassword; - } const { data } = await api.put('/api/auth/me', body); setUser({ ...user!, ...data }); localStorage.setItem('jc_user', JSON.stringify({ ...user!, ...data })); - setPassword(''); - setNewPassword(''); setSuccess('Сохранено'); setTimeout(() => setSuccess(''), 3000); } catch (err: any) { @@ -208,28 +199,6 @@ export default function ProfileModal({ onClose }: Props) { - {/* Password */} -
-
- - Сменить пароль -
- setPassword(e.target.value)} - className="w-full px-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" - placeholder="Текущий пароль" - /> - setNewPassword(e.target.value)} - className="w-full px-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" - placeholder="Новый пароль" - /> -
- {error &&

{error}

} {success &&

{success}

} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 8099169..0cd8c44 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -25,26 +25,21 @@ function applyPhoneMask(raw: string): string { return r; } +// step 'phone' → enter phone, request code +// step 'code' → enter code (existing user → login) +// step 'name' → new user: enter name to complete registration +type Step = 'phone' | 'code' | 'name'; + export default function LoginPage({ onLogin }: Props) { const navigate = useNavigate(); - const [tab, setTab] = useState<'login' | 'register'>('login'); - - // 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 [step, setStep] = useState('phone'); + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [name, setName] = useState(''); const [countdown, setCountdown] = useState(0); - const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - // Countdown timer for resend useEffect(() => { if (countdown <= 0) return; const t = setTimeout(() => setCountdown(c => c - 1), 1000); @@ -53,28 +48,14 @@ 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'; - async function handleLogin(e: React.FormEvent) { - e.preventDefault(); + async function sendCode(e?: React.FormEvent) { + e?.preventDefault(); setError(''); setLoading(true); try { - const { data } = await api.post('/api/auth/login', { phone: loginPhone, password: loginPassword }); - onLogin(data.token, data.user); - navigate('/'); - } catch (err: any) { - setError(err.response?.data?.error || 'Ошибка входа'); - } finally { - setLoading(false); - } - } - - async function handleSendCode(e: React.FormEvent) { - e.preventDefault(); - setError(''); - setLoading(true); - try { - await api.post('/api/auth/send-code', { phone: regPhone }); - setRegStep(2); + await api.post('/api/auth/send-code', { phone }); + setStep('code'); + setCode(''); setCountdown(60); } catch (err: any) { setError(err.response?.data?.error || 'Ошибка отправки SMS'); @@ -83,154 +64,105 @@ export default function LoginPage({ onLogin }: Props) { } } - 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) { + async function verifyCode(e: React.FormEvent, displayName?: string) { e.preventDefault(); setError(''); setLoading(true); try { - const { data } = await api.post('/api/auth/register', { - phone: regPhone, code: regCode, displayName: regName, password: regPassword, - }); + const { data } = await api.post('/api/auth/complete', { phone, code, displayName }); onLogin(data.token, data.user); navigate('/'); } catch (err: any) { - setError(err.response?.data?.error || 'Ошибка регистрации'); + if (err.response?.data?.isNew) { + // New user — ask for name + setStep('name'); + setError(''); + } else { + setError(err.response?.data?.error || 'Ошибка'); + } } finally { setLoading(false); } } - function switchTab(t: 'login' | 'register') { - setTab(t); - setError(''); - setRegStep(1); - setRegCode(''); - } - return (
{/* Logo */} -
-
- JaniChat -
+
+ JaniChat

JaniChat

+

+ {step === 'phone' && 'Введите номер телефона'} + {step === 'code' && 'Введите код из SMS'} + {step === 'name' && 'Как вас зовут?'} +

- {/* Tabs */} -
- - -
- - {/* ── LOGIN ── */} - {tab === 'login' && ( -
-
- - setLoginPhone(applyPhoneMask(e.target.value))} - className={inputCls} placeholder="+7 (___) ___-__-__" autoComplete="tel" required /> -
-
- - setLoginPassword(e.target.value)} - className={inputCls} placeholder="Введите пароль" autoComplete="current-password" required /> -
+ {/* ── Step 1: Phone ── */} + {step === 'phone' && ( + + setPhone(applyPhoneMask(e.target.value))} + className={inputCls} placeholder="+7 (___) ___-__-__" + autoComplete="tel" autoFocus 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} сек. - : - } -
+ setCode(e.target.value.replace(/\D/g, '').slice(0, 4))} + className={`${inputCls} text-center text-3xl tracking-[0.6em] font-bold`} + placeholder="••••" autoComplete="one-time-code" autoFocus 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}
} - + + )} + + {/* ── Step 3: Name (new user only) ── */} + {step === 'name' && ( +
verifyCode(e, name)} className="space-y-4"> +

Вы регистрируетесь впервые

+ setName(e.target.value)} + className={inputCls} placeholder="Ваше имя" + autoComplete="name" autoFocus required + /> + {error &&
{error}
} +
)}