Compare commits

...

2 Commits

Author SHA1 Message Date
Cauê Faleiros
cbbe519b5a feat: allow admins to edit member emails
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m14s
2026-03-06 14:56:16 -03:00
Cauê Faleiros
13b4c0316b feat: restrict managers to their own team
- Backend now only returns users, teams, and attendances from a manager's own team.

- Hidden 'Todas as Equipes' filter from manager dashboard.

- Removed manager ability to create or edit teams.
2026-03-06 14:54:42 -03:00
4 changed files with 80 additions and 28 deletions

View File

@@ -286,7 +286,17 @@ apiRouter.get('/users', async (req, res) => {
let q = 'SELECT * FROM users'; let q = 'SELECT * FROM users';
const params = []; const params = [];
if (effectiveTenantId && effectiveTenantId !== 'all') { q += ' WHERE tenant_id = ?'; params.push(effectiveTenantId); } if (effectiveTenantId && effectiveTenantId !== 'all') {
q += ' WHERE tenant_id = ?';
params.push(effectiveTenantId);
}
// Strict RBAC: Managers can only see users in their own team
if (req.user.role === 'manager') {
q += (params.length > 0 ? ' AND' : ' WHERE') + ' team_id = ?';
params.push(req.user.team_id);
}
const [rows] = await pool.query(q, params); const [rows] = await pool.query(q, params);
res.json(rows); res.json(rows);
} catch (error) { res.status(500).json({ error: error.message }); } } catch (error) { res.status(500).json({ error: error.message }); }
@@ -447,17 +457,27 @@ apiRouter.get('/attendances', async (req, res) => {
if (req.user.role === 'agent') { if (req.user.role === 'agent') {
q += ' AND a.user_id = ?'; q += ' AND a.user_id = ?';
params.push(req.user.id); params.push(req.user.id);
} else if (userId && userId !== 'all') { } else {
// check if it's a slug or id if (req.user.role === 'manager') {
if (userId.startsWith('u_')) { q += ' AND u.team_id = ?';
q += ' AND a.user_id = ?'; params.push(req.user.team_id);
params.push(userId); } else if (teamId && teamId !== 'all') {
} else { q += ' AND u.team_id = ?';
q += ' AND u.slug = ?'; params.push(teamId);
params.push(userId); }
if (userId && userId !== 'all') {
// check if it's a slug or id
if (userId.startsWith('u_')) {
q += ' AND a.user_id = ?';
params.push(userId);
} else {
q += ' AND u.slug = ?';
params.push(userId);
}
} }
} }
if (teamId && teamId !== 'all') { q += ' AND u.team_id = ?'; params.push(teamId); }
if (funnelStage && funnelStage !== 'all') { q += ' AND a.funnel_stage = ?'; params.push(funnelStage); } if (funnelStage && funnelStage !== 'all') { q += ' AND a.funnel_stage = ?'; params.push(funnelStage); }
if (origin && origin !== 'all') { q += ' AND a.origin = ?'; params.push(origin); } if (origin && origin !== 'all') { q += ' AND a.origin = ?'; params.push(origin); }
@@ -513,6 +533,13 @@ apiRouter.get('/teams', async (req, res) => {
q += ' WHERE tenant_id = ?'; q += ' WHERE tenant_id = ?';
params.push(effectiveTenantId); params.push(effectiveTenantId);
} }
// Strict RBAC: Managers can only see their own team
if (req.user.role === 'manager') {
q += (params.length > 0 ? ' AND' : ' WHERE') + ' id = ?';
params.push(req.user.team_id);
}
const [rows] = await pool.query(q, params); const [rows] = await pool.query(q, params);
res.json(rows); res.json(rows);
} catch (error) { } catch (error) {
@@ -520,7 +547,7 @@ apiRouter.get('/teams', async (req, res) => {
} }
}); });
apiRouter.post('/teams', requireRole(['admin', 'manager', 'owner', 'super_admin']), async (req, res) => { apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
const { name, description, tenantId } = req.body; const { name, description, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -536,7 +563,7 @@ apiRouter.post('/teams', requireRole(['admin', 'manager', 'owner', 'super_admin'
} }
}); });
apiRouter.put('/teams/:id', requireRole(['admin', 'manager', 'owner', 'super_admin']), async (req, res) => { apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
const { name, description } = req.body; const { name, description } = req.body;
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);

View File

@@ -253,14 +253,16 @@ export const Dashboard: React.FC = () => {
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)} {users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select> </select>
<select {currentUser?.role !== 'manager' && (
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all" <select
value={filters.teamId} className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
onChange={(e) => handleFilterChange('teamId', e.target.value)} value={filters.teamId}
> onChange={(e) => handleFilterChange('teamId', e.target.value)}
<option value="all">Todas Equipes</option> >
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)} <option value="all">Todas Equipes</option>
</select> {teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
)}
</> </>
)} )}

View File

@@ -218,7 +218,20 @@ export const TeamManagement: React.FC = () => {
</div> </div>
<div> <div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">E-mail</label> <label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">E-mail</label>
<input type="email" value={formData.email} onChange={e => setFormData({...formData, email: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 disabled:bg-zinc-50 dark:disabled:bg-dark-bg/50 dark:disabled:text-dark-muted" disabled={!!editingUser} required /> <input
type="email"
value={formData.email}
onChange={e => setFormData({...formData, email: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 disabled:bg-zinc-50 dark:disabled:bg-dark-bg/50 dark:disabled:text-dark-muted"
disabled={
!!editingUser && (
currentUser?.role === 'manager' ||
(editingUser.role === 'admin' && currentUser?.role !== 'super_admin') ||
(editingUser.role === 'super_admin' && currentUser?.role !== 'super_admin')
)
}
required
/>
</div> </div>
{currentUser?.role === 'super_admin' && ( {currentUser?.role === 'super_admin' && (
<div> <div>

View File

@@ -1,12 +1,13 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Building2, Users, Plus, Search, Target, ArrowUpRight, Loader2, Edit2, X } from 'lucide-react'; import { Building2, Users, Plus, Search, Target, ArrowUpRight, Loader2, Edit2, X } from 'lucide-react';
import { getTeams, getUsers, getAttendances, createTeam, updateTeam } from '../services/dataService'; import { getTeams, getUsers, getAttendances, createTeam, updateTeam, getUserById } from '../services/dataService';
import { User, Attendance } from '../types'; import { User, Attendance } from '../types';
export const Teams: React.FC = () => { export const Teams: React.FC = () => {
const [teams, setTeams] = useState<any[]>([]); const [teams, setTeams] = useState<any[]>([]);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [attendances, setAttendances] = useState<Attendance[]>([]); const [attendances, setAttendances] = useState<Attendance[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -15,15 +16,18 @@ export const Teams: React.FC = () => {
const loadData = async () => { const loadData = async () => {
const tid = localStorage.getItem('ctms_tenant_id'); const tid = localStorage.getItem('ctms_tenant_id');
const uid = localStorage.getItem('ctms_user_id');
if (!tid) return; if (!tid) return;
setLoading(true); setLoading(true);
try { try {
const [ft, fu, fa] = await Promise.all([ const [ft, fu, fa, me] = await Promise.all([
getTeams(tid), getTeams(tid),
getUsers(tid), getUsers(tid),
getAttendances(tid, { dateRange: { start: new Date(0), end: new Date() }, userId: 'all', teamId: 'all' }) getAttendances(tid, { dateRange: { start: new Date(0), end: new Date() }, userId: 'all', teamId: 'all' }),
uid ? getUserById(uid) : null
]); ]);
setTeams(ft); setUsers(fu); setAttendances(fa); setTeams(ft); setUsers(fu); setAttendances(fa);
if (me) setCurrentUser(me);
} catch (e) { console.error(e); } finally { setLoading(false); } } catch (e) { console.error(e); } finally { setLoading(false); }
}; };
@@ -51,6 +55,8 @@ export const Teams: React.FC = () => {
if (loading && teams.length === 0) return <div className="p-12 text-center text-zinc-400 dark:text-dark-muted transition-colors">Carregando...</div>; if (loading && teams.length === 0) return <div className="p-12 text-center text-zinc-400 dark:text-dark-muted transition-colors">Carregando...</div>;
const canManage = currentUser?.role === 'admin' || currentUser?.role === 'super_admin';
return ( return (
<div className="space-y-8 max-w-7xl mx-auto pb-12 transition-colors duration-300"> <div className="space-y-8 max-w-7xl mx-auto pb-12 transition-colors duration-300">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@@ -58,9 +64,11 @@ export const Teams: React.FC = () => {
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">Times</h1> <h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">Times</h1>
<p className="text-zinc-500 dark:text-dark-muted text-sm">Visualize o desempenho e gerencie seus grupos de vendas.</p> <p className="text-zinc-500 dark:text-dark-muted text-sm">Visualize o desempenho e gerencie seus grupos de vendas.</p>
</div> </div>
<button onClick={() => { setEditingTeam(null); setFormData({name:'', description:''}); 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"> {canManage && (
<Plus size={16} /> Novo Time <button onClick={() => { setEditingTeam(null); setFormData({name:'', description:''}); 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> <Plus size={16} /> Novo Time
</button>
)}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -68,7 +76,9 @@ export const Teams: React.FC = () => {
<div key={t.id} className="bg-white dark:bg-dark-card rounded-2xl border border-zinc-200 dark:border-dark-border p-6 shadow-sm group hover:shadow-md transition-all"> <div key={t.id} className="bg-white dark:bg-dark-card rounded-2xl border border-zinc-200 dark:border-dark-border p-6 shadow-sm group hover:shadow-md transition-all">
<div className="flex justify-between mb-6"> <div className="flex justify-between mb-6">
<div className="p-3 bg-zinc-50 dark:bg-dark-bg text-zinc-600 dark:text-dark-muted rounded-xl border border-zinc-100 dark:border-dark-border"><Building2 size={24} /></div> <div className="p-3 bg-zinc-50 dark:bg-dark-bg text-zinc-600 dark:text-dark-muted rounded-xl border border-zinc-100 dark:border-dark-border"><Building2 size={24} /></div>
<button onClick={() => { setEditingTeam(t); setFormData({name:t.name, description:t.description||''}); setIsModalOpen(true); }} className="text-zinc-400 dark:text-dark-muted hover:text-zinc-900 dark:hover:text-dark-text opacity-0 group-hover:opacity-100 p-2 rounded-lg hover:bg-zinc-50 dark:hover:bg-dark-bg transition-all"><Edit2 size={18} /></button> {canManage && (
<button onClick={() => { setEditingTeam(t); setFormData({name:t.name, description:t.description||''}); setIsModalOpen(true); }} className="text-zinc-400 dark:text-dark-muted hover:text-zinc-900 dark:hover:text-dark-text opacity-0 group-hover:opacity-100 p-2 rounded-lg hover:bg-zinc-50 dark:hover:bg-dark-bg transition-all"><Edit2 size={18} /></button>
)}
</div> </div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-zinc-50 mb-1">{t.name}</h3> <h3 className="text-lg font-bold text-zinc-900 dark:text-zinc-50 mb-1">{t.name}</h3>
<p className="text-sm text-zinc-500 dark:text-dark-muted mb-6 h-10 line-clamp-2">{t.description || 'Sem descrição definida.'}</p> <p className="text-sm text-zinc-500 dark:text-dark-muted mb-6 h-10 line-clamp-2">{t.description || 'Sem descrição definida.'}</p>