ligbox-ops-platform/projects/ops-desk/frontend/assets/user-management-panel.js
Ligbox Spec Hub b03bb2c37c feat(desk): UI/API Spec 039-041 + deploy atómico e smoke GREEN
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>
2026-07-02 13:32:15 +00:00

484 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 };
})();