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> {
|
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}`
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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