feat: implement secure multi-tenancy, RBAC, and premium dark mode
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m54s

- Enforced tenant isolation and Role-Based Access Control across all API routes

- Implemented secure profile avatar upload using multer and UUIDs

- Redesigned UI with a premium "Onyx & Gold" Charcoal dark mode

- Added Funnel Stage and Origin filters to Dashboard and User Detail pages

- Replaced "Referral" with "Indicação" across the platform and database

- Optimized Dockerfile and local environment setup for reliable deployments

- Fixed frontend syntax errors and improved KPI/Chart visualizations
This commit is contained in:
Cauê Faleiros
2026-03-03 17:16:55 -03:00
parent b7e73fce3d
commit 20bdf510fd
32 changed files with 2810 additions and 1140 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Users, Plus, Mail, Search, X, Edit, Trash2, Loader2, CheckCircle2, AlertCircle, AlertTriangle } from 'lucide-react';
import { Users, Plus, Mail, Search, X, Edit, Trash2, Loader2, AlertTriangle } from 'lucide-react';
import { getUsers, getTeams, createMember, deleteUser, updateUser, getUserById } from '../services/dataService';
import { User } from '../types';
@@ -15,7 +15,13 @@ export const TeamManagement: React.FC = () => {
const [userToDelete, setUserToDelete] = useState<User | null>(null);
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const [editingUser, setEditingUser] = useState<User | null>(null);
const [formData, setFormData] = useState({ name: '', email: '', role: 'agent' as any, team_id: '', status: 'active' as any });
const [formData, setFormData] = useState({
name: '',
email: '',
role: 'agent' as any,
team_id: '',
status: 'active' as any
});
const loadData = async () => {
const tid = localStorage.getItem('ctms_tenant_id');
@@ -37,9 +43,11 @@ export const TeamManagement: React.FC = () => {
setIsSaving(true);
try {
const tid = localStorage.getItem('ctms_tenant_id') || '';
const success = editingUser ? await updateUser(editingUser.id, formData) : await createMember({ ...formData, tenant_id: tid });
const success = editingUser
? await updateUser(editingUser.id, formData)
: await createMember({ ...formData, tenant_id: tid });
if (success) { setIsModalOpen(false); loadData(); }
} catch (err) { console.error(err); } finally { setIsSaving(false); }
} catch (err) { alert('Erro ao salvar'); } finally { setIsSaving(false); }
};
const handleConfirmDelete = async () => {
@@ -47,89 +55,143 @@ export const TeamManagement: React.FC = () => {
setIsSaving(true);
try {
if (await deleteUser(userToDelete.id)) { setIsDeleteModalOpen(false); loadData(); }
} catch (err) { console.error(err); } finally { setIsSaving(false); }
} catch (err) { alert('Erro ao excluir'); } finally { setIsSaving(false); }
};
if (loading && users.length === 0) return <div className="flex h-screen items-center justify-center text-slate-400">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';
const filtered = users.filter(u => u.name.toLowerCase().includes(searchTerm.toLowerCase()) || u.email.toLowerCase().includes(searchTerm.toLowerCase()));
const getRoleLabel = (role: string) => {
if (role === 'admin') return 'Admin';
if (role === 'manager') return 'Gerente';
return 'Agente';
};
const getRoleBadge = (role: string) => {
switch (role) {
case 'admin': return 'bg-purple-100 text-purple-700 border-purple-200';
case 'manager': return 'bg-blue-100 text-blue-700 border-blue-200';
default: return 'bg-slate-100 text-slate-700 border-slate-200';
case 'admin': return 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800';
case 'manager': return 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800';
default: return 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-dark-input dark:text-dark-muted dark:border-dark-border';
}
};
return (
<div className="space-y-8 max-w-7xl mx-auto pb-12">
<div className="space-y-8 max-w-7xl mx-auto pb-12 transition-colors duration-300">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Membros</h1>
<p className="text-slate-500 text-sm">Gerencie os acessos da sua organização.</p>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text tracking-tight">Membros</h1>
<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>
{canManage && (
<button onClick={() => { setEditingUser(null); setFormData({name:'', email:'', role:'agent', team_id:'', status:'active'}); setIsModalOpen(true); }} className="bg-slate-900 text-white px-4 py-2 rounded-lg flex items-center gap-2 text-sm font-bold shadow-sm">
<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">
<Plus size={16} /> Adicionar Membro
</button>
)}
</div>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div className="p-4 border-b border-slate-100 bg-slate-50/30">
<input type="text" placeholder="Buscar membros..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="w-full max-w-md border border-slate-200 p-2 rounded-lg text-sm outline-none" />
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-xl shadow-sm overflow-hidden">
<div className="p-4 border-b border-zinc-100 dark:border-dark-border bg-zinc-50 dark:bg-dark-bg/50">
<div className="relative max-w-md">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
<input type="text" placeholder="Buscar membros..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="w-full pl-9 pr-4 py-2 bg-white dark:bg-dark-bg border border-zinc-200 dark:border-dark-border rounded-lg text-sm text-zinc-900 dark:text-dark-text outline-none focus:ring-2 focus:ring-brand-yellow/20 transition-all" />
</div>
</div>
<table className="w-full text-left">
<thead className="bg-slate-50/50 text-slate-500 text-xs uppercase font-bold border-b">
<tr>
<th className="px-6 py-4">Usuário</th>
<th className="px-6 py-4">Função</th>
<th className="px-6 py-4">Time</th>
<th className="px-6 py-4">Status</th>
{canManage && <th className="px-6 py-4 text-right">Ações</th>}
</tr>
</thead>
<tbody className="divide-y text-sm">
{filtered.map(user => (
<tr key={user.id} className="hover:bg-slate-50 group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<img src={`https://ui-avatars.com/api/?name=${encodeURIComponent(user.name)}&background=random`} alt="" className="w-8 h-8 rounded-full" />
<div><div className="font-bold text-slate-900">{user.name}</div><div className="text-xs text-slate-500">{user.email}</div></div>
</div>
</td>
<td className="px-6 py-4"><span className={`px-2.5 py-0.5 rounded-full text-xs font-bold border capitalize ${getRoleBadge(user.role)}`}>{user.role}</span></td>
<td className="px-6 py-4 text-slate-600">{teams.find(t => t.id === user.team_id)?.name || '-'}</td>
<td className="px-6 py-4"><span className={`px-2 py-0.5 rounded-full text-xs font-bold ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'}`}>{user.status}</span></td>
{canManage && (
<td className="px-6 py-4 text-right flex justify-end gap-2 opacity-0 group-hover:opacity-100">
<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}); setIsModalOpen(true); }} className="p-1 hover:text-blue-600"><Edit size={16} /></button>
<button onClick={() => { setUserToDelete(user); setDeleteConfirmName(''); setIsDeleteModalOpen(true); }} className="p-1 hover:text-red-600"><Trash2 size={16} /></button>
</td>
)}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="bg-zinc-50 dark:bg-dark-bg/50 text-zinc-500 dark:text-dark-muted text-xs uppercase font-bold border-b dark:border-dark-border">
<th className="px-6 py-4">Usuário</th>
<th className="px-6 py-4">Função</th>
<th className="px-6 py-4">Time</th>
<th className="px-6 py-4">Status</th>
{canManage && <th className="px-6 py-4 text-right">Ações</th>}
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y dark:divide-dark-border text-sm">
{filtered.map(user => (
<tr key={user.id} className="hover:bg-zinc-50 dark:hover:bg-dark-border/50 transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="relative">
<img
src={user.avatar_url
? (user.avatar_url.startsWith('http') ? user.avatar_url : `${import.meta.env.PROD ? '' : 'http://localhost:3001'}${user.avatar_url}`)
: `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name)}&background=random`}
alt=""
className="w-10 h-10 rounded-full border border-zinc-200 dark:border-dark-border object-cover"
/>
<span className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-dark-card ${user.status === 'active' ? 'bg-green-500' : 'bg-zinc-300 dark:bg-zinc-600'}`}></span>
</div>
<div>
<div className="font-bold text-zinc-900 dark:text-dark-text">{user.name}</div>
<div className="text-xs text-zinc-500 dark:text-dark-muted">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold border capitalize ${getRoleBadge(user.role)}`}>{getRoleLabel(user.role)}</span>
</td>
<td className="px-6 py-4 text-zinc-600 dark:text-zinc-300 font-medium">{teams.find(t => t.id === user.team_id)?.name || '-'}</td>
<td className="px-6 py-4">
<span className={`px-2 py-0.5 rounded-full text-xs font-bold border ${user.status === 'active' ? 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' : 'bg-zinc-100 text-zinc-500 border-zinc-200 dark:bg-dark-input dark:text-dark-muted dark:border-dark-border'}`}>{user.status === 'active' ? 'Ativo' : 'Inativo'}</span>
</td>
{canManage && (
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 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}); 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>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
<h3 className="text-lg font-bold mb-4">{editingUser ? 'Editar' : 'Novo'} Membro</h3>
<form onSubmit={handleSave} className="space-y-4">
<div><label className="text-xs font-bold text-slate-500 block mb-1">NOME</label><input type="text" value={formData.name} onChange={e => setFormData({...formData, name:e.target.value})} className="w-full border p-2 rounded-lg" required /></div>
<div><label className="text-xs font-bold text-slate-500 block mb-1">E-MAIL</label><input type="email" value={formData.email} onChange={e => setFormData({...formData, email:e.target.value})} className="w-full border p-2 rounded-lg" disabled={!!editingUser} required /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="text-xs font-bold text-slate-500 block mb-1">FUNÇÃO</label><select value={formData.role} onChange={e => setFormData({...formData, role: e.target.value as any})} className="w-full border p-2 rounded-lg"><option value="agent">Agente</option><option value="manager">Gerente</option><option value="admin">Admin</option></select></div>
<div><label className="text-xs font-bold text-slate-500 block mb-1">TIME</label><select value={formData.team_id} onChange={e => setFormData({...formData, team_id: e.target.value})} className="w-full border p-2 rounded-lg"><option value="">Nenhum</option>{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}</select></div>
<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">
<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">
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Nome Completo</label>
<input type="text" value={formData.name} onChange={e => setFormData({...formData, name: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" required />
</div>
<div><label className="text-xs font-bold text-slate-500 block mb-1">STATUS</label><select value={formData.status} onChange={e => setFormData({...formData, status: e.target.value as any})} className="w-full border p-2 rounded-lg"><option value="active">Ativo</option><option value="inactive">Inativo</option></select></div>
<div className="flex justify-end gap-2 pt-4">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2">Cancelar</button>
<button type="submit" className="bg-slate-900 text-white px-6 py-2 rounded-lg font-bold">{isSaving ? '...' : 'Salvar'}</button>
<div>
<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 />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Função</label>
<select value={formData.role} onChange={e => setFormData({...formData, role: e.target.value as any})} 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">
<option value="agent">Agente</option>
<option value="manager">Gerente</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Time</label>
<select value={formData.team_id} onChange={e => setFormData({...formData, team_id: 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">
<option value="">Nenhum</option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Status</label>
<select value={formData.status} onChange={e => setFormData({...formData, status: e.target.value as any})} 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">
<option value="active">Ativo</option>
<option value="inactive">Inativo</option>
</select>
</div>
<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="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>
</div>
</form>
</div>
@@ -137,13 +199,15 @@ export const TeamManagement: React.FC = () => {
)}
{isDeleteModalOpen && userToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white rounded-2xl p-6 w-full max-w-md">
<h3 className="text-lg font-bold mb-2">Excluir {userToDelete.name}?</h3>
<input type="text" value={deleteConfirmName} onChange={e => setDeleteConfirmName(e.target.value)} className="w-full border-2 p-2 rounded-lg mb-4 text-center font-bold" placeholder="Digite o nome para confirmar" />
<div className="flex gap-2">
<button onClick={() => setIsDeleteModalOpen(false)} className="flex-1 py-2">Cancelar</button>
<button onClick={handleConfirmDelete} disabled={deleteConfirmName !== userToDelete.name} className="flex-1 py-2 bg-red-600 text-white rounded-lg">Excluir</button>
<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-md shadow-xl animate-in zoom-in duration-200 text-center transition-colors border border-transparent dark:border-dark-border">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-full flex items-center justify-center mx-auto mb-4"><AlertTriangle size={32} /></div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-dark-text mb-2">Excluir {userToDelete.name}?</h3>
<p className="text-sm text-zinc-500 dark:text-dark-muted mb-8 leading-relaxed">Esta ação é <strong>permanente</strong>. Para confirmar, digite o nome completo dele abaixo:</p>
<input type="text" value={deleteConfirmName} onChange={e => setDeleteConfirmName(e.target.value)} className="w-full bg-white dark:bg-dark-input border-2 border-red-50 dark:border-red-900/20 p-3 rounded-xl mb-8 text-center font-bold text-base text-zinc-900 dark:text-dark-text outline-none focus:ring-2 focus:ring-red-100 dark:focus:ring-red-900/40 transition-all" placeholder="Nome completo" />
<div className="flex gap-3">
<button onClick={() => setIsDeleteModalOpen(false)} className="flex-1 py-3 text-sm font-bold text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg border border-zinc-100 dark:border-dark-border transition-colors">Cancelar</button>
<button onClick={handleConfirmDelete} disabled={isSaving || deleteConfirmName !== userToDelete.name} className="flex-1 py-3 text-sm font-bold bg-red-600 text-white rounded-lg disabled:opacity-50 shadow-lg shadow-red-200 dark:shadow-none transition-all hover:bg-red-700">Excluir para sempre</button>
</div>
</div>
</div>