← back to Ventura Claw

server/public/admin-wizard.html

399 lines

<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Connector Wizard — VenturaClaw</title>
<style>
  :root {
    --bg:#0d0d0e; --bg-2:#161618; --bg-3:#1f1f22;
    --ink:#e9e9eb; --ink-mute:#8a8a92; --ink-faint:#5a5a62;
    --rule:#2a2a2e; --gold:#d4a04a; --good:#8fb89a; --bad:#c87a6e;
    --serif: ui-serif, Georgia, "Times New Roman", serif;
    --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
    --sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
  }
  * { box-sizing: border-box; }
  html, body { margin:0; padding:0; background:var(--bg); color:var(--ink); font-family:var(--sans); min-height:100vh; }
  a { color:var(--gold); text-decoration:none; } a:hover { text-decoration:underline; }
  header { display:flex; align-items:center; gap:14px; padding:14px 22px; border-bottom:1px solid var(--rule); background:var(--bg-2); }
  header h1 { margin:0; font-family:var(--serif); font-size:20px; font-weight:500; }
  header h1 em { font-style:italic; color:var(--gold); }
  header .crumbs { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--ink-mute); }
  header .crumbs a { color:var(--ink-mute); }
  main { max-width:680px; margin:60px auto; padding:0 22px; }

  .progress-wrap { margin-bottom:32px; }
  .progress-bar { height:4px; background:var(--bg-3); border-radius:2px; overflow:hidden; }
  .progress-fill { height:100%; background:var(--gold); transition:width 240ms ease; }
  .progress-text { display:flex; justify-content:space-between; margin-top:8px; font-family:var(--mono); font-size:11px; color:var(--ink-mute); letter-spacing:0.10em; text-transform:uppercase; }

  .card {
    background:var(--bg-2); border:1px solid var(--rule); border-radius:6px;
    padding:32px; min-height:320px; display:flex; flex-direction:column;
  }
  .card .step-tag { font-family:var(--mono); font-size:10px; letter-spacing:0.18em; text-transform:uppercase; color:var(--gold); margin-bottom:14px; }
  .card h2 { margin:0 0 6px 0; font-family:var(--serif); font-size:32px; font-weight:500; line-height:1.15; }
  .card h2 em { font-style:italic; color:var(--gold); }
  .card .category { font-family:var(--mono); font-size:11px; color:var(--ink-mute); letter-spacing:0.10em; text-transform:uppercase; margin-bottom:24px; }
  .card .question { font-size:16px; line-height:1.5; color:var(--ink); margin-bottom:20px; }
  .card .hint { font-family:var(--mono); font-size:11px; line-height:1.6; color:var(--ink-mute); margin-bottom:6px; }
  .card .hint strong { color:var(--gold); font-weight:500; }
  .card .help-link { font-family:var(--mono); font-size:11px; margin-bottom:18px; }

  .field { display:flex; flex-direction:column; gap:6px; margin-bottom:14px; }
  .field label { font-family:var(--mono); font-size:11px; color:var(--ink-mute); letter-spacing:0.06em; }
  .field input {
    background:var(--bg-3); color:var(--ink); border:1px solid var(--rule);
    border-radius:3px; padding:11px 14px; font-family:var(--mono); font-size:13px;
  }
  .field input:focus { outline:none; border-color:var(--gold); }

  .actions { display:flex; gap:10px; margin-top:auto; padding-top:20px; }
  .btn {
    flex:1; padding:12px 18px; border-radius:3px; cursor:pointer;
    font-family:var(--mono); font-size:11px; letter-spacing:0.12em; text-transform:uppercase;
    background:transparent; border:1px solid var(--rule); color:var(--ink-mute);
    transition: background 120ms, color 120ms, border-color 120ms;
  }
  .btn:hover { color:var(--ink); border-color:var(--ink-mute); }
  .btn-primary { background:var(--gold); color:var(--bg); border-color:var(--gold); font-weight:600; flex:1.4; }
  .btn-primary:hover { filter:brightness(1.08); color:var(--bg); border-color:var(--gold); }
  .btn-skip { color:var(--ink-faint); }
  .btn-vault { color:var(--gold); border-color:var(--gold); }
  .btn-vault:hover { background:rgba(212,160,74,0.10); color:var(--gold); }

  .toast { position:fixed; top:24px; right:24px; padding:12px 18px; border-radius:3px; font-family:var(--mono); font-size:12px; box-shadow:0 6px 24px rgba(0,0,0,0.4); display:none; z-index:50; }
  .toast.ok  { background:rgba(143,184,154,0.18); border:1px solid var(--good); color:var(--good); }
  .toast.bad { background:rgba(200,122,110,0.18); border:1px solid var(--bad); color:var(--bad); }

  /* Test-result inline panel (between fields and actions) */
  .test-result {
    margin: 4px 0 14px 0; padding: 10px 14px; border-radius: 3px;
    font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em;
    display: none; line-height: 1.5; word-break: break-word;
  }
  .test-result.ok      { background: rgba(143,184,154,0.10); border: 1px solid var(--good);   color: var(--good); }
  .test-result.skip    { background: rgba(212,160,74,0.06);  border: 1px solid var(--ink-faint); color: var(--ink-mute); }
  .test-result.bad     { background: rgba(200,122,110,0.12); border: 1px solid var(--bad);    color: var(--bad); }
  .test-result.testing { background: var(--bg-3); border: 1px solid var(--rule); color: var(--ink-mute); }
  .test-result strong { font-weight: 600; }
  .btn[disabled] { opacity: 0.5; cursor: not-allowed; }

  .done-card { text-align:center; padding:48px 32px; }
  .done-card .checkmark { font-size:64px; color:var(--good); margin-bottom:16px; }
  .done-card .stat { font-family:var(--serif); font-size:36px; color:var(--gold); margin:18px 0 8px 0; }
  .done-card .stat-label { font-family:var(--mono); font-size:11px; color:var(--ink-mute); letter-spacing:0.14em; text-transform:uppercase; }

  /* Health strip — bulk-test view of every filled connector */
  .health-strip {
    margin-bottom: 22px; padding: 14px 18px;
    background: var(--bg-2); border: 1px solid var(--rule); border-radius: 4px;
  }
  .health-strip-header {
    display: flex; align-items: center; gap: 10px; margin-bottom: 10px;
    font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-mute);
  }
  .health-strip-header .retest {
    margin-left: auto; padding: 5px 12px; border-radius: 2px; cursor: pointer;
    font-family: var(--mono); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase;
    background: transparent; color: var(--gold); border: 1px solid var(--gold);
  }
  .health-strip-header .retest:hover { background: rgba(212,160,74,0.10); }
  .health-pills { display: flex; flex-wrap: wrap; gap: 6px; }
  .health-pill {
    padding: 5px 10px; border-radius: 12px; font-family: var(--mono); font-size: 11px;
    border: 1px solid var(--rule); display: inline-flex; align-items: center; gap: 6px;
    cursor: pointer; transition: filter 120ms ease;
  }
  .health-pill:hover { filter: brightness(1.15); }
  .health-pill.ok       { background: rgba(143,184,154,0.10); border-color: var(--good);   color: var(--good); }
  .health-pill.skipped  { background: rgba(212,160,74,0.06);  border-color: var(--ink-faint); color: var(--ink-mute); }
  .health-pill.bad      { background: rgba(200,122,110,0.12); border-color: var(--bad);    color: var(--bad); }
  .health-pill.testing  { background: var(--bg-3); color: var(--ink-mute); }
  .health-pill .pill-icon { font-weight: 700; }
