From c3bffcbc6553b6a5f79d7fff59c3a38a51d322bd Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Thu, 23 Jul 2026 23:10:31 -0300 Subject: [PATCH] Create local password-recovery workspace skill for code reuse and documentation --- .agents/skills/password-recovery/SKILL.md | 284 ++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 .agents/skills/password-recovery/SKILL.md diff --git a/.agents/skills/password-recovery/SKILL.md b/.agents/skills/password-recovery/SKILL.md new file mode 100644 index 0000000..cf08adf --- /dev/null +++ b/.agents/skills/password-recovery/SKILL.md @@ -0,0 +1,284 @@ +--- +name: password-recovery +description: Complete dual-channel password recovery system (Email SMTP + WhatsApp Evolution API v2) with token generation and expiration. +--- + +# Dual-Channel Password Recovery System (Email SMTP + WhatsApp Evolution API v2) + +This skill documents how to build a complete, production-ready, dual-channel password recovery flow using **Email (SMTP)** and **WhatsApp (Evolution API v2)**. + +--- + +## 1. Database Schema (Prisma ORM) + +Add fields for tracking reset tokens in the `User` model, and fields to store SMTP configuration in the `SystemSettings` (SaaS tenant settings) model. + +```prisma +model User { + id String @id @default(cuid()) + email String @unique + whatsapp String? + password String + + // Password Recovery Fields + resetPasswordToken String? @db.Text + resetPasswordExpires DateTime? +} + +model SystemSettings { + id String @id @default("default") + + // SMTP Email Server Configurations + smtpHost String? + smtpPort Int? @default(587) + smtpUser String? + smtpPass String? + smtpFrom String? @default("no-reply@yourdomain.com") + smtpSecure Boolean @default(false) + + // Evolution API Configurations (for WhatsApp notifications) + evolutionApiUrl String @default("https://evolution.yourdomain.com") + evolutionApiKey String @default("") + evolutionInstance String @default("default_instance") + whatsappConnected Boolean @default(false) +} +``` + +--- + +## 2. Backend Implementation (Node.js & Express) + +### SMTP Transporter Setup (using `nodemailer`) +Dynamically create the SMTP transporter using the configurations fetched from the database: + +```typescript +import nodemailer from 'nodemailer'; + +async function getMailTransporter(settings: any) { + if (!settings || !settings.smtpHost || !settings.smtpUser || !settings.smtpPass) { + throw new Error('Configurações SMTP não preenchidas no banco de dados.'); + } + + return nodemailer.createTransport({ + host: settings.smtpHost, + port: settings.smtpPort || 587, + secure: settings.smtpSecure, // true for 465, false for other ports + auth: { + user: settings.smtpUser, + pass: settings.smtpPass, + }, + tls: { + rejectUnauthorized: false // Avoid SSL handshake errors on custom/VPS SMTP servers + } + }); +} +``` + +### Endpoint: Request Reset Token (`/api/auth/forgot-password`) +Generates a cryptographically secure token, saves it with a 1-hour expiration time, and triggers the delivery channel (Email or WhatsApp): + +```typescript +import crypto from 'crypto'; +import Express, { Request, Response } from 'express'; + +const app = Express(); + +app.post('/api/auth/forgot-password', async (req: Request, res: Response) => { + const { identifier, channel } = req.body; // channel: 'email' | 'whatsapp' + + if (!identifier || !channel) { + return res.status(400).json({ error: 'Identificador e canal são obrigatórios.' }); + } + + try { + let user; + if (channel === 'whatsapp') { + const cleanPhone = identifier.replace(/\D/g, ''); + const withoutDDI = cleanPhone.replace(/^55/, ''); + const withDDI = '55' + withoutDDI; + + // Robust phone matching: matches any format of the phone stored in the database + user = await prisma.user.findFirst({ + where: { + OR: [ + { whatsapp: cleanPhone }, + { whatsapp: withoutDDI }, + { whatsapp: withDDI } + ] + } + }); + } else { + user = await prisma.user.findFirst({ + where: { email: identifier.toLowerCase() } + }); + } + + // Always return success even if user doesn't exist to prevent enumeration attacks + if (!user) { + return res.json({ success: true, message: 'Link de recuperação enviado com sucesso.' }); + } + + // Generate secure 32-byte hex token valid for 1 hour + 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 = `${req.headers.origin}/reset-password?token=${token}`; + + if (channel === 'email') { + const settings = await prisma.systemSettings.findFirst(); + const transporter = await getMailTransporter(settings); + + const mailOptions = { + from: settings?.smtpFrom || '"Recuperação de Senha" ', + to: user.email, + subject: 'Redefinição de Senha', + html: ` +
+

