[object Object]

← back to Marketing Command Center

Channels: self-serve Activate page — paste app creds in-browser (written to .env, never echoed), copy redirect URIs, portal links, one-click Connect; /setup + guarded /config endpoints

bdfc352d4692779104f49809f26c9219699fd06f · 2026-06-13 09:01:29 -0700 · Steve Abrams

Files touched

Diff

commit bdfc352d4692779104f49809f26c9219699fd06f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jun 13 09:01:29 2026 -0700

    Channels: self-serve Activate page — paste app creds in-browser (written to .env, never echoed), copy redirect URIs, portal links, one-click Connect; /setup + guarded /config endpoints
---
 modules/channels/index.js   | 94 +++++++++++++++++++++++++++++++++++++++++++++
 public/panels/channels.html | 24 ++++++++++--
 public/panels/channels.js   | 92 +++++++++++++++++++++++++++++++++++++-------
 3 files changed, 192 insertions(+), 18 deletions(-)

diff --git a/modules/channels/index.js b/modules/channels/index.js
index 6ebfb63..f46f6d9 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -26,6 +26,66 @@ const writeTokens = (o) => { fs.mkdirSync(path.dirname(TOKENS), { recursive: tru
 const redirectBase = () => env('OAUTH_REDIRECT_BASE') || 'https://marketing.designerwallcoverings.com';
 const redirectUri = (p) => `${redirectBase()}/api/channels/oauth/${p}/callback`;
 
+// ── Self-serve setup (the Activate page) ──────────────────────────────────────
+// .env is the source of truth the shell loads; we write directly to it AND update
+// process.env live so the Connect button lights up without a restart.
+const ENV_PATH = path.join(__dirname, '..', '..', '.env');
+// Allowlist — only these may be written via the form (no arbitrary env injection).
+const ALLOWED_KEYS = new Set([
+  'META_APP_ID', 'META_APP_SECRET', 'FB_PAGE_IDS', 'FB_PAGE_ACCESS_TOKEN', 'IG_BUSINESS_ACCOUNT_ID', 'IG_ACCESS_TOKEN',
+  'TIKTOK_CLIENT_KEY', 'TIKTOK_CLIENT_SECRET', 'TIKTOK_ACCESS_TOKEN',
+  'YOUTUBE_CLIENT_ID', 'YOUTUBE_CLIENT_SECRET', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'YOUTUBE_ACCESS_TOKEN', 'YOUTUBE_REFRESH_TOKEN',
+  'OAUTH_REDIRECT_BASE',
+]);
+// Provider cards the front-end renders from. id is the form group only.
+const SETUP_PROVIDERS = [
+  {
+    id: 'meta', title: 'Meta — Facebook + Instagram', icon: '📘',
+    blurb: 'One Meta app activates BOTH Facebook Pages and Instagram. In the portal: create a Business app, add "Facebook Login", request the permissions below, and register the redirect URIs.',
+    portal: 'https://developers.facebook.com/apps', portalLabel: 'Meta for Developers',
+    platforms: ['facebook', 'instagram'],
+    permissions: 'pages_manage_posts · pages_read_engagement · instagram_content_publish · instagram_basic · business_management',
+    fields: [
+      { key: 'META_APP_ID', label: 'App ID', secret: false, ph: 'e.g. 1234567890123456' },
+      { key: 'META_APP_SECRET', label: 'App Secret', secret: true, ph: '32-char secret' },
+    ],
+  },
+  {
+    id: 'tiktok', title: 'TikTok', icon: '🎵',
+    blurb: 'TikTok for Developers app with the Content Posting API. Direct-post requires app audit; until approved, sandbox posts are private-only.',
+    portal: 'https://developers.tiktok.com', portalLabel: 'TikTok for Developers',
+    platforms: ['tiktok'],
+    permissions: 'video.publish · video.upload · user.info.basic',
+    fields: [
+      { key: 'TIKTOK_CLIENT_KEY', label: 'Client Key', secret: false, ph: 'aw...' },
+      { key: 'TIKTOK_CLIENT_SECRET', label: 'Client Secret', secret: true, ph: 'client secret' },
+    ],
+  },
+  {
+    id: 'youtube', title: 'YouTube', icon: '▶️',
+    blurb: 'Google Cloud project with YouTube Data API v3 enabled + an OAuth client (type: Web application). OAuth connects now; the binary upload pipeline lands later, so posts stage until then.',
+    portal: 'https://console.cloud.google.com/apis/credentials', portalLabel: 'Google Cloud Console',
+    platforms: ['youtube'],
+    permissions: 'youtube.upload · youtube.readonly',
+    fields: [
+      { key: 'YOUTUBE_CLIENT_ID', label: 'OAuth Client ID', secret: false, ph: '...apps.googleusercontent.com' },
+      { key: 'YOUTUBE_CLIENT_SECRET', label: 'OAuth Client Secret', secret: true, ph: 'GOCSPX-...' },
+    ],
+  },
+];
+
+function setEnvKeys(updates) {
+  let lines = [];
+  try { lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n'); } catch { /* new file */ }
+  for (const [k, v] of Object.entries(updates)) {
+    lines = lines.filter(l => !l.startsWith(k + '='));
+    if (v !== '' && v != null) { lines.push(`${k}=${v}`); process.env[k] = String(v); }
+    else { delete process.env[k]; }
+  }
+  const out = lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '');
+  fs.writeFileSync(ENV_PATH, out.endsWith('\n') ? out : out + '\n', { mode: 0o600 });
+}
+
 // Per-platform OAuth config. clientId/secret come from env (Steve pastes once).
 const OAUTH = {
   youtube: {
@@ -183,6 +243,40 @@ module.exports = {
     });
     router.get('/outbox', (_req, res) => res.json({ items: readOutbox().slice(-100).reverse() }));
 
+    // Activate page data: provider cards + which keys are already set (booleans only,
+    // never the values) + the redirect URIs to register in each portal.
+    router.get('/setup', (_req, res) => {
+      const st = platformStatus();
+      const providers = SETUP_PROVIDERS.map(p => ({
+        id: p.id, title: p.title, icon: p.icon, blurb: p.blurb,
+        portal: p.portal, portalLabel: p.portalLabel, permissions: p.permissions,
+        redirectUris: p.platforms.map(pl => ({ platform: pl, uri: redirectUri(pl) })),
+        fields: p.fields.map(f => ({ key: f.key, label: f.label, secret: f.secret, ph: f.ph, set: !!env(f.key) })),
+        platforms: p.platforms.map(pl => ({
+          id: pl, label: st[pl].label, connected: st[pl].connected,
+          configured: st[pl].configured, connectUrl: st[pl].connectUrl,
+        })),
+      }));
+      res.json({ redirectBase: redirectBase(), providers });
+    });
+
+    // Save app credentials → .env (allowlisted keys only). Blank value = keep
+    // existing (we simply skip it) so re-saving one field doesn't wipe the rest.
+    router.post('/config', (req, res) => {
+      const keys = (req.body && req.body.keys) || {};
+      const updates = {};
+      for (const [k, v] of Object.entries(keys)) {
+        if (!ALLOWED_KEYS.has(k)) return res.status(400).json({ error: `key not allowed: ${k}` });
+        if (typeof v !== 'string') return res.status(400).json({ error: `bad value for ${k}` });
+        const t = v.trim();
+        if (t === '') continue; // blank = leave as-is
+        updates[k] = t;
+      }
+      if (!Object.keys(updates).length) return res.status(400).json({ error: 'nothing to save' });
+      try { setEnvKeys(updates); } catch (e) { return res.status(500).json({ error: e.message }); }
+      res.json({ ok: true, saved: Object.keys(updates), status: platformStatus() });
+    });
+
     // OAuth: start the connect flow → redirect the browser to the platform consent.
     router.get('/connect/:platform', (req, res) => {
       const url = authorizeUrl(req.params.platform);
diff --git a/public/panels/channels.html b/public/panels/channels.html
index 8f9e60f..812fab1 100644
--- a/public/panels/channels.html
+++ b/public/panels/channels.html
@@ -1,11 +1,16 @@
 <div class="muted-banner" id="ch-banner">Loading channel status…</div>
+
 <div class="card">
-  <h2>Connected channels</h2>
-  <div class="muted" style="margin-bottom:12px">The publishing engine posts to every connected account at once. Connect each platform to go live; until then posts stage in the outbox.</div>
-  <div class="grid" id="ch-platforms" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))"></div>
+  <h2>① Activate channels</h2>
+  <div class="muted" style="margin-bottom:8px">Paste each app's keys below — they're written to the server <code>.env</code> and <b>never shown back</b>. Then add the redirect URI in that app's portal and click <b>Connect</b> to authorize in your browser.</div>
+  <div id="ch-hosthint"></div>
+  <div id="ch-setup" class="grid" style="grid-template-columns:repeat(auto-fill,minmax(360px,1fr))">
+    <div class="muted">Loading setup…</div>
+  </div>
 </div>
+
 <div class="card">
-  <h2>Composer</h2>
+  <h2>② Composer</h2>
   <div class="row">
     <div style="flex:1;min-width:280px">
       <label>Caption</label>
@@ -22,7 +27,18 @@
     </div>
   </div>
 </div>
+
 <div class="card">
   <h2>Outbox</h2>
   <div id="ch-outbox" class="muted">No posts yet.</div>
 </div>
+
+<style>
+  .setup-card .copyrow{display:flex;gap:6px;align-items:center;margin:3px 0}
+  .setup-card .copyrow code{flex:1;background:#f3efe7;border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:11px;overflow:auto;white-space:nowrap}
+  .setup-card .btn.xs{padding:4px 9px;font-size:11px}
+  .setup-card .fld{margin-top:8px}
+  .setup-card .fld label{display:flex;gap:6px;align-items:center}
+  .setup-card .saved-pill{background:#e3efe0;color:#3a6b3a;font-size:9.5px;padding:1px 7px}
+  .ch-sublabel{font-size:11.5px;font-weight:700;color:var(--mut);margin:11px 0 3px;text-transform:uppercase;letter-spacing:.4px}
+</style>
diff --git a/public/panels/channels.js b/public/panels/channels.js
index 65ef279..232374b 100644
--- a/public/panels/channels.js
+++ b/public/panels/channels.js
@@ -1,33 +1,96 @@
 window.MCC_PANELS = window.MCC_PANELS || {};
 window.MCC_PANELS['channels'] = {
   async init(root) {
+    const ORIGIN = location.origin;
     const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
     const $ = s => root.querySelector(s);
+    const jget = async u => (await fetch(ORIGIN + u, { credentials: 'same-origin' })).json();
     let status = {};
+
     async function loadStatus() {
-      status = await (await fetch('/api/channels/status')).json();
+      status = await jget('/api/channels/status');
       const ps = status.platforms || {};
       $('#ch-banner').innerHTML = `${status.connectedCount}/${status.total} channels connected · ${status.outbox} in outbox. ` +
-        (status.connectedCount === 0 ? 'No channels connected yet — authorize each platform to enable live posting.' : 'Connected channels post live; the rest stage.');
-      $('#ch-platforms').innerHTML = Object.entries(ps).map(([k, p]) => `
-        <div class="card" style="margin:0;border-color:${p.connected ? 'var(--sage)' : 'var(--line)'}">
-          <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.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
+        (status.connectedCount === 0 ? 'No channels connected yet — activate each platform below to enable live posting.' : 'Connected channels post live; the rest stage.');
       $('#ch-targets').innerHTML = Object.entries(ps).map(([k, p]) =>
         `<label style="display:flex;align-items:center;gap:6px;font-weight:600;color:var(--ink)"><input type="checkbox" class="ch-t" value="${k}" style="width:auto"> ${p.icon} ${esc(p.label)}${p.connected ? '' : ' <span class="muted" style="font-weight:400">(stages)</span>'}</label>`).join('');
     }
+
+    // ── ① Activate page ──────────────────────────────────────────────────────
+    async function loadSetup() {
+      const d = await jget('/api/channels/setup');
+      // Host hint: OAuth completes on the redirect-base host, so creds must live there.
+      try {
+        const baseHost = new URL(d.redirectBase).host;
+        if (location.host !== baseHost) {
+          $('#ch-hosthint').innerHTML =
+            `<div class="muted-banner" style="background:#fff8ec;border-color:#f0e0c0;color:#7a5a2a">
+              ⚠ Authorization finishes on <b>${esc(baseHost)}</b> (your OAuth redirect host). Save your keys and click Connect on
+              <a href="${esc(d.redirectBase)}/#channels" target="_blank"><b>the live activation page ↗</b></a> so the browser hand-off can complete.</div>`;
+        } else { $('#ch-hosthint').innerHTML = ''; }
+      } catch { $('#ch-hosthint').innerHTML = ''; }
+
+      $('#ch-setup').innerHTML = d.providers.map(p => {
+        const statusPills = p.platforms.map(pl =>
+          `<span class="pill" style="background:${pl.connected ? '#e3efe0' : pl.configured ? '#eef3f8' : '#f3e9e9'};color:${pl.connected ? '#3a6b3a' : pl.configured ? '#3a5a8a' : '#9a5a5a'}">${esc(pl.label)}: ${pl.connected ? 'connected' : pl.configured ? 'ready' : 'needs keys'}</span>`).join(' ');
+        const redirects = p.redirectUris.map(r =>
+          `<div class="copyrow"><code>${esc(r.uri)}</code><button class="btn ghost xs" data-copy="${esc(r.uri)}">copy</button></div>`).join('');
+        const fields = p.fields.map(f =>
+          `<div class="fld"><label>${esc(f.label)} ${f.set ? '<span class="pill saved-pill">saved</span>' : ''}</label>
+            <input type="${f.secret ? 'password' : 'text'}" data-k="${esc(f.key)}" autocomplete="off"
+              placeholder="${f.set ? '•••••• saved — leave blank to keep' : esc(f.ph || '')}"></div>`).join('');
+        const connects = p.platforms.map(pl =>
+          pl.connected ? `<span class="pill" style="background:#e3efe0;color:#3a6b3a">✓ ${esc(pl.label)}</span>`
+            : (pl.connectUrl ? `<a class="btn gated" style="text-decoration:none" href="${ORIGIN}${esc(pl.connectUrl)}">Connect ${esc(pl.label)} →</a>` : '')
+        ).join(' ');
+        return `<div class="card setup-card" style="margin:0">
+          <div style="display:flex;align-items:center;gap:8px"><span style="font-size:20px">${p.icon}</span><b>${esc(p.title)}</b></div>
+          <div style="margin:6px 0 4px">${statusPills}</div>
+          <div class="muted" style="font-size:12px;margin:6px 0 10px">${esc(p.blurb)}</div>
+          <a class="btn ghost xs" target="_blank" rel="noopener noreferrer" href="${esc(p.portal)}">Open ${esc(p.portalLabel)} ↗</a>
+          <div class="ch-sublabel">Redirect URI — add in portal</div>${redirects}
+          <div class="ch-sublabel">Permissions / scopes</div>
+          <div class="muted" style="font-size:11.5px">${esc(p.permissions)}</div>
+          <div class="ch-sublabel">App credentials</div>${fields}
+          <div class="row" style="margin-top:12px;align-items:center">
+            <button class="btn gold" data-save="${esc(p.id)}">Save credentials</button>
+            ${connects}
+            <span class="muted setup-msg" data-msg="${esc(p.id)}" style="font-size:12px"></span>
+          </div>
+        </div>`;
+      }).join('');
+
+      // copy buttons
+      root.querySelectorAll('[data-copy]').forEach(b => b.onclick = async () => {
+        try { await navigator.clipboard.writeText(b.dataset.copy); const t = b.textContent; b.textContent = 'copied ✓'; setTimeout(() => b.textContent = t, 1200); }
+        catch { b.textContent = 'select & ⌘C'; }
+      });
+      // save buttons
+      root.querySelectorAll('[data-save]').forEach(b => b.onclick = async () => {
+        const card = b.closest('.setup-card');
+        const msg = card.querySelector('.setup-msg');
+        const keys = {};
+        card.querySelectorAll('input[data-k]').forEach(i => { if (i.value.trim()) keys[i.dataset.k] = i.value.trim(); });
+        if (!Object.keys(keys).length) { msg.textContent = 'nothing to save (fields blank)'; return; }
+        msg.textContent = 'saving…'; b.disabled = true;
+        try {
+          const r = await fetch(ORIGIN + '/api/channels/config', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ keys }) });
+          const j = await r.json();
+          if (j.error) { msg.textContent = '✗ ' + j.error; }
+          else { msg.textContent = `✓ saved ${j.saved.length} key(s) — Connect now lit`; await loadStatus(); await loadSetup(); }
+        } catch (e) { msg.textContent = '✗ ' + e.message; }
+        b.disabled = false;
+      });
+    }
+
     async function loadOutbox() {
-      const d = await (await fetch('/api/channels/outbox')).json();
+      const d = await jget('/api/channels/outbox');
       $('#ch-outbox').innerHTML = (d.items || []).length ? d.items.map(it => `
         <div class="row" style="justify-content:space-between;border-bottom:1px solid var(--line);padding:7px 2px">
           <span>${esc(it.channel)} · <span class="muted">${esc((it.caption || '').slice(0, 50))}</span></span>
           <span class="pill">${esc(it.status)}</span></div>`).join('') : '<div class="muted">No posts yet.</div>';
     }
+
     async function send(live) {
       const channels = [...root.querySelectorAll('.ch-t:checked')].map(c => c.value);
       const caption = $('#ch-caption').value.trim(); const mediaUrl = $('#ch-media').value.trim();
@@ -36,13 +99,14 @@ window.MCC_PANELS['channels'] = {
       if (live && !confirm(`PUBLISH LIVE to: ${channels.join(', ')}?\n\nConnected channels post immediately; unconnected ones stage. Proceed?`)) return;
       $('#ch-msg').textContent = live ? 'publishing…' : 'staging…';
       const body = { channels, caption, mediaUrl, confirm: live, dryRun: !live };
-      const r = await fetch('/api/channels/publish', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
+      const r = await fetch(ORIGIN + '/api/channels/publish', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body) });
       const d = await r.json();
       $('#ch-msg').textContent = d.error ? '✗ ' + d.error : (d.note || 'done');
       loadOutbox(); loadStatus();
     }
     $('#ch-stage').onclick = () => send(false);
     $('#ch-publish').onclick = () => send(true);
-    await loadStatus(); await loadOutbox();
+
+    await loadStatus(); await loadSetup(); await loadOutbox();
   },
 };

← 2b26633 MCC shell: fetch against location.origin so embedded-URL cre  ·  back to Marketing Command Center  ·  Asset Library = Image Bank: add GDrive (folder rclone-import 267a4bb →