← back to Marketing Command Center
public/panels/accounts.js
201 lines
// 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 => {
const r = await fetch(ORIGIN + u, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`${u} → HTTP ${r.status}`);
return r.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();
},
};