← 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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>