fix: resolve super_admin privileges and tenant management issues

- Fixed real backend deletion for tenants

- Allowed super_admins to manage other super_admins in Global Users

- Filtered teams based on selected tenant in user creation

- Protected system tenant from deletion
This commit is contained in:
Cauê Faleiros
2026-03-04 11:36:47 -03:00
parent 75631909df
commit d5b57835a7
5 changed files with 187 additions and 17 deletions

View File

@@ -457,7 +457,14 @@ apiRouter.get('/teams', async (req, res) => {
try { try {
const { tenantId } = req.query; const { tenantId } = req.query;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
const [rows] = await pool.query('SELECT * FROM teams WHERE tenant_id = ?', [effectiveTenantId]);
let q = 'SELECT * FROM teams';
const params = [];
if (effectiveTenantId && effectiveTenantId !== 'all') {
q += ' WHERE tenant_id = ?';
params.push(effectiveTenantId);
}
const [rows] = await pool.query(q, params);
res.json(rows); res.json(rows);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -509,13 +516,49 @@ apiRouter.post('/tenants', requireRole(['super_admin']), async (req, res) => {
await connection.beginTransaction(); await connection.beginTransaction();
const tid = `tenant_${crypto.randomUUID().split('-')[0]}`; const tid = `tenant_${crypto.randomUUID().split('-')[0]}`;
await connection.query('INSERT INTO tenants (id, name, slug, admin_email, status) VALUES (?, ?, ?, ?, ?)', [tid, name, slug, admin_email, status || 'active']); await connection.query('INSERT INTO tenants (id, name, slug, admin_email, status) VALUES (?, ?, ?, ?, ?)', [tid, name, slug, admin_email, status || 'active']);
const uid = `u_${crypto.randomUUID().split('-')[0]}`;
await connection.query('INSERT INTO users (id, tenant_id, name, email, role) VALUES (?, ?, ?, ?, ?)', [uid, tid, 'Admin', admin_email, 'admin']); // Check if user already exists
const [existingUser] = await connection.query('SELECT id FROM users WHERE email = ?', [admin_email]);
if (existingUser.length === 0) {
const uid = `u_${crypto.randomUUID().split('-')[0]}`;
const placeholderHash = 'pending_setup';
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)', [uid, tid, 'Admin', admin_email, placeholderHash, 'admin']);
const token = crypto.randomBytes(32).toString('hex');
await connection.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))', [admin_email, token]);
const setupLink = `${process.env.APP_URL || 'http://localhost:3001'}/#/reset-password?token=${token}`;
await transporter.sendMail({
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
to: admin_email,
subject: 'Bem-vindo ao Fasto - Crie sua senha',
html: `<p>Você foi convidado para ser Admin. <a href="${setupLink}">Crie sua senha aqui</a>.</p>`
}).catch(err => console.error("Email failed:", err));
}
await connection.commit(); await connection.commit();
res.status(201).json({ id: tid }); res.status(201).json({ id: tid });
} catch (error) { await connection.rollback(); res.status(500).json({ error: error.message }); } finally { connection.release(); } } catch (error) { await connection.rollback(); res.status(500).json({ error: error.message }); } finally { connection.release(); }
}); });
apiRouter.put('/tenants/:id', requireRole(['super_admin']), async (req, res) => {
const { name, slug, admin_email, status } = req.body;
try {
await pool.query(
'UPDATE tenants SET name = ?, slug = ?, admin_email = ?, status = ? WHERE id = ?',
[name, slug || null, admin_email, status, req.params.id]
);
res.json({ message: 'Tenant updated successfully.' });
} catch (error) { res.status(500).json({ error: error.message }); }
});
apiRouter.delete('/tenants/:id', requireRole(['super_admin']), async (req, res) => {
try {
await pool.query('DELETE FROM tenants WHERE id = ?', [req.params.id]);
res.json({ message: 'Tenant deleted successfully.' });
} catch (error) { res.status(500).json({ error: error.message }); }
});
// Mount the API Router // Mount the API Router
app.use('/api', apiRouter); app.use('/api', apiRouter);
@@ -529,6 +572,55 @@ if (process.env.NODE_ENV === 'production') {
}); });
} }
app.listen(PORT, () => { // Auto-provision Super Admin
const provisionSuperAdmin = async () => {
const email = 'suporte@blyzer.com.br';
try {
// Ensure system tenant exists
await pool.query('INSERT IGNORE INTO tenants (id, name, slug, admin_email, status) VALUES (?, ?, ?, ?, ?)', ['system', 'System Admin', 'system', email, 'active']);
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
if (existing.length === 0) {
console.log('Provisioning default super_admin...');
const uid = `u_${crypto.randomUUID().split('-')[0]}`;
const placeholderHash = 'pending_setup';
await pool.query(
'INSERT INTO users (id, tenant_id, name, email, password_hash, role, status) VALUES (?, ?, ?, ?, ?, ?, ?)',
[uid, 'system', 'Blyzer Suporte', email, placeholderHash, 'super_admin', 'active']
);
const token = crypto.randomBytes(32).toString('hex');
await pool.query(
'INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))',
[email, token]
);
const setupLink = `${process.env.APP_URL || 'http://localhost:3001'}/#/reset-password?token=${token}`;
console.log(`\n\n=== SUPER ADMIN SETUP LINK ===\n${setupLink}\n==============================\n\n`);
await transporter.sendMail({
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
to: email,
subject: 'Conta Super Admin Criada - Fasto',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #222; border-radius: 12px; background: #0a0a0a; color: #ededed;">
<h2 style="color: #facc15;">Conta Super Admin Gerada</h2>
<p>Sua conta de suporte (super_admin) foi criada no Fasto.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${setupLink}" style="background-color: #facc15; color: #09090b; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Definir Senha do Super Admin</a>
</div>
<p style="font-size: 12px; color: #888;">Este link expira em 24 horas.</p>
</div>
`
}).catch(err => console.error("Failed to send super_admin email:", err));
}
} catch (error) {
console.error('Failed to provision super_admin:', error);
}
};
app.listen(PORT, async () => {
await provisionSuperAdmin();
console.log(`🚀 Servidor Backend rodando em http://localhost:${PORT}`); console.log(`🚀 Servidor Backend rodando em http://localhost:${PORT}`);
}); });

View File

@@ -124,6 +124,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
Super Admin Super Admin
</div> </div>
<SidebarItem to="/super-admin" icon={Building2} label="Organizações" collapsed={false} /> <SidebarItem to="/super-admin" icon={Building2} label="Organizações" collapsed={false} />
<SidebarItem to="/admin/users" icon={Users} label="Usuários Globais" collapsed={false} />
</> </>
)} )}
</nav> </nav>

