← 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
A modules/accounts/index.jsM modules/channels/index.jsM modules/registry.jsM public/app.jsA public/panels/accounts.htmlA public/panels/accounts.js
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 =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 →