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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user