← back to Ventura Claw
server/public/admin-approvals.html
197 lines
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Approvals — VenturaClaw</title>
<link rel="stylesheet" href="/static/style.css" />
<style>
.filter-bar { padding: 14px 18px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.filter-pill { display: inline-flex; gap: 6px; align-items: center; padding: 5px 12px; border: 1px solid var(--rule); border-radius: 999px; font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; cursor: pointer; }
.filter-pill.active { color: var(--gold); border-color: var(--gold); background: rgba(212,160,74,0.08); }
.filter-pill .ct { opacity: 0.7; }
.approval-row { padding: 14px; border: 1px solid var(--rule); border-radius: 4px; margin-bottom: 10px; transition: border-color 200ms; }
.approval-row.pending { border-color: var(--warn); }
.approval-row.approved { border-color: rgba(143,184,154,0.4); opacity: 0.75; }
.approval-row.rejected { border-color: rgba(201,133,110,0.4); opacity: 0.6; }
.approval-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
.approval-meta { font-family: var(--mono); font-size: 10px; letter-spacing: .10em; color: var(--ink-mute); margin-bottom: 6px; }
.approval-title { font-family: var(--serif); font-size: 17px; font-weight: 500; }
.approval-msg { font-size: 13px; color: var(--ink-soft); margin-top: 6px; }
.approval-input { font-family: var(--mono); font-size: 11px; background: rgba(244,241,234,0.04); padding: 8px 12px; border-radius: 2px; margin-top: 8px; max-height: 200px; overflow-y: auto; white-space: pre-wrap; }
.approval-haiku { font-family: var(--serif); font-style: italic; font-size: 14px; line-height: 1.6; color: var(--gold); padding: 12px 18px; margin-top: 8px; border-left: 2px solid var(--gold); background: rgba(212,160,74,0.04); white-space: pre-line; }
.approval-haiku::before { content: "⌘ "; opacity: 0.5; }
.approval-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.approval-actions input { font-size: 11px; padding: 6px 10px; min-width: 200px; }
.batch-bar { padding: 12px 18px; background: rgba(212,160,74,0.08); border: 1px solid var(--gold); border-radius: 4px; margin-bottom: 16px; display: none; gap: 12px; align-items: center; }
.batch-bar.show { display: flex; }
input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--gold); cursor: pointer; }
</style>
</head><body>
<header class="topbar">
<div class="brand"><div class="logo-dot"></div><div><div class="brand-name">Ventura<em style="font-style:italic;color:var(--gold)">Claw</em></div><div class="brand-sub">Approval Queue</div></div></div>
<nav class="nav" style="display:flex;gap:6px;font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase">
<a href="/admin" style="padding:8px 14px;color:var(--ink-soft)">Dashboard</a>
<a href="/admin/users" style="padding:8px 14px;color:var(--ink-soft)">Users</a>
<a href="/admin/connectors" style="padding:8px 14px;color:var(--ink-soft)">Connectors</a>
<a href="/admin/approvals" style="padding:8px 14px;color:var(--gold);border-bottom:1px solid var(--gold)">Approvals</a>
<a href="/admin/audit" style="padding:8px 14px;color:var(--ink-soft)">Audit</a>
<a href="/chat" style="padding:8px 14px;color:var(--ink-soft)">Chat</a>
<a href="#" id="logout" style="padding:8px 14px;color:var(--ink-soft)">Sign out</a>
</nav>
</header>
<main style="padding:24px 28px;max-width:1380px;margin:0 auto">
<div class="glass filter-bar">
<span class="filter-pill active" data-filter="pending">Pending <span class="ct" id="ct-pending">0</span></span>
<span class="filter-pill" data-filter="approved">Approved <span class="ct" id="ct-approved">0</span></span>
<span class="filter-pill" data-filter="rejected">Rejected <span class="ct" id="ct-rejected">0</span></span>
<span class="filter-pill" data-filter="all">All <span class="ct" id="ct-all">0</span></span>
<input id="search" placeholder="filter by user or connector…" style="flex:1;min-width:200px" />
</div>
<div class="batch-bar glass" id="batch-bar">
<span style="font-family:var(--mono);font-size:11px;letter-spacing:.10em" id="batch-count">0 selected</span>
<button class="btn btn-ok" id="batch-approve">Approve selected</button>
<button class="btn btn-bad" id="batch-reject">Reject selected</button>
<button class="btn btn-ghost" id="batch-clear">Clear</button>
</div>
<div id="rows"></div>
</main>
<div class="footer">VenturaClaw</div>
<script>
const esc = s => String(s == null ? "" : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
let APPROVALS = [];
let FILTER = 'pending';
let SEARCH = '';
const SELECTED = new Set();
async function refresh() {
const d = await (await fetch('/api/approvals')).json();
APPROVALS = d.approvals || [];
const counts = { pending:0, approved:0, rejected:0, all: APPROVALS.length };
for (const a of APPROVALS) counts[a.status] = (counts[a.status]||0) + 1;
for (const k of Object.keys(counts)) {
const el = document.getElementById('ct-'+k);
if (el) el.textContent = counts[k];
}
render();
}
function render() {
const list = APPROVALS.filter(a => {
if (FILTER !== 'all' && a.status !== FILTER) return false;
if (SEARCH) {
const hay = `${a.userEmail||''} ${a.connector||''} ${a.connectorName||''} ${a.action||''} ${a.message||''}`.toLowerCase();
if (!hay.includes(SEARCH)) return false;
}
return true;
});
if (!list.length) {
document.getElementById('rows').innerHTML = `<div style="text-align:center;padding:60px;color:var(--ink-mute)">no ${FILTER === 'all' ? '' : FILTER + ' '}requests${SEARCH ? ' matching '+SEARCH : ''}</div>`;
updateBatch();
return;
}
document.getElementById('rows').innerHTML = list.map(a => {
const inputJson = a.input ? esc(JSON.stringify(a.input, null, 2)) : '<span style="opacity:0.4">no payload</span>';
return `<div class="approval-row ${a.status}" data-id="${esc(a.id)}">
<div class="approval-head">
<div style="flex:1;min-width:0">
<div class="approval-meta">${new Date(a.createdAt).toLocaleString()} · ${esc(a.userEmail||'')} · ${esc(a.status.toUpperCase())}${a.decidedBy?` by ${esc(a.decidedBy)}`:''}</div>
<div class="approval-title">${esc(a.connectorName||a.connector)}.<em style="color:var(--gold)">${esc(a.action)}</em></div>
<div class="approval-msg">"${esc((a.message||'').slice(0,160))}"</div>
<div class="approval-haiku" data-haiku-for="${esc(a.id)}">…</div>
<div class="approval-input">${inputJson}</div>
${a.executionResult ? `<div style="margin-top:6px;font-family:var(--mono);font-size:11px;color:var(--good)">→ executed: ${esc(JSON.stringify(a.executionResult).slice(0,160))}</div>` : ''}
${a.executionError ? `<div style="margin-top:6px;font-family:var(--mono);font-size:11px;color:var(--bad)">✗ ${esc(a.executionError)}</div>` : ''}
</div>
${a.status === 'pending' ? `
<div class="approval-actions">
<input type="checkbox" data-batch="${esc(a.id)}" ${SELECTED.has(a.id)?'checked':''} title="batch select" />
<button class="btn btn-ok" data-ok="${esc(a.id)}">Approve</button>
<button class="btn btn-bad" data-no="${esc(a.id)}">Reject</button>
<button class="btn btn-ghost" data-edit="${esc(a.id)}">Edit & approve</button>
</div>` : ''}
</div>
</div>`;
}).join('');
// Lazy-fetch haiku per pending approval (qwen3:14b on Mac1)
document.querySelectorAll('[data-haiku-for]').forEach(async el => {
try {
const r = await fetch(`/api/approvals/${el.dataset.haikuFor}/haiku`);
const j = await r.json();
el.textContent = j.haiku || '(local LLM offline)';
} catch { el.textContent = '(LLM offline)'; }
});
document.querySelectorAll('[data-ok]').forEach(b => b.addEventListener('click', () => decide(b.dataset.ok, 'approve')));
document.querySelectorAll('[data-no]').forEach(b => b.addEventListener('click', () => decide(b.dataset.no, 'reject')));
document.querySelectorAll('[data-edit]').forEach(b => b.addEventListener('click', () => editApprove(b.dataset.edit)));
document.querySelectorAll('[data-batch]').forEach(cb => cb.addEventListener('change', () => {
if (cb.checked) SELECTED.add(cb.dataset.batch); else SELECTED.delete(cb.dataset.batch);
updateBatch();
}));
updateBatch();
}
function updateBatch() {
const bar = document.getElementById('batch-bar');
if (SELECTED.size) { bar.classList.add('show'); document.getElementById('batch-count').textContent = `${SELECTED.size} selected`; }
else bar.classList.remove('show');
}
async function decide(id, decision, opts) {
const body = { decision, ...(opts||{}) };
const r = await fetch(`/api/approvals/${id}/decide`, { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify(body) });
if (!r.ok) {
const j = await r.json();
if (j.error === 'comms_compliance_violations') {
const v = (j.violations||[]).map(x => `• ${x.rule}: ${x.msg}`).join('\n');
const ok = confirm(`Comms-compliance blocked this approval:\n\n${v}\n\nOverride and execute anyway? (Audit-logged.)`);
if (ok) return decide(id, decision, { comms_compliance_override: true });
} else alert(j.error || 'failed');
}
SELECTED.delete(id);
refresh();
}
function editApprove(id) {
const a = APPROVALS.find(x => x.id === id);
const cur = JSON.stringify(a.input || {}, null, 2);
const next = prompt(`Edit input JSON before approve (or cancel to abort):`, cur);
if (next == null) return;
let parsed;
try { parsed = JSON.parse(next); } catch { return alert('invalid JSON'); }
decide(id, 'approve', { input: parsed });
}
async function batchDecide(decision) {
const ids = [...SELECTED];
if (!confirm(`${decision} ${ids.length} approval(s)?`)) return;
for (const id of ids) await decide(id, decision);
SELECTED.clear();
refresh();
}
document.querySelectorAll('[data-filter]').forEach(p => p.addEventListener('click', () => {
document.querySelectorAll('[data-filter]').forEach(x => x.classList.remove('active'));
p.classList.add('active');
FILTER = p.dataset.filter;
render();
}));
document.getElementById('search').addEventListener('input', e => { SEARCH = e.target.value.toLowerCase(); render(); });
document.getElementById('batch-approve').addEventListener('click', () => batchDecide('approve'));
document.getElementById('batch-reject').addEventListener('click', () => batchDecide('reject'));
document.getElementById('batch-clear').addEventListener('click', () => { SELECTED.clear(); render(); });
document.getElementById('logout').addEventListener('click', async e => {
e.preventDefault(); await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login';
});
refresh();
setInterval(refresh, 30_000); // auto-refresh every 30s
</script>
</body></html>