diff --git a/backend/policies/accessPolicy.js b/backend/policies/accessPolicy.js index 2f2e948..494f00a 100644 --- a/backend/policies/accessPolicy.js +++ b/backend/policies/accessPolicy.js @@ -23,6 +23,7 @@ const canUpdateUser = (actor, targetUser) => { const canManageUserStatus = (actor) => actor.role === 'super_admin' || actor.role === 'admin'; const canChangeUserEmail = (actor, targetUser) => actor.id === targetUser.id || actor.role === 'super_admin' || actor.role === 'admin'; const canManageUserRoleOrTeam = (actor) => actor.role === 'super_admin' || actor.role === 'admin'; +const canManageUserPassword = (actor) => actor.role === 'super_admin' || actor.role === 'admin'; const canReadAttendance = (actor, attendance) => { if (!actor || !attendance || !sameTenant(actor, attendance)) return false; @@ -38,5 +39,6 @@ module.exports = { canManageUserStatus, canChangeUserEmail, canManageUserRoleOrTeam, + canManageUserPassword, canReadAttendance, }; diff --git a/backend/routes/usersRoutes.js b/backend/routes/usersRoutes.js index 54b25a2..800b9b1 100644 --- a/backend/routes/usersRoutes.js +++ b/backend/routes/usersRoutes.js @@ -1,4 +1,5 @@ const express = require('express'); +const bcrypt = require('bcryptjs'); const crypto = require('crypto'); const { requireRole } = require('../middleware/auth'); const { @@ -7,10 +8,12 @@ const { canManageUserStatus, canChangeUserEmail, canManageUserRoleOrTeam, + canManageUserPassword, } = require('../policies/accessPolicy'); const { recordActivity } = require('../services/activityService'); const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, created_at'; +const MIN_PASSWORD_LENGTH = 8; const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => { const router = express.Router(); @@ -60,8 +63,9 @@ const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => { }); router.post('/users', requireRole(['admin', 'manager', 'super_admin']), async (req, res) => { - const { name, email, role, team_id, tenant_id } = req.body; + const { name, email, role, team_id, tenant_id, password } = req.body; const effectiveTenantId = req.user.role === 'super_admin' ? tenant_id : req.user.tenant_id; + const shouldSetPassword = typeof password === 'string' && password.length > 0; let finalRole = role || 'agent'; let finalTeamId = team_id || null; @@ -72,18 +76,32 @@ const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => { finalTeamId = req.user.team_id; } try { + if (shouldSetPassword && !canManageUserPassword(req.user)) { + return res.status(403).json({ error: 'Apenas admins podem definir senha de usuários.' }); + } + if (shouldSetPassword && password.length < MIN_PASSWORD_LENGTH) { + return res.status(400).json({ error: `A senha deve ter pelo menos ${MIN_PASSWORD_LENGTH} caracteres.` }); + } + if (finalRole === 'super_admin' && req.user.role !== 'super_admin') { + return res.status(403).json({ error: 'Apenas super admins podem criar super admins.' }); + } + 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'; + const passwordHash = shouldSetPassword ? await bcrypt.hash(password, 10) : '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'] + [uid, effectiveTenantId, finalTeamId, name, email, passwordHash, slug, finalRole, 'active'] ); + if (shouldSetPassword) { + return res.status(201).json({ id: uid, message: 'Membro criado com senha definida.' }); + } + 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))', @@ -119,12 +137,22 @@ const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => { }); router.put('/users/:id', async (req, res) => { - const { name, bio, role, team_id, status, email } = req.body; + const { name, bio, role, team_id, status, email, password } = req.body; + const shouldUpdatePassword = typeof password === 'string' && password.length > 0; 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.' }); + if (shouldUpdatePassword && !canManageUserPassword(req.user)) { + return res.status(403).json({ error: 'Apenas admins podem alterar senha de usuários.' }); + } + if (shouldUpdatePassword && password.length < MIN_PASSWORD_LENGTH) { + return res.status(400).json({ error: `A senha deve ter pelo menos ${MIN_PASSWORD_LENGTH} caracteres.` }); + } + if (role === 'super_admin' && req.user.role !== 'super_admin') { + return res.status(403).json({ error: 'Apenas super admins podem definir super admins.' }); + } 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; @@ -141,6 +169,12 @@ const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => { [name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, req.params.id] ); + if (shouldUpdatePassword) { + const passwordHash = await bcrypt.hash(password, 10); + await pool.query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, req.params.id]); + await pool.query('DELETE FROM password_resets WHERE email = ?', [finalEmail]); + } + 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) { diff --git a/backend/test/accessPolicy.test.js b/backend/test/accessPolicy.test.js index a3eca4e..681f4c9 100644 --- a/backend/test/accessPolicy.test.js +++ b/backend/test/accessPolicy.test.js @@ -6,6 +6,7 @@ const { canManageUserStatus, canChangeUserEmail, canManageUserRoleOrTeam, + canManageUserPassword, canReadAttendance, } = require('../policies/accessPolicy'); @@ -48,6 +49,13 @@ test('only admins can manage role, team, and status fields', () => { assert.equal(canChangeUserEmail(manager, agent), false); }); +test('only admins can manage user passwords', () => { + assert.equal(canManageUserPassword(superAdmin), true); + assert.equal(canManageUserPassword(admin), true); + assert.equal(canManageUserPassword(manager), false); + assert.equal(canManageUserPassword(agent), false); +}); + test('attendance detail policy matches role boundaries', () => { const ownAttendance = { id: 'att_1', tenant_id: 'tenant_a', user_id: 'u_agent', team_id: 'team_a' }; const teamAttendance = { id: 'att_2', tenant_id: 'tenant_a', user_id: 'u_another', team_id: 'team_a' }; diff --git a/package-lock.json b/package-lock.json index 0495fe6..3797848 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2913,9 +2913,9 @@ } }, "node_modules/react-router": { - "version": "7.13.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", - "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -2935,12 +2935,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.13.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", - "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", "license": "MIT", "dependencies": { - "react-router": "7.13.1" + "react-router": "7.17.0" }, "engines": { "node": ">=20.0.0" diff --git a/src/pages/TeamManagement.tsx b/src/pages/TeamManagement.tsx index 7e54df3..46fd97e 100644 --- a/src/pages/TeamManagement.tsx +++ b/src/pages/TeamManagement.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { Users, Plus, Mail, Search, X, Edit, Trash2, Loader2, AlertTriangle } from 'lucide-react'; +import { Users, Plus, Search, X, Edit, Trash2, Loader2, AlertTriangle, KeyRound } from 'lucide-react'; import { getUsers, getTeams, createMember, deleteUser, updateUser, getUserById, getTenants } from '../services/dataService'; import { User, Tenant } from '../types'; @@ -24,7 +24,9 @@ export const TeamManagement: React.FC = () => { role: 'agent' as any, team_id: '', status: 'active' as any, - tenant_id: '' + tenant_id: '', + password: '', + confirmPassword: '' }); const loadData = async () => { @@ -67,12 +69,36 @@ export const TeamManagement: React.FC = () => { try { const tid = localStorage.getItem('ctms_tenant_id') || ''; const finalTenantId = currentUser?.role === 'super_admin' ? formData.tenant_id : tid; + const password = formData.password.trim(); + const confirmPassword = formData.confirmPassword.trim(); + + if (password || confirmPassword) { + if (password.length < 8) { + alert('A senha deve ter pelo menos 8 caracteres.'); + return; + } + if (password !== confirmPassword) { + alert('As senhas não conferem.'); + return; + } + } + + const payload: any = { + name: formData.name, + email: formData.email, + role: formData.role, + team_id: formData.team_id, + status: formData.status, + tenant_id: finalTenantId + }; + + if (password) payload.password = password; if (editingUser) { - const success = await updateUser(editingUser.id, { ...formData, tenant_id: finalTenantId }); + const success = await updateUser(editingUser.id, payload); if (success) { setIsModalOpen(false); loadData(); } } else { - await createMember({ ...formData, tenant_id: finalTenantId }); + await createMember(payload); setIsModalOpen(false); loadData(); } @@ -94,6 +120,7 @@ export const TeamManagement: React.FC = () => { if (loading && users.length === 0) return
Carregando...
; const canManage = currentUser?.role === 'admin' || currentUser?.role === 'super_admin' || currentUser?.role === 'manager'; + const canManagePasswords = currentUser?.role === 'admin' || currentUser?.role === 'super_admin'; const filtered = users.filter(u => (u.name.toLowerCase().includes(searchTerm.toLowerCase()) || u.email.toLowerCase().includes(searchTerm.toLowerCase())) && (tenantFilter === 'all' || u.tenant_id === tenantFilter) @@ -122,7 +149,7 @@ export const TeamManagement: React.FC = () => {

Visualize e gerencie as funções dos membros da sua organização.

{canManage && ( - )} @@ -195,7 +222,7 @@ export const TeamManagement: React.FC = () => { {canManage && (
- +
@@ -209,7 +236,7 @@ export const TeamManagement: React.FC = () => { {isModalOpen && (
-
+

{editingUser ? 'Editar Usuário' : 'Novo Membro'}

@@ -275,6 +302,38 @@ export const TeamManagement: React.FC = () => {
+ {canManagePasswords && ( +
+
+ + {editingUser ? 'Alterar senha' : 'Senha inicial'} +
+
+
+ + setFormData({...formData, password:e.target.value})} + className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-dark-text outline-none focus:ring-2 focus:ring-brand-yellow/20" + placeholder={editingUser ? 'Deixe em branco para manter' : 'Opcional'} + autoComplete="new-password" + /> +
+
+ + setFormData({...formData, confirmPassword:e.target.value})} + className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-dark-text outline-none focus:ring-2 focus:ring-brand-yellow/20" + placeholder="Repita a senha" + autoComplete="new-password" + /> +
+
+
+ )}