ligbox-ops-platform/projects/ops-desk/frontend/assets/access-matrix.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

1014 lines
38 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.

(function () {
'use strict';
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 A0A7 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,
};
})();