Fix login page UX + add password reset flow

- Move Field component outside RegisterForm — fixes focus loss on every keystroke
- Remove setError('') at submit start — fixes error "flash" that looked like page reload
- Clear errors onChange instead, so error persists until user starts correcting
- Add 'Forgot password?' link in LoginForm
- Add ForgotPasswordForm (email input, success state)
- Add ResetPasswordPage at /reset-password?token=...
- Backend: POST /api/auth/forgot-password — generates 1h token, sends email
- Backend: POST /api/auth/reset-password — validates token, updates password hash
- Backend: migration 006 — reset_token/reset_token_expires columns
- Backend: sendPasswordResetEmail in email.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 18:43:05 +03:00
parent 42dc4518e2
commit 02c3b7452a
7 changed files with 439 additions and 40 deletions

View File

@@ -1,8 +1,8 @@
import { useState } from 'react'
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
import {
Hotel, Eye, EyeOff, Sun, Moon, AlertCircle,
Building2, ChevronRight, ChevronLeft, CheckCircle2, User,
Hotel, Eye, EyeOff, Moon, Sun, AlertCircle,
CheckCircle2, ArrowLeft,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
@@ -15,9 +15,7 @@ const DEMO_EMAILS = [
'admin@hotelsync.io',
]
type LegalType = 'ooo' | 'ip' | 'ao' | 'pao' | 'other'
const LEGAL_TYPES: { id: LegalType; label: string }[] = [
{ id: 'ooo', label: 'ООО' },
{ id: 'ip', label: 'ИП' },
@@ -25,9 +23,31 @@ const LEGAL_TYPES: { id: LegalType; label: string }[] = [
{ id: 'pao', label: 'ПАО' },
{ id: 'other', label: 'Другое'},
]
// suppress unused-import lint in case LEGAL_TYPES is used elsewhere
void LEGAL_TYPES
// ── Field component — defined OUTSIDE RegisterForm to prevent unmount on re-render
interface FieldProps {
label: string
error?: string
children: React.ReactNode
}
function Field({ label, error, children }: FieldProps) {
return (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{label}</label>
{children}
{error && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertCircle size={11} /> {error}
</p>
)}
</div>
)
}
// ── Login form ─────────────────────────────────────────────────────────────────
function LoginForm({ onSwitch }: { onSwitch: () => void }) {
function LoginForm({ onSwitch, onForgot }: { onSwitch: () => void; onForgot: () => void }) {
const { login } = useAuth()
const navigate = useNavigate()
@@ -37,12 +57,13 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [searchParams] = useSearchParams()
const confirmed = searchParams.get('confirmed') === '1'
const tokenError = searchParams.get('error')
const confirmed = searchParams.get('confirmed') === '1'
const resetDone = searchParams.get('reset') === 'success'
const tokenError = searchParams.get('error')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
// don't clear error here — clear it onChange; clearing here causes "flash"
setLoading(true)
try {
const loggedIn = await login(email, password)
@@ -50,7 +71,8 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) {
navigate('/calendar')
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка соединения'
setError(`Ошибка сервера: ${msg}`)
// Strip "Ошибка сервера:" prefix for clean 403 messages from backend
setError(msg)
} finally {
setLoading(false)
}
@@ -70,16 +92,28 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) {
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Email</label>
<input
type="email" className="input" placeholder="email@example.com"
value={email} onChange={e => setEmail(e.target.value)} required
value={email}
onChange={e => { setEmail(e.target.value); setError('') }}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Пароль</label>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Пароль</label>
<button
type="button"
onClick={onForgot}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
Забыли пароль?
</button>
</div>
<div className="relative">
<input
type={showPass ? 'text' : 'password'} className="input pr-10"
placeholder="••••••••" value={password}
onChange={e => setPassword(e.target.value)} required
onChange={e => { setPassword(e.target.value); setError('') }}
required
/>
<button type="button" onClick={() => setShowPass(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
@@ -99,6 +133,11 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) {
<CheckCircle2 size={15} /> Email подтверждён! Теперь вы можете войти.
</div>
)}
{resetDone && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 text-sm">
<CheckCircle2 size={15} /> Пароль успешно изменён. Войдите с новым паролем.
</div>
)}
{tokenError === 'invalid_token' && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 text-sm">
<AlertCircle size={15} /> Ссылка недействительна или уже использована.
@@ -146,6 +185,89 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) {
)
}
// ── Forgot password form ────────────────────────────────────────────────────────
function ForgotPasswordForm({ onBack }: { onBack: () => void }) {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [done, setDone] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await api.auth.forgotPassword(email)
setDone(true)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка'
setError(msg)
} finally {
setLoading(false)
}
}
if (done) {
return (
<div className="text-center py-4">
<div className="w-16 h-16 rounded-full bg-emerald-100 dark:bg-emerald-900/40 flex items-center justify-center mx-auto mb-5">
<CheckCircle2 size={32} className="text-emerald-600 dark:text-emerald-400" />
</div>
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-2">Письмо отправлено</h2>
<p className="text-slate-500 dark:text-slate-400 mb-2 text-sm">
Если аккаунт с адресом <span className="font-medium text-slate-700 dark:text-slate-300">{email}</span> существует,
на него придёт письмо со ссылкой для сброса пароля.
</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-8">Ссылка действительна 1 час.</p>
<button onClick={onBack} className="btn-primary w-full justify-center py-2.5">
Вернуться к входу
</button>
</div>
)
}
return (
<>
<button
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 mb-6 -ml-1"
>
<ArrowLeft size={15} /> Назад
</button>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">Восстановление пароля</h2>
<p className="text-slate-500 dark:text-slate-400 mb-8">
Введите email аккаунта мы пришлём ссылку для сброса пароля
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Email</label>
<input
type="email" className="input" placeholder="email@example.com"
value={email}
onChange={e => { setEmail(e.target.value); setError('') }}
required
autoFocus
/>
</div>
{error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} /> {error}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full justify-center py-2.5">
{loading
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
: 'Отправить ссылку'}
</button>
</form>
</>
)
}
// ── Register form ──────────────────────────────────────────────────────────────
function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
const [done, setDone] = useState(false)
@@ -159,18 +281,6 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const Field = ({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) => (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{label}</label>
{children}
{error && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertCircle size={11} /> {error}
</p>
)}
</div>
)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const err: Record<string, string> = {}
@@ -209,7 +319,7 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-8">
На адрес <span className="font-medium text-slate-700 dark:text-slate-300">{email}</span> отправлено
письмо с подтверждением. Менеджер HotelSync активирует аккаунт в течение одного рабочего дня.
письмо с подтверждением. Перейдите по ссылке в письме, чтобы активировать аккаунт.
</p>
<button onClick={onSwitch} className="btn-primary w-full justify-center py-2.5">
Перейти к входу
@@ -229,7 +339,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
type="text"
className={cn('input', errors.hotelName && 'border-red-400 focus:ring-red-400')}
placeholder="Grand Palace Hotel"
value={hotelName} onChange={e => setHotelName(e.target.value)}
value={hotelName}
onChange={e => { setHotelName(e.target.value); setErrors(prev => ({ ...prev, hotelName: '' })) }}
/>
</Field>
@@ -237,7 +348,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
<input
type="text" className="input"
placeholder="г. Москва, ул. Примерная, д. 1"
value={address} onChange={e => setAddress(e.target.value)}
value={address}
onChange={e => setAddress(e.target.value)}
/>
</Field>
@@ -246,7 +358,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
type="text"
className={cn('input', errors.contact && 'border-red-400 focus:ring-red-400')}
placeholder="Иванов Иван Иванович"
value={contact} onChange={e => setContact(e.target.value)}
value={contact}
onChange={e => { setContact(e.target.value); setErrors(prev => ({ ...prev, contact: '' })) }}
/>
</Field>
@@ -255,7 +368,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
type="email"
className={cn('input', errors.email && 'border-red-400 focus:ring-red-400')}
placeholder="director@myhotel.ru"
value={email} onChange={e => setEmail(e.target.value)}
value={email}
onChange={e => { setEmail(e.target.value); setErrors(prev => ({ ...prev, email: '' })) }}
/>
</Field>
@@ -263,7 +377,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
<input
type="tel" className="input"
placeholder="+7 (999) 000-00-00"
value={phone} onChange={e => setPhone(e.target.value)}
value={phone}
onChange={e => setPhone(e.target.value)}
/>
</Field>
@@ -273,7 +388,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
type={showPass ? 'text' : 'password'}
className={cn('input pr-10', errors.password && 'border-red-400 focus:ring-red-400')}
placeholder="Минимум 8 символов"
value={password} onChange={e => setPassword(e.target.value)}
value={password}
onChange={e => { setPassword(e.target.value); setErrors(prev => ({ ...prev, password: '' })) }}
/>
<button type="button" onClick={() => setShowPass(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
@@ -315,9 +431,9 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
// ── Page ───────────────────────────────────────────────────────────────────────
export function LoginPage() {
const { user } = useAuth()
const { user } = useAuth()
const { theme, toggle } = useTheme()
const [mode, setMode] = useState<'login' | 'register'>('login')
const [mode, setMode] = useState<'login' | 'register' | 'forgot'>('login')
if (user) {
return <Navigate to="/calendar" replace />
@@ -356,7 +472,6 @@ export function LoginPage() {
))}
</div>
{/* Registration benefits highlight */}
{mode === 'register' && (
<div className="mt-10 space-y-3">
{[
@@ -396,10 +511,9 @@ export function LoginPage() {
</span>
</div>
{mode === 'login'
? <LoginForm onSwitch={() => setMode('register')} />
: <RegisterForm onSwitch={() => setMode('login')} />
}
{mode === 'login' && <LoginForm onSwitch={() => setMode('register')} onForgot={() => setMode('forgot')} />}
{mode === 'register' && <RegisterForm onSwitch={() => setMode('login')} />}
{mode === 'forgot' && <ForgotPasswordForm onBack={() => setMode('login')} />}
</div>
</div>
</div>

View File

@@ -0,0 +1,150 @@
import { useState } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { Hotel, Eye, EyeOff, AlertCircle, CheckCircle2, Moon, Sun } from 'lucide-react'
import { useTheme } from '../contexts/ThemeContext'
import { api } from '../lib/api'
import { cn } from '../lib/utils'
export function ResetPasswordPage() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { theme, toggle } = useTheme()
const token = searchParams.get('token') ?? ''
const [password, setPassword] = useState('')
const [password2, setPassword2] = useState('')
const [showPass, setShowPass] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [done, setDone] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (password.length < 8) { setError('Минимум 8 символов'); return }
if (password !== password2) { setError('Пароли не совпадают'); return }
setError('')
setLoading(true)
try {
await api.auth.resetPassword(token, password)
setDone(true)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка'
setError(msg)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-brand-50 via-white to-slate-100 dark:from-slate-900 dark:via-slate-900 dark:to-brand-950 flex flex-col">
<div className="flex justify-end p-4">
<button onClick={toggle} className="btn-ghost p-2">
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
</button>
</div>
<div className="flex-1 flex items-center justify-center px-6">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="flex items-center gap-2.5 mb-8 justify-center">
<div className="w-9 h-9 rounded-xl bg-brand-600 flex items-center justify-center">
<Hotel size={18} className="text-white" />
</div>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
Hotel<span className="text-brand-600">Sync</span>
</span>
</div>
{!token ? (
<div className="text-center py-6">
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm mb-6">
<AlertCircle size={15} /> Ссылка недействительна. Запросите новое письмо.
</div>
<button onClick={() => navigate('/login')} className="btn-primary w-full justify-center py-2.5">
На страницу входа
</button>
</div>
) : done ? (
<div className="text-center py-6">
<div className="w-16 h-16 rounded-full bg-emerald-100 dark:bg-emerald-900/40 flex items-center justify-center mx-auto mb-5">
<CheckCircle2 size={32} className="text-emerald-600 dark:text-emerald-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">Пароль изменён!</h2>
<p className="text-slate-500 dark:text-slate-400 mb-8">
Теперь вы можете войти с новым паролем.
</p>
<button onClick={() => navigate('/login')} className="btn-primary w-full justify-center py-2.5">
Войти
</button>
</div>
) : (
<>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">Новый пароль</h2>
<p className="text-slate-500 dark:text-slate-400 mb-8">Введите новый пароль для вашего аккаунта</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Новый пароль</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
className="input pr-10"
placeholder="Минимум 8 символов"
value={password}
onChange={e => { setPassword(e.target.value); setError('') }}
required
/>
<button type="button" onClick={() => setShowPass(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{password && (
<div className="mt-1.5 flex gap-1">
{[password.length >= 8, /[A-Z]/.test(password), /[0-9]/.test(password)].map((ok, i) => (
<div key={i} className={cn('h-1 flex-1 rounded-full transition-colors', ok ? 'bg-emerald-500' : 'bg-slate-200 dark:bg-slate-600')} />
))}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Повторите пароль</label>
<input
type={showPass ? 'text' : 'password'}
className={cn('input', password2 && password !== password2 && 'border-red-400 focus:ring-red-400')}
placeholder="Повторите пароль"
value={password2}
onChange={e => { setPassword2(e.target.value); setError('') }}
required
/>
{password2 && password !== password2 && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">Пароли не совпадают</p>
)}
</div>
{error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} /> {error}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full justify-center py-2.5">
{loading
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
: 'Сохранить пароль'}
</button>
</form>
<p className="mt-5 text-center text-sm text-slate-500 dark:text-slate-400">
<button onClick={() => navigate('/login')} className="text-brand-600 dark:text-brand-400 font-medium hover:underline">
Вернуться к входу
</button>
</p>
</>
)}
</div>
</div>
</div>
)
}