← back to Ventura Claw

server/public/connections.html

822 lines

<!doctype html>
<html lang="en"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>My Connections — VenturaClaw</title>
<link rel="stylesheet" href="/static/style.css" />
<style>
  /* OAuth tile grid */
  #oauth-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 10px;
  }
  .oauth-tile {
    background: var(--bg-card);
    border: 1px solid var(--rule);
    border-radius: 3px;
    padding: 18px 16px 14px;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 10px;
    transition: border-color 0.15s;
  }
  .oauth-tile:hover { border-color: var(--rule-strong); }
  .oauth-tile.is-connected { border-color: rgba(143,184,154,0.35); }
  .oauth-tile-logo {
    width: 28px;
    height: 28px;
    object-fit: contain;
    border-radius: 2px;
  }
  .oauth-tile-name {
    font-family: var(--serif);
    font-size: 15px;
    font-weight: 500;
    line-height: 1.2;
    color: var(--ink);
  }
  .oauth-tile-label {
    font-family: var(--mono);
    font-size: 9px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--ink-mute);
  }
  .oauth-tile-footer {
    margin-top: auto;
    width: 100%;
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .oauth-connected-badge {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.08em;
    color: var(--good);
    display: flex;
    align-items: center;
    gap: 4px;
  }
  .oauth-not-ready {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.08em;
    color: var(--ink-mute);
    font-style: italic;
  }
  .btn-oauth {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.10em;
    text-transform: uppercase;
    background: transparent;
    border: 1px solid var(--gold);
    color: var(--gold);
    border-radius: 2px;
    padding: 5px 12px;
    cursor: pointer;
    transition: background 0.12s, color 0.12s;
    white-space: nowrap;
  }
  .btn-oauth:hover { background: var(--gold); color: var(--bg); }
  .btn-oauth:disabled {
    border-color: var(--rule-strong);
    color: var(--ink-mute);
    cursor: not-allowed;
  }
  /* "newly connected" flash */
  @keyframes flash-connected {
    0%   { background: rgba(143,184,154,0.18); }
    100% { background: var(--bg-card); }
  }
  .oauth-tile.flash-new { animation: flash-connected 1.6s ease-out forwards; }

  /* per-tile select checkbox */
  .oauth-tile { position: relative; }
  .oauth-tile-check {
    position: absolute;
    top: 10px;
    right: 10px;
    width: 16px;
    height: 16px;
    cursor: pointer;
    accent-color: var(--gold);
  }
  .oauth-tile.is-selected {
    border-color: var(--gold);
    box-shadow: 0 0 0 1px var(--gold) inset;
  }

  /* bulk control bar */
  .bulk-bar {
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 10px 12px;
    margin-bottom: 14px;
    border: 1px solid var(--rule);
    border-radius: 3px;
    background: rgba(244,241,234,0.03);
    flex-wrap: wrap;
  }
  .bulk-bar label {
    display: flex;
    align-items: center;
    gap: 8px;
    cursor: pointer;
    font-family: var(--mono);
    font-size: 11px;
    letter-spacing: 0.10em;
    text-transform: uppercase;
    color: var(--ink);
  }
  .bulk-bar input[type=checkbox] { accent-color: var(--gold); width: 14px; height: 14px; cursor: pointer; }
  .bulk-bar .bulk-count {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.10em;
    color: var(--ink-mute);
  }
  .bulk-bar .bulk-spacer { flex: 1; }
  .btn-bulk-go {
    font-family: var(--mono);
    font-size: 11px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    background: var(--gold);
    color: var(--bg);
    border: 1px solid var(--gold);
    border-radius: 2px;
    padding: 7px 16px;
    cursor: pointer;
    font-weight: 600;
  }
  .btn-bulk-go:disabled {
    background: transparent;
    color: var(--ink-mute);
    border-color: var(--rule-strong);
    cursor: not-allowed;
    font-weight: normal;
  }

  /* queue continue bar */
  .queue-bar {
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 12px 14px;
    margin-bottom: 14px;
    border: 1px solid var(--gold);
    border-radius: 3px;
    background: rgba(212,160,74,0.08);
    flex-wrap: wrap;
  }
  .queue-bar .queue-msg {
    font-family: var(--serif);
    font-size: 14px;
    color: var(--ink);
  }
  .queue-bar .queue-progress {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.10em;
    color: var(--ink-mute);
  }
  .queue-bar .queue-spacer { flex: 1; }
  .queue-bar .btn-queue-next {
    font-family: var(--mono);
    font-size: 11px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    background: var(--gold);
    color: var(--bg);
    border: 1px solid var(--gold);
    border-radius: 2px;
    padding: 7px 16px;
    cursor: pointer;
    font-weight: 600;
  }
  .queue-bar .btn-queue-cancel {
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    background: transparent;
    color: var(--ink-mute);
    border: 1px solid var(--rule-strong);
    border-radius: 2px;
    padding: 6px 12px;
    cursor: pointer;
  }

  /* section heading */
  .section-heading {
    display: flex;
    align-items: baseline;
    gap: 12px;
    margin-bottom: 14px;
  }
  .section-heading h2 {
    font-family: var(--serif);
    font-size: 22px;
    font-weight: 500;
    color: var(--gold);
    margin: 0;
    letter-spacing: -0.01em;
    line-height: 1;
  }
  .section-heading span {
    font-family: var(--mono);
    font-size: 9px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--ink-mute);
  }
</style>
</head><body>
<div class="bg-orb orb-1"></div><div class="bg-orb orb-2"></div><div class="bg-orb orb-3"></div>

<header class="topbar glass">
  <div class="brand"><div class="logo-dot"></div>
    <div><div class="brand-name">VenturaClaw</div><div class="brand-sub">MY CONNECTIONS</div></div></div>
  <nav class="nav">
    <a href="/chat">Chat</a>
    <a class="active" href="/connections">My Connections</a>
    <a href="/connections/import" style="color:var(--gold);border-bottom:1px solid var(--gold)">Import</a>
    <a href="/brand">Brand</a>
    <a href="#" id="logout">Sign out</a>
  </nav>
</header>

<main style="padding:0 14px 14px;display:flex;flex-direction:column;gap:20px;max-width:920px;margin:0 auto">

  <!-- ── OAUTH ONE-CLICK SECTION ── -->
  <div class="glass" style="padding:18px 20px">
    <div class="section-heading">
      <h2>Connect with one click</h2>
      <span>OAuth 2.0</span>
    </div>
    <p style="font-size:13px;opacity:0.72;margin:0 0 16px;line-height:1.55">
      Grant VenturaClaw access to your accounts without pasting API keys.
      Tick the providers you want, hit <strong style="color:var(--ink)">Connect selected</strong>, and we'll walk you through each one in order — or click Connect on any single tile.
    </p>

    <!-- bulk select bar -->
    <div class="bulk-bar" id="bulk-bar" style="display:none">
      <label><input type="checkbox" id="bulk-select-all" /> Select all available</label>
      <span class="bulk-count" id="bulk-count">0 selected</span>
      <span class="bulk-spacer"></span>
      <button class="btn-bulk-go" id="bulk-go" disabled>Connect selected →</button>
    </div>

    <!-- queue progress bar (shown during bulk connect) -->
    <div class="queue-bar" id="queue-bar" style="display:none">
      <div>
        <div class="queue-msg" id="queue-msg">Ready to connect.</div>
        <div class="queue-progress" id="queue-progress"></div>
      </div>
      <span class="queue-spacer"></span>
      <button class="btn-queue-cancel" id="queue-cancel">Cancel queue</button>
      <button class="btn-queue-next" id="queue-next">Continue →</button>
    </div>

    <div id="oauth-grid">
      <!-- tiles injected by JS -->
      <div style="font-family:var(--mono);font-size:11px;color:var(--ink-mute);padding:8px 0" id="oauth-loading">Loading providers…</div>
    </div>
  </div>

  <!-- ── MANUAL TOKEN VAULT SECTION ── -->
  <div class="glass" style="padding:18px 20px">
    <div class="section-heading">
      <h2>Manual token vault</h2>
      <span>API keys</span>
    </div>
    <p style="font-size:13px;opacity:0.72;margin:0 0 6px;line-height:1.55">
      Paste your own API tokens for each tool. Sensitive actions still go through the admin approval queue,
      but executions use <strong style="color:var(--ink)">your</strong> credentials, not the server's.
    </p>
  </div>

  <div id="grid" style="display:flex;flex-direction:column;gap:14px"></div>
