[object Object]

← back to Marketing Command Center

Add Accounts panel: Amazon-style social-account filter rail with connection status + inline how-to-fix credentials

3b13d2e8500ab84185fabe7d50730f3f2d474231 · 2026-07-14 11:27:25 -0700 · Steve

Files touched

Diff

commit 3b13d2e8500ab84185fabe7d50730f3f2d474231
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 14 11:27:25 2026 -0700

    Add Accounts panel: Amazon-style social-account filter rail with connection status + inline how-to-fix credentials
---
 modules/accounts/index.js   | 135 ++++++++++++++++++++++++++++++
 modules/channels/index.js   |  12 +++
 modules/registry.js         |   1 +
 public/app.js               |   2 +-
 public/panels/accounts.html | 105 ++++++++++++++++++++++++
 public/panels/accounts.js   | 196 ++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 450 insertions(+), 1 deletion(-)

diff --git a/modules/accounts/index.js b/modules/accounts/index.js
new file mode 100644
index 0000000..f47722b
--- /dev/null
+++ b/modules/accounts/index.js
@@ -0,0 +1,135 @@
+// Accounts — a single Amazon-style rail listing EVERY social account with its
+// live connection status and, when credentials are missing, the exact "how to
+// fix" (which env keys to paste, the app portal, the redirect URI to register,
+// the permissions to request, and the one-click Connect URL when OAuth is ready).
+//
+// This module owns NO credential logic of its own — it is a read-only VIEW that
+// aggregates the authoritative sources so the truth lives in exactly one place:
+//   • social platforms (FB/IG/TikTok/YouTube/Bluesky/Threads) → channels module
+//   • LinkedIn                                                 → its own env keys
+// If channels changes how "connected" is decided, this rail follows for free.
+const channels = require('../channels');
+
+const env = (k) => (process.env[k] || '').trim();
+
+// Map each social platform → the setup provider card that documents how to fix it
+// (portal, permissions, redirect URIs, the env fields to paste). Built from the
+// channels module's own SETUP_PROVIDERS so the copy never diverges.
+function providerByPlatform() {
+  const out = {};
+  for (const p of channels.setupProviders()) {
+    for (const pl of p.platforms) out[pl] = p;
+  }
+  return out;
+}
+
+// LinkedIn isn't part of the channels publish engine (separate Phase-2 module),
+// so its how-to-fix is described here from the same env keys it reads.
+function linkedinAccount() {
+  const token = env('LINKEDIN_ACCESS_TOKEN');
+  const orgUrn = env('LINKEDIN_ORG_URN');
+  const personUrn = env('LINKEDIN_AUTHOR_URN');
+  const connected = !!(token && (orgUrn || personUrn));
+  const handle = orgUrn || personUrn || null;
+  return {
+    platform: 'linkedin',
+    label: 'LinkedIn',
+    icon: '💼',
+    connected,
+    configured: connected,
+    accounts: handle ? [handle] : [],
+    caveat: connected ? null : 'Phase 2 — live posting stages until a token + author/org URN are set.',
+    needs: 'A LinkedIn access token plus the author URN (personal profile) or org URN (Company Page).',
+    scopes: ['w_member_social', 'r_liteprofile'],
+    connectUrl: null,
+    howToFix: {
+      portal: 'https://www.linkedin.com/developers/apps',
+      portalLabel: 'LinkedIn Developers',
+      permissions: 'w_member_social · r_liteprofile (or r_organization_social for a Company Page)',
+      redirectUris: [],
+      fields: [
+        { key: 'LINKEDIN_ACCESS_TOKEN', label: 'Access Token', secret: true, set: !!token },
+        { key: 'LINKEDIN_AUTHOR_URN', label: 'Author URN (personal)', secret: false, set: !!personUrn },
+        { key: 'LINKEDIN_ORG_URN', label: 'Org URN (Company Page)', secret: false, set: !!orgUrn },
+      ],
+    },
+  };
+}
+
+// Normalize the channels platformStatus() map + its setup providers into the flat,
+// UI-ready account list the rail renders.
+function buildAccounts() {
+  const st = channels.platformStatus();
+  const byPlatform = providerByPlatform();
+  const redir = channels.redirectUriFor;
+
+  const list = Object.entries(st).map(([platform, s]) => {
+    const prov = byPlatform[platform];
+    const howToFix = prov ? {
+      portal: prov.portal,
+      portalLabel: prov.portalLabel,
+      permissions: prov.permissions,
+      // redirect URI(s) to register for THIS platform in its portal (OAuth apps only)
+      redirectUris: prov.noRedirect ? [] : [{ platform, uri: redir(platform) }],
+      // the env fields for the provider group, flagged with whether each is already set
+      fields: (prov.fields || []).map(f => ({ key: f.key, label: f.label, secret: !!f.secret, set: !!env(f.key) })),
+    } : null;
+    return {
+      platform,
+      label: s.label,
+      icon: s.icon,
+      connected: !!s.connected,
+      configured: !!s.configured,
+      accounts: s.accounts || [],
+      caveat: s.caveat || null,
+      needs: s.needs || null,
+      scopes: s.scopes || [],
+      connectUrl: s.connectUrl || null,
+      howToFix,
+    };
+  });
+
+  list.push(linkedinAccount());
+
+  // Stable, sensible order: Social Media Media leads with the big four, then the rest.
+  const ORDER = ['facebook', 'instagram', 'tiktok', 'youtube', 'linkedin', 'bluesky', 'threads'];
+  list.sort((a, b) => {
+    const ia = ORDER.indexOf(a.platform), ib = ORDER.indexOf(b.platform);
+    return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
+  });
+  return list;
+}
+
+// A per-account "bucket" so the left-rail status facets can count + filter:
+//   connected      — creds present, posts go live
+//   needs-creds    — no creds at all → must paste keys / connect
+//   staged-only    — creds present BUT a caveat means posts still stage (audit/pipeline gap)
+function bucketOf(a) {
+  if (!a.connected) return 'needs-creds';
+  if (a.caveat) return 'staged-only';
+  return 'connected';
+}
+
+module.exports = {
+  id: 'accounts',
+  title: 'Accounts',
+  icon: '🔑',
+  mount(router) {
+    router.get('/list', (_req, res) => {
+      try {
+        const accounts = buildAccounts().map(a => ({ ...a, bucket: bucketOf(a) }));
+        const counts = accounts.reduce((m, a) => (m[a.bucket] = (m[a.bucket] || 0) + 1, m), {});
+        res.json({
+          accounts,
+          total: accounts.length,
+          counts: {
+            all: accounts.length,
+            connected: counts['connected'] || 0,
+            'staged-only': counts['staged-only'] || 0,
+            'needs-creds': counts['needs-creds'] || 0,
+          },
+        });
+      } catch (e) { res.status(500).json({ error: e.message }); }
+    });
+  },
+};
diff --git a/modules/channels/index.js b/modules/channels/index.js
index 8f89de4..20ca3b8 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -70,6 +70,9 @@ const ALLOWED_KEYS = new Set([
   'YOUTUBE_CLIENT_ID', 'YOUTUBE_CLIENT_SECRET', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'YOUTUBE_ACCESS_TOKEN', 'YOUTUBE_REFRESH_TOKEN',
   'BLUESKY_HANDLE', 'BLUESKY_APP_PASSWORD',
   'THREADS_ACCESS_TOKEN', 'THREADS_USER_ID',
+  // LinkedIn (Phase 2) — allowed so the Accounts panel's one-stop "fix" can save
+  // them the same way; the linkedin module reads these same keys from process.env.
+  'LINKEDIN_ACCESS_TOKEN', 'LINKEDIN_AUTHOR_URN', 'LINKEDIN_ORG_URN',
   'OAUTH_REDIRECT_BASE',
 ]);
 // Provider cards the front-end renders from. id is the form group only.
