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

@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS reset_token VARCHAR(255),
ADD COLUMN IF NOT EXISTS reset_token_expires TIMESTAMPTZ;

View File

@@ -10,6 +10,52 @@ const transporter = nodemailer.createTransport({
}, },
}) })
export async function sendPasswordResetEmail(to: string, name: string, token: string): Promise<void> {
const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru'
const resetUrl = `${appUrl}/reset-password?token=${token}`
const fromAddr = process.env.SMTP_USER ?? 'noreply@hotelsync.ru'
await transporter.sendMail({
from: `"HotelSync" <${fromAddr}>`,
to,
subject: 'Сброс пароля — HotelSync',
html: `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:Arial,sans-serif;background:#f1f5f9;margin:0;padding:40px 16px;">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
<div style="background:linear-gradient(135deg,#4f46e5,#6366f1);padding:32px 40px;text-align:center;">
<div style="display:inline-block;width:48px;height:48px;background:rgba(255,255,255,0.2);border-radius:12px;line-height:48px;font-size:24px;margin-bottom:12px;">🔑</div>
<h1 style="color:#fff;margin:0;font-size:26px;font-weight:700;letter-spacing:-0.5px;">HotelSync</h1>
<p style="color:#c7d2fe;margin:6px 0 0;font-size:14px;">Современная PMS-система</p>
</div>
<div style="padding:40px;">
<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Сброс пароля</h2>
<p style="color:#475569;line-height:1.7;margin:0 0 8px;font-size:15px;">Привет, ${name}!</p>
<p style="color:#475569;line-height:1.7;margin:0 0 28px;font-size:15px;">
Мы получили запрос на сброс пароля для вашего аккаунта HotelSync. Нажмите на кнопку ниже, чтобы создать новый пароль.
</p>
<div style="text-align:center;margin:0 0 28px;">
<a href="${resetUrl}" style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;padding:14px 36px;border-radius:10px;font-weight:600;font-size:16px;letter-spacing:0.2px;">
Создать новый пароль
</a>
</div>
<p style="color:#94a3b8;font-size:13px;margin:0 0 16px;line-height:1.6;background:#f8fafc;padding:12px 16px;border-radius:8px;border-left:3px solid #e2e8f0;">
⏱ Ссылка действительна <strong>1 час</strong>.<br>
Если вы не запрашивали сброс пароля — просто проигнорируйте это письмо.
</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;">
<p style="color:#94a3b8;font-size:12px;margin:0;line-height:1.6;">
Кнопка не работает? Скопируйте ссылку в браузер:<br>
<a href="${resetUrl}" style="color:#6366f1;word-break:break-all;font-size:11px;">${resetUrl}</a>
</p>
</div>
</div>
</body>
</html>`,
})
}
export async function sendConfirmationEmail(to: string, name: string, token: string): Promise<void> { export async function sendConfirmationEmail(to: string, name: string, token: string): Promise<void> {
const apiUrl = process.env.API_URL ?? 'https://api.hotelsync.ru' const apiUrl = process.env.API_URL ?? 'https://api.hotelsync.ru'
const confirmUrl = `${apiUrl}/api/auth/confirm-email?token=${token}` const confirmUrl = `${apiUrl}/api/auth/confirm-email?token=${token}`

View File

@@ -4,7 +4,7 @@ import crypto from 'crypto'
import { db } from '../db' import { db } from '../db'
import { redis } from '../redis' import { redis } from '../redis'
import { config } from '../config' import { config } from '../config'
import { sendConfirmationEmail } from '../email' import { sendConfirmationEmail, sendPasswordResetEmail } from '../email'
import type { JwtPayload } from '../types' import type { JwtPayload } from '../types'
const auth: FastifyPluginAsync = async (fastify) => { const auth: FastifyPluginAsync = async (fastify) => {
@@ -251,6 +251,84 @@ const auth: FastifyPluginAsync = async (fastify) => {
return { ok: true } return { ok: true }
}) })
// ── POST /api/auth/forgot-password ────────────────────────────────────────
fastify.post<{ Body: { email: string } }>(
'/api/auth/forgot-password',
{
schema: {
body: {
type: 'object',
required: ['email'],
properties: { email: { type: 'string' } },
},
},
},
async (request, reply) => {
const { email } = request.body
const { rows } = await db.query(
`SELECT id, name FROM users WHERE email = $1 AND email_confirmed = true`,
[email.toLowerCase().trim()],
)
// Always return 200 to prevent email enumeration
if (rows.length === 0) return { ok: true }
const user = rows[0]
const resetToken = crypto.randomBytes(32).toString('hex')
const expires = new Date(Date.now() + 3600 * 1000) // 1 hour
await db.query(
`UPDATE users SET reset_token = $1, reset_token_expires = $2 WHERE id = $3`,
[resetToken, expires, user.id],
)
try {
await sendPasswordResetEmail(email, user.name as string, resetToken)
} catch (err) {
console.error('[email] Password reset email failed:', err)
}
return reply.code(200).send({ ok: true })
},
)
// ── POST /api/auth/reset-password ─────────────────────────────────────────
fastify.post<{ Body: { token: string; password: string } }>(
'/api/auth/reset-password',
{
schema: {
body: {
type: 'object',
required: ['token', 'password'],
properties: {
token: { type: 'string' },
password: { type: 'string', minLength: 8 },
},
},
},
},
async (request, reply) => {
const { token, password } = request.body
const { rows } = await db.query(
`SELECT id FROM users
WHERE reset_token = $1
AND reset_token_expires > NOW()`,
[token],
)
if (rows.length === 0) {
return reply.code(400).send({ error: 'Ссылка недействительна или истекла' })
}
const passwordHash = await bcrypt.hash(password, 12)
await db.query(
`UPDATE users SET password_hash = $1, reset_token = NULL, reset_token_expires = NULL WHERE id = $2`,
[passwordHash, rows[0].id],
)
return { ok: true }
},
)
// ── GET /api/auth/me ─────────────────────────────────────────────────────── // ── GET /api/auth/me ───────────────────────────────────────────────────────
fastify.get( fastify.get(
'/api/auth/me', '/api/auth/me',

View File

@@ -35,6 +35,7 @@ import { MaintenancePage } from './pages/MaintenancePage'
import { DiscountsPage } from './pages/DiscountsPage' import { DiscountsPage } from './pages/DiscountsPage'
import { GuestReviewPage } from './pages/GuestReviewPage' import { GuestReviewPage } from './pages/GuestReviewPage'
import { GuestRoomServicePage } from './pages/GuestRoomServicePage' import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
import { ResetPasswordPage } from './pages/ResetPasswordPage'
export default function App() { export default function App() {
return ( return (
@@ -46,7 +47,8 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
{/* Public */} {/* Public */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/review/:slug" element={<GuestReviewPage />} /> <Route path="/review/:slug" element={<GuestReviewPage />} />
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} /> <Route path="/room-service/:slug" element={<GuestRoomServicePage />} />

View File

@@ -155,6 +155,12 @@ export const api = {
password: string password: string
}) => }) =>
req<{ ok: boolean; message: string }>('POST', '/api/auth/register', data), req<{ ok: boolean; message: string }>('POST', '/api/auth/register', data),
forgotPassword: (email: string) =>
req<{ ok: boolean }>('POST', '/api/auth/forgot-password', { email }),
resetPassword: (token: string, password: string) =>
req<{ ok: boolean }>('POST', '/api/auth/reset-password', { token, password }),
}, },
// ── Rooms ───────────────────────────────────────────────────────────────── // ── Rooms ─────────────────────────────────────────────────────────────────

View File

@@ -1,8 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom' import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
import { import {
Hotel, Eye, EyeOff, Sun, Moon, AlertCircle, Hotel, Eye, EyeOff, Moon, Sun, AlertCircle,
Building2, ChevronRight, ChevronLeft, CheckCircle2, User, CheckCircle2, ArrowLeft,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext' import { useTheme } from '../contexts/ThemeContext'
@@ -15,9 +15,7 @@ const DEMO_EMAILS = [
'admin@hotelsync.io', 'admin@hotelsync.io',
] ]
type LegalType = 'ooo' | 'ip' | 'ao' | 'pao' | 'other' type LegalType = 'ooo' | 'ip' | 'ao' | 'pao' | 'other'
const LEGAL_TYPES: { id: LegalType; label: string }[] = [ const LEGAL_TYPES: { id: LegalType; label: string }[] = [
{ id: 'ooo', label: 'ООО' }, { id: 'ooo', label: 'ООО' },
{ id: 'ip', label: 'ИП' }, { id: 'ip', label: 'ИП' },
@@ -25,9 +23,31 @@ const LEGAL_TYPES: { id: LegalType; label: string }[] = [
{ id: 'pao', label: 'ПАО' }, { id: 'pao', label: 'ПАО' },
{ id: 'other', 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 ───────────────────────────────────────────────────────────────── // ── Login form ─────────────────────────────────────────────────────────────────
function LoginForm({ onSwitch }: { onSwitch: () => void }) { function LoginForm({ onSwitch, onForgot }: { onSwitch: () => void; onForgot: () => void }) {
const { login } = useAuth() const { login } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
@@ -37,12 +57,13 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const confirmed = searchParams.get('confirmed') === '1' const confirmed = searchParams.get('confirmed') === '1'
const tokenError = searchParams.get('error') const resetDone = searchParams.get('reset') === 'success'
const tokenError = searchParams.get('error')
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError('') // don't clear error here — clear it onChange; clearing here causes "flash"
setLoading(true) setLoading(true)
try { try {
const loggedIn = await login(email, password) const loggedIn = await login(email, password)
@@ -50,7 +71,8 @@ function LoginForm({ onSwitch }: { onSwitch: () => void }) {
navigate('/calendar') navigate('/calendar')
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка соединения' const msg = err instanceof Error ? err.message : 'Ошибка соединения'
setError(`Ошибка сервера: ${msg}`) // Strip "Ошибка сервера:" prefix for clean 403 messages from backend
setError(msg)
} finally { } finally {
setLoading(false) 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> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Email</label>
<input <input
type="email" className="input" placeholder="email@example.com" 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>
<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"> <div className="relative">
<input <input
type={showPass ? 'text' : 'password'} className="input pr-10" type={showPass ? 'text' : 'password'} className="input pr-10"
placeholder="••••••••" value={password} 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)} <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"> 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 подтверждён! Теперь вы можете войти. <CheckCircle2 size={15} /> Email подтверждён! Теперь вы можете войти.
</div> </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' && ( {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"> <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} /> Ссылка недействительна или уже использована. <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 ────────────────────────────────────────────────────────────── // ── Register form ──────────────────────────────────────────────────────────────
function RegisterForm({ onSwitch }: { onSwitch: () => void }) { function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
@@ -159,18 +281,6 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({}) 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
const err: Record<string, string> = {} const err: Record<string, string> = {}
@@ -209,7 +319,7 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
</p> </p>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-8"> <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> отправлено На адрес <span className="font-medium text-slate-700 dark:text-slate-300">{email}</span> отправлено
письмо с подтверждением. Менеджер HotelSync активирует аккаунт в течение одного рабочего дня. письмо с подтверждением. Перейдите по ссылке в письме, чтобы активировать аккаунт.
</p> </p>
<button onClick={onSwitch} className="btn-primary w-full justify-center py-2.5"> <button onClick={onSwitch} className="btn-primary w-full justify-center py-2.5">
Перейти к входу Перейти к входу
@@ -229,7 +339,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
type="text" type="text"
className={cn('input', errors.hotelName && 'border-red-400 focus:ring-red-400')} className={cn('input', errors.hotelName && 'border-red-400 focus:ring-red-400')}
placeholder="Grand Palace Hotel" 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> </Field>
@@ -237,7 +348,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
<input <input
type="text" className="input" type="text" className="input"
placeholder="г. Москва, ул. Примерная, д. 1" placeholder="г. Москва, ул. Примерная, д. 1"
value={address} onChange={e => setAddress(e.target.value)} value={address}
onChange={e => setAddress(e.target.value)}
/> />
</Field> </Field>
@@ -246,7 +358,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
type="text" type="text"
className={cn('input', errors.contact && 'border-red-400 focus:ring-red-400')} className={cn('input', errors.contact && 'border-red-400 focus:ring-red-400')}
placeholder="Иванов Иван Иванович" placeholder="Иванов Иван Иванович"
value={contact} onChange={e => setContact(e.target.value)} value={contact}
onChange={e => { setContact(e.target.value); setErrors(prev => ({ ...prev, contact: '' })) }}
/> />
</Field> </Field>
@@ -255,7 +368,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
type="email" type="email"
className={cn('input', errors.email && 'border-red-400 focus:ring-red-400')} className={cn('input', errors.email && 'border-red-400 focus:ring-red-400')}
placeholder="director@myhotel.ru" 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> </Field>
@@ -263,7 +377,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
<input <input
type="tel" className="input" type="tel" className="input"
placeholder="+7 (999) 000-00-00" placeholder="+7 (999) 000-00-00"
value={phone} onChange={e => setPhone(e.target.value)} value={phone}
onChange={e => setPhone(e.target.value)}
/> />
</Field> </Field>
@@ -273,7 +388,8 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
type={showPass ? 'text' : 'password'} type={showPass ? 'text' : 'password'}
className={cn('input pr-10', errors.password && 'border-red-400 focus:ring-red-400')} className={cn('input pr-10', errors.password && 'border-red-400 focus:ring-red-400')}
placeholder="Минимум 8 символов" 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)} <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"> 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 ─────────────────────────────────────────────────────────────────────── // ── Page ───────────────────────────────────────────────────────────────────────
export function LoginPage() { export function LoginPage() {
const { user } = useAuth() const { user } = useAuth()
const { theme, toggle } = useTheme() const { theme, toggle } = useTheme()
const [mode, setMode] = useState<'login' | 'register'>('login') const [mode, setMode] = useState<'login' | 'register' | 'forgot'>('login')
if (user) { if (user) {
return <Navigate to="/calendar" replace /> return <Navigate to="/calendar" replace />
@@ -356,7 +472,6 @@ export function LoginPage() {
))} ))}
</div> </div>
{/* Registration benefits highlight */}
{mode === 'register' && ( {mode === 'register' && (
<div className="mt-10 space-y-3"> <div className="mt-10 space-y-3">
{[ {[
@@ -396,10 +511,9 @@ export function LoginPage() {
</span> </span>
</div> </div>
{mode === 'login' {mode === 'login' && <LoginForm onSwitch={() => setMode('register')} onForgot={() => setMode('forgot')} />}
? <LoginForm onSwitch={() => setMode('register')} /> {mode === 'register' && <RegisterForm onSwitch={() => setMode('login')} />}
: <RegisterForm onSwitch={() => setMode('login')} /> {mode === 'forgot' && <ForgotPasswordForm onBack={() => setMode('login')} />}
}
</div> </div>
</div> </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>
)
}