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:
Ai
2026-05-25 15:09:32 +03:00
parent 63f280f754
commit 2c362c06cd
3 changed files with 187 additions and 26 deletions

View File

@@ -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">Только латиница, цифры и _ (330 символов)</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>

View File

@@ -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>
);