[object Object]

← back to Ig Token Wizard

Add Continue-with-Facebook OAuth flow — paste 2 values, click one button

6f0ed4a43084b23c33d01d87b2df959c38c10c8c · 2026-05-19 11:48:19 -0700 · Steve

Files touched

Diff

commit 6f0ed4a43084b23c33d01d87b2df959c38c10c8c
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 11:48:19 2026 -0700

    Add Continue-with-Facebook OAuth flow — paste 2 values, click one button
---
 server.js | 397 +++++++++++++++++++++++++++++++++++++++++++++-----------------
 1 file changed, 288 insertions(+), 109 deletions(-)

diff --git a/server.js b/server.js
index ee1acda..a857030 100644
--- a/server.js
+++ b/server.js
@@ -1,25 +1,50 @@
 // ─────────────────────────────────────────────────────────────────────────
-// ig-token-wizard — local click-through wizard that turns a short-lived Meta
-// token into the IG_USER_ID + IG_ACCESS_TOKEN that wallco.ai's reel maker
-// needs to publish to Instagram.
+// ig-token-wizard — local click-through wizard that produces the
+// IG_USER_ID + IG_ACCESS_TOKEN that wallco.ai's reel maker needs to publish
+// to Instagram.
 //
-// You still do Step 1 in your browser (generate a short-lived token in Meta's
-// Graph API Explorer — that needs your Facebook login, nothing can automate
-// it). The wizard does Steps 2-5: long-lived exchange, page lookup, IG-account
-// resolution, and a verification call.
+// TWO paths:
+//   • "Continue with Facebook" — the wizard hosts the OAuth login itself.
+//     You paste only App ID + App Secret, click one blue button, approve in
+//     Meta's dialog, and the wizard captures + exchanges the token for you.
+//   • Manual paste — paste App ID + Secret + a short-lived Explorer token.
 //
-// Zero dependencies. Binds 127.0.0.1 only — tokens never leave this machine
-// and are never written to disk.
-//   node server.js   →   http://127.0.0.1:9939
+// Either way the wizard runs: code/short-token → long-lived exchange →
+// Page lookup → IG-account resolution → verification.
+//
+// Zero dependencies. Binds loopback only (127.0.0.1 + ::1). Tokens are never
+// written to disk; App ID/Secret live in memory only for the OAuth round-trip.
+//   node server.js   →   http://localhost:9939
 // ─────────────────────────────────────────────────────────────────────────
 'use strict';
 const http = require('http');
+const crypto = require('crypto');
 
 const PORT = process.env.PORT || 9939;
-const GRAPH = 'https://graph.facebook.com/v19.0';
-
+const API_VER = 'v19.0';
+const GRAPH = 'https://graph.facebook.com/' + API_VER;
+const OAUTH_DIALOG = 'https://www.facebook.com/' + API_VER + '/dialog/oauth';
 const PERMS = 'instagram_basic,instagram_content_publish,pages_show_list,pages_read_engagement,business_management';
 
