← 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 &amp; 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 &amp; 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 &amp; 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') %>