Recuperação de Senha

+

Olá, ${user.name || 'Aluno'}.

+

Recebemos uma solicitação para redefinir a senha da sua conta.

+
+ Redefinir Minha Senha +
+

Se você não fez essa solicitação, pode ignorar este e-mail com segurança.

+

Link válido por 1 hora.

+
+ ` + }; + await transporter.sendMail(mailOptions); + } else if (channel === 'whatsapp') { + const text = `*Recuperação de Senha*\n\nOlá, você solicitou a recuperação de sua senha.\nClique no link abaixo para criar uma nova senha:\n\n${resetUrl}\n\n_Válido por 1 hora._`; + + // Send message via sendWhatsappNotification (explained in Section 4) + await sendWhatsappNotification(user.whatsapp || identifier, text); + } + + res.json({ success: true, message: 'Link de recuperação enviado com sucesso.' }); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao processar solicitação.' }); + } +}); +``` + +### Endpoint: Save New Password (`/api/auth/reset-password`) +Validates the token, checks if it has expired, hashes the new password using `bcrypt`, updates the database, and voids the token. + +```typescript +import bcrypt from 'bcrypt'; + +app.post('/api/auth/reset-password', async (req: Request, res: Response) => { + const { token, newPassword } = req.body; + + if (!token || !newPassword) { + return res.status(400).json({ error: 'Token e nova senha são obrigatórios.' }); + } + + try { + const user = await prisma.user.findFirst({ + where: { + resetPasswordToken: token, + resetPasswordExpires: { gt: new Date() } // Check if expiration date is greater than now + } + }); + + if (!user) { + return res.status(400).json({ error: 'Token inválido ou expirado.' }); + } + + // Hash the password and clear the reset fields + 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 (err: any) { + res.status(500).json({ error: 'Erro ao redefinir a senha.' }); + } +}); +``` + +--- + +## 3. WhatsApp Dispatch System (Evolution API v2) + +Always format phone numbers correctly (prepending Brazil's DDI `55` if it only has 10 or 11 digits) and dispatch with the correct Evolution API v2 JSON payload. + +```typescript +import axios from 'axios'; + +async function sendWhatsappNotification(phone: string, text: string) { + try { + const settings = await prisma.systemSettings.findFirst(); + if (!settings?.whatsappConnected || !settings.evolutionApiUrl || !settings.evolutionApiKey) { + return false; // WhatsApp configuration missing or inactive + } + + // Clean and validate digits + let cleanPhone = phone.replace(/\D/g, ''); + if (!cleanPhone) return false; + + // Inject country code (DDI) 55 if missing (standard Brazilian number with DDD) + if (cleanPhone.length === 10 || cleanPhone.length === 11) { + cleanPhone = '55' + cleanPhone; + } + + const apiUrl = settings.evolutionApiUrl.replace(/\/$/, ''); + const instance = settings.evolutionInstance || 'default_instance'; + + // MUST use the Evolution API v2 body schema: { number, text, delay } + await axios.post(`${apiUrl}/message/sendText/${instance}`, { + number: cleanPhone, + text: text, + delay: 1200 + }, { + headers: { apikey: settings.evolutionApiKey } + }); + + return true; + } catch (err: any) { + console.error('Falha ao enviar mensagem de WhatsApp:', err.message); + return false; + } +} +``` + +--- + +## 4. Frontend Integration (React) + +### Parsing Token from URL +Read the token parameter in a React page/component on load: + +```tsx +import React, { useEffect, useState } from 'react'; + +export const App = () => { + const [resetToken, setResetToken] = useState(null); + const [isForgotPassword, setIsForgotPassword] = useState(false); + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const tokenParam = urlParams.get('token'); + + // Check if path matches /reset-password and token is present + if (window.location.pathname === '/reset-password' && tokenParam) { + setResetToken(tokenParam); + setIsForgotPassword(true); // Open password modal automatically + } + }, []); + + // Render forgot password modal or main login here... +}; +```