View File

@@ -3,7 +3,7 @@ import {
Building2, Users, MessageSquare, Plus, Search, Building2, Users, MessageSquare, Plus, Search,
Edit, Trash2, ChevronDown, ChevronUp, ChevronsUpDown, X Edit, Trash2, ChevronDown, ChevronUp, ChevronsUpDown, X
} from 'lucide-react'; } from 'lucide-react';
import { getTenants, createTenant } from '../services/dataService'; import { getTenants, createTenant, deleteTenant } from '../services/dataService';
import { Tenant } from '../types'; import { Tenant } from '../types';
import { DateRangePicker } from '../components/DateRangePicker'; import { DateRangePicker } from '../components/DateRangePicker';
import { KPICard } from '../components/KPICard'; import { KPICard } from '../components/KPICard';
@@ -76,9 +76,18 @@ export const SuperAdmin: React.FC = () => {
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleDelete = (id: string) => { const handleDelete = async (id: string) => {
if (id === 'system') {
alert('A organização do sistema não pode ser excluída.');
return;
}
if (confirm('Tem certeza que deseja excluir esta organização? Esta ação não pode ser desfeita.')) { if (confirm('Tem certeza que deseja excluir esta organização? Esta ação não pode ser desfeita.')) {
setTenants(prev => prev.filter(t => t.id !== id)); const success = await deleteTenant(id);
if (success) {
setTenants(prev => prev.filter(t => t.id !== id));
} else {
alert('Erro ao excluir a organização do servidor.');
}
} }
}; };

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Users, Plus, Mail, Search, X, Edit, Trash2, Loader2, 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 { getUsers, getTeams, createMember, deleteUser, updateUser, getUserById, getTenants } from '../services/dataService';
import { User } from '../types'; import { User, Tenant } from '../types';
export const TeamManagement: React.FC = () => { export const TeamManagement: React.FC = () => {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<any[]>([]); const [teams, setTeams] = useState<any[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
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);
@@ -20,7 +21,8 @@ export const TeamManagement: React.FC = () => {
email: '', email: '',
role: 'agent' as any, role: 'agent' as any,
team_id: '', team_id: '',
status: 'active' as any status: 'active' as any,
tenant_id: ''
}); });
const loadData = async () => { const loadData = async () => {
@@ -29,9 +31,28 @@ export const TeamManagement: React.FC = () => {
if (!tid) return; if (!tid) return;
setLoading(true); setLoading(true);
try { try {
const [fu, ft, me] = await Promise.all([getUsers(tid), getTeams(tid), uid ? getUserById(uid) : null]); const me = uid ? await getUserById(uid) : null;
setUsers(fu.filter(u => u.role !== 'super_admin'));
setTeams(ft); const isSuperAdmin = me?.role === 'super_admin';
const effectiveTid = isSuperAdmin ? 'all' : tid;
const promises: Promise<any>[] = [
getUsers(effectiveTid),
getTeams(effectiveTid)
];
if (isSuperAdmin) {
promises.push(getTenants());
}
const results = await Promise.all(promises);
setUsers(isSuperAdmin ? results[0] : results[0].filter((u: User) => u.role !== 'super_admin'));
setTeams(results[1]);
if (isSuperAdmin && results[2]) {
setTenants(results[2]);
}
if (me) setCurrentUser(me); if (me) setCurrentUser(me);
} catch (err) { console.error(err); } finally { setLoading(false); } } catch (err) { console.error(err); } finally { setLoading(false); }
}; };
@@ -43,11 +64,13 @@ export const TeamManagement: React.FC = () => {
setIsSaving(true); setIsSaving(true);
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;
if (editingUser) { if (editingUser) {
const success = await updateUser(editingUser.id, formData); const success = await updateUser(editingUser.id, { ...formData, tenant_id: finalTenantId });
if (success) { setIsModalOpen(false); loadData(); } if (success) { setIsModalOpen(false); loadData(); }
} else { } else {
await createMember({ ...formData, tenant_id: tid }); await createMember({ ...formData, tenant_id: finalTenantId });
setIsModalOpen(false); setIsModalOpen(false);
loadData(); loadData();
} }
@@ -72,6 +95,7 @@ export const TeamManagement: React.FC = () => {
const filtered = users.filter(u => u.name.toLowerCase().includes(searchTerm.toLowerCase()) || u.email.toLowerCase().includes(searchTerm.toLowerCase())); const filtered = users.filter(u => u.name.toLowerCase().includes(searchTerm.toLowerCase()) || u.email.toLowerCase().includes(searchTerm.toLowerCase()));
const getRoleLabel = (role: string) => { const getRoleLabel = (role: string) => {
if (role === 'super_admin') return 'Super Admin';
if (role === 'admin') return 'Admin'; if (role === 'admin') return 'Admin';
if (role === 'manager') return 'Gerente'; if (role === 'manager') return 'Gerente';
return 'Agente'; return 'Agente';
@@ -111,6 +135,7 @@ export const TeamManagement: React.FC = () => {
<thead> <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"> <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">Usuário</th>
{currentUser?.role === 'super_admin' && <th className="px-6 py-4">Organização</th>}
<th className="px-6 py-4">Função</th> <th className="px-6 py-4">Função</th>
<th className="px-6 py-4">Time</th> <th className="px-6 py-4">Time</th>
<th className="px-6 py-4">Status</th> <th className="px-6 py-4">Status</th>
@@ -138,6 +163,13 @@ export const TeamManagement: React.FC = () => {
</div> </div>
</div> </div>
</td> </td>
{currentUser?.role === 'super_admin' && (
<td className="px-6 py-4">
<span className="text-zinc-600 dark:text-zinc-300 font-medium">
{tenants.find(t => t.id === user.tenant_id)?.name || user.tenant_id}
</span>
</td>
)}
<td className="px-6 py-4"> <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> <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>
@@ -148,7 +180,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 opacity-0 group-hover:opacity-100 transition-opacity"> <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={() => { 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={() => { 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>
@@ -173,6 +205,15 @@ export const TeamManagement: React.FC = () => {
<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} required />
</div> </div>
{currentUser?.role === 'super_admin' && (
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Organização</label>
<select value={formData.tenant_id} onChange={e => setFormData({...formData, tenant_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" required>
<option value="">Selecione uma organização</option>
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Função</label> <label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Função</label>
@@ -186,7 +227,7 @@ export const TeamManagement: React.FC = () => {
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Time</label> <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"> <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> <option value="">Nenhum</option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)} {teams.filter(t => !formData.tenant_id || t.tenant_id === formData.tenant_id).map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select> </select>
</div> </div>
</div> </div>

View File

@@ -248,6 +248,33 @@ export const createTenant = async (tenantData: any): Promise<boolean> => {
} }
}; };
export const updateTenant = async (id: string, tenantData: any): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/tenants/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(tenantData)
});
return response.ok;
} catch (error) {
console.error("API Error (updateTenant):", error);
return false;
}
};
export const deleteTenant = async (id: string): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/tenants/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (deleteTenant):", error);
return false;
}
};
// --- Auth Functions --- // --- Auth Functions ---
export const logout = () => { export const logout = () => {