Extract backend auth routes
This commit is contained in:
@@ -29,6 +29,7 @@ COPY backend/db.js ./db.js
|
|||||||
COPY backend/config ./config
|
COPY backend/config ./config
|
||||||
COPY backend/middleware ./middleware
|
COPY backend/middleware ./middleware
|
||||||
COPY backend/policies ./policies
|
COPY backend/policies ./policies
|
||||||
|
COPY backend/routes ./routes
|
||||||
COPY backend/services ./services
|
COPY backend/services ./services
|
||||||
COPY backend/utils ./utils
|
COPY backend/utils ./utils
|
||||||
|
|
||||||
|
|||||||
275
backend/index.js
275
backend/index.js
@@ -1,8 +1,6 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
@@ -13,6 +11,7 @@ const transporter = require('./services/mailer');
|
|||||||
const { createCorsMiddleware } = require('./config/cors');
|
const { createCorsMiddleware } = require('./config/cors');
|
||||||
const { allowedOrigins, getBaseUrl, getStartupBaseUrl, isProduction, jwtSecret: JWT_SECRET, port: PORT } = require('./config/runtime');
|
const { allowedOrigins, getBaseUrl, getStartupBaseUrl, isProduction, jwtSecret: JWT_SECRET, port: PORT } = require('./config/runtime');
|
||||||
const { authenticateToken, requireRole } = require('./middleware/auth');
|
const { authenticateToken, requireRole } = require('./middleware/auth');
|
||||||
|
const { createAuthRouter } = require('./routes/authRoutes');
|
||||||
const {
|
const {
|
||||||
canReadUser,
|
canReadUser,
|
||||||
canUpdateUser,
|
canUpdateUser,
|
||||||
@@ -76,277 +75,7 @@ apiRouter.use(authenticateToken);
|
|||||||
|
|
||||||
// --- Auth Routes ---
|
// --- Auth Routes ---
|
||||||
|
|
||||||
// Register
|
apiRouter.use(createAuthRouter({ pool, transporter, getBaseUrl, jwtSecret: JWT_SECRET }));
|
||||||
apiRouter.post('/auth/register', async (req, res) => {
|
|
||||||
const { name, email, password, organizationName } = req.body;
|
|
||||||
try {
|
|
||||||
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
|
||||||
if (existing.length > 0) return res.status(400).json({ error: 'E-mail já cadastrado.' });
|
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
|
||||||
const verificationCode = Math.floor(100000 + Math.random() * 900000).toString();
|
|
||||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
|
||||||
|
|
||||||
await pool.query(
|
|
||||||
'INSERT INTO pending_registrations (email, password_hash, full_name, organization_name, verification_code, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
||||||
[email, passwordHash, name, organizationName, verificationCode, expiresAt]
|
|
||||||
);
|
|
||||||
|
|
||||||
const mailOptions = {
|
|
||||||
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
|
|
||||||
to: email,
|
|
||||||
subject: 'Seu código de verificação Fasto',
|
|
||||||
text: `Olá ${name}, seu código de verificação é: ${verificationCode}`,
|
|
||||||
html: `
|
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
|
|
||||||
<h2 style="color: #0f172a;">Bem-vindo ao Fasto!</h2>
|
|
||||||
<p style="color: #475569;">Seu código: <strong>${verificationCode}</strong></p>
|
|
||||||
</div>`
|
|
||||||
};
|
|
||||||
await transporter.sendMail(mailOptions);
|
|
||||||
res.json({ message: 'Código enviado.' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Register error:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
apiRouter.post('/auth/verify', async (req, res) => {
|
|
||||||
const { email, code } = req.body;
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
const [pending] = await connection.query(
|
|
||||||
'SELECT * FROM pending_registrations WHERE email = ? AND verification_code = ? AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1',
|
|
||||||
[email, code]
|
|
||||||
);
|
|
||||||
if (pending.length === 0) return res.status(400).json({ error: 'Código inválido.' });
|
|
||||||
|
|
||||||
const data = pending[0];
|
|
||||||
const tenantId = `tenant_${crypto.randomUUID().split('-')[0]}`;
|
|
||||||
const userId = `u_${crypto.randomUUID().split('-')[0]}`;
|
|
||||||
|
|
||||||
await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)',
|
|
||||||
[tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]);
|
|
||||||
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)',
|
|
||||||
[userId, tenantId, data.full_name, email, data.password_hash, 'admin']); await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
res.json({ message: 'Sucesso.' });
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Login
|
|
||||||
apiRouter.post('/auth/login', async (req, res) => {
|
|
||||||
const { email, password } = req.body;
|
|
||||||
try {
|
|
||||||
const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
|
|
||||||
if (users.length === 0) return res.status(401).json({ error: 'Credenciais inválidas.' });
|
|
||||||
|
|
||||||
const user = users[0];
|
|
||||||
|
|
||||||
// Verificar se o usuário está ativo
|
|
||||||
if (user.status !== 'active') {
|
|
||||||
return res.status(403).json({ error: 'Sua conta está inativa. Contate o administrador.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await bcrypt.compare(password, user.password_hash);
|
|
||||||
if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' });
|
|
||||||
|
|
||||||
// Generate Access Token (short-lived)
|
|
||||||
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '15m' });
|
|
||||||
|
|
||||||
// Generate Refresh Token (long-lived)
|
|
||||||
const refreshToken = crypto.randomBytes(40).toString('hex');
|
|
||||||
const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`;
|
|
||||||
|
|
||||||
// Store Refresh Token in database (expires in 30 days)
|
|
||||||
await pool.query(
|
|
||||||
'INSERT INTO refresh_tokens (id, user_id, token, token_hash, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
|
||||||
[refreshId, user.id, maskSecret(refreshId, refreshToken), hashSecret(refreshToken)]
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
token,
|
|
||||||
refreshToken,
|
|
||||||
user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug }
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh Token
|
|
||||||
apiRouter.post('/auth/refresh', async (req, res) => {
|
|
||||||
const { refreshToken } = req.body;
|
|
||||||
if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verifies if the token exists and hasn't expired
|
|
||||||
const [tokens] = await pool.query(
|
|
||||||
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE (r.token_hash = ? OR r.token = ?) AND r.expires_at > NOW()',
|
|
||||||
[hashSecret(refreshToken), refreshToken]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tokens.length === 0) {
|
|
||||||
// If invalid, optionally delete the bad token if it's just expired
|
|
||||||
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
|
||||||
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = tokens[0];
|
|
||||||
|
|
||||||
if (user.status !== 'active') {
|
|
||||||
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
|
||||||
return res.status(403).json({ error: 'Sua conta está inativa.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sliding Expiration: Extend the refresh token's life by another 30 days
|
|
||||||
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
|
||||||
|
|
||||||
// Issue a new short-lived access token
|
|
||||||
const newAccessToken = jwt.sign(
|
|
||||||
{ id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug },
|
|
||||||
JWT_SECRET,
|
|
||||||
{ expiresIn: '15m' }
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ token: newAccessToken });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Logout (Revoke Refresh Token)
|
|
||||||
apiRouter.post('/auth/logout', async (req, res) => {
|
|
||||||
const { refreshToken } = req.body;
|
|
||||||
try {
|
|
||||||
if (refreshToken) {
|
|
||||||
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
|
||||||
}
|
|
||||||
res.json({ message: 'Logout bem-sucedido.' });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// God Mode (Impersonate Tenant)
|
|
||||||
apiRouter.post('/impersonate/:tenantId', requireRole(['super_admin']), async (req, res) => {
|
|
||||||
try {
|
|
||||||
// Buscar o primeiro admin (ou qualquer usuário) do tenant para assumir a identidade
|
|
||||||
const [users] = await pool.query("SELECT * FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1", [req.params.tenantId]);
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Nenhum administrador encontrado nesta organização para assumir a identidade.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = users[0];
|
|
||||||
|
|
||||||
if (user.status !== 'active') {
|
|
||||||
return res.status(403).json({ error: 'A conta do admin desta organização está inativa.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gerar um token JWT como se fôssemos o admin do tenant
|
|
||||||
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '2h' });
|
|
||||||
res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forgot Password
|
|
||||||
apiRouter.post('/auth/forgot-password', async (req, res) => {
|
|
||||||
const { email } = req.body;
|
|
||||||
try {
|
|
||||||
const [users] = await pool.query('SELECT name FROM users WHERE email = ?', [email]);
|
|
||||||
if (users.length > 0) {
|
|
||||||
const token = crypto.randomBytes(32).toString('hex');
|
|
||||||
await pool.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [email, token]);
|
|
||||||
const link = `${getBaseUrl(req)}/#/reset-password?token=${token}`;
|
|
||||||
|
|
||||||
const mailOptions = { from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
|
|
||||||
to: email,
|
|
||||||
subject: 'Recuperação de Senha - Fasto',
|
|
||||||
html: `
|
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
|
|
||||||
<h2 style="color: #0f172a;">Olá, ${users[0].name}!</h2>
|
|
||||||
<p style="color: #475569;">Você solicitou a recuperação de senha da sua conta no Fasto.</p>
|
|
||||||
<p style="color: #475569;">Clique no botão abaixo para criar uma nova senha:</p>
|
|
||||||
<div style="text-align: center; margin: 30px 0;">
|
|
||||||
<a href="${link}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Redefinir Minha Senha</a>
|
|
||||||
</div>
|
|
||||||
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos. Se você não solicitou isso, pode ignorar este e-mail.</p>
|
|
||||||
<div style="border-top: 1px solid #f1f5f9; margin-top: 20px; padding-top: 20px; text-align: center;">
|
|
||||||
<p style="font-size: 12px; color: #94a3b8;">Desenvolvido por <a href="https://blyzer.com.br" style="color: #3b82f6; text-decoration: none;">Blyzer</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
};
|
|
||||||
await transporter.sendMail(mailOptions);
|
|
||||||
}
|
|
||||||
res.json({ message: 'Se o e-mail existir, enviamos as instruções.' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Forgot password error:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset Password
|
|
||||||
apiRouter.post('/auth/reset-password', async (req, res) => {
|
|
||||||
const { token, password, name } = req.body;
|
|
||||||
try {
|
|
||||||
const [resets] = await pool.query('SELECT email FROM password_resets WHERE token = ? AND expires_at > NOW()', [token]);
|
|
||||||
if (resets.length === 0) return res.status(400).json({ error: 'Token inválido ou expirado.' });
|
|
||||||
|
|
||||||
const hash = await bcrypt.hash(password, 10);
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
// If a name is provided (like in the initial admin setup flow), update it along with the password
|
|
||||||
await pool.query('UPDATE users SET password_hash = ?, name = ? WHERE email = ?', [hash, name, resets[0].email]);
|
|
||||||
|
|
||||||
// Notify managers/admins of the same tenant
|
|
||||||
const [u] = await pool.query('SELECT id, name, tenant_id, role, team_id FROM users WHERE email = ?', [resets[0].email]);
|
|
||||||
if (u.length > 0) {
|
|
||||||
const user = u[0];
|
|
||||||
const [notifiable] = await pool.query(
|
|
||||||
"SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager', 'super_admin') AND id != ?",
|
|
||||||
[user.tenant_id, user.id]
|
|
||||||
);
|
|
||||||
for (const n of notifiable) {
|
|
||||||
await pool.query(
|
|
||||||
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
|
|
||||||
[crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the new user is an admin, notify super_admins too
|
|
||||||
if (user.role === 'admin') {
|
|
||||||
const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'");
|
|
||||||
for (const sa of superAdmins) {
|
|
||||||
await pool.query(
|
|
||||||
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
|
|
||||||
[crypto.randomUUID(), sa.id, 'success', 'Admin Ativo', `O admin ${name} da organização configurou sua conta.`, `/super-admin`]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Standard password reset, just update the hash
|
|
||||||
await pool.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, resets[0].email]);
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query('DELETE FROM password_resets WHERE email = ?', [resets[0].email]);
|
|
||||||
res.json({ message: 'Senha e perfil atualizados com sucesso.' });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- User Routes ---
|
// --- User Routes ---
|
||||||
apiRouter.get('/users', async (req, res) => {
|
apiRouter.get('/users', async (req, res) => {
|
||||||
|
|||||||
262
backend/routes/authRoutes.js
Normal file
262
backend/routes/authRoutes.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { hashSecret, maskSecret } = require('../utils/security');
|
||||||
|
const { requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const createAuthRouter = ({ pool, transporter, getBaseUrl, jwtSecret }) => {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/auth/register', async (req, res) => {
|
||||||
|
const { name, email, password, organizationName } = req.body;
|
||||||
|
try {
|
||||||
|
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
||||||
|
if (existing.length > 0) return res.status(400).json({ error: 'E-mail já cadastrado.' });
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
const verificationCode = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO pending_registrations (email, password_hash, full_name, organization_name, verification_code, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[email, passwordHash, name, organizationName, verificationCode, expiresAt]
|
||||||
|
);
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
|
||||||
|
to: email,
|
||||||
|
subject: 'Seu código de verificação Fasto',
|
||||||
|
text: `Olá ${name}, seu código de verificação é: ${verificationCode}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
|
||||||
|
<h2 style="color: #0f172a;">Bem-vindo ao Fasto!</h2>
|
||||||
|
<p style="color: #475569;">Seu código: <strong>${verificationCode}</strong></p>
|
||||||
|
</div>`
|
||||||
|
});
|
||||||
|
res.json({ message: 'Código enviado.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Register error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/auth/verify', async (req, res) => {
|
||||||
|
const { email, code } = req.body;
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const [pending] = await connection.query(
|
||||||
|
'SELECT * FROM pending_registrations WHERE email = ? AND verification_code = ? AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1',
|
||||||
|
[email, code]
|
||||||
|
);
|
||||||
|
if (pending.length === 0) return res.status(400).json({ error: 'Código inválido.' });
|
||||||
|
|
||||||
|
const data = pending[0];
|
||||||
|
const tenantId = `tenant_${crypto.randomUUID().split('-')[0]}`;
|
||||||
|
const userId = `u_${crypto.randomUUID().split('-')[0]}`;
|
||||||
|
|
||||||
|
await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)',
|
||||||
|
[tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]);
|
||||||
|
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[userId, tenantId, data.full_name, email, data.password_hash, 'admin']);
|
||||||
|
await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
res.json({ message: 'Sucesso.' });
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/auth/login', async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
try {
|
||||||
|
const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
|
||||||
|
if (users.length === 0) return res.status(401).json({ error: 'Credenciais inválidas.' });
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
return res.status(403).json({ error: 'Sua conta está inativa. Contate o administrador.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' });
|
||||||
|
|
||||||
|
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, jwtSecret, { expiresIn: '15m' });
|
||||||
|
const refreshToken = crypto.randomBytes(40).toString('hex');
|
||||||
|
const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO refresh_tokens (id, user_id, token, token_hash, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
||||||
|
[refreshId, user.id, maskSecret(refreshId, refreshToken), hashSecret(refreshToken)]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/auth/refresh', async (req, res) => {
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [tokens] = await pool.query(
|
||||||
|
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE (r.token_hash = ? OR r.token = ?) AND r.expires_at > NOW()',
|
||||||
|
[hashSecret(refreshToken), refreshToken]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
||||||
|
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = tokens[0];
|
||||||
|
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
||||||
|
return res.status(403).json({ error: 'Sua conta está inativa.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
||||||
|
|
||||||
|
const newAccessToken = jwt.sign(
|
||||||
|
{ id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug },
|
||||||
|
jwtSecret,
|
||||||
|
{ expiresIn: '15m' }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ token: newAccessToken });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/auth/logout', async (req, res) => {
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
try {
|
||||||
|
if (refreshToken) {
|
||||||
|
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
||||||
|
}
|
||||||
|
res.json({ message: 'Logout bem-sucedido.' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/impersonate/:tenantId', requireRole(['super_admin']), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [users] = await pool.query("SELECT * FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1", [req.params.tenantId]);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Nenhum administrador encontrado nesta organização para assumir a identidade.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
return res.status(403).json({ error: 'A conta do admin desta organização está inativa.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, jwtSecret, { expiresIn: '2h' });
|
||||||
|
res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/auth/forgot-password', async (req, res) => {
|
||||||
|
const { email } = req.body;
|
||||||
|
try {
|
||||||
|
const [users] = await pool.query('SELECT name FROM users WHERE email = ?', [email]);
|
||||||
|
if (users.length > 0) {
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
await pool.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [email, token]);
|
||||||
|
const link = `${getBaseUrl(req)}/#/reset-password?token=${token}`;
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
|
||||||
|
to: email,
|
||||||
|
subject: 'Recuperação de Senha - Fasto',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
|
||||||
|
<h2 style="color: #0f172a;">Olá, ${users[0].name}!</h2>
|
||||||
|
<p style="color: #475569;">Você solicitou a recuperação de senha da sua conta no Fasto.</p>
|
||||||
|
<p style="color: #475569;">Clique no botão abaixo para criar uma nova senha:</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${link}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Redefinir Minha Senha</a>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos. Se você não solicitou isso, pode ignorar este e-mail.</p>
|
||||||
|
<div style="border-top: 1px solid #f1f5f9; margin-top: 20px; padding-top: 20px; text-align: center;">
|
||||||
|
<p style="font-size: 12px; color: #94a3b8;">Desenvolvido por <a href="https://blyzer.com.br" style="color: #3b82f6; text-decoration: none;">Blyzer</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ message: 'Se o e-mail existir, enviamos as instruções.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Forgot password error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/auth/reset-password', async (req, res) => {
|
||||||
|
const { token, password, name } = req.body;
|
||||||
|
try {
|
||||||
|
const [resets] = await pool.query('SELECT email FROM password_resets WHERE token = ? AND expires_at > NOW()', [token]);
|
||||||
|
if (resets.length === 0) return res.status(400).json({ error: 'Token inválido ou expirado.' });
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
await pool.query('UPDATE users SET password_hash = ?, name = ? WHERE email = ?', [hash, name, resets[0].email]);
|
||||||
|
|
||||||
|
const [u] = await pool.query('SELECT id, name, tenant_id, role, team_id FROM users WHERE email = ?', [resets[0].email]);
|
||||||
|
if (u.length > 0) {
|
||||||
|
const user = u[0];
|
||||||
|
const [notifiable] = await pool.query(
|
||||||
|
"SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager', 'super_admin') AND id != ?",
|
||||||
|
[user.tenant_id, user.id]
|
||||||
|
);
|
||||||
|
for (const n of notifiable) {
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'");
|
||||||
|
for (const sa of superAdmins) {
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[crypto.randomUUID(), sa.id, 'success', 'Admin Ativo', `O admin ${name} da organização configurou sua conta.`, `/super-admin`]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await pool.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, resets[0].email]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('DELETE FROM password_resets WHERE email = ?', [resets[0].email]);
|
||||||
|
res.json({ message: 'Senha e perfil atualizados com sucesso.' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { createAuthRouter };
|
||||||
Reference in New Issue
Block a user