← 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,'&').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);
}
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>