Allow admins to manage user passwords

This commit is contained in:
Cauê Faleiros
2026-06-09 14:26:00 -03:00
parent 40c7807a65
commit f77ee406ec
5 changed files with 121 additions and 18 deletions

View File

@@ -23,6 +23,7 @@ const canUpdateUser = (actor, targetUser) => {
const canManageUserStatus = (actor) => actor.role === 'super_admin' || actor.role === 'admin'; 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 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 canManageUserRoleOrTeam = (actor) => actor.role === 'super_admin' || actor.role === 'admin';
const canManageUserPassword = (actor) => actor.role === 'super_admin' || actor.role === 'admin';
const canReadAttendance = (actor, attendance) => { const canReadAttendance = (actor, attendance) => {
if (!actor || !attendance || !sameTenant(actor, attendance)) return false; if (!actor || !attendance || !sameTenant(actor, attendance)) return false;
@@ -38,5 +39,6 @@ module.exports = {
canManageUserStatus, canManageUserStatus,
canChangeUserEmail, canChangeUserEmail,
canManageUserRoleOrTeam, canManageUserRoleOrTeam,
canManageUserPassword,
canReadAttendance, canReadAttendance,
}; };

View File

@@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto'); const crypto = require('crypto');
const { requireRole } = require('../middleware/auth'); const { requireRole } = require('../middleware/auth');
const { const {
@@ -7,10 +8,12 @@ const {
canManageUserStatus, canManageUserStatus,
canChangeUserEmail, canChangeUserEmail,
canManageUserRoleOrTeam, canManageUserRoleOrTeam,
canManageUserPassword,
} = require('../policies/accessPolicy'); } = require('../policies/accessPolicy');
const { recordActivity } = require('../services/activityService'); 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 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 createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => {
const router = express.Router(); 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) => { 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 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 finalRole = role || 'agent';
let finalTeamId = team_id || null; let finalTeamId = team_id || null;
@@ -72,18 +76,32 @@ const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => {
finalTeamId = req.user.team_id; finalTeamId = req.user.team_id;
} }
try { 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]); 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.' }); if (existing.length > 0) return res.status(400).json({ error: 'E-mail já cadastrado.' });
const uid = `u_${crypto.randomUUID().split('-')[0]}`; const uid = `u_${crypto.randomUUID().split('-')[0]}`;
const slug = `${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${crypto.randomBytes(4).toString('hex')}`; 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( await pool.query(
'INSERT INTO users (id, tenant_id, team_id, name, email, password_hash, slug, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', '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'); const token = crypto.randomBytes(32).toString('hex');
await pool.query( await pool.query(
'INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', '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) => { 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 { try {
const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]); 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 (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 (!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 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 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] [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') { if (finalTeamId && finalTeamId !== existing[0].team_id && existing[0].status === 'active') {
const [team] = await pool.query('SELECT name FROM teams WHERE id = ?', [finalTeamId]); const [team] = await pool.query('SELECT name FROM teams WHERE id = ?', [finalTeamId]);
if (team.length > 0) { if (team.length > 0) {

View File

@@ -6,6 +6,7 @@ const {
canManageUserStatus, canManageUserStatus,
canChangeUserEmail, canChangeUserEmail,
canManageUserRoleOrTeam, canManageUserRoleOrTeam,
canManageUserPassword,
canReadAttendance, canReadAttendance,
} = require('../policies/accessPolicy'); } = require('../policies/accessPolicy');
@@ -48,6 +49,13 @@ test('only admins can manage role, team, and status fields', () => {
assert.equal(canChangeUserEmail(manager, agent), false); 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', () => { 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 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' }; const teamAttendance = { id: 'att_2', tenant_id: 'tenant_a', user_id: 'u_another', team_id: 'team_a' };

14
package-lock.json generated
View File

@@ -2913,9 +2913,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.13.1", "version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -2935,12 +2935,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.13.1", "version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.13.1" "react-router": "7.17.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; 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 { getUsers, getTeams, createMember, deleteUser, updateUser, getUserById, getTenants } from '../services/dataService';
import { User, Tenant } from '../types'; import { User, Tenant } from '../types';
@@ -24,7 +24,9 @@ export const TeamManagement: React.FC = () => {
role: 'agent' as any, role: 'agent' as any,
team_id: '', team_id: '',
status: 'active' as any, status: 'active' as any,
tenant_id: '' tenant_id: '',
password: '',
confirmPassword: ''
}); });
const loadData = async () => { const loadData = async () => {
@@ -67,12 +69,36 @@ export const TeamManagement: React.FC = () => {
try { try {
const tid = localStorage.getItem('ctms_tenant_id') || ''; const tid = localStorage.getItem('ctms_tenant_id') || '';
const finalTenantId = currentUser?.role === 'super_admin' ? formData.tenant_id : tid; 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) { if (editingUser) {
const success = await updateUser(editingUser.id, { ...formData, tenant_id: finalTenantId }); const success = await updateUser(editingUser.id, payload);
if (success) { setIsModalOpen(false); loadData(); } if (success) { setIsModalOpen(false); loadData(); }
} else { } else {
await createMember({ ...formData, tenant_id: finalTenantId }); await createMember(payload);
setIsModalOpen(false); setIsModalOpen(false);
loadData(); loadData();
} }
@@ -94,6 +120,7 @@ export const TeamManagement: React.FC = () => {
if (loading && users.length === 0) return <div className="flex h-screen items-center justify-center text-zinc-400 dark:text-dark-muted transition-colors">Carregando...</div>; if (loading && users.length === 0) return <div className="flex h-screen items-center justify-center text-zinc-400 dark:text-dark-muted transition-colors">Carregando...</div>;
const canManage = currentUser?.role === 'admin' || currentUser?.role === 'super_admin' || currentUser?.role === 'manager'; 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 => const filtered = users.filter(u =>
(u.name.toLowerCase().includes(searchTerm.toLowerCase()) || u.email.toLowerCase().includes(searchTerm.toLowerCase())) && (u.name.toLowerCase().includes(searchTerm.toLowerCase()) || u.email.toLowerCase().includes(searchTerm.toLowerCase())) &&
(tenantFilter === 'all' || u.tenant_id === tenantFilter) (tenantFilter === 'all' || u.tenant_id === tenantFilter)
@@ -122,7 +149,7 @@ export const TeamManagement: React.FC = () => {
<p className="text-zinc-500 dark:text-dark-muted text-sm">Visualize e gerencie as funções dos membros da sua organização.</p> <p className="text-zinc-500 dark:text-dark-muted text-sm">Visualize e gerencie as funções dos membros da sua organização.</p>
</div> </div>
{canManage && ( {canManage && (
<button onClick={() => { setEditingUser(null); setFormData({name:'', email:'', role:'agent', team_id:'', status:'active'}); setIsModalOpen(true); }} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-4 py-2 rounded-lg flex items-center gap-2 text-sm font-bold shadow-sm hover:opacity-90 transition-all"> <button onClick={() => { setEditingUser(null); setFormData({name:'', email:'', role:'agent', team_id:'', status:'active', tenant_id:'', password:'', confirmPassword:''}); setIsModalOpen(true); }} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-4 py-2 rounded-lg flex items-center gap-2 text-sm font-bold shadow-sm hover:opacity-90 transition-all">
<Plus size={16} /> Adicionar Membro <Plus size={16} /> Adicionar Membro
</button> </button>
)} )}
@@ -195,7 +222,7 @@ export const TeamManagement: React.FC = () => {
{canManage && ( {canManage && (
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2 transition-opacity"> <div className="flex justify-end gap-2 transition-opacity">
<button onClick={() => { setEditingUser(user); setFormData({name:user.name, email:user.email, role:user.role as any, team_id:user.team_id||'', status:user.status as any, tenant_id: user.tenant_id||''}); setIsModalOpen(true); }} className="p-2 hover:bg-zinc-100 dark:hover:bg-dark-border text-zinc-400 hover:text-zinc-900 dark:hover:text-dark-text rounded-lg transition-colors"><Edit size={16} /></button> <button onClick={() => { setEditingUser(user); setFormData({name:user.name, email:user.email, role:user.role as any, team_id:user.team_id||'', status:user.status as any, tenant_id: user.tenant_id||'', password:'', confirmPassword:''}); setIsModalOpen(true); }} className="p-2 hover:bg-zinc-100 dark:hover:bg-dark-border text-zinc-400 hover:text-zinc-900 dark:hover:text-dark-text rounded-lg transition-colors"><Edit size={16} /></button>
<button onClick={() => { setUserToDelete(user); setDeleteConfirmName(''); setIsDeleteModalOpen(true); }} className="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 text-zinc-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg transition-colors"><Trash2 size={16} /></button> <button onClick={() => { setUserToDelete(user); setDeleteConfirmName(''); setIsDeleteModalOpen(true); }} className="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 text-zinc-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg transition-colors"><Trash2 size={16} /></button>
</div> </div>
</td> </td>
@@ -209,7 +236,7 @@ export const TeamManagement: React.FC = () => {
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
<div className="bg-white dark:bg-dark-card rounded-2xl p-8 w-full max-w-lg shadow-2xl animate-in zoom-in duration-200 transition-colors border border-transparent dark:border-dark-border"> <div className="bg-white dark:bg-dark-card rounded-2xl p-8 w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-2xl animate-in zoom-in duration-200 transition-colors border border-transparent dark:border-dark-border">
<h3 className="text-xl font-bold text-zinc-900 dark:text-dark-text mb-6">{editingUser ? 'Editar Usuário' : 'Novo Membro'}</h3> <h3 className="text-xl font-bold text-zinc-900 dark:text-dark-text mb-6">{editingUser ? 'Editar Usuário' : 'Novo Membro'}</h3>
<form onSubmit={handleSave} className="space-y-5"> <form onSubmit={handleSave} className="space-y-5">
<div> <div>
@@ -275,6 +302,38 @@ export const TeamManagement: React.FC = () => {
<option value="inactive">Inativo</option> <option value="inactive">Inativo</option>
</select> </select>
</div> </div>
{canManagePasswords && (
<div className="space-y-4 rounded-xl border border-zinc-200 dark:border-dark-border bg-zinc-50 dark:bg-dark-bg/50 p-4">
<div className="flex items-center gap-2 text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase">
<KeyRound size={14} className="text-brand-yellow" />
{editingUser ? 'Alterar senha' : 'Senha inicial'}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Senha</label>
<input
type="password"
value={formData.password}
onChange={e => 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"
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Confirmar</label>
<input
type="password"
value={formData.confirmPassword}
onChange={e => 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"
/>
</div>
</div>
</div>
)}
<div className="flex justify-end gap-3 pt-6 border-t dark:border-dark-border mt-6"> <div className="flex justify-end gap-3 pt-6 border-t dark:border-dark-border mt-6">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-6 py-2.5 text-sm font-medium text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg transition-colors">Cancelar</button> <button type="button" onClick={() => setIsModalOpen(false)} className="px-6 py-2.5 text-sm font-medium text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg transition-colors">Cancelar</button>
<button type="submit" disabled={isSaving} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-8 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2 hover:opacity-90 transition-all">{isSaving ? <Loader2 className="animate-spin" size={16} /> : 'Salvar Alterações'}</button> <button type="submit" disabled={isSaving} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-8 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2 hover:opacity-90 transition-all">{isSaving ? <Loader2 className="animate-spin" size={16} /> : 'Salvar Alterações'}</button>