+// In-memory session store for the OAuth round-trip. Keyed by a cookie value.
+// Entries expire after 15 minutes. Never persisted.
+const sessions = new Map();
+function newSession(data) {
+  const id = crypto.randomBytes(18).toString('hex');
+  sessions.set(id, Object.assign({ ts: Date.now() }, data));
+  return id;
+}
+function getSession(id) {
+  const s = id && sessions.get(id);
+  if (!s) return null;
+  if (Date.now() - s.ts > 15 * 60 * 1000) { sessions.delete(id); return null; }
+  return s;
+}
+setInterval(() => {
+  const cutoff = Date.now() - 15 * 60 * 1000;
+  for (const [k, v] of sessions) if (v.ts < cutoff) sessions.delete(k);
+}, 5 * 60 * 1000).unref();
+
 // ── Graph API helpers ──────────────────────────────────────────────────────
 async function graph(pathQuery) {
   const r = await fetch(GRAPH + pathQuery);
@@ -32,9 +57,20 @@ async function graph(pathQuery) {
   return j;
 }
 
+// Exchange an OAuth `code` (from the login dialog) for a short-lived token.
+async function codeToToken({ appId, appSecret, redirectUri, code }) {
+  const j = await graph('/oauth/access_token'
+    + '?client_id=' + encodeURIComponent(appId)
+    + '&client_secret=' + encodeURIComponent(appSecret)
+    + '&redirect_uri=' + encodeURIComponent(redirectUri)
+    + '&code=' + encodeURIComponent(code));
+  if (!j.access_token) throw new Error('Meta did not return an access token from the login code.');
+  return j.access_token;
+}
+
 // Run the whole short-token → IG-credentials chain.
 async function resolveCredentials({ appId, appSecret, shortToken }) {
-  // Step 2 — exchange short-lived user token for a long-lived one.
+  // Exchange short-lived user token for a long-lived one.
   const ex = await graph('/oauth/access_token?grant_type=fb_exchange_token'
     + '&client_id=' + encodeURIComponent(appId)
     + '&client_secret=' + encodeURIComponent(appSecret)
@@ -42,8 +78,8 @@ async function resolveCredentials({ appId, appSecret, shortToken }) {
   const longUserToken = ex.access_token;
   if (!longUserToken) throw new Error('No long-lived token returned — check App ID / Secret / token.');
 
-  // Step 3 — list Facebook Pages. Each page token derived from a long-lived
-  // user token is itself long-lived / effectively non-expiring.
+  // List Facebook Pages. A page token derived from a long-lived user token is
+  // itself long-lived / effectively non-expiring.
   const pagesRes = await graph('/me/accounts?fields=name,access_token,id&access_token='
     + encodeURIComponent(longUserToken));
   const pages = pagesRes.data || [];
@@ -52,7 +88,7 @@ async function resolveCredentials({ appId, appSecret, shortToken }) {
       + 'must be a Business/Creator account linked to a Facebook Page.');
   }
 
-  // Steps 4 & 5 — for each page, resolve the linked IG account + verify it.
+  // For each page, resolve the linked IG account + verify it.
   const accounts = [];
   for (const pg of pages) {
     try {
@@ -82,17 +118,113 @@ async function resolveCredentials({ appId, appSecret, shortToken }) {
   return { accounts };
 }
 
-// ── HTTP server ────────────────────────────────────────────────────────────
-function send(res, code, type, body) {
-  res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
+// ── tiny helpers ───────────────────────────────────────────────────────────
+function send(res, code, type, body, extraHeaders) {
+  res.writeHead(code, Object.assign(
+    { 'Content-Type': type, 'Cache-Control': 'no-store' }, extraHeaders || {}));
   res.end(body);
 }
+function esc(s) {
+  return String(s == null ? '' : s).replace(/[&<>"]/g,
+    c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+}
+function parseCookies(req) {
+  const out = {};
+  (req.headers.cookie || '').split(';').forEach(p => {
+    const i = p.indexOf('='); if (i < 0) return;
+    out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
+  });
+  return out;
+}
+// The redirect URI Meta must have whitelisted — derived from the Host the
+// browser actually used, so it always matches.
+function redirectUri(req) {
+  const host = (req.headers.host || ('localhost:' + PORT)).trim();
+  return 'http://' + host + '/oauth/callback';
+}
 
+// ── HTTP server ────────────────────────────────────────────────────────────
 const requestHandler = async (req, res) => {
-  if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
-    return send(res, 200, 'text/html; charset=utf-8', PAGE);
+  const u = new URL(req.url, 'http://x');
+  const path = u.pathname;
+
+  // Home page.
+  if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
+    return send(res, 200, 'text/html; charset=utf-8', renderPage(redirectUri(req)));
   }
-  if (req.method === 'POST' && req.url === '/api/run') {
+
+  // Step A of OAuth: stash App ID/Secret in a session, hand back a cookie.
+  if (req.method === 'POST' && path === '/api/config') {
+    let body = '';
+    req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
+    req.on('end', () => {
+      try {
+        const { appId, appSecret } = JSON.parse(body || '{}');
+        if (!appId || !appSecret) {
+          return send(res, 400, 'application/json',
+            JSON.stringify({ ok: false, error: 'Enter both App ID and App Secret.' }));
+        }
+        const state = crypto.randomBytes(12).toString('hex');
+        const sid = newSession({
+          appId: String(appId).trim(), appSecret: String(appSecret).trim(), state,
+        });
+        send(res, 200, 'application/json', JSON.stringify({ ok: true }),
+          { 'Set-Cookie': 'igw_sess=' + sid + '; Path=/; HttpOnly; SameSite=Lax' });
+      } catch (e) {
+        send(res, 200, 'application/json', JSON.stringify({ ok: false, error: e.message }));
+      }
+    });
+    return;
+  }
+
+  // Step B: bounce the browser to Meta's login dialog.
+  if (req.method === 'GET' && path === '/oauth/start') {
+    const s = getSession(parseCookies(req).igw_sess);
+    if (!s) return send(res, 200, 'text/html; charset=utf-8',
+      resultPage('Session expired', '<p>Your session timed out. Go back and click “Continue with Facebook” again.</p>'));
+    const url = OAUTH_DIALOG
+      + '?client_id=' + encodeURIComponent(s.appId)
+      + '&redirect_uri=' + encodeURIComponent(redirectUri(req))
+      + '&scope=' + encodeURIComponent(PERMS)
+      + '&state=' + encodeURIComponent(s.state)
+      + '&response_type=code';
+    return send(res, 302, 'text/plain', 'redirecting', { 'Location': url });
+  }
+
+  // Step C: Meta redirects back here with ?code=… — exchange + resolve.
+  if (req.method === 'GET' && path === '/oauth/callback') {
+    const s = getSession(parseCookies(req).igw_sess);
+    const code = u.searchParams.get('code');
+    const state = u.searchParams.get('state');
+    const err = u.searchParams.get('error_description') || u.searchParams.get('error');
+    if (err) return send(res, 200, 'text/html; charset=utf-8',
+      resultPage('Login cancelled', '<p>' + esc(err) + '</p>'));
+    if (!s) return send(res, 200, 'text/html; charset=utf-8',
+      resultPage('Session expired', '<p>Start over — click “Continue with Facebook” again.</p>'));
+    if (!code || state !== s.state) return send(res, 200, 'text/html; charset=utf-8',
+      resultPage('Could not verify the login', '<p>The login response did not match. Start over.</p>'));
+    try {
+      const shortToken = await codeToToken({
+        appId: s.appId, appSecret: s.appSecret,
+        redirectUri: redirectUri(req), code,
+      });
+      const { accounts } = await resolveCredentials({
+        appId: s.appId, appSecret: s.appSecret, shortToken,
+      });
+      sessions.delete(parseCookies(req).igw_sess);
+      return send(res, 200, 'text/html; charset=utf-8',
+        resultPage('Choose your Instagram account', accountsHtml(accounts)));
+    } catch (e) {
+      return send(res, 200, 'text/html; charset=utf-8',
+        resultPage('Something went wrong', '<div class="err">' + esc(e.message) + '</div>'
+          + '<p class="note">Most common cause: the redirect URI is not whitelisted. '
+          + 'In your Meta app → Facebook Login → Settings, add exactly:<br><code>'
+          + esc(redirectUri(req)) + '</code></p>'));
+    }
+  }
+
+  // Manual-paste fallback.
+  if (req.method === 'POST' && path === '/api/run') {
     let body = '';
     req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
     req.on('end', async () => {
@@ -114,13 +246,12 @@ const requestHandler = async (req, res) => {
     });
     return;
   }
+
   send(res, 404, 'text/plain', 'not found');
 };
 
 // Listen on BOTH loopback addresses — IPv4 127.0.0.1 and IPv6 ::1 — so the
 // wizard is reachable whether the browser resolves "localhost" to v4 or v6.
-// Both are loopback-only; the wizard handles Meta secrets and is never
-// exposed to the LAN.
 for (const host of ['127.0.0.1', '::1']) {
   http.createServer(requestHandler).listen(PORT, host, () => {
     const shown = host.includes(':') ? '[' + host + ']' : host;
@@ -128,11 +259,8 @@ for (const host of ['127.0.0.1', '::1']) {
   });
 }
 
-// ── The page ───────────────────────────────────────────────────────────────
-const PAGE = `<!doctype html><html lang="en"><head>
-<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Instagram Token Wizard — wallco.ai</title>
-<style>
+// ── shared CSS ─────────────────────────────────────────────────────────────
+const CSS = `
   :root{--bg:#faf8f3;--ink:#2a2014;--soft:#6b5e4a;--faint:#9b8f78;--line:#ddd3bf;
         --gold:#c9a14b;--card:#fff;--sans:'Helvetica Neue',Arial,sans-serif;--serif:Georgia,serif}
   *{box-sizing:border-box}
@@ -146,8 +274,8 @@ const PAGE = `<!doctype html><html lang="en"><head>
      font:600 12px var(--sans);align-items:center;justify-content:center;flex:none}
   .hint{color:var(--faint);font-size:12.5px;margin:2px 0 12px}
   ol{margin:8px 0 12px;padding-left:20px;font-size:13.5px;color:var(--soft)}
-  ol li{margin:4px 0}
-  code{background:#f0ebdf;padding:1px 5px;border-radius:4px;font-size:12px}
+  ol li{margin:5px 0}
+  code{background:#f0ebdf;padding:1px 5px;border-radius:4px;font-size:12px;word-break:break-all}
   .perms{display:flex;flex-wrap:wrap;gap:5px;margin:8px 0}
   .perms span{background:#f0ebdf;border:1px solid var(--line);border-radius:5px;padding:3px 8px;font-size:11.5px}
   label{display:block;font-size:12px;color:var(--soft);margin:11px 0 4px;font-weight:600}
@@ -157,15 +285,13 @@ const PAGE = `<!doctype html><html lang="en"><head>
   .btn{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:8px;cursor:pointer;
        font:600 14px var(--sans);padding:12px 24px;margin-top:14px}
   .btn.primary{background:var(--ink);color:var(--bg)}
-  .btn.primary:disabled{opacity:.45;cursor:not-allowed}
+  .btn:disabled{opacity:.45;cursor:not-allowed}
+  .btn.fb{background:#1877f2;color:#fff;font-size:15px;padding:13px 26px}
   .btn.gold{background:var(--gold);color:#1a1408}
-  .ext{display:inline-block;background:#1877f2;color:#fff;text-decoration:none;border-radius:7px;
-       padding:9px 16px;font:600 13px var(--sans);margin:4px 0}
-  #out{margin-top:8px}
+  details{margin-top:10px}
+  summary{cursor:pointer;font-size:13px;color:var(--soft)}
   .acct{border:1px solid var(--line);border-radius:9px;padding:12px 14px;margin:8px 0;background:var(--bg)}
-  .acct.linked{cursor:pointer}
-  .acct.linked:hover{border-color:var(--gold);background:#fffdf6}
-  .acct.no{opacity:.6}
+  .acct.no{opacity:.65}
   .acct b{font-size:14px}
   .acct .meta{font-size:12px;color:var(--faint)}
   .err{color:#b4453a;font-size:13px;background:#fbeae8;border:1px solid #e8c4bf;border-radius:8px;
@@ -176,88 +302,151 @@ const PAGE = `<!doctype html><html lang="en"><head>
         padding:11px 13px;word-break:break-all;line-height:1.7;margin:8px 0}
   .ok{color:#7fd99a}
   .spin{display:none;font-size:13px;color:var(--soft);margin-top:10px}
-  .note{font-size:11.5px;color:var(--faint);line-height:1.5;margin-top:10px}
-</style></head><body>
+  .note{font-size:11.5px;color:var(--faint);line-height:1.55;margin-top:10px}
+  a{color:#1877f2}
+`;
+
+// ── result / callback page ─────────────────────────────────────────────────
+function accountsHtml(accounts) {
+  if (!accounts || !accounts.length) return '<div class="err">No Facebook Pages found on this account.</div>';
+  let html = '<p class="hint">Copy the two lines for the account wallco posts from, and paste them to Claude.</p>';
+  accounts.forEach((a, i) => {
+    if (a.igLinked) {
+      const lines = 'IG_USER_ID=' + a.igUserId + '\nIG_ACCESS_TOKEN=' + a.igAccessToken;
+      html += '<div class="acct"><b>@' + esc(a.igUsername || 'instagram') + '</b>'
+        + (a.followers != null ? ' &middot; ' + a.followers.toLocaleString() + ' followers' : '')
+        + '<div class="meta">via Page: ' + esc(a.pageName) + '</div>'
+        + '<div class="cred">IG_USER_ID=<span class="ok">' + esc(a.igUserId) + '</span><br>'
+        + 'IG_ACCESS_TOKEN=<span class="ok">' + esc(a.igAccessToken) + '</span></div>'
+        + '<button class="btn gold" data-copy="' + esc(lines) + '">Copy both lines</button></div>';
+    } else {
+      html += '<div class="acct no"><b>' + esc(a.pageName) + '</b>'
+        + '<div class="meta">No Instagram account linked'
+        + (a.error ? ' — ' + esc(a.error) : '') + '</div></div>';
+    }
+  });
+  html += '<p class="note">These run entirely on your machine. The Page token is long-lived '
+    + '(no 60-day clock). Don’t paste them anywhere public.</p>';
+  return html;
+}
+function resultPage(title, inner) {
+  return '<!doctype html><html lang="en"><head><meta charset="utf-8">'
+    + '<meta name="viewport" content="width=device-width, initial-scale=1">'
+    + '<title>' + esc(title) + ' — IG Token Wizard</title><style>' + CSS + '</style></head><body>'
+    + '<div class="wrap"><h1>' + esc(title) + '</h1>' + inner
+    + '<p style="margin-top:22px"><a href="/">&larr; Back to the wizard</a></p></div>'
+    + '<script>document.addEventListener("click",function(e){'
+    + 'var b=e.target.closest("[data-copy]");if(!b)return;'
+    + 'navigator.clipboard.writeText(b.getAttribute("data-copy")).then(function(){'
+    + 'var t=b.textContent;b.textContent="Copied \\u2713";setTimeout(function(){b.textContent=t;},1800);});'
+    + '});</script></body></html>';
+}
+
+// ── home page ──────────────────────────────────────────────────────────────
+function renderPage(redirUri) {
+  return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Instagram Token Wizard — wallco.ai</title>
+<style>${CSS}</style></head><body>
 <div class="wrap">
   <h1>Instagram Token Wizard</h1>
-  <p class="sub">Turns a short-lived Meta token into the two credentials wallco.ai needs to publish reels &amp; stories to Instagram. Runs entirely on your machine — tokens are never saved or sent anywhere but Meta.</p>
+  <p class="sub">Hooks wallco.ai up to publish reels &amp; stories to Instagram. Paste two values, click one blue button, approve in Meta&rsquo;s pop-up &mdash; the wizard does the rest. Runs entirely on your machine.</p>
 
   <div class="step">
-    <h2><span class="n">1</span> Get a short-lived token (in your browser)</h2>
-    <p class="hint">This one step needs your Facebook login — nothing can automate it. It takes ~30 seconds.</p>
+    <h2><span class="n">1</span> One-time Meta setup</h2>
     <ol>
-      <li>Make sure wallco's Instagram is a <b>Business/Creator</b> account <b>linked to a Facebook Page</b>.</li>
-      <li>At <code>developers.facebook.com</code> create an App (type <b>Business</b>) and add the <b>Instagram Graph API</b> product. Note its <b>App ID</b> + <b>App Secret</b> (Settings → Basic).</li>
-      <li>Open the Graph API Explorer, pick your app, tick these permissions, then click <b>Generate Access Token</b>:</li>
+      <li>wallco&rsquo;s Instagram must be a <b>Business/Creator</b> account <b>linked to a Facebook Page</b>.</li>
+      <li>At <a href="https://developers.facebook.com/apps" target="_blank" rel="noopener">developers.facebook.com/apps</a> open (or create, type <b>Business</b>) your app. Add two products: <b>Instagram Graph API</b> and <b>Facebook Login</b>.</li>
+      <li>In <b>Facebook Login &rarr; Settings</b>, under <b>Valid OAuth Redirect URIs</b>, paste exactly this and save:
+        <br><code>${esc(redirUri)}</code></li>
+      <li>From <b>Settings &rarr; Basic</b>, copy your <b>App ID</b> and <b>App Secret</b> (click &ldquo;Show&rdquo;).</li>
     </ol>
-    <div class="perms">
-      <span>instagram_basic</span><span>instagram_content_publish</span><span>pages_show_list</span>
-      <span>pages_read_engagement</span><span>business_management</span>
-    </div>
-    <a class="ext" href="https://developers.facebook.com/tools/explorer/" target="_blank" rel="noopener">Open Graph API Explorer ↗</a>
-    <p class="hint">Copy the token it shows — it expires in ~1 hour, which is plenty.</p>
+    <p class="hint">You only do this once. After it&rsquo;s saved, every future token is just the blue button below.</p>
   </div>
 
   <div class="step">
-    <h2><span class="n">2</span> Paste your three values</h2>
-    <p class="hint">The wizard does the rest — long-lived exchange, Page lookup, IG-account resolution, verification.</p>
+    <h2><span class="n">2</span> Connect</h2>
     <label for="appId">App ID</label>
     <input id="appId" autocomplete="off" placeholder="e.g. 1234567890123456">
     <label for="appSecret">App Secret</label>
-    <input id="appSecret" autocomplete="off" placeholder="32-character secret from Settings → Basic">
-    <label for="shortToken">Short-lived access token (from the Explorer)</label>
-    <textarea id="shortToken" autocomplete="off" placeholder="EAAG..."></textarea>
-    <button class="btn primary" id="run">Get my Instagram credentials</button>
-    <div class="spin" id="spin">Talking to Meta… (a few seconds)</div>
+    <input id="appSecret" autocomplete="off" placeholder="32-character secret from Settings &rarr; Basic">
+    <button class="btn fb" id="fb">Continue with Facebook</button>
+    <div class="spin" id="spin">Saving&hellip;</div>
     <div id="err"></div>
-  </div>
+    <p class="note">Clicking the button sends you to Meta&rsquo;s own login page. Approve the requested permissions and pick wallco&rsquo;s Page &mdash; you land back here with the credentials.</p>
 
-  <div class="step" id="step3" style="display:none">
-    <h2><span class="n">3</span> Pick the Instagram account</h2>
-    <p class="hint">Click the wallco account to get its final credentials.</p>
-    <div id="out"></div>
+    <details>
+      <summary>Or paste a short-lived token manually (advanced)</summary>
+      <label for="mAppId">App ID</label>
+      <input id="mAppId" autocomplete="off">
+      <label for="mAppSecret">App Secret</label>
+      <input id="mAppSecret" autocomplete="off">
+      <label for="mToken">Short-lived token (from the <a href="https://developers.facebook.com/tools/explorer/" target="_blank" rel="noopener">Graph API Explorer</a>)</label>
+      <textarea id="mToken" autocomplete="off" placeholder="EAAG..."></textarea>
+      <div class="perms">
+        <span>instagram_basic</span><span>instagram_content_publish</span><span>pages_show_list</span>
+        <span>pages_read_engagement</span><span>business_management</span>
+      </div>
+      <button class="btn primary" id="run">Get credentials from token</button>
+      <div class="spin" id="mspin">Talking to Meta&hellip;</div>
+      <div id="merr"></div>
+      <div id="mout"></div>
+    </details>
   </div>
-
-  <div id="final"></div>
 </div>
 <script>
-  var ACCOUNTS = [];
-  var $ = function(id){ return document.getElementById(id); };
+  var $=function(id){return document.getElementById(id);};
 
-  $('run').addEventListener('click', function(){
-    var appId=$('appId').value.trim(), appSecret=$('appSecret').value.trim(), shortToken=$('shortToken').value.trim();
-    $('err').innerHTML=''; $('final').innerHTML=''; $('step3').style.display='none';
-    if(!appId||!appSecret||!shortToken){ $('err').innerHTML='<div class="err">Fill in all three fields.</div>'; return; }
-    $('run').disabled=true; $('spin').style.display='block';
+  // OAuth path.
+  $('fb').addEventListener('click',function(){
+    var appId=$('appId').value.trim(), appSecret=$('appSecret').value.trim();
+    $('err').innerHTML='';
+    if(!appId||!appSecret){ $('err').innerHTML='<div class="err">Enter both App ID and App Secret.</div>'; return; }
+    $('fb').disabled=true; $('spin').style.display='block';
+    fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({appId:appId,appSecret:appSecret})})
+    .then(function(r){return r.json();})
+    .then(function(j){
+      if(!j.ok){ $('fb').disabled=false; $('spin').style.display='none';
+        $('err').innerHTML='<div class="err">'+esc(j.error||'error')+'</div>'; return; }
+      window.location='/oauth/start';
+    })
+    .catch(function(e){ $('fb').disabled=false; $('spin').style.display='none';
+      $('err').innerHTML='<div class="err">'+esc(e.message)+'</div>'; });
+  });
+
+  // Manual paste path.
+  $('run').addEventListener('click',function(){
+    var appId=$('mAppId').value.trim(), appSecret=$('mAppSecret').value.trim(), token=$('mToken').value.trim();
+    $('merr').innerHTML=''; $('mout').innerHTML='';
+    if(!appId||!appSecret||!token){ $('merr').innerHTML='<div class="err">Fill in all three fields.</div>'; return; }
+    $('run').disabled=true; $('mspin').style.display='block';
     fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},
-      body:JSON.stringify({appId:appId,appSecret:appSecret,shortToken:shortToken})})
+      body:JSON.stringify({appId:appId,appSecret:appSecret,shortToken:token})})
     .then(function(r){return r.json();})
     .then(function(j){
-      $('run').disabled=false; $('spin').style.display='none';
-      if(!j.ok){ $('err').innerHTML='<div class="err">'+esc(j.error||'Something went wrong')+'</div>'; return; }
-      ACCOUNTS=j.accounts||[];
-      renderAccounts();
+      $('run').disabled=false; $('mspin').style.display='none';
+      if(!j.ok){ $('merr').innerHTML='<div class="err">'+esc(j.error||'error')+'</div>'; return; }
+      renderAccounts(j.accounts||[]);
     })
-    .catch(function(e){
-      $('run').disabled=false; $('spin').style.display='none';
-      $('err').innerHTML='<div class="err">'+esc(e.message)+'</div>';
-    });
+    .catch(function(e){ $('run').disabled=false; $('mspin').style.display='none';
+      $('merr').innerHTML='<div class="err">'+esc(e.message)+'</div>'; });
   });
 
-  function renderAccounts(){
-    $('step3').style.display='block';
-    var out=$('out'); out.innerHTML='';
-    if(!ACCOUNTS.length){ out.innerHTML='<div class="err">No Facebook Pages found.</div>'; return; }
-    ACCOUNTS.forEach(function(a,i){
-      var d=document.createElement('div');
+  function renderAccounts(list){
+    var out=$('mout'); out.innerHTML='';
+    if(!list.length){ out.innerHTML='<div class="err">No Facebook Pages found.</div>'; return; }
+    list.forEach(function(a){
+      var d=document.createElement('div'); d.className='acct'+(a.igLinked?'':' no');
       if(a.igLinked){
-        d.className='acct linked';
+        var lines='IG_USER_ID='+a.igUserId+'\\nIG_ACCESS_TOKEN='+a.igAccessToken;
         d.innerHTML='<b>@'+esc(a.igUsername||'instagram')+'</b>'+
           (a.followers!=null?' &middot; '+a.followers.toLocaleString()+' followers':'')+
-          '<div class="meta">via Page: '+esc(a.pageName)+'</div>';
-        d.addEventListener('click',function(){ showFinal(a); });
+          '<div class="meta">via Page: '+esc(a.pageName)+'</div>'+
+          '<div class="cred">IG_USER_ID=<span class="ok">'+esc(a.igUserId)+'</span><br>'+
+          'IG_ACCESS_TOKEN=<span class="ok">'+esc(a.igAccessToken)+'</span></div>'+
+          '<button class="btn gold" data-copy="'+esc(lines)+'">Copy both lines</button>';
       } else {
-        d.className='acct no';
         d.innerHTML='<b>'+esc(a.pageName)+'</b><div class="meta">No Instagram account linked'+
           (a.error?' — '+esc(a.error):'')+'</div>';
       }
@@ -265,26 +454,16 @@ const PAGE = `<!doctype html><html lang="en"><head>
     });
   }
 
-  function showFinal(a){
-    var lines='IG_USER_ID='+a.igUserId+'\\nIG_ACCESS_TOKEN='+a.igAccessToken;
-    $('final').innerHTML=
-      '<div class="result"><h3>✓ Credentials for @'+esc(a.igUsername||'')+'</h3>'+
-      '<div class="cred">IG_USER_ID=<span class="ok">'+esc(a.igUserId)+'</span><br>'+
-      'IG_ACCESS_TOKEN=<span class="ok">'+esc(a.igAccessToken)+'</span></div>'+
-      '<button class="btn gold" id="copy">Copy both lines</button>'+
-      '<p class="note">Paste these two lines to Claude — they\\'ll be routed to the instagram-agent '+
-      'and the wallco.ai reel maker goes live. This Page token is long-lived (doesn\\'t expire on a '+
-      '60-day clock). Don\\'t paste these anywhere public.</p></div>';
-    $('copy').addEventListener('click',function(){
-      navigator.clipboard.writeText(lines).then(function(){
-        $('copy').textContent='Copied ✓';
-        setTimeout(function(){ $('copy').textContent='Copy both lines'; },1800);
-      });
+  document.addEventListener('click',function(e){
+    var b=e.target.closest('[data-copy]'); if(!b) return;
+    navigator.clipboard.writeText(b.getAttribute('data-copy')).then(function(){
+      var t=b.textContent; b.textContent='Copied \\u2713';
+      setTimeout(function(){ b.textContent=t; },1800);
     });
-    $('final').scrollIntoView({behavior:'smooth'});
-  }
+  });
 
   function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){
     return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
 </script>
 </body></html>`;
+}

← 069360c Wizard: listen on both IPv4 and IPv6 loopback so localhost r  ·  back to Ig Token Wizard  ·  Broaden .gitignore — bak/pre/orig/rej/swp + dist/build/.next 5dd6922 →