Commita governance, user-wizard, operational-feed e catálogo RBAC; adiciona deploy-desk-full.sh, smoke-desk.sh e regra anti-deploy parcial; documenta credencial VM112 @betinplace. Co-authored-by: Cursor <cursoragent@cursor.com>
484 lines
18 KiB
JavaScript
484 lines
18 KiB
JavaScript
/**
|
||
* Gestão de utilizadores — painel direito do Controle de acesso
|
||
* Criar · Editar · Congelar · Copiar · Eliminar
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
const ROLE_META = [
|
||
{ value: 'super_admin', label: 'Super Admin', group: 'Ops', code: 'SU' },
|
||
{ value: 'ops_lead', label: 'Chefe Ops', group: 'Ops', code: 'CO' },
|
||
{ value: 'technician', label: 'Suporte', group: 'Ops', code: 'TEC' },
|
||
{ value: 'noc', label: 'NOC', group: 'Ops', code: 'NOC' },
|
||
{ value: 'sales_admin', label: 'Sales Admin', group: 'Comercial', code: 'SAD' },
|
||
{ value: 'sales_support', label: 'Sales Support', group: 'Comercial', code: 'SSU' },
|
||
{ value: 'finance', label: 'Financeiro', group: 'Negócio', code: 'FIN' },
|
||
{ value: 'marketing', label: 'Marketing', group: 'Negócio', code: 'MKT' },
|
||
{ value: 'seo', label: 'SEO', group: 'Negócio', code: 'SEO' },
|
||
{ value: 'developer', label: 'Developer', group: 'Plataforma', code: 'DEV' },
|
||
{ value: 'devops', label: 'DevOps', group: 'Plataforma', code: 'DVO' },
|
||
{ value: 'security_analyst', label: 'Segurança / SOC', group: 'Plataforma', code: 'SOC' },
|
||
{ value: 'content_editor', label: 'Conteúdo / CMS', group: 'Plataforma', code: 'CMS' },
|
||
{ value: 'agentic_operator', label: 'Operador Agentes IA', group: 'Plataforma', code: 'AIO' },
|
||
{ value: 'partner', label: 'Partner', group: 'Externo', code: 'PTR' },
|
||
];
|
||
|
||
const ASSIGNABLE = ROLE_META.filter((r) => r.value !== 'super_admin');
|
||
const PORTAL_ID = 'um-modal-portal';
|
||
|
||
let users = [];
|
||
let filterQ = '';
|
||
let filterStatus = 'all';
|
||
let modalMode = null;
|
||
let modalUser = null;
|
||
let createDefaultRole = 'technician';
|
||
let saving = false;
|
||
let msg = '';
|
||
let currentHost = null;
|
||
let currentOpts = null;
|
||
|
||
function esc(s) {
|
||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function roleMeta(roleId) {
|
||
return ROLE_META.find((r) => r.value === roleId) || { label: roleId, group: '—', code: '?' };
|
||
}
|
||
|
||
function groupForRole(roleId) {
|
||
return roleMeta(roleId).group;
|
||
}
|
||
|
||
function roleSelectGrouped(selected, includeSuper = false) {
|
||
const list = includeSuper ? ROLE_META : ASSIGNABLE;
|
||
const groups = [...new Set(list.map((r) => r.group))];
|
||
return groups.map((g) => {
|
||
const opts = list.filter((r) => r.group === g).map((r) =>
|
||
`<option value="${r.value}"${r.value === selected ? ' selected' : ''}>${esc(r.label)} (${r.code})</option>`
|
||
).join('');
|
||
return `<optgroup label="${esc(g)}">${opts}</optgroup>`;
|
||
}).join('');
|
||
}
|
||
|
||
function filteredUsers(roleLock) {
|
||
const q = filterQ.trim().toLowerCase();
|
||
return users.filter((u) => {
|
||
if (roleLock && u.role !== roleLock && !(roleLock === 'super_admin' && u.username === 'root')) return false;
|
||
if (filterStatus === 'active' && !u.active) return false;
|
||
if (filterStatus === 'inactive' && u.active) return false;
|
||
if (!q) return true;
|
||
const hay = [u.username, u.email, u.display_name, roleMeta(u.role).label, roleMeta(u.role).group].join(' ').toLowerCase();
|
||
return hay.includes(q);
|
||
});
|
||
}
|
||
|
||
async function loadUsers() {
|
||
const r = await fetchWithTimeout('/api/v1/auth/users', { headers: authHeaders() });
|
||
if (!r.ok) throw new Error(`${r.status}`);
|
||
const data = await r.json();
|
||
users = data.users || [];
|
||
return users;
|
||
}
|
||
|
||
async function apiUser(path, opts = {}) {
|
||
const r = await fetchWithTimeout(`/api/v1/auth${path}`, {
|
||
...opts,
|
||
headers: authHeaders({ 'Content-Type': 'application/json', ...(opts.headers || {}) }),
|
||
});
|
||
if (!r.ok) {
|
||
const t = await r.text();
|
||
throw new Error(t.slice(0, 200) || String(r.status));
|
||
}
|
||
return r.json();
|
||
}
|
||
|
||
function renderModalHtml() {
|
||
if (!modalMode) return '';
|
||
const isCreate = modalMode === 'create';
|
||
const isClone = modalMode === 'clone';
|
||
const u = modalUser || {};
|
||
const title = isCreate ? 'Criar utilizador' : isClone ? 'Copiar utilizador' : 'Editar utilizador';
|
||
const defaultRole = isCreate ? createDefaultRole : (u.role || createDefaultRole);
|
||
|
||
return `
|
||
<div class="um-modal-root" role="dialog" aria-modal="true">
|
||
<div class="um-backdrop" data-um-close></div>
|
||
<article class="um-modal-card" data-um-card>
|
||
<header class="um-modal-head">
|
||
<h3>${title}</h3>
|
||
<button type="button" class="um-close" data-um-close aria-label="Fechar">×</button>
|
||
</header>
|
||
<form class="um-form" data-um-form>
|
||
${!isCreate ? `<p class="um-meta"><code>${esc(u.username)}</code></p>` : ''}
|
||
${isCreate ? `<p class="um-hint um-hint--block">Perfil sugerido: <strong>${esc(roleMeta(createDefaultRole).label)}</strong> — pode escolher qualquer perfil abaixo.</p>` : ''}
|
||
${isCreate || isClone ? `
|
||
<label class="um-field">
|
||
<span>E-mail (login)</span>
|
||
<input type="email" name="email" required placeholder="nome@empresa.com" autocomplete="off"/>
|
||
</label>
|
||
<label class="um-field">
|
||
<span>${isClone ? 'Nova senha (opcional)' : 'Senha inicial'}</span>
|
||
<input type="password" name="password" ${isCreate ? 'required minlength="6"' : 'minlength="6"'} placeholder="${isClone ? 'Gera automaticamente se vazio' : 'Mín. 6 caracteres'}" autocomplete="new-password"/>
|
||
</label>` : ''}
|
||
<label class="um-field">
|
||
<span>Nome completo</span>
|
||
<input type="text" name="display_name" value="${esc(u.display_name || '')}" placeholder="Nome visível"/>
|
||
</label>
|
||
<label class="um-field">
|
||
<span>Perfil · Grupo</span>
|
||
<select name="role" data-um-role-select ${u.username === 'root' ? 'disabled' : ''}>
|
||
${roleSelectGrouped(defaultRole, u.username === 'root' || u.role === 'super_admin' || isCreate)}
|
||
</select>
|
||
<small class="um-hint">Grupo: <strong data-um-group-preview>${esc(groupForRole(defaultRole))}</strong></small>
|
||
</label>
|
||
${!isCreate && !isClone ? `
|
||
<label class="um-field">
|
||
<span>Nova senha (opcional)</span>
|
||
<input type="password" name="password" minlength="6" placeholder="Deixe vazio para manter" autocomplete="new-password"/>
|
||
</label>
|
||
<label class="um-toggle-row">
|
||
<span>Conta activa</span>
|
||
<label class="acs-toggle">
|
||
<input type="checkbox" name="active" ${u.active ? 'checked' : ''} ${u.username === 'root' ? 'disabled' : ''}/>
|
||
<span class="acs-toggle-slider"></span>
|
||
</label>
|
||
</label>` : `
|
||
<label class="um-toggle-row">
|
||
<span>Activar conta já</span>
|
||
<label class="acs-toggle">
|
||
<input type="checkbox" name="active" checked/>
|
||
<span class="acs-toggle-slider"></span>
|
||
</label>
|
||
</label>`}
|
||
${msg ? `<p class="um-msg um-msg--err">${esc(msg)}</p>` : ''}
|
||
<footer class="um-modal-foot">
|
||
<button type="button" class="um-btn-ghost" data-um-close>Cancelar</button>
|
||
<button type="submit" class="um-btn-primary" ${saving ? 'disabled' : ''}>
|
||
${saving ? 'A guardar…' : isCreate ? 'Criar utilizador' : isClone ? 'Copiar' : 'Guardar'}
|
||
</button>
|
||
</footer>
|
||
</form>
|
||
</article>
|
||
</div>`;
|
||
}
|
||
|
||
function syncModalPortal() {
|
||
let portal = document.getElementById(PORTAL_ID);
|
||
if (!modalMode) {
|
||
if (portal) portal.innerHTML = '';
|
||
document.body.classList.remove('um-scroll-lock');
|
||
return;
|
||
}
|
||
if (!portal) {
|
||
portal = document.createElement('div');
|
||
portal.id = PORTAL_ID;
|
||
document.body.appendChild(portal);
|
||
}
|
||
portal.innerHTML = renderModalHtml();
|
||
document.body.classList.toggle('um-scroll-lock', true);
|
||
bindModalEvents(portal);
|
||
}
|
||
|
||
function renderTable(list) {
|
||
if (!list.length) {
|
||
return '<p class="um-empty">Nenhum utilizador nesta função. Use «Criar utilizador» para adicionar.</p>';
|
||
}
|
||
const rows = list.map((u) => {
|
||
const rm = roleMeta(u.role);
|
||
const isRoot = u.username === 'root';
|
||
return `
|
||
<tr class="um-row">
|
||
<td>
|
||
<strong>${esc(u.display_name || u.username)}</strong>
|
||
<span class="um-sub">${esc(u.email || u.username)}</span>
|
||
</td>
|
||
<td><span class="um-role-badge">${esc(rm.code)}</span></td>
|
||
<td>${u.active ? '<span class="um-status um-status--on">Activo</span>' : '<span class="um-status um-status--off">Off</span>'}</td>
|
||
<td class="um-actions">
|
||
<button type="button" class="um-act" data-um-edit="${esc(u.username)}">Editar</button>
|
||
${!isRoot ? `<button type="button" class="um-act" data-um-freeze="${esc(u.username)}">${u.active ? 'Congelar' : 'Activar'}</button>` : ''}
|
||
<button type="button" class="um-act" data-um-clone="${esc(u.username)}">Copiar</button>
|
||
${!isRoot ? `<button type="button" class="um-act um-act--danger" data-um-delete="${esc(u.username)}">Eliminar</button>` : ''}
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="um-table-wrap">
|
||
<table class="um-table um-table--compact">
|
||
<thead>
|
||
<tr><th>Utilizador</th><th>Perfil</th><th>Estado</th><th>Acções</th></tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function renderContent(roleLock, roleMeta, embedded) {
|
||
const list = filteredUsers(roleLock);
|
||
const code = window.DeskAccessControlPanel?.ROLE_CODES?.[roleLock] || '';
|
||
const active = list.filter((u) => u.active).length;
|
||
|
||
return `
|
||
<div class="um-panel${embedded ? ' um-panel--embedded' : ''}" data-um-root>
|
||
<header class="um-head um-head--embedded">
|
||
<div>
|
||
<span class="acs-matrix-users-title">Utilizadores</span>
|
||
${embedded ? `
|
||
<span class="acs-matrix-users-meta">
|
||
<code class="acs-role-code-inline">${esc(code)}</code>
|
||
${esc(roleMeta?.label || roleLock || '')}
|
||
<span class="acs-matrix-users-count">${list.length} utilizador${list.length === 1 ? '' : 'es'}</span>
|
||
</span>` : ''}
|
||
</div>
|
||
<button type="button" class="um-btn-primary um-btn-primary--sm" data-um-create>+ Criar</button>
|
||
</header>
|
||
|
||
<div class="um-mini-stats">
|
||
<span><strong>${list.length}</strong> filtrados</span>
|
||
<span><strong>${active}</strong> activos</span>
|
||
<span><strong>${users.length}</strong> total</span>
|
||
</div>
|
||
|
||
<div class="um-toolbar um-toolbar--compact">
|
||
<input type="search" class="um-search" data-um-search placeholder="Pesquisar…" value="${esc(filterQ)}"/>
|
||
<select data-um-filter-status>
|
||
<option value="all" ${filterStatus === 'all' ? 'selected' : ''}>Todos</option>
|
||
<option value="active" ${filterStatus === 'active' ? 'selected' : ''}>Activos</option>
|
||
<option value="inactive" ${filterStatus === 'inactive' ? 'selected' : ''}>Congelados</option>
|
||
</select>
|
||
${currentOpts?.onRegistration ? '<button type="button" class="um-btn-ghost um-btn-ghost--sm" data-um-reg>Pedidos cadastro</button>' : ''}
|
||
</div>
|
||
|
||
<div data-um-list>${renderTable(list)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function refreshList() {
|
||
if (!currentHost) return;
|
||
const roleLock = currentOpts?.roleFilter || null;
|
||
const listEl = currentHost.querySelector('[data-um-list]');
|
||
if (listEl) listEl.innerHTML = renderTable(filteredUsers(roleLock));
|
||
bindRowEvents(currentHost);
|
||
}
|
||
|
||
let editCallback = null;
|
||
|
||
async function handleSubmit(form) {
|
||
const roleLock = currentOpts?.roleFilter || null;
|
||
const fd = new FormData(form);
|
||
saving = true;
|
||
msg = '';
|
||
syncModalPortal();
|
||
try {
|
||
if (modalMode === 'create') {
|
||
await apiUser('/users', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
email: fd.get('email'),
|
||
password: fd.get('password'),
|
||
role: fd.get('role'),
|
||
display_name: fd.get('display_name') || null,
|
||
active: !!form.querySelector('[name="active"]')?.checked,
|
||
}),
|
||
});
|
||
} else if (modalMode === 'clone') {
|
||
const res = await apiUser(`/users/${encodeURIComponent(modalUser.username)}/clone`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
email: fd.get('email'),
|
||
password: fd.get('password') || null,
|
||
display_name: fd.get('display_name') || null,
|
||
active: !!form.querySelector('[name="active"]')?.checked,
|
||
}),
|
||
});
|
||
if (res.generated_password) {
|
||
window.alert(`Copiado.\nSenha gerada: ${res.generated_password}`);
|
||
}
|
||
} else {
|
||
const payload = {
|
||
display_name: fd.get('display_name') || null,
|
||
role: fd.get('role'),
|
||
active: !!form.querySelector('[name="active"]')?.checked,
|
||
};
|
||
const pwd = fd.get('password');
|
||
if (pwd && String(pwd).length >= 6) payload.password = pwd;
|
||
await apiUser(`/users/${encodeURIComponent(modalUser.username)}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
modalMode = null;
|
||
modalUser = null;
|
||
msg = '';
|
||
syncModalPortal();
|
||
await loadUsers();
|
||
refreshList();
|
||
if (editCallback) { const cb = editCallback; editCallback = null; cb(); }
|
||
} catch (e) {
|
||
msg = e.message;
|
||
syncModalPortal();
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
function closeModal() {
|
||
modalMode = null;
|
||
modalUser = null;
|
||
msg = '';
|
||
saving = false;
|
||
syncModalPortal();
|
||
}
|
||
|
||
function bindModalEvents(portal) {
|
||
portal.querySelector('.um-backdrop')?.addEventListener('click', (e) => {
|
||
if (e.target !== e.currentTarget) return;
|
||
closeModal();
|
||
});
|
||
portal.querySelectorAll('[data-um-close]').forEach((el) => {
|
||
el.addEventListener('click', (e) => { e.stopPropagation(); closeModal(); });
|
||
});
|
||
portal.querySelector('[data-um-card]')?.addEventListener('mousedown', (e) => e.stopPropagation());
|
||
portal.querySelector('[data-um-form]')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
await handleSubmit(e.target);
|
||
});
|
||
portal.querySelector('[data-um-role-select]')?.addEventListener('change', (e) => {
|
||
const preview = portal.querySelector('[data-um-group-preview]');
|
||
if (preview) preview.textContent = groupForRole(e.target.value);
|
||
});
|
||
}
|
||
|
||
function bindRowEvents(host) {
|
||
host.querySelectorAll('[data-um-edit]').forEach((btn) => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
modalUser = users.find((u) => u.username === btn.dataset.umEdit);
|
||
modalMode = 'edit';
|
||
msg = '';
|
||
syncModalPortal();
|
||
});
|
||
});
|
||
host.querySelectorAll('[data-um-clone]').forEach((btn) => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
modalUser = users.find((u) => u.username === btn.dataset.umClone);
|
||
modalMode = 'clone';
|
||
msg = '';
|
||
syncModalPortal();
|
||
});
|
||
});
|
||
host.querySelectorAll('[data-um-freeze]').forEach((btn) => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const u = users.find((x) => x.username === btn.dataset.umFreeze);
|
||
if (!u) return;
|
||
const label = u.active ? 'Congelar' : 'Activar';
|
||
if (!window.confirm(`${label} ${u.username}?`)) return;
|
||
try {
|
||
await apiUser(`/users/${encodeURIComponent(u.username)}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ active: !u.active }),
|
||
});
|
||
await loadUsers();
|
||
refreshList();
|
||
} catch (err) {
|
||
window.alert(err.message);
|
||
}
|
||
});
|
||
});
|
||
host.querySelectorAll('[data-um-delete]').forEach((btn) => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const username = btn.dataset.umDelete;
|
||
if (!window.confirm(`Eliminar ${username}? Irreversível.`)) return;
|
||
try {
|
||
await apiUser(`/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||
await loadUsers();
|
||
refreshList();
|
||
} catch (err) {
|
||
window.alert(err.message);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function bindEvents(host) {
|
||
host.querySelector('[data-um-create]')?.addEventListener('click', () => {
|
||
modalMode = 'create';
|
||
modalUser = null;
|
||
msg = '';
|
||
syncModalPortal();
|
||
});
|
||
|
||
host.querySelector('[data-um-reg]')?.addEventListener('click', () => {
|
||
if (typeof currentOpts?.onRegistration === 'function') currentOpts.onRegistration();
|
||
});
|
||
|
||
host.querySelector('[data-um-search]')?.addEventListener('input', (e) => {
|
||
filterQ = e.target.value;
|
||
clearTimeout(host._umTimer);
|
||
host._umTimer = setTimeout(refreshList, 250);
|
||
});
|
||
|
||
host.querySelector('[data-um-filter-status]')?.addEventListener('change', (e) => {
|
||
filterStatus = e.target.value;
|
||
refreshList();
|
||
});
|
||
|
||
bindRowEvents(host);
|
||
}
|
||
|
||
async function paint(host, opts = {}) {
|
||
if (!host) return;
|
||
currentHost = host;
|
||
currentOpts = opts;
|
||
createDefaultRole = opts.roleFilter || 'technician';
|
||
|
||
if (typeof canManageUsers === 'function' && !canManageUsers()) {
|
||
host.innerHTML = '<p class="loading">Sem permissão.</p>';
|
||
return;
|
||
}
|
||
|
||
const firstLoad = !users.length;
|
||
if (firstLoad) {
|
||
host.innerHTML = '<p class="loading">Carregando utilizadores…</p>';
|
||
try {
|
||
await loadUsers();
|
||
} catch (e) {
|
||
host.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
return;
|
||
}
|
||
}
|
||
|
||
host.innerHTML = renderContent(opts.roleFilter, opts.roleMeta, opts.embedded);
|
||
bindEvents(host);
|
||
syncModalPortal();
|
||
}
|
||
|
||
function reset() {
|
||
users = [];
|
||
filterQ = '';
|
||
filterStatus = 'all';
|
||
modalMode = null;
|
||
modalUser = null;
|
||
msg = '';
|
||
currentHost = null;
|
||
currentOpts = null;
|
||
closeModal();
|
||
}
|
||
|
||
function openEdit(username, onSaved) {
|
||
loadUsers().then(() => {
|
||
modalUser = users.find((u) => u.username === username);
|
||
if (!modalUser) return;
|
||
modalMode = 'edit';
|
||
msg = '';
|
||
editCallback = onSaved || null;
|
||
syncModalPortal();
|
||
}).catch((e) => window.alert(e.message));
|
||
}
|
||
|
||
window.DeskUserManagement = { paint, reset, loadUsers, openEdit, ROLE_META };
|
||
})();
|