</main>

<div class="footer">VenturaClaw</div>

<script>
// ── OAuth-popup auto-close ──
// When this page loads inside an OAuth popup we opened ourselves (window.opener exists and the
// popup name matches `cc_oauth_<vendor>`), close the popup so the parent's queue can advance.
// We close on a tiny delay so the cookie/session write from the OAuth callback fully commits.
try {
  if (window.opener && !window.opener.closed && /^cc_oauth_/.test(window.name)) {
    setTimeout(() => { try { window.close(); } catch {} }, 200);
  }
} catch {}

// ── OAuth provider metadata (mirrors server OAUTH_PROVIDERS) ──
// icon: simpleicons slug; tint: brand hex used for icon tint
const OAUTH_PROVIDERS = [
  { id: 'google',    name: 'Google',    label: 'Sheets / Drive / Gmail', icon: 'google',    tint: '4285F4' },
  { id: 'slack',     name: 'Slack',     label: 'Chat & channels',        icon: 'slack',     tint: '4A154B' },
  { id: 'stripe',    name: 'Stripe',    label: 'Payments & Connect',     icon: 'stripe',    tint: '635BFF' },
  { id: 'notion',    name: 'Notion',    label: 'Docs & databases',       icon: 'notion',    tint: 'ffffff'  },
  { id: 'hubspot',   name: 'HubSpot',   label: 'CRM & contacts',         icon: 'hubspot',   tint: 'FF7A59' },
  { id: 'mailchimp', name: 'Mailchimp', label: 'Email marketing',        icon: 'mailchimp', tint: 'FFE01B' },
  { id: 'discord',   name: 'Discord',   label: 'Guilds & bots',          icon: 'discord',   tint: '5865F2' },
];

// ── State ──
let oauthFilledIds = new Set();
let newlyConnectedId = null;
// Selection set for bulk connect — vendor ids the user ticked.
let selectedIds = new Set();

// ── DOM refs ──
const oauthGrid = document.getElementById('oauth-grid');
const manualGrid = document.getElementById('grid');
const bulkBar = document.getElementById('bulk-bar');
const bulkSelectAll = document.getElementById('bulk-select-all');
const bulkCount = document.getElementById('bulk-count');
const bulkGo = document.getElementById('bulk-go');
const queueBar = document.getElementById('queue-bar');
const queueMsg = document.getElementById('queue-msg');
const queueProgress = document.getElementById('queue-progress');
const queueNext = document.getElementById('queue-next');
const queueCancel = document.getElementById('queue-cancel');

// ── Detect return from OAuth callback ──
// The server redirects back to /connections (no extra query param).
// We use sessionStorage to snapshot which OAuth vendors were connected BEFORE leaving,
// and compare on return to detect what's new.
function getSnapshot() {
  try { return new Set(JSON.parse(sessionStorage.getItem('cc_oauth_snapshot') || '[]')); } catch { return new Set(); }
}
function saveSnapshot(ids) {
  try { sessionStorage.setItem('cc_oauth_snapshot', JSON.stringify([...ids])); } catch {}
}

function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }

