[object Object]

← back to Marketing Command Center

Channels OAuth connect machinery: per-platform connect/callback (YouTube/Google, TikTok, Meta FB+IG) + token store + one-click Connect buttons; reads app client creds from env

a57c5b8bb21527999f4e5b1edbff65c1116d39d9 · 2026-06-10 09:17:40 -0700 · Steve

Files touched

Diff

commit a57c5b8bb21527999f4e5b1edbff65c1116d39d9
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 10 09:17:40 2026 -0700

    Channels OAuth connect machinery: per-platform connect/callback (YouTube/Google, TikTok, Meta FB+IG) + token store + one-click Connect buttons; reads app client creds from env
---
 .gitignore                |  1 +
 modules/channels/index.js | 98 ++++++++++++++++++++++++++++++++++++++++++++++-
 public/panels/channels.js |  3 +-
 3 files changed, 100 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index b3d024f..bbab7ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ data/*.cache.json
 # ship code, not the library, so uploads aren't clobbered on redeploy.
 data/assets.json
 data/assets/
+data/channels-tokens.json
diff --git a/modules/channels/index.js b/modules/channels/index.js
index d472886..6ebfb63 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -15,9 +15,76 @@ const readOutbox = () => { try { return JSON.parse(fs.readFileSync(OUTBOX, 'utf8
 const writeOutbox = (a) => { fs.mkdirSync(path.dirname(OUTBOX), { recursive: true }); fs.writeFileSync(OUTBOX, JSON.stringify(a, null, 2)); };
 const env = (k) => (process.env[k] || '').trim();
 
+// ── OAuth connect machinery ───────────────────────────────────────────────────
+// Tokens minted by the connect flow are stored here (gitignored), so a one-time
+// browser "Authorize" is all it takes once the app client id/secret are set.
+const TOKENS = path.join(__dirname, '..', '..', 'data', 'channels-tokens.json');
+const readTokens = () => { try { return JSON.parse(fs.readFileSync(TOKENS, 'utf8')); } catch { return {}; } };
+const writeTokens = (o) => { fs.mkdirSync(path.dirname(TOKENS), { recursive: true }); fs.writeFileSync(TOKENS, JSON.stringify(o, null, 2)); };
+// Public base for OAuth redirect URIs — register `${base}/api/channels/oauth/<platform>/callback`
+// in each app console. Defaults to the live host; override with OAUTH_REDIRECT_BASE.
+const redirectBase = () => env('OAUTH_REDIRECT_BASE') || 'https://marketing.designerwallcoverings.com';
+const redirectUri = (p) => `${redirectBase()}/api/channels/oauth/${p}/callback`;
+
+// Per-platform OAuth config. clientId/secret come from env (Steve pastes once).
+const OAUTH = {
+  youtube: {
+    clientId: () => env('YOUTUBE_CLIENT_ID') || env('GOOGLE_CLIENT_ID'),
+    clientSecret: () => env('YOUTUBE_CLIENT_SECRET') || env('GOOGLE_CLIENT_SECRET'),
+    authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
+    tokenUrl: 'https://oauth2.googleapis.com/token',
+    scope: 'https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly',
+    extra: { access_type: 'offline', prompt: 'consent' },
+  },
+  tiktok: {
+    clientId: () => env('TIKTOK_CLIENT_KEY') || env('TIKTOK_CLIENT_ID'),
+    clientSecret: () => env('TIKTOK_CLIENT_SECRET'),
+    authUrl: 'https://www.tiktok.com/v2/auth/authorize/',
+    tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/',
+    scope: 'video.publish,video.upload,user.info.basic',
+    clientParam: 'client_key',
+  },
+  facebook: {
+    clientId: () => env('META_APP_ID'),
+    clientSecret: () => env('META_APP_SECRET'),
+    authUrl: 'https://www.facebook.com/v21.0/dialog/oauth',
+    tokenUrl: 'https://graph.facebook.com/v21.0/oauth/access_token',
+    scope: 'pages_manage_posts,pages_read_engagement,instagram_content_publish,instagram_basic,business_management',
+  },
+};
+OAUTH.instagram = OAUTH.facebook; // same Meta app/flow
+
+function authorizeUrl(p) {
+  const c = OAUTH[p]; if (!c || !c.clientId()) return null;
+  const params = new URLSearchParams({
+    [c.clientParam || 'client_id']: c.clientId(),
+    redirect_uri: redirectUri(p),
+    response_type: 'code',
+    scope: c.scope,
+    state: p,
+    ...(c.extra || {}),
+  });
+  return `${c.authUrl}?${params.toString()}`;
+}
+async function exchangeCode(p, code) {
+  const c = OAUTH[p];
+  const body = new URLSearchParams({
+    [c.clientParam || 'client_id']: c.clientId(),
+    client_secret: c.clientSecret(),
+    code, grant_type: 'authorization_code', redirect_uri: redirectUri(p),
+  });
+  const r = await fetch(c.tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body });
+  const j = await r.json().catch(() => ({}));
+  if (!r.ok || j.error) throw new Error(j.error_description || j.error?.message || JSON.stringify(j).slice(0, 200));
+  return j; // {access_token, refresh_token?, expires_in?}
+}
+
 // ── Per-platform connection status (what's wired vs what Steve must authorize) ──
 function platformStatus() {
-  return {
+  const tk = readTokens();
+  const cfg = (p) => !!(OAUTH[p] && OAUTH[p].clientId() && OAUTH[p].clientSecret());
+  const tokenConnected = (p) => !!(tk[p] && tk[p].access_token);
+  const base = {
     facebook: {
       label: 'Facebook Pages', icon: '📘',
       connected: !!(env('META_APP_ID') && env('FB_PAGE_ACCESS_TOKEN')),
@@ -47,6 +114,14 @@ function platformStatus() {
       scopes: ['https://www.googleapis.com/auth/youtube.upload'],
     },
   };
+  // Merge in OAuth-flow state: a platform is `connected` if env creds OR a minted
+  // token exists; `configured` = app client id/secret present so a Connect button works.
+  for (const p of Object.keys(base)) {
+    base[p].configured = cfg(p);
+    base[p].connected = base[p].connected || tokenConnected(p);
+    base[p].connectUrl = (base[p].configured && !base[p].connected) ? `/api/channels/connect/${p}` : null;
+  }
+  return base;
 }
 
 // ── Real adapters (fire only when connected) ──────────────────────────────────
@@ -108,6 +183,27 @@ module.exports = {
     });
     router.get('/outbox', (_req, res) => res.json({ items: readOutbox().slice(-100).reverse() }));
 
+    // OAuth: start the connect flow → redirect the browser to the platform consent.
+    router.get('/connect/:platform', (req, res) => {
+      const url = authorizeUrl(req.params.platform);
+      if (!url) return res.status(400).send(`Channel '${req.params.platform}' has no app client id/secret configured yet. Paste them into .env first.`);
+      res.redirect(url);
+    });
+    // OAuth callback: exchange the code, store the token, mark connected.
+    router.get('/oauth/:platform/callback', async (req, res) => {
+      const p = req.params.platform;
+      if (req.query.error) return res.status(400).send(`Authorization declined: ${req.query.error_description || req.query.error}`);
+      if (!req.query.code) return res.status(400).send('No authorization code returned.');
+      try {
+        const tok = await exchangeCode(p, req.query.code);
+        const all = readTokens();
+        all[p] = { ...tok, connectedAt: new Date().toISOString() };
+        if (p === 'facebook' || p === 'instagram') { all.facebook = all[p]; all.instagram = all[p]; }
+        writeTokens(all);
+        res.send(`<body style="font-family:system-ui;padding:50px;text-align:center"><h2>✓ ${p} connected</h2><p>You can close this tab and return to the Marketing Command Center.</p><a href="/#channels">← back to Channels</a></body>`);
+      } catch (e) { res.status(500).send(`Token exchange failed for ${p}: ${e.message}`); }
+    });
+
     // The publish pipeline. Stages by default; only fires live with confirm + !dryRun
     // AND a connected platform. Unconnected platforms always stage.
     router.post('/publish', async (req, res) => {
diff --git a/public/panels/channels.js b/public/panels/channels.js
index de8a324..65ef279 100644
--- a/public/panels/channels.js
+++ b/public/panels/channels.js
@@ -14,7 +14,8 @@ window.MCC_PANELS['channels'] = {
           <div style="display:flex;align-items:center;gap:8px"><span style="font-size:20px">${p.icon}</span><b>${esc(p.label)}</b>
             <span class="pill" style="margin-left:auto;background:${p.connected ? '#e3efe0' : '#f3e9e9'};color:${p.connected ? '#3a6b3a' : '#9a5a5a'}">${p.connected ? 'connected' : 'not connected'}</span></div>
           ${p.accounts && p.accounts.length ? `<div class="muted" style="margin-top:6px;font-size:12px">${p.accounts.length} account(s)</div>` : ''}
-          ${!p.connected ? `<div class="muted" style="margin-top:8px;font-size:11.5px"><b>To connect:</b> ${esc(p.needs)}</div>` : ''}
+          ${p.connectUrl ? `<a class="btn gold" style="margin-top:10px;display:inline-block;text-decoration:none" href="${esc(p.connectUrl)}">Connect ${esc(p.label)} →</a>`
+            : (!p.connected ? `<div class="muted" style="margin-top:8px;font-size:11.5px"><b>${p.configured ? 'Ready — connect above.' : 'Needs app creds:'}</b> ${esc(p.needs)}</div>` : '')}
         </div>`).join('');
       // targets
       $('#ch-targets').innerHTML = Object.entries(ps).map(([k, p]) =>

← b546fbd Copy panel: Gemini path (preferred over dead Anthropic) → re  ·  back to Marketing Command Center  ·  Vendor IG: headless-verified follower counts (33 live, real 1cc1006 →