From 4d130b5b5d6d6c6ff7f343343763e0e5fc41e3b0 Mon Sep 17 00:00:00 2001
From: Sidney Gomes
Date: Thu, 23 Jul 2026 22:05:10 -0300
Subject: [PATCH] Implementa fluxo de Esqueci a Senha duplo com e-mail SMTP e
notificacoes WhatsApp via Evolution API
---
package-lock.json | 21 ++++
package.json | 2 +
prisma/schema.prisma | 10 +-
server.ts | 156 +++++++++++++++++++++++++++-
src/App.tsx | 144 +++++++++++++++++++++++++-
src/components/AdminDashboard.tsx | 163 +++++++++++++++++++++++++++++-
6 files changed, 490 insertions(+), 6 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 31ba3cb..0aef6fd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"multer": "^1.4.5-lts.1",
+ "nodemailer": "^9.0.3",
"prisma": "^6.4.1",
"react": "^19.0.1",
"react-dom": "^19.0.1",
@@ -33,6 +34,7 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^1.4.12",
"@types/node": "^22.14.0",
+ "@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.21",
@@ -1990,6 +1992,16 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/nodemailer": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
+ "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/qs": {
"version": "6.15.1",
"dev": true,
@@ -3906,6 +3918,15 @@
"node": ">=18"
}
},
+ "node_modules/nodemailer": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
+ "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/nypm": {
"version": "0.6.8",
"license": "MIT",
diff --git a/package.json b/package.json
index e4e8b7c..61244f4 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"multer": "^1.4.5-lts.1",
+ "nodemailer": "^9.0.3",
"prisma": "^6.4.1",
"react": "^19.0.1",
"react-dom": "^19.0.1",
@@ -36,6 +37,7 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^1.4.12",
"@types/node": "^22.14.0",
+ "@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.21",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e5d0df0..8f2e96b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -58,8 +58,9 @@ model User {
birthDate String? @map("birth_date")
avatarUrl String? @map("avatar_url") @db.Text
trialEndsAt DateTime? @map("trial_ends_at")
+ resetPasswordToken String? @map("reset_password_token") @db.Text
+ resetPasswordExpires DateTime? @map("reset_password_expires")
createdAt DateTime @default(now()) @map("created_at")
-
notes Note[]
progress Progress[]
payments Payment[]
@@ -267,6 +268,13 @@ model SystemSettings {
cnpj String @default("") @map("cnpj")
address String @default("") @map("address")
+ smtpHost String? @map("smtp_host")
+ smtpPort Int? @default(587) @map("smtp_port")
+ smtpUser String? @map("smtp_user")
+ smtpPass String? @map("smtp_pass")
+ smtpFrom String? @default("no-reply@microtecinformaticacurso.com.br") @map("smtp_from")
+ smtpSecure Boolean @default(false) @map("smtp_secure")
+
@@map("system_settings")
}
diff --git a/server.ts b/server.ts
index 2da5935..a86bbd2 100644
--- a/server.ts
+++ b/server.ts
@@ -6,6 +6,8 @@ import multer from 'multer';
import axios from 'axios';
import { execSync } from 'child_process';
import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3';
+import crypto from 'crypto';
+import nodemailer from 'nodemailer';
import { prisma } from './src/db/prisma';
import { Prisma } from '@prisma/client';
import { AsaasService } from './src/services/asaas';
@@ -75,8 +77,27 @@ async function initMinio() {
}
}
// Start initialization async, don't block server start
+// Start initialization async, don't block server start
initMinio();
+// --- SMTP EMAILS CONFIGURATION ---
+async function getTransporter() {
+ const settings = await prisma.systemSettings.findFirst();
+ if (!settings || !settings.smtpHost) {
+ throw new Error('As credenciais SMTP não estão configuradas.');
+ }
+
+ return nodemailer.createTransport({
+ host: settings.smtpHost,
+ port: settings.smtpPort || 587,
+ secure: settings.smtpSecure,
+ auth: {
+ user: settings.smtpUser,
+ pass: settings.smtpPass,
+ },
+ });
+}
+
// --- JWT AUTH MIDDLEWARE ---
interface AuthenticatedRequest extends Request {
@@ -217,6 +238,103 @@ app.post('/api/auth/login', async (req: Request, res: Response) => {
}
});
+// 2.5 Auth: Forgot Password
+app.post('/api/auth/forgot-password', async (req: Request, res: Response) => {
+ const { identifier, channel } = req.body;
+
+ if (!identifier || !channel) {
+ res.status(400).json({ error: 'Informe o identificador e o canal (email ou whatsapp).' });
+ return;
+ }
+
+ try {
+ const user = await prisma.user.findFirst({
+ where: channel === 'whatsapp' ? { whatsapp: identifier } : { email: identifier.toLowerCase() }
+ });
+
+ if (!user) {
+ res.json({ success: true, message: 'Se o usuário existir, o link foi enviado.' });
+ return;
+ }
+
+ const token = crypto.randomBytes(32).toString('hex');
+ const expires = new Date(Date.now() + 3600000); // 1 hour
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { resetPasswordToken: token, resetPasswordExpires: expires }
+ });
+
+ const resetUrl = `https://estudo.microtecinformaticacurso.com.br/reset-password?token=${token}`;
+
+ if (channel === 'whatsapp') {
+ const text = `*Recuperação de Senha - MicrotecFlix*\n\nVocê solicitou a recuperação da sua senha.\nClique no link abaixo para criar uma nova senha:\n\n${resetUrl}\n\n_Se você não solicitou isso, ignore esta mensagem._`;
+ await sendWhatsappNotification(user.whatsapp || identifier, text);
+ } else {
+ const transporter = await getTransporter();
+ const settings = await prisma.systemSettings.findFirst();
+ await transporter.sendMail({
+ from: `"${settings?.brandName || 'Microtec'}Flix" <${settings?.smtpFrom || 'no-reply@microtecinformaticacurso.com.br'}>`,
+ to: user.email,
+ subject: 'Recuperação de Senha',
+ html: `
+
+
${settings?.brandName || 'MICROTEC'}FLIX
+
Recuperação de Senha
+
Você solicitou a recuperação da sua senha. Clique no botão abaixo para redefini-la (válido por 1 hora).
+
Redefinir Senha
+
Se você não solicitou, apenas ignore este e-mail.
+
+ `
+ });
+ }
+
+ res.json({ success: true, message: 'Link de recuperação enviado com sucesso.' });
+ } catch (error: any) {
+ console.error('Forgot password error:', error);
+ res.status(500).json({ error: error.message || 'Erro ao enviar link de recuperação.' });
+ }
+});
+
+// 2.6 Auth: Reset Password
+app.post('/api/auth/reset-password', async (req: Request, res: Response) => {
+ const { token, newPassword } = req.body;
+ if (!token || !newPassword) {
+ res.status(400).json({ error: 'Token e nova senha são obrigatórios.' });
+ return;
+ }
+
+ try {
+ const user = await prisma.user.findFirst({
+ where: {
+ resetPasswordToken: token,
+ resetPasswordExpires: { gte: new Date() }
+ }
+ });
+
+ if (!user) {
+ res.status(400).json({ error: 'Token inválido ou expirado.' });
+ return;
+ }
+
+ const hashedPassword = bcrypt.hashSync(newPassword, 10);
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: {
+ password: hashedPassword,
+ resetPasswordToken: null,
+ resetPasswordExpires: null
+ }
+ });
+
+ res.json({ success: true, message: 'Senha redefinida com sucesso!' });
+ } catch (error) {
+ console.error('Reset password error:', error);
+ res.status(500).json({ error: 'Erro ao redefinir senha.' });
+ }
+});
+
// 3. Auth: Me
app.get('/api/auth/me', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
if (!req.user) {
@@ -1062,7 +1180,13 @@ app.get('/api/admin/settings', authenticateToken, requireAdmin, async (req: Auth
brandName: settings?.brandName || 'MICROTEC',
brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.',
cnpj: settings?.cnpj || '',
- address: settings?.address || ''
+ address: settings?.address || '',
+ smtpHost: settings?.smtpHost || '',
+ smtpPort: settings?.smtpPort || 587,
+ smtpUser: settings?.smtpUser || '',
+ smtpPass: settings?.smtpPass || '',
+ smtpFrom: settings?.smtpFrom || 'no-reply@microtecinformaticacurso.com.br',
+ smtpSecure: settings?.smtpSecure || false
});
});
@@ -1095,6 +1219,36 @@ app.post('/api/admin/test-asaas', authenticateToken, requireAdmin, async (req: A
}
});
+app.post('/api/admin/test-smtp', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const { smtpHost, smtpPort, smtpSecure, smtpUser, smtpPass, smtpFrom } = req.body;
+
+ const transporter = nodemailer.createTransport({
+ host: smtpHost,
+ port: smtpPort,
+ secure: smtpSecure,
+ auth: {
+ user: smtpUser,
+ pass: smtpPass
+ }
+ });
+
+ await transporter.verify();
+
+ // Envia um email de teste para o próprio remetente (ou admin)
+ await transporter.sendMail({
+ from: smtpFrom,
+ to: req.user!.email,
+ subject: 'Teste de Conexão SMTP - MicrotecFlix',
+ text: 'Se você está recebendo este e-mail, sua configuração SMTP na MicrotecFlix foi realizada com sucesso!'
+ });
+
+ res.json({ success: true, message: 'Conexão estabelecida e e-mail de teste enviado com sucesso!' });
+ } catch (err: any) {
+ res.status(500).json({ success: false, message: err.message || 'Erro ao testar conexão SMTP' });
+ }
+});
+
// --- EVOLUTION API WHATSAPP ---
app.get('/api/admin/whatsapp/qrcode', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
diff --git a/src/App.tsx b/src/App.tsx
index 9269f46..06fd0ec 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -30,6 +30,8 @@ export default function App() {
// Authentication forms
const [isRegister, setIsRegister] = useState(false);
+ const [isForgotPassword, setIsForgotPassword] = useState(false);
+ const [resetToken, setResetToken] = useState(null);
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
@@ -50,6 +52,13 @@ export default function App() {
const [settings, setSettings] = useState<{ brandName?: string; brandSlogan?: string; cnpj?: string; address?: string } | null>(null);
useEffect(() => {
+ const urlParams = new URLSearchParams(window.location.search);
+ const tokenParam = urlParams.get('token');
+ if (window.location.pathname === '/reset-password' && tokenParam) {
+ setResetToken(tokenParam);
+ setIsForgotPassword(true);
+ }
+
fetch('/api/settings')
.then(res => res.json())
.then(data => {
@@ -111,6 +120,55 @@ export default function App() {
}
};
+ const handleForgotPassword = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setAuthError(null);
+ setAuthSuccess(null);
+ setAuthLoading(true);
+ try {
+ const res = await fetch('/api/auth/forgot-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ identifier: loginEmail, channel: loginEmail.includes('@') ? 'email' : 'whatsapp' })
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Erro ao solicitar recuperação.');
+ setAuthSuccess(data.message || 'Link de recuperação enviado.');
+ } catch (err: any) {
+ setAuthError(err.message);
+ } finally {
+ setAuthLoading(false);
+ }
+ };
+
+ const handleResetPassword = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (regPassword !== regPassword.trim() || regPassword.length < 6) {
+ setAuthError('A senha deve ter no mínimo 6 caracteres.');
+ return;
+ }
+ setAuthError(null);
+ setAuthSuccess(null);
+ setAuthLoading(true);
+ try {
+ const res = await fetch('/api/auth/reset-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: resetToken, newPassword: regPassword })
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Erro ao redefinir senha.');
+ setAuthSuccess(data.message || 'Senha redefinida com sucesso! Redirecionando...');
+ setTimeout(() => {
+ window.location.href = '/';
+ }, 3000);
+ } catch (err: any) {
+ setAuthError(err.message);
+ } finally {
+ setAuthLoading(false);
+ }
+ };
+
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setAuthError(null);
@@ -345,14 +403,86 @@ export default function App() {
- {/* Error indicators */}
{authError && (
{authError}
)}
+
+ {authSuccess && (
+
+ {authSuccess}
+
+ )}
- {isRegister ? (
+ {resetToken ? (
+ /* RESET PASSWORD FORM */
+
+ ) : isForgotPassword ? (
+ /* FORGOT PASSWORD FORM */
+
+ ) : isRegister ? (
/* REGISTER FORM */
)}
+
+ {/* TAB 10: SMTP CONFIGURATION */}
+ {activeTab === 'smtp_settings' && (
+
+
+
+
+
Configurações de Envio de E-mail (SMTP)
+
Credenciais para envio de e-mails transacionais (ex: Recuperação de Senha).
+
+
+
+
+
+ )}
>
)}