@@ -505,3 +508,12 @@ module.exports = {
     });
   },
 };
+
+// ── Shared read-only exports for the Accounts panel ───────────────────────────
+// The Accounts rail is a VIEW over the same credential truth this module owns.
+// Rather than duplicate the env checks (and risk drift), expose the authoritative
+// helpers so `modules/accounts` can aggregate without re-deriving. Additive — the
+// server only reads id/title/icon/mount, so these extra props are inert to it.
+module.exports.platformStatus = platformStatus;
+module.exports.setupProviders = () => SETUP_PROVIDERS;
+module.exports.redirectUriFor = redirectUri;
diff --git a/modules/registry.js b/modules/registry.js
index 2022f7b..40bf333 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -7,6 +7,7 @@
 // — never this file or server.js (avoids cross-agent conflicts). To add a module,
 // the integrator appends one line here.
 module.exports = [
+  'accounts',           // Amazon-style rail: every social account + connection status + inline "how to fix" credentials (read-only view over channels + linkedin)
   'board',              // Hootsuite-style streams board: one column per channel (social + email), dated cards
   'constant-contact',   // CC: campaigns, contacts, calendar, draft + schedule (gated sends)
   'calendar',           // marketing calendar: retail/holiday dates + campaign schedule
diff --git a/public/app.js b/public/app.js
index e9ca949..5a30805 100644
--- a/public/app.js
+++ b/public/app.js
@@ -9,7 +9,7 @@ let PANELS = [];
 
 // Curated nav groups. Social Media leads. Any panel not listed falls into "More".
 const GROUPS = [
-  { name: 'Social Media', ids: ['board', 'social', 'channels', 'linkedin'] },
+  { name: 'Social Media', ids: ['accounts', 'board', 'social', 'channels', 'linkedin'] },
   { name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'templates'] },
   { name: 'Email', ids: ['constant-contact', 'send-times'] },
   { name: 'Audience', ids: ['segments', 'profiles', 'segment-perf'] },
diff --git a/public/panels/accounts.html b/public/panels/accounts.html
new file mode 100644
index 0000000..f8913ea
--- /dev/null
+++ b/public/panels/accounts.html
@@ -0,0 +1,105 @@
+<div class="muted-banner" id="acc-banner">Loading accounts…</div>
+
+<!-- Amazon-style layout: faceted filter rail LEFT, account detail RIGHT -->
+<div class="acc-wrap">
+
+  <!-- ── LEFT: the filter panel (facets + the full account list) ─────────── -->
+  <aside class="acc-rail">
+    <div class="acc-railcard">
+      <input type="search" id="acc-search" placeholder="Filter accounts…" autocomplete="off">
+    </div>
+
+    <div class="acc-railcard">
+      <div class="acc-railhead">Status</div>
+      <div id="acc-facets" class="acc-facets"><div class="muted" style="font-size:12px">Loading…</div></div>
+    </div>
+
+    <div class="acc-railcard">
+      <div class="acc-railhead">Accounts <span id="acc-listcount" class="acc-count"></span></div>
+      <div id="acc-list" class="acc-list"><div class="muted" style="font-size:12px">Loading…</div></div>
+    </div>
+  </aside>
+
+  <!-- ── RIGHT: detail for the selected account (or an all-accounts grid) ─── -->
+  <section class="acc-main" id="acc-main">
+    <div class="muted">Select an account on the left.</div>
+  </section>
+</div>
+
+<template id="acc-row-tpl">
+  <button class="acc-row" type="button">
+    <span class="acc-ic"></span>
+    <span class="acc-name"></span>
+    <span class="acc-dot"></span>
+  </button>
+</template>
+
+<style>
+.acc-wrap{display:grid;grid-template-columns:264px minmax(0,1fr);gap:18px;align-items:start}
+@media(max-width:900px){.acc-wrap{grid-template-columns:1fr}}
+
+/* left rail */
+.acc-rail{display:flex;flex-direction:column;gap:12px;position:sticky;top:8px}
+.acc-railcard{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px}
+.acc-railcard #acc-search{width:100%;box-sizing:border-box}
+.acc-railhead{font:700 11px/1 Inter;letter-spacing:.5px;text-transform:uppercase;color:var(--mut);margin-bottom:10px}
+.acc-count{color:var(--mut);font-weight:400}
+.acc-facets{display:flex;flex-direction:column;gap:2px}
+.acc-facet{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:transparent;border:0;
+ border-radius:8px;padding:7px 8px;cursor:pointer;font:500 13px/1.2 Inter;color:var(--ink)}
+.acc-facet:hover{background:var(--cream)}
+.acc-facet.on{background:var(--cream);font-weight:700}
+.acc-facet .fdot{width:8px;height:8px;border-radius:50%;flex:0 0 auto}
+.acc-facet .fct{margin-left:auto;font-size:11px;color:var(--mut);background:#fff;border:1px solid var(--line);
+ border-radius:999px;padding:1px 8px;font-weight:600}
+.acc-facet.on .fct{background:var(--accent);color:#fff;border-color:var(--accent)}
+
+.acc-list{display:flex;flex-direction:column;gap:1px;max-height:60vh;overflow:auto}
+.acc-row{display:flex;align-items:center;gap:9px;width:100%;text-align:left;background:transparent;border:0;
+ border-radius:8px;padding:8px 8px;cursor:pointer;font:500 13.5px/1.2 Inter;color:var(--ink)}
+.acc-row:hover{background:var(--cream)}
+.acc-row.on{background:var(--cream);box-shadow:inset 3px 0 0 var(--accent)}
+.acc-ic{font-size:16px;flex:0 0 auto;width:20px;text-align:center}
+.acc-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.acc-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}
+.dot-ok{background:#3a8a4a}
+.dot-warn{background:#c79a3a}
+.dot-off{background:#c04a4a}
+
+/* right detail */
+.acc-main{min-height:200px}
+.acc-head{display:flex;align-items:center;gap:12px;margin-bottom:4px}
+.acc-head .h-ic{font-size:30px}
+.acc-head h2{margin:0;font:600 22px/1.1 "Cormorant Garamond",Georgia,serif}
+.acc-badge{font:700 10.5px/1 Inter;letter-spacing:.4px;text-transform:uppercase;border-radius:999px;padding:4px 10px}
+.badge-ok{background:#e3efe0;color:#2f6b34}
+.badge-warn{background:#fdf3dc;color:#8a6a1e}
+.badge-off{background:#f7e2e2;color:#a53a3a}
+.acc-handles{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0}
+.acc-note{font-size:13px;line-height:1.5;color:#4a443c;margin:6px 0}
+.acc-caveat{background:#fff8ec;border:1px solid #f0e0c0;border-radius:10px;padding:10px 12px;color:#7a5a2a;
+ font-size:12.5px;line-height:1.5;margin:10px 0}
+.fix{border:1px solid var(--line);border-radius:12px;padding:16px;margin-top:14px;background:#fff}
+.fix h3{margin:0 0 4px;font:700 13px/1.2 Inter;text-transform:uppercase;letter-spacing:.4px;color:var(--accent)}
+.fix .step{display:flex;gap:10px;padding:10px 0;border-bottom:1px solid var(--line)}
+.fix .step:last-child{border-bottom:0}
+.fix .n{flex:0 0 22px;height:22px;border-radius:50%;background:var(--cream);color:var(--accent);
+ font:700 12px/22px Inter;text-align:center}
+.fix .sbody{flex:1;min-width:0}
+.fix .sbody b{font-size:13px}
+.fix .copyrow{display:flex;gap:6px;align-items:center;margin:6px 0}
+.fix .copyrow code{flex:1;background:#f3efe7;border:1px solid var(--line);border-radius:7px;padding:6px 9px;
+ font-size:11.5px;overflow:auto;white-space:nowrap}
+.fix .fld{margin:8px 0}
+.fix .fld label{display:flex;align-items:center;gap:7px;font-size:12.5px;font-weight:600;margin-bottom:4px}
+.fix .fld input{width:100%;box-sizing:border-box}
+.fix .set-pill{background:#e3efe0;color:#3a6b3a;font:700 9.5px/1 Inter;border-radius:999px;padding:2px 7px}
+.fix .miss-pill{background:#f7e2e2;color:#a53a3a;font:700 9.5px/1 Inter;border-radius:999px;padding:2px 7px}
+.fix .perm{font-size:12px;color:#5a544b;background:var(--cream);border-radius:8px;padding:7px 10px}
+.acc-summary{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
+.acc-scard{border:1px solid var(--line);border-radius:11px;padding:12px;background:#fff;cursor:pointer}
+.acc-scard:hover{border-color:var(--accent)}
+.acc-scard .st{display:flex;align-items:center;gap:8px;font-weight:600}
+.acc-scard .ss{font-size:11.5px;color:var(--mut);margin-top:6px}
+.btn.xs{padding:5px 10px;font-size:12px}
+</style>
diff --git a/public/panels/accounts.js b/public/panels/accounts.js
new file mode 100644
index 0000000..1fe0f99
--- /dev/null
+++ b/public/panels/accounts.js
@@ -0,0 +1,196 @@
+// Accounts panel — Amazon-style faceted rail over every social account.
+// LEFT: status facets (All / Connected / Staged-only / Needs credentials) + a
+// searchable list of all accounts, each with a live status dot. RIGHT: the
+// selected account's connection detail and, when creds are missing, an inline
+// "how to fix" (paste keys → saved to .env via the channels config route, plus
+// the portal link, redirect URI, permissions, and a Connect button for OAuth).
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['accounts'] = {
+  init(root) {
+    const ORIGIN = location.origin;
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+    const $ = sel => root.querySelector(sel);
+    const jget = async u => (await fetch(ORIGIN + u, { credentials: 'same-origin' })).json();
+
+    const FACETS = [
+      { key: 'all',         label: 'All accounts',     dot: null },
+      { key: 'connected',   label: 'Connected',        dot: 'dot-ok' },
+      { key: 'staged-only', label: 'Staged only',      dot: 'dot-warn' },
+      { key: 'needs-creds', label: 'Needs credentials', dot: 'dot-off' },
+    ];
+    const DOT = { 'connected': 'dot-ok', 'staged-only': 'dot-warn', 'needs-creds': 'dot-off' };
+    const BADGE = { 'connected': ['badge-ok', 'Connected · live'], 'staged-only': ['badge-warn', 'Connected · stages'], 'needs-creds': ['badge-off', 'Needs credentials'] };
+
+    const state = { accounts: [], counts: {}, facet: 'all', q: '', selected: null };
+    const rowTpl = $('#acc-row-tpl');
+
+    async function load() {
+      let d;
+      try { d = await jget('/api/accounts/list'); }
+      catch (_) { $('#acc-banner').textContent = 'Could not load accounts.'; return; }
+      state.accounts = d.accounts || [];
+      state.counts = d.counts || {};
+      const c = state.counts;
+      $('#acc-banner').innerHTML =
+        `<b>${c.connected || 0}</b> connected · <b>${c['staged-only'] || 0}</b> staged-only · <b>${c['needs-creds'] || 0}</b> need credentials — of ${c.all || 0} accounts. ` +
+        `Pick one to see its status and how to fix it.`;
+      // keep the current selection if it still exists, else default to first that needs creds
+      if (state.selected && !state.accounts.find(a => a.platform === state.selected))
+        state.selected = null;
+      if (!state.selected) {
+        const firstBroken = state.accounts.find(a => a.bucket === 'needs-creds');
+        state.selected = firstBroken ? firstBroken.platform : (state.accounts[0] && state.accounts[0].platform) || null;
+      }
+      renderFacets();
+      renderList();
+      renderDetail();
+    }
+
+    function renderFacets() {
+      const box = $('#acc-facets');
+      box.innerHTML = '';
+      for (const f of FACETS) {
+        const b = document.createElement('button');
+        b.type = 'button';
+        b.className = 'acc-facet' + (state.facet === f.key ? ' on' : '');
+        const n = state.counts[f.key] != null ? state.counts[f.key] : (f.key === 'all' ? state.accounts.length : 0);
+        b.innerHTML =
+          (f.dot ? `<span class="fdot ${f.dot}"></span>` : `<span class="fdot" style="background:transparent;border:1px solid var(--line)"></span>`) +
+          `<span>${esc(f.label)}</span><span class="fct">${n}</span>`;
+        b.onclick = () => { state.facet = f.key; renderFacets(); renderList(); };
+        box.appendChild(b);
+      }
+    }
+
+    function visible() {
+      const q = state.q.trim().toLowerCase();
+      return state.accounts.filter(a => {
+        if (state.facet !== 'all' && a.bucket !== state.facet) return false;
+        if (q && !(a.label.toLowerCase().includes(q) || a.platform.includes(q) ||
+                   (a.accounts || []).some(h => String(h).toLowerCase().includes(q)))) return false;
+        return true;
+      });
+    }
+
+    function renderList() {
+      const box = $('#acc-list');
+      const list = visible();
+      $('#acc-listcount').textContent = `(${list.length})`;
+      box.innerHTML = '';
+      if (!list.length) { box.innerHTML = '<div class="muted" style="font-size:12px;padding:6px">No accounts match.</div>'; return; }
+      for (const a of list) {
+        const node = rowTpl.content.firstElementChild.cloneNode(true);
+        node.querySelector('.acc-ic').textContent = a.icon || '•';
+        node.querySelector('.acc-name').textContent = a.label;
+        node.querySelector('.acc-dot').className = 'acc-dot ' + (DOT[a.bucket] || 'dot-off');
+        node.title = BADGE[a.bucket] ? BADGE[a.bucket][1] : '';
+        if (a.platform === state.selected) node.classList.add('on');
+        node.onclick = () => { state.selected = a.platform; renderList(); renderDetail(); };
+        box.appendChild(node);
+      }
+    }
+
+    function renderDetail() {
+      const main = $('#acc-main');
+      const a = state.accounts.find(x => x.platform === state.selected);
+      if (!a) { main.innerHTML = '<div class="muted">Select an account on the left.</div>'; return; }
+
+      const [badgeCls, badgeTxt] = BADGE[a.bucket] || ['badge-off', 'Unknown'];
+      const handles = (a.accounts || []).length
+        ? `<div class="acc-handles">${a.accounts.map(h => `<span class="pill">${esc(h)}</span>`).join('')}</div>`
+        : `<div class="acc-note muted">No account handle discovered yet.</div>`;
+      const caveat = a.caveat ? `<div class="acc-caveat">⚠ ${esc(a.caveat)}</div>` : '';
+
+      let html =
+        `<div class="acc-head">
+           <span class="h-ic">${a.icon || '•'}</span>
+           <h2>${esc(a.label)}</h2>
+           <span class="acc-badge ${badgeCls}">${esc(badgeTxt)}</span>
+         </div>
+         ${handles}
+         ${a.needs ? `<div class="acc-note"><b>What it needs:</b> ${esc(a.needs)}</div>` : ''}
+         ${caveat}`;
+
+      // "How to fix" — only when there's something to do (not fully connected) and
+      // we have provider docs for it.
+      if (a.bucket !== 'connected' && a.howToFix) {
+        html += renderFix(a);
+      } else if (a.bucket === 'connected') {
+        html += `<div class="fix"><h3>Connected</h3><div class="acc-note">This account is wired for live posting${a.scopes && a.scopes.length ? ` (scopes: ${a.scopes.map(esc).join(', ')})` : ''}. Nothing to fix.</div></div>`;
+      }
+      main.innerHTML = html;
+      wireFix(a, main);
+    }
+
+    function renderFix(a) {
+      const f = a.howToFix;
+      const redirects = (f.redirectUris || []).map(r =>
+        `<div class="copyrow"><code>${esc(r.uri)}</code><button class="btn ghost xs acc-copy" data-copy="${esc(r.uri)}" type="button">Copy</button></div>`).join('');
+      const fields = (f.fields || []).map(fl =>
+        `<div class="fld">
+           <label>${esc(fl.label)} <span class="${fl.set ? 'set-pill' : 'miss-pill'}">${fl.set ? 'SET' : 'MISSING'}</span></label>
+           <input type="${fl.secret ? 'password' : 'text'}" data-key="${esc(fl.key)}" placeholder="${fl.set ? '•••••• (already saved — leave blank to keep)' : 'paste ' + esc(fl.key)}" autocomplete="off">
+         </div>`).join('');
+
+      let steps = '';
+      let n = 1;
+      steps += `<div class="step"><div class="n">${n++}</div><div class="sbody">
+          <b>Create / open the app</b>
+          <div class="acc-note" style="margin:4px 0 0">${f.portal ? `<a href="${esc(f.portal)}" target="_blank" rel="noopener noreferrer">${esc(f.portalLabel || 'Developer portal')} ↗</a>` : 'See the platform developer portal.'}</div>
+        </div></div>`;
+      if (f.permissions) steps += `<div class="step"><div class="n">${n++}</div><div class="sbody">
+          <b>Request these permissions</b><div class="perm" style="margin-top:5px">${esc(f.permissions)}</div>
+        </div></div>`;
+      if (redirects) steps += `<div class="step"><div class="n">${n++}</div><div class="sbody">
+          <b>Register this redirect URI</b><div class="acc-note" style="margin:2px 0">Add it to the app's OAuth settings exactly.</div>${redirects}
+        </div></div>`;
+      if (fields) steps += `<div class="step"><div class="n">${n++}</div><div class="sbody">
+          <b>Paste the keys</b><div class="acc-note" style="margin:2px 0">Saved to the server <code>.env</code> and never shown back.</div>${fields}
+          <div style="margin-top:8px"><button class="btn gold xs acc-save" type="button">Save keys</button> <span class="muted acc-savemsg" style="font-size:12px"></span></div>
+        </div></div>`;
+      const connect = a.connectUrl
+        ? `<div class="step"><div class="n">${n++}</div><div class="sbody">
+             <b>Authorize in your browser</b><div class="acc-note" style="margin:2px 0">Keys are set — one click to mint the token.</div>
+             <a class="btn gated xs" href="${esc(ORIGIN + a.connectUrl)}" target="_blank" rel="noopener noreferrer">🔗 Connect ${esc(a.label)}</a>
+           </div></div>`
+        : '';
+
+      return `<div class="fix"><h3>How to fix</h3>${steps}${connect}</div>`;
+    }
+
+    function wireFix(a, main) {
+      main.querySelectorAll('.acc-copy').forEach(b => b.onclick = () => {
+        const t = b.getAttribute('data-copy');
+        navigator.clipboard && navigator.clipboard.writeText(t);
+        const o = b.textContent; b.textContent = 'Copied ✓'; setTimeout(() => b.textContent = o, 1200);
+      });
+      const saveBtn = main.querySelector('.acc-save');
+      if (saveBtn) saveBtn.onclick = async () => {
+        const keys = {};
+        main.querySelectorAll('input[data-key]').forEach(inp => {
+          const v = inp.value.trim();
+          if (v) keys[inp.getAttribute('data-key')] = v;
+        });
+        const msg = main.querySelector('.acc-savemsg');
+        if (!Object.keys(keys).length) { msg.textContent = 'Nothing to save — paste at least one key.'; return; }
+        saveBtn.disabled = true; msg.textContent = 'Saving…';
+        let r;
+        try {
+          r = await fetch(ORIGIN + '/api/channels/config', {
+            method: 'POST', headers: { 'Content-Type': 'application/json' },
+            credentials: 'same-origin', body: JSON.stringify({ keys }),
+          }).then(x => x.json());
+        } catch (_) { r = { error: 'request failed' }; }
+        saveBtn.disabled = false;
+        if (r && r.ok) { msg.textContent = '✓ Saved ' + (r.saved || []).join(', '); await load(); }
+        else { msg.textContent = '✗ ' + ((r && r.error) || 'save failed'); }
+      };
+    }
+
+    // search
+    $('#acc-search').addEventListener('input', e => { state.q = e.target.value; renderList(); });
+
+    load();
+  },
+};

← 897ebac chore: macstudio3 migration — reconcile from mac2 + repoint  ·  back to Marketing Command Center  ·  deploy: ship Accounts panel to Kamatera; fix .deploy.conf HE fa0d1c1 →