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> {
const apiUrl = process.env.API_URL ?? 'https://api.hotelsync.ru'
const confirmUrl = `${apiUrl}/api/auth/confirm-email?token=${token}`

View File

@@ -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',