</style>
</head><body>

<header>
  <h1>Connector <em>Wizard</em></h1>
  <div class="crumbs"><a href="/admin">admin</a> · <a href="/admin/connectors">connectors</a> · wizard</div>
</header>

<main>
  <div class="progress-wrap">
    <div class="progress-bar"><div class="progress-fill" id="bar" style="width:0%"></div></div>
    <div class="progress-text"><span id="pos">0/0</span><span id="filled">0 connected</span></div>
  </div>

  <div class="health-strip" id="health-strip" style="display:none">
    <div class="health-strip-header">
      <span>✦ Already saved · live health</span>
      <span id="health-summary" style="color:var(--ink-faint);text-transform:none;letter-spacing:0">…</span>
      <button class="retest" id="health-retest">Re-test</button>
    </div>
    <div class="health-pills" id="health-pills"></div>
  </div>

  <div id="card" class="card">
    <div class="step-tag" id="step-tag">loading…</div>
    <h2 id="conn-name"></h2>
    <div class="category" id="conn-category"></div>
    <div class="question" id="question"></div>
    <div id="fields"></div>
    <div class="help-link" id="help-link"></div>
    <div class="test-result" id="test-result"></div>
    <div class="actions">
      <button class="btn btn-skip" id="btn-skip">Skip</button>
      <button class="btn btn-vault" id="btn-vault" title="Try to find this key in the secrets-manager vault">🔍 Search Vault</button>
      <button class="btn" id="btn-test" title="Save then validate against the provider's API">Test</button>
      <button class="btn btn-primary" id="btn-save">Save & Next →</button>
    </div>
  </div>
</main>

<div id="toast" class="toast"></div>

<script>
const $ = s => document.querySelector(s);
const esc = s => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');

let allConnectors = [];      // unfilled, in display order
let totalCatalog = 0;
let cursor = 0;

function toast(msg, isError) {
  const t = $('#toast');
  t.textContent = msg;
  t.className = 'toast ' + (isError ? 'bad' : 'ok');
  t.style.display = 'block';
  clearTimeout(toast._t);
  toast._t = setTimeout(() => t.style.display = 'none', 3500);
}

