Files
bi-agents/src/admin-panel.js
root 8641100a18 feat: per-user panel permissions system
Replace hardcoded role-based access with granular per-panel permissions.
Each user can now be assigned any combination of 6 panels (Corporate, BI
Executive, Clientes, Providers, Usuarios, Meu Dashboard) regardless of
their role. Existing users are auto-migrated with defaults based on role.

- Add src/panels.js with panel registry and default permissions
- Add permissions column to SQLite + migration for existing users
- Add requirePermission() middleware, replace requireRole on all routes
- Dynamic nav in buildHeader based on user permissions
- Permissions checkbox UI in admin panel with role presets
- Anti-lockout: users cannot remove 'usuarios' from themselves

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:27:36 -05:00

621 lines
23 KiB
JavaScript

/**
* Admin Panel - HTML builder for user management
*/
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
const { PANELS, DEFAULT_PERMISSIONS } = require('./panels');
function buildAdminHTML(agentes, admin) {
const now = new Date().toLocaleString('pt-BR');
// Precompute permissions JSON for each agent row
const agentesWithPerms = agentes.map(a => {
let perms = [];
try { perms = JSON.parse(a.permissions || '[]'); } catch(e) {}
return { ...a, _perms: perms };
});
const pageCSS = `
<style>
.toolbar {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 20px;
}
.toolbar h2 { font-size: 18px; font-weight: 700; color: var(--text); }
.btn-create {
background: var(--admin-accent); color: white; border: none;
padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 600;
font-family: inherit; cursor: pointer; transition: all 0.15s;
box-shadow: 0 2px 6px rgba(46,125,50,0.3);
}
.btn-create:hover { background: #25732a; transform: translateY(-1px); }
.table-card {
background: var(--card); border-radius: 12px; border: 1px solid var(--border);
box-shadow: 0 1px 3px rgba(0,0,0,0.06); overflow: hidden;
}
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
background: #FAFBFC; padding: 12px 16px; text-align: left;
font-weight: 600; color: var(--text-secondary); font-size: 11px;
text-transform: uppercase; letter-spacing: 0.4px;
border-bottom: 2px solid var(--border); white-space: nowrap;
}
tbody td {
padding: 12px 16px; border-bottom: 1px solid #F3F4F6;
white-space: nowrap; vertical-align: middle;
}
tbody tr:hover { background: #F8F9FA; }
tbody tr:nth-child(even) { background: #FAFBFC; }
tbody tr:nth-child(even):hover { background: #F8F9FA; }
.status-badge {
display: inline-block; padding: 4px 10px; border-radius: 12px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
}
.status-badge.active { background: var(--green-bg); color: var(--green); }
.status-badge.inactive { background: var(--red-bg); color: var(--red); }
.status-badge.admin { background: var(--admin-bg); color: var(--admin-accent); }
.status-badge.corporate { background: var(--corporate-bg); color: var(--corporate-accent); }
.status-badge.agent { background: var(--blue-bg); color: var(--blue); }
.perm-badge {
display: inline-block; padding: 2px 7px; border-radius: 8px;
font-size: 10px; font-weight: 600; margin: 1px 2px;
background: var(--blue-bg); color: var(--blue);
}
.actions { display: flex; gap: 6px; }
.btn-action {
padding: 6px 12px; border-radius: 6px; font-size: 11px; font-weight: 600;
font-family: inherit; cursor: pointer; transition: all 0.15s; border: none;
}
.btn-emular { background: var(--green-bg); color: var(--green); text-decoration: none; }
.btn-emular:hover { background: #C8E6C9; }
.btn-edit { background: var(--blue-bg); color: var(--blue); }
.btn-edit:hover { background: #D2E3FC; }
.btn-toggle { background: var(--orange-bg); color: var(--orange); }
.btn-toggle:hover { background: #FDE7D8; }
.btn-password { background: var(--primary-bg); color: var(--primary); }
.btn-password:hover { background: #E8DCFA; }
/* Modal */
.modal-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); z-index: 1000;
align-items: center; justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--card); border-radius: 16px; width: 100%; max-width: 520px;
max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.modal-header {
padding: 20px 24px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
}
.modal-header h3 { font-size: 16px; font-weight: 700; color: var(--text); }
.modal-close {
background: none; border: none; font-size: 24px; color: var(--text-muted);
cursor: pointer; line-height: 1;
}
.modal-close:hover { color: var(--text); }
.modal-body { padding: 24px; }
.form-group { margin-bottom: 18px; }
.form-group label {
display: block; font-size: 12px; font-weight: 600;
color: var(--text-secondary); text-transform: uppercase;
letter-spacing: 0.3px; margin-bottom: 6px;
}
.form-group input, .form-group select {
width: 100%; padding: 10px 14px; border: 1.5px solid var(--border);
border-radius: 8px; font-size: 14px; font-family: inherit;
color: var(--text); transition: all 0.15s; background: white;
}
.form-group select { cursor: pointer; }
.form-group input:focus, .form-group select:focus {
outline: none; border-color: var(--admin-accent);
box-shadow: 0 0 0 3px rgba(46,125,50,0.12);
}
.form-group input:disabled { background: #F5F5F5; color: var(--text-muted); }
.modal-footer {
padding: 16px 24px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 10px;
}
.btn-cancel {
padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 600;
font-family: inherit; cursor: pointer; transition: all 0.15s;
background: white; border: 1.5px solid var(--border); color: var(--text);
}
.btn-cancel:hover { background: #F5F5F5; }
.btn-submit {
padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 600;
font-family: inherit; cursor: pointer; transition: all 0.15s;
background: var(--admin-accent); border: none; color: white;
box-shadow: 0 2px 6px rgba(46,125,50,0.3);
}
.btn-submit:hover { background: #25732a; }
/* Permissions checkboxes */
.perm-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
margin-top: 6px;
}
.perm-check {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: 8px; border: 1.5px solid var(--border);
cursor: pointer; transition: all 0.15s; font-size: 13px;
}
.perm-check:hover { border-color: var(--admin-accent); background: var(--green-bg); }
.perm-check input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--admin-accent); }
.perm-check.checked { border-color: var(--admin-accent); background: var(--green-bg); }
.alert {
padding: 12px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
margin-bottom: 20px; display: none;
}
.alert.success { background: var(--green-bg); color: var(--green); }
.alert.error { background: var(--red-bg); color: var(--red); }
.alert.show { display: block; }
@media (max-width: 768px) {
.toolbar { flex-direction: column; gap: 12px; align-items: stretch; }
.toolbar h2 { font-size: 16px; }
.btn-create { text-align: center; }
/* Mobile table improvements */
table { font-size: 12px; }
thead th { padding: 10px 8px; font-size: 10px; }
tbody td { padding: 10px 8px; }
/* Stack action buttons on mobile */
.actions {
flex-direction: column;
gap: 4px;
}
.btn-action {
padding: 8px 10px;
font-size: 11px;
text-align: center;
width: 100%;
}
/* Modal improvements for mobile */
.modal {
margin: 10px;
max-height: calc(100vh - 20px);
border-radius: 12px;
}
.modal-header { padding: 16px 20px; }
.modal-body { padding: 16px 20px; }
.modal-footer {
padding: 12px 20px;
flex-direction: column;
gap: 8px;
}
.modal-footer button { width: 100%; }
.perm-grid { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.table-card { border-radius: 8px; }
table { font-size: 11px; }
thead th { padding: 8px 6px; }
tbody td { padding: 8px 6px; }
.status-badge { font-size: 10px; padding: 3px 8px; }
}
/* Dark Mode overrides */
[data-theme="dark"] thead th { background: var(--bg); }
[data-theme="dark"] tbody td { border-bottom-color: var(--border); }
[data-theme="dark"] tbody tr:hover { background: rgba(255,255,255,0.04); }
[data-theme="dark"] tbody tr:nth-child(even) { background: rgba(255,255,255,0.02); }
[data-theme="dark"] tbody tr:nth-child(even):hover { background: rgba(255,255,255,0.04); }
[data-theme="dark"] .form-group input,
[data-theme="dark"] .form-group select { background: var(--bg); color: var(--text); border-color: var(--border); }
[data-theme="dark"] .form-group input:disabled { background: var(--border); }
[data-theme="dark"] .btn-cancel { background: var(--bg); border-color: var(--border); color: var(--text); }
[data-theme="dark"] .btn-cancel:hover { background: var(--border); }
[data-theme="dark"] .modal { background: var(--card); }
[data-theme="dark"] .modal-overlay.active { background: rgba(0,0,0,0.7); }
[data-theme="dark"] .btn-emular:hover { background: rgba(63,185,80,0.15); }
[data-theme="dark"] .btn-edit:hover { background: rgba(88,166,255,0.15); }
[data-theme="dark"] .btn-toggle:hover { background: rgba(240,136,62,0.15); }
[data-theme="dark"] .btn-password:hover { background: rgba(188,140,255,0.15); }
[data-theme="dark"] .perm-check { border-color: var(--border); }
[data-theme="dark"] .perm-check:hover { background: rgba(63,185,80,0.08); }
[data-theme="dark"] .perm-check.checked { background: rgba(63,185,80,0.08); border-color: var(--green); }
</style>
`;
// Panel label map for badges
const panelLabels = {};
PANELS.forEach(p => { panelLabels[p.key] = p.label; });
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
${buildHead('Usuarios', pageCSS)}
</head>
<body>
${buildHeader({ role: admin.role || 'admin', userName: admin.nome, activePage: 'users', permissions: admin.permissions || [] })}
<div class="app-container">
<div id="alertBox" class="alert"></div>
<div class="toolbar">
<h2>Usuarios Cadastrados (${agentes.length})</h2>
<button class="btn-create" onclick="openCreateModal()">+ Novo Usuario</button>
</div>
<div class="table-card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Nome</th>
<th>E-mail</th>
<th>Role</th>
<th>Permissoes</th>
<th>Agente ID</th>
<th>Status</th>
<th>Criado em</th>
<th>Acoes</th>
</tr>
</thead>
<tbody id="agentesTable">
${agentesWithPerms.map(a => `
<tr data-id="${a.id}" data-permissions='${JSON.stringify(a._perms)}'>
<td>${a.id}</td>
<td>${a.nome}</td>
<td>${a.email}</td>
<td><span class="status-badge ${a.role === 'admin' ? 'admin' : a.role === 'corporate' ? 'corporate' : 'agent'}">${a.role === 'admin' ? 'Admin' : a.role === 'corporate' ? 'Corporate' : 'Agente'}</span></td>
<td>${a._perms.map(k => '<span class="perm-badge">' + (panelLabels[k] || k) + '</span>').join(' ')}</td>
<td>${a.agente_id || '-'}</td>
<td><span class="status-badge ${a.ativo ? 'active' : 'inactive'}">${a.ativo ? 'Ativo' : 'Inativo'}</span></td>
<td>${a.created_at ? new Date(a.created_at).toLocaleDateString('pt-BR') : '-'}</td>
<td class="actions">
${a.agente_id ? `<a href="/corporate/emular/${a.agente_id}" class="btn-action btn-emular" title="Ver como este agente">Emular</a>` : ''}
<button class="btn-action btn-edit" onclick="openEditModal(${a.id}, '${a.nome.replace(/'/g, "\\'")}', '${a.email.replace(/'/g, "\\'")}', ${a.agente_id}, '${a.role || 'agente'}', event)">Editar</button>
<button class="btn-action btn-toggle" onclick="toggleAgente(${a.id}, ${a.ativo})">${a.ativo ? 'Desativar' : 'Ativar'}</button>
<button class="btn-action btn-password" onclick="openPasswordModal(${a.id}, '${a.nome.replace(/'/g, "\\'")}')">Senha</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
${buildFooter()}
<!-- Create/Edit Modal -->
<div class="modal-overlay" id="agentModal">
<div class="modal">
<div class="modal-header">
<h3 id="modalTitle">Novo Usuario</h3>
<button class="modal-close" onclick="closeModal('agentModal')">&times;</button>
</div>
<form id="agentForm" onsubmit="submitAgentForm(event)">
<div class="modal-body">
<input type="hidden" id="agentId" name="id">
<div class="form-group">
<label>Nome</label>
<input type="text" id="agentNome" name="nome" required placeholder="Nome do usuario">
</div>
<div class="form-group">
<label>E-mail</label>
<input type="email" id="agentEmail" name="email" required placeholder="usuario@email.com">
</div>
<div class="form-group">
<label>Tipo de Usuario (preset)</label>
<select id="agentRole" name="role" onchange="onRoleChange()">
<option value="agente">Agente</option>
<option value="corporate">Corporate</option>
<option value="admin">Administrador</option>
</select>
</div>
<div class="form-group">
<label>Permissoes de Acesso</label>
<div class="perm-grid" id="permGrid">
${PANELS.map(p => `
<label class="perm-check" id="permLabel_${p.key}">
<input type="checkbox" name="perm_${p.key}" value="${p.key}" onchange="onPermChange(this)">
${p.label}
</label>
`).join('')}
</div>
</div>
<div class="form-group" id="agenteIdGroup">
<label>Agente ID (Sistema)</label>
<input type="number" id="agentAgenteId" name="agente_id" placeholder="ID numerico do agente">
</div>
<div class="form-group" id="senhaGroup">
<label>Senha</label>
<input type="password" id="agentSenha" name="senha" placeholder="Senha de acesso" minlength="6">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-cancel" onclick="closeModal('agentModal')">Cancelar</button>
<button type="submit" class="btn-submit" id="submitBtn">Criar Usuario</button>
</div>
</form>
</div>
</div>
<!-- Password Reset Modal -->
<div class="modal-overlay" id="passwordModal">
<div class="modal">
<div class="modal-header">
<h3 id="passwordModalTitle">Redefinir Senha</h3>
<button class="modal-close" onclick="closeModal('passwordModal')">&times;</button>
</div>
<form id="passwordForm" onsubmit="submitPasswordForm(event)">
<div class="modal-body">
<input type="hidden" id="passwordAgentId">
<div class="form-group">
<label>Nova Senha</label>
<input type="password" id="newPassword" required placeholder="Nova senha" minlength="6">
</div>
<div class="form-group">
<label>Confirmar Senha</label>
<input type="password" id="confirmPassword" required placeholder="Confirme a senha" minlength="6">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-cancel" onclick="closeModal('passwordModal')">Cancelar</button>
<button type="submit" class="btn-submit">Redefinir Senha</button>
</div>
</form>
</div>
</div>
<script>
var isEditing = false;
var DEFAULT_PERMS = ${JSON.stringify(DEFAULT_PERMISSIONS)};
function showAlert(message, type) {
var alert = document.getElementById('alertBox');
alert.textContent = message;
alert.className = 'alert ' + type + ' show';
setTimeout(function() { alert.className = 'alert'; }, 4000);
}
function getPermCheckboxes() {
return document.querySelectorAll('#permGrid input[type="checkbox"]');
}
function setPermissions(keys) {
getPermCheckboxes().forEach(function(cb) {
cb.checked = keys.includes(cb.value);
updatePermLabel(cb);
});
toggleAgenteIdField();
}
function onPermChange(cb) {
updatePermLabel(cb);
toggleAgenteIdField();
}
function updatePermLabel(cb) {
var label = document.getElementById('permLabel_' + cb.value);
if (label) {
if (cb.checked) label.classList.add('checked');
else label.classList.remove('checked');
}
}
function getSelectedPermissions() {
var perms = [];
getPermCheckboxes().forEach(function(cb) {
if (cb.checked) perms.push(cb.value);
});
return perms;
}
function onRoleChange() {
var role = document.getElementById('agentRole').value;
var defaults = DEFAULT_PERMS[role] || [];
setPermissions(defaults);
}
function toggleAgenteIdField() {
var perms = getSelectedPermissions();
var agenteIdGroup = document.getElementById('agenteIdGroup');
var agenteIdInput = document.getElementById('agentAgenteId');
if (perms.includes('dashboard')) {
agenteIdGroup.style.display = 'block';
agenteIdInput.required = !isEditing;
} else {
agenteIdGroup.style.display = 'none';
agenteIdInput.required = false;
}
}
function openCreateModal(event) {
if (event) event.stopPropagation();
isEditing = false;
document.getElementById('modalTitle').textContent = 'Novo Usuario';
document.getElementById('submitBtn').textContent = 'Criar Usuario';
document.getElementById('agentForm').reset();
document.getElementById('agentId').value = '';
document.getElementById('agentRole').value = 'agente';
document.getElementById('senhaGroup').style.display = 'block';
document.getElementById('agentSenha').required = true;
onRoleChange();
setTimeout(function() {
document.getElementById('agentModal').classList.add('active');
document.getElementById('agentNome').focus();
}, 10);
}
function openEditModal(id, nome, email, agenteId, role, event) {
if (event) event.stopPropagation();
isEditing = true;
document.getElementById('modalTitle').textContent = 'Editar Usuario';
document.getElementById('submitBtn').textContent = 'Salvar Alteracoes';
document.getElementById('agentId').value = id;
document.getElementById('agentNome').value = nome;
document.getElementById('agentEmail').value = email;
document.getElementById('agentAgenteId').value = agenteId;
document.getElementById('agentRole').value = role || 'agente';
document.getElementById('senhaGroup').style.display = 'none';
document.getElementById('agentSenha').required = false;
// Load permissions from data attribute
var row = document.querySelector('tr[data-id="' + id + '"]');
var perms = [];
if (row) {
try { perms = JSON.parse(row.getAttribute('data-permissions') || '[]'); } catch(e) {}
}
setPermissions(perms);
setTimeout(function() {
document.getElementById('agentModal').classList.add('active');
document.getElementById('agentNome').focus();
}, 10);
}
function openPasswordModal(id, nome) {
document.getElementById('passwordModalTitle').textContent = 'Redefinir Senha: ' + nome;
document.getElementById('passwordAgentId').value = id;
document.getElementById('passwordForm').reset();
document.getElementById('passwordModal').classList.add('active');
document.getElementById('newPassword').focus();
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
}
async function submitAgentForm(e) {
e.preventDefault();
var id = document.getElementById('agentId').value;
var role = document.getElementById('agentRole').value;
var permissions = getSelectedPermissions();
var data = {
nome: document.getElementById('agentNome').value,
email: document.getElementById('agentEmail').value,
role: role,
permissions: permissions,
};
// Agente ID
var agenteIdVal = document.getElementById('agentAgenteId').value;
if (permissions.includes('dashboard')) {
if (!isEditing && !agenteIdVal) {
showAlert('Agente ID e obrigatorio para acesso ao Meu Dashboard', 'error');
return;
}
data.agente_id = parseInt(agenteIdVal) || 0;
} else {
data.agente_id = parseInt(agenteIdVal) || 0;
}
if (!isEditing) {
data.senha = document.getElementById('agentSenha').value;
}
try {
var url = isEditing ? '/admin/agentes/' + id : '/admin/agentes';
var method = isEditing ? 'PUT' : 'POST';
var res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
var result = await res.json();
if (res.ok) {
showAlert(isEditing ? 'Usuario atualizado com sucesso!' : 'Usuario criado com sucesso!', 'success');
closeModal('agentModal');
setTimeout(function() { location.reload(); }, 1000);
} else {
showAlert(result.error || 'Erro ao salvar usuario', 'error');
}
} catch (err) {
showAlert('Erro de conexao', 'error');
}
}
async function submitPasswordForm(e) {
e.preventDefault();
var id = document.getElementById('passwordAgentId').value;
var newPassword = document.getElementById('newPassword').value;
var confirmPassword = document.getElementById('confirmPassword').value;
if (newPassword !== confirmPassword) {
showAlert('As senhas nao coincidem', 'error');
return;
}
try {
var res = await fetch('/admin/agentes/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ senha: newPassword })
});
var result = await res.json();
if (res.ok) {
showAlert('Senha redefinida com sucesso!', 'success');
closeModal('passwordModal');
} else {
showAlert(result.error || 'Erro ao redefinir senha', 'error');
}
} catch (err) {
showAlert('Erro de conexao', 'error');
}
}
async function toggleAgente(id, currentStatus) {
var action = currentStatus ? 'desativar' : 'ativar';
if (!confirm('Deseja ' + action + ' este usuario?')) return;
try {
var res = await fetch('/admin/agentes/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ativo: currentStatus ? 0 : 1 })
});
var result = await res.json();
if (res.ok) {
showAlert('Usuario ' + (currentStatus ? 'desativado' : 'ativado') + ' com sucesso!', 'success');
setTimeout(function() { location.reload(); }, 1000);
} else {
showAlert(result.error || 'Erro ao alterar status', 'error');
}
} catch (err) {
showAlert('Erro de conexao', 'error');
}
}
// Close modal on overlay click
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
overlay.addEventListener('click', function(e) {
if (e.target === overlay) overlay.classList.remove('active');
});
});
// Prevent clicks inside modal from closing it
document.querySelectorAll('.modal').forEach(function(modal) {
modal.addEventListener('click', function(e) { e.stopPropagation(); });
});
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(function(m) { m.classList.remove('active'); });
}
});
<\/script>
</body>
</html>`;
}
module.exports = { buildAdminHTML };