From 02c3b7452ae539eab062b11401480fccc8a783a1 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 17 Mar 2026 18:43:05 +0300 Subject: [PATCH] Fix login page UX + add password reset flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/migrations/006_reset_password.sql | 3 + backend/src/email.ts | 46 ++++++ backend/src/routes/auth.ts | 80 ++++++++- src/App.tsx | 4 +- src/lib/api.ts | 6 + src/pages/LoginPage.tsx | 190 +++++++++++++++++----- src/pages/ResetPasswordPage.tsx | 150 +++++++++++++++++ 7 files changed, 439 insertions(+), 40 deletions(-) create mode 100644 backend/migrations/006_reset_password.sql create mode 100644 src/pages/ResetPasswordPage.tsx diff --git a/backend/migrations/006_reset_password.sql b/backend/migrations/006_reset_password.sql new file mode 100644 index 0000000..305fc84 --- /dev/null +++ b/backend/migrations/006_reset_password.sql @@ -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; diff --git a/backend/src/email.ts b/backend/src/email.ts index 824462d..5da98e2 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -10,6 +10,52 @@ const transporter = nodemailer.createTransport({ }, }) +export async function sendPasswordResetEmail(to: string, name: string, token: string): Promise { + 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: ` + + + +
+
+
🔑
+

HotelSync

+

Современная PMS-система

+
+
+

Сброс пароля

+

Привет, ${name}!

+

+ Мы получили запрос на сброс пароля для вашего аккаунта HotelSync. Нажмите на кнопку ниже, чтобы создать новый пароль. +

+ +

+ ⏱ Ссылка действительна 1 час.
+ Если вы не запрашивали сброс пароля — просто проигнорируйте это письмо. +

+
+

+ Кнопка не работает? Скопируйте ссылку в браузер:
+ ${resetUrl} +

+
+
+ +`, + }) +} + export async function sendConfirmationEmail(to: string, name: string, token: string): Promise { const apiUrl = process.env.API_URL ?? 'https://api.hotelsync.ru' const confirmUrl = `${apiUrl}/api/auth/confirm-email?token=${token}` diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index a40f082..fc6dfbe 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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', diff --git a/src/App.tsx b/src/App.tsx index 1ae9e48..dc4b185 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { {/* Public */} - } /> + } /> + } /> } /> } /> diff --git a/src/lib/api.ts b/src/lib/api.ts index abeb100..77b104f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 ───────────────────────────────────────────────────────────────── diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 1e08a31..06212ac 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -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 ( +
+ + {children} + {error && ( +

+ {error} +

+ )} +
+ ) +} // ── 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 }) { setEmail(e.target.value)} required + value={email} + onChange={e => { setEmail(e.target.value); setError('') }} + required />
- +
+ + +
setPassword(e.target.value)} required + onChange={e => { setPassword(e.target.value); setError('') }} + required />
)} + {resetDone && ( +
+ Пароль успешно изменён. Войдите с новым паролем. +
+ )} {tokenError === 'invalid_token' && (
Ссылка недействительна или уже использована. @@ -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 ( +
+
+ +
+

Письмо отправлено

+

+ Если аккаунт с адресом {email} существует, + на него придёт письмо со ссылкой для сброса пароля. +

+

Ссылка действительна 1 час.

+ +
+ ) + } + + return ( + <> + + +

Восстановление пароля

+

+ Введите email аккаунта — мы пришлём ссылку для сброса пароля +

+ +
+
+ + { setEmail(e.target.value); setError('') }} + required + autoFocus + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ + ) +} + // ── 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>({}) - const Field = ({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) => ( -
- - {children} - {error && ( -

- {error} -

- )} -
- ) - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() const err: Record = {} @@ -209,7 +319,7 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {

На адрес {email} отправлено - письмо с подтверждением. Менеджер HotelSync активирует аккаунт в течение одного рабочего дня. + письмо с подтверждением. Перейдите по ссылке в письме, чтобы активировать аккаунт.

- {/* Registration benefits highlight */} {mode === 'register' && (
{[ @@ -396,10 +511,9 @@ export function LoginPage() {
- {mode === 'login' - ? setMode('register')} /> - : setMode('login')} /> - } + {mode === 'login' && setMode('register')} onForgot={() => setMode('forgot')} />} + {mode === 'register' && setMode('login')} />} + {mode === 'forgot' && setMode('login')} />}
diff --git a/src/pages/ResetPasswordPage.tsx b/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..c8e681e --- /dev/null +++ b/src/pages/ResetPasswordPage.tsx @@ -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 ( +
+
+ +
+ +
+
+ {/* Logo */} +
+
+ +
+ + HotelSync + +
+ + {!token ? ( +
+
+ Ссылка недействительна. Запросите новое письмо. +
+ +
+ ) : done ? ( +
+
+ +
+

Пароль изменён!

+

+ Теперь вы можете войти с новым паролем. +

+ +
+ ) : ( + <> +

Новый пароль

+

Введите новый пароль для вашего аккаунта

+ +
+
+ +
+ { setPassword(e.target.value); setError('') }} + required + /> + +
+ {password && ( +
+ {[password.length >= 8, /[A-Z]/.test(password), /[0-9]/.test(password)].map((ok, i) => ( +
+ ))} +
+ )} +
+ +
+ + { setPassword2(e.target.value); setError('') }} + required + /> + {password2 && password !== password2 && ( +

Пароли не совпадают

+ )} +
+ + {error && ( +
+ {error} +
+ )} + + + + +

+ +

+ + )} +
+
+
+ ) +}