let allFilled = []; // connections that already have credentials saved (for the health strip)

async function load() {
  const r = await fetch('/api/me/connections');
  const d = await r.json();
  totalCatalog = d.connections.length;
  allConnectors = d.connections.filter(c => !c.filled);
  allFilled = d.connections.filter(c => c.filled);
  // Already-connected count
  const filled = totalCatalog - allConnectors.length;
  $('#filled').textContent = filled + ' / ' + totalCatalog + ' connected';
  // Show health strip if any are saved
  if (allFilled.length) {
    $('#health-strip').style.display = 'block';
    renderHealthStrip();           // initial pass: testing… state
    refreshHealth();                // hit /api/connectors/health-all in background
  }
  if (!allConnectors.length) return showDone(filled, totalCatalog);
  render();
}

function renderHealthStrip(healthData) {
  const wrap = $('#health-pills');
  wrap.innerHTML = '';
  for (const c of allFilled) {
    const h = healthData ? healthData[c.id] : undefined;
    let cls = 'testing', icon = '…', title = 'testing…';
    if (healthData) {
      // /health-all only probes real-impl connectors; absence means catalog-only
      if (!h) { cls = 'skipped'; icon = '⊘'; title = 'catalog-only — no test handler yet'; }
      else if (h.ok) { cls = 'ok'; icon = '✓'; title = h.label || h.account || h.business || h.email || 'authenticated'; }
      else if (h.reason === 'not_implemented') { cls = 'skipped'; icon = '⊘'; title = 'no test handler — credentials stored'; }
      else { cls = 'bad'; icon = '✗'; title = h.reason || h.error || 'failed'; }
    }
    const pill = document.createElement('span');
    pill.className = 'health-pill ' + cls;
    pill.title = c.name + ' · ' + title;
    pill.innerHTML = '<span class="pill-icon">' + icon + '</span> ' + esc(c.name);
    pill.addEventListener('click', () => {
      // Click pill → push that connector to the queue at the current cursor and re-render
      if (!allConnectors.find(x => x.id === c.id)) {
        allConnectors.splice(cursor, 0, c);
      }
      render();
    });
    wrap.appendChild(pill);
  }
  if (healthData) {
    const ok = allFilled.filter(c => healthData[c.id]?.ok).length;
    const skipped = allFilled.filter(c => !healthData[c.id] || healthData[c.id]?.reason === 'not_implemented').length;
    const bad = allFilled.length - ok - skipped;
    $('#health-summary').textContent = ok + ' authenticate · ' + skipped + ' no test handler · ' + bad + ' rejected';
  }
}

async function refreshHealth() {
  $('#health-summary').textContent = 'testing all…';
  try {
    const r = await fetch('/api/connectors/health-all');
    const j = await r.json();
    renderHealthStrip(j.data || {});
  } catch (e) {
    $('#health-summary').textContent = 'health probe failed: ' + e.message;
  }
}

function render() {
  const c = allConnectors[cursor];
  if (!c) return showDone(totalCatalog - allConnectors.length + cursor, totalCatalog);
  // Reset test-result panel each step
  const tr = $('#test-result'); if (tr) { tr.style.display = 'none'; tr.className = 'test-result'; tr.innerHTML = ''; }
  $('#step-tag').textContent = 'STEP ' + (cursor + 1) + ' OF ' + allConnectors.length;
  $('#conn-name').innerHTML = esc(c.name).replace(/\s+(\w+)$/, ' <em>$1</em>');
  $('#conn-category').textContent = c.category || '—';
  $('#question').textContent = 'Do you have a ' + c.name + ' API key or token?';
  // Build fields
  const fwrap = $('#fields');
  fwrap.innerHTML = '';
  for (const f of c.fields || []) {
    const wrap = document.createElement('div');
    wrap.className = 'field';
    wrap.innerHTML = `
      <label>${esc(f.label)}${f.required ? ' *' : ''}</label>
      <input type="${f.type === 'password' ? 'password' : 'text'}" id="f-${esc(f.key)}" placeholder="${esc(f.hint || '')}" autocomplete="off" spellcheck="false" />
    `;
    fwrap.appendChild(wrap);
  }
  // Help link
  const help = $('#help-link');
  if (c.get_key_url || c.acquisition?.get_key_url) {
    const url = c.get_key_url || c.acquisition.get_key_url;
    help.innerHTML = '<strong>Get a key:</strong> <a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(url) + '</a>';
    if (c.get_key_steps) help.innerHTML += '<div style="margin-top:6px;color:var(--ink-faint)">' + esc(c.get_key_steps) + '</div>';
  } else {
    help.innerHTML = '';
  }
  // Progress
  const pct = ((totalCatalog - allConnectors.length + cursor) / totalCatalog) * 100;
  $('#bar').style.width = pct + '%';
  $('#pos').textContent = (cursor + 1) + ' / ' + allConnectors.length + ' to go';
  // Focus first field
  setTimeout(() => fwrap.querySelector('input')?.focus(), 50);
}

