diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index a8675cb..605b2d7 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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 = {а:'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( diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx index c002f38..2d5c869 100644 --- a/frontend/src/components/ProfileModal.tsx +++ b/frontend/src/components/ProfileModal.tsx @@ -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 */}
-
- -
@{user?.username}
-
+
+ +
+ @ + 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} + /> +
+

Только латиница, цифры и _ (3–30 символов)

+