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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user