/* Admin Panel — shell, overview, users management ====================
Reachable only by users with users.manage OR integrations.manage.
=================================================================== */
/* Platforms an ad account can be mapped to (drives the Ad-accounts SOURCES
column + the source-ID editor). Add a platform here and it renders + saves
everywhere — nothing else is hardcoded. Now unified under Zernio. */
const SRC_PLATFORMS = [
{ id:'meta', label:'Meta ad account ID', icon:'meta', hint:'via Zernio' },
{ id:'google', label:'Google customer ID', icon:'google', hint:'via Zernio' },
{ id:'tiktok', label:'TikTok advertiser ID', icon:'tiktok', hint:'via Zernio' },
{ id:'x', label:'X (Twitter) account ID', icon:'x', hint:'via Zernio' },
{ id:'linkedin', label:'LinkedIn account ID', icon:'linkedin', hint:'via Zernio' },
];
const SRC_PLATFORM_IDS = SRC_PLATFORMS.map(s=>s.id);
/* ---------- Client-portal shortcut (admin rail footer) -------------
The mirror of the portal's Settings → "Your other admin tool": one habit for
"where do I go to manage people". Relay manages the internal TEAM (this app's
`users` table); the client portal manages CLIENTS (its own `cv_*` tables, often a
different deployment and database). They are deliberately not merged, so a link is
the whole integration.
Two ways it resolves, in order:
1. Same-origin — on a full-suite deployment `client-admin.html` sits next to this
file, so we probe for it and need no configuration at all.
2. Operator-set URL, kept in localStorage. NOT hardcoded: this file ships in the
full-suite package, so a literal host would leak one operator's URL to the next.
Nothing renders until one of the two resolves. Per-device by design — it is a
shortcut, not shared configuration, and it stays out of the API contract. */
const PORTAL_LS_KEY = 'relay.portalAdminUrl';
const readPortalUrl = () => { try { return localStorage.getItem(PORTAL_LS_KEY) || ''; } catch(_){ return ''; } };
/* Same rule the portal's backend enforces on its side: absolute https, or a same-origin
path. Anything else (javascript:, data:, //host) is refused rather than sanitised. */
const validPortalUrl = u => /^https:\/\/[A-Za-z0-9][^\s"'<>`\\]{2,299}$/.test(u) || /^\/[^\s"'<>`\\/][^\s"'<>`\\]{0,299}$/.test(u);
function PortalShortcut(){
const [saved, setSaved] = useState(readPortalUrl);
const [detected, setDetected] = useState('');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [err, setErr] = useState('');
/* Probe once, only when nothing is configured. A 404/network error just means the
portal is not part of this deployment — stay silent, never surface it as an error. */
useEffect(()=>{
if (saved) return;
let dead = false;
fetch('/api/cv/admin/auth/status', { credentials:'same-origin' })
.then(r => { if (!dead && r.ok) setDetected('/client-admin.html'); })
.catch(()=>{});
return ()=>{ dead = true; };
}, [saved]);
const url = saved || detected;
const commit = () => {
const v = draft.trim();
if (v && !validPortalUrl(v)) { setErr('Use a full https:// address.'); return; }
try { v ? localStorage.setItem(PORTAL_LS_KEY, v) : localStorage.removeItem(PORTAL_LS_KEY); } catch(_){}
setSaved(v); setErr(''); setEditing(false);
};
const startEdit = () => { setDraft(saved); setErr(''); setEditing(true); };
if (editing) return (
{(() => {
/* Show an icon for every platform this account is actually
mapped to (canonical order), incl. X + LinkedIn. Falls back
to a dash when nothing is mapped. */
const shown = SRC_PLATFORM_IDS.filter(p=>src[p]);
return shown.length
? shown.map(p=> )
: ·;
})()}
Paused accounts are excluded from data pulls and hidden from non-admin users.
)}
);
}
/* ===================================================================
Admin panel shell
=================================================================== */
/* ---------- Client feeds: this dashboard → a client's portal ---------------
The credentials are issued on the PORTAL side (owner console → Workspaces →
Connect feed) and pasted here. That order is deliberate: the portal decides who
may receive, this side decides which account is sent. Neither end can help
itself to the other's data. */
function ClientFeeds({ accounts, readOnly }){
const [links, setLinks] = useState(null);
const [err, setErr] = useState('');
const [busy, setBusy] = useState('');
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState({ accountId:'', label:'', hookUrl:'', secret:'', windowDays:28, dimMode:'platform' });
const load = async () => {
try { setLinks((await RelayAPI.get('/portal/links')).links || []); setErr(''); }
catch(e){ setErr(e.message || 'Could not load client feeds.'); setLinks([]); }
};
useEffect(()=>{ load(); }, []);
const add = async () => {
if (!draft.accountId || !draft.hookUrl.trim() || !draft.secret.trim()){
setErr('Pick an ad account and paste both the feed URL and the key from the portal.'); return;
}
setBusy('new');
try { await RelayAPI.post('/portal/links', draft); setAdding(false);
setDraft({ accountId:'', label:'', hookUrl:'', secret:'', windowDays:28, dimMode:'platform' }); setErr(''); await load(); }
catch(e){ setErr(e.message || 'Could not create the feed.'); }
finally { setBusy(''); }
};
const act = async (id, fn, msg) => {
setBusy(id);
try { await fn(); setErr(''); await load(); }
catch(e){ setErr(e.message || msg); }
finally { setBusy(''); }
};
const pushNow = id => act(id, async ()=>{
const r = await RelayAPI.post(`/portal/links/${id}/push`);
if (r.result && !r.result.ok) setErr(r.result.error || 'The push was rejected.');
}, 'Could not push.');
const accName = id => (accounts.find(a=>a.id===id)||{}).name || id;
return (
Client feeds
Send an ad account's daily campaign numbers straight into that client's own dashboard.
Connect the feed in the client portal first — it gives you a URL and a key to paste here.
setDraft(d=>({...d, secret:e.target.value}))} placeholder="paste the key" className={inputCls}/>
setDraft(d=>({...d, windowDays:+e.target.value||28}))} className={inputCls}/>
Each run re-sends the last {draft.windowDays} days so late-attributed conversions
correct themselves on the client's dashboard instead of stacking up. Campaign totals
only — deliberately, since sending several levels at once would multiply every figure.
{draft.dimMode==='account'
? ' Choose “Ad account” when several accounts feed the same client workspace — it is what keeps each one a separate series instead of merging them all into one platform.'
: ' Choose “Ad account” instead if several accounts feed the same client workspace, or they will merge into a single platform series.'}
{!readOnly && <>
pushNow(l.id)}>{busy===l.id?'…':'Push now'}act(l.id, ()=>RelayAPI.put(`/portal/links/${l.id}`, { status:l.status==='active'?'paused':'active' }), 'Could not update.')}>
{l.status==='active'?'Pause':'Resume'}
{
/* Stops sending only. Whatever the client already received is theirs,
and clearing it is a decision taken on the portal side. */
if (!confirm('Stop sending this feed?\n\nData already delivered stays on the client dashboard — remove it from the portal if you need it gone.')) return;
act(l.id, ()=>RelayAPI.del(`/portal/links/${l.id}`), 'Could not remove.');
}}>Remove
>}