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>
1014 lines
38 KiB
JavaScript
1014 lines
38 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
|
||
const LEVEL_SHORT = {
|
||
full: 'ADM',
|
||
read: 'READ',
|
||
link: 'LINK',
|
||
api: 'API',
|
||
system: 'SYS',
|
||
none: '—',
|
||
};
|
||
|
||
const state = {
|
||
data: null,
|
||
tab: 'overview',
|
||
selectedRole: 'super_admin',
|
||
softwareFilter: 'all',
|
||
saving: false,
|
||
saveError: null,
|
||
moduleDraft: null,
|
||
bindingDraft: null,
|
||
};
|
||
|
||
async function crudApi(method, path, body) {
|
||
const opts = {
|
||
method,
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
};
|
||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||
const r = await fetchWithTimeout(`/api/v1${path}`, opts);
|
||
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
|
||
if (method === 'DELETE' || r.status === 204) return {};
|
||
const ct = r.headers.get('content-type') || '';
|
||
if (ct.includes('json')) return r.json();
|
||
return r.text();
|
||
}
|
||
|
||
async function reloadMatrix() {
|
||
state.data = await api('/rbac/matrix');
|
||
state.moduleDraft = null;
|
||
state.bindingDraft = null;
|
||
}
|
||
|
||
function selectedRoleMeta() {
|
||
return state.data?.catalog?.roles?.[state.selectedRole] || {};
|
||
}
|
||
|
||
function canCrud() {
|
||
return !!state.data?.crud_enabled;
|
||
}
|
||
|
||
function isRoleLocked() {
|
||
return !!selectedRoleMeta().locked;
|
||
}
|
||
|
||
async function handleNewRole() {
|
||
const id = window.prompt('ID da função (slug, ex.: billing_analyst):');
|
||
if (!id) return;
|
||
const label = window.prompt('Nome exibido (pt-BR):', id.replace(/_/g, ' '));
|
||
if (!label) return;
|
||
state.saving = true;
|
||
state.saveError = null;
|
||
try {
|
||
await crudApi('POST', '/rbac/roles', {
|
||
id: id.trim().toLowerCase(),
|
||
label: label.trim(),
|
||
category: 'custom',
|
||
description: '',
|
||
});
|
||
await reloadMatrix();
|
||
state.selectedRole = id.trim().toLowerCase();
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} catch (e) {
|
||
state.saveError = e.message;
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} finally {
|
||
state.saving = false;
|
||
}
|
||
}
|
||
|
||
async function handleCloneRole() {
|
||
const newId = window.prompt(`Copiar ${state.selectedRole} — novo ID (slug):`);
|
||
if (!newId) return;
|
||
const newLabel = window.prompt('Nome da cópia:', `${selectedRoleMeta().label || state.selectedRole} (cópia)`);
|
||
if (!newLabel) return;
|
||
state.saving = true;
|
||
try {
|
||
await crudApi('POST', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/clone`, {
|
||
new_id: newId.trim().toLowerCase(),
|
||
new_label: newLabel.trim(),
|
||
});
|
||
await reloadMatrix();
|
||
state.selectedRole = newId.trim().toLowerCase();
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} catch (e) {
|
||
state.saveError = e.message;
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} finally {
|
||
state.saving = false;
|
||
}
|
||
}
|
||
|
||
async function handleFreezeRole() {
|
||
const meta = selectedRoleMeta();
|
||
const next = meta.status === 'frozen' ? 'active' : 'frozen';
|
||
const msg = next === 'frozen' ? 'Pausar esta função? Novas atribuições serão bloqueadas.' : 'Reactivar função?';
|
||
if (!window.confirm(msg)) return;
|
||
state.saving = true;
|
||
try {
|
||
await crudApi('PATCH', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/status`, { status: next });
|
||
await reloadMatrix();
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} catch (e) {
|
||
state.saveError = e.message;
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} finally {
|
||
state.saving = false;
|
||
}
|
||
}
|
||
|
||
async function handleDeleteRole() {
|
||
if (!window.confirm(`Arquivar função ${state.selectedRole}? Falha se houver utilizadores activos.`)) return;
|
||
state.saving = true;
|
||
try {
|
||
await crudApi('DELETE', `/rbac/roles/${encodeURIComponent(state.selectedRole)}`);
|
||
await reloadMatrix();
|
||
state.selectedRole = state.data.role_columns?.[0] || 'super_admin';
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} catch (e) {
|
||
state.saveError = e.message;
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} finally {
|
||
state.saving = false;
|
||
}
|
||
}
|
||
|
||
function handleReportDownload() {
|
||
const token = localStorage.getItem('desk_token') || sessionStorage.getItem('desk_token');
|
||
const url = `/api/v1/rbac/roles/${encodeURIComponent(state.selectedRole)}/report.csv`;
|
||
fetch(url, { headers: authHeaders() })
|
||
.then((r) => {
|
||
if (!r.ok) throw new Error(String(r.status));
|
||
return r.blob();
|
||
})
|
||
.then((blob) => {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `matriz-${state.selectedRole}.csv`;
|
||
a.click();
|
||
})
|
||
.catch((e) => { state.saveError = e.message; paint(document.getElementById('access-matrix-content')); });
|
||
}
|
||
|
||
async function saveModuleDraft() {
|
||
if (!state.moduleDraft) return;
|
||
state.saving = true;
|
||
try {
|
||
await crudApi('PUT', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/modules`, {
|
||
modules: state.moduleDraft,
|
||
});
|
||
await reloadMatrix();
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} catch (e) {
|
||
state.saveError = e.message;
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} finally {
|
||
state.saving = false;
|
||
}
|
||
}
|
||
|
||
async function saveBindingDraft() {
|
||
if (!state.bindingDraft) return;
|
||
state.saving = true;
|
||
try {
|
||
await crudApi('PUT', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/bindings`, {
|
||
bindings: state.bindingDraft,
|
||
});
|
||
await reloadMatrix();
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} catch (e) {
|
||
state.saveError = e.message;
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} finally {
|
||
state.saving = false;
|
||
}
|
||
}
|
||
|
||
function renderToolbar() {
|
||
if (!canCrud()) return '';
|
||
const meta = selectedRoleMeta();
|
||
const frozen = meta.status === 'frozen';
|
||
return `<div class="am-toolbar">
|
||
<button type="button" class="am-tool-btn primary" data-am-action="new-role">+ Nova função</button>
|
||
<button type="button" class="am-tool-btn" data-am-action="clone-role" ${isRoleLocked() ? 'disabled' : ''}>Copiar</button>
|
||
<button type="button" class="am-tool-btn" data-am-action="freeze-role" ${isRoleLocked() ? 'disabled' : ''}>${frozen ? 'Reactivar' : 'Pausar'}</button>
|
||
<button type="button" class="am-tool-btn" data-am-action="delete-role" ${isRoleLocked() ? 'disabled' : ''}>Arquivar</button>
|
||
<button type="button" class="am-tool-btn" data-am-action="report-csv">Relatório CSV</button>
|
||
</div>`;
|
||
}
|
||
|
||
async function patchApi(path, body) {
|
||
const r = await fetchWithTimeout(`/api/v1${path}`, {
|
||
method: 'PATCH',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
|
||
return r.json();
|
||
}
|
||
|
||
function bindingMeta(agentId, roleId, relation) {
|
||
const b = state.data?.agent_bindings_matrix?.agents?.[agentId]?.[roleId]?.[relation];
|
||
return {
|
||
enabled: !!b?.enabled,
|
||
locked: !!b?.locked,
|
||
};
|
||
}
|
||
|
||
function capMeta(capId, roleId) {
|
||
const c = state.data?.agent_bindings_matrix?.caps?.[capId]?.[roleId];
|
||
return { enabled: !!c?.enabled, locked: !!c?.locked };
|
||
}
|
||
|
||
async function toggleBinding(agentId, roleId, relation) {
|
||
const meta = bindingMeta(agentId, roleId, relation);
|
||
if (meta.locked) return;
|
||
state.saving = true;
|
||
state.saveError = null;
|
||
try {
|
||
await patchApi('/rbac/agent-bindings', {
|
||
agent_id: agentId,
|
||
role_id: roleId,
|
||
relation,
|
||
enabled: !meta.enabled,
|
||
});
|
||
state.data = await api('/rbac/matrix');
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} catch (e) {
|
||
state.saveError = e.message;
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} finally {
|
||
state.saving = false;
|
||
}
|
||
}
|
||
|
||
async function toggleCap(capId, roleId) {
|
||
const meta = capMeta(capId, roleId);
|
||
if (meta.locked) return;
|
||
state.saving = true;
|
||
state.saveError = null;
|
||
try {
|
||
await patchApi('/rbac/agent-governance', {
|
||
cap_id: capId,
|
||
role_id: roleId,
|
||
enabled: !meta.enabled,
|
||
});
|
||
state.data = await api('/rbac/matrix');
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} catch (e) {
|
||
state.saveError = e.message;
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} finally {
|
||
state.saving = false;
|
||
}
|
||
}
|
||
|
||
async function api(path) {
|
||
const r = await fetchWithTimeout(`/api/v1${path}`, {
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
});
|
||
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
|
||
return r.json();
|
||
}
|
||
|
||
function levelMeta(level) {
|
||
return state.data.legend[level] || state.data.legend.none;
|
||
}
|
||
|
||
function badgeHtml(level, opts = {}) {
|
||
const meta = levelMeta(level);
|
||
const short = LEVEL_SHORT[meta.access] || '—';
|
||
const cls = opts.compact ? ' am-badge--compact' : '';
|
||
return `<span class="am-badge am-badge--${meta.access}${cls}" title="${esc(meta.label)}">${short}</span>`;
|
||
}
|
||
|
||
function serviceLabel(svc) {
|
||
return state.data.service_labels?.[svc] || svc;
|
||
}
|
||
|
||
function rolesByCategory(catalog, categoryLabels) {
|
||
const groups = {};
|
||
Object.values(catalog.roles || {}).forEach((role) => {
|
||
const cat = role.category || 'platform';
|
||
if (!groups[cat]) groups[cat] = [];
|
||
groups[cat].push(role);
|
||
});
|
||
const order = ['ops', 'commercial', 'business', 'platform', 'custom', 'system'];
|
||
return order
|
||
.filter((c) => groups[c]?.length)
|
||
.map((c) => ({ id: c, label: categoryLabels[c] || c, roles: groups[c] }));
|
||
}
|
||
|
||
function roleDeskRows() {
|
||
const roleId = state.selectedRole;
|
||
return (state.data.desk_modules || []).map((row) => ({
|
||
...row,
|
||
level: row.levels[roleId] || 'none',
|
||
})).filter((r) => r.level !== 'none');
|
||
}
|
||
|
||
function renderRoleSidebar() {
|
||
const groups = rolesByCategory(state.data.catalog, state.data.category_labels || {});
|
||
return groups.map((g) => `
|
||
<div class="am-role-group nav-zone">
|
||
<span class="nav-zone__label">${esc(g.label)}</span>
|
||
<div class="nav-zone__links">
|
||
${g.roles.map((r) => {
|
||
const st = r.status === 'frozen' ? ' <span class="am-role-paused">pausado</span>' : '';
|
||
const code = window.DeskAccessControlPanel?.ROLE_CODES?.[r.id] || '';
|
||
return `
|
||
<button type="button" class="am-role-btn nav-link${state.selectedRole === r.id ? ' active' : ''}${r.status === 'frozen' ? ' am-role-btn--frozen' : ''}"
|
||
data-am-role="${esc(r.id)}">
|
||
${code ? `<span class="am-role-code" aria-hidden="true">${esc(code)}</span>` : ''}
|
||
<span class="am-role-label">${esc(r.label)}</span>${st}
|
||
</button>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function renderScopeBar() {
|
||
return `<div class="am-scope-bar">
|
||
${(state.data.scope_layers || []).map((l) => `
|
||
<div class="am-scope-item">
|
||
<span class="am-scope-label">${esc(l.label)}</span>
|
||
<span class="am-scope-host">${esc(l.host)}</span>
|
||
<span class="am-scope-desc">${esc(l.desc)}</span>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderMatrixTable(rows, rowLabel, idKey) {
|
||
const { role_columns, legend } = state.data;
|
||
const cols = role_columns || [];
|
||
const head = cols.map((c) => {
|
||
const label = state.data.catalog.roles[c]?.label || c;
|
||
const hl = c === state.selectedRole ? ' am-col-active' : '';
|
||
return `<th class="${hl.trim()}" title="${esc(c)}">${esc(label.split(' ')[0])}</th>`;
|
||
}).join('');
|
||
|
||
const body = rows.map((row) => {
|
||
const cells = cols.map((role) => {
|
||
const lv = row.levels[role] || 'none';
|
||
const tdHl = role === state.selectedRole ? ' am-row-highlight' : '';
|
||
return `<td class="${tdHl.trim()}">${badgeHtml(lv)}</td>`;
|
||
}).join('');
|
||
const sub = row.product
|
||
? `<span class="am-row-sub">${esc(row.product)}</span>`
|
||
: `<code class="am-row-code">${esc(row[idKey] || '')}</code>`;
|
||
return `<tr>
|
||
<td class="am-sticky">
|
||
<span class="am-row-title">${esc(row.label)}</span>
|
||
${sub}
|
||
</td>${cells}</tr>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="am-table-wrap">
|
||
<table class="am-matrix-table">
|
||
<thead><tr><th class="am-sticky">${esc(rowLabel)}</th>${head}</tr></thead>
|
||
<tbody>${body}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function renderDeskMatrix() {
|
||
if (canCrud() && !isRoleLocked()) {
|
||
if (!state.moduleDraft) {
|
||
const draft = {};
|
||
(state.data.desk_modules || []).forEach((row) => {
|
||
draft[row.id] = row.levels[state.selectedRole] || 'none';
|
||
});
|
||
state.moduleDraft = draft;
|
||
}
|
||
const levels = ['full', 'read', 'api', 'link', 'none'];
|
||
const rows = (state.data.desk_modules || []).map((row) => `
|
||
<tr>
|
||
<td>${esc(row.label)} <code class="am-row-code">${esc(row.id)}</code></td>
|
||
<td>
|
||
<select class="am-mod-select" data-am-mod="${esc(row.id)}">
|
||
${levels.map((lv) =>
|
||
`<option value="${lv}" ${state.moduleDraft[row.id] === lv ? 'selected' : ''}>${lv}</option>`
|
||
).join('')}
|
||
</select>
|
||
</td>
|
||
</tr>`).join('');
|
||
return `
|
||
<p class="am-section-lead">Editor de módulos Desk — função <strong>${esc(roleLabel(state.selectedRole))}</strong></p>
|
||
<div class="am-table-wrap">
|
||
<table class="am-edit-table">
|
||
<thead><tr><th>Módulo</th><th>Acesso</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="am-editor-actions">
|
||
<button type="button" class="am-tool-btn primary" data-am-action="save-modules">Salvar módulos</button>
|
||
</div>
|
||
<hr class="am-hr"/>
|
||
<p class="am-section-lead">Grelha completa (todas as funções)</p>
|
||
${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`;
|
||
}
|
||
return `
|
||
<p class="am-section-lead">Grelha completa — módulos internos do Desk (VM122).</p>
|
||
${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`;
|
||
}
|
||
|
||
function renderBindingCards(bindings, title) {
|
||
if (!bindings.length) {
|
||
return `<p class="am-empty">${title ? esc(title) + ' — ' : ''}sem registos para esta função.</p>`;
|
||
}
|
||
const byService = {};
|
||
bindings.forEach((b) => {
|
||
if (!byService[b.service]) byService[b.service] = [];
|
||
byService[b.service].push(b);
|
||
});
|
||
return `
|
||
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
|
||
<div class="am-api-grid">
|
||
${Object.entries(byService).flatMap(([svc, items]) =>
|
||
items.map((b) => `
|
||
<article class="am-api-card am-svc-${esc(b.service)}">
|
||
<header class="am-api-card-head">
|
||
<span class="am-api-service">${esc(serviceLabel(svc))}</span>
|
||
${badgeHtml(b.access || 'full', { compact: true })}
|
||
</header>
|
||
<div class="am-api-type">${esc(b.type || 'binding')}</div>
|
||
<code class="am-api-value">${esc(b.value)}</code>
|
||
<footer class="am-api-foot">${esc(svc)}</footer>
|
||
</article>`)
|
||
).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderSoftwareCards(rows, title) {
|
||
if (!rows.length) {
|
||
return `<p class="am-empty">${esc(title || 'Software')} — sem acesso directo.</p>`;
|
||
}
|
||
return `
|
||
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
|
||
<div class="am-sw-grid">
|
||
${rows.map((r) => {
|
||
const meta = levelMeta(r.level);
|
||
return `<article class="am-sw-card">
|
||
<header class="am-sw-card-head">
|
||
<div>
|
||
<strong>${esc(r.label)}</strong>
|
||
<span class="am-sw-product">${esc(r.product)}</span>
|
||
</div>
|
||
${badgeHtml(r.level, { compact: true })}
|
||
</header>
|
||
<p class="am-sw-group">${esc(r.group)}</p>
|
||
${r.host && r.host !== '—' ? `<code class="am-sw-host">${esc(r.host)}</code>` : ''}
|
||
</article>`;
|
||
}).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderDeskChips() {
|
||
const rows = roleDeskRows();
|
||
if (!rows.length) {
|
||
return '<p class="am-empty">Sem módulos Desk activos para esta função.</p>';
|
||
}
|
||
return `<div class="am-desk-chips">
|
||
${rows.map((r) => `
|
||
<span class="am-desk-chip" title="${esc(r.id)}">
|
||
${esc(r.label)}
|
||
${badgeHtml(r.level, { compact: true })}
|
||
</span>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderRoleStats(role) {
|
||
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
|
||
const bindings = role?.bindings || [];
|
||
const desk = roleDeskRows();
|
||
return `<div class="am-stats">
|
||
<div class="am-stat"><span class="am-stat-n">${desk.length}</span><span class="am-stat-l">Módulos Desk</span></div>
|
||
<div class="am-stat"><span class="am-stat-n">${sw.length}</span><span class="am-stat-l">Software</span></div>
|
||
<div class="am-stat"><span class="am-stat-n">${bindings.length}</span><span class="am-stat-l">APIs / Grupos</span></div>
|
||
<div class="am-stat"><span class="am-stat-n">${(state.data.role_agents_summaries?.[state.selectedRole] || []).length}</span><span class="am-stat-l">Agentes</span></div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderRoleOverview() {
|
||
const role = state.data.catalog.roles[state.selectedRole];
|
||
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
|
||
const bindings = role?.bindings || [];
|
||
|
||
return `
|
||
${renderScopeBar()}
|
||
${renderRoleStats(role)}
|
||
<section class="am-block">
|
||
<h4 class="am-block-title">Desk VM122 — módulos activos</h4>
|
||
${renderDeskChips()}
|
||
</section>
|
||
<section class="am-block">
|
||
${renderSoftwareCards(sw, 'Software & infra em escopo')}
|
||
</section>
|
||
<section class="am-block">
|
||
${renderBindingCards(bindings, 'APIs, grupos Odoo e permissões')}
|
||
</section>
|
||
<section class="am-block">
|
||
<h4 class="am-block-title">Agentics — capacidades da função</h4>
|
||
${renderRoleAgentCaps()}
|
||
${renderRoleAgentsSummary('')}
|
||
</section>`;
|
||
}
|
||
|
||
function renderSoftwareMatrix() {
|
||
const { software_groups } = state.data;
|
||
const groups = (software_groups || []).filter((g) =>
|
||
state.softwareFilter === 'all' || g.id === state.softwareFilter
|
||
);
|
||
|
||
const filters = [
|
||
{ id: 'all', label: 'Todas as camadas' },
|
||
...(software_groups || []).map((g) => ({ id: g.id, label: g.label.split('—')[0].trim() })),
|
||
];
|
||
|
||
const filterBar = `
|
||
<div class="am-filter-bar">
|
||
${filters.map((f) => `
|
||
<button type="button" class="am-filter${state.softwareFilter === f.id ? ' active' : ''}"
|
||
data-am-sw-filter="${esc(f.id)}">${esc(f.label)}</button>`).join('')}
|
||
</div>`;
|
||
|
||
const sections = groups.map((grp) => {
|
||
const meta = [grp.host, grp.url].filter(Boolean).join(' · ');
|
||
return `
|
||
<section class="am-sw-section">
|
||
<header class="am-sw-section-head">
|
||
<div>
|
||
<h4>${esc(grp.label)}</h4>
|
||
${meta ? `<span class="am-sw-meta">${esc(meta)}</span>` : ''}
|
||
</div>
|
||
</header>
|
||
${renderMatrixTable(grp.items || [], 'Recurso', 'id')}
|
||
</section>`;
|
||
}).join('');
|
||
|
||
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
|
||
return `
|
||
<p class="am-section-lead">Matriz de software fora do Desk — VM112 (onboard/mail), VM123 (finance/hosting) e consolas de infra.</p>
|
||
${renderSoftwareCards(sw, 'Resumo da função seleccionada')}
|
||
${filterBar}${sections}`;
|
||
}
|
||
|
||
function renderBindings() {
|
||
const role = state.data.catalog.roles[state.selectedRole];
|
||
if (!role) return '<p class="am-empty">Função não encontrada</p>';
|
||
let editor = '';
|
||
if (canCrud() && !isRoleLocked()) {
|
||
if (!state.bindingDraft) {
|
||
state.bindingDraft = (role.bindings || []).map((b) => ({ ...b }));
|
||
}
|
||
const rows = state.bindingDraft.map((b, i) => `
|
||
<tr>
|
||
<td><input class="am-bind-in" data-am-bind="${i}" data-field="service" value="${esc(b.service || '')}"/></td>
|
||
<td><input class="am-bind-in" data-am-bind="${i}" data-field="type" value="${esc(b.type || '')}"/></td>
|
||
<td><input class="am-bind-in" data-am-bind="${i}" data-field="value" value="${esc(b.value || '')}"/></td>
|
||
<td><input class="am-bind-in" data-am-bind="${i}" data-field="access" value="${esc(b.access || 'full')}"/></td>
|
||
<td><button type="button" class="am-tool-btn sm" data-am-rm-bind="${i}">×</button></td>
|
||
</tr>`).join('');
|
||
editor = `
|
||
<section class="am-block">
|
||
<h4 class="am-block-title">Editor bindings</h4>
|
||
<div class="am-table-wrap">
|
||
<table class="am-edit-table">
|
||
<thead><tr><th>Serviço</th><th>Tipo</th><th>Valor</th><th>Acesso</th><th></th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="am-editor-actions">
|
||
<button type="button" class="am-tool-btn" data-am-action="add-binding">+ Binding</button>
|
||
<button type="button" class="am-tool-btn primary" data-am-action="save-bindings">Salvar bindings</button>
|
||
</div>
|
||
</section>`;
|
||
}
|
||
const groups = (state.data.external_groups || []).map((g) =>
|
||
`<li><code>${esc(g.id)}</code> — ${esc(g.label)} <span class="am-sw-meta">${esc(g.service)}</span></li>`
|
||
).join('');
|
||
return `
|
||
${editor}
|
||
<p class="am-section-lead">Bindings estilo Odoo — grupos, roles e permissões provisionados por função.</p>
|
||
${renderBindingCards(role.bindings || [], '')}
|
||
${groups ? `<section class="am-block"><h4 class="am-block-title">Grupos externos registados</h4><ul class="am-group-list">${groups}</ul></section>` : ''}`;
|
||
}
|
||
|
||
function renderAudit() {
|
||
const entries = state.data.role_audit || [];
|
||
if (!entries.length) return '<p class="am-empty">Sem alterações registadas ainda.</p>';
|
||
return `<div class="am-audit-list">
|
||
${entries.map((e) => `
|
||
<article class="am-audit-row">
|
||
<header><strong>${esc(e.action)}</strong> · ${esc(e.entity_type)} <code>${esc(e.entity_id)}</code></header>
|
||
<p class="am-sw-meta">${esc(e.actor)} · ${esc(e.created_at)}</p>
|
||
</article>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function roleLabel(roleId) {
|
||
return state.data.catalog.roles[roleId]?.label || roleId;
|
||
}
|
||
|
||
function renderRelationChip(rel) {
|
||
const labels = state.data.agent_relation_labels || {
|
||
approve: 'Aprova',
|
||
focus: 'Operador',
|
||
ui: 'UI',
|
||
};
|
||
return `<span class="am-rel am-rel--${esc(rel)}">${esc(labels[rel] || rel)}</span>`;
|
||
}
|
||
|
||
function renderRoleChips(roleIds) {
|
||
return (roleIds || []).map((r) =>
|
||
`<span class="am-role-chip${r === state.selectedRole ? ' am-role-chip--sel' : ''}" title="${esc(r)}">${esc(roleLabel(r))}</span>`
|
||
).join('');
|
||
}
|
||
|
||
function renderAgentGovernanceLegend() {
|
||
const labels = state.data.agent_relation_labels || {};
|
||
return `<div class="am-agent-legend">
|
||
${Object.entries(labels).map(([k, v]) =>
|
||
`<span class="am-agent-legend-item">${renderRelationChip(k)} ${esc(v)}</span>`
|
||
).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderRoleAgentCaps() {
|
||
const capsDef = [
|
||
['use_ui', 'UI Agentics'],
|
||
['trigger_runs', 'Disparar cenários'],
|
||
['approve_runbooks', 'Aprovar runbooks'],
|
||
['configure_models', 'Configurar LLM'],
|
||
];
|
||
const roleId = state.selectedRole;
|
||
if (!state.data.editable) {
|
||
const caps = state.data.role_agentic_caps?.[roleId] || [];
|
||
if (!caps.length) {
|
||
return '<p class="am-empty">Esta função não acede ao módulo Agentics.</p>';
|
||
}
|
||
return `<div class="am-cap-chips">
|
||
${caps.map((c) => `<span class="am-cap-chip">${esc(c.label)}</span>`).join('')}
|
||
</div>`;
|
||
}
|
||
return `<div class="am-cap-toggles">
|
||
${capsDef.map(([id, label]) => {
|
||
const meta = capMeta(id, roleId);
|
||
return `<button type="button"
|
||
class="am-cap-toggle${meta.enabled ? ' on' : ''}${meta.locked ? ' locked' : ''}"
|
||
${meta.locked ? 'disabled' : ''}
|
||
data-am-cap="${esc(id)}|${esc(roleId)}"
|
||
title="${esc(label)}">${esc(label)}</button>`;
|
||
}).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderRoleAgentsSummary(title) {
|
||
const rows = state.data.role_agents_summaries?.[state.selectedRole] || [];
|
||
if (!rows.length) {
|
||
return `<p class="am-empty">${esc(title || 'Agentes')} — sem interacção directa.</p>`;
|
||
}
|
||
return `
|
||
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
|
||
<div class="am-agent-mini-grid">
|
||
${rows.map((a) => `
|
||
<article class="am-agent-mini${a.is_approver ? ' am-agent-mini--approve' : ''}">
|
||
<header>
|
||
<span class="am-agent-id">${esc(a.id)}</span>
|
||
<strong>${esc(a.name)}</strong>
|
||
</header>
|
||
<p class="am-agent-role">${esc(a.role)}</p>
|
||
<div class="am-rel-row">${(a.relations || []).map(renderRelationChip).join('')}</div>
|
||
</article>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderAgentCard(a) {
|
||
const rels = a.role_relations?.[state.selectedRole] || [];
|
||
const hit = rels.length > 0;
|
||
return `
|
||
<article class="am-agent-card${hit ? ' am-agent-card--hit' : ''}">
|
||
<header class="am-agent-top">
|
||
<span class="am-agent-id">${esc(a.id)}</span>
|
||
<div>
|
||
<strong>${esc(a.name)}</strong>
|
||
<span class="am-agent-codename">${esc(a.codename)}</span>
|
||
</div>
|
||
</header>
|
||
<p class="am-agent-role">${esc(a.role)}</p>
|
||
${hit ? `<div class="am-rel-row am-rel-row--you">${rels.map(renderRelationChip).join('')} <span class="am-you">esta função</span></div>` : ''}
|
||
<div class="am-agent-section">
|
||
<span class="am-agent-lbl">Aprova</span>
|
||
${renderRoleChips(a.approvers)}
|
||
</div>
|
||
<div class="am-agent-section">
|
||
<span class="am-agent-lbl">Operadores</span>
|
||
${renderRoleChips(a.operators)}
|
||
</div>
|
||
${(a.reads || []).length ? `
|
||
<div class="am-agent-section">
|
||
<span class="am-agent-lbl">Lê</span>
|
||
<span class="am-agent-meta">${esc(a.reads.slice(0, 3).join(' · '))}</span>
|
||
</div>` : ''}
|
||
<footer class="am-agent-approval">
|
||
<span>Regra Spec 027</span>
|
||
<code>${esc(a.approval_text)}</code>
|
||
</footer>
|
||
</article>`;
|
||
}
|
||
|
||
function renderRelationToggle(agentId, roleId, relation) {
|
||
const meta = bindingMeta(agentId, roleId, relation);
|
||
const short = { ui: 'UI', focus: 'OP', approve: 'AP' }[relation] || relation;
|
||
return `<button type="button"
|
||
class="am-toggle${meta.enabled ? ' on' : ''}${meta.locked ? ' locked' : ''}"
|
||
${meta.locked ? 'disabled' : ''}
|
||
data-am-toggle="${esc(agentId)}|${esc(roleId)}|${relation}"
|
||
title="${esc(relation)}${meta.locked ? ' (obrigatório)' : ''}">${short}</button>`;
|
||
}
|
||
|
||
function renderAgentMatrixCell(agentId, role, rels) {
|
||
const tdHl = role === state.selectedRole ? ' am-row-highlight' : '';
|
||
if (!state.data.editable) {
|
||
const inner = rels.length
|
||
? rels.map(renderRelationChip).join('')
|
||
: '<span class="am-rel am-rel--none">—</span>';
|
||
return `<td class="${tdHl.trim()}">${inner}</td>`;
|
||
}
|
||
return `<td class="am-cell-toggles${tdHl.trim()}">
|
||
${renderRelationToggle(agentId, role, 'ui')}
|
||
${renderRelationToggle(agentId, role, 'focus')}
|
||
${renderRelationToggle(agentId, role, 'approve')}
|
||
</td>`;
|
||
}
|
||
|
||
function renderAgentsMatrix() {
|
||
const agents = state.data.agents || [];
|
||
const cols = state.data.role_columns || [];
|
||
const head = cols.map((c) => {
|
||
const hl = c === state.selectedRole ? ' am-col-active' : '';
|
||
return `<th class="${hl.trim()}" title="${esc(c)}">${esc(roleLabel(c).split(' ')[0])}</th>`;
|
||
}).join('');
|
||
|
||
const body = agents.map((a) => {
|
||
const cells = cols.map((role) => {
|
||
const rels = a.role_relations?.[role] || [];
|
||
return renderAgentMatrixCell(a.id, role, rels);
|
||
}).join('');
|
||
return `<tr>
|
||
<td class="am-sticky">
|
||
<span class="am-row-title">${esc(a.id)} · ${esc(a.name)}</span>
|
||
<span class="am-row-sub">${esc(a.role)}</span>
|
||
</td>${cells}</tr>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<h4 class="am-block-title">Matriz agente × função</h4>
|
||
${renderAgentGovernanceLegend()}
|
||
<div class="am-table-wrap">
|
||
<table class="am-matrix-table">
|
||
<thead><tr><th class="am-sticky">Agente</th>${head}</tr></thead>
|
||
<tbody>${body}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function renderAgents() {
|
||
const caps = state.data.role_agentic_caps?.[state.selectedRole] || [];
|
||
return `
|
||
<p class="am-section-lead">
|
||
Agentes A0–A7 usam conta <code>agent_system</code>.
|
||
${state.data.editable
|
||
? 'Clique <strong>UI / OP / AP</strong> para ligar ou desligar atribuições (audit activo).'
|
||
: 'Três relações: UI · Operador · Aprova.'}
|
||
</p>
|
||
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
|
||
${(state.data.editable || caps.length) ? `<section class="am-block"><h4 class="am-block-title">Capacidades — ${esc(roleLabel(state.selectedRole))}</h4>${renderRoleAgentCaps()}</section>` : ''}
|
||
${renderAgentsMatrix()}
|
||
<section class="am-block">
|
||
<h4 class="am-block-title">Detalhe por agente</h4>
|
||
<div class="am-agents-grid">
|
||
${(state.data.agents || []).map(renderAgentCard).join('')}
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderLegend() {
|
||
const legend = state.data.legend || {};
|
||
return Object.entries(legend).map(([key, l]) =>
|
||
`<span class="am-legend-item">${badgeHtml(key, { compact: true })} ${esc(l.label)}</span>`
|
||
).join('');
|
||
}
|
||
|
||
function activeTabHint() {
|
||
const tab = (state.data.tabs || []).find((t) => t.id === state.tab);
|
||
return tab?.hint || '';
|
||
}
|
||
|
||
function renderMainPanel() {
|
||
if (state.tab === 'access-control') {
|
||
return `
|
||
<div class="am-panel am-panel--access-control">
|
||
<div id="am-access-control-host" class="am-access-control-host"></div>
|
||
</div>`;
|
||
}
|
||
if (state.tab === 'quem-faz-o-que') {
|
||
return `
|
||
<div class="am-panel am-panel--executive">
|
||
<div id="am-executive-map-host" class="am-executive-map-host"></div>
|
||
</div>`;
|
||
}
|
||
const role = state.data.catalog.roles[state.selectedRole];
|
||
let body = '';
|
||
if (state.tab === 'overview') body = renderRoleOverview();
|
||
else if (state.tab === 'vm122') body = renderDeskMatrix();
|
||
else if (state.tab === 'software') body = renderSoftwareMatrix();
|
||
else if (state.tab === 'bindings') body = renderBindings();
|
||
else if (state.tab === 'agents') body = renderAgents();
|
||
else if (state.tab === 'audit') body = renderAudit();
|
||
|
||
return `
|
||
<div class="am-panel">
|
||
<header class="am-panel-head">
|
||
<div class="am-panel-title">
|
||
<span class="am-panel-kicker">Spec 027 · RBAC</span>
|
||
<h3>${esc(role?.label || state.selectedRole)}</h3>
|
||
<p class="am-panel-desc">${esc(role?.description || '')}</p>
|
||
${activeTabHint() ? `<p class="am-tab-hint">${esc(activeTabHint())}</p>` : ''}
|
||
</div>
|
||
<div class="am-legend">${renderLegend()}</div>
|
||
</header>
|
||
<div class="am-panel-body">${body}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderTabs() {
|
||
return (state.data.tabs || []).map((t) => {
|
||
const sep = t.separated ? '<span class="am-tab-sep" aria-hidden="true"></span>' : '';
|
||
const extra = t.id === 'access-control' ? ' am-tab--access-control' : (t.id === 'quem-faz-o-que' ? ' am-tab--quem-faz-o-que' : '');
|
||
return `${sep}<button type="button" class="am-tab${extra}${state.tab === t.id ? ' active' : ''}" data-am-tab="${esc(t.id)}">${esc(t.label)}</button>`;
|
||
}).join('');
|
||
}
|
||
|
||
function bindEvents(root) {
|
||
root.querySelectorAll('[data-am-role]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
state.selectedRole = btn.dataset.amRole;
|
||
state.moduleDraft = null;
|
||
state.bindingDraft = null;
|
||
window.DeskExecutiveMap?.resetFilters?.();
|
||
paint(root);
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-tab]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
state.tab = btn.dataset.amTab;
|
||
if (typeof window.getDeskState === 'function') {
|
||
const ds = window.getDeskState();
|
||
if (ds) ds.matrixTab = state.tab;
|
||
}
|
||
paint(root);
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-sw-filter]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
state.softwareFilter = btn.dataset.amSwFilter;
|
||
paint(root);
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-toggle]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
if (state.saving || !state.data?.editable) return;
|
||
const [agentId, roleId, relation] = btn.dataset.amToggle.split('|');
|
||
toggleBinding(agentId, roleId, relation);
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-cap]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
if (state.saving || !state.data?.editable) return;
|
||
const [capId, roleId] = btn.dataset.amCap.split('|');
|
||
toggleCap(capId, roleId);
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-action]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const a = btn.dataset.amAction;
|
||
if (a === 'new-role') handleNewRole();
|
||
if (a === 'clone-role') handleCloneRole();
|
||
if (a === 'freeze-role') handleFreezeRole();
|
||
if (a === 'delete-role') handleDeleteRole();
|
||
if (a === 'report-csv') handleReportDownload();
|
||
if (a === 'save-modules') saveModuleDraft();
|
||
if (a === 'save-bindings') saveBindingDraft();
|
||
if (a === 'add-binding') {
|
||
state.bindingDraft = state.bindingDraft || [];
|
||
state.bindingDraft.push({ service: 'desk', type: 'permission', value: 'read_tickets', access: 'read' });
|
||
paint(root);
|
||
}
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-mod]').forEach((sel) => {
|
||
sel.addEventListener('change', () => {
|
||
if (!state.moduleDraft) return;
|
||
state.moduleDraft[sel.dataset.amMod] = sel.value;
|
||
});
|
||
});
|
||
root.querySelectorAll('.am-bind-in').forEach((inp) => {
|
||
inp.addEventListener('change', () => {
|
||
const i = Number(inp.dataset.amBind);
|
||
const field = inp.dataset.field;
|
||
if (!state.bindingDraft?.[i]) return;
|
||
state.bindingDraft[i][field] = inp.value;
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-rm-bind]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const i = Number(btn.dataset.amRmBind);
|
||
state.bindingDraft.splice(i, 1);
|
||
paint(root);
|
||
});
|
||
});
|
||
}
|
||
|
||
function paint(root) {
|
||
root.innerHTML = `
|
||
<div class="am-wrap">
|
||
<div class="am-header am-header--toolbar">
|
||
<span class="am-preview-tag">${state.data?.crud_enabled ? 'CRUD · audit ON' : state.data?.editable ? 'Edição · audit ON' : 'Consulta'}${state.saving ? ' · …' : ''}</span>
|
||
</div>
|
||
${renderToolbar()}
|
||
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
|
||
<nav class="am-subnav">${renderTabs()}</nav>
|
||
<div class="am-layout">
|
||
<aside class="am-role-list">${renderRoleSidebar()}</aside>
|
||
${renderMainPanel()}
|
||
</div>
|
||
</div>`;
|
||
bindEvents(root);
|
||
window.DeskTopnav?.remountMatrixRoles?.();
|
||
if (state.tab === 'access-control') {
|
||
const host = root.querySelector('#am-access-control-host');
|
||
const roleMeta = state.data?.catalog?.roles?.[state.selectedRole];
|
||
window.DeskAccessControlPanel?.paint?.(host, {
|
||
selectedRole: state.selectedRole,
|
||
roleMeta,
|
||
catalog: state.data?.catalog,
|
||
editable: !!state.data?.editable,
|
||
});
|
||
}
|
||
if (state.tab === 'quem-faz-o-que') {
|
||
const host = root.querySelector('#am-executive-map-host');
|
||
window.DeskExecutiveMap?.paint?.(host, { selectedRole: state.selectedRole });
|
||
}
|
||
}
|
||
|
||
async function renderAccessMatrix() {
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (!root) return;
|
||
if (typeof ensureValidSession === 'function' && !(await ensureValidSession())) return;
|
||
root.innerHTML = '<p class="loading">Carregando Matriz de Acesso…</p>';
|
||
try {
|
||
state.data = await api('/rbac/matrix');
|
||
if (!state.selectedRole && state.data.role_columns?.length) {
|
||
state.selectedRole = state.data.role_columns[0];
|
||
}
|
||
const tabIds = (state.data.tabs || []).map((t) => t.id);
|
||
if (typeof window.getDeskState === 'function') {
|
||
const ds = window.getDeskState();
|
||
if (ds?.matrixTab && tabIds.includes(ds.matrixTab)) {
|
||
state.tab = ds.matrixTab;
|
||
}
|
||
}
|
||
if (!tabIds.includes(state.tab) && tabIds.length) state.tab = tabIds[0];
|
||
paint(root);
|
||
} catch (e) {
|
||
root.innerHTML = `<p class="loading">Matriz indisponível: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
window.renderAccessMatrix = renderAccessMatrix;
|
||
window.DeskAccessMatrix = {
|
||
renderAccessMatrix,
|
||
getState: () => state,
|
||
};
|
||
})();
|