[object Object]

← back to Ventura Claw

Connector wizard: one-question-at-a-time onboarding at /admin/wizard

7334fa7d1771672a0f6a6e46bd2b3a1a481bf3c4 · 2026-05-06 16:42:45 -0700 · SteveStudio2

Replaces the wall-of-tiles connector page with a guided wizard. Walks Steve
through every unfilled connector one at a time, with paste/skip/save buttons
and a 'Search Vault' button that auto-fills from secrets-manager .env on
Mac dev (gracefully no-ops on Kamatera prod where the vault isn't present).

- New GET /admin/wizard serves the single-page wizard
- New GET /api/wizard/vault-search?connector=:id inverts ENV_KEY_TO_CONNECTOR
  and looks up matching env values from $VC_VAULT_PATH or
  ~/Projects/secrets-manager/.env. Admin-gated; never proxies arbitrary paths.
- Cmd/Ctrl-Enter to save+next; progress bar shows N/M filled.
- Wizard fetches /api/me/connections, filters to .filled==false,
  uses existing POST /api/me/connections/:id to save each.

Mirrored to Kamatera /root/public-projects/ventura-claw with backups.

Files touched

Diff

commit 7334fa7d1771672a0f6a6e46bd2b3a1a481bf3c4
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 16:42:45 2026 -0700

    Connector wizard: one-question-at-a-time onboarding at /admin/wizard
    
    Replaces the wall-of-tiles connector page with a guided wizard. Walks Steve
    through every unfilled connector one at a time, with paste/skip/save buttons
    and a 'Search Vault' button that auto-fills from secrets-manager .env on
    Mac dev (gracefully no-ops on Kamatera prod where the vault isn't present).
    
    - New GET /admin/wizard serves the single-page wizard
    - New GET /api/wizard/vault-search?connector=:id inverts ENV_KEY_TO_CONNECTOR
      and looks up matching env values from $VC_VAULT_PATH or
      ~/Projects/secrets-manager/.env. Admin-gated; never proxies arbitrary paths.
    - Cmd/Ctrl-Enter to save+next; progress bar shows N/M filled.
    - Wizard fetches /api/me/connections, filters to .filled==false,
      uses existing POST /api/me/connections/:id to save each.
    
    Mirrored to Kamatera /root/public-projects/ventura-claw with backups.
---
 server/public/admin-wizard.html | 242 ++++++++++++++++++++++++++++++++++++++++
 server/server.js                |  41 +++++++
 2 files changed, 283 insertions(+)

diff --git a/server/public/admin-wizard.html b/server/public/admin-wizard.html
new file mode 100644
index 0000000..40aae97
--- /dev/null
+++ b/server/public/admin-wizard.html
@@ -0,0 +1,242 @@
+<!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); }
+
+  .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; }
+</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 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="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 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);
+}
+
+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);
+  // Already-connected count
+  const filled = totalCatalog - allConnectors.length;
+  $('#filled').textContent = filled + ' / ' + totalCatalog + ' connected';
+  if (!allConnectors.length) return showDone(filled, totalCatalog);
+  render();
+}
+
+function render() {
+  const c = allConnectors[cursor];
+  if (!c) return showDone(totalCatalog - allConnectors.length + cursor, totalCatalog);
+  $('#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">' + 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();
+});
+
+$('#btn-save').addEventListener('click', async () => {
+  const c = allConnectors[cursor];
+  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; }
+  }
+  if (!any) {
+    toast('Paste a key or hit Skip', true);
+    return;
+  }
+  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('✓ ' + c.name + ' connected');
+    cursor++;
+    render();
+  } else {
+    toast('✗ ' + (j.error || 'failed'), true);
+  }
+});
+
+$('#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);
+  }
+});
+
+// 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>
diff --git a/server/server.js b/server/server.js
index 8f12b49..1244f2c 100644
--- a/server/server.js
+++ b/server/server.js
@@ -519,6 +519,43 @@ app.get("/fill",        requireAuthPage, (req, res) => res.sendFile(path.join(PU
 app.get("/admin",                 requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin.html")));
 app.get("/admin/users",           requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-users.html")));
 app.get("/admin/connectors",      requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-connectors.html")));
+app.get("/admin/wizard",          requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-wizard.html")));
+
+// Wizard helper — peek at the secrets-manager vault for a connector's likely keys.
+// Local-only convenience: returns a per-field map of values found, or {found:false}.
+// SECURITY: gated to admin + only reads the canonical .env path; never proxies arbitrary paths.
+app.get("/api/wizard/vault-search", requireAdmin, (req, res) => {
+  const cid = String(req.query.connector || "").replace(/[^a-z0-9_-]/gi, "");
+  if (!cid) return res.status(400).json({ error: "connector required" });
+  // Reuse the same map the import endpoint uses — invert it: { connectorId: { field: [keys...] } }
+  const inv = {};
+  for (const [envKey, m] of Object.entries(ENV_KEY_TO_CONNECTOR || {})) {
+    if (!inv[m.connector]) inv[m.connector] = {};
+    if (!inv[m.connector][m.field]) inv[m.connector][m.field] = [];
+    inv[m.connector][m.field].push(envKey);
+  }
+  const wanted = inv[cid];
+  if (!wanted) return res.json({ found: false, message: "no env-key map for " + cid });
+  // Read the vault
+  const vaultPath = (process.env.VC_VAULT_PATH || (process.env.HOME || "/root") + "/Projects/secrets-manager/.env");
+  let vault;
+  try { vault = fs.readFileSync(vaultPath, "utf8"); }
+  catch (e) { return res.json({ found: false, message: "vault not readable on this server (expected on Kamatera; works on Mac dev)" }); }
+  const env = {};
+  for (const line of vault.split("\n")) {
+    const m = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
+    if (m) env[m[1]] = m[2];
+  }
+  // Match each field
+  const fields = {};
+  for (const [field, candidates] of Object.entries(wanted)) {
+    for (const k of candidates) {
+      if (env[k]) { fields[field] = env[k]; break; }
+    }
+  }
+  if (!Object.keys(fields).length) return res.json({ found: false, message: "no values in vault for those fields" });
+  res.json({ found: true, fields });
+});
 app.get("/admin/approvals",       requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-approvals.html")));
 app.get("/admin/audit",           requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-audit.html")));
 app.get("/admin/services-leads",  requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-services-leads.html")));
@@ -1704,6 +1741,10 @@ const ENV_KEY_TO_CONNECTOR = {
   SUPABASE_KEY:             { connector: 'supabase',    field: 'api_key' },
   ELEVENLABS_API_KEY:       { connector: 'elevenlabs',  field: 'ELEVENLABS_API_KEY' },
   PURELYMAIL_API_TOKEN:     { connector: 'purelymail',  field: 'PURELYMAIL_API_TOKEN' },
+  GEORGE_URL:               { connector: 'gmail',       field: 'GEORGE_URL' },
+  GEORGE_BASIC_AUTH:        { connector: 'gmail',       field: 'GEORGE_BASIC_AUTH' },
+  LINKEDIN_CLIENT_ID:       { connector: 'linkedin',    field: 'client_id' },
+  LINKEDIN_CLIENT_SECRET:   { connector: 'linkedin',    field: 'client_secret' },
   // Aliases for common variants:
   STRIPE_API_KEY:           { connector: 'stripe',      field: 'api_key' },
   CLOUDFLARE_TOKEN:         { connector: 'cloudflare',  field: 'api_key' },

← 0c593b6 ENV_KEY_TO_CONNECTOR: add ELEVENLABS_API_KEY, PURELYMAIL_API  ·  back to Ventura Claw  ·  Wizard: Test button — saves + validates against connector he 437cf54 →