ligbox-ops-platform/projects/ops-desk/frontend/assets/access-matrix.js
Ligbox Spec Hub 6c4b063f76 Implement Spec 027 access matrix preview and agent bindings enforcement.
Adds RBAC matrix API/UI behind feature flags, agent×role toggles with audit,
and extends Agentic Ops with streaming chat, Kimi LLM support, and UI updates.
2026-06-20 22:02:03 +00:00

651 lines
24 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,
};
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', '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">
<h4>${esc(g.label)}</h4>
${g.roles.map((r) => `
<button type="button" class="am-role-btn${state.selectedRole === r.id ? ' active' : ''}"
data-am-role="${esc(r.id)}">${esc(r.label)}</button>
`).join('')}
</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() {
return `
<p class="am-section-lead">Grelha completa — módulos internos do Desk (VM122). Use <strong>Visão da função</strong> para resumo ou <strong>Software & Infra</strong> para VM112/123.</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>';
return `
<p class="am-section-lead">Bindings estilo Odoo — grupos, roles e permissões provisionados por função.</p>
${renderBindingCards(role.bindings || [], '')}`;
}
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() {
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();
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) =>
`<button type="button" class="am-tab${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;
paint(root);
});
});
root.querySelectorAll('[data-am-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
state.tab = btn.dataset.amTab;
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);
});
});
}
function paint(root) {
root.innerHTML = `
<div class="am-wrap">
<div class="am-header">
<div>
<h2 class="am-page-title">Matriz de Acessos</h2>
<p class="am-page-sub">Quatro camadas: Desk VM122 · VM112 · VM123 · Infra externa</p>
</div>
<span class="am-preview-tag">${state.data?.editable ? 'Edição · audit ON' : 'Preview · read-only'}${state.saving ? ' · …' : ''}</span>
</div>
${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);
}
async function renderAccessMatrix() {
const root = document.getElementById('access-matrix-content');
if (!root) return;
root.innerHTML = '<p class="loading">Carregando matriz de acessos…</p>';
try {
state.data = await api('/rbac/matrix');
if (!state.selectedRole && state.data.role_columns?.length) {
state.selectedRole = state.data.role_columns[0];
}
paint(root);
} catch (e) {
root.innerHTML = `<p class="loading">Matriz indisponível: ${esc(e.message)}</p>`;
}
}
window.renderAccessMatrix = renderAccessMatrix;
window.DeskAccessMatrix = { renderAccessMatrix };
})();