← 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
M modules/channels/index.jsM public/panels/channels.htmlM public/panels/channels.js
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 →