← 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
A server/public/admin-wizard.htmlM server/server.js
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
+
+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 →