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

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