Implementa fluxo de Esqueci a Senha duplo com e-mail SMTP e notificacoes WhatsApp via Evolution API
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s
Details
This commit is contained in:
parent
16cbca320e
commit
4d130b5b5d
|
|
@ -21,6 +21,7 @@
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
|
@ -1990,6 +1992,16 @@
|
||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|
@ -3906,6 +3918,15 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.8",
|
"version": "0.6.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
|
|
@ -36,6 +37,7 @@
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,9 @@ model User {
|
||||||
birthDate String? @map("birth_date")
|
birthDate String? @map("birth_date")
|
||||||
avatarUrl String? @map("avatar_url") @db.Text
|
avatarUrl String? @map("avatar_url") @db.Text
|
||||||
trialEndsAt DateTime? @map("trial_ends_at")
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
notes Note[]
|
notes Note[]
|
||||||
progress Progress[]
|
progress Progress[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
|
@ -267,6 +268,13 @@ model SystemSettings {
|
||||||
cnpj String @default("") @map("cnpj")
|
cnpj String @default("") @map("cnpj")
|
||||||
address String @default("") @map("address")
|
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")
|
@@map("system_settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
156
server.ts
156
server.ts
|
|
@ -6,6 +6,8 @@ import multer from 'multer';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3';
|
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 './src/db/prisma';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { AsaasService } from './src/services/asaas';
|
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
|
||||||
|
// Start initialization async, don't block server start
|
||||||
initMinio();
|
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 ---
|
// --- JWT AUTH MIDDLEWARE ---
|
||||||
interface AuthenticatedRequest extends Request {
|
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: `
|
||||||
|
<div style="background-color: #141414; color: #fff; padding: 40px; font-family: sans-serif; text-align: center;">
|
||||||
|
<h1 style="color: #E50914;">${settings?.brandName || 'MICROTEC'}FLIX</h1>
|
||||||
|
<h2>Recuperação de Senha</h2>
|
||||||
|
<p>Você solicitou a recuperação da sua senha. Clique no botão abaixo para redefini-la (válido por 1 hora).</p>
|
||||||
|
<a href="${resetUrl}" style="display: inline-block; padding: 15px 30px; margin: 20px 0; background-color: #E50914; color: #fff; text-decoration: none; font-weight: bold; border-radius: 4px;">Redefinir Senha</a>
|
||||||
|
<p style="color: #666; font-size: 12px;">Se você não solicitou, apenas ignore este e-mail.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// 3. Auth: Me
|
||||||
app.get('/api/auth/me', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
app.get('/api/auth/me', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
|
|
@ -1062,7 +1180,13 @@ app.get('/api/admin/settings', authenticateToken, requireAdmin, async (req: Auth
|
||||||
brandName: settings?.brandName || 'MICROTEC',
|
brandName: settings?.brandName || 'MICROTEC',
|
||||||
brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.',
|
brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.',
|
||||||
cnpj: settings?.cnpj || '',
|
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 ---
|
// --- EVOLUTION API WHATSAPP ---
|
||||||
app.get('/api/admin/whatsapp/qrcode', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
app.get('/api/admin/whatsapp/qrcode', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
144
src/App.tsx
144
src/App.tsx
|
|
@ -30,6 +30,8 @@ export default function App() {
|
||||||
|
|
||||||
// Authentication forms
|
// Authentication forms
|
||||||
const [isRegister, setIsRegister] = useState(false);
|
const [isRegister, setIsRegister] = useState(false);
|
||||||
|
const [isForgotPassword, setIsForgotPassword] = useState(false);
|
||||||
|
const [resetToken, setResetToken] = useState<string | null>(null);
|
||||||
const [loginEmail, setLoginEmail] = useState('');
|
const [loginEmail, setLoginEmail] = useState('');
|
||||||
const [loginPassword, setLoginPassword] = useState('');
|
const [loginPassword, setLoginPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
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);
|
const [settings, setSettings] = useState<{ brandName?: string; brandSlogan?: string; cnpj?: string; address?: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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')
|
fetch('/api/settings')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
|
|
@ -345,14 +403,86 @@ export default function App() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error indicators */}
|
|
||||||
{authError && (
|
{authError && (
|
||||||
<div className="bg-red-950/40 border border-red-900/50 text-red-400 p-3 rounded-lg text-xs leading-relaxed text-center">
|
<div className="bg-red-950/40 border border-red-900/50 text-red-400 p-3 rounded-lg text-xs leading-relaxed text-center">
|
||||||
{authError}
|
{authError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{authSuccess && (
|
||||||
|
<div className="bg-emerald-950/40 border border-emerald-900/50 text-emerald-400 p-3 rounded-lg text-xs leading-relaxed text-center">
|
||||||
|
{authSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isRegister ? (
|
{resetToken ? (
|
||||||
|
/* RESET PASSWORD FORM */
|
||||||
|
<form onSubmit={handleResetPassword} className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Nova Senha</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
required
|
||||||
|
value={regPassword}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-3 text-zinc-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={authLoading}
|
||||||
|
className="w-full glass-btn-primary text-white text-xs font-bold py-3 px-4 rounded-lg flex items-center justify-center space-x-1.5 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span>{authLoading ? 'Redefinindo...' : 'Salvar Nova Senha'}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : isForgotPassword ? (
|
||||||
|
/* FORGOT PASSWORD FORM */
|
||||||
|
<form onSubmit={handleForgotPassword} className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">E-mail ou WhatsApp (com DDI ex: 5511999999999)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={loginEmail}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={authLoading}
|
||||||
|
className="w-full glass-btn-primary text-white text-xs font-bold py-3 px-4 rounded-lg flex items-center justify-center space-x-1.5 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span>{authLoading ? 'Enviando...' : 'Receber Link de Recuperação'}</span>
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-xs text-zinc-500">
|
||||||
|
Lembrou a senha?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setIsForgotPassword(false); setAuthError(null); setAuthSuccess(null); }}
|
||||||
|
className="text-white hover:underline font-bold"
|
||||||
|
>
|
||||||
|
Fazer Login
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
) : isRegister ? (
|
||||||
/* REGISTER FORM */
|
/* REGISTER FORM */
|
||||||
<form onSubmit={handleRegister} className="space-y-4">
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -495,6 +625,16 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setIsForgotPassword(true); setAuthError(null); setAuthSuccess(null); }}
|
||||||
|
className="text-[10px] text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Esqueci minha senha
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={authLoading}
|
disabled={authLoading}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
TrendingUp, Users, AlertTriangle, XCircle, Search,
|
TrendingUp, Users, AlertTriangle, XCircle, Search,
|
||||||
Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye, DollarSign,
|
Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye, DollarSign,
|
||||||
Shield, Layers, LogOut, Menu, X, GraduationCap
|
Shield, Layers, LogOut, Menu, X, GraduationCap, Mail
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, Legend, Cell, PieChart, Pie } from 'recharts';
|
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, Legend, Cell, PieChart, Pie } from 'recharts';
|
||||||
import { SubscriptionStatus } from '../types';
|
import { SubscriptionStatus } from '../types';
|
||||||
|
|
@ -75,7 +75,7 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
|
||||||
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
|
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'students' | 'courses' | 'plans' | 'payments' | 'asaas_settings' | 'whatsapp_settings' | 'webhooks_logs' | 'saas_settings'>('dashboard');
|
const [activeTab, setActiveTab] = useState<'dashboard' | 'students' | 'courses' | 'plans' | 'payments' | 'asaas_settings' | 'whatsapp_settings' | 'webhooks_logs' | 'saas_settings' | 'smtp_settings'>('dashboard');
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
@ -85,6 +85,7 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
|
||||||
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||||
const [showSaveSuccessModal, setShowSaveSuccessModal] = useState(false);
|
const [showSaveSuccessModal, setShowSaveSuccessModal] = useState(false);
|
||||||
const [asaasTestResult, setAsaasTestResult] = useState<{ loading: boolean; success: boolean | null; message: string } | null>(null);
|
const [asaasTestResult, setAsaasTestResult] = useState<{ loading: boolean; success: boolean | null; message: string } | null>(null);
|
||||||
|
const [smtpTestResult, setSmtpTestResult] = useState<{ loading: boolean; success: boolean | null; message: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
|
|
@ -165,6 +166,39 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTestSmtpConnection = async () => {
|
||||||
|
setSmtpTestResult({ loading: true, success: null, message: 'Testando conexão SMTP...' });
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/test-smtp', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
smtpHost: settings?.smtpHost,
|
||||||
|
smtpPort: parseInt(settings?.smtpPort || '587'),
|
||||||
|
smtpUser: settings?.smtpUser,
|
||||||
|
smtpPass: settings?.smtpPass,
|
||||||
|
smtpSecure: settings?.smtpSecure,
|
||||||
|
smtpFrom: settings?.smtpFrom
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setSmtpTestResult({
|
||||||
|
loading: false,
|
||||||
|
success: data.success,
|
||||||
|
message: data.message || (data.success ? 'Conexão SMTP estabelecida e e-mail enviado com sucesso!' : 'Falha na conexão SMTP.')
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
setSmtpTestResult({
|
||||||
|
loading: false,
|
||||||
|
success: false,
|
||||||
|
message: 'Falha de comunicação com o servidor ao testar SMTP.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchCourses = async () => {
|
const fetchCourses = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/courses', {
|
const res = await fetch('/api/courses', {
|
||||||
|
|
@ -432,6 +466,13 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
|
||||||
<ShieldCheck size={16} />
|
<ShieldCheck size={16} />
|
||||||
<span>Configuração do SaaS</span>
|
<span>Configuração do SaaS</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveTab('smtp_settings'); setIsMobileMenuOpen(false); }}
|
||||||
|
className={`w-full flex items-center space-x-2.5 px-3 py-2 rounded-lg text-xs font-bold transition-all text-left ${activeTab === 'smtp_settings' ? 'bg-[#E50914]/10 text-white border-l-2 border-[#E50914]' : 'text-zinc-400 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<Mail size={16} />
|
||||||
|
<span>Configurações SMTP</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1281,6 +1322,124 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* TAB 10: SMTP CONFIGURATION */}
|
||||||
|
{activeTab === 'smtp_settings' && (
|
||||||
|
<div className="glass-card rounded-xl border border-white/10 p-6 shadow-2xl space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
|
||||||
|
<Mail className="text-[#E50914]" size={24} />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white font-display">Configurações de Envio de E-mail (SMTP)</h2>
|
||||||
|
<p className="text-xs text-zinc-500">Credenciais para envio de e-mails transacionais (ex: Recuperação de Senha).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveSettings} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 bg-white/5 p-6 rounded-xl border border-white/5">
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Servidor SMTP (Host)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.smtpHost || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Porta</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.smtpPort || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Usuário SMTP</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.smtpUser || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Senha SMTP</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={settings.smtpPass || ''}
|
||||||
|
onChange={(e) => 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="**************"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">E-mail do Remetente (From)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.smtpFrom || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 flex flex-col justify-center">
|
||||||
|
<label className="flex items-center space-x-3 cursor-pointer mt-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.smtpSecure || false}
|
||||||
|
onChange={(e) => setSettings({...settings, smtpSecure: e.target.checked})}
|
||||||
|
className="form-checkbox h-5 w-5 text-[#E50914] rounded border-white/10 bg-black/40 focus:ring-[#E50914] focus:ring-offset-black"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-bold text-white">Usar Conexão Segura (SSL/TLS)</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-zinc-500 mt-1 pl-8">Marque se a porta for 465. Para 587 (STARTTLS), normalmente fica desmarcado.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Results */}
|
||||||
|
{smtpTestResult && (
|
||||||
|
<div className={`p-4 rounded-xl text-xs font-bold ${smtpTestResult.loading ? 'bg-zinc-800 text-zinc-400' : smtpTestResult.success ? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/20' : 'bg-red-500/10 text-red-500 border border-red-500/20'}`}>
|
||||||
|
{smtpTestResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestSmtpConnection}
|
||||||
|
disabled={smtpTestResult?.loading}
|
||||||
|
className="glass-btn text-white font-bold py-3.5 px-6 rounded-xl border border-white/10 hover:bg-white/5 transition-all text-xs flex items-center gap-2 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{smtpTestResult?.loading ? <RefreshCw className="animate-spin" size={16} /> : <Mail size={16} />}
|
||||||
|
<span>Enviar E-mail de Teste</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSavingSettings}
|
||||||
|
className="glass-btn-primary text-white font-extrabold py-3.5 px-8 rounded-xl shadow-lg hover:scale-105 transition-all flex items-center gap-2 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSavingSettings ? (
|
||||||
|
<RefreshCw className="animate-spin" size={18} />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 size={18} />
|
||||||
|
)}
|
||||||
|
<span>{isSavingSettings ? 'Salvando...' : 'Salvar Configurações SMTP'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue