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.
This commit is contained in:
@@ -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]);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user