// ── Build OAuth tiles ──
function buildOAuthTile(provider, isConnected, isNew) {
  const tile = document.createElement('div');
  tile.className = 'oauth-tile' + (isConnected ? ' is-connected' : '') + (isNew ? ' flash-new' : '');
  tile.dataset.vendor = provider.id;

  const iconSlug = String(provider.icon || '').replace(/[^a-z0-9-]/gi,'');
  const tintClean = String(provider.tint || '').replace(/[^0-9a-fA-F]/g,'').slice(0,6);
  const iconUrl = `https://cdn.simpleicons.org/${iconSlug}/${tintClean}`;

  tile.innerHTML = `
    ${isConnected ? '' : `<input type="checkbox" class="oauth-tile-check" data-select="${esc(provider.id)}" title="Select for bulk connect" />`}
    <img class="oauth-tile-logo" src="${esc(iconUrl)}" alt="${esc(provider.name)} logo"
         onerror="this.style.display='none'" loading="lazy" />
    <div>
      <div class="oauth-tile-name">${esc(provider.name)}</div>
      <div class="oauth-tile-label">${esc(provider.label)}</div>
    </div>
    <div class="oauth-tile-footer">
      ${isConnected
        ? `<span class="oauth-connected-badge">&#10003;&nbsp;connected</span>
           <button class="btn-oauth" style="margin-left:auto;border-color:var(--bad);color:var(--bad)"
                   data-disconnect="${esc(provider.id)}">Revoke</button>`
        : `<button class="btn-oauth" data-connect="${esc(provider.id)}">Connect</button>`
      }
    </div>`;

  // Wire up Connect button
  const connectBtn = tile.querySelector('[data-connect]');
  if (connectBtn) {
    connectBtn.addEventListener('click', () => {
      // Save current snapshot before navigating so we can diff on return
      saveSnapshot(oauthFilledIds);
      // Open OAuth flow: server handles auth check + state CSRF + redirect to vendor
      window.open(`/oauth/${provider.id}/connect`, '_blank', 'width=600,height=700,noopener');
      // Poll for the popup closing, then refresh to pick up the new token
      const check = setInterval(() => {
        // We can't read popup.closed reliably for cross-origin flows, so
        // instead just re-check our connections after 3s and again at 8s
      }, 500);
      // Refresh once at 3s and 8s after click — covers fast and slow providers
      setTimeout(() => refresh(true), 3000);
      setTimeout(() => refresh(true), 8000);
    });
  }

  // Wire up Revoke button
  const revokeBtn = tile.querySelector('[data-disconnect]');
  if (revokeBtn) {
    revokeBtn.addEventListener('click', async () => {
      if (!confirm(`Remove your ${provider.name} OAuth token?`)) return;
      const r = await fetch(`/api/me/connections/${provider.id}`, { method: 'DELETE' });
      if (r.ok) {
        oauthFilledIds.delete(provider.id);
        saveSnapshot(oauthFilledIds);
        await refresh();
      }
    });
  }

  return tile;
}

function renderOAuthSection(connections) {
  // Build a set of filled vendor IDs from the connections list
  const filledMap = {};
  for (const c of connections) filledMap[c.id] = c.filled;

  const prevSnapshot = getSnapshot();
  oauthFilledIds = new Set();
  for (const p of OAUTH_PROVIDERS) {
    if (filledMap[p.id]) oauthFilledIds.add(p.id);
  }

  // Detect newly connected: in current set but not in snapshot
  newlyConnectedId = null;
  for (const id of oauthFilledIds) {
    if (!prevSnapshot.has(id)) { newlyConnectedId = id; break; }
  }

  // Update snapshot to reflect current state
  saveSnapshot(oauthFilledIds);

  oauthGrid.innerHTML = '';
  // Drop already-connected vendors from the selection set
  for (const id of [...selectedIds]) if (oauthFilledIds.has(id)) selectedIds.delete(id);

  for (const p of OAUTH_PROVIDERS) {
    const isConnected = oauthFilledIds.has(p.id);
    const isNew = p.id === newlyConnectedId;
    const tileEl = buildOAuthTile(p, isConnected, isNew);
    if (selectedIds.has(p.id)) tileEl.classList.add('is-selected');
    oauthGrid.appendChild(tileEl);
  }

  // Wire per-tile checkboxes
  oauthGrid.querySelectorAll('[data-select]').forEach(cb => {
    cb.checked = selectedIds.has(cb.dataset.select);
    cb.addEventListener('change', () => {
      const id = cb.dataset.select;
      if (cb.checked) selectedIds.add(id); else selectedIds.delete(id);
      const tile = cb.closest('.oauth-tile');
      tile.classList.toggle('is-selected', cb.checked);
      updateBulkBar();
    });
  });

  updateBulkBar();
  renderQueueBar();
}

