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:
3
backend/migrations/006_reset_password.sql
Normal file
3
backend/migrations/006_reset_password.sql
Normal 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;
|
||||
@@ -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> {
|
||||
const apiUrl = process.env.API_URL ?? 'https://api.hotelsync.ru'
|
||||
const confirmUrl = `${apiUrl}/api/auth/confirm-email?token=${token}`
|
||||
|
||||
@@ -4,7 +4,7 @@ import crypto from 'crypto'
|
||||
import { db } from '../db'
|
||||
import { redis } from '../redis'
|
||||
import { config } from '../config'
|
||||
import { sendConfirmationEmail } from '../email'
|
||||
import { sendConfirmationEmail, sendPasswordResetEmail } from '../email'
|
||||
import type { JwtPayload } from '../types'
|
||||
|
||||
const auth: FastifyPluginAsync = async (fastify) => {
|
||||
@@ -251,6 +251,84 @@ const auth: FastifyPluginAsync = async (fastify) => {
|
||||
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 ───────────────────────────────────────────────────────
|
||||
fastify.get(
|
||||
'/api/auth/me',
|
||||
|
||||
@@ -35,6 +35,7 @@ import { MaintenancePage } from './pages/MaintenancePage'
|
||||
import { DiscountsPage } from './pages/DiscountsPage'
|
||||
import { GuestReviewPage } from './pages/GuestReviewPage'
|
||||
import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
|
||||
import { ResetPasswordPage } from './pages/ResetPasswordPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -46,7 +47,8 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* 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="/room-service/:slug" element={<GuestRoomServicePage />} />
|
||||
|
||||
|
||||
@@ -155,6 +155,12 @@ export const api = {
|
||||
password: string
|
||||
}) =>
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
|
||||
150
src/pages/ResetPasswordPage.tsx
Normal file
150
src/pages/ResetPasswordPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user