- Reject pure-numeric passwords (must contain ≥1 letter) in RegisterForm, ResetPasswordPage, and backend register/reset-password handlers - Replace bare strength bars with labeled 2×2 grid: ≥8 символов, Содержит букву, Заглавная буква, Цифра - Auto-capitalize first letter of each word in "Название отеля" and "Контактное лицо" fields (autoCapitalize="words" + JS handler) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
160 lines
7.9 KiB
TypeScript
160 lines
7.9 KiB
TypeScript
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 (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) { setError('Пароль должен содержать хотя бы одну букву'); 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-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||
{[
|
||
{ ok: password.length >= 8, label: '≥ 8 символов' },
|
||
{ ok: /[a-zA-Zа-яА-ЯёЁ]/.test(password), label: 'Содержит букву' },
|
||
{ ok: /[A-ZА-ЯЁ]/.test(password), label: 'Заглавная буква' },
|
||
{ ok: /[0-9]/.test(password), label: 'Цифра' },
|
||
].map(({ ok, label }) => (
|
||
<div key={label} className="flex items-center gap-1.5 text-xs">
|
||
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0 transition-colors', ok ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600')} />
|
||
<span className={cn('transition-colors', ok ? 'text-emerald-700 dark:text-emerald-400' : 'text-slate-400 dark:text-slate-500')}>{label}</span>
|
||
</div>
|
||
))}
|
||
</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>
|
||
)
|
||
}
|