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 */ +
+
+ +
+ + setRegPassword(e.target.value)} + placeholder="Mínimo 6 caracteres" + className="w-full glass-input rounded-lg p-3 pl-10 pr-10 text-xs text-white focus:outline-none" + /> + +
+
+ +
+ ) : isForgotPassword ? ( + /* FORGOT PASSWORD FORM */ +
+
+ +
+ + setLoginEmail(e.target.value)} + placeholder="email@exemplo.com ou 5511999999999" + className="w-full glass-input rounded-lg p-3 pl-10 text-xs text-white focus:outline-none" + /> +
+
+ +

+ Lembrou a senha?{' '} + +

+
+ ) : isRegister ? ( /* REGISTER FORM */
@@ -495,6 +625,16 @@ export default function App() {
+
+ +
+ + @@ -1281,6 +1322,124 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
)} + + {/* 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).

+
+
+ +
+
+ +
+ + setSettings({...settings, smtpHost: e.target.value})} + className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#E50914] transition-colors" + placeholder="Ex: smtp.hostinger.com" + /> +
+ +
+ + setSettings({...settings, smtpPort: parseInt(e.target.value) || 587})} + className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#E50914] transition-colors" + placeholder="Ex: 465 ou 587" + /> +
+ +
+ + setSettings({...settings, smtpUser: e.target.value})} + className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#E50914] transition-colors" + placeholder="Ex: contato@seudominio.com.br" + /> +
+ +
+ + setSettings({...settings, smtpPass: e.target.value})} + className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#E50914] transition-colors" + placeholder="**************" + /> +
+ +
+ + setSettings({...settings, smtpFrom: e.target.value})} + className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#E50914] transition-colors" + placeholder="Ex: no-reply@seudominio.com.br" + /> +
+ +
+ +

Marque se a porta for 465. Para 587 (STARTTLS), normalmente fica desmarcado.

+
+ +
+ + {/* Test Results */} + {smtpTestResult && ( +
+ {smtpTestResult.message} +
+ )} + +
+ + +
+
+
+ )} )}