← 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
// ── 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">✓ 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>