feat: passwordless SMS-only auth (login + registration)

- Single unified flow: phone → SMS code → (if new: enter name)
- POST /api/auth/complete — verifies OTP, logs in or registers
- Removed password from login/register completely
- New users get random password_hash (not used for auth)
- Removed password change section from ProfileModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ai
2026-05-25 15:54:05 +03:00
parent dbf328b197
commit b095d20852
3 changed files with 119 additions and 240 deletions

View File

@@ -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: 'Код истёк. Запросите новый' });
}
if (entry.code !== String(code)) {
entry.attempts++;
if (entry.attempts > 5) {
if (entry.attempts >= 5) {
otpStore.delete(phone);
return reply.status(400).send({ error: 'Слишком много попыток. Запросите новый код' });
}
if (entry.code !== String(code)) {
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<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()] || '_';
@@ -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(

View File

@@ -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) {
</div>
</div>
{/* Password */}
<div className="space-y-3 border-t border-gray-100 pt-4">
<div className="flex items-center gap-2 text-sm font-medium text-gray-600">
<Lock className="w-4 h-4" />
Сменить пароль
</div>
<input
type="password"
value={password}
onChange={e => 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="Текущий пароль"
/>
<input
type="password"
value={newPassword}
onChange={e => 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="Новый пароль"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
{success && <p className="text-green-500 text-sm">{success}</p>}
</div>

View File

@@ -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<Step>('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 (
<div className="bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4" style={{ minHeight: '100dvh' }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-8">
{/* Logo */}
<div className="text-center mb-6">
<div className="w-20 h-20 mx-auto mb-4">
<img src="/icon-192.png" alt="JaniChat" className="w-20 h-20 rounded-2xl shadow-lg" />
</div>
<div className="text-center mb-8">
<img src="/icon-192.png" alt="JaniChat" className="w-20 h-20 mx-auto mb-4 rounded-2xl shadow-lg" />
<h1 className="text-2xl font-bold text-gray-900">JaniChat</h1>
<p className="text-gray-400 text-sm mt-1">
{step === 'phone' && 'Введите номер телефона'}
{step === 'code' && 'Введите код из SMS'}
{step === 'name' && 'Как вас зовут?'}
</p>
</div>
{/* Tabs */}
<div className="flex bg-gray-100 rounded-xl p-1 mb-6">
<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={() => 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>
{/* ── 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="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="password" value={loginPassword}
onChange={e => setLoginPassword(e.target.value)}
className={inputCls} placeholder="Введите пароль" autoComplete="current-password" required />
</div>
{/* ── Step 1: Phone ── */}
{step === 'phone' && (
<form onSubmit={sendCode} className="space-y-4">
<input
type="tel" inputMode="numeric" value={phone}
onChange={e => setPhone(applyPhoneMask(e.target.value))}
className={inputCls} placeholder="+7 (___) ___-__-__"
autoComplete="tel" autoFocus required
/>
{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 ? '...' : 'Войти'}
{loading ? 'Отправка...' : 'Получить код'}
</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(''); }}
{/* ── Step 2: Code ── */}
{step === 'code' && (
<form onSubmit={verifyCode} className="space-y-4">
<div className="text-center text-sm text-gray-500 mb-2">
Код отправлен на <span className="font-medium text-gray-800">{phone}</span>
<button type="button" onClick={() => { setStep('phone'); 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">
<input
type="text" inputMode="numeric" maxLength={4} value={code}
onChange={e => 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
/>
<div className="text-center">
{countdown > 0
? <span className="text-xs text-gray-400">Повторная отправка через {countdown} сек.</span>
: <button type="button" onClick={handleResendCode} disabled={loading}
: <button type="button" onClick={() => sendCode()} 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}
<button type="submit" disabled={loading || code.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 ? '...' : 'Зарегистрироваться'}
{loading ? '...' : 'Продолжить'}
</button>
</form>
)}
{/* ── Step 3: Name (new user only) ── */}
{step === 'name' && (
<form onSubmit={e => verifyCode(e, name)} className="space-y-4">
<p className="text-sm text-gray-500 text-center">Вы регистрируетесь впервые</p>
<input
type="text" value={name}
onChange={e => setName(e.target.value)}
className={inputCls} placeholder="Ваше имя"
autoComplete="name" autoFocus required
/>
{error && <div className="bg-red-50 text-red-600 text-sm px-4 py-3 rounded-xl">{error}</div>}
<button type="submit" disabled={loading || !name.trim()}
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>
)}