← 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 ? ' · ' + 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="/">← 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 & 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 & stories to Instagram. Paste two values, click one blue button, approve in Meta’s pop-up — 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’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 → Settings</b>, under <b>Valid OAuth Redirect URIs</b>, paste exactly this and save:
+ <br><code>${esc(redirUri)}</code></li>
+ <li>From <b>Settings → Basic</b>, copy your <b>App ID</b> and <b>App Secret</b> (click “Show”).</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’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 → Basic">
+ <button class="btn fb" id="fb">Continue with Facebook</button>
+ <div class="spin" id="spin">Saving…</div>
<div id="err"></div>
- </div>
+ <p class="note">Clicking the button sends you to Meta’s own login page. Approve the requested permissions and pick wallco’s Page — 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…</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?' · '+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 {'&':'&','<':'<','>':'>','"':'"'}[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 →