function showDone(filled, total) {
  $('#card').className = 'card done-card';
  $('#card').innerHTML = `
    <div class="checkmark">✓</div>
    <div style="font-family:var(--serif);font-size:28px">All caught up</div>
    <div class="stat">${filled} / ${total}</div>
    <div class="stat-label">Connectors active</div>
    <div style="margin-top:24px;font-family:var(--mono);font-size:11px;color:var(--ink-mute)">
      <a href="/admin/connectors">→ View connectors page</a>
    </div>
  `;
  $('#bar').style.width = '100%';
  $('#pos').textContent = 'done';
}

$('#btn-skip').addEventListener('click', () => {
  cursor++;
  render();
});

function collectFields(c) {
  const body = {};
  let any = false;
  for (const f of c.fields || []) {
    const el = $('#f-' + f.key);
    if (el && el.value.trim()) { body[f.key] = el.value.trim(); any = true; }
  }
  return { body, any };
}

function setTestResult(state, html) {
  const el = $('#test-result');
  el.className = 'test-result ' + state;
  el.innerHTML = html;
  el.style.display = 'block';
}

async function saveCurrent() {
  const c = allConnectors[cursor];
  const { body, any } = collectFields(c);
  if (!any) { toast('Paste a key or hit Skip', true); return null; }
  const r = await fetch('/api/me/connections/' + encodeURIComponent(c.id), {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const j = await r.json();
  if (!j.ok) {
    toast('✗ ' + (j.error || 'save failed'), true);
    return null;
  }
  return c;
}

$('#btn-save').addEventListener('click', async () => {
  const saved = await saveCurrent();
  if (!saved) return;
  toast('✓ ' + saved.name + ' saved');
  cursor++;
  render();
});

$('#btn-test').addEventListener('click', async () => {
  const c = allConnectors[cursor];
  $('#btn-test').disabled = true; $('#btn-save').disabled = true;
  setTestResult('testing', 'Saving + testing against ' + esc(c.name) + '…');
  const saved = await saveCurrent();
  if (!saved) {
    setTestResult('bad', '<strong>Save failed.</strong> Fix above and try again.');
    $('#btn-test').disabled = false; $('#btn-save').disabled = false;
    return;
  }
  // Now hit the health endpoint
  try {
    const r = await fetch('/api/connectors/' + encodeURIComponent(c.id) + '/health');
    const h = await r.json();
    if (h.ok) {
      setTestResult('ok', '<strong>✓ ' + esc(c.name) + ' authenticates cleanly.</strong> ' + esc(h.label || h.account || h.email || ''));
      // Auto-advance after 1.2s on success
      setTimeout(() => { cursor++; render(); $('#btn-test').disabled = false; $('#btn-save').disabled = false; }, 1200);
    } else if (h.reason === 'not_implemented') {
      setTestResult('skip', '<strong>Saved.</strong> No test handler for ' + esc(c.name) + ' yet — credentials stored, will be used once a handler is wired.');
      $('#btn-test').disabled = false; $('#btn-save').disabled = false;
    } else {
      setTestResult('bad', '<strong>✗ ' + esc(c.name) + ' rejected.</strong> ' + esc(h.reason || h.error || 'unknown') + (h.hint ? ' · ' + esc(h.hint) : ''));
      $('#btn-test').disabled = false; $('#btn-save').disabled = false;
    }
  } catch (e) {
    setTestResult('bad', '<strong>✗ Test crashed.</strong> ' + esc(e.message));
    $('#btn-test').disabled = false; $('#btn-save').disabled = false;
  }
});

$('#btn-vault').addEventListener('click', async () => {
  const c = allConnectors[cursor];
  // Map connector id → likely env-var names + try the secrets-manager vault.
  const r = await fetch('/api/wizard/vault-search?connector=' + encodeURIComponent(c.id));
  const j = await r.json();
  if (j.found && j.fields) {
    let any = false;
    for (const [k, v] of Object.entries(j.fields)) {
      const el = $('#f-' + k);
      if (el) { el.value = v; any = true; }
    }
    toast(any ? '✓ Found ' + Object.keys(j.fields).length + ' field(s) — review then save' : '✗ no matching fields', !any);
  } else {
    toast('✗ ' + (j.message || 'not in vault'), true);
  }
});

$('#health-retest').addEventListener('click', refreshHealth);

// Cmd/Ctrl-Enter to save
document.addEventListener('keydown', e => {
  if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
    e.preventDefault();
    $('#btn-save').click();
  }
});

load();
</script>
</body></html>