// ── Bulk control bar ──
function availableForBulk() {
  return OAUTH_PROVIDERS.filter(p => !oauthFilledIds.has(p.id)).map(p => p.id);
}
function updateBulkBar() {
  const avail = availableForBulk();
  if (avail.length === 0) {
    bulkBar.style.display = 'none';
    return;
  }
  bulkBar.style.display = 'flex';
  const n = selectedIds.size;
  bulkCount.textContent = n === 0 ? `0 selected · ${avail.length} available`
                                  : `${n} selected · ${avail.length} available`;
  bulkGo.disabled = (n === 0) || queueIsActive();
  // Master checkbox state
  bulkSelectAll.checked = (n > 0 && n === avail.length);
  bulkSelectAll.indeterminate = (n > 0 && n < avail.length);
}
bulkSelectAll.addEventListener('change', () => {
  const avail = availableForBulk();
  if (bulkSelectAll.checked) {
    selectedIds = new Set(avail);
  } else {
    selectedIds = new Set();
  }
  // Re-render to reflect selection (cheap — same connection data)
  renderOAuthSection(_lastConnections || []);
});
bulkGo.addEventListener('click', () => {
  if (selectedIds.size === 0) return;
  startQueue([...selectedIds]);
});

// ── Sequential OAuth queue ──
// Each popup needs its own user gesture (Chrome consumes transient activation per popup),
// so we open #1 here on click, then show a Continue button to open #2, etc.
const QUEUE_KEY = 'cc_oauth_queue';
function loadQueue() {
  try { return JSON.parse(sessionStorage.getItem(QUEUE_KEY) || 'null'); } catch { return null; }
}
function saveQueue(q) {
  if (!q) sessionStorage.removeItem(QUEUE_KEY);
  else sessionStorage.setItem(QUEUE_KEY, JSON.stringify(q));
}
function queueIsActive() {
  const q = loadQueue();
  return !!(q && q.pending && q.pending.length > 0);
}
function providerName(id) {
  return (OAUTH_PROVIDERS.find(p => p.id === id) || {}).name || id;
}

function startQueue(ids) {
  // Filter out any already-connected (in case state changed)
  const pending = ids.filter(id => !oauthFilledIds.has(id));
  if (!pending.length) return;
  saveQueue({ pending, completed: [], started_at: Date.now() });
  // Open the first immediately on user click (transient activation present)
  openNextInQueue(true);
}

function openNextInQueue(isUserGesture) {
  const q = loadQueue();
  if (!q || !q.pending.length) {
    finishQueue();
    return;
  }
  const id = q.pending[0];
  // Skip if it became connected meanwhile
  if (oauthFilledIds.has(id)) {
    q.pending.shift();
    q.completed.push(id);
    saveQueue(q);
    renderQueueBar();
    refresh(true);
    return;
  }
  // Save snapshot so the OAuth-return detection works
  saveSnapshot(oauthFilledIds);
  const popup = window.open(`/oauth/${id}/connect`, `cc_oauth_${id}`, 'width=600,height=700');
  if (!popup) {
    queueMsg.textContent = `Pop-up blocked. Allow pop-ups for this site, then click Continue.`;
    return;
  }
  // Mark this id as in-flight; we leave it at the head of pending until we observe it connected
  // or the user clicks "Skip"
  renderQueueBar();
  // Poll until the popup is closed; then refresh and either advance or wait for user click
  const poll = setInterval(() => {
    let closed = false;
    try { closed = popup.closed; } catch { closed = true; }
    if (closed) {
      clearInterval(poll);
      // Allow callback redirect to settle, then refresh
      setTimeout(async () => {
        await refresh(true);
        const q2 = loadQueue();
        if (!q2) return;
        if (oauthFilledIds.has(id)) {
          q2.pending.shift();
          q2.completed.push(id);
          saveQueue(q2);
          renderQueueBar();
          // Still pending? Show Continue button (need new user gesture to open next popup).
          if (q2.pending.length === 0) finishQueue();
        } else {
          // Connect didn't complete (user closed early). Keep id at head; let user click Continue
          // to retry, or Skip via cancel-then-deselect.
          renderQueueBar(`Didn't finish ${providerName(id)}. Click Continue to retry, or Cancel.`);
        }
      }, 800);
    }
  }, 500);
}

function renderQueueBar(overrideMsg) {
  const q = loadQueue();
  if (!q || !q.pending.length) {
    queueBar.style.display = 'none';
    return;
  }
  queueBar.style.display = 'flex';
  const total = q.pending.length + q.completed.length;
  const done = q.completed.length;
  const nextId = q.pending[0];
  queueMsg.textContent = overrideMsg || `Next up: ${providerName(nextId)} — click Continue to open the OAuth window.`;
  queueProgress.textContent = `${done}/${total} done · ${q.pending.length} remaining`;
  queueNext.textContent = `Continue → ${providerName(nextId)}`;
}

