feat: adiciona fluxo de redefinição de senha e melhora página de configurações
- Adiciona página /recuperar para solicitar redefinição de senha - Adiciona página /redefinir-senha para definir nova senha com token - Cria APIs /api/auth/forgot-password e /api/auth/reset-password - Adiciona notificação por e-mail quando ticket é criado - Repagina página de configurações removendo informações técnicas - Adiciona script de teste para todos os tipos de e-mail - Corrige acentuações em templates de e-mail 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
300179279a
commit
1bc08d3a5f
10 changed files with 1258 additions and 166 deletions
101
src/app/api/auth/forgot-password/route.ts
Normal file
101
src/app/api/auth/forgot-password/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import crypto from "crypto"
|
||||
|
||||
import { render } from "@react-email/render"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { sendSmtpMail } from "@/server/email-smtp"
|
||||
import SimpleNotificationEmail from "../../../../../emails/simple-notification-email"
|
||||
|
||||
function getSmtpConfig() {
|
||||
const host = process.env.SMTP_HOST ?? process.env.SMTP_ADDRESS
|
||||
const port = process.env.SMTP_PORT
|
||||
const username = process.env.SMTP_USER ?? process.env.SMTP_USERNAME
|
||||
const password = process.env.SMTP_PASS ?? process.env.SMTP_PASSWORD
|
||||
const fromEmail = process.env.SMTP_FROM_EMAIL ?? process.env.MAILER_SENDER_EMAIL
|
||||
const fromName = process.env.SMTP_FROM_NAME ?? "Raven"
|
||||
|
||||
if (!host || !port || !username || !password || !fromEmail) return null
|
||||
|
||||
return {
|
||||
host,
|
||||
port: Number(port),
|
||||
username,
|
||||
password,
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
tls: process.env.SMTP_SECURE === "true",
|
||||
starttls: process.env.SMTP_SECURE !== "true",
|
||||
rejectUnauthorized: false,
|
||||
timeoutMs: 15000,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { email } = body
|
||||
|
||||
if (!email || typeof email !== "string") {
|
||||
return NextResponse.json({ error: "E-mail é obrigatório" }, { status: 400 })
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim()
|
||||
|
||||
// Busca o usuário pelo e-mail (sem revelar se existe ou não por segurança)
|
||||
const user = await prisma.authUser.findFirst({
|
||||
where: { email: normalizedEmail },
|
||||
})
|
||||
|
||||
// Sempre retorna sucesso para não revelar se o e-mail existe
|
||||
if (!user) {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
// Gera um token seguro
|
||||
const token = crypto.randomBytes(32).toString("hex")
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hora
|
||||
|
||||
// Remove tokens anteriores do mesmo usuário
|
||||
await prisma.authVerification.deleteMany({
|
||||
where: {
|
||||
identifier: `password-reset:${user.id}`,
|
||||
},
|
||||
})
|
||||
|
||||
// Salva o novo token
|
||||
await prisma.authVerification.create({
|
||||
data: {
|
||||
identifier: `password-reset:${user.id}`,
|
||||
value: token,
|
||||
expiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
// Envia o e-mail
|
||||
const smtp = getSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.error("[FORGOT_PASSWORD] SMTP não configurado")
|
||||
return NextResponse.json({ success: true }) // Não revela erro de configuração
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://tickets.esdrasrenan.com.br"
|
||||
const resetUrl = `${baseUrl}/redefinir-senha?token=${token}`
|
||||
|
||||
const html = await render(
|
||||
SimpleNotificationEmail({
|
||||
title: "Redefinição de Senha",
|
||||
message: `Olá, ${user.name ?? "usuário"}!\n\nRecebemos uma solicitação para redefinir a senha da sua conta.\n\nSe você não fez essa solicitação, pode ignorar este e-mail com segurança.\n\nEste link expira em 1 hora.`,
|
||||
ctaLabel: "Redefinir Senha",
|
||||
ctaUrl: resetUrl,
|
||||
}),
|
||||
{ pretty: true }
|
||||
)
|
||||
|
||||
await sendSmtpMail(smtp, normalizedEmail, "Redefinição de Senha - Raven", html)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("[FORGOT_PASSWORD] Erro:", error)
|
||||
return NextResponse.json({ error: "Erro ao processar solicitação" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
97
src/app/api/auth/reset-password/route.ts
Normal file
97
src/app/api/auth/reset-password/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { hashPassword } from "better-auth/crypto"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { token, password } = body
|
||||
|
||||
if (!token || typeof token !== "string") {
|
||||
return NextResponse.json({ error: "Token inválido" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!password || typeof password !== "string" || password.length < 6) {
|
||||
return NextResponse.json({ error: "A senha deve ter pelo menos 6 caracteres" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Busca o token de verificação
|
||||
const verification = await prisma.authVerification.findFirst({
|
||||
where: {
|
||||
value: token,
|
||||
identifier: { startsWith: "password-reset:" },
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
|
||||
if (!verification) {
|
||||
return NextResponse.json({ error: "Token inválido ou expirado" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Extrai o userId do identifier
|
||||
const userId = verification.identifier.replace("password-reset:", "")
|
||||
|
||||
// Busca o usuário
|
||||
const user = await prisma.authUser.findUnique({
|
||||
where: { id: userId },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Hash da nova senha
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
// Atualiza a conta do usuário com a nova senha
|
||||
await prisma.authAccount.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
providerId: "credential",
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
})
|
||||
|
||||
// Remove o token usado
|
||||
await prisma.authVerification.delete({
|
||||
where: { id: verification.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("[RESET_PASSWORD] Erro:", error)
|
||||
return NextResponse.json({ error: "Erro ao redefinir senha" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// GET para validar se o token é válido (usado pela página)
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const token = searchParams.get("token")
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ valid: false, error: "Token não fornecido" })
|
||||
}
|
||||
|
||||
const verification = await prisma.authVerification.findFirst({
|
||||
where: {
|
||||
value: token,
|
||||
identifier: { startsWith: "password-reset:" },
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
|
||||
if (!verification) {
|
||||
return NextResponse.json({ valid: false, error: "Token inválido ou expirado" })
|
||||
}
|
||||
|
||||
return NextResponse.json({ valid: true })
|
||||
} catch (error) {
|
||||
console.error("[RESET_PASSWORD] Erro ao validar token:", error)
|
||||
return NextResponse.json({ valid: false, error: "Erro ao validar token" })
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue