diff --git a/backend/index.js b/backend/index.js index 686e4b6..1b164eb 100644 --- a/backend/index.js +++ b/backend/index.js @@ -12,17 +12,10 @@ const { createCorsMiddleware } = require('./config/cors'); const { allowedOrigins, getBaseUrl, getStartupBaseUrl, isProduction, jwtSecret: JWT_SECRET, port: PORT } = require('./config/runtime'); const { authenticateToken, requireRole } = require('./middleware/auth'); const { createAuthRouter } = require('./routes/authRoutes'); -const { - canReadUser, - canUpdateUser, - canManageUserStatus, - canChangeUserEmail, - canManageUserRoleOrTeam, - canReadAttendance, -} = require('./policies/accessPolicy'); +const { createUsersRouter } = require('./routes/usersRoutes'); +const { canReadAttendance } = require('./policies/accessPolicy'); const app = express(); -const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at'; app.use(createCorsMiddleware({ allowedOrigins, isProduction })); app.use(express.json()); @@ -77,193 +70,7 @@ apiRouter.use(authenticateToken); apiRouter.use(createAuthRouter({ pool, transporter, getBaseUrl, jwtSecret: JWT_SECRET })); -// --- User Routes --- -apiRouter.get('/users', async (req, res) => { - try { - const { tenantId } = req.query; - const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; - - let q = `SELECT ${USER_PUBLIC_FIELDS} FROM users`; - const params = []; - if (effectiveTenantId && effectiveTenantId !== 'all') { - q += ' WHERE tenant_id = ?'; - params.push(effectiveTenantId); - } - - // Strict RBAC: Managers can only see users in their own team, or themselves if they don't have a team yet - if (req.user.role === 'manager') { - if (req.user.team_id) { - q += (params.length > 0 ? ' AND' : ' WHERE') + ' (team_id = ? OR id = ?)'; - params.push(req.user.team_id, req.user.id); - } else { - q += (params.length > 0 ? ' AND' : ' WHERE') + ' id = ?'; - params.push(req.user.id); - } - } - - const [rows] = await pool.query(q, params); - res.json(rows); - } catch (error) { res.status(500).json({ error: error.message }); } -}); - -apiRouter.get('/users/:idOrSlug', async (req, res) => { - try { - const [rows] = await pool.query(`SELECT ${USER_PUBLIC_FIELDS} FROM users WHERE id = ? OR slug = ?`, [req.params.idOrSlug, req.params.idOrSlug]); - if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' }); - if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado' }); - - if (!canReadUser(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' }); - - res.json(rows[0]); - } catch (error) { - console.error('Error in GET /users/:idOrSlug:', error); - res.status(500).json({ error: error.message }); - } -}); - -// Convidar Novo Membro (Admin criando usuário) -apiRouter.post('/users', requireRole(['admin', 'manager', 'super_admin']), async (req, res) => { - const { name, email, role, team_id, tenant_id } = req.body; - const effectiveTenantId = req.user.role === 'super_admin' ? tenant_id : req.user.tenant_id; - - // Strict RBAC: Managers can only create agents and assign them to their own team - let finalRole = role || 'agent'; - let finalTeamId = team_id || null; - - if (req.user.role === 'manager') { - if (!req.user.team_id) return res.status(403).json({ error: 'Gerente sem time não pode criar membros.' }); - finalRole = 'agent'; // Force manager creations to be agents - finalTeamId = req.user.team_id; // Force assignment to manager's team - } - try { - // 1. Verificar se e-mail já existe - 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 uid = `u_${crypto.randomUUID().split('-')[0]}`; - const slug = `${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${crypto.randomBytes(4).toString('hex')}`; - const placeholderHash = 'pending_setup'; // Usuário não pode logar com isso - - // 2. Criar Usuário - await pool.query( - 'INSERT INTO users (id, tenant_id, team_id, name, email, password_hash, slug, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - [uid, effectiveTenantId, finalTeamId, name, email, placeholderHash, slug, finalRole, 'active'] - ); - - // 3. Gerar Token de Setup de Senha (reusando lógica de reset) - 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] - ); - - // 4. Enviar E-mail de Boas-vindas - const setupLink = `${getBaseUrl(req)}/#/reset-password?token=${token}`; - - await transporter.sendMail({ - from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, - to: email, - subject: 'Bem-vindo ao Fasto - Finalize seu cadastro', - html: ` -
-

Olá, ${name}!

-

Você foi convidado para participar da equipe no Fasto.

-

Clique no botão abaixo para definir sua senha e acessar sua conta:

-
- Finalizar Cadastro -
-

Este link expira em 15 minutos. Se você não esperava este convite, ignore este e-mail.

-
-

Desenvolvido por Blyzer

-
-
- ` - }); - res.status(201).json({ id: uid, message: 'Convite enviado com sucesso.' }); - } catch (error) { - console.error('Invite error:', error); - res.status(500).json({ error: error.message }); - } -}); - -apiRouter.put('/users/:id', async (req, res) => { - const { name, bio, role, team_id, status, email, sound_enabled } = req.body; - try { - const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]); - if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); - - if (!canUpdateUser(req.user, existing[0])) return res.status(403).json({ error: 'Acesso negado.' }); - - // Only Admins can change roles and teams. Managers can only edit basic info of their team members. - const finalRole = canManageUserRoleOrTeam(req.user) && role !== undefined ? role : existing[0].role; - const finalTeamId = canManageUserRoleOrTeam(req.user) && team_id !== undefined ? team_id : existing[0].team_id; - const finalStatus = canManageUserStatus(req.user) && status !== undefined ? status : existing[0].status; - const finalEmail = canChangeUserEmail(req.user, existing[0]) && email !== undefined ? email : existing[0].email; - const finalSoundEnabled = req.user.id === req.params.id && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true); - - if (finalEmail !== existing[0].email) { - const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]); - if (emailCheck.length > 0) return res.status(400).json({ error: 'E-mail já está em uso.' }); - } - - await pool.query( - 'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ?, sound_enabled = ? WHERE id = ?', - [name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, finalSoundEnabled, req.params.id] - ); - - // Trigger Notification for Team Change - if (finalTeamId && finalTeamId !== existing[0].team_id && existing[0].status === 'active') { - const [team] = await pool.query('SELECT name FROM teams WHERE id = ?', [finalTeamId]); - if (team.length > 0) { - await pool.query( - 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', - [crypto.randomUUID(), req.params.id, 'info', 'Novo Time', `Você foi adicionado ao time ${team[0].name}.`, '/'] - ); - } - } - - res.json({ message: 'User updated successfully.' }); - } catch (error) { console.error('Update user error:', error); - res.status(500).json({ error: error.message }); - } -}); - -apiRouter.delete('/users/:id', requireRole(['admin', 'super_admin']), async (req, res) => { - try { - const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]); - if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); - if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) { - return res.status(403).json({ error: 'Acesso negado.' }); - } - - await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]); - res.json({ message: 'User deleted successfully.' }); - } catch (error) { - console.error('Delete user error:', error); - res.status(500).json({ error: error.message }); - } -}); - -// Upload de Avatar -apiRouter.post('/users/:id/avatar', upload.single('avatar'), async (req, res) => { - try { - if (!req.file) return res.status(400).json({ error: 'Nenhum arquivo enviado.' }); - - // Validar se o usuário está alterando o próprio avatar (ou super_admin) - if (req.user.id !== req.params.id && req.user.role !== 'super_admin') { - return res.status(403).json({ error: 'Acesso negado.' }); - } - - const avatarUrl = `/uploads/${req.file.filename}`; - await pool.query('UPDATE users SET avatar_url = ? WHERE id = ?', [avatarUrl, req.params.id]); - - res.json({ avatarUrl }); - } catch (error) { - console.error('Avatar upload error:', error); - res.status(500).json({ error: error.message }); - } -}); - +apiRouter.use(createUsersRouter({ pool, upload, transporter, getBaseUrl })); // --- Notifications Routes --- apiRouter.get('/notifications', async (req, res) => { diff --git a/backend/routes/usersRoutes.js b/backend/routes/usersRoutes.js new file mode 100644 index 0000000..8c45046 --- /dev/null +++ b/backend/routes/usersRoutes.js @@ -0,0 +1,198 @@ +const express = require('express'); +const crypto = require('crypto'); +const { requireRole } = require('../middleware/auth'); +const { + canReadUser, + canUpdateUser, + canManageUserStatus, + canChangeUserEmail, + canManageUserRoleOrTeam, +} = require('../policies/accessPolicy'); + +const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at'; + +const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => { + const router = express.Router(); + + router.get('/users', async (req, res) => { + try { + const { tenantId } = req.query; + const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; + + let q = `SELECT ${USER_PUBLIC_FIELDS} FROM users`; + const params = []; + if (effectiveTenantId && effectiveTenantId !== 'all') { + q += ' WHERE tenant_id = ?'; + params.push(effectiveTenantId); + } + + if (req.user.role === 'manager') { + if (req.user.team_id) { + q += (params.length > 0 ? ' AND' : ' WHERE') + ' (team_id = ? OR id = ?)'; + params.push(req.user.team_id, req.user.id); + } else { + q += (params.length > 0 ? ' AND' : ' WHERE') + ' id = ?'; + params.push(req.user.id); + } + } + + const [rows] = await pool.query(q, params); + res.json(rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + router.get('/users/:idOrSlug', async (req, res) => { + try { + const [rows] = await pool.query(`SELECT ${USER_PUBLIC_FIELDS} FROM users WHERE id = ? OR slug = ?`, [req.params.idOrSlug, req.params.idOrSlug]); + if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' }); + if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado' }); + + if (!canReadUser(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' }); + + res.json(rows[0]); + } catch (error) { + console.error('Error in GET /users/:idOrSlug:', error); + res.status(500).json({ error: error.message }); + } + }); + + router.post('/users', requireRole(['admin', 'manager', 'super_admin']), async (req, res) => { + const { name, email, role, team_id, tenant_id } = req.body; + const effectiveTenantId = req.user.role === 'super_admin' ? tenant_id : req.user.tenant_id; + + let finalRole = role || 'agent'; + let finalTeamId = team_id || null; + + if (req.user.role === 'manager') { + if (!req.user.team_id) return res.status(403).json({ error: 'Gerente sem time não pode criar membros.' }); + finalRole = 'agent'; + finalTeamId = req.user.team_id; + } + 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 uid = `u_${crypto.randomUUID().split('-')[0]}`; + const slug = `${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${crypto.randomBytes(4).toString('hex')}`; + const placeholderHash = 'pending_setup'; + + await pool.query( + 'INSERT INTO users (id, tenant_id, team_id, name, email, password_hash, slug, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [uid, effectiveTenantId, finalTeamId, name, email, placeholderHash, slug, finalRole, 'active'] + ); + + 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 setupLink = `${getBaseUrl(req)}/#/reset-password?token=${token}`; + + await transporter.sendMail({ + from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, + to: email, + subject: 'Bem-vindo ao Fasto - Finalize seu cadastro', + html: ` +
+

Olá, ${name}!

+

Você foi convidado para participar da equipe no Fasto.

+

Clique no botão abaixo para definir sua senha e acessar sua conta:

+
+ Finalizar Cadastro +
+

Este link expira em 15 minutos. Se você não esperava este convite, ignore este e-mail.

+
+

Desenvolvido por Blyzer

+
+
+ ` + }); + res.status(201).json({ id: uid, message: 'Convite enviado com sucesso.' }); + } catch (error) { + console.error('Invite error:', error); + res.status(500).json({ error: error.message }); + } + }); + + router.put('/users/:id', async (req, res) => { + const { name, bio, role, team_id, status, email, sound_enabled } = req.body; + try { + const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]); + if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); + + if (!canUpdateUser(req.user, existing[0])) return res.status(403).json({ error: 'Acesso negado.' }); + + const finalRole = canManageUserRoleOrTeam(req.user) && role !== undefined ? role : existing[0].role; + const finalTeamId = canManageUserRoleOrTeam(req.user) && team_id !== undefined ? team_id : existing[0].team_id; + const finalStatus = canManageUserStatus(req.user) && status !== undefined ? status : existing[0].status; + const finalEmail = canChangeUserEmail(req.user, existing[0]) && email !== undefined ? email : existing[0].email; + const finalSoundEnabled = req.user.id === req.params.id && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true); + + if (finalEmail !== existing[0].email) { + const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]); + if (emailCheck.length > 0) return res.status(400).json({ error: 'E-mail já está em uso.' }); + } + + await pool.query( + 'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ?, sound_enabled = ? WHERE id = ?', + [name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, finalSoundEnabled, req.params.id] + ); + + if (finalTeamId && finalTeamId !== existing[0].team_id && existing[0].status === 'active') { + const [team] = await pool.query('SELECT name FROM teams WHERE id = ?', [finalTeamId]); + if (team.length > 0) { + await pool.query( + 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', + [crypto.randomUUID(), req.params.id, 'info', 'Novo Time', `Você foi adicionado ao time ${team[0].name}.`, '/'] + ); + } + } + + res.json({ message: 'User updated successfully.' }); + } catch (error) { + console.error('Update user error:', error); + res.status(500).json({ error: error.message }); + } + }); + + router.delete('/users/:id', requireRole(['admin', 'super_admin']), async (req, res) => { + try { + const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]); + if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); + if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) { + return res.status(403).json({ error: 'Acesso negado.' }); + } + + await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]); + res.json({ message: 'User deleted successfully.' }); + } catch (error) { + console.error('Delete user error:', error); + res.status(500).json({ error: error.message }); + } + }); + + router.post('/users/:id/avatar', upload.single('avatar'), async (req, res) => { + try { + if (!req.file) return res.status(400).json({ error: 'Nenhum arquivo enviado.' }); + + if (req.user.id !== req.params.id && req.user.role !== 'super_admin') { + return res.status(403).json({ error: 'Acesso negado.' }); + } + + const avatarUrl = `/uploads/${req.file.filename}`; + await pool.query('UPDATE users SET avatar_url = ? WHERE id = ?', [avatarUrl, req.params.id]); + + res.json({ avatarUrl }); + } catch (error) { + console.error('Avatar upload error:', error); + res.status(500).json({ error: error.message }); + } + }); + + return router; +}; + +module.exports = { createUsersRouter };