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>
676 lines
29 KiB
JavaScript
676 lines
29 KiB
JavaScript
/**
|
|
* Access Control Hub — Gestão de utilizadores
|
|
* Spec 040 · DS-FE-004 · Matriz → aba Controle de acesso
|
|
*/
|
|
(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' },
|
|
];
|
|
|
|
let host = null;
|
|
let opts = {};
|
|
let subTab = 'users';
|
|
let users = [];
|
|
let stats = { total: 0, active: 0, frozen: 0, super_admin: 0 };
|
|
let selectedUser = null;
|
|
let detailTab = 'details';
|
|
let filterQ = '';
|
|
let filterRole = 'all';
|
|
let filterGroup = 'all';
|
|
let filterStatus = 'all';
|
|
let confirmModal = null;
|
|
let createMenuOpen = false;
|
|
const favorites = new Set();
|
|
|
|
const SVG = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round"';
|
|
|
|
const ICONS = {
|
|
users: `<svg ${SVG}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.5"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
|
userCheck: `<svg ${SVG}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.5"/><path d="m16 11 2 2 4.5-4.5"/></svg>`,
|
|
snowflake: `<svg ${SVG}><path d="M12 2v20M2 12h20M4.93 4.93l14.14 14.14M19.07 4.93 4.93 19.07M12 2l2.5 4.5M12 2 9.5 6.5M12 22l2.5-4.5M12 22 9.5 17.5M2 12l4.5 2.5M2 12l4.5-2.5M22 12l-4.5 2.5M22 12l-4.5-2.5"/></svg>`,
|
|
shield: `<svg ${SVG}><path d="M12 22s7-3.5 8-10V6l-8-3.5L4 6v6c1 6.5 8 10 8 10z"/></svg>`,
|
|
plus: `<svg ${SVG} stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
|
|
chevronDown: `<svg ${SVG} stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>`,
|
|
filter: `<svg ${SVG}><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>`,
|
|
search: `<svg ${SVG}><circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>`,
|
|
more: `<svg ${SVG} stroke-width="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>`,
|
|
star: `<svg ${SVG}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
|
|
close: `<svg ${SVG} stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
|
|
edit: `<svg ${SVG}><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>`,
|
|
freeze: `<svg ${SVG}><path d="M12 2v20M2 12h20M4.93 4.93l14.14 14.14M19.07 4.93 4.93 19.07"/></svg>`,
|
|
copy: `<svg ${SVG}><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
|
|
lock: `<svg ${SVG}><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
|
|
shieldCheck: `<svg ${SVG}><path d="M12 22s7-3.5 8-10V6l-8-3.5L4 6v6c1 6.5 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>`,
|
|
usersGroup: `<svg ${SVG}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.5"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
|
trash: `<svg ${SVG}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
|
|
checkCircle: `<svg ${SVG}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m22 4-10 10-3-3"/></svg>`,
|
|
infoShield: `<svg ${SVG}><path d="M12 22s7-3.5 8-10V6l-8-3.5L4 6v6c1 6.5 8 10 8 10z"/></svg>`,
|
|
};
|
|
|
|
function fmtDateTime(iso) {
|
|
if (!iso) return '—';
|
|
try {
|
|
const d = new Date(iso);
|
|
const date = d.toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
const time = d.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
return `${date} ${time}`;
|
|
} catch (_) {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function kpiCard(iconKey, tone, value, label, sub) {
|
|
return `
|
|
<article class="ach-kpi-card">
|
|
<div class="ach-kpi-icon ach-kpi-icon--${tone}">${ICONS[iconKey]}</div>
|
|
<div class="ach-kpi-body">
|
|
<span class="ach-kpi-value">${esc(value)}</span>
|
|
<span class="ach-kpi-label">${esc(label)}</span>
|
|
<span class="ach-kpi-sub">${esc(sub)}</span>
|
|
</div>
|
|
</article>`;
|
|
}
|
|
|
|
function quickAction(iconKey, label, dataAttr) {
|
|
return `
|
|
<button type="button" class="ach-quick-card" ${dataAttr}>
|
|
${ICONS[iconKey]}
|
|
<span>${esc(label)}</span>
|
|
</button>`;
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function roleMeta(id) {
|
|
return ROLE_META.find((r) => r.value === id) || { label: id, group: '—', code: '?' };
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
if (!iso) return '—';
|
|
try {
|
|
return new Date(iso).toLocaleDateString('pt-PT');
|
|
} catch (_) {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function initials(name) {
|
|
const p = String(name || '?').trim().split(/\s+/);
|
|
return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase() || '?';
|
|
}
|
|
|
|
async function loadData() {
|
|
const [uRes, sRes] = await Promise.all([
|
|
fetchWithTimeout('/api/v1/auth/users', { headers: authHeaders() }),
|
|
fetchWithTimeout('/api/v1/governance/users/stats', { headers: authHeaders() }),
|
|
]);
|
|
if (!uRes.ok) throw new Error(String(uRes.status));
|
|
if (!sRes.ok) throw new Error(String(sRes.status));
|
|
const uData = await uRes.json();
|
|
stats = await sRes.json();
|
|
users = uData.users || [];
|
|
}
|
|
|
|
function filteredUsers() {
|
|
const q = filterQ.trim().toLowerCase();
|
|
const roleLock = opts.selectedRole;
|
|
return users.filter((u) => {
|
|
if (selectedUser && u.username === selectedUser) return true;
|
|
const rm = roleMeta(u.role);
|
|
if (roleLock && u.role !== roleLock && !(roleLock === 'super_admin' && u.username === 'root')) return false;
|
|
if (filterRole !== 'all' && u.role !== filterRole) return false;
|
|
if (filterGroup !== 'all' && rm.group !== filterGroup) return false;
|
|
if (filterStatus === 'active' && !u.active) return false;
|
|
if (filterStatus === 'frozen' && u.active) return false;
|
|
if (!q) return true;
|
|
const hay = [u.username, u.email, u.display_name, rm.label, rm.group].join(' ').toLowerCase();
|
|
return hay.includes(q);
|
|
});
|
|
}
|
|
|
|
function createSplitButton() {
|
|
return `
|
|
<div class="ach-create-split">
|
|
<button type="button" class="ach-create-main" data-ach-create>
|
|
${ICONS.plus}
|
|
Criar utilizador
|
|
</button>
|
|
<button type="button" class="ach-create-menu" data-ach-create-menu aria-label="Mais opções de criação">
|
|
${ICONS.chevronDown}
|
|
</button>
|
|
<div class="ach-create-dropdown${createMenuOpen ? '' : ' is-hidden'}" data-ach-create-dropdown>
|
|
<button type="button" data-ach-create-user>Criar utilizador</button>
|
|
<button type="button" data-ach-create-support>Criar Perfil Suporte</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function subnavHtml() {
|
|
return `
|
|
<nav class="lb-subnav">
|
|
<button type="button" class="lb-subtab${subTab === 'users' ? ' active' : ''}" data-ach-sub="users">Gestão de utilizadores</button>
|
|
<button type="button" class="lb-subtab${subTab === 'capabilities' ? ' active' : ''}" data-ach-sub="capabilities">Capacidades da função</button>
|
|
</nav>`;
|
|
}
|
|
|
|
function kpiSectionHtml() {
|
|
return `
|
|
<div class="ach-kpi-section">
|
|
<div class="ach-kpi-top">
|
|
${subnavHtml()}
|
|
<div class="ach-create-col">${createSplitButton()}</div>
|
|
</div>
|
|
<div class="ach-kpi-row">
|
|
${kpiCard('users', 'blue', stats.total, 'Total de usuários', `${stats.active} activos • ${stats.frozen} congelados`)}
|
|
${kpiCard('userCheck', 'green', stats.active, 'Activos', 'Usuários com acesso')}
|
|
${kpiCard('snowflake', 'orange', stats.frozen, 'Congelados', 'Acesso bloqueado')}
|
|
${kpiCard('shield', 'purple', stats.super_admin, 'Super Admin', 'Permissão máxima')}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function filterField(label, inner) {
|
|
return `
|
|
<label class="ach-field">
|
|
<span class="ach-field__label">${esc(label)}</span>
|
|
${inner}
|
|
</label>`;
|
|
}
|
|
|
|
function userTableRow(u) {
|
|
const rm = roleMeta(u.role);
|
|
const sel = selectedUser === u.username ? ' ach-table-row--selected' : '';
|
|
const tfaHtml = u.totp_enabled
|
|
? `<span class="ach-tfa ach-tfa--ok">${ICONS.checkCircle}</span>`
|
|
: `<span class="ach-tfa ach-tfa--warn" title="2FA inactivo">⚠</span>`;
|
|
const statusHtml = u.active
|
|
? `<span class="ach-status ach-status--active"><span class="ach-status-dot"></span>Activo</span>`
|
|
: `<span class="ach-status ach-status--frozen">${ICONS.snowflake} Congelado</span>`;
|
|
return `
|
|
<tr class="ach-table-row${sel}" data-ach-user="${esc(u.username)}">
|
|
<td class="ach-col-user">
|
|
<div class="ach-user-cell">
|
|
<span class="ach-user-cell__avatar">${esc(initials(u.display_name || u.username))}</span>
|
|
<span class="ach-user-cell__text">
|
|
<strong class="ach-user-cell__name">${esc(u.display_name || u.username)}</strong>
|
|
<span class="ach-user-cell__email">${esc(u.email || u.username)}</span>
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="ach-col-profile">
|
|
<span class="ach-role-pill">${esc(rm.code)}</span>
|
|
<span>${esc(rm.label)}</span>
|
|
</td>
|
|
<td>${esc(rm.group)}</td>
|
|
<td>${statusHtml}</td>
|
|
<td class="ach-col-center">${tfaHtml}</td>
|
|
<td class="ach-col-date">${fmtDateTime(u.last_login_at)}</td>
|
|
<td class="ach-col-actions">
|
|
<button type="button" class="ach-row-menu" data-ach-user="${esc(u.username)}" aria-label="Acções">${ICONS.more}</button>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
|
|
function usersTable(list) {
|
|
return `
|
|
<div class="ach-table-card">
|
|
<table class="ach-users-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Utilizador</th>
|
|
<th>Perfil</th>
|
|
<th>Grupo</th>
|
|
<th>Estado</th>
|
|
<th>2FA</th>
|
|
<th>Último acesso</th>
|
|
<th>Acções</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${list.length
|
|
? list.map(userTableRow).join('')
|
|
: '<tr><td colspan="7" class="ach-table-empty">Nenhum utilizador encontrado.</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="ach-table-foot">
|
|
<span class="ach-table-count">Mostrando ${list.length} de ${users.length}</span>
|
|
</div>`;
|
|
}
|
|
|
|
function usersPanel() {
|
|
const list = filteredUsers();
|
|
const groups = [...new Set(ROLE_META.map((r) => r.group))];
|
|
const roleLock = opts.selectedRole;
|
|
const roleLockMeta = roleMeta(roleLock);
|
|
const roleLockBanner = roleLock ? `
|
|
<div class="ach-audit-banner" style="margin:0 0 10px">
|
|
${ICONS.infoShield}
|
|
<span>A mostrar apenas utilizadores da função <strong>${esc(roleLockMeta.label)}</strong> (seleccionada na Matriz). Para ver todos, escolha outra função na barra lateral ou use o filtro Perfil abaixo.</span>
|
|
</div>` : '';
|
|
return `
|
|
<div class="ach-layout">
|
|
<div class="ach-left">
|
|
${kpiSectionHtml()}
|
|
${roleLockBanner}
|
|
<div class="ach-toolbar">
|
|
<label class="ach-field ach-field--search">
|
|
<span class="ach-field__label">Pesquisar</span>
|
|
<span class="ach-search-wrap">
|
|
<span class="ach-search-icon" aria-hidden="true">${ICONS.search}</span>
|
|
<input type="search" class="ach-field__input ach-field__input--search" data-ach-search
|
|
placeholder="Pesquisar por nome, e-mail ou perfil…" value="${esc(filterQ)}"/>
|
|
</span>
|
|
</label>
|
|
${filterField('Perfil', `
|
|
<select class="ach-field__select" data-ach-filter-role>
|
|
<option value="all"${filterRole === 'all' ? ' selected' : ''}>Todos</option>
|
|
${ROLE_META.map((r) => `<option value="${r.value}"${filterRole === r.value ? ' selected' : ''}>${esc(r.label)}</option>`).join('')}
|
|
</select>`)}
|
|
${filterField('Grupo', `
|
|
<select class="ach-field__select" data-ach-filter-group>
|
|
<option value="all"${filterGroup === 'all' ? ' selected' : ''}>Todos os grupos</option>
|
|
${groups.map((g) => `<option value="${esc(g)}"${filterGroup === g ? ' selected' : ''}>${esc(g)}</option>`).join('')}
|
|
</select>`)}
|
|
${filterField('Estado', `
|
|
<select class="ach-field__select" data-ach-filter-status>
|
|
<option value="all"${filterStatus === 'all' ? ' selected' : ''}>Todos</option>
|
|
<option value="active"${filterStatus === 'active' ? ' selected' : ''}>Activos</option>
|
|
<option value="frozen"${filterStatus === 'frozen' ? ' selected' : ''}>Congelados</option>
|
|
</select>`)}
|
|
<button type="button" class="ach-filters-btn" data-ach-filters>
|
|
${ICONS.filter}
|
|
Filtros
|
|
</button>
|
|
</div>
|
|
${usersTable(list)}
|
|
</div>
|
|
<aside class="ach-detail-col" data-ach-detail>${detailPanel()}</aside>
|
|
</div>`;
|
|
}
|
|
|
|
function detailPanel() {
|
|
const u = users.find((x) => x.username === selectedUser);
|
|
if (!u) {
|
|
return `<div class="ach-user-panel"><div class="ach-user-panel__body"><p class="ach-kpi-sub">Seleccione um utilizador</p></div></div>`;
|
|
}
|
|
const rm = roleMeta(u.role);
|
|
const isRoot = u.username === 'root';
|
|
const isFav = favorites.has(u.username);
|
|
const tfaOn = u.totp_enabled;
|
|
|
|
let tabBody = '';
|
|
if (detailTab === 'details') {
|
|
tabBody = `
|
|
<div class="ach-kv-list">
|
|
<div class="ach-kv-row">
|
|
<span class="ach-kv-label">Perfil</span>
|
|
<span class="ach-kv-value ach-profile-badge">
|
|
<span class="ach-profile-code">${esc(rm.code)}</span>
|
|
${esc(rm.label)}
|
|
</span>
|
|
</div>
|
|
<div class="ach-kv-row">
|
|
<span class="ach-kv-label">Grupo</span>
|
|
<span class="ach-kv-value">${esc(rm.group)}</span>
|
|
</div>
|
|
<div class="ach-kv-row">
|
|
<span class="ach-kv-label">Telefone</span>
|
|
<span class="ach-kv-value">${esc(u.phone || '—')}</span>
|
|
</div>
|
|
<div class="ach-kv-row">
|
|
<span class="ach-kv-label">Criado em</span>
|
|
<span class="ach-kv-value">${fmtDateTime(u.created_at)}</span>
|
|
</div>
|
|
<div class="ach-kv-row">
|
|
<span class="ach-kv-label">Último acesso</span>
|
|
<span class="ach-kv-value">${fmtDateTime(u.last_login_at)}</span>
|
|
</div>
|
|
<div class="ach-kv-row">
|
|
<span class="ach-kv-label">2FA</span>
|
|
<span class="ach-kv-value">
|
|
${tfaOn
|
|
? `<span class="ach-2fa-ok">${ICONS.checkCircle} Activado</span>`
|
|
: 'Não activo'}
|
|
</span>
|
|
</div>
|
|
</div>`;
|
|
} else if (detailTab === 'permissions') {
|
|
tabBody = `<p class="ach-kpi-sub">Permissões herdadas do perfil <strong>${esc(rm.label)}</strong>. Edição avançada na aba «Quem faz o quê».</p>
|
|
<p style="margin-top:12px"><a href="#" data-ach-goto-matrix>Abrir mapa de permissões</a></p>`;
|
|
} else {
|
|
tabBody = `<div class="lb-audit-list" data-ach-audit><p class="ach-kpi-sub">A carregar…</p></div>`;
|
|
loadAudit(u.username);
|
|
}
|
|
|
|
return `
|
|
<div class="ach-user-panel">
|
|
<header class="ach-user-panel__head">
|
|
<div class="ach-user-panel__identity">
|
|
<div class="ach-user-avatar">${esc(initials(u.display_name || u.username))}</div>
|
|
<div>
|
|
<div class="ach-user-name-row">
|
|
<span class="ach-user-name">${esc(u.display_name || u.username)}</span>
|
|
<span class="ach-user-badge${u.active ? '' : ' ach-user-badge--frozen'}">${u.active ? 'Activo' : 'Congelado'}</span>
|
|
</div>
|
|
<p class="ach-user-email">${esc(u.email || u.username)}</p>
|
|
</div>
|
|
</div>
|
|
<div class="ach-user-panel__tools">
|
|
<button type="button" class="ach-icon-btn ach-icon-btn--fav${isFav ? ' active' : ''}" data-ach-favorite title="Favorito">${ICONS.star}</button>
|
|
<button type="button" class="ach-icon-btn" data-ach-close-panel title="Fechar">${ICONS.close}</button>
|
|
</div>
|
|
</header>
|
|
<nav class="ach-user-tabs">
|
|
<button type="button" class="ach-user-tab${detailTab === 'details' ? ' active' : ''}" data-ach-dtab="details">Detalhes</button>
|
|
<button type="button" class="ach-user-tab${detailTab === 'permissions' ? ' active' : ''}" data-ach-dtab="permissions">Permissões</button>
|
|
<button type="button" class="ach-user-tab${detailTab === 'activities' ? ' active' : ''}" data-ach-dtab="activities">Atividades</button>
|
|
</nav>
|
|
<div class="ach-user-panel__body">${tabBody}</div>
|
|
<section class="ach-quick-section">
|
|
<h4 class="ach-quick-title">Ações rápidas</h4>
|
|
<div class="ach-quick-grid">
|
|
${quickAction('edit', 'Editar utilizador', 'data-ach-edit')}
|
|
${quickAction('freeze', u.active ? 'Congelar conta' : 'Activar conta', 'data-ach-freeze')}
|
|
${quickAction('copy', 'Copiar utilizador', 'data-ach-clone')}
|
|
${quickAction('lock', 'Redefinir senha', 'data-ach-reset-pwd')}
|
|
${quickAction('shieldCheck', 'Resetar 2FA', 'data-ach-reset-2fa')}
|
|
${quickAction('usersGroup', 'Gerenciar grupos', 'data-ach-groups')}
|
|
</div>
|
|
${!isRoot ? `
|
|
<button type="button" class="ach-delete-btn" data-ach-deactivate>
|
|
${ICONS.trash}
|
|
Eliminar utilizador
|
|
</button>` : ''}
|
|
</section>
|
|
<div class="ach-audit-banner">
|
|
${ICONS.infoShield}
|
|
<span>As alterações realizadas aqui são registadas em audit log e não podem ser desfeitas.</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
async function loadAudit(username) {
|
|
const el = host?.querySelector('[data-ach-audit]');
|
|
if (!el) return;
|
|
try {
|
|
const r = await fetchWithTimeout(
|
|
`/api/v1/governance/audit?target_type=user&target_id=${encodeURIComponent(username)}&limit=20`,
|
|
{ headers: authHeaders() },
|
|
);
|
|
const data = await r.json();
|
|
const events = data.events || [];
|
|
el.innerHTML = events.length
|
|
? events.map((e) => `<div class="lb-audit-item"><strong>${esc(e.summary)}</strong><br/><span class="lb-stat-sub">${fmtDate(e.created_at)}</span></div>`).join('')
|
|
: '<p class="lb-stat-sub">Sem actividades registadas.</p>';
|
|
} catch (e) {
|
|
el.innerHTML = `<p class="lb-stat-sub">Erro: ${esc(e.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
function capabilitiesPanel() {
|
|
if (!window.DeskAccessControlPanel?.paint) {
|
|
return '<p class="lb-stat-sub">Módulo de capacidades indisponível.</p>';
|
|
}
|
|
return '<div id="ach-capabilities-host"></div>';
|
|
}
|
|
|
|
function render() {
|
|
if (!host) return;
|
|
const body = subTab === 'users' ? usersPanel() : capabilitiesPanel();
|
|
host.innerHTML = `
|
|
<div class="lb-page${subTab === 'users' ? ' lb-page--ach-users' : ''}" data-ach-root>
|
|
${subTab === 'capabilities' ? `<header class="ach-page-head">${subnavHtml()}</header>` : ''}
|
|
${body}
|
|
</div>
|
|
${confirmModal || ''}`;
|
|
|
|
bindEvents();
|
|
if (subTab === 'capabilities') {
|
|
const capHost = host.querySelector('#ach-capabilities-host');
|
|
window.DeskAccessControlPanel?.paintCapabilitiesOnly?.(capHost, opts)
|
|
|| window.DeskAccessControlPanel?.paint?.(capHost, { ...opts, usersOnly: false });
|
|
}
|
|
}
|
|
|
|
function showConfirm(title, message, onConfirm, danger = false) {
|
|
confirmModal = `
|
|
<div class="lb-modal-sm" data-ach-confirm-root>
|
|
<div class="lb-modal-sm-card">
|
|
<h3 style="margin:0 0 8px">${esc(title)}</h3>
|
|
<p class="lb-stat-sub">${esc(message)}</p>
|
|
<p class="lb-stat-sub" style="color:var(--lb-danger)">Acção registada no audit log.</p>
|
|
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
|
|
<button type="button" class="lb-btn-ghost" data-ach-confirm-cancel>Cancelar</button>
|
|
<button type="button" class="${danger ? 'lb-btn-danger' : 'lb-btn-primary'}" data-ach-confirm-ok>Confirmar</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
render();
|
|
host.querySelector('[data-ach-confirm-cancel]')?.addEventListener('click', () => {
|
|
confirmModal = null;
|
|
render();
|
|
});
|
|
host.querySelector('[data-ach-confirm-ok]')?.addEventListener('click', async () => {
|
|
confirmModal = null;
|
|
await onConfirm();
|
|
render();
|
|
});
|
|
}
|
|
|
|
async function apiJson(path, init = {}) {
|
|
const r = await fetchWithTimeout(path, {
|
|
...init,
|
|
headers: authHeaders({ 'Content-Type': 'application/json', ...(init.headers || {}) }),
|
|
});
|
|
if (!r.ok) throw new Error((await r.text()).slice(0, 200));
|
|
return r.json();
|
|
}
|
|
|
|
function bindEvents() {
|
|
host.querySelectorAll('[data-ach-sub]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
subTab = btn.dataset.achSub;
|
|
render();
|
|
});
|
|
});
|
|
|
|
function openCreateWizard(defaultRole) {
|
|
createMenuOpen = false;
|
|
window.DeskUserWizard?.open?.({
|
|
defaultRole: defaultRole || opts.selectedRole || 'technician',
|
|
onDone: async (wizardResult) => {
|
|
await loadData();
|
|
const email = wizardResult?.user?.username || wizardResult?.user?.email;
|
|
if (email) {
|
|
selectedUser = email;
|
|
filterRole = 'all';
|
|
filterGroup = 'all';
|
|
filterStatus = 'all';
|
|
filterQ = email.split('@')[0] || '';
|
|
}
|
|
render();
|
|
},
|
|
});
|
|
}
|
|
|
|
host.querySelector('[data-ach-create]')?.addEventListener('click', () => openCreateWizard());
|
|
host.querySelector('[data-ach-create-user]')?.addEventListener('click', () => openCreateWizard());
|
|
host.querySelector('[data-ach-create-support]')?.addEventListener('click', () => openCreateWizard('technician'));
|
|
|
|
host.querySelector('[data-ach-create-menu]')?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
createMenuOpen = !createMenuOpen;
|
|
render();
|
|
});
|
|
|
|
host.querySelector('[data-ach-filters]')?.addEventListener('click', () => {
|
|
host.querySelector('[data-ach-search]')?.focus();
|
|
});
|
|
|
|
if (createMenuOpen) {
|
|
const closeMenu = (e) => {
|
|
if (!e.target.closest('[data-ach-create-dropdown]') && !e.target.closest('[data-ach-create-menu]')) {
|
|
createMenuOpen = false;
|
|
document.removeEventListener('click', closeMenu);
|
|
render();
|
|
}
|
|
};
|
|
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
|
}
|
|
|
|
host.querySelector('[data-ach-search]')?.addEventListener('input', (e) => {
|
|
filterQ = e.target.value;
|
|
clearTimeout(host._achTimer);
|
|
host._achTimer = setTimeout(render, 200);
|
|
});
|
|
|
|
['role', 'group', 'status'].forEach((k) => {
|
|
host.querySelector(`[data-ach-filter-${k}]`)?.addEventListener('change', (e) => {
|
|
if (k === 'role') filterRole = e.target.value;
|
|
if (k === 'group') filterGroup = e.target.value;
|
|
if (k === 'status') filterStatus = e.target.value;
|
|
render();
|
|
});
|
|
});
|
|
|
|
host.querySelectorAll('[data-ach-user]').forEach((el) => {
|
|
el.addEventListener('click', (e) => {
|
|
if (e.target.closest('.ach-row-menu')) e.preventDefault();
|
|
selectedUser = el.dataset.achUser;
|
|
detailTab = 'details';
|
|
render();
|
|
});
|
|
});
|
|
|
|
host.querySelectorAll('[data-ach-dtab]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
detailTab = btn.dataset.achDtab;
|
|
render();
|
|
});
|
|
});
|
|
|
|
host.querySelector('[data-ach-close-panel]')?.addEventListener('click', () => {
|
|
selectedUser = null;
|
|
render();
|
|
});
|
|
|
|
const u = users.find((x) => x.username === selectedUser);
|
|
if (!u) return;
|
|
|
|
host.querySelector('[data-ach-favorite]')?.addEventListener('click', () => {
|
|
if (favorites.has(u.username)) favorites.delete(u.username);
|
|
else favorites.add(u.username);
|
|
render();
|
|
});
|
|
|
|
host.querySelector('[data-ach-groups]')?.addEventListener('click', () => {
|
|
window.alert('Gerenciar grupos — disponível na próxima fase.');
|
|
});
|
|
|
|
host.querySelector('[data-ach-goto-matrix]')?.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
document.querySelector('[data-am-tab="quem-faz-o-que"]')?.click();
|
|
});
|
|
|
|
host.querySelector('[data-ach-freeze]')?.addEventListener('click', () => {
|
|
const label = u.active ? 'Congelar' : 'Activar';
|
|
showConfirm(`${label} conta`, `${label} ${u.username}?`, async () => {
|
|
await apiJson(`/api/v1/governance/users/${encodeURIComponent(u.username)}/freeze`, { method: 'POST' });
|
|
await loadData();
|
|
});
|
|
});
|
|
|
|
host.querySelector('[data-ach-reset-pwd]')?.addEventListener('click', () => {
|
|
showConfirm('Redefinir senha', `Gerar nova senha para ${u.username}?`, async () => {
|
|
const res = await apiJson(`/api/v1/governance/users/${encodeURIComponent(u.username)}/reset-password`, { method: 'POST' });
|
|
window.alert(`Nova senha: ${res.generated_password}`);
|
|
await loadData();
|
|
});
|
|
});
|
|
|
|
host.querySelector('[data-ach-reset-2fa]')?.addEventListener('click', () => {
|
|
showConfirm('Resetar 2FA', `Resetar autenticador de ${u.username}?`, async () => {
|
|
await apiJson(`/api/v1/auth/users/${encodeURIComponent(u.username)}/reset-2fa`, { method: 'POST' });
|
|
await loadData();
|
|
});
|
|
});
|
|
|
|
host.querySelector('[data-ach-deactivate]')?.addEventListener('click', () => {
|
|
showConfirm('Eliminar utilizador', `Eliminar permanentemente ${u.username}? Esta acção não pode ser desfeita.`, async () => {
|
|
await apiJson(`/api/v1/auth/users/${encodeURIComponent(u.username)}`, { method: 'DELETE' });
|
|
selectedUser = null;
|
|
await loadData();
|
|
}, true);
|
|
});
|
|
|
|
host.querySelector('[data-ach-clone]')?.addEventListener('click', () => {
|
|
const email = window.prompt('E-mail do novo utilizador (cópia):');
|
|
if (!email) return;
|
|
apiJson(`/api/v1/auth/users/${encodeURIComponent(u.username)}/clone`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email: email.trim().toLowerCase() }),
|
|
}).then(async (res) => {
|
|
if (res.generated_password) window.alert(`Copiado. Senha: ${res.generated_password}`);
|
|
await loadData();
|
|
render();
|
|
}).catch((e) => window.alert(e.message));
|
|
});
|
|
|
|
host.querySelector('[data-ach-edit]')?.addEventListener('click', () => {
|
|
if (window.DeskUserManagement?.openEdit) {
|
|
window.DeskUserManagement.openEdit(u.username, async () => {
|
|
await loadData();
|
|
render();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
async function paint(container, options = {}) {
|
|
host = container;
|
|
opts = options;
|
|
if (typeof canManageUsers === 'function' && !canManageUsers()) {
|
|
host.innerHTML = '<p class="loading">Sem permissão — apenas Super Admin gere acessos.</p>';
|
|
return;
|
|
}
|
|
host.innerHTML = '<p class="loading">Carregando Controle de acesso…</p>';
|
|
try {
|
|
await loadData();
|
|
if (!selectedUser && users.length) selectedUser = users[0].username;
|
|
render();
|
|
} catch (e) {
|
|
host.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
function selectUser(username) {
|
|
selectedUser = username;
|
|
subTab = 'users';
|
|
filterRole = 'all';
|
|
filterQ = String(username || '').split('@')[0] || '';
|
|
if (host) {
|
|
loadData().then(render).catch(() => render());
|
|
}
|
|
}
|
|
|
|
window.DeskAccessControlHub = { paint, selectUser };
|
|
})();
|