function finishQueue() {
  saveQueue(null);
  queueBar.style.display = 'none';
  selectedIds = new Set();
  updateBulkBar();
}

queueNext.addEventListener('click', () => openNextInQueue(true));
queueCancel.addEventListener('click', () => {
  if (!confirm('Cancel the connect queue? Already-connected providers stay connected.')) return;
  finishQueue();
});

// ── Manual connector tile ──
function tile(c) {
  const el = document.createElement('div');
  el.className = 'glass';
  el.style.padding = '18px';
  const docsHref = (typeof c.docsUrl === 'string' && /^https?:\/\//.test(c.docsUrl)) ? c.docsUrl : '#';
  el.innerHTML = `
    <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:14px;margin-bottom:14px">
      <div>
        <div class="brand-sub">${esc(c.category)}${c.via_mcp ? ' · 🔵 VIA MCP' : ''}</div>
        <div style="font-size:18px;font-weight:600;margin-top:4px">${esc(c.name)}</div>
        <div style="display:flex;gap:10px;font-size:11px;opacity:0.7;margin-top:2px">
          <a href="${esc(docsHref)}" target="_blank" rel="noopener">docs ↗</a>
          ${c.get_key_url ? `<a href="${esc(c.get_key_url)}" target="_blank" rel="noopener" style="color:var(--gold)">Get key ↗</a>` : ''}
          ${c.filled ? `<button data-skills="${esc(c.id)}" style="background:none;border:none;color:var(--gold);cursor:pointer;font-family:var(--mono);font-size:10px;letter-spacing:.10em;padding:0">Skills ▼</button>` : ''}
        </div>
        ${c.get_key_steps && !c.filled ? `<div style="font-size:11px;color:var(--ink-soft);margin-top:8px;line-height:1.5">${esc(c.get_key_steps)}</div>` : ''}
        <div class="skills-panel" data-skills-for="${esc(c.id)}" style="display:none;margin-top:8px;padding:8px;background:rgba(244,241,234,0.04);border:1px solid var(--rule);border-radius:2px"></div>
      </div>
      <div style="display:flex;gap:8px;align-items:center">
        <span class="badge ${c.filled?'badge-approved':'badge-pending'}">${c.filled?(c.via_mcp?'🔵 mcp':'connected'):'not connected'}</span>
        <button class="btn btn-ghost" data-test="${esc(c.id)}">Test</button>
        ${c.filled?`<button class="btn btn-bad" data-disconnect="${esc(c.id)}">Disconnect</button>`:''}
      </div>
    </div>
    <form data-id="${esc(c.id)}" style="display:grid;gap:10px">
      ${(c.fields || []).map(f => `
        <label style="display:flex;flex-direction:column;gap:4px">
          <span style="font-size:11px;color:var(--ink-mute);text-transform:uppercase;letter-spacing:0.10em">${esc(f.label)}${f.required?' *':''}</span>
          <input name="${esc(f.key)}" type="${f.type==='password'?'password':'text'}" placeholder="${esc(c.filled ? (c.filled_fields?.[f.key] || '•••') : (f.hint || ''))}" autocomplete="off" />
          ${f.hint?`<span style="font-size:10px;opacity:0.55">${esc(f.hint)}</span>`:''}
        </label>`).join('')}
      <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:6px">
        <button type="submit" class="btn">${c.filled?'Update':'Connect'}</button>
      </div>
      <div class="health-result" role="status" aria-live="polite" style="font-size:12px;opacity:0.85;margin-top:4px"></div>
    </form>`;
  return el;
}

// ── Anthropic banner + MCP discovery ──
async function renderAnthropicBanner() {
  const me = await (await fetch('/api/anthropic/me')).json();
  let host = document.getElementById('anthropic-banner');
  if (!host) {
    host = document.createElement('div');
    host.id = 'anthropic-banner';
    host.style.cssText = 'margin:0 0 18px;padding:18px;border:1px solid var(--gold);border-radius:4px;background:rgba(212,160,74,0.06);display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap';
    const main = document.querySelector('main');
    if (main) main.insertBefore(host, main.firstChild);
  }
  if (me.connected) {
    host.innerHTML = `
      <div>
        <div style="font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--good)">✓ ANTHROPIC CONNECTED · …${esc(me.last4 || '')}</div>
        <div style="font-size:13px;margin-top:4px">Claude is signed in. Auto-discover any MCP servers you have configured locally.</div>
      </div>
      <div style="display:flex;gap:8px">
        <button class="btn" id="discover-mcps">Discover my MCPs</button>
        <button class="btn btn-primary" id="auto-import">Auto-import all tokens</button>
      </div>`;
    host.querySelector('#discover-mcps').addEventListener('click', discoverMcps);
    host.querySelector('#auto-import').addEventListener('click', autoImport);
  } else {
    host.innerHTML = `
      <div>
        <div style="font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--gold)">⚡ CONNECT CLAUDE TO AUTO-IMPORT YOUR MCPs</div>
        <div style="font-size:13px;margin-top:4px">Drop your Anthropic API key and VenturaClaw will auto-discover the MCP servers you've already wired in Claude Code.</div>
      </div>
      <div style="display:flex;gap:8px;align-items:center">
        <a class="btn" href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener">Get key ↗</a>
        <input id="ant-key" type="password" placeholder="sk-ant-..." style="font-family:var(--mono);font-size:12px;width:240px" />
        <button class="btn btn-primary" id="ant-save">Connect</button>
      </div>`;
    host.querySelector('#ant-save').addEventListener('click', async () => {
      const key = host.querySelector('#ant-key').value.trim();
      if (!key) return;
      const r = await fetch('/api/me/connections/anthropic', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ ANTHROPIC_API_KEY: key }) });
      if (r.ok) renderAnthropicBanner();
      else alert((await r.json()).error || 'failed');
    });
  }
}
let DISCOVERED_MCPS = new Set(); // bc_connector ids that are MCP-mapped
async function discoverMcps() {
  const r = await fetch('/api/anthropic/discover-mcps', { method: 'POST', headers: {'content-type':'application/json'}, body: '{}' });
  const d = await r.json();
  if (!r.ok) return alert(d.error + ': ' + (d.hint || ''));
  DISCOVERED_MCPS = new Set((d.mcps || []).filter(m => m.bc_connector).map(m => m.bc_connector));
  alert(`Discovered ${d.discovered} MCP servers · ${d.mapped} map to VenturaClaw connectors · ${d.already_in_bc} already have tokens loaded.`);
  refresh();
}
async function autoImport() {
  if (!confirm('Auto-import all token values from your local secrets-manager + MCP config into your VenturaClaw account?')) return;
  const r = await fetch('/api/anthropic/auto-import', { method: 'POST' });
  const d = await r.json();
  if (!r.ok) return alert(d.error + (d.hint ? ': ' + d.hint : ''));
  alert(`Imported ${d.imported} connectors · ${d.skipped} skipped (no value found).`);
  refresh();
}

