Files
hotelsync/src/pages/ResetPasswordPage.tsx
HotelSync 02c3b7452a 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>
2026-03-17 18:43:05 +03:00

151 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}