feat: registration, phone mask, username change
- Registration form on login page (tab toggle login/register) - POST /api/auth/register — creates account with phone+name+password - Phone mask +7 (XXX) XXX-XX-XX on login and profile pages - Username now editable in profile (latin/digits/_, 3-30 chars) - PUT /api/auth/me now accepts username with uniqueness validation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { pool } from '../db.js';
|
||||
|
||||
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316'];
|
||||
|
||||
function userDto(u: any) {
|
||||
return {
|
||||
id: u.id, username: u.username, displayName: u.display_name,
|
||||
@@ -14,6 +16,50 @@ function userDto(u: any) {
|
||||
}
|
||||
|
||||
export default async function authRoutes(app: FastifyInstance) {
|
||||
// Register
|
||||
app.post('/register', async (req, reply) => {
|
||||
const { phone, displayName, password } = req.body as any;
|
||||
if (!phone || !displayName || !password) {
|
||||
return reply.status(400).send({ error: 'Заполните все поля' });
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return reply.status(400).send({ error: 'Пароль минимум 6 символов' });
|
||||
}
|
||||
|
||||
// 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
|
||||
let base = displayName.toLowerCase()
|
||||
.replace(/[^a-z0-9а-яё]/gi, '_')
|
||||
.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()] || '_';
|
||||
}) || 'user';
|
||||
let username = base;
|
||||
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 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 *`,
|
||||
[username, displayName, hash, 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 || '';
|
||||
@@ -45,7 +91,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 } = req.body as any;
|
||||
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]);
|
||||
@@ -55,9 +101,25 @@ export default async function authRoutes(app: FastifyInstance) {
|
||||
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]);
|
||||
}
|
||||
|
||||
if (username) {
|
||||
if (!/^[a-z0-9_]{3,30}$/.test(username)) {
|
||||
return reply.status(400).send({ error: 'Логин: только латиница, цифры, _ (3–30 символов)' });
|
||||
}
|
||||
const { rows: [taken] } = await pool.query(
|
||||
'SELECT id FROM users WHERE username = $1 AND id != $2', [username, id]
|
||||
);
|
||||
if (taken) return reply.status(400).send({ error: 'Этот логин уже занят' });
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE users SET display_name = COALESCE($1, display_name), bio = COALESCE($2, bio), phone = COALESCE($3, phone), position = COALESCE($4, position) WHERE id = $5',
|
||||
[displayName, bio, phone, position, id]
|
||||
`UPDATE users SET
|
||||
display_name = COALESCE($1, display_name),
|
||||
bio = COALESCE($2, bio),
|
||||
phone = COALESCE($3, phone),
|
||||
position = COALESCE($4, position),
|
||||
username = COALESCE($5, username)
|
||||
WHERE id = $6`,
|
||||
[displayName, bio, phone, position, username || null, id]
|
||||
);
|
||||
|
||||
const { rows: [user] } = await pool.query(
|
||||
|
||||
@@ -6,6 +6,24 @@ import { wsClient } from '../api/ws';
|
||||
import api from '../api/client';
|
||||
import Avatar from './Avatar';
|
||||
|
||||
function applyPhoneMask(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const d = digits.startsWith('8') ? '7' + digits.slice(1)
|
||||
: digits.startsWith('7') ? digits
|
||||
: digits.length ? '7' + digits : '';
|
||||
if (!d) return '';
|
||||
let r = '+7';
|
||||
if (d.length <= 1) return r;
|
||||
r += ' (' + d.slice(1, Math.min(4, d.length));
|
||||
if (d.length < 4) return r;
|
||||
r += ') ' + d.slice(4, Math.min(7, d.length));
|
||||
if (d.length < 7) return r;
|
||||
r += '-' + d.slice(7, Math.min(9, d.length));
|
||||
if (d.length < 9) return r;
|
||||
r += '-' + d.slice(9, 11);
|
||||
return r;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -14,8 +32,9 @@ export default function ProfileModal({ onClose }: Props) {
|
||||
const { user, setUser, logout } = useStore();
|
||||
const navigate = useNavigate();
|
||||
const [displayName, setDisplayName] = useState(user?.displayName || '');
|
||||
const [username, setUsername] = useState(user?.username || '');
|
||||
const [bio, setBio] = useState(user?.bio || '');
|
||||
const [phone, setPhone] = useState(user?.phone || '');
|
||||
const [phone, setPhone] = useState(applyPhoneMask(user?.phone || ''));
|
||||
const [position, setPosition] = useState(user?.position || '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
@@ -30,7 +49,7 @@ export default function ProfileModal({ onClose }: Props) {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const body: any = { displayName, bio, phone, position: position || null };
|
||||
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;
|
||||
@@ -134,10 +153,6 @@ export default function ProfileModal({ onClose }: Props) {
|
||||
|
||||
{/* Info */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Логин</label>
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-xl text-sm text-gray-400">@{user?.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Имя</label>
|
||||
<input
|
||||
@@ -147,6 +162,20 @@ export default function ProfileModal({ onClose }: Props) {
|
||||
placeholder="Ваше имя"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Логин (@username)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">@</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
|
||||
className="w-full pl-7 pr-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
placeholder="username"
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Только латиница, цифры и _ (3–30 символов)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">О себе</label>
|
||||
<textarea
|
||||
@@ -160,10 +189,12 @@ export default function ProfileModal({ onClose }: Props) {
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Телефон</label>
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
onChange={e => setPhone(applyPhoneMask(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="+7..."
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -7,14 +7,38 @@ interface Props {
|
||||
onLogin: (token: string, user: User) => void;
|
||||
}
|
||||
|
||||
function applyPhoneMask(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const d = digits.startsWith('8') ? '7' + digits.slice(1)
|
||||
: digits.startsWith('7') ? digits
|
||||
: digits.length ? '7' + digits : '';
|
||||
if (!d) return '';
|
||||
let r = '+7';
|
||||
if (d.length <= 1) return r;
|
||||
r += ' (' + d.slice(1, Math.min(4, d.length));
|
||||
if (d.length < 4) return r;
|
||||
r += ') ' + d.slice(4, Math.min(7, d.length));
|
||||
if (d.length < 7) return r;
|
||||
r += '-' + d.slice(7, Math.min(9, d.length));
|
||||
if (d.length < 9) return r;
|
||||
r += '-' + d.slice(9, 11);
|
||||
return r;
|
||||
}
|
||||
|
||||
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('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
function handlePhoneChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setPhone(applyPhoneMask(e.target.value));
|
||||
}
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
@@ -29,28 +53,76 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post('/api/auth/register', { phone, password, displayName });
|
||||
onLogin(data.token, data.user);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Ошибка регистрации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
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-8">
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">JaniChat</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Войдите в аккаунт</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form onSubmit={tab === 'login' ? handleLogin : handleRegister} className="space-y-4">
|
||||
{tab === 'register' && (
|
||||
<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
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Номер телефона</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
className="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"
|
||||
placeholder="+7..."
|
||||
onChange={handlePhoneChange}
|
||||
className={inputCls}
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -61,9 +133,9 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="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"
|
||||
placeholder="Введите пароль"
|
||||
autoComplete="current-password"
|
||||
className={inputCls}
|
||||
placeholder={tab === 'register' ? 'Минимум 6 символов' : 'Введите пароль'}
|
||||
autoComplete={tab === 'register' ? 'new-password' : 'current-password'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -79,13 +151,9 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
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 ? '...' : tab === 'login' ? 'Войти' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">
|
||||
Доступ только для зарегистрированных пользователей
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user