// ── Main refresh ──
let _lastConnections = [];
async function refresh(silent) {
  if (!silent) renderAnthropicBanner();
  const d = await (await fetch('/api/me/connections')).json();

  // Mark MCP-discovered connectors with a badge
  for (const c of d.connections) c.via_mcp = DISCOVERED_MCPS.has(c.id);

  _lastConnections = d.connections;

  // Render OAuth section first
  renderOAuthSection(d.connections);

  // Render manual tiles
  manualGrid.innerHTML = '';
  for (const c of d.connections) manualGrid.appendChild(tile(c));

  // Wire skill dropdowns (per-tile [Skills ▼])
  document.querySelectorAll('[data-skills]').forEach(b => b.addEventListener('click', async () => {
    const id = b.dataset.skills;
    const panel = document.querySelector(`[data-skills-for="${id}"]`);
    if (panel.style.display === 'block') { panel.style.display = 'none'; return; }
    panel.innerHTML = '<div style="font-family:var(--mono);font-size:10px;color:var(--ink-mute)">Loading actions…</div>';
    panel.style.display = 'block';
    try {
      const a = await (await fetch(`/api/connectors/${id}/actions`)).json();
      const reads  = (a.reads  || []).map(act => `<button class="btn btn-ghost" style="font-size:10px;padding:6px 10px;margin:2px" data-run="${id}.${act}" data-write="false">${act}</button>`).join('');
      const writes = (a.writes || []).map(act => `<button class="btn btn-bad"  style="font-size:10px;padding:6px 10px;margin:2px" data-run="${id}.${act}" data-write="true">${act} ⚠</button>`).join('');
      panel.innerHTML = `
        <div style="font-family:var(--mono);font-size:9px;letter-spacing:.14em;color:var(--good);margin-bottom:4px">READS (auto-run)</div>${reads || '<span style="opacity:0.5;font-size:11px">none</span>'}
        <div style="font-family:var(--mono);font-size:9px;letter-spacing:.14em;color:var(--bad);margin:8px 0 4px">WRITES (queue for approval)</div>${writes || '<span style="opacity:0.5;font-size:11px">none</span>'}
        <div class="skill-result" style="margin-top:8px;font-family:var(--mono);font-size:11px"></div>`;
      panel.querySelectorAll('[data-run]').forEach(rb => rb.addEventListener('click', async () => {
        const [cid, ...act] = rb.dataset.run.split('.');
        const action = act.join('.');
        const r = await fetch(`/api/connectors/${cid}/run`, { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ action, input: {} }) });
        const j = await r.json();
        const out = panel.querySelector('.skill-result');
        if (r.ok) out.innerHTML = `<span style="color:var(--good)">✓ ${action}</span> — ${esc(JSON.stringify(j.result || j).slice(0, 200))}`;
        else if (r.status === 409 && j.code === 'approval_required') out.innerHTML = `<span style="color:var(--warn)">⏳ ${action} — approval-gated. Re-run with force OR via /admin/approvals.</span>`;
        else out.innerHTML = `<span style="color:var(--bad)">✗ ${action} — ${esc(j.error || 'failed')}</span>`;
      }));
    } catch (e) { panel.innerHTML = `<span style="color:var(--bad)">${e.message}</span>`; }
  }));

  // Wire manual forms
  document.querySelectorAll('form[data-id]').forEach(f => {
    f.addEventListener('submit', async e => {
      e.preventDefault();
      const id = f.dataset.id;
      const fd = new FormData(f);
      const body = {};
      for (const [k,v] of fd.entries()) if (v && v.trim()) body[k] = v.trim();
      if (!Object.keys(body).length) { setResult(f, 'Enter at least one field.', false); return; }
      const r = await fetch(`/api/me/connections/${id}`, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
      if (r.ok) { setResult(f, 'Saved. Testing connection…', true); await testConn(id, f); refresh(); }
      else { const e = await r.json(); setResult(f, e.error || 'Save failed', false); }
    });
  });

  document.querySelectorAll('[data-test]').forEach(b => b.addEventListener('click', async () => {
    const f = document.querySelector(`form[data-id="${b.dataset.test}"]`);
    await testConn(b.dataset.test, f);
  }));

  document.querySelectorAll('[data-disconnect]').forEach(b => b.addEventListener('click', async () => {
    if (!confirm('Remove your saved tokens for this connector?')) return;
    await fetch(`/api/me/connections/${b.dataset.disconnect}`, { method: 'DELETE' });
    refresh();
  }));
}

async function testConn(id, formEl) {
  const r = await fetch(`/api/connectors/${id}/health`);
  const d = await r.json();
  if (d.ok) {
    const detail = d.team ? `team=${d.team}` : d.account_id ? `account=${d.account_id}` : d.status ? `token=${d.status}` : 'verified';
    setResult(formEl, `✓ Connected · ${detail}`, true);
  } else {
    setResult(formEl, `✗ ${d.reason || 'failed'}`, false);
  }
}

function setResult(formEl, msg, ok) {
  const el = formEl.querySelector('.health-result');
  el.textContent = msg;
  el.style.color = ok ? 'var(--good)' : 'var(--bad)';
}

document.getElementById('logout').addEventListener('click', async e => {
  e.preventDefault(); await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login';
});

// Initial load — remove the loading placeholder once first render happens
refresh().then(() => {
  const loading = document.getElementById('oauth-loading');
  if (loading) loading.remove();
  // Resume any in-flight bulk-connect queue (e.g. user refreshed mid-flow)
  renderQueueBar();
});
</script>
</body></html>