← back to AbramsOS
views/connectors.ejs
189 lines
<%- include('partials/header', { title: 'Connectors' }) %>
<section class="page-head">
<h1>Connectors</h1>
<a class="button primary" href="/auth/google/start">Connect another Gmail</a>
</section>
<section class="glass" id="bankSection" style="padding:1.25rem 1.5rem;margin-bottom:1rem">
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap">
<div>
<h2 style="margin:.2rem 0">Banks & cards</h2>
<p class="subtle" style="margin:0">Connect Wells Fargo (or any US bank) via <strong>Plaid</strong> — a secure read-only link that pulls transactions into your purchases, bills & savings. AbramsOS never sees your bank password; Plaid handles the login.</p>
</div>
<div id="bankAction"><span class="subtle">checking…</span></div>
</div>
<div id="bankList" style="margin-top:.75rem"></div>
<div id="bankStatus" class="subtle" style="margin-top:.5rem"></div>
</section>
<section class="glass" id="withingsSection" style="padding:1.25rem 1.5rem;margin-bottom:1rem">
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap">
<div>
<h2 style="margin:.2rem 0">Health devices</h2>
<p class="subtle" style="margin:0">Connect <strong>Withings</strong> (smart scale / BP cuff / watch) to auto-sync weight, blood pressure, heart rate & SpO₂ into <a href="/health">Health</a>. Read-only via Withings OAuth.</p>
</div>
<div id="withAction"><span class="subtle">checking…</span></div>
</div>
<div id="withStatus" class="subtle" style="margin-top:.5rem"></div>
<hr style="border:none;border-top:1px solid rgba(128,128,128,.2);margin:1rem 0">
<div>
<strong>No signup? Upload a statement instead.</strong>
<p class="subtle" style="margin:.25rem 0">In Wells Fargo online banking → your account → <em>Download Account Activity</em> → <em>Comma Delimited Format (CSV)</em>, then drop the file here. No Plaid, no third-party account — handles Wells Fargo's headerless format.</p>
<form id="csvForm" style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input type="file" name="file" accept=".csv,text/csv" required>
<button class="button" type="submit">Import CSV</button>
<span id="csvStatus" class="subtle"></span>
</form>
</div>
</section>
<% if (!connectors.length) { %>
<section class="empty glass">
<p>No connectors yet. <a href="/auth/google/start">Connect Gmail</a> to start ingesting receipts.</p>
</section>
<% } else { %>
<section class="connector-list">
<% connectors.forEach(c => { %>
<article class="connector glass">
<header>
<h3><%= c.provider %> · <%= c.external_subject_id %></h3>
<span class="badge <%= c.revoked_at ? 'revoked' : 'active' %>">
<%= c.revoked_at ? 'Revoked' : 'Active' %>
</span>
</header>
<dl class="meta">
<dt>Granted</dt><dd><%= new Date(c.granted_at).toLocaleString() %></dd>
<dt>Last sync</dt><dd><%= c.last_sync_at ? new Date(c.last_sync_at).toLocaleString() : 'never' %></dd>
<dt>Messages ingested</dt><dd><%= c.message_count %></dd>
</dl>
<% if (!c.revoked_at) { %>
<button class="button" data-sync-id="<%= c.id %>">Sync now</button>
<span class="sync-status" data-status-for="<%= c.id %>"></span>
<% } %>
</article>
<% }) %>
</section>
<% } %>
<script>
document.querySelectorAll('[data-sync-id]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.syncId;
const status = document.querySelector('[data-status-for="' + id + '"]');
btn.disabled = true;
status.textContent = 'Syncing…';
try {
const r = await fetch('/api/connectors/' + id + '/sync', { method: 'POST' });
const j = await r.json();
if (j.ok) {
status.textContent = `Fetched ${j.fetched}, inserted ${j.inserted}, ${j.purchases} purchases extracted.`;
setTimeout(() => location.reload(), 1500);
} else {
status.textContent = 'Error: ' + (j.error || 'unknown');
}
} catch (e) {
status.textContent = 'Error: ' + e.message;
} finally {
btn.disabled = false;
}
});
});
</script>
<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
<script>
(async function bank(){
const action=document.getElementById('bankAction'), list=document.getElementById('bankList'), statusEl=document.getElementById('bankStatus');
const say=(m)=>{ statusEl.textContent=m||''; };
let st;
try { st=await (await fetch('/api/plaid/status')).json(); }
catch(e){ action.innerHTML='<span class="subtle">status unavailable</span>'; return; }
if(!st.configured){
action.innerHTML='<span class="badge">setup needed</span>';
say('Add PLAID_CLIENT_ID + PLAID_SECRET (free sandbox keys at dashboard.plaid.com) via the secrets skill, then reload. Production keys are needed to link a real Wells Fargo account.');
return;
}
action.innerHTML='<button class="button primary" id="connectBank">+ Connect a bank</button>'
+ ' <span class="subtle">('+st.env+')</span>';
// Render connected banks
if(st.banks && st.banks.length){
list.innerHTML='<div class="connector-list">'+st.banks.map(b=>
'<article class="connector glass"><header><h3>Bank · '+(b.external_subject_id||'').slice(0,10)+'…</h3></header>'
+'<dl class="meta"><dt>Linked</dt><dd>'+new Date(b.created_at).toLocaleString()+'</dd>'
+'<dt>Last sync</dt><dd>'+(b.last_sync_at?new Date(b.last_sync_at).toLocaleString():'never')+'</dd></dl>'
+'<button class="button" data-bank-sync="'+b.id+'">Sync transactions</button> <span data-bsync="'+b.id+'" class="subtle"></span></article>'
).join('')+'</div>';
list.querySelectorAll('[data-bank-sync]').forEach(btn=>btn.addEventListener('click',async()=>{
const id=btn.dataset.bankSync, s=list.querySelector('[data-bsync="'+id+'"]'); btn.disabled=true; s.textContent='Syncing…';
try{ const j=await (await fetch('/api/connectors/plaid/'+id+'/sync',{method:'POST'})).json();
s.textContent=j.ok?('fetched '+j.fetched+', +'+j.purchases+' purchases'):('error: '+(j.error||'?')); }
catch(e){ s.textContent='error: '+e.message; } finally{ btn.disabled=false; }
}));
}
document.getElementById('connectBank').addEventListener('click',async()=>{
say('Opening secure Plaid login…');
let token;
try{ const r=await (await fetch('/api/plaid/link/token',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})).json();
if(r.error){ say('Plaid error: '+r.error); return; } token=r.link_token; }
catch(e){ say('Could not start Plaid: '+e.message); return; }
const handler=Plaid.create({
token,
onSuccess: async (public_token, metadata)=>{
say('Linking '+((metadata&&metadata.institution&&metadata.institution.name)||'bank')+'…');
const x=await (await fetch('/api/plaid/exchange',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({public_token, institution_name:(metadata&&metadata.institution&&metadata.institution.name)||null})})).json();
if(x.ok){ say('Linked! Syncing…'); await fetch('/api/connectors/plaid/'+x.connector_id+'/sync',{method:'POST'}); location.reload(); }
else say('Link failed: '+(x.error||'?'));
},
onExit: (err)=>{ if(err) say('Closed: '+(err.display_message||err.error_message||'')); }
});
handler.open();
});
})();
</script>
<script>
document.getElementById('csvForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const s=document.getElementById('csvStatus'), btn=e.target.querySelector('button');
const fd=new FormData(e.target); if(!fd.get('file') || !fd.get('file').size){ s.textContent='pick a file'; return; }
btn.disabled=true; s.textContent='Importing…';
try{
const j=await (await fetch('/api/upload/csv',{method:'POST',body:fd})).json();
if(j.error){ s.textContent='Error: '+j.error+(j.details?(' — '+j.details.join('; ')):''); }
else { s.textContent=`Imported ${j.inserted} transactions`+(j.skipped?` (${j.skipped} skipped: deposits/dupes)`:''); setTimeout(()=>location.reload(),1500); }
}catch(err){ s.textContent='Error: '+err.message; } finally{ btn.disabled=false; }
});
</script>
<script>
(async function withings(){
const action=document.getElementById('withAction'), s=document.getElementById('withStatus');
let st; try{ st=await (await fetch('/api/withings/status')).json(); }catch(e){ action.innerHTML='<span class="subtle">status unavailable</span>'; return; }
if(!st.configured){
action.innerHTML='<span class="badge">setup needed</span>';
s.textContent='Register an app at developer.withings.com, then set WITHINGS_CLIENT_ID + WITHINGS_CLIENT_SECRET via the secrets skill and reload.';
return;
}
if(!st.connected){
action.innerHTML='<a class="button primary" href="/auth/withings">Connect Withings</a>';
return;
}
action.innerHTML='<button class="button" id="withSync">Sync now</button>';
s.textContent = st.last_sync_at ? ('Last sync: '+new Date(st.last_sync_at).toLocaleString()) : 'Connected — never synced';
document.getElementById('withSync').addEventListener('click', async (e)=>{
e.target.disabled=true; s.textContent='Syncing…';
try{ const j=await (await fetch('/api/withings/sync',{method:'POST'})).json();
s.textContent=j.ok?('fetched '+j.fetched+', +'+j.inserted+' new readings'):('error: '+(j.error||'?')); }
catch(err){ s.textContent='error: '+err.message; } finally{ e.target.disabled=false; }
});
})();
</script>
<%- include('partials/footer') %>